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;
+ }
+}