fix(focas): decode servo/spindle meters by their base/exponent envelope; MaxRpm -> MotorSpeedPct

Servo (0x0056) and spindle (0x0040) meter records are 8 bytes:
[i32 value][u16 base][u16 exponent], the same self-describing envelope as the
position blocks.

Servo: offset 4 was read as a decimal count. It is the BASE (a steady 10), so
every load was divided by 10^10 and published ~0 -- the sister ignitionoee panel
read servo 0% on every axis of a Makino cutting at 20,000 rpm. Loads are now
value / base^exponent, a direct percentage.

Spindle: the raw meter is a 1/16384 fraction of full scale. Settled on the real
Z-34184 by regressing the type-1 meter against cnc_rddynamic2 spindle speed over
596..20,000 rpm (rpm = 1.220749 x meter; 16384 x 1.220749 = 20000.75). So:
- Spindle/{name}/Load publishes % of rated load (Float64; was the raw Int32
  count -- a reading of 9155 meant 55.9%).
- Spindle/{name}/MaxRpm is RENAMED Spindle/{name}/MotorSpeedPct (Float64, % of
  maximum speed) and is polled with Load. The old node was actively wrong: the
  type-1 meter is the live motor speed, and it was read once at bootstrap and
  served forever as if it were a max-RPM constant. Client-visible: FIXED: tags
  on .../MaxRpm must be re-pointed.
- The spindle read no longer stops at the first zero after slot 1 (which dropped
  an idle second spindle and kept non-zero residue); every slot is returned.

A record whose envelope is undescribable (base not 2/10, exponent > 18 -- e.g.
residue past the configured axis/spindle count) publishes BadNoData, never a Good 0.

Ported from the sister ignitionoee FOCAS driver v0.17.2/v0.17.3 (Focas.Wire-019);
live captures live-spmeter-{load,maxrpm}-0x0040.bin added as fixtures.
This commit is contained in:
Joseph Doherty
2026-10-07 14:07:22 -04:00
parent 69e0a622f3
commit 1dba46bd7b
13 changed files with 518 additions and 123 deletions
@@ -140,9 +140,10 @@ public interface IFocasClient : IDisposable
// ---- Fixed-tree T3.5 (servo meters) ----
/// <summary>
/// Read the servo-load meter percentages across all configured axes.
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
/// disconnected session or unsupported CNC.
/// Read the servo-load meter percentages across all configured axes. Each record's own
/// <c>value / base^exponent</c> envelope is applied (servo loads are direct percentages);
/// a record whose envelope describes no value carries a null <see cref="FocasServoLoad.LoadPercent"/>.
/// Empty list on a disconnected session or unsupported CNC.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The servo-load meter percentages across all configured axes.</returns>
@@ -151,26 +152,31 @@ public interface IFocasClient : IDisposable
// ---- Fixed-tree T3.6 (spindle meters) ----
/// <summary>
/// Read per-spindle load percentages. Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>. Empty list on a
/// disconnected session or when the CNC doesn't support the call (older
/// series like 16i may return EW_FUNC).
/// Read per-spindle load as a percentage of rated load (<c>cnc_rdspmeter</c> type 0; the
/// raw meter is a 1/16384 fraction of full scale). Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>; a slot whose record describes no
/// value is null. Empty list on a disconnected session or when the CNC doesn't support the
/// call (older series like 16i may return EW_FUNC).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-spindle load percentages, indexed as per <see cref="GetSpindleNamesAsync"/>.</returns>
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
/// Read per-spindle motor speed as a percentage of maximum speed (<c>cnc_rdspmeter</c>
/// type 1, historically labelled <c>cnc_rdspmaxrpm</c>). A live value, not a max-RPM
/// constant. Index alignment and null semantics as per <see cref="GetSpindleLoadsAsync"/>.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-spindle maximum RPM values, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
/// <returns>Per-spindle motor-speed percentages, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
}
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
/// <summary>
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
/// described no usable value (never a fabricated 0).
/// </summary>
public sealed record FocasServoLoad(string AxisName, double? LoadPercent);
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
public enum FocasTimerKind
@@ -297,19 +303,16 @@ public sealed record FocasDynamicSnapshot(
/// </param>
public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false)
{
/// <summary>
/// Largest exponent that can describe a real coordinate. An i32 carries at most ten digits,
/// so anything beyond is a misread block — and left unbounded, <c>raw / 10^400</c> is
/// <c>raw / Infinity = 0.0</c>, a Good reading of a machine parked at the origin.
/// </summary>
public const int MaxExponent = 18;
/// <summary>
/// False when the block says it holds no usable position: the <c>0xFFFF</c> vacant marker, a
/// base other than 2 or 10, or an absurd exponent. A per-node quality decision — that node
/// publishes Bad, its siblings are unaffected.
/// </summary>
public bool IsValid => !HasScale || (Exponent <= MaxExponent && Base is 2 or 10);
/// <remarks>
/// The exponent bound matters: left unbounded, <c>raw / 10^400</c> is
/// <c>raw / Infinity = 0.0</c> — a Good reading of a machine parked at the origin.
/// </remarks>
public bool IsValid => !HasScale || Wire.FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>
/// The position in engineering units: <c>Raw / Base^Exponent</c> when the block carried its
@@ -321,7 +324,7 @@ public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent
public double? ToEngineering(int fallbackDecimalPlaces)
{
if (!IsValid) return null;
if (HasScale) return Exponent == 0 ? Raw : Raw / Math.Pow(Base, Exponent);
if (HasScale) return Wire.FocasValueEnvelope.Scale(Raw, Base, Exponent);
var dp = Math.Max(0, fallbackDecimalPlaces);
return dp > 0 ? Raw / Math.Pow(10, dp) : Raw;
}