Improvements to train travel mode based on recent field tests - #1084
Merged
Merged
Conversation
"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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.