Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
T
Joseph Doherty 1dba46bd7b 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.
2026-10-07 14:07:22 -04:00

239 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// Return envelope over a parsed wire response. <see cref="Rc"/> carries the FOCAS
/// <c>EW_*</c> code from the response block — <c>0</c> / <see cref="IsOk"/> means the
/// call succeeded and <see cref="Value"/> is populated; non-zero means the CNC rejected
/// the call and <see cref="Value"/> is <c>default</c>. Callers use the RC to distinguish
/// "feature missing on this series" (<c>EW_FUNC</c> / <c>EW_NOOPT</c>) from genuine
/// failures.
/// </summary>
public readonly record struct FocasResult<T>(short Rc, T? Value)
{
/// <summary>True when <see cref="Rc"/> is zero (<c>EW_OK</c>).</summary>
public bool IsOk => Rc == 0;
}
/// <summary>CNC identity payload returned by <c>cnc_sysinfo</c>.</summary>
public sealed record WireSysInfo(
short AddInfo,
short MaxAxis,
string CncType,
string MachineType,
string Series,
string Version,
string Axes);
/// <summary>Coarse CNC state bits returned by <c>cnc_statinfo</c> — the seven-word status block plus TM mode.</summary>
public sealed record WireStatus(
short Auto,
short Run,
short Motion,
short Mstb,
short Emergency,
short Alarm,
short Edit,
short TmMode);
/// <summary>
/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go,
/// each carrying the scale its own <c>0x0026</c> block reported.
/// </summary>
public sealed record WireAxisPosition(
FocasPosition Absolute,
FocasPosition Machine,
FocasPosition Relative,
FocasPosition Distance);
/// <summary>
/// Fast-poll bundle for one axis from <c>cnc_rddynamic2</c> — alarm flags, active program
/// numbers, sequence number, actual feed rate, actual spindle speed, and the position
/// quadruple.
/// </summary>
public sealed record WireDynamic(
int Alarm,
int ProgramNumber,
int MainProgramNumber,
int SequenceNumber,
int FeedRate,
int SpindleSpeed,
WireAxisPosition Axis);
/// <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,
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>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 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
/// axis index (preserved even when the name is empty so callers can pass it to
/// <c>cnc_rddynamic2</c>).
/// </summary>
public readonly record struct WireAxisRecord(short Index, string Name);
/// <summary>One spindle-name slot from <c>cnc_rdspdlname</c>.</summary>
public readonly record struct WireSpindleRecord(short Index, string Name);
/// <summary>Parameter value returned by <c>cnc_rdparam</c>, interpreted as a scalar Int32.</summary>
public sealed record WireParameter(
short DataNumber,
short Type,
int Value);
/// <summary>
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the real value is
/// <c>Value / 10^Decimal</c> (<see cref="Scaled"/>). FANUC normalises every macro to a
/// 9-significant-digit mantissa, so <see cref="Value"/> alone is never a value to publish
/// (live: <c>#502 = 270</c> arrives as <c>270000000 / 10^6</c>).
/// </summary>
/// <param name="Number">Macro variable number (the request argument; not carried in the payload).</param>
/// <param name="Value">The 9-significant-digit mantissa.</param>
/// <param name="Decimal">Implied decimal places; negative (<c>0xFFFF</c> on the wire) = vacant.</param>
public sealed record WireMacro(
short Number,
int Value,
short Decimal)
{
/// <summary>
/// True when the CNC reported the variable as vacant (undefined / never set):
/// <c>dec_val == 0xFFFF</c>. A vacant macro's mantissa is 0, indistinguishable from a
/// genuine 0, so this must be tested before <see cref="Value"/> means anything. Any
/// negative decimal is treated as vacant — no other negative has ever been observed and
/// failing closed is the safe reading.
/// </summary>
public bool IsVacant => Decimal < 0;
/// <summary>The real value, <c>Value / 10^Decimal</c>; null when <see cref="IsVacant"/>.</summary>
public double? Scaled => IsVacant ? null : Decimal == 0 ? Value : Value / Math.Pow(10.0, Decimal);
}
/// <summary>PMC range read-back from <c>pmc_rdpmcrng</c>: one or more values of the requested width.</summary>
public sealed record WirePmcRange(
short Area,
short DataType,
ushort Start,
ushort End,
IReadOnlyList<long> Values);
/// <summary>
/// One active alarm from <c>cnc_rdalmmsg2</c>. Mirrors the vendor <c>ODBALMMSG2</c>
/// layout; <see cref="AlarmGroup"/> is populated when the wire responder carries it
/// (currently <c>null</c> for both the 76-byte vendor shape and the 80-byte legacy
/// shape).
/// </summary>
public sealed record WireAlarm(
int AlarmNumber,
short Type,
short Axis,
short MessageLength,
string Message,
int? AlarmGroup = null);
/// <summary>
/// Executing-program identity from <c>cnc_exeprgname2</c>: the NUL-terminated name and
/// the trailing 32-bit O-number (null when the wire responder omits the trailing int).
/// </summary>
public sealed record WireProgramName(
string Name,
int? ONumber);
/// <summary>One cumulative timer reading from <c>cnc_rdtimer</c> (minutes + fractional milliseconds).</summary>
public sealed record WireTimer(
short Type,
int Minutes,
int Milliseconds);