Skip to content

Commit 1e5daf6

Browse files
msallinclaude
andcommitted
feat(anchor): manual chain length feeds the Auto radius
Sailors had no way to tell the plotter how much rode they let out, so Auto could only react to swing the boat had already done - on a calm set the boat sits over its anchor and Auto under-reads the radius it will need once the wind fills in. Add a Chain field to the Set Radius panel (persisted as a setting). Auto now floors the swing term with the chain's horizontal projection at the current depth - AnchorRadiusHeuristic.ChainHorizontalProjection = sqrt(rode^2 - depth^2), with conservative fallbacks (full rode when depth is unknown, zero when the rode is shorter than the depth). The breakdown labels the term "rode" when the chain projection dominates, "swing"/"current" otherwise, so the helm can see which input drove the number. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f590014 commit 1e5daf6

10 files changed

Lines changed: 385 additions & 16 deletions

File tree

OnaPlotter.Tests/AnchorRadiusHeuristicTests.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,73 @@ public async Task Suggest_AcrossDepthRange_AlwaysInClampBounds()
291291
await Assert.That(got).IsLessThanOrEqualTo(AnchorRadiusHeuristic.MaxSuggestedMeters);
292292
}
293293
}
294+
295+
// --- ChainHorizontalProjection -----------------------------------
296+
// Taut-rode horizontal reach: sqrt(rode^2 - depth^2), with
297+
// conservative fallbacks. Feeds the Auto-radius chain floor.
298+
299+
[Test]
300+
public async Task ChainProjection_RodeAndDepth_ReturnsPythagoreanLeg()
301+
{
302+
// 3-4-5: 5 m rode, 3 m depth -> 4 m horizontal. Exact integer
303+
// triangle so the assert needs no tolerance.
304+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(5, 3)).IsEqualTo(4.0);
305+
// Realistic anchorage: 40 m rode, 8 m depth.
306+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(40, 8))
307+
.IsEqualTo(Math.Sqrt(40 * 40 - 8 * 8));
308+
}
309+
310+
[Test]
311+
public async Task ChainProjection_NullDepth_ReturnsFullRode_Conservative()
312+
{
313+
// No depth -> assume rode lying flat (max horizontal reach).
314+
// Over-estimating the radius is the safe direction.
315+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(40, null)).IsEqualTo(40.0);
316+
}
317+
318+
[Test]
319+
public async Task ChainProjection_UnusableDepth_ReturnsFullRode()
320+
{
321+
// Non-positive / non-finite depth routes to the same
322+
// conservative full-rode fallback as null.
323+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(40, 0)).IsEqualTo(40.0);
324+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(40, -5)).IsEqualTo(40.0);
325+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(40, double.NaN)).IsEqualTo(40.0);
326+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(40, double.PositiveInfinity)).IsEqualTo(40.0);
327+
}
328+
329+
[Test]
330+
public async Task ChainProjection_RodeShorterThanDepth_ReturnsZero()
331+
{
332+
// Sub-1:1 scope: the rode is effectively vertical, no
333+
// meaningful horizontal reach. Boundary rode == depth also
334+
// collapses to 0.
335+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(5, 8)).IsEqualTo(0.0);
336+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(8, 8)).IsEqualTo(0.0);
337+
}
338+
339+
[Test]
340+
public async Task ChainProjection_NoRode_ReturnsZero()
341+
{
342+
// Rode <= 0 / NaN / Infinity -> the chain term simply doesn't
343+
// contribute (and never feeds NaN into the caller's max()).
344+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(0, 8)).IsEqualTo(0.0);
345+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(-10, 8)).IsEqualTo(0.0);
346+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(double.NaN, 8)).IsEqualTo(0.0);
347+
await Assert.That(AnchorRadiusHeuristic.ChainHorizontalProjection(double.PositiveInfinity, 8)).IsEqualTo(0.0);
348+
}
349+
350+
[Test]
351+
public async Task ChainProjection_AlwaysLessThanOrEqualRode()
352+
{
353+
// Invariant: the horizontal leg never exceeds the hypotenuse
354+
// (the rode). Sweep depths for a fixed rode.
355+
const double rode = 50;
356+
for (double d = 0.5; d < rode + 10; d += 0.5)
357+
{
358+
double proj = AnchorRadiusHeuristic.ChainHorizontalProjection(rode, d);
359+
await Assert.That(proj).IsGreaterThanOrEqualTo(0.0);
360+
await Assert.That(proj).IsLessThanOrEqualTo(rode);
361+
}
362+
}
294363
}

OnaPlotter.Tests/AppSettingsServiceTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,27 @@ public async Task TcpaAlarmMin_RoundTrip()
428428
await Assert.That(svc2.TcpaAlarmMin).IsEqualTo(15);
429429
}
430430

431+
[Test]
432+
public async Task AnchorChainLength_RoundTrip_AndClampsNegativeToZero()
433+
{
434+
var kv = new InMemoryKv();
435+
var svc = new AppSettingsService(kv);
436+
await svc.InitializeAsync();
437+
438+
// Default is 0 ("not entered").
439+
await Assert.That(svc.AnchorChainLengthMeters).IsEqualTo(0.0);
440+
441+
await svc.SetAnchorChainLengthMetersAsync(45);
442+
var svc2 = new AppSettingsService(kv);
443+
await svc2.InitializeAsync();
444+
await Assert.That(svc2.AnchorChainLengthMeters).IsEqualTo(45.0);
445+
446+
// Negative input clamps to 0 (a length can't be negative, and
447+
// it must not feed a NaN into the chain-projection sqrt).
448+
await svc2.SetAnchorChainLengthMetersAsync(-10);
449+
await Assert.That(svc2.AnchorChainLengthMeters).IsEqualTo(0.0);
450+
}
451+
431452
[Test]
432453
public async Task EnabledRoutes_Overwrite()
433454
{

OnaPlotter.Tests/Components/AnchorEditPanelTests.cs

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ private static IRenderedComponent<AnchorEditPanel> RenderSetRadius(
6262
bool initialPickIsAuto = false,
6363
bool canMove = false,
6464
bool moveActive = false,
65-
Action<bool>? onToggleMove = null)
65+
Action<bool>? onToggleMove = null,
66+
int chainLengthMeters = 0,
67+
Action<int>? onChainLengthChanged = null)
6668
{
6769
return ctx.RenderComponent<AnchorEditPanel>(p => p
6870
.Add(x => x.Mode, AnchorEditPanel.AnchorPanelMode.SetRadius)
@@ -74,8 +76,11 @@ private static IRenderedComponent<AnchorEditPanel> RenderSetRadius(
7476
.Add(x => x.AutoBreakdown, autoBreakdown)
7577
.Add(x => x.CanMove, canMove)
7678
.Add(x => x.MoveActive, moveActive)
79+
.Add(x => x.ChainLengthMeters, chainLengthMeters)
7780
.Add(x => x.OnToggleMove, Microsoft.AspNetCore.Components.EventCallback.Factory
7881
.Create<bool>(p, v => onToggleMove?.Invoke(v)))
82+
.Add(x => x.OnChainLengthChanged, Microsoft.AspNetCore.Components.EventCallback.Factory
83+
.Create<int>(p, v => onChainLengthChanged?.Invoke(v)))
7984
.Add(x => x.OnPreviewRadius, Microsoft.AspNetCore.Components.EventCallback.Factory
8085
.Create<int>(p, r => onPreviewRadius?.Invoke(r)))
8186
// Most existing tests only care about the radius value; surface
@@ -854,6 +859,77 @@ public async Task SetRadiusMode_MoveButton_Busy_DoesNotToggle()
854859
await Assert.That(toggles).IsEmpty();
855860
}
856861

862+
// ---- Chain / rode length input ----
863+
864+
[Test]
865+
public async Task SetRadiusMode_ChainInput_Renders()
866+
{
867+
using var ctx = new Bunit.TestContext();
868+
var cut = RenderSetRadius(ctx, initial: 30);
869+
870+
await Assert.That(cut.FindAll(".anchor-edit-chain-input").Count).IsEqualTo(1);
871+
}
872+
873+
[Test]
874+
public async Task SetRadiusMode_ChainInput_ShowsValueWhenSet_BlankWhenZero()
875+
{
876+
using var ctx = new Bunit.TestContext();
877+
878+
var withChain = RenderSetRadius(ctx, initial: 30, chainLengthMeters: 40);
879+
await Assert.That(withChain.Find(".anchor-edit-chain-input").GetAttribute("value"))
880+
.IsEqualTo("40");
881+
882+
var noChain = RenderSetRadius(ctx, initial: 30, chainLengthMeters: 0);
883+
await Assert.That(noChain.Find(".anchor-edit-chain-input").GetAttribute("value"))
884+
.IsEqualTo("");
885+
}
886+
887+
[Test]
888+
public async Task SetRadiusMode_ChainInput_Change_FiresParsedValue()
889+
{
890+
using var ctx = new Bunit.TestContext();
891+
var changes = new List<int>();
892+
var cut = RenderSetRadius(ctx, initial: 30, onChainLengthChanged: v => changes.Add(v));
893+
894+
cut.Find(".anchor-edit-chain-input").Change("45");
895+
896+
await Assert.That(changes).IsEquivalentTo([45]);
897+
}
898+
899+
[Test]
900+
public async Task SetRadiusMode_ChainInput_EmptyOrInvalid_FiresZero()
901+
{
902+
// Cleared field / non-numeric collapses to 0 ("not entered")
903+
// rather than throwing or leaving a stale value.
904+
using var ctx = new Bunit.TestContext();
905+
var changes = new List<int>();
906+
var cut = RenderSetRadius(ctx, initial: 30, chainLengthMeters: 40,
907+
onChainLengthChanged: v => changes.Add(v));
908+
909+
cut.Find(".anchor-edit-chain-input").Change("");
910+
await Assert.That(changes).IsEquivalentTo([0]);
911+
}
912+
913+
[Test]
914+
public async Task SetRadiusMode_ChainInput_Busy_Disabled()
915+
{
916+
using var ctx = new Bunit.TestContext();
917+
var cut = RenderSetRadius(ctx, initial: 30, busy: true);
918+
919+
await Assert.That(cut.Find(".anchor-edit-chain-input").HasAttribute("disabled")).IsTrue();
920+
}
921+
922+
[Test]
923+
public async Task DropMode_NoChainInput()
924+
{
925+
// Chain entry only makes sense once the radius decision is in
926+
// play; Drop mode stays minimal.
927+
using var ctx = new Bunit.TestContext();
928+
var cut = RenderDrop(ctx);
929+
930+
await Assert.That(cut.FindAll(".anchor-edit-chain-input").Count).IsEqualTo(0);
931+
}
932+
857933
// ---- External InitialRadiusMeters changes (server delta echo) ----
858934

859935
[Test]

OnaPlotter.Tests/FakeSettings.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ internal sealed class FakeSettings : IAppSettings
5858
public double AnchorTideSafetyMargin { get; set; } = 1.0;
5959
public double AnchorAutoRadiusSafetyMargin { get; set; } = 5.0;
6060
public double ManualAnchorRadiusMeters { get; set; } = 30.0;
61+
public double AnchorChainLengthMeters { get; set; } = 0.0;
6162
public double DeadmanTimeoutMinutes { get; set; } = 0.0;
6263
public double DeadmanNightMinutes { get; set; } = 15.0;
6364
public int SnoozeDurationMinutes { get; set; } = 10;
@@ -189,6 +190,7 @@ public Task ApplyMobileFirstRunDefaultsAsync(bool isMobile)
189190
public Task SetAnchorTideSafetyMarginAsync(double v) => Task.CompletedTask;
190191
public Task SetAnchorAutoRadiusSafetyMarginAsync(double v) { AnchorAutoRadiusSafetyMargin = v; return Task.CompletedTask; }
191192
public Task SetManualAnchorRadiusMetersAsync(double v) => Task.CompletedTask;
193+
public Task SetAnchorChainLengthMetersAsync(double v) { AnchorChainLengthMeters = Math.Max(0, v); return Task.CompletedTask; }
192194
public Task SetDeadmanTimeoutMinutesAsync(double v) { DeadmanTimeoutMinutes = v; return Task.CompletedTask; }
193195
public Task SetDeadmanNightMinutesAsync(double v) { DeadmanNightMinutes = v; return Task.CompletedTask; }
194196
public Task SetSnoozeDurationMinutesAsync(int v) { SnoozeDurationMinutes = v; return Task.CompletedTask; }

OnaPlotter/Components/Map/AnchorEditPanel.razor

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,24 @@
9393
}
9494
</div>
9595

96+
@* Chain / rode length. Feeds the Auto radius (the chain's
97+
horizontal projection at the current depth floors the swing
98+
term), so the helm entering "we let out 40 m" makes Auto
99+
account for how far the boat CAN swing before it actually
100+
has. Persisted parent-side; blank = not entered. Plain
101+
on-change (blur / Enter) avoids a PUT-storm per keystroke. *@
102+
<label class="anchor-edit-chain"
103+
title="Deployed chain / rode length - feeds the Auto radius">
104+
<span class="anchor-edit-chain-label">Chain</span>
105+
<input type="number" inputmode="numeric" min="0" max="300" step="5"
106+
class="anchor-edit-chain-input"
107+
value="@(ChainLengthMeters > 0 ? ChainLengthMeters.ToString() : "")"
108+
placeholder="&ndash;"
109+
@onchange="OnChainInputChanged"
110+
disabled="@Busy" />
111+
<span class="hud-unit">m</span>
112+
</label>
113+
96114
@* Move toggles the on-map drag handle so the helm can nudge
97115
the pin onto the actual lie of the chain (the plugin
98116
captures GPS at drop time, which can be a boat-length off).
@@ -284,6 +302,19 @@
284302
/// PUT itself rides along with the radius on Set.</summary>
285303
[Parameter] public EventCallback<bool> OnToggleMove { get; set; }
286304

305+
/// <summary>Helm-entered deployed chain / rode length (metres),
306+
/// parent-owned (persisted in settings). 0 renders the input blank
307+
/// ("not entered"). Feeds the parent's Auto radius computation -
308+
/// the panel just surfaces the field + relays edits.</summary>
309+
[Parameter] public int ChainLengthMeters { get; set; }
310+
311+
/// <summary>Helm edited the chain field. Carries the parsed whole-
312+
/// metre value (0 when cleared / invalid). Parent persists it and
313+
/// recomputes the Auto radius; the new <see cref="AutoPreviewRadius"/>
314+
/// flows back and, when Auto is the active pick, re-previews the
315+
/// on-map ring.</summary>
316+
[Parameter] public EventCallback<int> OnChainLengthChanged { get; set; }
317+
287318
/// <summary>Helm-tunable anchor-radius presets (metres). 150 m
288319
/// dropped from the v1 set on field-study feedback - 5:1 scope
289320
/// at 30 m of water is already 150 m and that's deeper than the
@@ -459,6 +490,24 @@
459490
await OnToggleMove.InvokeAsync(!MoveActive);
460491
}
461492

493+
/// <summary>Parse the chain field and relay the whole-metre value
494+
/// to the parent. Empty / non-numeric / negative all collapse to
495+
/// 0 ("not entered"), which the heuristic treats as "no chain
496+
/// term". Invariant parse so a comma-decimal locale on the number
497+
/// input doesn't mis-read the digits.</summary>
498+
private async Task OnChainInputChanged(ChangeEventArgs e)
499+
{
500+
if (Busy) return;
501+
var raw = e.Value?.ToString();
502+
if (!int.TryParse(raw, System.Globalization.NumberStyles.Integer,
503+
System.Globalization.CultureInfo.InvariantCulture, out int meters)
504+
|| meters < 0)
505+
{
506+
meters = 0;
507+
}
508+
await OnChainLengthChanged.InvokeAsync(meters);
509+
}
510+
462511
private async Task OnCancelClicked()
463512
{
464513
if (Busy) return;

0 commit comments

Comments
 (0)