From b129e207bab4463969618ac3af0b25346c57ff80 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Mon, 19 Oct 2026 18:52:37 +0000 Subject: [PATCH 1/2] feat: per-axis RestTolerance in the options and the device settings (bug-034) GenericAxisOptions.RestTolerance (default 0.1, the owner ruling of 2026-10-19; Validate refuses < 0 and NaN, 0 = exact equality) is reported by ModbusLinearAxis/ModbusRotaryAxis as the SDK leaf's RestTolerance, and is one row in GenericAxisProperties.All right after Tolerance, so it shows up in the device settings dialog. RocketWelder.SDK.* 2.30.0 -> 3.1.0 (the release that adds the member). Protocol unchanged: this is a host-side reading rule, not a register. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfrwEnFVrCJNqiDqWFyb4 --- .../DeclarationTests.cs | 16 ++++++++++ .../FactoryTests.cs | 27 +++++++++++++++++ .../PropertyTableTests.cs | 30 +++++++++++++++++++ .../GenericAxisProperties.cs | 6 ++++ ...odelingEvolution.GenericAxis.Plugin.csproj | 6 ++-- .../OptionsAndHygieneTests.cs | 11 +++++++ .../GenericAxisOptions.cs | 18 +++++++++++ .../ModbusLinearAxis.cs | 3 ++ .../ModbusRotaryAxis.cs | 3 ++ .../ModelingEvolution.GenericAxis.csproj | 2 +- 10 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/ModelingEvolution.GenericAxis.Plugin.Tests/DeclarationTests.cs b/src/ModelingEvolution.GenericAxis.Plugin.Tests/DeclarationTests.cs index 5154b38..a8a5415 100644 --- a/src/ModelingEvolution.GenericAxis.Plugin.Tests/DeclarationTests.cs +++ b/src/ModelingEvolution.GenericAxis.Plugin.Tests/DeclarationTests.cs @@ -91,6 +91,7 @@ public void The_Axis_Table_Declares_The_Designed_Suffixes_Types_And_Defaults() ("DefaultSpeedPercent", "double", "100"), ("Acceleration", "double", null), ("Tolerance", "double", "0.001"), + ("RestTolerance", "double", "0.1"), ("HomingTimeoutSeconds", "int", "120")); } @@ -113,4 +114,19 @@ public void Labels_Carry_The_Units_Of_The_Axis_Kind_And_Headings_Carry_The_Axis_ .Should().Be("Status block base (input registers)", "ADR-36: the status block is input registers"); turntable.Group.Should().Be("Turntable — Machine limits"); } + + [Fact] + public void The_Rest_Tolerance_Row_Is_Labelled_In_The_Axis_Unit_Under_Motion() + { + var carriage = PluginHarness.Track.Axes.Single().PropertySchemas + .Single(s => s.Name == PluginHarness.CarriageKey("RestTolerance")); + var turntable = PluginHarness.Positioner.Axes.Single().PropertySchemas + .Single(s => s.Name == PluginHarness.TurntableKey("RestTolerance")); + + carriage.DisplayName.Should().Be("Rest tolerance (mm) — allowed change between two capture readings"); + turntable.DisplayName.Should().Be("Rest tolerance (°) — allowed change between two capture readings"); + carriage.Group.Should().Be("Carriage — Motion"); + carriage.Required.Should().BeFalse(); + turntable.Default.Should().Be("0.1"); + } } diff --git a/src/ModelingEvolution.GenericAxis.Plugin.Tests/FactoryTests.cs b/src/ModelingEvolution.GenericAxis.Plugin.Tests/FactoryTests.cs index 0979879..ce403e3 100644 --- a/src/ModelingEvolution.GenericAxis.Plugin.Tests/FactoryTests.cs +++ b/src/ModelingEvolution.GenericAxis.Plugin.Tests/FactoryTests.cs @@ -44,6 +44,7 @@ public void GA_U_44_Invalid_Device_Values_Are_Refused_Naming_The_Key(string key, [InlineData("DefaultSpeedPercent", "0")] [InlineData("DefaultSpeedPercent", "150")] [InlineData("Tolerance", "0")] + [InlineData("RestTolerance", "-0.1")] [InlineData("HomingTimeoutSeconds", "99999999999")] [InlineData("StatusBase", "65530")] // S+14 past input register 65535 [InlineData("CommandBase", "65530")] // C+11 past holding register 65535 @@ -128,6 +129,32 @@ public void The_Positioner_Factory_Builds_An_IPositioner_With_The_Turntable_Axis device.OwnerId.Should().Be((ushort)GenericAxisOwnerIdProperty.Default); } + [Fact] + public void The_Carriage_Reports_Its_Rest_Tolerance_Through_The_Sdk_Leaf() + { + var stored = PluginHarness.Build(PluginHarness.Track, PluginHarness.ConfigFor(PluginHarness.Track, + (PluginHarness.CarriageKey("Host"), UnreachableHost), + (PluginHarness.CarriageKey("RestTolerance"), "0.25"))); + var absent = PluginHarness.Build(PluginHarness.Track, PluginHarness.ConfigFor(PluginHarness.Track, + (PluginHarness.CarriageKey("Host"), UnreachableHost))); + + ((ILinearAxis)stored["carriage"]).RestTolerance.Value.Should().Be(0.25); + ((ILinearAxis)absent["carriage"]).RestTolerance.Value.Should().Be(0.1); + } + + [Fact] + public void The_Turntable_Reports_Its_Rest_Tolerance_Through_The_Sdk_Leaf() + { + var stored = PluginHarness.Build(PluginHarness.Positioner, PluginHarness.ConfigFor(PluginHarness.Positioner, + (PluginHarness.TurntableKey("Host"), UnreachableHost), + (PluginHarness.TurntableKey("RestTolerance"), "0"))); + var absent = PluginHarness.Build(PluginHarness.Positioner, PluginHarness.ConfigFor(PluginHarness.Positioner, + (PluginHarness.TurntableKey("Host"), UnreachableHost))); + + ((double)((IRotaryAxis)stored["turntable"]).RestTolerance).Should().Be(0); + ((double)((IRotaryAxis)absent["turntable"]).RestTolerance).Should().Be(0.1); + } + [Fact] public void A_Rebuild_Replaces_The_Tracked_Instance() { diff --git a/src/ModelingEvolution.GenericAxis.Plugin.Tests/PropertyTableTests.cs b/src/ModelingEvolution.GenericAxis.Plugin.Tests/PropertyTableTests.cs index 28f9567..a068b0c 100644 --- a/src/ModelingEvolution.GenericAxis.Plugin.Tests/PropertyTableTests.cs +++ b/src/ModelingEvolution.GenericAxis.Plugin.Tests/PropertyTableTests.cs @@ -27,6 +27,7 @@ private static readonly (string Suffix, string Raw, Func o.DefaultSpeed, new Percentage(20)), ("Acceleration", "1500", o => o.Acceleration, 1500.0), ("Tolerance", "0.05", o => o.Tolerance, 0.05), + ("RestTolerance", "0.25", o => o.RestTolerance, 0.25), ("HomingTimeoutSeconds", "90", o => o.HomingTimeout, TimeSpan.FromSeconds(90)), ]; @@ -74,6 +75,35 @@ public void An_Absent_Value_Keeps_The_Driver_Default() options.Should().Be(GenericAxisPlugin.CarriageTemplate with { Host = "10.0.0.5" }); } + [Fact] + public void RestTolerance_Absent_BindsTheDefaultPointOne() + { + var options = GenericAxisProperties.Bind(GenericAxisPlugin.CarriageTemplate, Stored([("Host", "h")])); + + options.RestTolerance.Should().Be(GenericAxisOptions.DefaultRestTolerance).And.Be(0.1); + } + + [Theory] + [InlineData("0", 0.0)] + [InlineData("0.25", 0.25)] + public void RestTolerance_Stored_LandsInTheOptions(string raw, double expected) + { + var options = GenericAxisProperties.Bind(GenericAxisPlugin.CarriageTemplate, + Stored([("Host", "h"), ("RestTolerance", raw)])); + + options.RestTolerance.Should().Be(expected); + } + + [Fact] + public void RestTolerance_Negative_IsRefusedByValidate_NamingTheHubKey() + { + var bind = () => GenericAxisProperties.Bind(GenericAxisPlugin.CarriageTemplate, + Stored([("Host", "h"), ("RestTolerance", "-0.1")])); + + bind.Should().Throw() + .WithMessage($"'{PluginHarness.CarriageKey("RestTolerance")}' refused by GenericAxisOptions.Validate:*"); + } + [Fact] public void The_Frozen_Identity_Is_Not_Bindable() { diff --git a/src/ModelingEvolution.GenericAxis.Plugin/GenericAxisProperties.cs b/src/ModelingEvolution.GenericAxis.Plugin/GenericAxisProperties.cs index e6c2b0d..5d6ee57 100644 --- a/src/ModelingEvolution.GenericAxis.Plugin/GenericAxisProperties.cs +++ b/src/ModelingEvolution.GenericAxis.Plugin/GenericAxisProperties.cs @@ -125,6 +125,12 @@ internal sealed record AxisProperty( o => Num(o.Tolerance), (o, v) => o with { Tolerance = ParseDouble(v) }), + new("RestTolerance", nameof(GenericAxisOptions.RestTolerance), + k => $"Rest tolerance ({Unit(k)}) — allowed change between two capture readings", "double", GroupMotion, + Required: false, + o => Num(o.RestTolerance), + (o, v) => o with { RestTolerance = ParseDouble(v) }), + new("HomingTimeoutSeconds", nameof(GenericAxisOptions.HomingTimeout), _ => "Homing budget (s)", "int", GroupSupervision, Required: false, o => Int((int)o.HomingTimeout.TotalSeconds), diff --git a/src/ModelingEvolution.GenericAxis.Plugin/ModelingEvolution.GenericAxis.Plugin.csproj b/src/ModelingEvolution.GenericAxis.Plugin/ModelingEvolution.GenericAxis.Plugin.csproj index 04fcee0..28513c1 100644 --- a/src/ModelingEvolution.GenericAxis.Plugin/ModelingEvolution.GenericAxis.Plugin.csproj +++ b/src/ModelingEvolution.GenericAxis.Plugin/ModelingEvolution.GenericAxis.Plugin.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/src/ModelingEvolution.GenericAxis.Tests/OptionsAndHygieneTests.cs b/src/ModelingEvolution.GenericAxis.Tests/OptionsAndHygieneTests.cs index 1d099f6..2abd563 100644 --- a/src/ModelingEvolution.GenericAxis.Tests/OptionsAndHygieneTests.cs +++ b/src/ModelingEvolution.GenericAxis.Tests/OptionsAndHygieneTests.cs @@ -28,6 +28,8 @@ public class OptionsAndHygieneTests { nameof(GenericAxisOptions.DefaultSpeed), o => o with { DefaultSpeed = new Percentage(0f) } }, { nameof(GenericAxisOptions.Acceleration), o => o with { Acceleration = 0 } }, { nameof(GenericAxisOptions.Tolerance), o => o with { Tolerance = 0 } }, + { nameof(GenericAxisOptions.RestTolerance), o => o with { RestTolerance = -0.1 } }, + { nameof(GenericAxisOptions.RestTolerance), o => o with { RestTolerance = double.NaN } }, { nameof(GenericAxisOptions.EnableTimeout), o => o with { EnableTimeout = TimeSpan.Zero } }, { nameof(GenericAxisOptions.StopTimeout), o => o with { StopTimeout = TimeSpan.Zero } }, { nameof(GenericAxisOptions.HomingTimeout), o => o with { HomingTimeout = TimeSpan.Zero } }, @@ -42,6 +44,14 @@ public void Validate_InvalidValue_ArgumentExceptionNamesField(string field, Func spoil(Valid).Invoking(o => o.Validate()).Should().Throw().Which.ParamName.Should().Be(field); } + [Theory(DisplayName = "GA-U-41 a rest tolerance of 0 (exact equality) or above is accepted (bug-034)")] + [InlineData(0.0)] + [InlineData(0.25)] + public void Validate_RestToleranceZeroOrPositive_IsAccepted(double restTolerance) + { + (Valid with { RestTolerance = restTolerance }).Invoking(o => o.Validate()).Should().NotThrow(); + } + [Fact(DisplayName = "GA-U-41 the defaults are the protocol's and the design's")] public void Defaults_AreNamedSources() { @@ -56,6 +66,7 @@ public void Defaults_AreNamedSources() Valid.DefaultSpeed.Value.Should().Be(100f); Valid.Acceleration.Should().BeNull(); Valid.Tolerance.Should().Be(0.001); + Valid.RestTolerance.Should().Be(0.1, "bug-034 owner ruling 2026-10-19"); Valid.EnableTimeout.Should().Be(TimeSpan.FromSeconds(5)); Valid.StopTimeout.Should().Be(TimeSpan.FromSeconds(5)); Valid.HomingTimeout.Should().Be(TimeSpan.FromSeconds(120)); diff --git a/src/ModelingEvolution.GenericAxis/GenericAxisOptions.cs b/src/ModelingEvolution.GenericAxis/GenericAxisOptions.cs index 1623315..0818a3e 100644 --- a/src/ModelingEvolution.GenericAxis/GenericAxisOptions.cs +++ b/src/ModelingEvolution.GenericAxis/GenericAxisOptions.cs @@ -30,6 +30,13 @@ public sealed record GenericAxisOptions /// ADR-19: one register quantum. public const double DefaultTolerance = Words.Quantum; + /// + /// bug-034 owner ruling 2026-10-19: a capture may see the reported position change by up to 0.1 (mm or °) + /// between its two readings at standstill — a servo in position hold moves in its last digits. Matches the + /// SDK's ILinearAxis/IRotaryAxis.RestTolerance default. + /// + public const double DefaultRestTolerance = 0.1; + /// ADR-20: enable/disable and reset confirmation budget. public static readonly TimeSpan DefaultEnableTimeout = TimeSpan.FromSeconds(5); @@ -93,6 +100,14 @@ public sealed record GenericAxisOptions /// Reported in-position tolerance. The PLC's InPosition decides arrival. public double Tolerance { get; init; } = DefaultTolerance; + /// + /// How much the reported position may change between two readings taken at standstill before the host + /// refuses a capture, in the axis's unit; reported as the SDK's RestTolerance. A host-side reading + /// rule, not a register. ≥ 0; 0 = exact equality (a drive that holds a quantised register). + /// Default (bug-034 owner ruling 2026-10-19). + /// + public double RestTolerance { get; init; } = DefaultRestTolerance; + /// Budget for Enable/Disable and Reset to show their state. public TimeSpan EnableTimeout { get; init; } = DefaultEnableTimeout; @@ -162,6 +177,9 @@ public void Validate() throw new ArgumentException($"{Name}: Acceleration {a} must be > 0 when set", nameof(Acceleration)); if (Tolerance <= 0) throw new ArgumentException($"{Name}: Tolerance {Tolerance} must be > 0", nameof(Tolerance)); + if (!(RestTolerance >= 0)) + throw new ArgumentException( + $"{Name}: RestTolerance {RestTolerance} must be ≥ 0 (0 = exact equality)", nameof(RestTolerance)); if (EnableTimeout <= TimeSpan.Zero) throw new ArgumentException($"{Name}: EnableTimeout must be > 0", nameof(EnableTimeout)); if (StopTimeout <= TimeSpan.Zero) diff --git a/src/ModelingEvolution.GenericAxis/ModbusLinearAxis.cs b/src/ModelingEvolution.GenericAxis/ModbusLinearAxis.cs index d3c37ce..9eadb53 100644 --- a/src/ModelingEvolution.GenericAxis/ModbusLinearAxis.cs +++ b/src/ModelingEvolution.GenericAxis/ModbusLinearAxis.cs @@ -51,6 +51,9 @@ internal ModbusLinearAxis(AxisEngine engine) /// public Length> Tolerance => new(_engine.Options.Tolerance); + /// + public Length> RestTolerance => new(_engine.Options.RestTolerance); + /// public Speed> MinSpeed => new(Words.Quantum); diff --git a/src/ModelingEvolution.GenericAxis/ModbusRotaryAxis.cs b/src/ModelingEvolution.GenericAxis/ModbusRotaryAxis.cs index 3bad210..9e8bae5 100644 --- a/src/ModelingEvolution.GenericAxis/ModbusRotaryAxis.cs +++ b/src/ModelingEvolution.GenericAxis/ModbusRotaryAxis.cs @@ -52,6 +52,9 @@ internal ModbusRotaryAxis(AxisEngine engine) /// public Degree Tolerance => Degree.Create(_engine.Options.Tolerance); + /// + public Degree RestTolerance => Degree.Create(_engine.Options.RestTolerance); + /// public AngularSpeed> MinSpeed => new(Words.Quantum); diff --git a/src/ModelingEvolution.GenericAxis/ModelingEvolution.GenericAxis.csproj b/src/ModelingEvolution.GenericAxis/ModelingEvolution.GenericAxis.csproj index 291e897..dec084c 100644 --- a/src/ModelingEvolution.GenericAxis/ModelingEvolution.GenericAxis.csproj +++ b/src/ModelingEvolution.GenericAxis/ModelingEvolution.GenericAxis.csproj @@ -17,7 +17,7 @@ - + From 4683fad2d9daed90f8ac35e165081a7f515821ec Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Mon, 19 Oct 2026 19:16:25 +0000 Subject: [PATCH 2/2] fix: Tolerance and RestTolerance refuse a non-finite value; drop a duplicate binding test (bug-034 review) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfrwEnFVrCJNqiDqWFyb4 --- .../PropertyTableTests.cs | 8 -------- .../OptionsAndHygieneTests.cs | 2 ++ .../GenericAxisOptions.cs | 11 ++++++----- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/ModelingEvolution.GenericAxis.Plugin.Tests/PropertyTableTests.cs b/src/ModelingEvolution.GenericAxis.Plugin.Tests/PropertyTableTests.cs index a068b0c..7d71de3 100644 --- a/src/ModelingEvolution.GenericAxis.Plugin.Tests/PropertyTableTests.cs +++ b/src/ModelingEvolution.GenericAxis.Plugin.Tests/PropertyTableTests.cs @@ -75,14 +75,6 @@ public void An_Absent_Value_Keeps_The_Driver_Default() options.Should().Be(GenericAxisPlugin.CarriageTemplate with { Host = "10.0.0.5" }); } - [Fact] - public void RestTolerance_Absent_BindsTheDefaultPointOne() - { - var options = GenericAxisProperties.Bind(GenericAxisPlugin.CarriageTemplate, Stored([("Host", "h")])); - - options.RestTolerance.Should().Be(GenericAxisOptions.DefaultRestTolerance).And.Be(0.1); - } - [Theory] [InlineData("0", 0.0)] [InlineData("0.25", 0.25)] diff --git a/src/ModelingEvolution.GenericAxis.Tests/OptionsAndHygieneTests.cs b/src/ModelingEvolution.GenericAxis.Tests/OptionsAndHygieneTests.cs index 2abd563..dc7e517 100644 --- a/src/ModelingEvolution.GenericAxis.Tests/OptionsAndHygieneTests.cs +++ b/src/ModelingEvolution.GenericAxis.Tests/OptionsAndHygieneTests.cs @@ -28,8 +28,10 @@ public class OptionsAndHygieneTests { nameof(GenericAxisOptions.DefaultSpeed), o => o with { DefaultSpeed = new Percentage(0f) } }, { nameof(GenericAxisOptions.Acceleration), o => o with { Acceleration = 0 } }, { nameof(GenericAxisOptions.Tolerance), o => o with { Tolerance = 0 } }, + { nameof(GenericAxisOptions.Tolerance), o => o with { Tolerance = double.PositiveInfinity } }, { nameof(GenericAxisOptions.RestTolerance), o => o with { RestTolerance = -0.1 } }, { nameof(GenericAxisOptions.RestTolerance), o => o with { RestTolerance = double.NaN } }, + { nameof(GenericAxisOptions.RestTolerance), o => o with { RestTolerance = double.PositiveInfinity } }, { nameof(GenericAxisOptions.EnableTimeout), o => o with { EnableTimeout = TimeSpan.Zero } }, { nameof(GenericAxisOptions.StopTimeout), o => o with { StopTimeout = TimeSpan.Zero } }, { nameof(GenericAxisOptions.HomingTimeout), o => o with { HomingTimeout = TimeSpan.Zero } }, diff --git a/src/ModelingEvolution.GenericAxis/GenericAxisOptions.cs b/src/ModelingEvolution.GenericAxis/GenericAxisOptions.cs index 0818a3e..d62c9a8 100644 --- a/src/ModelingEvolution.GenericAxis/GenericAxisOptions.cs +++ b/src/ModelingEvolution.GenericAxis/GenericAxisOptions.cs @@ -103,7 +103,7 @@ public sealed record GenericAxisOptions /// /// How much the reported position may change between two readings taken at standstill before the host /// refuses a capture, in the axis's unit; reported as the SDK's RestTolerance. A host-side reading - /// rule, not a register. ≥ 0; 0 = exact equality (a drive that holds a quantised register). + /// rule, not a register. Finite and ≥ 0; 0 = exact equality (a drive that holds a quantised register). /// Default (bug-034 owner ruling 2026-10-19). /// public double RestTolerance { get; init; } = DefaultRestTolerance; @@ -175,11 +175,12 @@ public void Validate() throw new ArgumentException($"{Name}: DefaultSpeed must be above 0 %", nameof(DefaultSpeed)); if (Acceleration is { } a && a <= 0) throw new ArgumentException($"{Name}: Acceleration {a} must be > 0 when set", nameof(Acceleration)); - if (Tolerance <= 0) - throw new ArgumentException($"{Name}: Tolerance {Tolerance} must be > 0", nameof(Tolerance)); - if (!(RestTolerance >= 0)) + if (!double.IsFinite(Tolerance) || Tolerance <= 0) + throw new ArgumentException($"{Name}: Tolerance {Tolerance} must be a finite number > 0", nameof(Tolerance)); + if (!double.IsFinite(RestTolerance) || RestTolerance < 0) throw new ArgumentException( - $"{Name}: RestTolerance {RestTolerance} must be ≥ 0 (0 = exact equality)", nameof(RestTolerance)); + $"{Name}: RestTolerance {RestTolerance} must be a finite number ≥ 0 (0 = exact equality)", + nameof(RestTolerance)); if (EnableTimeout <= TimeSpan.Zero) throw new ArgumentException($"{Name}: EnableTimeout must be > 0", nameof(EnableTimeout)); if (StopTimeout <= TimeSpan.Zero)