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
@@ -0,0 +1,127 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Servo (<c>cnc_rdsvmeter</c>, 0x0056) and spindle (<c>cnc_rdspmeter</c>, 0x0040) meter decode.
/// Both are arrays of 8-byte records carrying the same self-describing envelope as every other
/// value block on this wire — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c> (2 or 10),
/// <c>[+6..7] u16 exponent</c> — so <c>scaled = value / base^exponent</c>.
/// Ported from the sister ignitionoee FOCAS driver v0.17.2 / v0.17.3 (Focas.Wire-019).
/// </summary>
/// <remarks>
/// <para><b>Servo: offset 4 is the BASE, not a decimal count.</b> It holds a steady 10, so the
/// old <c>value / 10^decimal</c> divided every load by 10^10 and published ~0 — on a real
/// Makino cutting at 20,000 rpm the panel read servo 0% on every axis. Servo meters are
/// <b>direct</b> percentages (the live capture shows −57..227).</para>
/// <para><b>Spindle: a fraction of full scale in 1/16384 units.</b> Settled on the real Z-34184
/// by regressing the type-1 meter against the independently read <c>cnc_rddynamic2</c> spindle
/// speed over 596–20,000 rpm: <c>rpm = 1.220749 × meter</c>, and <c>16384 × 1.220749 =
/// 20000.75</c> — full scale is the spindle's 20,000 rpm maximum. So the type-1 meter
/// (<c>cnc_rdspmaxrpm</c> by name) is the <b>motor speed as a % of maximum</b>, not a max-rpm
/// constant; the type-0 load meter shares the envelope and the 16384 = 100% scale (its
/// acceleration peaks pin at 32767 — one count short of a 200% overload).</para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class FocasMeterDecodeTests
{
private static byte[] Record(int value, int @base, int exponent)
{
var b = new byte[8];
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), value);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent);
return b;
}
// ---- servo ----
[Fact]
public void The_live_servo_capture_reads_direct_percentages_not_value_over_ten_to_the_tenth()
{
var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin");
var meters = FocasWireClient.ParseServoMeters(
blocks.First(b => b.Command == 0x0056).Payload,
blocks.First(b => b.Command == 0x0089).Payload,
maxCount: 32);
meters[0].Base.ShouldBe(10);
meters[0].Exponent.ShouldBe(0);
meters.Take(3).Select(m => m.Scaled).ShouldBe([0.0, 50.0, -3.0]);
}
[Fact]
public void A_servo_record_applies_its_own_exponent()
{
var m = FocasWireClient.ParseServoMeters(Record(1234, 10, 1), Array.Empty<byte>(), 32).Single();
m.Scaled!.Value.ShouldBe(123.4, 1e-9);
}
[Theory]
[InlineData(5, 7, 0)] // base neither 2 nor 10
[InlineData(5, 10, 2560)] // residue past the controlled-axis count
public void An_undescribable_servo_record_has_no_value_not_zero(int value, int @base, int exponent)
{
var m = FocasWireClient.ParseServoMeters(Record(value, @base, exponent), Array.Empty<byte>(), 32).Single();
m.IsValid.ShouldBeFalse();
m.Scaled.ShouldBeNull();
}
// ---- spindle ----
[Fact]
public void The_live_spindle_load_capture_is_a_percentage_of_16384()
{
var metrics = FocasWireClient.ParseSpindleMetrics(
WireFixtures.Payload("live-spmeter-load-0x0040.bin", 0x0040));
// Slot 1: raw 0x23C3 = 9155, base 10, exponent 0 → 9155 × 100 / 16384 ≈ 55.88 %.
metrics[0].Value.ShouldBe(9155);
metrics[0].Percent!.Value.ShouldBe(9155 * 100.0 / 16384, 1e-9);
// Slot 2 is buffer residue (exponent 0x0A00 = 2560): no value, not a number.
metrics[1].IsValid.ShouldBeFalse();
metrics[1].Percent.ShouldBeNull();
}
[Fact]
public void The_live_motor_speed_capture_is_a_percentage_of_maximum_speed()
{
var metrics = FocasWireClient.ParseSpindleMetrics(
WireFixtures.Payload("live-spmeter-maxrpm-0x0040.bin", 0x0040));
// 0x3FFD = 16381 ≈ 99.98 % — a spindle at (just under) its 20,000 rpm maximum.
metrics[0].Value.ShouldBe(16381);
metrics[0].Percent!.Value.ShouldBe(16381 * 100.0 / 16384, 1e-9);
}
[Theory]
[InlineData(16382, 19998)]
[InlineData(12286, 14998)]
[InlineData(9830, 12000)]
[InlineData(695, 850)]
[InlineData(488, 595)]
public void Percent_reproduces_the_live_motor_speed_regression(int meter, int rpm)
{
var m = FocasWireClient.ParseSpindleMetrics(Record(meter, 10, 0)).Single();
m.Percent!.Value.ShouldBe(rpm * 100.0 / 20000.0, 0.02);
}
[Fact]
public void Full_scale_is_16384_and_overload_is_not_clamped()
{
WireSpindleMetric.FullScale.ShouldBe(16384);
FocasWireClient.ParseSpindleMetrics(Record(16384, 10, 0)).Single().Percent.ShouldBe(100.0);
var overload = FocasWireClient.ParseSpindleMetrics(Record(32767, 10, 0)).Single().Percent!.Value;
overload.ShouldBeInRange(199.9, 200.0);
}
[Fact]
public void The_spindle_envelope_exponent_applies_before_the_full_scale_division()
{
// 163840 / 10^1 = 16384 counts = 100 %.
FocasWireClient.ParseSpindleMetrics(Record(163840, 10, 1)).Single().Percent!.Value.ShouldBe(100.0, 1e-9);
}
}