diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs index b6537530..c133937b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs @@ -109,10 +109,27 @@ public sealed class FocasAlarmProjectionOptions /// address validation at FocasDriver.InitializeAsync; leave as /// to skip validation (legacy behaviour). /// +/// +/// Axis positions returned by cnc_rddynamic2 are scaled integers. The driver +/// divides AbsolutePosition / MachinePosition / RelativePosition / DistanceToGo by +/// 10^PositionDecimalPlaces at the publish seam so they surface in engineering +/// units on the Float64 axis nodes. Default 0 (no scaling) is byte-identical to +/// legacy behaviour. Auto-fetching this via cnc_getfigure is deferred (wire-gated), +/// so it is config-supplied. Negative values are clamped to 0 (no scaling). +/// public sealed record FocasDeviceOptions( string HostAddress, string? DeviceName = null, - FocasCncSeries Series = FocasCncSeries.Unknown); + FocasCncSeries Series = FocasCncSeries.Unknown, + int PositionDecimalPlaces = 0) +{ + /// + /// Axis-position decimal places, clamped to a non-negative value so the + /// 10^PositionDecimalPlaces divide at the publish seam can never misbehave. + /// + public int PositionDecimalPlaces { get; init; } = + PositionDecimalPlaces < 0 ? 0 : PositionDecimalPlaces; +} /// /// One FOCAS-backed OPC UA variable. is the canonical FOCAS diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 7273c002..7aeda5ea 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -819,10 +819,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap) { var host = state.Options.HostAddress; - state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition; - state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition; - state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition; - state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo; + // cnc_rddynamic2 returns positions as scaled integers; divide by + // 10^PositionDecimalPlaces so they surface in engineering units on the Float64 + // axis nodes. PositionDecimalPlaces is clamped non-negative at config parse, and a + // value of 0 yields factor 1.0 — i.e. the integer widened to double, byte-identical + // to legacy behaviour (12345 / 1.0 == 12345.0). FeedRate / SpindleSpeed (rate + // snapshot) and ServoLoad are NOT position-scaled and are published elsewhere. + var factor = state.Options.PositionDecimalPlaces > 0 + ? Math.Pow(10, state.Options.PositionDecimalPlaces) + : 1.0; + state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition / factor; + state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition / factor; + state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition / factor; + state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo / factor; } private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap) @@ -879,7 +888,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, { if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue; if (state.LastFixedSnapshots.TryGetValue(reference, out var raw)) - return new DataValueSnapshot((double)raw, FocasStatusMapper.Good, now, now); + return new DataValueSnapshot(raw, FocasStatusMapper.Good, now, now); // Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad" var suffixFull = reference[(state.Options.HostAddress.Length + 1)..]; @@ -1150,8 +1159,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public CancellationTokenSource? FixedTreeCts { get; set; } /// Gets or sets the fixed-tree cache for this device. public FocasFixedTreeCache? FixedTreeCache { get; set; } - /// Gets the last fixed tree snapshots by field name. - public Dictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// Gets the last fixed-tree snapshots by field name. Double-typed so axis + /// positions can carry the 10^PositionDecimalPlaces engineering-unit + /// scale applied at ; integer fields + /// (feed rate, spindle speed) widen to double on store. + /// + public Dictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase); /// Gets or sets the last program information snapshot. public FocasProgramInfo? LastProgramInfo { get; set; } /// Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs index 13a033fa..0ccf9cb4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs @@ -65,7 +65,8 @@ public static class FocasDriverFactoryExtensions HostAddress: d.HostAddress ?? throw new InvalidOperationException( $"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"), DeviceName: d.DeviceName, - Series: ParseSeries(d.Series ?? dto.Series)))] + Series: ParseSeries(d.Series ?? dto.Series), + PositionDecimalPlaces: d.PositionDecimalPlaces ?? 0))] : [], Tags = dto.Tags is { Count: > 0 } ? [.. dto.Tags.Select(t => new FocasTagDefinition( @@ -234,6 +235,13 @@ public static class FocasDriverFactoryExtensions /// Gets or sets the CNC series for this device (overrides top-level series if provided). public string? Series { get; init; } + + /// + /// Gets or sets the axis-position decimal places. cnc_rddynamic2 returns + /// positions as scaled integers; the driver divides by 10^PositionDecimalPlaces + /// so they surface in engineering units. Omitted / 0 = no scaling (legacy). + /// + public int? PositionDecimalPlaces { get; init; } } internal sealed class FocasTagDto diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs new file mode 100644 index 00000000..ebc818ae --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionScalingTests.cs @@ -0,0 +1,134 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Phase 4 data-type tier — axis-position scaling. cnc_rddynamic2 returns +/// positions as scaled integers; the driver applies a 10^PositionDecimalPlaces +/// divide at the publish seam so positions surface in +/// engineering units on the Float64 axis nodes. DecimalPlaces is config-supplied +/// (auto-fetch via cnc_getfigure is deferred — wire-gated). +/// +[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 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; + } + + /// + /// With PositionDecimalPlaces: 3 a raw AbsolutePosition of 12345 scales to + /// 12.345 on the Float64 node (engineering units). + /// + [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); + } + + /// + /// PositionDecimalPlaces: 0 (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). + /// + [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); + } + + /// + /// FeedRate (and other non-position fields) is NOT position-scaled — it surfaces + /// unchanged even when PositionDecimalPlaces is set. + /// + [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); + } +}