Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionDecodeTests.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

108 lines
4.5 KiB
C#

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);
}
}