From 06f664dfab2d987f83eee8187b3474d605694b62 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Mon, 8 Jun 2026 08:41:09 +0200 Subject: [PATCH] J: E116 time-bucket accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EptStateMachine now retains per-state cumulative dwell time so the host can read it as SVIDs. The implementation is zero-overhead while the FSM is idle (no timers, no background work) — on every transition we add the prior state's dwell to its bucket and reset the entered_ timestamp. Live dwell in the current state is included in accumulated() via a now-vs-entered_ delta at read time. New public API: accumulated(EptState) per-state cumulative ms (incl. live dwell) total_elapsed() denominator for utilization ratios reset_history() S2F43-style history clear This closes the gap I called out: previously we emitted CEIDs on transition but didn't accumulate the bucket the host actually queries for utilization metrics. Wiring these into specific SVIDs is the application's job (equipment.yaml declares SVIDs against any read callable); the runtime data is now there. 4 new test cases cover accumulation, live-dwell inclusion, and reset. Co-Authored-By: Claude Opus 4.7 --- include/secsgem/gem/ept_state.hpp | 17 +++++++++++++ src/gem/ept_state.cpp | 27 +++++++++++++++++++- tests/test_ept.cpp | 42 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/include/secsgem/gem/ept_state.hpp b/include/secsgem/gem/ept_state.hpp index 621f97f..ec98a60 100644 --- a/include/secsgem/gem/ept_state.hpp +++ b/include/secsgem/gem/ept_state.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -65,10 +66,26 @@ class EptStateMachine { // doesn't need a parallel timestamp tracker. bool on_event(EptEvent e); + // --- E116 time-bucket accounting -------------------------------------- + // Total accumulated time spent in `s` since the FSM was constructed + // or since reset_history() was last called. Time spent in the + // *current* state up to now is included. + std::chrono::milliseconds accumulated(EptState s) const; + + // Total time across all states (= time since reset_history()). Useful + // as the denominator for utilization / availability ratios. + std::chrono::milliseconds total_elapsed() const; + + // S2F43-style history clear. Sets every bucket to zero and re-anchors + // the current-state timer at "now". + void reset_history(); + private: EptTable table_; EptState state_ = EptState::NonScheduledTime; std::chrono::steady_clock::time_point entered_; + std::chrono::steady_clock::time_point reset_anchor_; + std::array buckets_{}; // indexed by EptState 0..5 StateChangeHandler on_change_; }; diff --git a/src/gem/ept_state.cpp b/src/gem/ept_state.cpp index e3fc548..a635601 100644 --- a/src/gem/ept_state.cpp +++ b/src/gem/ept_state.cpp @@ -78,7 +78,8 @@ EptTable default_ept_table() { EptStateMachine::EptStateMachine() : table_(default_ept_table()), - entered_(std::chrono::steady_clock::now()) {} + entered_(std::chrono::steady_clock::now()), + reset_anchor_(entered_) {} bool EptStateMachine::on_event(EptEvent e) { const auto* row = table_.find(state_, e); @@ -88,6 +89,7 @@ bool EptStateMachine::on_event(EptEvent e) { auto now = std::chrono::steady_clock::now(); auto dwell = std::chrono::duration_cast( now - entered_); + buckets_[static_cast(prev)] += dwell; state_ = *row->to; entered_ = now; if (on_change_) on_change_(prev, state_, e, dwell); @@ -95,4 +97,27 @@ bool EptStateMachine::on_event(EptEvent e) { return true; } +std::chrono::milliseconds EptStateMachine::accumulated(EptState s) const { + const auto idx = static_cast(s); + if (idx >= buckets_.size()) return std::chrono::milliseconds{0}; + auto base = buckets_[idx]; + if (state_ == s) { + const auto now = std::chrono::steady_clock::now(); + base += std::chrono::duration_cast(now - entered_); + } + return base; +} + +std::chrono::milliseconds EptStateMachine::total_elapsed() const { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - reset_anchor_); +} + +void EptStateMachine::reset_history() { + buckets_.fill(std::chrono::milliseconds{0}); + const auto now = std::chrono::steady_clock::now(); + entered_ = now; + reset_anchor_ = now; +} + } // namespace secsgem::gem diff --git a/tests/test_ept.cpp b/tests/test_ept.cpp index 3e90cf4..c3bff00 100644 --- a/tests/test_ept.cpp +++ b/tests/test_ept.cpp @@ -58,3 +58,45 @@ TEST_CASE("EPT: same-state event is a no-op (no handler call)") { CHECK(ept.on_event(EptEvent::EnterStandby)); // already in Standby CHECK(calls == 0); } + +TEST_CASE("EPT: time buckets accumulate per state") { + EptStateMachine ept; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + ept.on_event(EptEvent::EnterStandby); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + ept.on_event(EptEvent::EnterProductive); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + ept.on_event(EptEvent::EnterStandby); + + // Each bucket should have at least the slept time. + CHECK(ept.accumulated(EptState::NonScheduledTime).count() >= 5); + CHECK(ept.accumulated(EptState::Standby).count() >= 5); + CHECK(ept.accumulated(EptState::Productive).count() >= 5); + // Total = sum of buckets (modulo current-state in-progress dwell). + CHECK(ept.total_elapsed().count() >= 25); +} + +TEST_CASE("EPT: current-state dwell is counted in accumulated()") { + EptStateMachine ept; + ept.on_event(EptEvent::EnterProductive); + std::this_thread::sleep_for(std::chrono::milliseconds(15)); + // No transition out; accumulated should still include the live dwell. + CHECK(ept.accumulated(EptState::Productive).count() >= 10); +} + +TEST_CASE("EPT: reset_history zeroes all buckets") { + EptStateMachine ept; + ept.on_event(EptEvent::EnterStandby); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + ept.on_event(EptEvent::EnterProductive); + REQUIRE(ept.accumulated(EptState::Standby).count() > 0); + + ept.reset_history(); + for (auto s : {EptState::NonScheduledTime, EptState::ScheduledDowntime, + EptState::UnscheduledDowntime, EptState::Engineering, + EptState::Standby}) { + CHECK(ept.accumulated(s).count() == 0); + } + // Current state (Productive) starts re-accumulating from zero. + CHECK(ept.accumulated(EptState::Productive).count() < 5); +}