1dba46bd7b
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.
135 lines
5.5 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|