[focas] FOCAS — Cycle time per part / last cycle delta #401
@@ -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`:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -58,13 +58,46 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
|
||||
/// Names of the fixed-tree <c>Production/</c> child nodes per device — parts
|
||||
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
|
||||
/// seconds (issue #258). Order matters for deterministic discovery output.
|
||||
/// seconds (issue #258), plus the F5-a derived telemetry pair
|
||||
/// <c>LastCycleSeconds</c> + <c>LastCycleStartUtc</c> (issue #272).
|
||||
/// Order matters for deterministic discovery output.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The F5-a derivation is pure — it observes the same
|
||||
/// <c>cnc_rdparam(6711)</c> + cycle-timer values the existing F1-b
|
||||
/// projection already pulls on the probe tick, so no additional wire
|
||||
/// calls are issued.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>LastCycleSeconds</c> = the cycle-timer delta observed across
|
||||
/// two successive parts-count increments (<c>currentTimer -
|
||||
/// timerAtPreviousIncrement</c>). <c>LastCycleStartUtc</c> = the wall-
|
||||
/// clock at the moment of the second increment minus
|
||||
/// <c>LastCycleSeconds</c>. Both values are <c>null</c> until the second
|
||||
/// observed parts-count increment (one increment establishes the
|
||||
/// baseline; the second produces the first delta).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A parts-count counter reset (e.g. shift change, value goes
|
||||
/// backwards) re-baselines the state without emitting a negative
|
||||
/// <c>LastCycleSeconds</c>; 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 <c>LastCycle*</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly string[] ProductionFieldNames =
|
||||
[
|
||||
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
|
||||
"LastCycleSeconds", "LastCycleStartUtc",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F5-a (issue #272) — per-field <see cref="DriverDataType"/>
|
||||
/// dispatch for the <c>Production/</c> subtree. The four wire-sourced fields
|
||||
/// (PartsProduced / Required / Total + CycleTimeSeconds) stay
|
||||
/// <see cref="DriverDataType.Int32"/> for back-compat with the F1-b surface;
|
||||
/// the two F5-a derived fields surface as <see cref="DriverDataType.Float64"/>
|
||||
/// (sub-second precision is meaningful at fast cycle times) and
|
||||
/// <see cref="DriverDataType.DateTime"/> (UTC) respectively.
|
||||
/// </summary>
|
||||
private static DriverDataType ProductionFieldType(string field) => field switch
|
||||
{
|
||||
"LastCycleSeconds" => DriverDataType.Float64,
|
||||
"LastCycleStartUtc" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F5-a (issue #272) — pure derivation that maintains the per-device
|
||||
/// <c>LastCycleSeconds</c> + <c>LastCycleStartUtc</c> projection from the
|
||||
/// wire-sourced production snapshot the probe loop already collected.
|
||||
/// <para>
|
||||
/// The derivation observes <c>PartsProduced</c> + <c>CycleTimeSeconds</c>
|
||||
/// 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
|
||||
/// <c>LastCycleSeconds</c>; <c>LastCycleStartUtc</c> = current wall clock
|
||||
/// minus that delta.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Edge cases — see the <see cref="ProductionFieldNames"/> remarks for the
|
||||
/// full doc:
|
||||
/// <list type="bullet">
|
||||
/// <item>First observation establishes the baseline; no
|
||||
/// <c>LastCycleSeconds</c> is published until the second.</item>
|
||||
/// <item>Parts-count counter reset (current < previous) re-baselines
|
||||
/// without clobbering the most-recent published <c>LastCycle*</c>.</item>
|
||||
/// <item>Cycle-timer rollover (delta < 0 with positive parts
|
||||
/// increment) re-baselines without publishing a negative delta.</item>
|
||||
/// <item>Parts-count jumps > 1 publish the timer delta as one
|
||||
/// <c>LastCycleSeconds</c> per the plan's "delta over the window"
|
||||
/// definition — no per-part division.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F5-a (issue #272) — derivation state for
|
||||
/// <c>Production/LastCycleSeconds</c> + <c>Production/LastCycleStartUtc</c>.
|
||||
/// Maintained by <see cref="FocasDriver.UpdateCycleDerivation"/> on every
|
||||
/// probe tick that returns a non-null production snapshot. All fields
|
||||
/// reset on <see cref="FocasDriver.ReinitializeAsync"/> 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
|
||||
/// Reads of the per-device <c>Modal/<field></c> nodes serve from this cache
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #272, plan PR F5-a — unit coverage of the
|
||||
/// <c>Production/LastCycleSeconds</c> + <c>Production/LastCycleStartUtc</c>
|
||||
/// derivation. The derivation is a pure function of the
|
||||
/// <c>FocasProductionInfo</c> snapshot stream + wall-clock; these tests pin
|
||||
/// <see cref="FocasDriver.UpdateCycleDerivation"/>'s contract directly so
|
||||
/// the per-tick semantics stay locked even if the probe-loop wiring changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>Production/LastCycle*</c> 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
|
||||
/// <see cref="LastCycle_round_trips_through_ReadAsync_after_two_increments"/>
|
||||
/// which spins up a real <see cref="FocasDriver"/> + a probe-tick driven
|
||||
/// <see cref="FakeFocasClient"/>.
|
||||
/// </remarks>
|
||||
[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<DateTime>();
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> 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<FocasProductionInfo?> IFocasClient.GetProductionAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Production);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #272, plan PR F5-a — series-level (would-be integration) coverage of
|
||||
/// <c>Production/LastCycleSeconds</c> + <c>Production/LastCycleStartUtc</c>.
|
||||
/// The derivation is pure (no new wire calls — see
|
||||
/// <c>docs/v2/focas-deployment.md</c> § "Derived telemetry") so the
|
||||
/// real-simulator test asserts the existing <c>cnc_rdparam(6711)</c> +
|
||||
/// cycle-timer poll that F1-b already emits drives both fields end-to-end.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Build-only today: focas-mock has not yet shipped (tracked under
|
||||
/// <c>docs/v2/implementation/focas-simulator-plan.md</c> § "Cycle-time per
|
||||
/// part / last cycle delta — F5-a"). The unit-test coverage in
|
||||
/// <see cref="FocasCycleDeltaTests"/> exercises every same-process invariant
|
||||
/// of the derivation. The gated test below materialises the
|
||||
/// <c>SimulateCycleCompletionAsync</c> + admin-endpoint contract once the
|
||||
/// simulator binary lands.
|
||||
/// </remarks>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user