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:
@@ -420,12 +420,15 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result);
|
||||
}
|
||||
|
||||
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
|
||||
/// <summary>
|
||||
/// Read the per-spindle load meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>, type 0).
|
||||
/// <see cref="WireSpindleMetric.Percent"/> is % of rated load (16384 = 100%).
|
||||
/// </summary>
|
||||
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The per-spindle load percentages, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
/// <returns>The per-spindle load meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
|
||||
short spindleSelector = -1,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -433,13 +436,19 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
ushort? pathId = null)
|
||||
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
|
||||
|
||||
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
|
||||
/// <summary>
|
||||
/// Read the per-spindle motor-speed meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>,
|
||||
/// type 1 — historically labelled <c>cnc_rdspmaxrpm</c>). It is NOT a maximum-RPM constant:
|
||||
/// it is the live motor speed as a fraction of maximum, <see cref="WireSpindleMetric.Percent"/>
|
||||
/// (a regression against <c>cnc_rddynamic2</c> spindle speed on a real 31i-B gives
|
||||
/// <c>rpm = 1.220749 × meter</c>, i.e. 16384 = the spindle's 20,000 rpm maximum).
|
||||
/// </summary>
|
||||
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||
/// <returns>The per-spindle maximum RPMs, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
|
||||
/// <returns>The per-spindle motor-speed meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMotorSpeedAsync(
|
||||
short spindleSelector = -1,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? timeout = null,
|
||||
@@ -771,13 +780,27 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
|
||||
return ToResult<IReadOnlyList<WireSpindleMetric>>(block, payload =>
|
||||
{
|
||||
var values = new List<WireSpindleMetric>();
|
||||
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
|
||||
values.Add(new WireSpindleMetric((short)(values.Count + 1), ReadInt32(payload, offset)));
|
||||
return values;
|
||||
});
|
||||
return ToResult(block, ParseSpindleMetrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode a <c>cnc_rdspmeter</c> (0x0040) payload: 8-byte records of
|
||||
/// <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c>, one per
|
||||
/// slot of a fixed multi-slot buffer (slots past the spindle count are residue and decode
|
||||
/// as <see cref="WireSpindleMetric.IsValid"/> = false). Every slot is returned, in wire order.
|
||||
/// </summary>
|
||||
/// <param name="payload">The response-block payload.</param>
|
||||
/// <returns>The per-slot meters.</returns>
|
||||
internal static IReadOnlyList<WireSpindleMetric> ParseSpindleMetrics(byte[] payload)
|
||||
{
|
||||
var values = new List<WireSpindleMetric>();
|
||||
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
|
||||
values.Add(new WireSpindleMetric(
|
||||
(short)(values.Count + 1),
|
||||
ReadInt32(payload, offset),
|
||||
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 4, 2)),
|
||||
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 6, 2))));
|
||||
return values;
|
||||
}
|
||||
|
||||
private async Task<FocasResult<T>> ReadSingleAsync<T>(
|
||||
@@ -958,18 +981,16 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
|
||||
|
||||
/// <summary>
|
||||
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. On the
|
||||
/// 31i-B (v3) each per-axis LOADELM is <b>8 bytes</b> — {int32 data; int16 dec; int16 unit}
|
||||
/// — NOT the 12-byte shape the original parser assumed. The 12-byte stride misaligned after
|
||||
/// the first record (it read the dec/unit shorts of the prior record as the next record's
|
||||
/// data → wild values like 655360). Axis NAMES are not in the svmeter payload; they come
|
||||
/// from the <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated
|
||||
/// by index. Validated against a live 31i-B 2026-06-25.
|
||||
/// <para><b>Scaling caveat:</b> downstream applies LoadPercent = data / 10^dec; on the 31i-B
|
||||
/// <c>dec</c> read as 10, which makes idle loads vanishingly small. The data/dec/unit field
|
||||
/// semantics for servo load are inferred from the wire and not yet confirmed against the
|
||||
/// machine's servo-meter screen — confirm magnitude at commissioning. The alignment + name
|
||||
/// fix here is what removes the gross misaligned garbage.</para>
|
||||
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. Each
|
||||
/// per-axis record is <b>8 bytes</b> — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>,
|
||||
/// <c>[+6..7] u16 exponent</c> — the same self-describing envelope as the position blocks,
|
||||
/// so the load is <c>value / base^exponent</c>, a direct percentage. (A 12-byte stride
|
||||
/// misaligned after the first record; reading offset 4 as a decimal count divided every load
|
||||
/// by 10^10 — both fixed against live 31i-B captures, the latter by the sister ignitionoee
|
||||
/// driver, Focas.Wire-019.) Axis NAMES are not in the svmeter payload; they come from the
|
||||
/// <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated by index.
|
||||
/// The payload is a fixed 32-slot buffer: slots past the controlled-axis count are residue
|
||||
/// and decode as <see cref="WireServoMeter.IsValid"/> = false.
|
||||
/// </summary>
|
||||
/// <param name="svPayload">The raw <c>cnc_rdsvmeter</c> response payload.</param>
|
||||
/// <param name="axisNamePayload">The raw <c>cnc_rdaxisname</c> response payload used to correlate axis names by index.</param>
|
||||
@@ -989,8 +1010,8 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
(short)(index + 1),
|
||||
name,
|
||||
ReadInt32(svPayload, offset),
|
||||
ReadInt16(svPayload, offset + 4),
|
||||
ReadInt16(svPayload, offset + 6)));
|
||||
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 4, 2)), // BASE, not a decimal
|
||||
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 6, 2))));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -242,34 +242,32 @@ public sealed class WireFocasClient : IFocasClient
|
||||
var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
return result.Value
|
||||
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal)))
|
||||
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Scaled))
|
||||
.Where(s => s.AxisName.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
|
||||
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken);
|
||||
|
||||
private static async Task<IReadOnlyList<int>> ReadSpindleMetricAsync(
|
||||
/// <summary>
|
||||
/// One <c>cnc_rdspmeter</c> read as per-slot percentages (null for an undescribable slot).
|
||||
/// Every slot is returned — the driver reads only the slots of discovered spindles. (This
|
||||
/// used to stop at the first zero after slot 1, which both dropped a genuinely idle second
|
||||
/// spindle and kept non-zero buffer residue.)
|
||||
/// </summary>
|
||||
private static async Task<IReadOnlyList<double?>> ReadSpindleMetricAsync(
|
||||
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await call(-1, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
var list = new List<int>();
|
||||
foreach (var m in result.Value)
|
||||
{
|
||||
// Fanuc pads unused spindle slots with 0 — stop at the first trailing zero so the
|
||||
// list length matches the configured spindle count.
|
||||
if (m.Value == 0 && list.Count > 0) break;
|
||||
list.Add(m.Value);
|
||||
}
|
||||
return list;
|
||||
return result.Value.Select(m => m.Percent).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Disposes the wire client and releases all resources.</summary>
|
||||
|
||||
Reference in New Issue
Block a user