Auto: focas-f5a — cycle time per part / last cycle delta

Closes #272
This commit is contained in:
Joseph Doherty
2026-04-26 09:11:21 -04:00
parent 45770e8d90
commit e3d7c65f61
7 changed files with 711 additions and 9 deletions

View File

@@ -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 &lt; 0 leaves the
/// current <c>LastCycle*</c> values untouched. A parts-count jump of
/// &gt; 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 &gt;= 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 &lt; previous) re-baselines
/// without clobbering the most-recent published <c>LastCycle*</c>.</item>
/// <item>Cycle-timer rollover (delta &lt; 0 with positive parts
/// increment) re-baselines without publishing a negative delta.</item>
/// <item>Parts-count jumps &gt; 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/&lt;field&gt;</c> nodes serve from this cache