F1: EptStateMachine (E116 Equipment Performance Tracking)

Adds the six E116-0712 §6.2 buckets for classifying equipment time:

  NonScheduledTime (0)   not scheduled to operate
  ScheduledDowntime (1)  planned maintenance window
  UnscheduledDowntime (2) faults / unplanned stoppage
  Engineering (3)        engineering / qualification time
  Standby (4)            idle but available
  Productive (5)         actively producing

Wire-byte values pinned via static_assert to E116 §10.3.

The FSM is a classifier rather than a strict lifecycle — every
(state, event) pair is legal — but it remains data-driven through the
shared CarrierTransitionTable template so the default cross-product is
expressible declaratively.

The state-change handler also surfaces dwell time (how long the
previous state was held) computed off std::chrono::steady_clock, so
accounting code can compute MTBF / availability / utilization from a
single source without maintaining a parallel timestamp log.

4 test cases cover the initial state, every event firing, dwell-time
reporting, and the no-op same-state event (no handler call).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 02:17:16 +02:00
parent 777fa5e9f9
commit 7bff01c363
4 changed files with 235 additions and 0 deletions
+2
View File
@@ -57,6 +57,7 @@ add_library(secsgem
src/gem/carrier_state.cpp src/gem/carrier_state.cpp
src/gem/load_port_state.cpp src/gem/load_port_state.cpp
src/gem/substrate_state.cpp src/gem/substrate_state.cpp
src/gem/ept_state.cpp
src/gem/host_handler.cpp src/gem/host_handler.cpp
src/config/loader.cpp src/config/loader.cpp
src/endpoint.cpp src/endpoint.cpp
@@ -104,6 +105,7 @@ add_executable(secsgem_tests
tests/test_carrier_state.cpp tests/test_carrier_state.cpp
tests/test_carriers.cpp tests/test_carriers.cpp
tests/test_substrates.cpp tests/test_substrates.cpp
tests/test_ept.cpp
) )
target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest)
target_compile_definitions(secsgem_tests PRIVATE target_compile_definitions(secsgem_tests PRIVATE
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include "secsgem/gem/carrier_state.hpp" // shared CarrierTransition template
// E116 §6 Equipment Performance Tracking — top-level equipment state
// model that classifies operating time into productive / non-productive
// categories. Each transition is timestamped so the application can
// compute MTBF / availability / utilization from the same source.
//
// The states correspond directly to the SEMI-defined buckets in E116-
// 0712 §6.2; the wire byte values match the order the spec presents.
namespace secsgem::gem {
enum class EptState : uint8_t {
NonScheduledTime = 0, // equipment not scheduled to operate
ScheduledDowntime = 1, // scheduled maintenance
UnscheduledDowntime = 2, // unscheduled downtime (faults)
Engineering = 3, // engineering / qualification time
Standby = 4, // idle, available but not producing
Productive = 5, // actively producing
NoState = 255,
};
const char* ept_state_name(EptState s);
std::optional<EptState> parse_ept_state(const std::string& s);
// Events correspond to transitions the equipment can declare; events
// are intentionally state-neutral (any -> X) since EPT is a "what kind
// of time is this" classifier rather than a strict lifecycle.
enum class EptEvent {
EnterNonScheduled,
EnterScheduledDown,
EnterUnscheduledDown,
EnterEngineering,
EnterStandby,
EnterProductive,
};
const char* ept_event_name(EptEvent e);
using EptTable = CarrierTransitionTable<EptState, EptEvent>;
EptTable default_ept_table();
class EptStateMachine {
public:
using StateChangeHandler =
std::function<void(EptState from, EptState to, EptEvent,
std::chrono::milliseconds dwell_before_change)>;
EptStateMachine();
EptState state() const { return state_; }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
// Fire a transition. Returns true if a row matched. The dwell time
// (how long we sat in the previous state) is computed off the
// monotonic clock and surfaced to the handler so accounting code
// doesn't need a parallel timestamp tracker.
bool on_event(EptEvent e);
private:
EptTable table_;
EptState state_ = EptState::NonScheduledTime;
std::chrono::steady_clock::time_point entered_;
StateChangeHandler on_change_;
};
} // namespace secsgem::gem
+98
View File
@@ -0,0 +1,98 @@
#include "secsgem/gem/ept_state.hpp"
namespace secsgem::gem {
static_assert(static_cast<uint8_t>(EptState::NonScheduledTime) == 0);
static_assert(static_cast<uint8_t>(EptState::ScheduledDowntime) == 1);
static_assert(static_cast<uint8_t>(EptState::UnscheduledDowntime) == 2);
static_assert(static_cast<uint8_t>(EptState::Engineering) == 3);
static_assert(static_cast<uint8_t>(EptState::Standby) == 4);
static_assert(static_cast<uint8_t>(EptState::Productive) == 5);
const char* ept_state_name(EptState s) {
switch (s) {
case EptState::NonScheduledTime: return "NonScheduledTime";
case EptState::ScheduledDowntime: return "ScheduledDowntime";
case EptState::UnscheduledDowntime: return "UnscheduledDowntime";
case EptState::Engineering: return "Engineering";
case EptState::Standby: return "Standby";
case EptState::Productive: return "Productive";
case EptState::NoState: return "NoState";
}
return "?";
}
std::optional<EptState> parse_ept_state(const std::string& s) {
if (s == "NonScheduledTime") return EptState::NonScheduledTime;
if (s == "ScheduledDowntime") return EptState::ScheduledDowntime;
if (s == "UnscheduledDowntime") return EptState::UnscheduledDowntime;
if (s == "Engineering") return EptState::Engineering;
if (s == "Standby") return EptState::Standby;
if (s == "Productive") return EptState::Productive;
if (s == "NoState") return EptState::NoState;
return std::nullopt;
}
const char* ept_event_name(EptEvent e) {
switch (e) {
case EptEvent::EnterNonScheduled: return "EnterNonScheduled";
case EptEvent::EnterScheduledDown: return "EnterScheduledDown";
case EptEvent::EnterUnscheduledDown: return "EnterUnscheduledDown";
case EptEvent::EnterEngineering: return "EnterEngineering";
case EptEvent::EnterStandby: return "EnterStandby";
case EptEvent::EnterProductive: return "EnterProductive";
}
return "?";
}
namespace {
// Each event has exactly one target state regardless of source.
EptState target_of(EptEvent e) {
switch (e) {
case EptEvent::EnterNonScheduled: return EptState::NonScheduledTime;
case EptEvent::EnterScheduledDown: return EptState::ScheduledDowntime;
case EptEvent::EnterUnscheduledDown: return EptState::UnscheduledDowntime;
case EptEvent::EnterEngineering: return EptState::Engineering;
case EptEvent::EnterStandby: return EptState::Standby;
case EptEvent::EnterProductive: return EptState::Productive;
}
return EptState::NoState;
}
} // namespace
EptTable default_ept_table() {
EptTable t;
// Cross-product: every source state accepts every event. The FSM is
// a classifier, not a strict workflow, so all transitions are legal.
for (auto src : {EptState::NonScheduledTime, EptState::ScheduledDowntime,
EptState::UnscheduledDowntime, EptState::Engineering,
EptState::Standby, EptState::Productive}) {
for (auto ev : {EptEvent::EnterNonScheduled, EptEvent::EnterScheduledDown,
EptEvent::EnterUnscheduledDown, EptEvent::EnterEngineering,
EptEvent::EnterStandby, EptEvent::EnterProductive}) {
t.add({src, ev, target_of(ev), std::nullopt});
}
}
return t;
}
EptStateMachine::EptStateMachine()
: table_(default_ept_table()),
entered_(std::chrono::steady_clock::now()) {}
bool EptStateMachine::on_event(EptEvent e) {
const auto* row = table_.find(state_, e);
if (!row) return false;
if (row->to && *row->to != state_) {
auto prev = state_;
auto now = std::chrono::steady_clock::now();
auto dwell = std::chrono::duration_cast<std::chrono::milliseconds>(
now - entered_);
state_ = *row->to;
entered_ = now;
if (on_change_) on_change_(prev, state_, e, dwell);
}
return true;
}
} // namespace secsgem::gem
+60
View File
@@ -0,0 +1,60 @@
#include <doctest/doctest.h>
#include <chrono>
#include <thread>
#include <vector>
#include "secsgem/gem/ept_state.hpp"
using namespace secsgem::gem;
TEST_CASE("EPT: initial state is NonScheduledTime") {
EptStateMachine ept;
CHECK(ept.state() == EptState::NonScheduledTime);
}
TEST_CASE("EPT: every event transitions to exactly its target state") {
EptStateMachine ept;
CHECK(ept.on_event(EptEvent::EnterStandby));
CHECK(ept.state() == EptState::Standby);
CHECK(ept.on_event(EptEvent::EnterProductive));
CHECK(ept.state() == EptState::Productive);
CHECK(ept.on_event(EptEvent::EnterUnscheduledDown));
CHECK(ept.state() == EptState::UnscheduledDowntime);
CHECK(ept.on_event(EptEvent::EnterEngineering));
CHECK(ept.state() == EptState::Engineering);
CHECK(ept.on_event(EptEvent::EnterScheduledDown));
CHECK(ept.state() == EptState::ScheduledDowntime);
CHECK(ept.on_event(EptEvent::EnterNonScheduled));
CHECK(ept.state() == EptState::NonScheduledTime);
}
TEST_CASE("EPT: change handler reports dwell time") {
EptStateMachine ept;
std::vector<std::pair<EptState, std::chrono::milliseconds>> transitions;
ept.set_state_change_handler(
[&](EptState, EptState to, EptEvent, std::chrono::milliseconds dwell) {
transitions.emplace_back(to, dwell);
});
std::this_thread::sleep_for(std::chrono::milliseconds(5));
ept.on_event(EptEvent::EnterStandby);
std::this_thread::sleep_for(std::chrono::milliseconds(5));
ept.on_event(EptEvent::EnterProductive);
REQUIRE(transitions.size() == 2);
CHECK(transitions[0].first == EptState::Standby);
CHECK(transitions[0].second.count() >= 1); // dwelled in NonScheduledTime
CHECK(transitions[1].first == EptState::Productive);
CHECK(transitions[1].second.count() >= 1); // dwelled in Standby
}
TEST_CASE("EPT: same-state event is a no-op (no handler call)") {
EptStateMachine ept;
ept.on_event(EptEvent::EnterStandby);
int calls = 0;
ept.set_state_change_handler(
[&](EptState, EptState, EptEvent, std::chrono::milliseconds) { ++calls; });
CHECK(ept.on_event(EptEvent::EnterStandby)); // already in Standby
CHECK(calls == 0);
}