feat(focas): scale axis positions by 10^PositionDecimalPlaces (config-supplied)

This commit is contained in:
Joseph Doherty
2026-06-16 05:32:36 -04:00
parent fcb3801415
commit 4973075291
4 changed files with 182 additions and 9 deletions
@@ -109,10 +109,27 @@ public sealed class FocasAlarmProjectionOptions
/// address validation at <c>FocasDriver.InitializeAsync</c>; leave as
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
/// </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(
string HostAddress,
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>
/// 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)
{
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; }
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
public FocasFixedTreeCache? FixedTreeCache { get; set; }
/// <summary>Gets the last fixed tree snapshots by field name.</summary>
public Dictionary<string, int> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 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>
public FocasProgramInfo? LastProgramInfo { get; set; }
/// <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(
$"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
/// <summary>Gets or sets the CNC series for this device (overrides top-level series if provided).</summary>
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