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
@@ -59,18 +59,104 @@ public sealed record WireDynamic(
int SpindleSpeed,
WireAxisPosition Axis);
/// <summary>One servo-meter entry from <c>cnc_rdsvmeter</c> — per-axis load percentage (scale by 10^<see cref="Decimal"/>).</summary>
/// <summary>
/// The self-describing <c>(value, base, exponent)</c> envelope shared by every value record on
/// the FOCAS wire — <c>cnc_rddynamic2</c> positions, servo meters, spindle meters:
/// <c>scaled = value / base^exponent</c>, where only base 2 and base 10 are meaningful.
/// </summary>
internal static class FocasValueEnvelope
{
/// <summary>
/// Largest exponent that can describe a real value. An i32 carries at most ten digits, so
/// anything beyond is a misread record — and <c>value / 10^400</c> would be
/// <c>value / Infinity = 0.0</c>, a plausible Good zero.
/// </summary>
public const int MaxExponent = 18;
/// <summary>
/// True when the record describes a usable value. A fixed multi-slot buffer's slots past the
/// configured axis/spindle count hold unrelated memory, which shows up exactly here: a base
/// that is neither 2 nor 10, or an absurd exponent (incl. the <c>0xFFFF</c> vacant marker).
/// </summary>
/// <param name="base">The record's base field.</param>
/// <param name="exponent">The record's exponent field.</param>
/// <returns>Whether the record may be published.</returns>
public static bool IsValid(int @base, int exponent) => exponent <= MaxExponent && @base is 2 or 10;
/// <summary><c>value / base^exponent</c>, or null when <see cref="IsValid"/> is false.</summary>
/// <param name="value">The raw value.</param>
/// <param name="base">The record's base field.</param>
/// <param name="exponent">The record's exponent field.</param>
/// <returns>The scaled value, or null.</returns>
public static double? Scale(int value, int @base, int exponent) =>
!IsValid(@base, exponent) ? null : exponent == 0 ? value : value / Math.Pow(@base, exponent);
}
/// <summary>
/// One servo-meter entry from <c>cnc_rdsvmeter</c> (0x0056): one axis's load. The 8-byte record
/// is <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c> and the load
/// is a <b>direct</b> percentage, <see cref="Scaled"/> = <c>value / base^exponent</c>.
/// </summary>
/// <remarks>
/// Offset 4 used to be read as a decimal count ("data / 10^dec"). It is the <b>base</b> — a
/// steady 10 — so every load was divided by 10^10 and published ~0 (a real Makino cutting at
/// 20,000 rpm read servo 0% on every axis). Found by the sister ignitionoee driver,
/// Focas.Wire-019 (v0.17.2).
/// </remarks>
/// <param name="Index">1-based axis slot.</param>
/// <param name="Name">Axis name from the companion 0x0089 block (empty when none).</param>
/// <param name="Value">Raw load value.</param>
/// <param name="Base">Scale base (2 or 10 when meaningful).</param>
/// <param name="Exponent">Scale exponent.</param>
public sealed record WireServoMeter(
short Index,
string Name,
int Value,
short Decimal,
short Unit);
int Base,
int Exponent)
{
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>One spindle metric slot from <c>cnc_rdspload</c> / <c>cnc_rdspmaxrpm</c>.</summary>
/// <summary>The load percentage, <c>Value / Base^Exponent</c>; null when <see cref="IsValid"/> is false.</summary>
public double? Scaled => FocasValueEnvelope.Scale(Value, Base, Exponent);
}
/// <summary>
/// One spindle-meter slot from <c>cnc_rdspmeter</c> (0x0040; type 0 = load, type 1 = motor
/// speed). Same 8-byte envelope as <see cref="WireServoMeter"/>, but the scaled value is a
/// fraction of full scale in <c>1/16384</c> units — see <see cref="Percent"/>.
/// </summary>
/// <param name="Index">1-based spindle slot.</param>
/// <param name="Value">Raw meter value, in 1/16384-of-full-scale units.</param>
/// <param name="Base">Scale base.</param>
/// <param name="Exponent">Scale exponent.</param>
public sealed record WireSpindleMetric(
short Index,
int Value);
int Value,
int Base,
int Exponent)
{
/// <summary>
/// The meter's full-scale count: <c>16384</c> = 2^14 = 100% of rated load (type 0) or of
/// maximum spindle speed (type 1). Settled on the real Z-34184 by regressing the type-1
/// meter against <c>cnc_rddynamic2</c> spindle speed over 596–20,000 rpm:
/// <c>rpm = 1.220749 × meter</c>, <c>16384 × 1.220749 = 20000.75</c> (Focas.Wire-019).
/// </summary>
public const int FullScale = 1 << 14;
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>
/// The meter as a percentage — <c>(Value / Base^Exponent) × 100 / 16384</c> — or null when
/// <see cref="IsValid"/> is false. Values above 100 are real (short-term overload; the i16
/// record clamps one count short of 200%).
/// </summary>
public double? Percent => FocasValueEnvelope.Scale(Value, Base, Exponent) is { } scaled
? scaled * 100.0 / FullScale
: null;
}
/// <summary>
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based