diff --git a/CLAUDE.md b/CLAUDE.md
index 9fba1623..e28b0e5a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -593,7 +593,7 @@ 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.
+- 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.
- 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).
- 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.
diff --git a/Shared/AgOpenWeb.Services/YouTurn/YouTurnPathingService.cs b/Shared/AgOpenWeb.Services/YouTurn/YouTurnPathingService.cs
index b2871d09..c23bc4f4 100644
--- a/Shared/AgOpenWeb.Services/YouTurn/YouTurnPathingService.cs
+++ b/Shared/AgOpenWeb.Services/YouTurn/YouTurnPathingService.cs
@@ -882,6 +882,60 @@ public bool IsPassOpenAtThisEnd(Models.Track.Track track, double abHeading, int
return covered * 2 < stretch.Count;
}
+ ///
+ /// The least unworked crop a turn onto a pass left to do at its end must drive over, m: less, and
+ /// the turn lands short of what is left ().
+ ///
+ public const double MinNewGround = 5.0;
+
+ ///
+ /// Does a turn onto a pass left to do at its end () get there:
+ /// at least of unworked crop, inside the cultivated area, along the
+ /// turn's last (the exit leg, on the pass) and as much again on along
+ /// its last heading. A turn lands abreast of where it leaves the tractor's pass; where the pass's
+ /// own end lies beyond that (a slanting headland, a turn seated back from the fence or an
+ /// obstacle) the end is out of its reach. True with no map, or with no crop to judge. Fork only.
+ ///
+ public bool TurnReachesUnworkedGround(IReadOnlyList turnPath, Boundary? boundary,
+ IReadOnlyList? headlandLine, Func? isPointCovered)
+ {
+ const double Step = 1.0;
+ if (isPointCovered == null || turnPath.Count < 2) return true;
+ var samples = new List();
+ // Back along the path from its end.
+ double walked = 0;
+ for (int i = turnPath.Count - 1; i > 0 && walked < OpenEndStretch; i--)
+ {
+ var a = turnPath[i];
+ var b = turnPath[i - 1];
+ double d = GeometryMath.Distance(a, b);
+ for (double t = 0; t < d && walked + t < OpenEndStretch; t += Step)
+ {
+ double f = d > 1e-9 ? t / d : 0;
+ samples.Add(new Vec2(a.Easting + (b.Easting - a.Easting) * f, a.Northing + (b.Northing - a.Northing) * f));
+ }
+ walked += d;
+ }
+ // On past its end, the way it ends.
+ var last = turnPath[^1];
+ int k = turnPath.Count - 2;
+ while (k > 0 && GeometryMath.Distance(turnPath[k], last) < 0.5) k--;
+ double de = last.Easting - turnPath[k].Easting, dn = last.Northing - turnPath[k].Northing;
+ double len = Math.Sqrt(de * de + dn * dn);
+ if (len > 1e-6)
+ for (double s = Step; s <= OpenEndStretch; s += Step)
+ samples.Add(new Vec2(last.Easting + de / len * s, last.Northing + dn / len * s));
+
+ int inside = 0, unworked = 0;
+ foreach (var p in samples)
+ {
+ if (!IsPointInsideCultivatedArea(p.Easting, p.Northing, boundary, headlandLine)) continue;
+ inside++;
+ if (!isPointCovered(p.Easting, p.Northing)) unworked++;
+ }
+ return inside == 0 || unworked * Step >= MinNewGround;
+ }
+
///
/// Points every metre along the first of pass
/// inside the cultivated area back from the end the tractor is
diff --git a/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs b/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs
index e2df3a31..a3fca319 100644
--- a/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs
+++ b/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs
@@ -1191,8 +1191,11 @@ private void HandleSnakeCreation(
if (landable)
{
AimTurnAt(track, abHeading, guidance, turn, effects, target, "Snake");
+ string? message = effects.StatusMessage;
var built = BuildTurnPath(in ctx, track, headingRadians, abHeading, guidance, turn, effects);
- if (built.Path != null) return;
+ // Taken for its end left to do, and the turn lands short of it: worked at this end, and
+ // the next pass of the order is looked for on the next cycle.
+ if (built.Path != null) { TurnMissesOpenEnd(in ctx, track, target, turn, effects, message); return; }
refused = built;
}
else
@@ -1314,8 +1317,11 @@ private bool TurnOntoNearestOpenPass(
if (!SnakeLandable(in ctx, track, abHeading, pass, guidance, turn))
continue;
AimTurnAt(track, abHeading, guidance, turn, effects, pass, "Snake (nearest open pass)");
+ string? message = effects.StatusMessage;
if (BuildTurnPath(in ctx, track, headingRadians, abHeading, guidance, turn, effects).Path != null)
{
+ // Taken for its end left to do, and the turn lands short of it: the next one.
+ if (TurnMissesOpenEnd(in ctx, track, pass, turn, effects, message)) continue;
if (orderPassFar)
_logger.LogDebug("[YouTurn] Snake pass {Order} is {Count} passes over - turning onto pass {Pass}, the nearest still to do",
orderPass, Math.Abs(orderPass - current), pass);
@@ -1355,6 +1361,29 @@ private bool SnakeLandable(in TickContext ctx, Models.Track.Track track, double
return false;
}
+ ///
+ /// Pass , worked on the whole and taken as still to do only at this end
+ /// (), and the turn just built onto it
+ /// lands short of that end: the turn is dropped and the pass counts worked at this end. A turn
+ /// lands abreast of where it leaves the tractor's pass, and the end of the next pass can lie
+ /// beyond that (the headland line slanting out, a turn seated back from a fence or an
+ /// obstacle): the end stays unworked whatever turn is made here, and the whole pass was driven
+ /// again for nothing (a tester's field, simulator: the turn from 1L onto 2R at the north end,
+ /// seated back from the fence and a tree, and 2R driven a second time). Fork only.
+ ///
+ private bool TurnMissesOpenEnd(in TickContext ctx, Models.Track.Track track, int pass, YouTurnWorkingState turn,
+ YouTurnEffects effects, string? messageBefore)
+ {
+ if (turn.TurnPath is not { } path || !_openAtEnd.TryGetValue(pass, out bool open) || !open) return false;
+ if (!track.IsPathWorked(pass) && !(_coveredPasses.TryGetValue(pass, out bool covered) && covered)) return false;
+ if (_pathing.TurnReachesUnworkedGround(path, ctx.Boundary, ctx.WorkEndLine, ctx.IsPointCovered)) return false;
+ _logger.LogDebug("[YouTurn] Snake pass {Pass} is left to do only beyond where a turn here lands on it - worked at this end", pass);
+ _openAtEnd[pass] = false;
+ DiscardPlannedTurn(turn);
+ effects.StatusMessage = messageBefore; // not "YouTurn path created"
+ return true;
+ }
+
// The snake order this state machine planned, and the field it planned it for.
private List? _snakePlan;
private long _snakePlanFieldStamp;
diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenEndTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenEndTests.cs
index 120061ec..7d42bab9 100644
--- a/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenEndTests.cs
+++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/SnakeOpenEndTests.cs
@@ -167,6 +167,59 @@ public void With_the_turn_line_the_open_end_is_measured_from_the_headland_line()
});
}
+ [Test]
+ public void A_pass_left_to_do_beyond_where_the_turn_lands_on_it_is_worked_at_this_end()
+ {
+ // The fence reaches 20 m further south west of x = 22 (a corner of the field), so pass 4's
+ // own end is 20 m past the turn line ahead of the tractor on pass 6. Pass 4 is covered down
+ // to n = -52 and left below that, at its end. A turn here lands on pass 4 abreast of the
+ // turn line at -48 and drives north over the covered part: the end stays as it was, and the
+ // whole pass would be driven again for nothing (a tester's field, simulator: 2R driven twice).
+ var fence = new List { new(-50, -70), new(22, -70), new(22, -50), new(50, -50), new(50, 50), new(-50, 50) };
+ var boundary = new Boundary
+ {
+ OuterBoundary = new BoundaryPolygon
+ {
+ Points = fence.Select(p => new BoundaryPoint(p.Easting, p.Northing, 0)).ToList(),
+ },
+ };
+ // The turn line: the fence 2 m in.
+ var turnLine = new PolygonOffsetService().CalculatePointHeadings(new List
+ { new(-48, -68), new(20, -68), new(20, -48), new(48, -48), new(48, 48), new(-48, 48) });
+ var covered = AllCoveredBut(n => n < -52);
+ var position = new Position { Easting = PassX(6), Northing = -20, Heading = 180, Speed = 2.78 };
+ Assert.That(_pathing.IsPassOpenAtThisEnd(_track, 0, 4, 0, boundary, turnLine, position,
+ headingSameWay: false, headlandAhead: 28, covered), Is.True, "the scenario: pass 4 is left to do at its south end");
+
+ var ctx = new YouTurnStateMachine.TickContext(position, _track, boundary, turnLine,
+ UTurnSkipRows: 1, IsSkipWorkedMode: true, HeadlandCalculatedWidth: 10.0, HeadlandDistance: 10.0,
+ IsPointCovered: covered);
+ var guidance = new GuidanceWorkingState { HowManyPathsAway = 6, IsHeadingSameWay = false };
+ var turn = new YouTurnWorkingState { IsEnabled = true };
+ _sm.Tick(ctx, guidance, turn);
+ var effects = _sm.Tick(ctx, guidance, turn);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(turn.TurnPath, Is.Null, $"no turn onto pass {turn.ReturnPassTargetPath}");
+ Assert.That(effects.StatusMessage, Is.EqualTo("Field complete — all tracks worked"));
+ });
+ }
+
+ [Test]
+ public void A_turn_that_lands_on_the_part_left_to_do_reaches_it()
+ {
+ var turnPath = Enumerable.Range(0, 30).Select(i => new Vec3(PassX(4), -38 + i, 0)).ToList();
+ Assert.Multiple(() =>
+ {
+ Assert.That(_pathing.TurnReachesUnworkedGround(turnPath, _boundary, _square, AllCoveredBut(n => n < -10)),
+ Is.True, "left below -10: the turn's last 20 m are on it");
+ Assert.That(_pathing.TurnReachesUnworkedGround(turnPath, _boundary, _square, AllCoveredBut(n => n < -40)),
+ Is.False, "left below the field's edge here: nothing of it is reached");
+ Assert.That(_pathing.TurnReachesUnworkedGround(turnPath, _boundary, _square, null), Is.True, "no map");
+ });
+ }
+
[Test]
public void The_stretch_at_this_end_is_measured_from_where_the_pass_leaves_the_field()
{