fix(focas): scale positions from each cnc_rddynamic2 block; delete the fabricated cnc_getfigure

Every 0x0026 position block is self-describing: [i32 raw][u16 base][u16 exponent],
value = raw / base^exponent. The live 31i-B capture (ref-dynamic2-axis1.bin)
reports base 10 / exponent 4 on every block, so axis 1 is 280.1574 mm, not
2801574. The driver ignored the envelope and instead sent 'cnc_getfigure'
(0x00D3) -- a command that does not exist in the FOCAS wire protocol, served only
by the in-tree mock; the real control refuses it with rc=1, so on hardware every
position fell back to PositionDecimalPlaces (default 0) and published raw counts.

- ParsePositionBlock decodes the envelope into FocasPosition; the driver applies
  raw / base^exponent at publish. PositionDecimalPlaces is now only the fallback
  for a scale-less (4-byte) block.
- A block describing no position (0xFFFF vacant marker, base not 2/10, exponent
  > 18 -- raw / 10^400 would read as a Good 0.0) publishes BadNoData on that node
  alone; siblings are untouched.
- ReadPositionFiguresAsync / IFocasClient.GetPositionFiguresAsync and the
  per-axis figure cache are deleted.

Ported from the sister ignitionoee FOCAS driver v0.17.0 (+ Focas.Wire-014);
live axis-scoped captures live-dynamic2-axis{1,2}-0x0026.bin (Z-34184 X 0.0374,
Y 370.177) added as fixtures.
This commit is contained in:
Joseph Doherty
2026-10-07 14:03:08 -04:00
parent 9460da513c
commit 69e0a622f3
16 changed files with 380 additions and 356 deletions
@@ -153,13 +153,6 @@ internal class FakeFocasClient : IFocasClient
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
/// <summary>Gets or sets the per-axis position decimal-place figures returned by <see cref="GetPositionFiguresAsync"/>.</summary>
public IReadOnlyList<int> PositionFigures { get; set; } = [];
/// <summary>Gets the per-axis position decimal-place figures asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken ct) =>
Task.FromResult(PositionFigures);
/// <summary>Disposes the client.</summary>
public virtual void Dispose()
{
@@ -1,164 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Phase 4b — per-axis auto-scale from <c>cnc_getfigure</c> figures. The driver fetches
/// per-axis position decimal-place figures at init and applies them at the
/// <see cref="FocasDriver"/> publish seam. Precedence: an auto figure for an axis WINS;
/// the configured <c>PositionDecimalPlaces</c> is the per-axis fallback when the CNC did
/// not report a figure for that axis. These tests drive the Fake to exercise the driver's
/// auto-scale-vs-fallback logic independently of the wire path.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionAutoScaleTests
{
private const string Host = "focas://10.0.0.5:8193";
// A single-axis CNC (X only) so the snapshot we seed is unambiguous.
private static FakeFocasClient SingleAxisClient(
FocasDynamicSnapshot snap, IReadOnlyList<int> figures)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.DynamicByAxis[1] = snap; // FOCAS axis indexing is 1-based
c.PositionFigures = figures;
return c;
}
// A two-axis CNC (X, Y) — each axis gets its own seeded snapshot + figure.
private static FakeFocasClient TwoAxisClient(
FocasDynamicSnapshot xSnap, FocasDynamicSnapshot ySnap, IReadOnlyList<int> figures)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.AxisNames.Add(new("Y", ""));
c.DynamicByAxis[1] = xSnap; // axis 0 → 1-based index 1
c.DynamicByAxis[2] = ySnap; // axis 1 → 1-based index 2
c.PositionFigures = figures;
return c;
}
private static FocasDynamicSnapshot AbsSnap(int axisIndex, int absolutePosition) =>
new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0,
AbsolutePosition: absolutePosition, MachinePosition: 0,
RelativePosition: 0, DistanceToGo: 0);
private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClientFactory factory)
{
return new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(10),
},
}, "drv-1", factory);
}
// Drive the fixed-tree poll loop and wait (bounded) for the cached value to appear.
private static async Task<object?> ReadWhenPublishedAsync(FocasDriver drv, string reference)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline)
{
var snaps = await drv.ReadAsync([reference], CancellationToken.None);
var snap = snaps.Single();
if (snap.StatusCode == FocasStatusMapper.Good && snap.Value is not null)
return snap.Value;
await Task.Delay(15);
}
return null;
}
/// <summary>
/// An auto figure for an axis WINS over the configured manual value: figure 3 ⇒ ÷10^3
/// (12345 → 12.345), even though <c>PositionDecimalPlaces: 1</c> would have given ÷10.
/// </summary>
[Fact]
public async Task Auto_figure_wins_over_manual_config()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: [3]),
};
var drv = NewDriver(positionDecimalPlaces: 1, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(12.345); // ÷10^3 (auto), NOT 1234.5 (manual ÷10)
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// When <c>cnc_getfigure</c> reports nothing (empty list — the real wire backend's
/// behaviour), the driver falls back to the configured <c>PositionDecimalPlaces</c>:
/// manual 2 ⇒ ÷10^2 (12345 → 123.45).
/// </summary>
[Fact]
public async Task Falls_back_to_manual_when_getfigure_empty()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []),
};
var drv = NewDriver(positionDecimalPlaces: 2, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(123.45); // ÷10^2 (manual fallback)
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Figures are applied per-axis independently: figures [3, 1] ⇒ axis 0 ÷10^3 and
/// axis 1 ÷10^1.
/// </summary>
[Fact]
public async Task Per_axis_figures_scale_independently()
{
var factory = new FakeFocasClientFactory
{
Customise = () => TwoAxisClient(
AbsSnap(1, 12345), AbsSnap(2, 67890), figures: [3, 1]),
};
var drv = NewDriver(positionDecimalPlaces: 0, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var x = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
var y = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/Y/AbsolutePosition");
x.ShouldBe(12.345); // ÷10^3
y.ShouldBe(6789.0); // ÷10^1
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// The legacy manual-only path is unchanged: no auto figures + <c>PositionDecimalPlaces: 0</c>
/// ⇒ factor 1.0, so the raw integer is published widened to double (12345 → 12345.0).
/// </summary>
[Fact]
public async Task Manual_only_legacy_path_unchanged()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []),
};
var drv = NewDriver(positionDecimalPlaces: 0, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(12345.0); // factor 1.0 — raw widened to double
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,105 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Driver-level publish of self-describing <c>cnc_rddynamic2</c> positions. Each block's own
/// <c>base^exponent</c> wins over the configured <c>PositionDecimalPlaces</c> (which is now only
/// the fallback for a scale-less block), and a block describing no usable position Bads its own
/// node without disturbing its siblings. Replaces the <c>cnc_getfigure</c> (0x00D3) auto-scale
/// tests — that command is not a FOCAS wire command; the real 31i-B refuses it with <c>rc=1</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionBlockScaleTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDynamicSnapshot Snap(int axisIndex, FocasPosition abs, FocasPosition mach) =>
new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0,
AbsolutePosition: abs, MachinePosition: mach, RelativePosition: 0, DistanceToGo: 0);
private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClient client) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) },
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
private static async Task<DataValueSnapshot> ReadWhenPolledAsync(FocasDriver drv, string reference)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
DataValueSnapshot last;
do
{
// Until the first poll lands the reference resolves to nothing; once it has, the node
// reads either Good or (for an undescribable block) BadNoData.
last = (await drv.ReadAsync([reference], CancellationToken.None)).Single();
if (last.StatusCode is FocasStatusMapper.Good or FocasStatusMapper.BadNoData) return last;
await Task.Delay(15);
} while (DateTime.UtcNow < deadline);
return last;
}
private static FakeFocasClient XAxisClient(FocasDynamicSnapshot snap)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.DynamicByAxis[1] = snap;
return c;
}
[Fact]
public async Task The_blocks_own_scale_wins_over_PositionDecimalPlaces()
{
// The live 31i-B shape: raw 2801574, base 10, exponent 4 → 280.1574 mm.
var client = XAxisClient(Snap(1, new FocasPosition(2_801_574, 10, 4, HasScale: true), 0));
var drv = NewDriver(positionDecimalPlaces: 1, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.StatusCode.ShouldBe(FocasStatusMapper.Good);
((double)abs.Value!).ShouldBe(280.1574, 1e-9); // NOT 280157.4 (the ÷10 fallback)
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_scale_less_block_falls_back_to_PositionDecimalPlaces()
{
var client = XAxisClient(Snap(1, 12345, 0));
var drv = NewDriver(positionDecimalPlaces: 2, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.StatusCode.ShouldBe(FocasStatusMapper.Good);
abs.Value.ShouldBe(123.45);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_vacant_block_bads_its_own_node_and_not_its_sibling()
{
var client = XAxisClient(Snap(
1,
abs: new FocasPosition(0, 10, 0xFFFF, HasScale: true), // vacant-slot marker
mach: new FocasPosition(-22000, 10, 3, HasScale: true)));
var drv = NewDriver(positionDecimalPlaces: 0, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var mach = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/MachinePosition");
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
mach.StatusCode.ShouldBe(FocasStatusMapper.Good);
mach.Value.ShouldBe(-22.0);
abs.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
abs.Value.ShouldBeNull("an undescribable block is not a Good 0.0 at the origin");
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,107 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// <c>cnc_rddynamic2</c> position blocks (0x0026) are self-describing: <c>[0..3] i32 raw</c>,
/// <c>[4..5] u16 base</c> (2 or 10), <c>[6..7] u16 exponent</c> (<c>0xFFFF</c> = vacant slot);
/// <c>value = raw / base^exponent</c>. Ported from the sister ignitionoee FOCAS driver v0.17.0
/// (which also deleted the fabricated <c>cnc_getfigure</c> 0x00D3 — the real 31i-B refuses it
/// with <c>rc=1</c>) and Focas.Wire-014 (absurd exponents are refused, not divided by).
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionDecodeTests
{
private static byte[] Block(int raw, int @base, int exponent)
{
var b = new byte[8];
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), raw);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent);
return b;
}
[Fact]
public void The_live_dynamic2_capture_scales_every_position_from_its_own_block()
{
// ref-dynamic2-axis1.bin: four 0x0026 blocks (absolute / machine / relative / distance of
// axis 1), every one base 10, exponent 4. Undecoded they read 2801574 / 2801200 / 32 / 0.
var dynamic = FocasWireClient.ParseDynamic2(WireFixtures.Blocks("ref-dynamic2-axis1.bin"));
dynamic.Axis.Absolute.ToEngineering(0)!.Value.ShouldBe(280.1574, 1e-9);
dynamic.Axis.Machine.ToEngineering(0)!.Value.ShouldBe(280.1200, 1e-9);
dynamic.Axis.Relative.ToEngineering(0)!.Value.ShouldBe(0.0032, 1e-9);
dynamic.Axis.Distance.ToEngineering(0)!.Value.ShouldBe(0.0, 1e-9);
dynamic.FeedRate.ShouldBe(0);
dynamic.SpindleSpeed.ShouldBe(0);
}
[Fact]
public void The_block_scale_wins_over_the_configured_decimal_places()
{
var p = FocasWireClient.ParsePositionBlock(Block(123456, 10, 3));
p.ToEngineering(fallbackDecimalPlaces: 1)!.Value.ShouldBe(123.456, 1e-9);
}
[Theory]
[InlineData("live-dynamic2-axis1-0x0026.bin", 0.0374)] // Z-34184 X
[InlineData("live-dynamic2-axis2-0x0026.bin", 370.177)] // Z-34184 Y
public void Axis_scoped_live_requests_return_that_axis_scaled(string fixture, double expected)
{
var blocks = WireFixtures.Blocks(fixture).Where(b => b.Command == 0x0026).ToList();
blocks.Count.ShouldBe(1, "an axis-scoped request returns exactly one 0x0026 block");
FocasWireClient.ParsePositionBlock(blocks[0].Payload).ToEngineering(0)!.Value.ShouldBe(expected, 1e-9);
}
[Theory]
[InlineData(40, 2, 3, 5.0)] // inch-mode binary base
[InlineData(-22000, 10, 3, -22.0)]
[InlineData(42, 10, 0, 42.0)]
public void Positions_scale_by_base_to_the_exponent(int raw, int @base, int exponent, double expected)
{
FocasWireClient.ParsePositionBlock(Block(raw, @base, exponent)).ToEngineering(0)!.Value.ShouldBe(expected, 1e-9);
}
[Theory]
[InlineData(1, 10, 0xFFFF)] // vacant-slot marker
[InlineData(1234, 7, 2)] // base neither 2 nor 10
[InlineData(1234, 0, 2)]
[InlineData(123456, 10, 400)] // would divide by Infinity → a Good 0.0 at the origin
[InlineData(123456, 10, 40)]
public void An_undescribable_block_has_no_value(int raw, int @base, int exponent)
{
var p = FocasWireClient.ParsePositionBlock(Block(raw, @base, exponent));
p.IsValid.ShouldBeFalse();
p.ToEngineering(3).ShouldBeNull();
}
[Fact]
public void Every_plausible_exponent_still_decodes()
{
for (var exp = 0; exp <= 18; exp++)
FocasWireClient.ParsePositionBlock(Block(1, 10, exp)).IsValid.ShouldBeTrue($"exponent {exp}");
}
[Fact]
public void A_block_without_a_scale_falls_back_to_the_configured_decimal_places()
{
// A 4-byte block carries no envelope (older simulators); only then does
// PositionDecimalPlaces apply.
var p = FocasWireClient.ParsePositionBlock([0x00, 0x00, 0x30, 0x39]); // 12345
p.HasScale.ShouldBeFalse();
p.ToEngineering(2)!.Value.ShouldBe(123.45, 1e-9);
p.ToEngineering(0)!.Value.ShouldBe(12345.0);
}
[Fact]
public void A_raw_integer_position_converts_implicitly_as_a_scale_less_value()
{
FocasPosition p = 12345;
p.HasScale.ShouldBeFalse();
p.Raw.ShouldBe(12345);
}
}
@@ -5,12 +5,11 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Phase 4 data-type tier — axis-position scaling. <c>cnc_rddynamic2</c> returns
/// positions as scaled integers; the driver applies a <c>10^PositionDecimalPlaces</c>
/// divide at the <see cref="FocasDriver"/> publish seam so positions surface in
/// engineering units on the Float64 axis nodes. The managed wire backend fetches per-axis
/// figures live via <c>cnc_getfigure</c>; the config knob is the per-axis fallback used
/// when the CNC reports no figure for an axis. These tests cover the config-supplied path.
/// Phase 4 data-type tier — axis-position scaling, config-supplied fallback path. A real
/// CNC's <c>cnc_rddynamic2</c> position block carries its own <c>base^exponent</c> scale
/// (covered by <see cref="FocasPositionBlockScaleTests"/>); a scale-less position — the
/// raw integers these tests seed — is divided by <c>10^PositionDecimalPlaces</c> at the
/// <see cref="FocasDriver"/> publish seam.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionScalingTests
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// Offline byte-level coverage for the managed FOCAS/2 wire-protocol decode layer
/// (<see cref="FocasWireProtocol"/>) — the big-endian framing + block-envelope decode that
/// is this driver's analogue of P/Invoke struct marshalling. Before this suite the entire
/// wire decode surface (including the <c>cnc_getfigure</c> figure stride) ran without a
/// wire decode surface ran without a
/// single byte-level test; every other test drives the <c>IFocasClient</c> seam through the
/// Fake, which bypasses all framing. Recorded as Driver.FOCAS-013.
/// </summary>
@@ -200,29 +200,6 @@ public sealed class FocasWireProtocolTests
Should.Throw<FocasWireException>(() => FocasWireProtocol.ParseResponseBlocks(body));
}
// ---- The cnc_getfigure figure stride: decode a response block into shorts ----
[Fact]
public void ParseResponseBlocks_yields_a_payload_that_decodes_as_a_sequence_of_figure_shorts()
{
// cnc_getfigure (command 0x00d3) returns one big-endian short per axis. Build the
// response block and assert the payload decodes to the [3, 1, 0] figure sequence the
// driver's auto-scale path reads.
var figurePayload = new byte[6];
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(0, 2), 3);
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(2, 2), 1);
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(4, 2), 0);
var body = BuildResponseBody((command: (ushort)0x00d3, rc: (short)0, payload: figurePayload));
var block = FocasWireProtocol.ParseResponseBlocks(body).Single();
var figures = new List<int>();
for (var offset = 0; offset + 2 <= block.Payload.Length; offset += 2)
figures.Add(BinaryPrimitives.ReadInt16BigEndian(block.Payload.AsSpan(offset, 2)));
figures.ShouldBe(new[] { 3, 1, 0 });
}
// ---- ReadAscii / ReadNameRecord ----
[Fact]