Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs
T
Joseph Doherty 69e0a622f3 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.
2026-10-07 14:03:08 -04:00

135 lines
5.7 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// 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
{
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)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.DynamicByAxis[1] = snap; // FOCAS axis indexing is 1-based
return c;
}
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>
/// With <c>PositionDecimalPlaces: 3</c> a raw AbsolutePosition of 12345 scales to
/// 12.345 on the Float64 node (engineering units).
/// </summary>
[Fact]
public async Task PositionDecimalPlaces_3_scales_AbsolutePosition_by_thousand()
{
var snap = new FocasDynamicSnapshot(
AxisIndex: 1, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 250, ActualSpindleSpeed: 0,
AbsolutePosition: 12345, MachinePosition: 67890,
RelativePosition: 11111, DistanceToGo: 500);
var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(snap) };
var drv = NewDriver(positionDecimalPlaces: 3, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
var mach = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/MachinePosition");
var rel = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/RelativePosition");
var dtg = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/DistanceToGo");
abs.ShouldBe(12.345);
mach.ShouldBe(67.890);
rel.ShouldBe(11.111);
dtg.ShouldBe(0.500);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// <c>PositionDecimalPlaces: 0</c> (the default) must be byte-identical to today's
/// behaviour: the integer is published unchanged, widened to double on the Float64
/// node (12345 → 12345.0).
/// </summary>
[Fact]
public async Task PositionDecimalPlaces_0_leaves_position_unscaled()
{
var snap = new FocasDynamicSnapshot(
AxisIndex: 1, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 250, ActualSpindleSpeed: 0,
AbsolutePosition: 12345, MachinePosition: 0,
RelativePosition: 0, DistanceToGo: 0);
var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(snap) };
var drv = NewDriver(positionDecimalPlaces: 0, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(12345.0);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// FeedRate (and other non-position fields) is NOT position-scaled — it surfaces
/// unchanged even when PositionDecimalPlaces is set.
/// </summary>
[Fact]
public async Task FeedRate_is_not_position_scaled()
{
var snap = new FocasDynamicSnapshot(
AxisIndex: 1, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 250, ActualSpindleSpeed: 0,
AbsolutePosition: 12345, MachinePosition: 0,
RelativePosition: 0, DistanceToGo: 0);
var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(snap) };
var drv = NewDriver(positionDecimalPlaces: 3, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Sanity: the position IS scaled on this same device.
(await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition")).ShouldBe(12.345);
// FeedRate must remain the raw 250, not 0.250.
var feed = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/FeedRate/Actual");
feed.ShouldBe(250.0);
await drv.ShutdownAsync(CancellationToken.None);
}
}