feat(focas): scale axis positions by 10^PositionDecimalPlaces (config-supplied)
This commit is contained in:
@@ -109,10 +109,27 @@ public sealed class FocasAlarmProjectionOptions
|
|||||||
/// address validation at <c>FocasDriver.InitializeAsync</c>; leave as
|
/// address validation at <c>FocasDriver.InitializeAsync</c>; leave as
|
||||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="PositionDecimalPlaces">
|
||||||
|
/// Axis positions returned by <c>cnc_rddynamic2</c> are scaled integers. The driver
|
||||||
|
/// divides AbsolutePosition / MachinePosition / RelativePosition / DistanceToGo by
|
||||||
|
/// <c>10^PositionDecimalPlaces</c> at the publish seam so they surface in engineering
|
||||||
|
/// units on the Float64 axis nodes. Default <c>0</c> (no scaling) is byte-identical to
|
||||||
|
/// legacy behaviour. Auto-fetching this via <c>cnc_getfigure</c> is deferred (wire-gated),
|
||||||
|
/// so it is config-supplied. Negative values are clamped to 0 (no scaling).
|
||||||
|
/// </param>
|
||||||
public sealed record FocasDeviceOptions(
|
public sealed record FocasDeviceOptions(
|
||||||
string HostAddress,
|
string HostAddress,
|
||||||
string? DeviceName = null,
|
string? DeviceName = null,
|
||||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||||
|
int PositionDecimalPlaces = 0)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Axis-position decimal places, clamped to a non-negative value so the
|
||||||
|
/// <c>10^PositionDecimalPlaces</c> divide at the publish seam can never misbehave.
|
||||||
|
/// </summary>
|
||||||
|
public int PositionDecimalPlaces { get; init; } =
|
||||||
|
PositionDecimalPlaces < 0 ? 0 : PositionDecimalPlaces;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||||
|
|||||||
@@ -819,10 +819,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap)
|
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap)
|
||||||
{
|
{
|
||||||
var host = state.Options.HostAddress;
|
var host = state.Options.HostAddress;
|
||||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition;
|
// cnc_rddynamic2 returns positions as scaled integers; divide by
|
||||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition;
|
// 10^PositionDecimalPlaces so they surface in engineering units on the Float64
|
||||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition;
|
// axis nodes. PositionDecimalPlaces is clamped non-negative at config parse, and a
|
||||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo;
|
// 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)
|
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 (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
|
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"
|
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
|
||||||
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
|
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; }
|
public CancellationTokenSource? FixedTreeCts { get; set; }
|
||||||
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
|
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
|
||||||
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
||||||
/// <summary>Gets the last fixed tree snapshots by field name.</summary>
|
/// <summary>
|
||||||
public Dictionary<string, int> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
|
||||||
|
/// positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
|
||||||
|
/// scale applied at <see cref="PublishAxisSnapshot"/>; integer fields
|
||||||
|
/// (feed rate, spindle speed) widen to double on store.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
/// <summary>Gets or sets the last program information snapshot.</summary>
|
/// <summary>Gets or sets the last program information snapshot.</summary>
|
||||||
public FocasProgramInfo? LastProgramInfo { get; set; }
|
public FocasProgramInfo? LastProgramInfo { get; set; }
|
||||||
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ public static class FocasDriverFactoryExtensions
|
|||||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||||
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||||
DeviceName: d.DeviceName,
|
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 }
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
||||||
@@ -234,6 +235,13 @@ public static class FocasDriverFactoryExtensions
|
|||||||
|
|
||||||
/// <summary>Gets or sets the CNC series for this device (overrides top-level series if provided).</summary>
|
/// <summary>Gets or sets the CNC series for this device (overrides top-level series if provided).</summary>
|
||||||
public string? Series { get; init; }
|
public string? Series { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the axis-position decimal places. <c>cnc_rddynamic2</c> returns
|
||||||
|
/// positions as scaled integers; the driver divides by <c>10^PositionDecimalPlaces</c>
|
||||||
|
/// so they surface in engineering units. Omitted / <c>0</c> = no scaling (legacy).
|
||||||
|
/// </summary>
|
||||||
|
public int? PositionDecimalPlaces { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class FocasTagDto
|
internal sealed class FocasTagDto
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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. <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. DecimalPlaces is config-supplied
|
||||||
|
/// (auto-fetch via <c>cnc_getfigure</c> is deferred — wire-gated).
|
||||||
|
/// </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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user