feat(focas): per-axis auto-scale from cnc_getfigure figures (manual config = fallback)
This commit is contained in:
@@ -655,6 +655,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
|
||||
var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Per-axis decimal-place figures (cnc_getfigure), fetched once. Auto figures win
|
||||
// over the manual PositionDecimalPlaces config at the publish seam; an empty list
|
||||
// (the managed wire backend today) makes every axis fall back to the config knob.
|
||||
// Defensive: a figure-read failure must NOT fault device init — default to empty.
|
||||
state.PositionFigures = await SafeProbe(() => client.GetPositionFiguresAsync(ct), []);
|
||||
|
||||
// Optional-API probes — each returns empty / throws when unsupported.
|
||||
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
|
||||
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
|
||||
@@ -718,7 +724,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var axisIndex = i + 1; // FOCAS uses 1-based axis indexing
|
||||
var axis = cache.Axes[i];
|
||||
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
|
||||
PublishAxisSnapshot(state, axis, snap);
|
||||
PublishAxisSnapshot(state, axis, snap, i); // i = 0-based axis index → PositionFigures lookup
|
||||
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
|
||||
}
|
||||
|
||||
@@ -816,24 +822,41 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
|
||||
/// <see cref="TryReadFixedTree"/> and returns these cached values.
|
||||
/// </summary>
|
||||
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap)
|
||||
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap, int axisIndex)
|
||||
{
|
||||
var host = state.Options.HostAddress;
|
||||
// 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;
|
||||
// cnc_rddynamic2 returns positions as scaled integers; divide by 10^figures so they
|
||||
// surface in engineering units on the Float64 axis nodes. The figure is per-axis:
|
||||
// an auto cnc_getfigure figure WINS, and the configured PositionDecimalPlaces is the
|
||||
// fallback when the CNC didn't report one for that axis (see AxisFactor). A figure of
|
||||
// 0 yields factor 1.0 — i.e. the integer widened to double, byte-identical to legacy
|
||||
// behaviour (12345 / 1.0 == 12345.0). CAVEAT: the managed WireFocasClient returns no
|
||||
// figures today, so the REAL backend always uses the manual fallback; live auto-fetch
|
||||
// lands when a FocasWireClient cnc_getfigure wire command is added. FeedRate /
|
||||
// SpindleSpeed (rate snapshot) and ServoLoad are NOT position-scaled (published elsewhere).
|
||||
var factor = AxisFactor(state, axisIndex);
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the position-scale factor (10^figure) for a single axis. Auto
|
||||
/// (<c>cnc_getfigure</c>) wins per-axis; manual <c>PositionDecimalPlaces</c> is the
|
||||
/// fallback when the CNC didn't report a figure for that axis. Both clamp non-negative;
|
||||
/// 0 ⇒ factor 1.0 (legacy byte-identical). The managed wire backend returns no figures
|
||||
/// today, so the real backend always takes the manual-fallback branch.
|
||||
/// </summary>
|
||||
private static double AxisFactor(DeviceState state, int axisIndex)
|
||||
{
|
||||
var figures = state.PositionFigures;
|
||||
var dp = axisIndex >= 0 && axisIndex < figures.Count && figures[axisIndex] >= 0
|
||||
? figures[axisIndex]
|
||||
: Math.Max(0, state.Options.PositionDecimalPlaces);
|
||||
return dp > 0 ? Math.Pow(10, dp) : 1.0;
|
||||
}
|
||||
|
||||
private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
|
||||
{
|
||||
var host = state.Options.HostAddress;
|
||||
@@ -1176,6 +1199,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public Dictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Gets the last spindle load percentages by spindle index.</summary>
|
||||
public Dictionary<int, int> LastSpindleLoads { get; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the per-axis position decimal-place figures fetched once at init via
|
||||
/// <c>cnc_getfigure</c> (parallel to the axis-name list; index = axis). An auto figure
|
||||
/// for an axis WINS over the configured <c>PositionDecimalPlaces</c>; an empty list (the
|
||||
/// managed wire backend's behaviour today) makes every axis fall back to that config knob.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> PositionFigures { get; set; } = [];
|
||||
|
||||
/// <summary>Disposes the FOCAS client instance.</summary>
|
||||
public void DisposeClient()
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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. (The managed wire backend returns no figures today,
|
||||
/// so the real backend always uses the manual fallback — these tests drive the Fake.)
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user