feat(focas): coalesce adjacent PMC reads into one pmc_rdpmcrng (#266)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:59:07 -04:00
parent 5b36c717de
commit 8d916f2af7
11 changed files with 790 additions and 11 deletions
@@ -77,6 +77,96 @@ public sealed class WireBackendStatusAndPmcTests
}
}
/// <summary>
/// A poll batch of adjacent PMC bytes is served by ONE range read against the live wire, every tag gets
/// its own value, and a tag outside the merge window still reads through the per-tag path. Seeds are
/// byte-typed: the mock serves one entry per ADDRESS for every PMC type, which matches the real
/// byte-addressed range only for width 1.
/// </summary>
[Fact]
public async Task Adjacent_pmc_bytes_coalesce_into_one_range_read_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
pmc = new { R = new Dictionary<string, object>
{
["200"] = new { type = "byte", value = 17 },
["201"] = new { type = "byte", value = 200 },
["202"] = new { type = "byte", value = 0b0000_0100 },
["203"] = new { type = "byte", value = 9 },
["900"] = new { type = "byte", value = 33 },
}},
}, ct);
var counting = new CountingFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("R200", DeviceHost, "R200", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R201", DeviceHost, "R201", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R202b2", DeviceHost, "R202.2", FocasDataType.Bit, Writable: false),
new FocasTagDefinition("R203", DeviceHost, "R203", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R900", DeviceHost, "R900", FocasDataType.Byte, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-pmc-coalesce", clientFactory: counting);
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var snaps = await drv.ReadAsync(["R200", "R201", "R202b2", "R203", "R900"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snaps[0].Value).ShouldBe(17);
Convert.ToInt32(snaps[1].Value).ShouldBe(-56); // 200 as a signed byte
snaps[2].Value.ShouldBe(true);
Convert.ToInt32(snaps[3].Value).ShouldBe(9);
Convert.ToInt32(snaps[4].Value).ShouldBe(33);
counting.Client.ShouldNotBeNull();
counting.Client.RangeReads.ShouldBe(1, "R200..R203 merge into one pmc_rdpmcrng");
counting.Client.SingleReads.ShouldBe(1, "R900 is outside the gap window and reads on its own");
}
}
private sealed class CountingFactory : IFocasClientFactory
{
private readonly WireFocasClientFactory _inner = new();
public CountingClient? Client { get; private set; }
public void EnsureUsable() => _inner.EnsureUsable();
public IFocasClient Create() => Client = new CountingClient(_inner.Create());
}
private sealed class CountingClient(IFocasClient inner) : IFocasClient
{
public int RangeReads { get; private set; }
public int SingleReads { get; private set; }
public bool IsConnected => inner.IsConnected;
public Task ConnectAsync(FocasHostAddress a, TimeSpan t, CancellationToken ct) => inner.ConnectAsync(a, t, ct);
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct)
{ SingleReads++; return inner.ReadAsync(a, t, ct); }
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string l, FocasDataType t, int s, int e, CancellationToken ct)
{ RangeReads++; return inner.ReadPmcSlotsAsync(l, t, s, e, ct); }
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => inner.WriteAsync(a, t, v, ct);
public Task<bool> ProbeAsync(CancellationToken ct) => inner.ProbeAsync(ct);
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) => inner.ReadAlarmsAsync(ct);
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => inner.GetSysInfoAsync(ct);
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) => inner.GetAxisNamesAsync(ct);
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) => inner.GetSpindleNamesAsync(ct);
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int i, CancellationToken ct) => inner.ReadDynamicAsync(i, ct);
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) => inner.GetProgramInfoAsync(ct);
public Task<FocasStatus> GetStatusAsync(CancellationToken ct) => inner.GetStatusAsync(ct);
public Task<FocasTimer> GetTimerAsync(FocasTimerKind k, CancellationToken ct) => inner.GetTimerAsync(k, ct);
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) => inner.GetServoLoadsAsync(ct);
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) => inner.GetSpindleLoadsAsync(ct);
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) => inner.GetSpindleMotorSpeedsAsync(ct);
public void Dispose() => inner.Dispose();
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;