diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index 94d612a..c115438 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -8,6 +8,47 @@ Power Mate i families. Talks to the controller via the licensed For range-validation and per-series capability surface see [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md). +## Fixed-tree `Production/` projection — issue #258 (F1-b) + issue #272 (F5-a) + +Per-device read-only nodes refreshed from the same `cnc_rdparam` / +cycle-timer poll the probe loop already runs. No additional wire calls +are issued for any of these — they are all cache-or-derive reads. + +| Node | DataType | Source | Notes | +| --- | --- | --- | --- | +| `Production/PartsProduced` | `Int32` | `cnc_rdparam(6711)` | Active parts-count counter. Wraps to 0 on operator reset. | +| `Production/PartsRequired` | `Int32` | `cnc_rdparam(6712)` | Operator-set target. | +| `Production/PartsTotal` | `Int32` | `cnc_rdparam(6713)` | Lifetime parts counter. | +| `Production/CycleTimeSeconds` | `Int32` | `cnc_rdtimer` (channel 0) | Live cycle-time accumulator. Resets to 0 on next cycle start (CNC-side behaviour). | +| **`Production/LastCycleSeconds`** | **`Float64`** | **derived** | **Plan PR F5-a — seconds for the most recently completed cycle, computed as `CycleTimeSeconds(now) - CycleTimeSeconds(at previous parts-count increment)`. `null` until the second observed parts-count increment establishes a delta. Pure derivation, no new wire calls. See edge-case rules below.** | +| **`Production/LastCycleStartUtc`** | **`DateTime`** *(UTC)* | **derived** | **Plan PR F5-a — UTC wall-clock of the most-recent cycle's start, computed as `nowUtc - LastCycleSeconds`. `null` alongside `LastCycleSeconds` until the second observed increment.** | + +### F5-a derivation edge-case rules + +- **First observation** establishes the baseline; `LastCycleSeconds` / + `LastCycleStartUtc` stay `null` until the second observed parts-count + increment produces the first delta. +- **Parts-count counter reset** (current value goes backwards, e.g. + shift-change zero) **preserves the last published values** so an + operator reading the tag mid-shift-change sees the last known cycle + duration rather than `null` / Bad. The next positive transition + produces a fresh delta from the new baseline. +- **Cycle-timer rollover** (delta would be negative — e.g. CNC zeroes + the cycle timer at part completion) **leaves the previously-published + values unchanged for one tick** and re-baselines so the next + increment produces a clean delta. The driver does NOT publish a + negative `LastCycleSeconds`. +- **Parts-count jumps `> 1`** (backfill — e.g. counter increments by + 3 at once) publish the **timer delta over the window** as + `LastCycleSeconds`. The plan's "delta over the window between + successive parts-count increments" definition does not divide by the + count delta; the value reflects the actual elapsed timer between the + two observations. +- **Reconnect / reinit** clears the derivation state — the prior CNC + session's cycle-timer + parts-count snapshots may be invalidated by + the FWLIB session boundary, so the next post-reconnect probe tick + re-establishes the baseline before the next delta publishes. + ## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a `FocasAlarmProjection` exposes two modes via `FocasDriverOptions.AlarmProjection`: diff --git a/docs/v2/focas-deployment.md b/docs/v2/focas-deployment.md index c32f97c..3530f9a 100644 --- a/docs/v2/focas-deployment.md +++ b/docs/v2/focas-deployment.md @@ -44,6 +44,37 @@ reported wall-clock — keep CNC clocks on UTC so the dedup key `(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST transitions. +## Derived telemetry — issue #272 (plan PR F5-a) + +The `Production/` subtree gains two **derived** nodes alongside the four +F1-b wire-sourced fields: + +- `Production/LastCycleSeconds` (`Float64`) +- `Production/LastCycleStartUtc` (`DateTime` UTC) + +**No new wire calls.** Both nodes are computed client-visible from the +same `cnc_rdparam(6711)` + `cnc_rdtimer` poll the F1-b projection +already runs on every probe tick. There is no per-device knob — the +nodes are present for every CNC the driver connects to and surface +`null` until the second observed parts-count increment produces the +first delta. + +This means: + +- **No additional CNC load.** Probe-tick wire traffic is unchanged. +- **No new opt-in.** The nodes ship enabled by default and are + read-only (`SecurityClassification.ViewOnly`); no LDAP group needs + the new permission. +- **Reconnect re-baselines.** Per the FWLIB session boundary the + derivation state resets on reconnect / reinit, so the first cycle + observed after a reconnect re-establishes the baseline before + publishing the first post-reconnect delta. + +See [`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "Fixed-tree +`Production/` projection" for the full edge-case behaviour matrix +(parts-count counter reset, cycle-timer rollover, parts-count jumps +> 1). + ## Write safety — issue #269 (PARAM/MACRO, F4-b) + issue #270 (PMC, F4-c) The FOCAS driver supports `cnc_wrparam`, `cnc_wrmacro`, and `pmc_wrpmcrng` diff --git a/docs/v2/implementation/focas-simulator-plan.md b/docs/v2/implementation/focas-simulator-plan.md index 557c26b..2d13e24 100644 --- a/docs/v2/implementation/focas-simulator-plan.md +++ b/docs/v2/implementation/focas-simulator-plan.md @@ -304,6 +304,82 @@ Bit-level writes never appear here as a separate kind — they reach the simulator as 1-byte writes after the driver's RMW wrapper, so the audit shape is identical to a byte write at the same address. +## Cycle-time per part / last cycle delta — F5-a (issue #272) + +Plan PR F5-a derives `Production/LastCycleSeconds` + +`Production/LastCycleStartUtc` from the existing `cnc_rdparam(6711)` + +`cnc_rdtimer` snapshot stream — **pure derivation, no new wire calls**. +The simulator does NOT need new wire commands; the existing +`cnc_rdparam` + `cnc_rdtimer` handlers already cover the read surface. + +What focas-mock DOES need is an admin endpoint + test-fixture helper +that lets integration tests atomically increment the parts-count +counter alongside the cycle-time timer so the driver sees a clean +"cycle completed" transition on the next probe tick. + +### Per-profile state + +Already covered by the existing F1-b state map: + +- `parameters: Dict[int, int]` (entry `6711` is the parts-count counter). +- `timers: Dict[int, int]` (entry `0` is the live cycle-time counter, + in seconds). + +### Admin endpoint — `POST /admin/mock_simulate_cycle_completion` + +Atomically advances both values to model "the CNC just finished a +cycle". Atomicity matters: the F5-a derivation samples both fields on +every probe tick, so if the simulator updated parts-count and the +timer in two separate writes the test could observe an intermediate +state where parts-count incremented but the timer hasn't updated yet +(producing a misleading `LastCycleSeconds`). + +``` +POST /admin/mock_simulate_cycle_completion +{ + "profile": "Series30i", + "partsDelta": 1, // default 1; tests asserting backfill use 3+ + "newCycleTimerSeconds": 18 // absolute value, NOT a delta +} +``` + +Handler steps: + +1. `parameters[6711] += partsDelta` (under the per-profile lock). +2. `timers[0] = newCycleTimerSeconds`. +3. Return `200 OK` with the new values for verification. + +The endpoint MUST hold the profile's update lock for the full +read-modify-write so a concurrent `cnc_rdparam` + `cnc_rdtimer` poll +sees both fields in their pre-update OR post-update state — never +half-applied. + +### `FocasSimFixture.SimulateCycleCompletionAsync` + +The future test-support helper wraps the admin endpoint: + +```csharp +await fixture.SimulateCycleCompletionAsync( + profile: "Series30i", + partsDelta: 1, + newCycleTimerSeconds: 18); +``` + +Integration test `Series/CycleDeltaTests.cs` will assert: + +- After a 5 -> 6 transition with `newCycleTimerSeconds=18`, the + driver's `Production/LastCycleSeconds` settles to `currentTimer - + prevTimer`. +- `Production/LastCycleStartUtc` is within driver-tolerance of + `nowUtc - LastCycleSeconds` (allow a small window for probe-tick + jitter). +- Counter reset (parts -> 0) preserves the last published values. +- Cycle-timer rollover does not publish a negative delta. + +These tests are blocked on the focas-mock + integration-test project +landing; the unit-test coverage in `FocasCycleDeltaTests` already +exercises every same-process invariant of the derivation. + ### Status focas-mock simulator has not landed yet (tracked separately from F4-b / diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 4c616a7..7a786d1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -58,13 +58,46 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ]; /// - /// Names of the 4 fixed-tree Production/ child nodes per device — parts + /// Names of the fixed-tree Production/ child nodes per device — parts /// produced/required/total via cnc_rdparam(6711/6712/6713) + cycle-time - /// seconds (issue #258). Order matters for deterministic discovery output. + /// seconds (issue #258), plus the F5-a derived telemetry pair + /// LastCycleSeconds + LastCycleStartUtc (issue #272). + /// Order matters for deterministic discovery output. /// + /// + /// + /// The F5-a derivation is pure — it observes the same + /// cnc_rdparam(6711) + cycle-timer values the existing F1-b + /// projection already pulls on the probe tick, so no additional wire + /// calls are issued. + /// + /// + /// LastCycleSeconds = the cycle-timer delta observed across + /// two successive parts-count increments (currentTimer - + /// timerAtPreviousIncrement). LastCycleStartUtc = the wall- + /// clock at the moment of the second increment minus + /// LastCycleSeconds. Both values are null until the second + /// observed parts-count increment (one increment establishes the + /// baseline; the second produces the first delta). + /// + /// + /// A parts-count counter reset (e.g. shift change, value goes + /// backwards) re-baselines the state without emitting a negative + /// LastCycleSeconds; the previously-published values stay live + /// until the next positive transition produces a fresh delta. A + /// cycle-timer rollover (timer goes backwards while parts-count + /// increments) re-baselines the timer without publishing the negative + /// delta, so a single tick where the delta would be < 0 leaves the + /// current LastCycle* values untouched. A parts-count jump of + /// > 1 (backfill) emits one delta — the timer delta over the + /// window — without trying to per-part-divide the value, matching the + /// plan's "delta over the window between increments" intent. + /// + /// private static readonly string[] ProductionFieldNames = [ "PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds", + "LastCycleSeconds", "LastCycleStartUtc", ]; /// @@ -712,16 +745,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, WriteIdempotent: false)); } - // Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced / - // required / total + cycle-time seconds (issue #258). Cached on the probe tick - // + served from DeviceState.LastProduction. + // Fixed-tree Production/ subfolder — 6 read-only nodes: parts produced / + // required / total + cycle-time seconds (issue #258), plus the F5-a derived + // pair LastCycleSeconds / LastCycleStartUtc (issue #272). Cached on the probe + // tick + served from DeviceState.LastProduction (wire-sourced fields) and + // DeviceState.LastCycle* (derived). var productionFolder = deviceFolder.Folder("Production", "Production"); foreach (var field in ProductionFieldNames) { var fullRef = ProductionReferenceFor(device.HostAddress, field); productionFolder.Variable(field, field, new DriverAttributeInfo( FullName: fullRef, - DriverDataType: DriverDataType.Int32, + DriverDataType: ProductionFieldType(field), IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, @@ -878,6 +913,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _ => DriverDataType.String, }; + /// + /// Plan PR F5-a (issue #272) — per-field + /// dispatch for the Production/ subtree. The four wire-sourced fields + /// (PartsProduced / Required / Total + CycleTimeSeconds) stay + /// for back-compat with the F1-b surface; + /// the two F5-a derived fields surface as + /// (sub-second precision is meaningful at fast cycle times) and + /// (UTC) respectively. + /// + private static DriverDataType ProductionFieldType(string field) => field switch + { + "LastCycleSeconds" => DriverDataType.Float64, + "LastCycleStartUtc" => DriverDataType.DateTime, + _ => DriverDataType.Int32, + }; + /// /// Plan PR F4-b (issue #269) — declare the per-tag write classification the /// server-layer ACL gate (DriverNodeManager) consumes. Per the @@ -1064,6 +1115,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var production = await client.GetProductionAsync(ct).ConfigureAwait(false); if (production is not null) { + // F5-a (issue #272) — derive LastCycleSeconds + LastCycleStartUtc + // from the same observation. Pure derivation: no extra wire calls. + UpdateCycleDerivation(state, production, DateTime.UtcNow); state.LastProduction = production; state.LastProductionUtc = DateTime.UtcNow; } @@ -1169,10 +1223,132 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, device.LastStatusUtc, now); } + /// + /// Plan PR F5-a (issue #272) — pure derivation that maintains the per-device + /// LastCycleSeconds + LastCycleStartUtc projection from the + /// wire-sourced production snapshot the probe loop already collected. + /// + /// The derivation observes PartsProduced + CycleTimeSeconds + /// on every tick and remembers the values seen at the last positive + /// parts-count transition. When parts-count next increments by >= 1, the + /// delta in cycle-timer seconds across that window becomes + /// LastCycleSeconds; LastCycleStartUtc = current wall clock + /// minus that delta. + /// + /// + /// Edge cases — see the remarks for the + /// full doc: + /// + /// First observation establishes the baseline; no + /// LastCycleSeconds is published until the second. + /// Parts-count counter reset (current < previous) re-baselines + /// without clobbering the most-recent published LastCycle*. + /// Cycle-timer rollover (delta < 0 with positive parts + /// increment) re-baselines without publishing a negative delta. + /// Parts-count jumps > 1 publish the timer delta as one + /// LastCycleSeconds per the plan's "delta over the window" + /// definition — no per-part division. + /// + /// + /// + internal static void UpdateCycleDerivation( + DeviceState state, FocasProductionInfo production, DateTime nowUtc) + { + var partsCount = production.PartsProduced; + var cycleTimerSeconds = (double)production.CycleTimeSeconds; + + // First observation — establish the baseline. No prior increment to + // delta against, so nothing is published yet. + if (state.PreviousPartsCount is not int prevParts + || state.PreviousCycleTimerSeconds is not double prevTimer) + { + state.PreviousPartsCount = partsCount; + state.PreviousCycleTimerSeconds = cycleTimerSeconds; + state.PreviousIncrementAtUtc = null; + return; + } + + // Parts-count counter reset (e.g. shift-change zero) — leave any + // previously-published LastCycle* values live, but re-baseline so the + // next positive transition produces a fresh delta. Documented as + // "counter reset preserves the last-known values" in the field-doc + // remarks; tests assert this intentionally. + if (partsCount < prevParts) + { + state.PreviousPartsCount = partsCount; + state.PreviousCycleTimerSeconds = cycleTimerSeconds; + return; + } + + // No increment this tick — slide the timer baseline forward so the + // delta on the NEXT increment reflects the true window between + // increments rather than just the gap since the last sample. + // (Choosing snapshot-at-last-increment vs snapshot-at-prior-tick is + // the core "which delta?" question; per the plan: "delta in + // Timers/CycleSeconds between successive parts-count increments" so + // we keep the cycle-timer baseline pinned to the last increment and + // only update on transition. The previous-parts-count is also kept + // pinned so subsequent equal-parts ticks remain no-ops.) + if (partsCount == prevParts) + { + // Defensive: still detect cycle-timer rollover so the next + // increment's delta isn't poisoned by a backwards baseline. + if (cycleTimerSeconds < prevTimer) + { + state.PreviousCycleTimerSeconds = cycleTimerSeconds; + } + return; + } + + // Parts-count incremented — compute the delta. A negative delta + // means the cycle timer rolled over (or got reset by the operator) + // since the previous increment; per the plan we don't publish a + // negative LastCycleSeconds. Re-baseline so the next increment + // produces a clean delta. + var deltaSeconds = cycleTimerSeconds - prevTimer; + if (deltaSeconds < 0) + { + state.PreviousPartsCount = partsCount; + state.PreviousCycleTimerSeconds = cycleTimerSeconds; + return; + } + + state.LastCycleSeconds = deltaSeconds; + state.LastCycleStartUtc = nowUtc.AddSeconds(-deltaSeconds); + state.PreviousPartsCount = partsCount; + state.PreviousCycleTimerSeconds = cycleTimerSeconds; + state.PreviousIncrementAtUtc = nowUtc; + } + private DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now) { if (!_devices.TryGetValue(hostAddress, out var device)) return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + + // F5-a derived telemetry (issue #272) — served from DeviceState.LastCycle* + // which the probe tick maintains alongside (not on top of) the wire-sourced + // production cache. Both fields surface Good with a null value when the + // derivation has not yet observed two successive parts-count increments; + // an OPC UA client that round-trips a null DateTime gets DateTime.MinValue + // through the variant boundary, which the documented "no cycle observed yet" + // sentinel makes safe to treat as "unknown". + if (string.Equals(field, "LastCycleSeconds", StringComparison.Ordinal)) + { + return new DataValueSnapshot( + device.LastCycleSeconds, + FocasStatusMapper.Good, + device.LastCycleStartUtc, + now); + } + if (string.Equals(field, "LastCycleStartUtc", StringComparison.Ordinal)) + { + return new DataValueSnapshot( + device.LastCycleStartUtc, + FocasStatusMapper.Good, + device.LastCycleStartUtc, + now); + } + if (device.LastProduction is not { } snap) return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); var value = PickProductionField(snap, field); @@ -1593,6 +1769,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public FocasProductionInfo? LastProduction { get; set; } public DateTime LastProductionUtc { get; set; } + /// + /// Plan PR F5-a (issue #272) — derivation state for + /// Production/LastCycleSeconds + Production/LastCycleStartUtc. + /// Maintained by on every + /// probe tick that returns a non-null production snapshot. All fields + /// reset on via the + /// ShutdownAsync → InitializeAsync path (DeviceState is reconstructed + /// from scratch); the derivation history doesn't carry across a CNC + /// reconnect because the FWLIB session boundary may have re-zeroed both + /// parts-count and the cycle timer. + /// + public int? PreviousPartsCount { get; set; } + public double? PreviousCycleTimerSeconds { get; set; } + public DateTime? PreviousIncrementAtUtc { get; set; } + public double? LastCycleSeconds { get; set; } + public DateTime? LastCycleStartUtc { get; set; } + /// /// Cached cnc_modal M/S/T/B snapshot, refreshed on every probe tick. /// Reads of the per-device Modal/<field> nodes serve from this cache diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCycleDeltaTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCycleDeltaTests.cs new file mode 100644 index 0000000..ce7c657 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCycleDeltaTests.cs @@ -0,0 +1,290 @@ +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; + +/// +/// Issue #272, plan PR F5-a — unit coverage of the +/// Production/LastCycleSeconds + Production/LastCycleStartUtc +/// derivation. The derivation is a pure function of the +/// FocasProductionInfo snapshot stream + wall-clock; these tests pin +/// 's contract directly so +/// the per-tick semantics stay locked even if the probe-loop wiring changes. +/// +/// +/// The Production/LastCycle* values are surfaced through the same +/// fixed-tree dispatcher the F1-b parts-count nodes use (no new wire calls +/// — pure derivation). The end-to-end shape is exercised by +/// +/// which spins up a real + a probe-tick driven +/// . +/// +[Trait("Category", "Unit")] +public sealed class FocasCycleDeltaTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static FocasDriver.DeviceState NewState() => + new(FocasHostAddress.TryParse(Host)!, new FocasDeviceOptions(Host)); + + private static FocasProductionInfo Snap(int parts, int cycleTimerSeconds) => + new(PartsProduced: parts, PartsRequired: 0, PartsTotal: 0, CycleTimeSeconds: cycleTimerSeconds); + + [Fact] + public void First_observation_establishes_baseline_without_publishing_LastCycle() + { + var state = NewState(); + var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); + + FocasDriver.UpdateCycleDerivation(state, Snap(parts: 5, cycleTimerSeconds: 10), t0); + + state.LastCycleSeconds.ShouldBeNull(); + state.LastCycleStartUtc.ShouldBeNull(); + state.PreviousPartsCount.ShouldBe(5); + state.PreviousCycleTimerSeconds.ShouldBe(10.0); + } + + [Fact] + public void No_increment_holds_LastCycle_at_null() + { + var state = NewState(); + var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); + + FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); + FocasDriver.UpdateCycleDerivation(state, Snap(5, 12), t0.AddSeconds(2)); + + // No parts-count transition — derivation has not yet observed a complete cycle + // boundary. LastCycle* stays null until the next positive transition. + state.LastCycleSeconds.ShouldBeNull(); + state.LastCycleStartUtc.ShouldBeNull(); + } + + [Fact] + public void First_increment_publishes_timer_delta_across_the_window() + { + var state = NewState(); + var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); + + // Tick 1: parts=5, timer=10 — baseline. + FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); + // Tick 2: parts=5, timer=12 — no increment, no publish. + FocasDriver.UpdateCycleDerivation(state, Snap(5, 12), t0.AddSeconds(2)); + // Tick 3: parts=6, timer=18 — first increment. Delta = 18 - 10 = 8s + // (we keep the cycle-timer baseline pinned to the LAST INCREMENT, not the + // last sample, so the delta covers the true window between increments). + FocasDriver.UpdateCycleDerivation(state, Snap(6, 18), t0.AddSeconds(8)); + + state.LastCycleSeconds.ShouldBe(8.0); + state.LastCycleStartUtc.ShouldNotBeNull(); + state.LastCycleStartUtc!.Value.ShouldBe(t0); + } + + [Fact] + public void Second_increment_publishes_fresh_delta() + { + var state = NewState(); + var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); + + FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); + FocasDriver.UpdateCycleDerivation(state, Snap(6, 18), t0.AddSeconds(8)); + // Tick 4: parts=7, timer=25 — second increment. Delta = 25 - 18 = 7s. + FocasDriver.UpdateCycleDerivation(state, Snap(7, 25), t0.AddSeconds(15)); + + state.LastCycleSeconds.ShouldBe(7.0); + state.LastCycleStartUtc!.Value.ShouldBe(t0.AddSeconds(8)); + } + + [Fact] + public void Parts_count_reset_preserves_last_known_LastCycle_values() + { + var state = NewState(); + var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); + + FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); + FocasDriver.UpdateCycleDerivation(state, Snap(6, 18), t0.AddSeconds(8)); + var publishedSeconds = state.LastCycleSeconds; + var publishedStart = state.LastCycleStartUtc; + publishedSeconds.ShouldBe(8.0); + + // Parts-count reset (e.g. shift change) — value goes backwards. The + // derivation must NOT publish a negative LastCycleSeconds; instead it + // re-baselines so the next positive transition produces a fresh delta. + // The previously-published values stay live (operators reading the tag + // mid-shift-change see the last known cycle, not Bad / null). + FocasDriver.UpdateCycleDerivation(state, Snap(0, 0), t0.AddSeconds(20)); + + state.LastCycleSeconds.ShouldBe(publishedSeconds); + state.LastCycleStartUtc.ShouldBe(publishedStart); + state.PreviousPartsCount.ShouldBe(0); + state.PreviousCycleTimerSeconds.ShouldBe(0.0); + + // Next positive transition produces the new delta. + FocasDriver.UpdateCycleDerivation(state, Snap(1, 6), t0.AddSeconds(26)); + state.LastCycleSeconds.ShouldBe(6.0); + } + + [Fact] + public void Cycle_timer_rollover_on_increment_does_not_publish_negative_delta() + { + var state = NewState(); + var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); + + FocasDriver.UpdateCycleDerivation(state, Snap(5, 18), t0); + // Tick 2: parts=6 (positive transition) but timer rolled to 0 (CNC reset + // the cycle-time timer at part completion). Per the plan we treat this + // as rollover: re-baseline without publishing a negative delta. + FocasDriver.UpdateCycleDerivation(state, Snap(6, 0), t0.AddSeconds(1)); + + state.LastCycleSeconds.ShouldBeNull(); + state.LastCycleStartUtc.ShouldBeNull(); + + // Subsequent increment publishes a clean delta from the post-rollover + // baseline. + FocasDriver.UpdateCycleDerivation(state, Snap(7, 9), t0.AddSeconds(10)); + state.LastCycleSeconds.ShouldBe(9.0); + } + + [Fact] + public void Parts_count_jump_publishes_window_timer_delta_without_per_part_division() + { + var state = NewState(); + var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); + + FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); + // Backfill: parts jumps 5 -> 8. Per the plan we treat the timer delta + // over the window as the most-recent cycle's actual duration; we do NOT + // divide by the count delta. + FocasDriver.UpdateCycleDerivation(state, Snap(8, 25), t0.AddSeconds(15)); + + state.LastCycleSeconds.ShouldBe(15.0); + state.LastCycleStartUtc!.Value.ShouldBe(t0); + } + + [Fact] + public async Task ReinitializeAsync_clears_LastCycle_state() + { + // ReinitializeAsync = ShutdownAsync + InitializeAsync. ShutdownAsync clears + // _devices entirely and InitializeAsync constructs fresh DeviceState + // instances, so the F5-a derivation history is reset across reinit per + // the plan ("the prior CNC connection's history doesn't apply post- + // reconnect"). + var fake = new ProductionAwareFakeClient + { + Production = new FocasProductionInfo(5, 0, 0, 10), + }; + var factory = new FakeFocasClientFactory { Customise = () => fake }; + var driver = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + Tags = [], + Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) }, + }, "drv-1", factory); + await driver.InitializeAsync("{}", CancellationToken.None); + + // Drive two increments through the probe loop so the device has live + // LastCycle* state. + await WaitForAsync(() => + { + var s = driver.GetDeviceState(Host); + return Task.FromResult(s?.PreviousPartsCount == 5); + }, TimeSpan.FromSeconds(3)); + fake.Production = new FocasProductionInfo(6, 0, 0, 18); + await WaitForAsync(() => + { + var s = driver.GetDeviceState(Host); + return Task.FromResult(s?.LastCycleSeconds == 8.0); + }, TimeSpan.FromSeconds(3)); + + await driver.ReinitializeAsync("{}", CancellationToken.None); + + // The operator-visible LastCycle* values reset across the reinit + // boundary — the prior CNC session's history doesn't carry over. + // Previous*/baseline state may re-populate quickly because the probe + // loop restarts immediately after init, but until a NEW post-reinit + // increment is observed the published LastCycle* values stay null. + var post = driver.GetDeviceState(Host); + post.ShouldNotBeNull(); + post!.LastCycleSeconds.ShouldBeNull(); + post.LastCycleStartUtc.ShouldBeNull(); + + await driver.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public void DeviceState_initial_field_values_are_all_null() + { + // Direct invariant — fresh DeviceState (constructed during InitializeAsync) + // has all derivation history set to null. This is the contract a reinit + // relies on (ShutdownAsync clears _devices; InitializeAsync constructs + // fresh DeviceState instances). + var state = NewState(); + state.PreviousPartsCount.ShouldBeNull(); + state.PreviousCycleTimerSeconds.ShouldBeNull(); + state.PreviousIncrementAtUtc.ShouldBeNull(); + state.LastCycleSeconds.ShouldBeNull(); + state.LastCycleStartUtc.ShouldBeNull(); + } + + [Fact] + public async Task LastCycle_round_trips_through_ReadAsync_after_two_increments() + { + // End-to-end: a probe-tick-driven snapshot stream into a real FocasDriver + // produces Production/LastCycleSeconds + Production/LastCycleStartUtc + // through the standard ReadAsync path. No additional wire calls fire — + // both nodes are served from DeviceState.LastCycle*. + var fake = new ProductionAwareFakeClient + { + Production = new FocasProductionInfo(5, 0, 0, 10), + }; + var factory = new FakeFocasClientFactory { Customise = () => fake }; + var driver = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + Tags = [], + Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(40) }, + }, "drv-1", factory); + await driver.InitializeAsync("{}", CancellationToken.None); + + // First increment baseline + flip to second value to produce a delta. + await WaitForAsync(() => + { + var s = driver.GetDeviceState(Host); + return Task.FromResult(s?.PreviousPartsCount == 5); + }, TimeSpan.FromSeconds(3)); + fake.Production = new FocasProductionInfo(6, 0, 0, 18); + await WaitForAsync(() => + { + var s = driver.GetDeviceState(Host); + return Task.FromResult(s?.LastCycleSeconds == 8.0); + }, TimeSpan.FromSeconds(3)); + + var refs = new[] + { + $"{Host}::Production/LastCycleSeconds", + $"{Host}::Production/LastCycleStartUtc", + }; + var snaps = await driver.ReadAsync(refs, CancellationToken.None); + snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + snaps[0].Value.ShouldBe(8.0); + snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good); + snaps[1].Value.ShouldBeOfType(); + + await driver.ShutdownAsync(CancellationToken.None); + } + + private static async Task WaitForAsync(Func> condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!await condition() && DateTime.UtcNow < deadline) + await Task.Delay(20); + } + + private sealed class ProductionAwareFakeClient : FakeFocasClient, IFocasClient + { + public FocasProductionInfo? Production { get; set; } + Task IFocasClient.GetProductionAsync(CancellationToken ct) => + Task.FromResult(Production); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProductionFixedTreeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProductionFixedTreeTests.cs index a831b51..6dff9b3 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProductionFixedTreeTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProductionFixedTreeTests.cs @@ -39,9 +39,12 @@ public sealed class FocasProductionFixedTreeTests builder.Folders.ShouldContain(f => f.BrowseName == "Production" && f.DisplayName == "Production"); var prodVars = builder.Variables.Where(v => v.Info.FullName.Contains("::Production/")).ToList(); - prodVars.Count.ShouldBe(4); - string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"]; - foreach (var name in expected) + // Issue #272 (plan PR F5-a) added the two derived telemetry nodes + // LastCycleSeconds + LastCycleStartUtc on top of the original 4 F1-b + // wire-sourced fields, for 6 total Production/ children per device. + prodVars.Count.ShouldBe(6); + string[] int32Fields = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"]; + foreach (var name in int32Fields) { var node = prodVars.SingleOrDefault(v => v.BrowseName == name); node.BrowseName.ShouldBe(name); @@ -49,6 +52,17 @@ public sealed class FocasProductionFixedTreeTests node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); node.Info.FullName.ShouldBe($"{Host}::Production/{name}"); } + // F5-a derived telemetry — LastCycleSeconds is Float64 (sub-second + // precision is meaningful at fast cycle times); LastCycleStartUtc is + // DateTime UTC, matching the Diagnostics/LastSuccessfulRead surface. + var lastSec = prodVars.SingleOrDefault(v => v.BrowseName == "LastCycleSeconds"); + lastSec.BrowseName.ShouldBe("LastCycleSeconds"); + lastSec.Info.DriverDataType.ShouldBe(DriverDataType.Float64); + lastSec.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + var lastStart = prodVars.SingleOrDefault(v => v.BrowseName == "LastCycleStartUtc"); + lastStart.BrowseName.ShouldBe("LastCycleStartUtc"); + lastStart.Info.DriverDataType.ShouldBe(DriverDataType.DateTime); + lastStart.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } [Fact] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/CycleDeltaTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/CycleDeltaTests.cs new file mode 100644 index 0000000..3a3db45 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/CycleDeltaTests.cs @@ -0,0 +1,57 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.Series; + +/// +/// Issue #272, plan PR F5-a — series-level (would-be integration) coverage of +/// Production/LastCycleSeconds + Production/LastCycleStartUtc. +/// The derivation is pure (no new wire calls — see +/// docs/v2/focas-deployment.md § "Derived telemetry") so the +/// real-simulator test asserts the existing cnc_rdparam(6711) + +/// cycle-timer poll that F1-b already emits drives both fields end-to-end. +/// +/// +/// Build-only today: focas-mock has not yet shipped (tracked under +/// docs/v2/implementation/focas-simulator-plan.md § "Cycle-time per +/// part / last cycle delta — F5-a"). The unit-test coverage in +/// exercises every same-process invariant +/// of the derivation. The gated test below materialises the +/// SimulateCycleCompletionAsync + admin-endpoint contract once the +/// simulator binary lands. +/// +[Trait("Category", "Series")] +public sealed class CycleDeltaTests +{ + [Fact] + public void Derivation_contract_is_documented() + { + // Build-only scaffold — see FocasCycleDeltaTests for the actual fake-backed + // assertion. The integration version of this test (gated on a focas-mock + // simulator with a SimulateCycleCompletionAsync admin endpoint) will: + // 1. Spin up FocasDriver pointed at the simulator with parts=5, timer=10s. + // 2. Wait for the first probe tick (baseline established). + // 3. Call simulator.SimulateCycleCompletionAsync(profile, parts: 6, timer: 18s). + // 4. Wait for the next probe tick to refresh the production cache. + // 5. Read Production/LastCycleSeconds + Production/LastCycleStartUtc through + // the OPC UA surface; assert delta == 8.0 and the timestamp is now - 8s. + // The driver-side derivation already locks the contract in unit tests; this + // scaffold pins the simulator-side contract for the focas-mock implementor. + var info = typeof(FocasDriver).GetMethod( + nameof(FocasDriver.UpdateCycleDerivation), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + info.ShouldNotBeNull(); + } + + [Fact(Skip = "Hardware-gated — requires focas-mock with the SimulateCycleCompletionAsync admin endpoint (focas-simulator-plan.md § 'Cycle-time per part / last cycle delta — F5-a').")] + public Task Live_simulator_cycle_completion_round_trip() + { + // Body deliberately empty — the [Skip] attribute keeps this off the CI + // lane. When focas-mock lands the SimulateCycleCompletionAsync helper + + // matching admin endpoint, this test materialises a FocasDriver pointed + // at the simulator + drives the parts-count 5 -> 6 transition through real + // wire calls. + return Task.CompletedTask; + } +}