From e079bf4e542e4a68ba83da1fcb8a38c50dda9258 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:56:37 +0000 Subject: [PATCH] Fix snake, manual U-turn and line-entry pass tests Three places that decide "is this pass still to do" disagreed after the turn line (#214) and the painted-map rules (#201, #232, #234): - Snake order and its worked test read the turn line, so passes lying only in the headland band (turn line .. headland line) were in the order and, with sections off in the headland, never painted. They stayed "to do" and hid real passes: "End of field reached" with crop left. With the headland on and sections off in it, the order and the worked test now use the crop (WorkHeadlandLine). - A manual U-turn with "Ignore worked tracks" still used the old Track.WorkedPaths mark through ComputeNextTrack and GetNextUnworkedPathSkip. It now uses the same painted-map test as the auto turn. - Joining the nearest open pass when AutoSteer is engaged (#226) only checked the whole pass. It now also skips a pass whose piece on this side of a bay is done (IsPassToJoin). Tests: band pass hidden / painted, only band left = field complete, manual U-turn over map-worked passes, bay piece done on line entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKEBDE58NYzgHvbtTAtxnz --- CLAUDE.md | 4 +- README.md | 2 +- .../Pipeline/GpsPipelineService.cs | 39 +++++++-- .../YouTurn/YouTurnPathingService.cs | 5 +- .../YouTurn/YouTurnStateMachine.cs | 68 +++++++++++++--- .../Pipeline/NearestOpenPassEntryTests.cs | 31 +++++++ .../YouTurn/SnakeOpenEndTests.cs | 81 +++++++++++++++++++ .../YouTurn/SnakeOpenPassTests.cs | 4 +- 8 files changed, 209 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0cdfc833..c8a93849 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -593,11 +593,11 @@ turns from the on-screen buttons) with paths built by `YouTurnCreationService`: - The turn is built from the headland crossing **ahead** of the tractor (`FindTrackHeadlandIntersectionAhead`; one behind only past the last crossing). When no real turn fits, the simple fallback (a semicircle) is seated back along the pass until it keeps `UTurnDistanceFromBoundary` from the fence: to the headland crossing and up to 30 m into the field past it, starting ahead of the tractor (`SimpleFallback`, fork only: renatorgr/AgOpenWeb#130, #135). Only on a straight pass: the fallback runs straight from the tractor, so on a curve it is seated nowhere (as official) and built again as the tractor comes closer; seated to fit 190 m away on a boundary curve, it was never joined and the missed turn was made "from here". It is then used only if every point of it stays in the field, outside obstacles a turn may not drive through (`LeavesField`, #698); otherwise no turn, the creation-failed sound and "No U-turn fits inside the boundary at this end - take over manually". A hard boundary refuses a turn that would start under or behind the tractor. - A turn tried again with the turn lines moved in (the implement over a hard fence, a landing on a bend, an obstacle in its legs: `CreateTurnPath`'s setback loop, which moves the whole fence in) is seated back from the end of the pass the first try found: walking back from that end, at the moved line's crossing nearest to it (`YouTurnCreationInput.TurnLineEndIndex`, `TurnLineEndOnRetry`; not after a gap crossed). Moved in, the fence's turn line also crossed the pass where the fence bulges in beside it, and the first crossing ahead was taken: on Tapada da Casa an 8 m front boom near the north fence moved the line in 5 m and the turn was made 260 m early, beside the east bulge, landing on the next pass inside its crop and leaving a strip of it unsprayed. Test: `TurnSetbackStaysAtEndTests`. Official since #828; not after a gap crossed is fork only. - The next pass must have room at this end: at least 20 m of it inside the cultivated area back from the end ahead (`IsPathLandable` / `LandingLength`), not just any piece of it inside the field; otherwise "End of field reached - no room on the next pass at this end" (#712). In this fork, when no pass has room but the current pass goes on inside the field past the headland ahead (a corner the headland clips off it, a gap), there is no turn and AutoSteer drives on across it: "No room to turn at this headland - driving on across it" (`GapAhead` / `DrivesOnAcrossGap`, renatorgr/AgOpenWeb#129; upstream left it out until after the v1.0 freeze). The pass must run on at least 10 m inside past the gap (`MinRunAfterGap`): a sliver or spike of the headland line is not more field. -- Skip worked passes (the snake order): when the order's next pass has no room at this end, or its turn does not build, the turn goes onto the nearest pass still to do that has room, first on the side the order is going, then on the other side (one turn built per side; a pass at least the skip away before a nearer one; none further than twice the skip's step, else "End of field reached" and the operator decides), never against the side the operator picked with the U-turn arrow (`TurnOntoNearestOpenPass`; AgOpenGPS's `GetNextNotWorkedTrack` only steps outward on the turn's side past worked tracks, so the other side, passes nearer than the skip and the room and build checks are ours); the order is planned again from where it lands. The same nearest pass is tried first when the order's next pass still to do is further than twice the skip's step (`MaxFallbackStep`; the passes in between worked before), from any pass nearer than the order's, either side: the turn went from 17R to 7R along the headland, 80 m, with 16R and 14R open beside the tractor (a tester's field, simulator), and on 21R, with the order's 8R 13 passes over, 15R six over was never looked at and the turn onto 8R did not build, so there was no turn; with none of them taking a turn, the order's pass is turned onto as before. Fork only. With the operator's headland on and the sections off in it (`Tool.IsHeadlandSectionControl`), a pass the snake turns onto (the order's or a nearest one) must also have that room in the crop, inside the headland line (`SnakeLandable`): the turn line is the fence moved in by the U-turn distance, so a pass lying in the headland band had room there and nothing to work, and unpainted it was still to do (a tester's field, simulator: 13L, 12 m of crop, and 15L, none, in a corner of the headland, before 12L). Fork only. An end where no turn was made is remembered (`_failedEnd`) and not tried again on every cycle until the pass, the direction of travel, the nudge, the order, the field or headland, the skip, the U-turn arrow or a turn setting changes. The order is also planned again when the headland line or the outer boundary changes (`SnakeFieldStamp`), and forgotten with what was found about each pass when the line moves (`ForgetPassStatus`: Reference Line Nudge, an end trimmed; the moved line's pass N is another strip, and the old answers turned the snake past clean passes on Tapada da Casa). In the headland the snake looks for its next turn only heading out of the field (no cultivated ground ahead on the pass): a turn that ends in the headland leaves the tractor facing the field on its new pass, which is not the end of that pass. Fork only: a pass worked on the whole (half covered, `IsPassWorked`) is still to do at the end ahead when less than half of its 20 m inside the cultivated area next to that end is covered (`YouTurnPathingService.IsPassOpenAtThisEnd`, asked in `WorkedPasses` when a turn is planned, once per end): on a tester's field a pass 71 % covered, with its last 100 m at that end left, was turned past. Such a pass is turned onto only when the turn built gets to what is left: at least 5 m of unworked crop along the turn's last 20 m and 20 m on past it (`YouTurnPathingService.TurnReachesUnworkedGround`); otherwise the turn is dropped and the pass counts worked at this end (`TurnMissesOpenEnd`). A turn lands abreast of where it leaves the tractor's pass, and the next pass's end can lie beyond that (a slanting headland, a turn seated back from a fence or a tree): on a tester's field the turn from 1L onto 2R, seated back 12.5 m, drove the whole of 2R a second time and its end stayed as it was. Fork only: with the job's map, worked means the map (half covered), never `Track.WorkedPaths`, which a section on anywhere along the pass sets (the mark is used only with no map); and a pass still to do on the whole is worked at the end ahead when the piece a turn there lands on (from that end back until the pass leaves the cultivated area) is at least half covered (`IsPieceDoneAtThisEnd`): a bay cuts the pass and its short piece is done. On Baetas (official #815, the job harness) the short pieces below a bay marked their passes worked and the main pieces were never turned onto, "End of field reached" with 53 % undone; a pass with no bay is one piece, so one done only at this end is still turned onto here. +- Skip worked passes (the snake order): when the order's next pass has no room at this end, or its turn does not build, the turn goes onto the nearest pass still to do that has room, first on the side the order is going, then on the other side (one turn built per side; a pass at least the skip away before a nearer one; none further than twice the skip's step, else "End of field reached" and the operator decides), never against the side the operator picked with the U-turn arrow (`TurnOntoNearestOpenPass`; AgOpenGPS's `GetNextNotWorkedTrack` only steps outward on the turn's side past worked tracks, so the other side, passes nearer than the skip and the room and build checks are ours); the order is planned again from where it lands. The same nearest pass is tried first when the order's next pass still to do is further than twice the skip's step (`MaxFallbackStep`; the passes in between worked before), from any pass nearer than the order's, either side: the turn went from 17R to 7R along the headland, 80 m, with 16R and 14R open beside the tractor (a tester's field, simulator), and on 21R, with the order's 8R 13 passes over, 15R six over was never looked at and the turn onto 8R did not build, so there was no turn; with none of them taking a turn, the order's pass is turned onto as before. Fork only. With the operator's headland on and the sections off in it (`Tool.IsHeadlandSectionControl`), a pass the snake turns onto (the order's or a nearest one) must also have that room in the crop, inside the headland line (`SnakeLandable`): the turn line is the fence moved in by the U-turn distance, so a pass lying in the headland band had room there and nothing to work, and unpainted it was still to do (a tester's field, simulator: 13L, 12 m of crop, and 15L, none, in a corner of the headland, before 12L). The order and its worked-pass test are then made for the crop too (`SnakeArea`): a pass lying only in the band is not in the order, since unpainted until the headland is worked it stayed "to do" for ever, and next in the order it hid the passes still to do beyond it ("End of field reached" with a pass 13 over left). Fork only. An end where no turn was made is remembered (`_failedEnd`) and not tried again on every cycle until the pass, the direction of travel, the nudge, the order, the field or headland, the skip, the U-turn arrow or a turn setting changes. The order is also planned again when the headland line or the outer boundary changes (`SnakeFieldStamp`), and forgotten with what was found about each pass when the line moves (`ForgetPassStatus`: Reference Line Nudge, an end trimmed; the moved line's pass N is another strip, and the old answers turned the snake past clean passes on Tapada da Casa). In the headland the snake looks for its next turn only heading out of the field (no cultivated ground ahead on the pass): a turn that ends in the headland leaves the tractor facing the field on its new pass, which is not the end of that pass. Fork only: a pass worked on the whole (half covered, `IsPassWorked`) is still to do at the end ahead when less than half of its 20 m inside the cultivated area next to that end is covered (`YouTurnPathingService.IsPassOpenAtThisEnd`, asked in `WorkedPasses` when a turn is planned, once per end): on a tester's field a pass 71 % covered, with its last 100 m at that end left, was turned past. Such a pass is turned onto only when the turn built gets to what is left: at least 5 m of unworked crop along the turn's last 20 m and 20 m on past it (`YouTurnPathingService.TurnReachesUnworkedGround`); otherwise the turn is dropped and the pass counts worked at this end (`TurnMissesOpenEnd`). A turn lands abreast of where it leaves the tractor's pass, and the next pass's end can lie beyond that (a slanting headland, a turn seated back from a fence or a tree): on a tester's field the turn from 1L onto 2R, seated back 12.5 m, drove the whole of 2R a second time and its end stayed as it was. Fork only: with the job's map, worked means the map (half covered), never `Track.WorkedPaths`, which a section on anywhere along the pass sets (the mark is used only with no map); and a pass still to do on the whole is worked at the end ahead when the piece a turn there lands on (from that end back until the pass leaves the cultivated area) is at least half covered (`IsPieceDoneAtThisEnd`): a bay cuts the pass and its short piece is done. On Baetas (official #815, the job harness) the short pieces below a bay marked their passes worked and the main pieces were never turned onto, "End of field reached" with 53 % undone; a pass with no bay is one piece, so one done only at this end is still turned onto here. A manual U-turn with *Ignore worked tracks* steps over the passes the same map test calls worked (`WorkedPasses` into `ComputeNextTrack`), not the mark: it stepped over a pass with only a short piece done. Fork only. - The end of a pass at a hard outer boundary with no turn planned or driven there ("End of field reached", no turn that fits, U-turns off): as the pivot gets 1 m into the headland from the cultivated area (`HeadlandEntryCheckAfter`), on the line and heading out (the pass leaves the outer boundary within 300 m with no 10 m of field on it first, `YouTurnPathingService.PassLeavesFieldAhead`), AutoSteer goes off with the alarm, "AutoSteer off: end of the pass and no U-turn here. Turn by hand." (`YouTurnStateMachine.EndOfPassWithoutTurn`, step 5b'''' in `GpsPipelineService`; once per entry; fork only). Left on, AutoSteer steered on along the line through the fence (as AgOpenGPS, which only sends geoStop): on Tapada da Casa the simulator drove on 2.8 km. Heading into the field, a headland strip with field past it, or a soft boundary do not count. This stop, the line facing out of the field below and a pass still open at this end measure from the headland line, where the crop ends, not from the turn line the U-turn uses (`TickContext.PassEndLine`, the line the pipeline arms them on: from the turn line a headland more than 10 m wider than the U-turn distance never stopped AutoSteer). On a pass bent round Go around obstacles, "on the line" and the pass walked ahead are the bend's (`GoAroundPasses.Current`): a bend leaves the crop up to 45° off the pass, and measured against the pass the stop took the tractor for one turning by hand (Tapada da Casa, simulator: 3R bent round two trees in the headland left the crop 36° off, and AutoSteer steered on along the bend for 40 m until the fence guard stopped it with the 8 m boom over the fence). - A turn planned for the end the tractor was heading to is dropped when the tractor turns round on the pass (by hand, or the simulator's turn-around), and the turn at the end ahead is planned (TURNED ROUND in `YouTurnStateMachine.Tick`, `_plannedSameWay`; AgOpenGPS drops a created turn once the pivot leaves the turn area without it, `ResetCreatedYouTurn`). Kept, the headland stop and the fence guard below took it for a turn planned at the other end and left AutoSteer on (Tapada da Casa, simulator). Fork only. - Only a turn keeps the implement off a hard outer boundary. With none planned or driven, AutoSteer on, going forward, the tractor inside the fence, an end of the implement (half `ActualToolWidth` plus the offset either side of the tool) heading out across the fence, more than 0.5 m out and further than now within 2 s of travel, takes AutoSteer off with the alarm (`HardFenceGuard`, step 5b'''' in `GpsPipelineService`; fork only). AutoSteer joining a line from a corner with the line moved five passes swung an 8 m front boom 0.9 m over the fence. The 2 s are along the curve steered, not straight ahead: the implement's ends turn with the tractor about its turning centre, from the last cycle's steer angle (the one the line asked for when AutoSteer has just gone on; a cycle with no line steered before it waits for the next). Straight ahead leaves a bend: on a boundary curve's first lap (the boom's end on the fence) it took AutoSteer off at a bend with the tractor on the line (`HardFenceGuardPipelineTests`). Joining a line sideways beside the fence is an S that keeps the implement inside (*Stepping onto a line beside a hard fence*). Driving along the fence a little over it, back in, or below walking pace (`MinLookAhead`) does not count. -- Joining a new line in the headland (U-turn settings, `GuidanceConfig.LineEntryMode`: Nearest point, the default and AgOpenGPS's; Ask; Start of the pass; `uturn.entryMode`, `uturn.entryStraight`, saved with the tool profile; fork only): when AutoSteer is engaged, the line moved a pass or another track chosen, with the tractor in the headland (inside the field, outside the headland line), going forward, on an open track, `GpsPipelineService.UpdateLineEntry` asks `YouTurnCreationService.PlanLineEntry` for the way to the start of the pass: where the pass, driven either way, enters the cultivated area nearest the tractor (not behind it, no more than 100° from where it faces, 150 m at most; with none there, the tractor faces out of the field and the way turns it round, if it fits), reached by the shortest Dubins path at the U-turn radius from 3 m ahead (`DubinsPathService.GeneratePaths`) that keeps the pivot in the headland until it is on the pass `LineEntryStraight` (plus a front implement's reach) before the headland line, inside the field, the implement off a hard fence (`ManualTurnClearsHardBoundary`) and clear of turn obstacles (`ClearsTurnObstacles`). Start of the pass drives it like a turn (`FollowLineEntry`, the auto U-turn waits; it ends on the pass a little inside the crop, where the line takes over); Ask offers it (`GpsCycleResult.LineEntryOfferPass`, the scene's `entryOffer` and dashed `entryPath`, the approach sound, `youturn.entryTake` / `youturn.entryKeep`, 30 s) while the nearest point is steered, and plans it again from where the tractor is when taken. No way through says so once (`LineEntryNotice`, a toast) and the nearest point is used. A way or an offer is dropped by AutoSteer off, a pass, nudge or track change, a manual U-turn or reverse. In any mode, AutoSteer joining a line in the headland facing out of the field (the line, driven the way the tractor faces, reaches a hard outer boundary before any crop: `YouTurnStateMachine.LineAheadLeavesField`) with no way driven or offered goes off at once with the alarm, "AutoSteer off: the line ahead runs out of the field. Turn the tractor to face the field." (step 5b'''' in `GpsPipelineService`); it steered the tractor to the fence (Tapada da Casa). Tests: `LineEntryPlannerTests`, `LineEntryPipelineTests`. With the skip mode *Ignore worked tracks*, AutoSteer engaged in the headland joins the nearest pass still to do (not worked: the snake's test) that the way reaches straight, whatever the mode (Ask offers it), a toast naming it (`NearestOpenPassEntry`: the pass under the tractor, then one either side and so on up to 8, of two the shorter way; one the way can't reach is passed over); a lateral move or another track is left alone. Tests: `NearestOpenPassEntryTests`. +- Joining a new line in the headland (U-turn settings, `GuidanceConfig.LineEntryMode`: Nearest point, the default and AgOpenGPS's; Ask; Start of the pass; `uturn.entryMode`, `uturn.entryStraight`, saved with the tool profile; fork only): when AutoSteer is engaged, the line moved a pass or another track chosen, with the tractor in the headland (inside the field, outside the headland line), going forward, on an open track, `GpsPipelineService.UpdateLineEntry` asks `YouTurnCreationService.PlanLineEntry` for the way to the start of the pass: where the pass, driven either way, enters the cultivated area nearest the tractor (not behind it, no more than 100° from where it faces, 150 m at most; with none there, the tractor faces out of the field and the way turns it round, if it fits), reached by the shortest Dubins path at the U-turn radius from 3 m ahead (`DubinsPathService.GeneratePaths`) that keeps the pivot in the headland until it is on the pass `LineEntryStraight` (plus a front implement's reach) before the headland line, inside the field, the implement off a hard fence (`ManualTurnClearsHardBoundary`) and clear of turn obstacles (`ClearsTurnObstacles`). Start of the pass drives it like a turn (`FollowLineEntry`, the auto U-turn waits; it ends on the pass a little inside the crop, where the line takes over); Ask offers it (`GpsCycleResult.LineEntryOfferPass`, the scene's `entryOffer` and dashed `entryPath`, the approach sound, `youturn.entryTake` / `youturn.entryKeep`, 30 s) while the nearest point is steered, and plans it again from where the tractor is when taken. No way through says so once (`LineEntryNotice`, a toast) and the nearest point is used. A way or an offer is dropped by AutoSteer off, a pass, nudge or track change, a manual U-turn or reverse. In any mode, AutoSteer joining a line in the headland facing out of the field (the line, driven the way the tractor faces, reaches a hard outer boundary before any crop: `YouTurnStateMachine.LineAheadLeavesField`) with no way driven or offered goes off at once with the alarm, "AutoSteer off: the line ahead runs out of the field. Turn the tractor to face the field." (step 5b'''' in `GpsPipelineService`); it steered the tractor to the fence (Tapada da Casa). Tests: `LineEntryPlannerTests`, `LineEntryPipelineTests`. With the skip mode *Ignore worked tracks*, AutoSteer engaged in the headland joins the nearest pass still to do (the snake's tests, `YouTurnStateMachine.IsPassToJoin`: not worked on the whole, and its piece from the end the way enters it at not done, a bay cutting the pass) that the way reaches straight, whatever the mode (Ask offers it), a toast naming it (`NearestOpenPassEntry`: the pass under the tractor, then one either side and so on up to 8, of two the shorter way; one the way can't reach is passed over); a lateral move or another track is left alone. Tests: `NearestOpenPassEntryTests`. - An open curve whose ends face each other (a boundary curve made the long way round, A and B with an opening between them): A and B are the line's ends, no run-on past them, and the opening is a virtual boundary (`CurveEndOpening`, a strip of twice the U-turn radius plus the distance from boundary past A and B): the U-turn turns there into the next pass as at the field's edge. A turn that would hand over past the end of the next pass's line (the inner passes, whose two ends meet in a V, with a skip) is not made (`TurnLanding.PastLineEnd`). Past an open curve's end with no turn to take over, AutoSteer goes off ("past end of curve", AgOpenGPS `CABCurve`) instead of steering back (#694). Key parameters: diff --git a/README.md b/README.md index 12041ead..9531bc7a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Most of these have been offered to the official project too. When one is merged - **Headland gaps.** With "Drive across a headland gap up to" set on the U-turn tab, a pass that crosses a narrow tip of the field is driven on with the sections off, and the turn is made at the far end. - **U-turns with no room.** When no proper turn fits, the simple turn is moved back into the field instead of crossing the boundary. Where the headland clips a corner off the pass, AutoSteer drives on across it and says so (a thin spike of the headland line doesn't count). - **U-turn beside a fence that comes in.** With a hard boundary, a turn that had to be moved back to keep the implement off the fence at the end of the pass could be made far too early instead, where the fence comes in beside the pass: it landed on the next pass inside its crop and left a strip of it unsprayed. Now it is moved back from the end of the pass only. -- **Skip worked passes.** A pass more than half done but left short of the end the tractor is at (its 20 m next to that end mostly not covered) counts as still to do there, so the turn can go onto it. When the order's next pass is more than four passes over (the ones in between were done before), the turn goes onto the nearest pass still to do instead of along the headland to it, and the order starts again from there. A pass counts as done by what is painted on the map, not by a section having been on somewhere on it: where a bay cuts the passes, doing the short piece no longer marks the whole pass done, and the long piece gets its turn from the other end. (The turn onto the nearest pass with room is official since #807.) +- **Skip worked passes.** A pass more than half done but left short of the end the tractor is at (its 20 m next to that end mostly not covered) counts as still to do there, so the turn can go onto it. When the order's next pass is more than four passes over (the ones in between were done before), the turn goes onto the nearest pass still to do instead of along the headland to it, and the order starts again from there. A pass counts as done by what is painted on the map, not by a section having been on somewhere on it: where a bay cuts the passes, doing the short piece no longer marks the whole pass done, and the long piece gets its turn from the other end. With the headland on and its sections off, a pass lying only in the headland band is left to the headland laps: it no longer hides the passes still to do (that gave "End of field reached" with crop left). A manual U-turn and AutoSteer joining a pass from the headland count done passes the same way. (The turn onto the nearest pass with room is official since #807.) - **AutoSteer and a hard boundary.** At the end of a pass with no U-turn there (no pass with room at this end, no turn that fits, U-turn off), AutoSteer goes off with the alarm a metre into the headland, so there is the headland to turn by hand; before, it steered on through the fence. This also works on a pass bent round trees in the headland (Go around), which leaves the crop at an angle. And with no U-turn planned, when an end of the implement is heading out across a hard boundary (AutoSteer joining a line from a corner, for example), AutoSteer goes off with the alarm. It looks along the curve the tractor is steering, so on the first lap of a boundary curve it no longer goes off at a bend with the tractor on the line. A U-turn planned for one end is dropped when the tractor turns round on the pass, so these still work at the other end. - **AutoSteer flickering on and off.** With the steer switch / button set to Button, when the app took AutoSteer off just as it went on (a hard boundary warning, for example), the steer module's answer to the engage turned it on again, and it went on and off for seconds with the alarm. The app now ignores the module's "steering" for 1 s after it takes AutoSteer off itself. - **No line outside a hard boundary.** A pass that lies wholly outside a hard boundary (a lap of a boundary curve outside the fence, say) is no longer shown when the tractor is outside the field with AutoSteer off: the nearest pass inside is shown. Moving the line onto such a pass is refused with a message. diff --git a/Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs b/Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs index 5a2ccfd5..2612f47c 100644 --- a/Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs +++ b/Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs @@ -1111,7 +1111,7 @@ private void ProcessCycle(GpsData data) // the pass is driven, or offered with a sound (the nearest point is steered meanwhile). bool joinedNewLine = UpdateLineEntry(in intents, config, autoSteerEngaged, track, passNumber, nudgeOffset, headlandLine, boundary, contourOn || recordedPath != null, isSkipWorkedMode, - driftedEasting, driftedNorthing, headingRad); + driftedEasting, driftedNorthing, headingRad, turnLine, workHeadland); // AutoSteer may have chosen the pass to join (the nearest one still to do). passNumber = _guidanceWorking.HowManyPathsAway; @@ -2949,7 +2949,8 @@ private bool PassOutsideHardFence(Models.Track.Track track, int pass, double nud /// private bool UpdateLineEntry(in PipelineIntentBatch intents, ConfigurationStore config, bool autoSteerEngaged, Models.Track.Track? track, int passNumber, double nudgeOffset, List? headlandLine, Boundary? boundary, - bool otherGuidance, bool skipWorked, double e, double n, double headingRad) + bool otherGuidance, bool skipWorked, double e, double n, double headingRad, + IReadOnlyList? turnLine = null, IReadOnlyList? workHeadland = null) { var mode = config.Guidance.LineEntryMode; bool trackSwitched = !ReferenceEquals(track, _lineEntryTrack); @@ -2988,8 +2989,13 @@ private bool UpdateLineEntry(in PipelineIntentBatch intents, ConfigurationStore // choice of pass and is left alone. Fork only. if (engagedNow && !snapped && skipWorked && canPlan && InHeadland(boundary!, headlandLine!, e, n)) { + // The snake's view of the field (its tests, on the turn line and the crop as a turn has them). + var snakeView = new YouTurnStateMachine.TickContext( + new Position { Easting = e, Northing = n, Heading = headingRad * 180.0 / Math.PI }, + track, boundary, turnLine ?? headlandLine, 0, IsSkipWorkedMode: true, 0, 0, + IsPointCovered: _isPointCovered, WorkHeadlandLine: workHeadland, PassEndLine: headlandLine); var chosen = NearestOpenPassEntry(config, track!, passNumber, nudgeOffset, headlandLine!, boundary!, - e, n, headingRad, out bool onWorkedPass); + e, n, headingRad, in snakeView, out bool onWorkedPass); if (chosen is { } c) { _lineEntryOffer = null; @@ -3069,7 +3075,8 @@ private bool UpdateLineEntry(in PipelineIntentBatch intents, ConfigurationStore /// The pass AutoSteer joins when engaged in the headland with "Ignore worked tracks" (fork only): /// the nearest pass to (that one first, then one either side, two /// either side, up to ) that is still to do (not worked: the - /// snake's test, ), not wholly outside a hard fence, + /// snake's tests, : worked on the whole, or its piece + /// from the end it is entered at done, a bay cutting it), not wholly outside a hard fence, /// and that the way to the start of the pass reaches straight (: on the /// pass before the crop, at the U-turn radius, in the headland, the implement off a hard fence), /// or that the tractor is on already. Of two at the same distance the shorter way. A pass the way @@ -3078,7 +3085,7 @@ private bool UpdateLineEntry(in PipelineIntentBatch intents, ConfigurationStore /// private (int Pass, LineEntryPlan Plan)? NearestOpenPassEntry(ConfigurationStore config, Models.Track.Track track, int passNumber, double nudgeOffset, List headlandLine, Boundary boundary, double e, double n, - double headingRad, out bool onWorkedPass) + double headingRad, in YouTurnStateMachine.TickContext snakeView, out bool onWorkedPass) { onWorkedPass = false; int planned = 0; @@ -3088,7 +3095,7 @@ private bool UpdateLineEntry(in PipelineIntentBatch intents, ConfigurationStore foreach (int pass in k == 0 ? new[] { passNumber } : new[] { passNumber - k, passNumber + k }) { if (PassOutsideHardFence(track, pass, nudgeOffset, boundary)) continue; - if (_youTurnStateMachine.IsPassWorked(track, pass, nudgeOffset, boundary, headlandLine, _isPointCovered)) + if (!_youTurnStateMachine.IsPassToJoin(in snakeView, track, pass, nudgeOffset)) { if (k == 0) onWorkedPass = true; continue; @@ -3104,6 +3111,15 @@ private bool UpdateLineEntry(in PipelineIntentBatch intents, ConfigurationStore _logger.LogDebug("[LineEntry] Pass {Pass} is still to do: {Outcome}{Length}", PassName(pass), plan.Outcome, double.IsNaN(length) ? "" : string.Create(CultureInfo.InvariantCulture, $", way {length:F0} m")); if (double.IsNaN(length)) continue; + // Still to do on the whole, but its piece from the end the way enters it at is done (a + // bay cuts the pass): the snake's turns call it worked there too. + var entry = plan.Outcome == LineEntryOutcome.Planned ? EntryPose(plan.Path!) : new Vec3(e, n, headingRad); + if (!_youTurnStateMachine.IsPassToJoin(in snakeView, track, pass, nudgeOffset, entry)) + { + _logger.LogDebug("[LineEntry] Pass {Pass}: its piece from this end is done", PassName(pass)); + if (k == 0) onWorkedPass = true; + continue; + } if (best == null || length < best.Value.Length) best = (pass, plan, length); } if (best is { } b) return (b.Pass, b.Plan); @@ -3111,6 +3127,17 @@ private bool UpdateLineEntry(in PipelineIntentBatch intents, ConfigurationStore return null; } + /// The last point of a way and the heading (radians) it arrives at. + private static Vec3 EntryPose(IReadOnlyList path) + { + var last = path[^1]; + int k = path.Count - 2; + while (k > 0 && GeometryMath.Distance(path[k], last) < 0.5) k--; + var before = path[Math.Max(k, 0)]; + return new Vec3(last.Easting, last.Northing, + Math.Atan2(last.Easting - before.Easting, last.Northing - before.Northing)); + } + private static double PolylineLength(IReadOnlyList path) { double len = 0; diff --git a/Shared/AgOpenWeb.Services/YouTurn/YouTurnPathingService.cs b/Shared/AgOpenWeb.Services/YouTurn/YouTurnPathingService.cs index 60a96a01..e0aba783 100644 --- a/Shared/AgOpenWeb.Services/YouTurn/YouTurnPathingService.cs +++ b/Shared/AgOpenWeb.Services/YouTurn/YouTurnPathingService.cs @@ -53,7 +53,8 @@ public void ComputeNextTrack( YouTurnWorkingState turn, int uTurnSkipRows, bool isSkipWorkedMode, - Models.Track.Track? selectedTrack) + Models.Track.Track? selectedTrack, + Func? isWorked = null) { if (referenceTrack.Points.Count < 2) return; @@ -72,7 +73,7 @@ public void ComputeNextTrack( // Skip-worked mode: find next unworked path instead of fixed skip. if (isSkipWorkedMode && selectedTrack != null) { - pathsToMove = GetNextUnworkedPathSkip(selectedTrack, guidance.HowManyPathsAway, positiveOffset, pathsToMove); + pathsToMove = GetNextUnworkedPathSkip(selectedTrack, guidance.HowManyPathsAway, positiveOffset, pathsToMove, isWorked); } int offsetChange = positiveOffset ? pathsToMove : -pathsToMove; diff --git a/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs b/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs index 434fe28f..bd08246f 100644 --- a/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs +++ b/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs @@ -926,8 +926,13 @@ public YouTurnEffects TriggerManual( _logger.LogDebug("[ManualYouTurn] Triggering {Dir} turn, isHeadingSameWay={Way}", turnLeft ? "LEFT" : "RIGHT", guidance.IsHeadingSameWay); + // With "Ignore worked tracks" the passes stepped over are the snake's worked ones (the map), + // not Track.WorkedPaths, which a section on anywhere along a pass sets (fork only: the + // automatic turns stopped reading it, official #815, and a manual turn still stepped over + // a pass with only a short piece done). _pathing.ComputeNextTrack(track, abHeading, guidance, turn, - ctx.UTurnSkipRows, ctx.IsSkipWorkedMode, ctx.SelectedTrack); + ctx.UTurnSkipRows, ctx.IsSkipWorkedMode, ctx.SelectedTrack, + ctx.IsSkipWorkedMode ? WorkedPasses(in ctx, track, guidance, turn, atEndOf: null) : null); effects.SyncNextTrackToMap = true; if (turn.NextTrack is { Points.Count: < 2 }) { @@ -1016,13 +1021,33 @@ public void ForgetPassStatus(YouTurnWorkingState turn) } /// - /// Is pass worked: at least half covered on the job's map inside the - /// cultivated area, else (no map) marked so (, - /// the snake's test). For AutoSteer choosing the pass to join (fork only). + /// Is pass one for AutoSteer to join when engaged in the headland with + /// "Ignore worked tracks" (fork only)? The snake's own tests, so the pass AutoSteer joins and the + /// passes the turns go to agree: not worked on the whole ( + /// on the area the snake works, ), and, given (where + /// the pass is entered and the way it is driven, heading in radians: the last point of the way + /// through the headland, or the tractor already on the pass), its piece from that end not done + /// (): a bay cuts the pass and its piece on + /// this side is worked, so joining it drives that piece again and stops at the bay (official #815, + /// Baetas). A pass worked on the whole is not joined, even with its end here left: AutoSteer joins + /// a clean pass (Renato), and the turns come back for such an end + /// (). /// - public bool IsPassWorked(Models.Track.Track track, int pass, double nudgeOffset, Boundary? boundary, - IReadOnlyList? headlandLine, Func? isPointCovered) => - _pathing.IsPassWorked(track, pass, nudgeOffset, boundary, headlandLine, isPointCovered); + public bool IsPassToJoin(in TickContext ctx, Models.Track.Track track, int pass, double nudgeOffset, Vec3? entry = null) + { + var covered = ctx.IsPointCovered; + if (_pathing.IsPassWorked(track, pass, nudgeOffset, ctx.Boundary, SnakeArea(in ctx), covered)) return false; + if (covered == null || entry is not { } at || track.Points.Count < 2) return true; + var position = new Position { Easting = at.Easting, Northing = at.Northing, Heading = at.Heading * 180.0 / Math.PI }; + double abHeading = track.Points.Count > 2 + ? LocalCurveHeading(track, position) + : Math.Atan2(track.Points[1].Easting - track.Points[0].Easting, track.Points[1].Northing - track.Points[0].Northing); + bool drivenSameWay = Math.Abs(Math.IEEERemainder(at.Heading - abHeading, 2 * Math.PI)) < Math.PI / 2; + // The end tests look at the end ahead of the tractor; the end a pass is entered at lies + // behind the way it is driven from there. + return !_pathing.IsPieceDoneAtThisEnd(track, abHeading, pass, nudgeOffset, ctx.Boundary, ctx.WorkEndLine, position, + headingSameWay: !drivenSameWay, headlandAhead: 0, covered); + } /// /// Drop a planned turn that isn't being driven yet: the path, the next track and the @@ -1155,7 +1180,7 @@ private void HandleSnakeCreation( // headland built again with another width): the passes in it and their order are not this // field's. The old order sent the turn the wrong way, onto a pass by the fence where it would // not build (Tapada da Casa). - long fieldStamp = SnakeFieldStamp(ctx.Boundary, ctx.HeadlandLine); + long fieldStamp = SnakeFieldStamp(ctx.Boundary, SnakeArea(in ctx)); if (!YouTurnPathingService.IsSnakeOnPass(turn, guidance.HowManyPathsAway) || !ReferenceEquals(turn.SnakeSequence, _snakePlan) || fieldStamp != _snakePlanFieldStamp) { @@ -1172,7 +1197,7 @@ private void HandleSnakeCreation( int? nextPath = _pathing.GetNextSnakePath(turn, isWorked); // An order planned before the headland or boundary changed can go on to a pass that is // no longer in the field: plan it again for the field as it is. - if (nextPath is { } stale && !_pathing.IsPathInsideCultivated(track, abHeading, stale, ctx.Boundary, ctx.HeadlandLine)) + if (nextPath is { } stale && !_pathing.IsPathInsideCultivated(track, abHeading, stale, ctx.Boundary, SnakeArea(in ctx))) { _logger.LogDebug("[YouTurn] Snake pass {Pass} is outside the field - planning the snake again", stale); PlanSnake(in ctx, track, abHeading, guidance, turn, fieldStamp); @@ -1387,7 +1412,8 @@ private bool SnakeLandable(in TickContext ctx, Models.Track.Track track, double if (!_pathing.IsPathLandable(track, abHeading, pass, ctx.Boundary, ctx.HeadlandLine, ctx.CurrentPosition, guidance.IsHeadingSameWay, turn.DistanceToHeadland, current)) return false; - if (ctx.WorkHeadlandLine is not { Count: >= 3 } crop || !_configStore.Tool.IsHeadlandSectionControl) + var crop = SnakeArea(in ctx); + if (ReferenceEquals(crop, ctx.HeadlandLine)) return true; if (_pathing.IsPathLandable(track, abHeading, pass, ctx.Boundary, crop, ctx.CurrentPosition, guidance.IsHeadingSameWay, turn.DistanceToHeadland, current)) @@ -1396,6 +1422,19 @@ private bool SnakeLandable(in TickContext ctx, Models.Track.Track track, double return false; } + /// + /// The area the snake order and its worked-pass test are made for (fork only): with the + /// operator's headland on and the sections off in it (Tool.IsHeadlandSectionControl), the + /// crop inside the headland line; else the turn line, as the turn itself. With the sections off + /// in the headland, the band between the turn line and the headland line is never painted until + /// the headland is worked: a pass lying in it stayed "to do" for ever, and next in the order it + /// hid the passes still to do beyond it, so the end of the field was said with crop left to do + /// (a review of the snake rules: a pass 13 over left, "End of field reached"). The headland is + /// worked by its own laps. + /// + private IReadOnlyList? SnakeArea(in TickContext ctx) => + ctx.WorkHeadlandLine is { Count: >= 3 } crop && _configStore.Tool.IsHeadlandSectionControl ? crop : ctx.HeadlandLine; + /// /// Pass , worked on the whole and taken as still to do only at this end /// (), and the turn just built onto it @@ -1426,7 +1465,7 @@ private bool TurnMissesOpenEnd(in TickContext ctx, Models.Track.Track track, int private void PlanSnake(in TickContext ctx, Models.Track.Track track, double abHeading, GuidanceWorkingState guidance, YouTurnWorkingState turn, long fieldStamp) { - _pathing.BuildSnakeSequence(track, abHeading, guidance, turn, ctx.Boundary, ctx.HeadlandLine); + _pathing.BuildSnakeSequence(track, abHeading, guidance, turn, ctx.Boundary, SnakeArea(in ctx)); _snakePlan = turn.SnakeSequence; _snakePlanFieldStamp = fieldStamp; } @@ -1479,7 +1518,9 @@ private Func WorkedPasses(in TickContext ctx, Models.Track.Track trac GuidanceWorkingState guidance, YouTurnWorkingState turn, double? atEndOf) { var boundary = ctx.Boundary; - var headlandLine = ctx.HeadlandLine; + // Worked on the whole is judged on the area the order is made for (the crop, with the + // sections off in the headland), as the end of a pass below is. + var headlandLine = SnakeArea(in ctx); var isPointCovered = ctx.IsPointCovered; double nudge = guidance.NudgeOffset; var plan = turn.SnakeSequence; @@ -2021,7 +2062,8 @@ private void CompleteTurn( if (ctx.IsSkipWorkedMode && ctx.SelectedTrack != null) { pathsToMove = _pathing.GetNextUnworkedPathSkip( - ctx.SelectedTrack, guidance.HowManyPathsAway, positiveOffset, pathsToMove); + ctx.SelectedTrack, guidance.HowManyPathsAway, positiveOffset, pathsToMove, + WorkedPasses(in ctx, ctx.SelectedTrack, guidance, turn, atEndOf: null)); } int offsetChange = positiveOffset ? pathsToMove : -pathsToMove; diff --git a/Tests/AgOpenWeb.Services.Tests/Pipeline/NearestOpenPassEntryTests.cs b/Tests/AgOpenWeb.Services.Tests/Pipeline/NearestOpenPassEntryTests.cs index d5fde5ed..3d90bf8b 100644 --- a/Tests/AgOpenWeb.Services.Tests/Pipeline/NearestOpenPassEntryTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Pipeline/NearestOpenPassEntryTests.cs @@ -356,4 +356,35 @@ public void Facing_a_hard_fence_it_turns_round_onto_a_pass_still_to_do() Assert.That(first.LineEntryPath, Is.Not.Null); }); } + + [Test] + public void A_pass_whose_piece_on_this_side_of_a_bay_is_done_is_passed_over() + { + // The snake's rule for a pass a bay cuts (official #815, Baetas): its piece on this side is + // worked, though the pass is still to do on the whole. Joining it drove that piece again and + // stopped at the bay. A bay in from the east fence between 40 and 60 m north, its tip just + // west of 3R: 3R and the passes east of it have a 30 m piece in the south. + OpenField(skipWorked: true); + double tip = PassCentre(2) - 2; + _pipeline.SetHeadlandLine(new List + { + new(-80, 10, 0), new(80, 10, 0), new(80, 40, 0), new(tip, 40, 0), new(tip, 60, 0), new(80, 60, 0), + new(80, 270, 0), new(-80, 270, 0), + }); + Paint(-1, 0, 1); + double c2 = PassCentre(2); + _coverage.StartMapping(0, new Vec2(c2 - Width / 2, 10), new Vec2(c2 + Width / 2, 10)); + for (double n = 11; n <= 40; n += 1) + _coverage.AddCoveragePoint(0, new Vec2(c2 - Width / 2, n), new Vec2(c2 + Width / 2, n)); + _coverage.StopMapping(0); + + var first = Engage(PassCentre(0), 0, 90); // in the south headland on 1R, heading east + + Assert.Multiple(() => + { + Assert.That(first.Guidance!.HowManyPathsAway, Is.Not.AnyOf(-1, 0, 1, 2), + "not 3R: its piece south of the bay is done"); + Assert.That(first.LineEntryPath, Is.Not.Null); + }); + } } diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenEndTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenEndTests.cs index f23e73b7..a283477a 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenEndTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenEndTests.cs @@ -342,4 +342,85 @@ public void With_no_map_the_mark_says_which_passes_are_worked() "with a map, the map: nothing is covered"); }); } + + // The snake's area (fork only): with the operator's headland on and the sections off in it, the + // band between the turn line and the headland line is not painted until the headland is worked. + // A pass lying in it stayed "to do" for ever, and next in the order it hid the passes still to do + // beyond it. + + /// The fence ±50, the turn line ±48, the headland line ±40: passes 10 and 11 (x 42, 46) and + /// -11, -12 lie in the band only. Every pass of the crop covered but . + private YouTurnStateMachine.TickContext BandField(int? left, bool bandPainted, double northing = -25) + { + ConfigurationStore.Instance.Tool.IsHeadlandSectionControl = true; + var offset = new PolygonOffsetService(); + var turnLine = offset.CalculatePointHeadings(new List { new(-48, -48), new(48, -48), new(48, 48), new(-48, 48) }); + var headland = offset.CalculatePointHeadings(new List { new(-40, -40), new(40, -40), new(40, 40), new(-40, 40) }); + double edge = bandPainted ? 48 : 40; + Func covered = (e, n) => Math.Abs(e) < edge && Math.Abs(n) < edge + && !(left is int k && Math.Abs(e - PassX(k)) < ToolWidth / 2); + return new YouTurnStateMachine.TickContext( + new Position { Easting = PassX(8), Northing = northing, Heading = 180, Speed = 2.78 }, + _track, _boundary, turnLine, + UTurnSkipRows: 1, IsSkipWorkedMode: true, HeadlandCalculatedWidth: 10.0, HeadlandDistance: 10.0, + IsPointCovered: covered, WorkHeadlandLine: headland, PassEndLine: headland); + } + + [TestCase(false)] + [TestCase(true)] + public void A_pass_in_the_headland_band_does_not_hide_a_pass_still_to_do(bool bandPainted) + { + // On pass 8 heading south: the order goes on to 10 (the band), 11, 9, 7, ... and pass -5, + // 13 passes over, is the only pass of the crop left. Before, with the band unpainted, 10 was + // the next pass "to do", no turn could land on it, and "End of field reached" was said. + var ctx = BandField(left: -5, bandPainted); + var guidance = new GuidanceWorkingState { HowManyPathsAway = 8, IsHeadingSameWay = false }; + var turn = new YouTurnWorkingState { IsEnabled = true }; + + var effects = _sm.Tick(ctx, guidance, turn); + + Assert.Multiple(() => + { + Assert.That(turn.TurnPath, Is.Not.Null, $"a turn, not \"{effects.StatusMessage}\""); + Assert.That(turn.ReturnPassTargetPath, Is.EqualTo(-5)); + Assert.That(turn.SnakeSequence, Does.Not.Contain(10).And.Not.Contain(-12), "the order is the crop's passes"); + }); + } + + [Test] + public void With_only_the_headland_band_left_the_field_is_complete() + { + var ctx = BandField(left: null, bandPainted: false); + var guidance = new GuidanceWorkingState { HowManyPathsAway = 8, IsHeadingSameWay = false }; + var turn = new YouTurnWorkingState { IsEnabled = true }; + + var effects = _sm.Tick(ctx, guidance, turn); + + Assert.Multiple(() => + { + Assert.That(turn.TurnPath, Is.Null); + Assert.That(effects.StatusMessage, Is.EqualTo("Field complete — all tracks worked"), + "the headland is worked by its own laps"); + }); + } + + [Test] + public void A_manual_u_turn_steps_over_the_passes_the_map_says_are_worked() + { + // Pass 2 has a 10 m piece painted, so a section was on on it (marked worked), and the map says + // it is still to do. A manual turn to the right from pass 0 with "Ignore worked tracks" went to + // pass 3, stepping over it; the automatic turns go by the map. + _track.MarkPathWorked(2); + Func covered = (e, n) => Math.Abs(e - PassX(2)) < ToolWidth / 2 && n > 30; + var ctx = new YouTurnStateMachine.TickContext( + new Position { Easting = PassX(0), Northing = 0, Heading = 0, Speed = 2.78 }, + _track, _boundary, _square, UTurnSkipRows: 1, IsSkipWorkedMode: true, + HeadlandCalculatedWidth: 10.0, HeadlandDistance: 10.0, IsPointCovered: covered); + var guidance = new GuidanceWorkingState { HowManyPathsAway = 0, IsHeadingSameWay = true }; + var turn = new YouTurnWorkingState { IsEnabled = true }; + + _sm.TriggerManual(turnLeft: false, isAutoSteerEngaged: true, in ctx, guidance, turn); + + Assert.That(turn.PlannedNextPath, Is.EqualTo(2), "the map says pass 2 is still to do"); + } } diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenPassTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenPassTests.cs index 4d22542f..3e1f436e 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenPassTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenPassTests.cs @@ -293,7 +293,9 @@ public void A_pass_lying_in_the_headland_band_is_not_turned_onto_with_the_headla var effects = _sm.Tick(Ctx(turnLine, PassX(2), -25, 180, crop), guidance, turn); - Assert.That(turn.SnakeSequence!.Take(2), Is.EqualTo(new[] { 2, 4 }), "the scenario: the order goes on to pass 4"); + // The order is the crop's passes (pass 4, in the band only, was next in an order made for + // the turn line, and with nothing to paint it stayed "to do" for ever). + Assert.That(turn.SnakeSequence, Does.Not.Contain(4), "pass 4, in the band, is not in the order"); Assert.Multiple(() => { Assert.That(turn.TurnPath, Is.Not.Null, $"a turn, not \"{effects.StatusMessage}\"");