Skip to content

Improvements to train travel mode based on recent field tests - #1084

Merged
davecraig merged 13 commits into
Scottish-Tech-Army:mainfrom
davecraig:main
Sep 15, 2026
Merged

davecraig merged 13 commits into
Scottish-Tech-Army:mainfrom
davecraig:main

Conversation

@davecraig

Copy link
Copy Markdown
Contributor

No description provided.

davecraig and others added 13 commits September 15, 2026 13:57
"On {line} and close to {settlement}" was announced again every time the
settlement changed. At line speed that is very nearly every location update, so
an 80km Glasgow-Edinburgh replay said it 15 times on the stretch after Croy and
17 times on the stretch after Linlithgow, each one differing only in the name of
whatever village the train happened to be passing.

This is the noise roadDedup already stops on a road, where a numbered road
collapses its entire dedup key to its ref so that neither a change of street name
nor a new settlement alongside re-announces it. Neither train branch of
travellingReverseGeocodeName had the equivalent: the "since station" form kept
the settlement in its key, and the plain form passed no dedupText at all, so its
key was the whole spoken string.

Both now key on the line, plus - for the "since station" form - the station the
distance is measured from. Reaching the next station is what genuinely moves the
journey on and is worth hearing about; passing a village is not. The spoken text
is untouched, so the callout still names the settlement, it just does so once per
stretch of line instead of once per village.

On the TransferAtQueenStreet replay that takes the journey from 311 callouts to
274, and the "close to" family from 80 to 43. What remains is roughly one per
stretch between stations, which is the 60s callout-history expiry rather than
anything to do with settlements.

The four GPX recordings added here are real Scotrail journeys - two Glasgow to
Edinburgh runs in each direction, a transfer at Queen Street, and a run out to
Milngavie - which is where the repeats were noticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
testMovingGrid re-confected names for every way in the grid each time the grid
moved. On the 80km TransferAtQueenStreet replay that is 180,949 confections
across 54 grids, and 176s of the test's 190s - the grid loads, both map matchers
and the callouts themselves add up to the other 14s between them.

None of that work changed the result. Way.getName() confects lazily as each way
is actually named (see WayGenerator), so doing it eagerly up front produces
byte-identical callout output; confirmed by removing the sweep altogether and
diffing the replay against the full-sweep run.

Keeping it for the first grid preserves what the sweep was there for, which is
to run confection over a real grid's worth of live data rather than only the
synthetic ways in WayNamingTest. The test now takes 16s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
8697ca0bc dropped the settlement from this dedup key but kept the line in it. The
line doesn't belong there either: the rail matcher flickers onto depot sidings
and adjacent lines mid-journey - FromWaverley alone goes through "On train",
"On Bathgate Depot", "On Bathgate Foundry" and "On East Coast (Northern) Line"
between two stations - and under a key holding the line, every one of those
flickers is a fresh announcement of a journey that hasn't changed.

What's left is the last station called at, which is the one thing on a train
that genuinely marks progress, and the only one of the four candidates that is
stable between stations: the distance climbs on every call, the settlement
changes almost as often at line speed, and the line name is whatever the matcher
last locked onto.

TransferAtQueenStreet goes from 274 callouts to 272, and its "close to" family
from 43 to 41.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
Nothing consumes this yet. The consumers - RailMatchArbiter's hold, AutoCallout's
sticky windows, and UserGeometry.inMotion() - follow separately, so that each can
be judged against the replays on its own.

Soundscape decides every motion state from one instantaneous GPS speed
comparison, and speed cannot answer the question that actually matters. Measured
over four recorded journeys (TransferAtQueenStreet, ToWaverley, FromWaverley,
ToMilngavie), across 60s windows of fixes accurate to 25m or better:

                 60s median speed        60s net displacement
  standing       1.33 m/s (p90 1.44)     p50 12.4m, p90 19.4m
  walking        1.39 m/s (p90 1.49)     p10 66.4m, p50 76.4m

The best threshold that exists on speed still misreads 22% of standing windows
and 33% of walking ones, and inMotion()'s speed > 0.2 is true for every standing
window measured. Displacement has no measured error either way at 20m. It also
holds up where speed doesn't: one 341s spell on the Milngavie recording reported
a speed of ~0 throughout while the train covered two kilometres.

So the window decides, with hysteresis at 30m and an excursion limit at the same
figure to stop forty metres to a departure board and forty back reading as
standing still. Labelling the recordings by displacement alone, no genuinely
stationary window strays further than 23.6m from its anchor, so that limit never
fires on real stillness.

Getting out of a stationary spell is a different question, and Android answers it
much faster than displacement can: of the fixes recorded while genuinely standing
still, 2% carried a course it rated accurate to better than 45 degrees, against
80% of those recorded while walking. Two such courses in the last six fixes ends
the spell - measured to have no false escapes at all, while catching 81% of
walking within six seconds rather than the twenty-odd it takes to walk thirty
metres. It only ever ends a spell, never starts one, so a wrong answer there can
only restore the behaviour we already had.

That course has to be the GPS course and nothing else. Someone standing still
holding the phone up to read the screen produces a perfectly steady compass
heading while going nowhere, so the caller is required to derive the flag from
location.bearing and its own accuracy, and the KDoc says so.

GpxRecorder has always written <bearingAccuracyDegrees>, but GpxTrackPoint had no
field for it and GpxParser never read it, so every replay threw away the one
signal the fast escape runs on. Carried through now, and left absent rather than
defaulted where a recording has none: a bearing with no accuracy beside it says
which way somebody was pointed, not whether they were moving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
…plays

Plumbing only - nothing reads stationary or stationaryMillis yet, and the
TransferAtQueenStreet replay produces the same 272 callouts it did before, which
is the point of doing this as its own commit.

StationaryDetector is driven from GeoEngine's location loop ahead of both map
matchers, because RailMatchArbiter will want the verdict next. It is fed the
unfiltered flow: that's the stream the matchers already use, and the one the
detector's thresholds were measured on - the Kalman-filtered position has had
exactly the jitter it measures smoothed out of it. MvtTileTest.testMovingGrid
mirrors that ordering by hand, which meant hoisting its timestamp block above the
matchers; both sites now carry a comment naming the other, because if the
ordering drifts the replay stops reflecting the app silently.

The course flag is built from location.bearing and its own accuracy, and this is
deliberately stricter than the travelHeading gate a few lines above, which falls
back to trusting an ungated bearing. An ungated bearing is fine for steering
audio and useless here: it says which way somebody was pointed, not whether they
were moving.

The replays also gained two markers, written when the state changes rather than
per fix: "Stationary: true/false" and "Train: on <line>/off". Neither is a
callout, and neither is visible in callout text, which is why the problem this
series is about went unnoticed for so long. On TransferAtQueenStreet - three rail
legs - the existing behaviour turns out to produce 77 Train on/off pairs, and the
median gap between an "off" and the next "on" is two lines. The ride is ending and
restarting constantly, mostly at station stops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
Three things were ending rides that hadn't ended, all of them at stations.

The one this started from: railStillExplainsThePosition reopens the road-versus-
rail comparison wherever the train has slowed near a stop, and platform GPS in a
cutting or under a canopy is routinely further off the line than
onTheLineDistanceMetres, with the station approach road nearer still. Being
stopped near a stop is exactly what a dwell looks like and exactly what alighting
looks like; speed cannot tell them apart. Displacement can, so a passenger who
has not gone anywhere now holds the ride, with no time limit - what ends it is
them walking away, which is self-correcting, and any limit short enough to be
useful would be shorter than the 340s dwell in the recordings.

That alone wasn't enough, because releasing took five ticks and deciding somebody
is stationary takes a window: the ride was over twenty-five seconds before
anything could say it shouldn't be. So the detector's window came down from 60s
to 30s - measured at every length from 15s up, 25s is the shortest that still
separates standing from walking with no error either way, and 30s is the shortest
with a margin worth having (21.8m against 34.6m) - and the arbiter now waits
releaseTicksAtAStop where a ride could actually be ending, which is the one place
the question takes time to answer.

The out-and-back test went with it. Over a minute it earns its place; over thirty
seconds walking pace only reaches ~21m out and back, while genuinely stationary
windows stray up to 33m, so any limit tight enough to catch the walk rejects real
stillness first.

The third was the biggest and had nothing to do with stations as such.
MapMatchFilter deliberately holds its confidence for a few ticks after matchedWay
goes null, because a real journey loses every candidate at points and junctions
and picks one straight back up - its own comment says so. The arbiter took
confidence-without-a-way as the end of the ride and threw that grace away before
it could be used: 24 of the 31 remaining ride endings on TransferAtQueenStreet,
most lasting a tick or two before the line came back. It now holds the line it was
on, the same way the dropout path alongside it already did.

TransferAtQueenStreet goes from 77 ride endings to 2 - one per leg, at Glasgow
Queen Street and Edinburgh Waverley, which is where the passenger actually got
off. Across the fixtures every rail journey now reports one or two rides and
every non-rail one reports none, and the callout counts move only on rail files:
FromWaverley -23, GiffnockToCentral -9, ToTown -7, train-1 -7, ToWaverley -4,
train-2 -4. The walking and driving fixtures are unchanged to the callout -
tesco, which is a slow walk with stops, finds 32 stationary spells and says
exactly what it said before.

The acquire path gained a speed gate. Inside a station there is often no
confident road match, so railBeatsRoad returns true on its "nothing to compare
with" branch and ten ticks on a platform could earn a lock. That was harmless
only because probablyOnTrain() tested inVehicle() from outside, which the next
commit changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
AutoCallout keeps two sixty-second windows after the user was last seen in a
vehicle or on a train, so that a bus at a red light or a train at a signal
doesn't immediately expose pedestrian-style intersection and POI callouts and
then take them away again a moment later. The windows were being measured in
wall-clock time, and station dwells are longer than they are: across four
recorded journeys the dwells run from 17 to 340 seconds. So a passenger sitting
on a stopped train would have the window run out underneath them and start being
told about the intersections around the station.

The mechanism for this already existed. discountUnobservedTime pushes both
timestamps forward by the time the geoengine spent blind, on the grounds that no
time should count against a window measuring how long ago something happened if
nothing could have happened in it. Standing still is the same argument with
better evidence, so it is now discounted the same way and the function is
discountUninformativeTime.

This freezes rather than extends: the windows still expire the same interval
after the last moving moment, it just takes moving to get there.

Deliberately uncapped, unlike the blind-time discount. That cap exists because a
phone reporting junk indoors all afternoon is not evidence of anything, whereas
standing still is positive evidence - and any cap short enough to be useful would
be shorter than the 340s dwell this is meant to survive. The two cannot
double-count: StationaryDetector isn't updated at all while fixes are being
rejected, so stationaryMillis can't grow during the spells unobservedMillis
counts.

The trainStickyWindowMs comment claimed dwells were "~12-19 seconds", which is
what made sixty seconds look generous. It isn't, and that figure was half the
reason this went unnoticed.

Every rail fixture drops callouts - ToWaverley -31, FromWaverley -28,
TransferAtQueenStreet -26, GiffnockToCentral -9 - and so does the bus one,
BusTripToMilngavie -27, which is the same fix working at bus stops. tesco loses
two, both while genuinely stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
The two consumers that read the user's motion state directly.

inMotion() was speed > 0.2, which is true of somebody standing perfectly still:
measured across four recorded journeys, a stationary user's median GPS speed is
1.33 m/s against 1.39 m/s walking, and that test was true for every stationary
window in the recordings. That is how standing on the concourse at Glasgow Queen
Street came to be read as walking about. The windowed judgement gets the veto and
the speed check stays as the fast path for whatever the window hasn't decided.

The visible effect is that a jittering GPS course stops steering the field of
view while the user is stood still, which is what the Queen Street report was
about. It is a real trade: on tesco, a shopping trip with a lot of standing
about, four callouts go and one arrives. Two of the four were junk ("Ahead
Service", "OSM Feature crossing null"); the other two are a path description and
an intersection, and a different intersection takes their place, so the count of
intersection callouts is 33 either way. Worth having, but it is a change to what
a pedestrian hears and not only to what a passenger does.

probablyOnTrain() required inVehicle(), so it was false for the whole of every
dwell - up to 340 seconds in the recordings. The ride the arbiter was still
holding went unused for the entire stop, and the callouts that depend on knowing
the user is on a train switched off at exactly the moment a passenger most wants
them. Both halves are kept rather than trusting the lock alone, because the lock
can outlive the ride by design: a platform stays within onTheLineDistanceMetres
of the line and a concourse often has no confident road match, so the lock alone
would announce a line to somebody walking out of the station. Somebody neither at
vehicle speed nor standing where the train stopped is walking.

This is what the acquire speed gate in 1826d7bc8 was for: with a stationary user
now able to count as being on a train, a lock earned while standing on a platform
would have announced one.

Across the fixtures, ten rail journeys lose 117 callouts between them and six
non-rail ones lose 39, of which 32 are BusTripToMilngavie - the same fix working
at bus stops rather than station stops. 1104 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
Pulling out of Falkirk High, the passenger was told about the platform shelters
and the path and steps off the end of the platform - pedestrian callouts, to
somebody sitting on a train.

The ride itself was never in doubt: RailMatchArbiter holds the lock right through
the stop, and the replay markers show one unbroken ride from Glasgow Queen Street
to Edinburgh Waverley. Nothing used that. The sticky vehicle and train windows
were refreshed only while inVehicle() - a bare speed test - so the refresh
stopped the moment the train dropped below vehicle speed on its approach. The
braking, and the first thirty seconds of the dwell before the stationary window
could fill, then ate the sixty-second window before the standing-still freeze
added in 9e1fd5dca could start. By the time the train pulled away the window had
run out, and pulling away is itself the gap where a train is neither travelling
fast enough to count nor standing still any more.

So the windows are now fed from the arbiter's lock. Deliberately the raw lock
rather than probablyOnTrain(), which is false during exactly that pull-away. A
window is a decaying timer, so refreshing it from a lock that can outlive a ride
is safe: when the arbiter does let go, the window runs down from there as usual.

Across every fixture there are now no pedestrian-style callouts at all while the
arbiter holds a ride - TransferAtQueenStreet had 33 and has 17, all of them while
genuinely off the train. Rail journeys lose a lot of what was left:
TransferAtQueenStreet -63 against where this series started, ToWaverley -50,
FromWaverley -36, BackFromTown -28, train-2 -22, CentralToBuchananStreet -21,
ToTown -20. Most of it is platform furniture and repetition - eleven "Glasgow
Queen Street Entrance" and six "At Glasgow Queen Street" in ToWaverley alone.

One difference worth recording because it looks like a loss and isn't. ToWaverley
used to announce "Entering Finnieston Tunnel" and "Entering Charing Cross Tunnel"
three lines apart, and now announces the first only. They are consecutive bores,
and tunnelForgetDistanceMetres exists to collapse exactly that - its comment says
announcing two tunnels closer than 200m apart once is the better reading of them
anyway. TransferAtQueenStreet already collapsed the same pair before any of this;
ToWaverley only escaped it because the ride used to drop out between the two.

1104 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
StreetDescription's travel mode keeps confected path names out of a junction
description, on the grounds that somebody travelling wants the cross street and
not "between Path that joins Fifth Avenue and Great Western Road and Service that
joins Lennox Park and Crossveggate". That applies at least as much to a passenger
on a train as to a driver - arguably more, since a passenger can't act on a path
at all.

It was selected by inVehicle(), which is a bare speed check, so it was false
through every station dwell and every slow stretch of line and the description
fell back to the walking one. Asked of the arbiter's lock instead, which is what
actually knows there is a ride in progress - and not probablyOnTrain(), which is
itself false while a train pulls away from a platform, moving too slowly to count
as a vehicle and no longer standing still either.

Found by auditing the rest of the inVehicle() call sites for the same assumption
this series has been unpicking. Most are fine: the vehicle landmark, transit
stop and crossing callouts all test for a train explicitly, and the pedestrian
intersection and POI callouts are suppressed through a dwell by recentlyInVehicle,
which 297d0226f now feeds from the ride rather than the speedometer.

Two known sites are deliberately left alone for now. buildCalloutForRoadSense
still requires inVehicle(), so a passenger is never told which station they are
sitting at - the useful change, but a behaviour addition whose dedup path needs
checking against a 340s stop. And getCompassLabelFacingDirection is still given
inVehicle(), so "My Location" says the walking form while a train pulls away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
Leaving Glasgow Queen Street after changing trains, the whole Edinburgh leg
reported its progress since Partick - three stops back, on a different line, on a
train the passenger had got off several minutes earlier.

LastStationTracker had no way to know any of that had happened. It is only
written where travel-mode reverse geocoding finds a stop on the line being
ridden, and the first leg arrives at Queen Street Low Level underground, where
the fixes stop altogether: the ride ends in the tunnel and nothing ever sees an
arrival at Queen Street to record in Partick's place. So the stale station sat
there until the next one happened to be passed, which on the Edinburgh leg was
Bishopbriggs, four callouts and several kilometres later.

A distance since a station is a statement about progress along one ride, so the
tracker is cleared when there is no longer a ride to measure. Tested against
recentlyOnTrain rather than the arbiter's lock alone, so a line picked up again
after a tunnel keeps the station it was measuring from instead of losing it for
the next stretch - a change of trains takes far longer than that window.

The leg out of Queen Street now says "On Edinburgh and Glasgow Main Line and
close to Sighthill" with no distance at all until it calls at Bishopbriggs, and
measures from there. Checked across the rail fixtures, every since-station now
follows the line in order: BackFromTown runs Partick, Hyndland, Anniesland,
Hillfoot; ToMilngavie runs the full Bathgate-to-Milngavie sequence; and
TransferAtQueenStreet's two legs are Hyndland, Partick and then Bishopbriggs,
Lenzie, Croy, Falkirk High, Polmont, Linlithgow, Edinburgh Park, Haymarket.

ToWaverley drops one callout. Nothing else moves. 1104 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
Riding over the A81 in Milngavie, the callout was "Passing over Glasgow Road" -
the name on the street sign, but not the one a passenger works out where they are
from. Travel-mode reverse geocoding has combined the two for a while, because a
route number is how a road is signposted and talked about and is the part that
stays put while the street name changes along it. A road being crossed deserves
the same treatment as the road being travelled along.

The combining moves out of travellingReverseGeocodeName into roadNameWithRef and
is used by both. Only the train case is affected despite announceableCrossing
being shared with the road and walking crossings: attachRailwayCrossings records
ROAD_CROSSING on the railway and its mirror RAILWAY_CROSSING on the road, so a
road Way never carries one.

Doing this turned up a bug in the original. Way.getName falls back to the ref
only for a road with no name of its own, and then confectNamesForRoad may build a
description around it - "A779 that joins Tailend Moss and Old Deans Road". Pairing
that with the ref gives "A91 (A91 that joins Mathieson Gardens and Middleflat)",
which is what the StAndrews replay was already saying. So the ref is paired with
the name the road actually carries in OSM, and a road with a ref and no name is
just its number: that fixture now says "Traveling east along A91 away from Bow of
Fife", and the crossings say "Passing under A779" rather than reciting what it
joins.

Across the fixtures: "Passing over Glasgow Road" is now "Passing over A81 (Glasgow
Road)", and likewise A82 (Great Western Road), A809 (Drymen Road), B808 (Beith
Street), B8049 (Roman Road) and the rest. No file's callout count moves - this is
naming only. 1104 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ
The lock screen controls had no authoritative state behind them. The
now playing entry carried only a title, so playbackState stayed unknown
and the UI flipped its own Play button to Pause when tapped, and the
entry was published from configureAudioSession alone — which
ensureEngineStarted only reaches when the engine is not already
running. Anything that took the lock screen from us (Siri, a call,
another app) kept it until the next engine start happened to republish,
leaving the skip arrows greyed out.

publishNowPlaying is now the one place that touches
MPNowPlayingInfoCenter, and it pins playbackState to playing — the
iOS counterpart of SoundscapeDummyMediaPlayer.getState() returning a
fixed playWhenReady=true on Android. It runs after every remote
command, after an interruption ends, after a media services reset, and
when mixWithOthers changes before the engine has started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7YyswsqGBpg3mYrEyiTV
@davecraig
davecraig merged commit eb12fd7 into Scottish-Tech-Army:main Sep 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant