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:
@@ -140,18 +140,18 @@ internal class FakeFocasClient : IFocasClient
|
||||
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
||||
|
||||
/// <summary>Gets the list of spindle loads.</summary>
|
||||
public List<int> SpindleLoads { get; } = [];
|
||||
/// <summary>Gets the list of spindle maximum RPMs.</summary>
|
||||
public List<int> SpindleMaxRpms { get; } = [];
|
||||
/// <summary>Gets the list of spindle load percentages (null = undescribable slot).</summary>
|
||||
public List<double?> SpindleLoads { get; } = [];
|
||||
/// <summary>Gets the list of spindle motor-speed percentages (null = undescribable slot).</summary>
|
||||
public List<double?> SpindleMotorSpeeds { get; } = [];
|
||||
/// <summary>Gets spindle loads asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
|
||||
/// <summary>Gets spindle maximum RPMs asynchronously.</summary>
|
||||
public virtual Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<double?>>([.. SpindleLoads]);
|
||||
/// <summary>Gets spindle motor-speed percentages asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
|
||||
public virtual Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<double?>>([.. SpindleMotorSpeeds]);
|
||||
|
||||
/// <summary>Disposes the client.</summary>
|
||||
public virtual void Dispose()
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+11
-11
@@ -34,8 +34,8 @@ public sealed class FocasDuplicateSiblingNameTests
|
||||
// Two spindles whose displays also collide, with distinct data per slot.
|
||||
c.SpindleNames.Clear();
|
||||
c.SpindleNames.AddRange([new("S", "1", "", ""), new("S", "1", "", "")]);
|
||||
c.SpindleLoads.AddRange([5, 7]);
|
||||
c.SpindleMaxRpms.AddRange([8_000, 12_000]);
|
||||
c.SpindleLoads.AddRange([5.0, 7.0]);
|
||||
c.SpindleMotorSpeeds.AddRange([40.0, 60.0]);
|
||||
return c;
|
||||
},
|
||||
};
|
||||
@@ -159,17 +159,17 @@ public sealed class FocasDuplicateSiblingNameTests
|
||||
// The spindle read path matched by display name and took the FIRST index that matched,
|
||||
// so a duplicate spindle name made the second spindle's data unreachable.
|
||||
var drv = await StartedDriverAsync(
|
||||
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Int32, Writable: false),
|
||||
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Int32, Writable: false),
|
||||
new FocasTagDefinition("raw/s1max", Host, "FIXED:Spindle/S1_1/MaxRpm", FocasDataType.Int32, Writable: false),
|
||||
new FocasTagDefinition("raw/s2max", Host, "FIXED:Spindle/S1_2/MaxRpm", FocasDataType.Int32, Writable: false));
|
||||
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Float64, Writable: false),
|
||||
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Float64, Writable: false),
|
||||
new FocasTagDefinition("raw/s1speed", Host, "FIXED:Spindle/S1_1/MotorSpeedPct", FocasDataType.Float64, Writable: false),
|
||||
new FocasTagDefinition("raw/s2speed", Host, "FIXED:Spindle/S1_2/MotorSpeedPct", FocasDataType.Float64, Writable: false));
|
||||
|
||||
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1max", "raw/s2max"], CancellationToken.None);
|
||||
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1speed", "raw/s2speed"], CancellationToken.None);
|
||||
|
||||
read[0].Value.ShouldBe(5);
|
||||
read[1].Value.ShouldBe(7);
|
||||
read[2].Value.ShouldBe(8_000);
|
||||
read[3].Value.ShouldBe(12_000);
|
||||
read[0].Value.ShouldBe(5.0);
|
||||
read[1].Value.ShouldBe(7.0);
|
||||
read[2].Value.ShouldBe(40.0);
|
||||
read[3].Value.ShouldBe(60.0);
|
||||
read.ShouldAllBe(r => r.StatusCode == FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-level publish of servo / spindle meters (Focas.Wire-019 port): percentages publish
|
||||
/// Good as Float64, an undescribable record publishes BadNoData (never a Good 0), and the
|
||||
/// spindle motor-speed meter is a live, polled <c>MotorSpeedPct</c> — not the bootstrap-time
|
||||
/// <c>MaxRpm</c> constant it was mislabelled as.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasMeterPublishTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasDriver NewDriver(FakeFocasClient client) =>
|
||||
new(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) },
|
||||
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||
|
||||
private static async Task<DataValueSnapshot> ReadSettledAsync(FocasDriver drv, string reference, Func<DataValueSnapshot, bool> settled)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
DataValueSnapshot last;
|
||||
do
|
||||
{
|
||||
last = (await drv.ReadAsync([reference], CancellationToken.None)).Single();
|
||||
if (settled(last)) return last;
|
||||
await Task.Delay(15);
|
||||
} while (DateTime.UtcNow < deadline);
|
||||
return last;
|
||||
}
|
||||
|
||||
private static FakeFocasClient MachineClient()
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.AxisNames.Clear();
|
||||
c.AxisNames.AddRange([new("X", ""), new("Y", "")]);
|
||||
c.ServoLoads.AddRange([new("X", 46.0), new("Y", null)]); // Y: undescribable record
|
||||
c.SpindleNames.Clear();
|
||||
c.SpindleNames.Add(new("S", "1", "", ""));
|
||||
c.SpindleLoads.Add(55.88);
|
||||
c.SpindleMotorSpeeds.Add(2.49);
|
||||
return c;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Servo_loads_publish_percentages_and_an_undescribable_record_is_bad()
|
||||
{
|
||||
var drv = NewDriver(MachineClient());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var x = await ReadSettledAsync(drv, $"{Host}/Axes/X/ServoLoad", s => s.StatusCode == FocasStatusMapper.Good);
|
||||
var y = await ReadSettledAsync(drv, $"{Host}/Axes/Y/ServoLoad", s => s.StatusCode == FocasStatusMapper.BadNoData);
|
||||
|
||||
x.Value.ShouldBe(46.0);
|
||||
y.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
|
||||
y.Value.ShouldBeNull();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spindle_meters_publish_float64_percentages_and_motor_speed_is_polled_live()
|
||||
{
|
||||
var client = MachineClient();
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
|
||||
load.Value.ShouldBe(55.88);
|
||||
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => s.StatusCode == FocasStatusMapper.Good))
|
||||
.Value.ShouldBe(2.49);
|
||||
|
||||
// The spindle speeds up: the poll loop must follow it (MaxRpm was read once at bootstrap).
|
||||
client.SpindleMotorSpeeds[0] = 99.98;
|
||||
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => Equals(s.Value, 99.98)))
|
||||
.Value.ShouldBe(99.98);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_undescribable_spindle_slot_is_bad_not_zero()
|
||||
{
|
||||
var client = MachineClient();
|
||||
client.SpindleLoads[0] = null;
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.BadNoData);
|
||||
|
||||
load.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
|
||||
load.Value.ShouldBeNull();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_spindle_folder_offers_MotorSpeedPct_not_MaxRpm()
|
||||
{
|
||||
var drv = NewDriver(MachineClient());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
|
||||
|
||||
var builder = new NameRecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldContain("MotorSpeedPct");
|
||||
builder.Variables.ShouldNotContain("MaxRpm");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class NameRecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<string> Variables { get; } = [];
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add(browseName);
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ public sealed class FocasWireV3Tests
|
||||
[Fact]
|
||||
public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block()
|
||||
{
|
||||
// Three 8-byte records {int32 data; int16 dec=10; int16 unit=0}: data = 0, 50, -3.
|
||||
// Three 8-byte records {int32 value; u16 base=10; u16 exponent=0}: value = 0, 50, -3.
|
||||
byte[] sv =
|
||||
[
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0
|
||||
@@ -120,7 +120,8 @@ public sealed class FocasWireV3Tests
|
||||
meters[0].Value.ShouldBe(0);
|
||||
meters[1].Name.ShouldBe("Y");
|
||||
meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360
|
||||
meters[1].Decimal.ShouldBe((short)10);
|
||||
meters[1].Base.ShouldBe(10); // offset 4 is the envelope BASE, not a decimal count
|
||||
meters[1].Scaled.ShouldBe(50.0);
|
||||
meters[2].Name.ShouldBe("Z");
|
||||
meters[2].Value.ShouldBe(-3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user