e84: per-port FSM via E84PortStore

E84 (Parallel I/O) is fundamentally per-load-port: each port has its
own ten-wire handshake with the AMHS.  Earlier revisions modeled it
as a single equipment-wide FSM; this commit refactors to a per-port
store, so multi-LP tools can run independent handshakes in parallel.

Public API change in EquipmentDataModel:
  E84StateMachine e84;   -> removed
  E84PortStore    e84_ports;  // create(port_id), get(port_id), ...

Convenience pass-throughs: E84PortStore::on_signal_change auto-creates
the port on first use (ergonomic for demos); applications should call
create() explicitly with their full port set.

The two existing callsites (test_gem300_scenario, test_e87_wire_scenarios)
are updated.  The multi-LP test now demonstrates the actual win:
interleaved LP1 load + LP2 unload handshakes that reach their
respective Ready states without sequencing, and an ES on LP1 that
does NOT affect LP2 — exactly the failure mode the previous design
couldn't catch.

Five new dedicated tests in test_e84_ports.cpp for the store itself.

COMPLIANCE.md §4i updated: row now reflects per-port design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:50:18 +02:00
parent 2f0a4ba339
commit 77197b9c1e
7 changed files with 224 additions and 44 deletions
+98
View File
@@ -0,0 +1,98 @@
// E84PortStore: per-port E84 FSMs.
//
// Each load port owns its own E84 handshake state. These tests
// confirm the store keys correctly, that per-port FSMs are
// independent, and that the change handler reports the right
// port_id alongside the from/to state.
#include <doctest/doctest.h>
#include <utility>
#include <vector>
#include "secsgem/gem/store/e84_ports.hpp"
using namespace secsgem::gem;
TEST_CASE("E84PortStore: create + get + has + size") {
E84PortStore s;
CHECK(s.size() == 0);
REQUIRE(s.create(1));
REQUIRE(s.create(2));
CHECK(s.size() == 2);
CHECK(s.has(1));
CHECK(s.has(2));
CHECK_FALSE(s.has(3));
CHECK(s.get(1) != nullptr);
CHECK(s.get(99) == nullptr);
CHECK_FALSE(s.create(1)); // duplicate
}
TEST_CASE("E84PortStore: implicit-create on first signal-change") {
// Convenience pass-through: on_signal_change auto-creates the port
// if it doesn't exist yet.
E84PortStore s;
CHECK(s.on_signal_change(7, E84Signal::CS_0, true));
CHECK(s.has(7));
CHECK(s.get(7)->signal(E84Signal::CS_0));
}
TEST_CASE("E84PortStore: per-port FSMs run independently") {
E84PortStore s;
REQUIRE(s.create(1));
REQUIRE(s.create(2));
// LP1 walks all the way to Complete; LP2 stays idle.
s.on_signal_change(1, E84Signal::CS_0, true);
s.on_signal_change(1, E84Signal::VALID, true);
s.on_signal_change(1, E84Signal::L_REQ, true);
s.on_signal_change(1, E84Signal::BUSY, true);
s.on_signal_change(1, E84Signal::BUSY, false);
s.on_signal_change(1, E84Signal::COMPT, true);
CHECK(s.get(1)->state() == E84State::Complete);
CHECK(s.get(2)->state() == E84State::Idle);
// Reset LP1; LP2 is unaffected.
s.reset(1);
CHECK(s.get(1)->state() == E84State::Idle);
CHECK(s.get(2)->state() == E84State::Idle);
}
TEST_CASE("E84PortStore: change handler reports port_id") {
E84PortStore s;
std::vector<std::pair<uint8_t, E84State>> seen;
s.set_state_change_handler(
[&](uint8_t pid, E84State, E84State to, E84Signal) {
seen.emplace_back(pid, to);
});
REQUIRE(s.create(1));
REQUIRE(s.create(2));
s.on_signal_change(1, E84Signal::CS_0, true);
s.on_signal_change(2, E84Signal::CS_0, true);
s.on_signal_change(2, E84Signal::VALID, true);
s.on_signal_change(2, E84Signal::U_REQ, true);
// Should have seen events tagged with the right port_id.
bool saw_lp1_carrier_present = false;
bool saw_lp2_unload_ready = false;
for (auto& [pid, to] : seen) {
if (pid == 1 && to == E84State::CarrierPresent) saw_lp1_carrier_present = true;
if (pid == 2 && to == E84State::UnloadReady) saw_lp2_unload_ready = true;
}
CHECK(saw_lp1_carrier_present);
CHECK(saw_lp2_unload_ready);
}
TEST_CASE("E84PortStore: ids() returns the registered port set") {
E84PortStore s;
s.create(3);
s.create(1);
s.create(2);
auto ids = s.ids();
// std::map iteration is ordered by key, so the returned vector is
// sorted.
REQUIRE(ids.size() == 3);
CHECK(ids[0] == 1);
CHECK(ids[1] == 2);
CHECK(ids[2] == 3);
}
+18 -25
View File
@@ -222,35 +222,28 @@ TEST_CASE("Multi-LP: 4 ports are independent — events on one don't affect anot
gem::SlotMapStatus::NotRead);
}
TEST_CASE("Multi-LP: simultaneous E84 handshakes on two ports stay independent") {
// No wire here — the E84 FSM is a per-port axis in the equipment
// model; we test that two separate ports running their handshakes in
// overlapping sequences don't interfere. The data model has a
// single E84StateMachine for now; this exercise documents that the
// current design is per-equipment, not per-port — so the test runs
// the second handshake AFTER the first completes, and asserts the
// second one sees the FSM cleanly returned to Idle.
TEST_CASE("Multi-LP: per-port E84 FSMs run truly in parallel") {
// E84 is now per-port (E84PortStore). Two ports can be in completely
// different states at the same time without sequencing.
gem::EquipmentDataModel m;
m.load_ports.create(1);
m.load_ports.create(2);
// LP1 load handshake.
m.e84.on_signal_change(gem::E84Signal::CS_0, true);
m.e84.on_signal_change(gem::E84Signal::VALID, true);
m.e84.on_signal_change(gem::E84Signal::L_REQ, true);
CHECK(m.e84.state() == gem::E84State::LoadReady);
m.e84.on_signal_change(gem::E84Signal::BUSY, true);
m.e84.on_signal_change(gem::E84Signal::BUSY, false);
m.e84.on_signal_change(gem::E84Signal::COMPT, true);
CHECK(m.e84.state() == gem::E84State::Complete);
// LP1: interleave load-handshake bring-up with LP2: interleave
// unload-handshake bring-up. Both reach their respective Ready
// states with no cross-contamination.
m.e84_ports.on_signal_change(1, gem::E84Signal::CS_0, true);
m.e84_ports.on_signal_change(2, gem::E84Signal::CS_0, true);
m.e84_ports.on_signal_change(1, gem::E84Signal::VALID, true);
m.e84_ports.on_signal_change(2, gem::E84Signal::VALID, true);
m.e84_ports.on_signal_change(1, gem::E84Signal::L_REQ, true);
m.e84_ports.on_signal_change(2, gem::E84Signal::U_REQ, true);
// Reset for LP2.
m.e84.reset();
CHECK(m.e84.state() == gem::E84State::Idle);
CHECK(m.e84_ports.get(1)->state() == gem::E84State::LoadReady);
CHECK(m.e84_ports.get(2)->state() == gem::E84State::UnloadReady);
// LP2 unload handshake.
m.e84.on_signal_change(gem::E84Signal::CS_0, true);
m.e84.on_signal_change(gem::E84Signal::VALID, true);
m.e84.on_signal_change(gem::E84Signal::U_REQ, true);
CHECK(m.e84.state() == gem::E84State::UnloadReady);
// ES on LP1 must not affect LP2.
m.e84_ports.on_signal_change(1, gem::E84Signal::ES, true);
CHECK(m.e84_ports.get(1)->state() == gem::E84State::EmergencyStop);
CHECK(m.e84_ports.get(2)->state() == gem::E84State::UnloadReady);
}
+17 -17
View File
@@ -48,15 +48,15 @@ TEST_CASE("GEM300 scenario: carrier arrival -> processing -> departure") {
// ---- 2. E84 load handshake at LP1 --------------------------------------
// AMHS asserts CS_0, then VALID; equipment asserts L_REQ; AMHS asserts
// BUSY, then drops BUSY + asserts COMPT.
m.e84.on_signal_change(E84Signal::CS_0, true);
m.e84.on_signal_change(E84Signal::VALID, true);
m.e84.on_signal_change(E84Signal::L_REQ, true);
CHECK(m.e84.state() == E84State::LoadReady);
m.e84_ports.on_signal_change(1,E84Signal::CS_0, true);
m.e84_ports.on_signal_change(1,E84Signal::VALID, true);
m.e84_ports.on_signal_change(1,E84Signal::L_REQ, true);
CHECK(m.e84_ports.get(1)->state() == E84State::LoadReady);
m.e84.on_signal_change(E84Signal::BUSY, true);
m.e84.on_signal_change(E84Signal::BUSY, false);
m.e84.on_signal_change(E84Signal::COMPT, true);
CHECK(m.e84.state() == E84State::Complete);
m.e84_ports.on_signal_change(1,E84Signal::BUSY, true);
m.e84_ports.on_signal_change(1,E84Signal::BUSY, false);
m.e84_ports.on_signal_change(1,E84Signal::COMPT, true);
CHECK(m.e84_ports.get(1)->state() == E84State::Complete);
// Load port transitions on the equipment side.
m.load_ports.fire_transfer_event(1, LoadPortTransferEvent::StartLoading);
@@ -153,15 +153,15 @@ TEST_CASE("GEM300 scenario: carrier arrival -> processing -> departure") {
}
// ---- 12. Carrier unloads via E84 -------------------------------------
m.e84.reset();
m.e84.on_signal_change(E84Signal::CS_0, true);
m.e84.on_signal_change(E84Signal::VALID, true);
m.e84.on_signal_change(E84Signal::U_REQ, true);
CHECK(m.e84.state() == E84State::UnloadReady);
m.e84.on_signal_change(E84Signal::BUSY, true);
m.e84.on_signal_change(E84Signal::BUSY, false);
m.e84.on_signal_change(E84Signal::COMPT, true);
CHECK(m.e84.state() == E84State::Complete);
m.e84_ports.reset(1);
m.e84_ports.on_signal_change(1,E84Signal::CS_0, true);
m.e84_ports.on_signal_change(1,E84Signal::VALID, true);
m.e84_ports.on_signal_change(1,E84Signal::U_REQ, true);
CHECK(m.e84_ports.get(1)->state() == E84State::UnloadReady);
m.e84_ports.on_signal_change(1,E84Signal::BUSY, true);
m.e84_ports.on_signal_change(1,E84Signal::BUSY, false);
m.e84_ports.on_signal_change(1,E84Signal::COMPT, true);
CHECK(m.e84_ports.get(1)->state() == E84State::Complete);
m.load_ports.fire_transfer_event(1, LoadPortTransferEvent::StartUnloading);
m.load_ports.fire_transfer_event(1, LoadPortTransferEvent::CompleteUnloading);