Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterPublishTests.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

135 lines
5.5 KiB
C#

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();
}
}
}