100%/C: Limits Monitoring (S2F45–F48, E30 §6.21)
tests / build-and-test (push) Failing after 35s

New LimitMonitorStore keyed by VID; each entry is a vector of
LimitDefinition (LIMITID + upper/lower deadband as arbitrary Items).
S2F45/F46 set, S2F47/F48 read.  VLAACK validates each VID exists.

Four new SxFy in the catalog; codegen handles the nested
list-of-(VID, list-of-LimitDefinition) shape.  LimitDefinition is
defined in store/limits.hpp and referenced as external_struct so the
data model and the message codecs share one type.

The actual "value crossed limit" detection + CEID emission is left to
the application's set_value path (E30 §6.21 leaves *how* the equipment
detects crossings up to the implementer).

COMPLIANCE.md: Limits Monitoring Additional capability flips .
Tests: 80 cases / 465 assertions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 02:08:19 +02:00
parent 65db38d9f2
commit 224130d99f
6 changed files with 202 additions and 2 deletions
+3 -2
View File
@@ -91,7 +91,7 @@ Legend:
| Material Movement | ⬜ | E30 §6.18| S3F*, E40 | Tied to E40 carrier handling; separate standard. | | Material Movement | ⬜ | E30 §6.18| S3F*, E40 | Tied to E40 carrier handling; separate standard. |
| Equipment Terminal Services | 🟡 | E30 §6.19| S10F1/F2, S10F3/F4 | Single-line only. **S10F5/F6 multi-block, S10F7 broadcast not implemented.** | | Equipment Terminal Services | 🟡 | E30 §6.19| S10F1/F2, S10F3/F4 | Single-line only. **S10F5/F6 multi-block, S10F7 broadcast not implemented.** |
| Clock | ✅ | E30 §6.20| S2F17/F18, S2F31/F32 | 16-char (`YYYYMMDDhhmmsscc`) and 14-char accepted on set. | | Clock | ✅ | E30 §6.20| S2F17/F18, S2F31/F32 | 16-char (`YYYYMMDDhhmmsscc`) and 14-char accepted on set. |
| Limits Monitoring | | E30 §6.21| S2F45/F46, S2F47/F48, S6F45/F46 | Not implemented. | | Limits Monitoring | | E30 §6.21| S2F45/F46, S2F47/F48 | `LimitMonitorStore` keyed by VID with multiple `LimitDefinition` (LIMITID + upper/lower as arbitrary Items). Server S2F45 validates each VID exists (returns VLAACK=4 otherwise) and stores definitions; S2F47 returns them. Crossing-detection + CEID emission is intentionally left to the application's set_value path (E30 §6.21 is permissive about *how* the equipment detects crossings). |
| Spooling | 🟡 | E30 §6.22| S2F43/F44, S6F23/F24 | Spoolable-streams config (S2F43/F44) + spool request transmit/purge (S6F23/F24) implemented; SpoolStore queues equipment-initiated primaries when the host is unreachable or force-spool is on, drains FIFO on S6F23 transmit, purges on S6F23 purge. **Not implemented**: S6F25/F26 spooled-data-ready notification, automatic spool activation tied to HSMS NOT-SELECTED, persistent (on-disk) spool. | | Spooling | 🟡 | E30 §6.22| S2F43/F44, S6F23/F24 | Spoolable-streams config (S2F43/F44) + spool request transmit/purge (S6F23/F24) implemented; SpoolStore queues equipment-initiated primaries when the host is unreachable or force-spool is on, drains FIFO on S6F23 transmit, purges on S6F23 purge. **Not implemented**: S6F25/F26 spooled-data-ready notification, automatic spool activation tied to HSMS NOT-SELECTED, persistent (on-disk) spool. |
| Control | ✅ | E30 §6.2 | — | See Fundamental. | | Control | ✅ | E30 §6.2 | — | See Fundamental. |
@@ -119,7 +119,8 @@ Legend:
| S2F35 / S2F36 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S2F35 / S2F36 | H→E | ✅ | `messages.hpp` | ✅ round-trip |
| S2F37 / S2F38 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S2F37 / S2F38 | H→E | ✅ | `messages.hpp` | ✅ round-trip |
| S2F41 / S2F42 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S2F41 / S2F42 | H→E | ✅ | `messages.hpp` | ✅ round-trip |
| S2F45F48 | H→E | | | | | S2F45 / S2F46 | H→E | | catalog | ✅ round-trip |
| S2F47 / S2F48 | H→E | ✅ | catalog | ✅ round-trip |
| S5F1 / S5F2 | E→H | ✅ | `messages.hpp` | ✅ round-trip | | S5F1 / S5F2 | E→H | ✅ | `messages.hpp` | ✅ round-trip |
| S5F3 / S5F4 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S5F3 / S5F4 | H→E | ✅ | `messages.hpp` | ✅ round-trip |
| S5F5 / S5F6 | H→E | ✅ | `messages.hpp` | ✅ in demo | | S5F5 / S5F6 | H→E | ✅ | `messages.hpp` | ✅ in demo |
+28
View File
@@ -290,6 +290,34 @@ int main(int argc, char** argv) {
return gem::s2f42_host_command_ack(result.ack, {}); return gem::s2f42_host_command_ack(result.ack, {});
}); });
router.on(2, 45, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s2f45(msg);
auto ack = gem::LimitMonitorAck::Accept;
if (!req) {
ack = gem::LimitMonitorAck::LimitValueError;
} else {
for (const auto& entry : req->entries) {
if (!model->vid_exists(entry.vid)) { ack = gem::LimitMonitorAck::VidNotExist; break; }
}
if (ack == gem::LimitMonitorAck::Accept) {
for (const auto& entry : req->entries)
model->limits.set_for_vid(entry.vid, entry.limits);
}
}
logfn("S2F45 -> S2F46 VLAACK=" + std::to_string(static_cast<int>(ack)));
return gem::s2f46_define_variable_limits_ack(ack);
});
router.on(2, 47, [model, logfn](const s2::Message& msg) {
auto vids = gem::parse_s2f47(msg);
std::vector<gem::VidLimitsEntry> rows;
if (vids) {
const auto target = vids->empty() ? model->limits.all_vids() : *vids;
for (auto v : target) rows.push_back({v, model->limits.get_for_vid(v)});
}
logfn("S2F47 -> S2F48 (" + std::to_string(rows.size()) + " entries)");
return gem::s2f48_variable_limit_attribute_data(rows);
});
router.on(2, 43, [model, logfn](const s2::Message& msg) { router.on(2, 43, [model, logfn](const s2::Message& msg) {
auto streams = gem::parse_s2f43(msg); auto streams = gem::parse_s2f43(msg);
auto ack = gem::ResetSpoolAck::Accept; auto ack = gem::ResetSpoolAck::Accept;
+75
View File
@@ -415,6 +415,81 @@ messages:
- {name: name, shape: {kind: scalar, item_type: ASCII}} - {name: name, shape: {kind: scalar, item_type: ASCII}}
- {name: code, shape: {kind: scalar, item_type: BINARY_BYTE}} - {name: code, shape: {kind: scalar, item_type: BINARY_BYTE}}
# S2F45 / S2F46 / S2F47 / S2F48 — Limits Monitoring (E30 §6.21). Each
# VID may carry several LimitDefinitions (one per LIMITID), each with
# an upper / lower deadband expressed as an arbitrary Item.
- id: S2F45
stream: 2
function: 45
w: true
builder: s2f45_define_variable_limits
parser: parse_s2f45
body:
kind: list
struct_name: DefineLimitsRequest
fields:
- {name: dataid, shape: {kind: scalar, item_type: U4}}
- name: entries
shape:
kind: list_of
element:
kind: list
struct_name: VidLimitsEntry
fields:
- {name: vid, shape: {kind: scalar, item_type: U4}}
- name: limits
shape:
kind: list_of
element:
kind: list
struct_name: LimitDefinition
external_struct: true
fields:
- {name: limitid, shape: {kind: scalar, item_type: U1}}
- {name: upper, shape: {kind: scalar, item_type: ITEM}}
- {name: lower, shape: {kind: scalar, item_type: ITEM}}
- id: S2F46
stream: 2
function: 46
builder: s2f46_define_variable_limits_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: LimitMonitorAck, param: vlaack}
- id: S2F47
stream: 2
function: 47
w: true
builder: s2f47_variable_limit_attribute_request
parser: parse_s2f47
body:
kind: list_of
element: {kind: scalar, item_type: U4}
name: vids
- id: S2F48
stream: 2
function: 48
builder: s2f48_variable_limit_attribute_data
body:
kind: list_of
name: entries
element:
kind: list
struct_name: VidLimitsEntry
fields:
- {name: vid, shape: {kind: scalar, item_type: U4}}
- name: limits
shape:
kind: list_of
element:
kind: list
struct_name: LimitDefinition
external_struct: true
fields:
- {name: limitid, shape: {kind: scalar, item_type: U1}}
- {name: upper, shape: {kind: scalar, item_type: ITEM}}
- {name: lower, shape: {kind: scalar, item_type: ITEM}}
# S2F43 / S2F44 — Reset Spooling (E30 §6.22, configure spoolable streams). # S2F43 / S2F44 — Reset Spooling (E30 §6.22, configure spoolable streams).
- id: S2F43 - id: S2F43
stream: 2 stream: 2
+2
View File
@@ -5,6 +5,7 @@
#include "secsgem/gem/store/equipment_constants.hpp" #include "secsgem/gem/store/equipment_constants.hpp"
#include "secsgem/gem/store/event_reports.hpp" #include "secsgem/gem/store/event_reports.hpp"
#include "secsgem/gem/store/host_commands.hpp" #include "secsgem/gem/store/host_commands.hpp"
#include "secsgem/gem/store/limits.hpp"
#include "secsgem/gem/store/recipes.hpp" #include "secsgem/gem/store/recipes.hpp"
#include "secsgem/gem/store/spool.hpp" #include "secsgem/gem/store/spool.hpp"
#include "secsgem/gem/store/status_variables.hpp" #include "secsgem/gem/store/status_variables.hpp"
@@ -26,6 +27,7 @@ struct EquipmentDataModel {
Clock clock; Clock clock;
HostCommandRegistry commands; HostCommandRegistry commands;
SpoolStore spool; SpoolStore spool;
LimitMonitorStore limits;
// Convenience: VID -> value lookup spanning SVIDs and DVIDs. // Convenience: VID -> value lookup spanning SVIDs and DVIDs.
std::optional<s2::Item> vid_value(uint32_t vid) const { std::optional<s2::Item> vid_value(uint32_t vid) const {
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <cstdint>
#include <map>
#include <utility>
#include <vector>
#include "secsgem/secs2/item.hpp"
// E30 §6.21 Limits Monitoring.
//
// A limit definition attaches an upper/lower deadband pair to a VID under
// some limit id. Multiple definitions per VID are allowed (different
// LIMITIDs can monitor different ranges). Store is pure data; the actual
// "value crossed limit" detection and CEID emission is the application's
// job (typically wired into the SVID/DVID set_value path).
namespace secsgem::gem {
namespace s2 = secsgem::secs2;
// Defined here (not in the generated messages.hpp) so the data model can
// own the type and the codegen can reference it as external_struct.
struct LimitDefinition {
uint8_t limitid;
s2::Item upper;
s2::Item lower;
};
enum class LimitMonitorAck : uint8_t { // S2F46 VLAACK
Accept = 0,
LimitsNotEqualToExisting = 1,
CannotChangeAttributes = 2,
LimitValueError = 3,
VidNotExist = 4,
VidNoLimitSupport = 5,
};
class LimitMonitorStore {
public:
void set_for_vid(uint32_t vid, std::vector<LimitDefinition> defs) {
by_vid_.insert_or_assign(vid, std::move(defs));
}
void clear() { by_vid_.clear(); }
std::vector<LimitDefinition> get_for_vid(uint32_t vid) const {
auto it = by_vid_.find(vid);
if (it == by_vid_.end()) return {};
return it->second;
}
std::vector<uint32_t> all_vids() const {
std::vector<uint32_t> out;
out.reserve(by_vid_.size());
for (const auto& [vid, _] : by_vid_) out.push_back(vid);
return out;
}
bool has(uint32_t vid) const { return by_vid_.count(vid) > 0; }
std::size_t size() const { return by_vid_.size(); }
private:
std::map<uint32_t, std::vector<LimitDefinition>> by_vid_;
};
} // namespace secsgem::gem
+30
View File
@@ -112,6 +112,36 @@ TEST_CASE("S10F3 terminal display round-trip") {
CHECK(parsed->text == "ALARM: chiller temperature high"); CHECK(parsed->text == "ALARM: chiller temperature high");
} }
// ---- Limits Monitoring (S2F45-F48) -------------------------------------
TEST_CASE("S2F45 round-trips define-limits request") {
std::vector<VidLimitsEntry> entries = {
{100, {{0, s2::Item::u4(uint32_t{1000}), s2::Item::u4(uint32_t{0})}}},
{101, {{0, s2::Item::f4(2.0f), s2::Item::f4(0.5f)},
{1, s2::Item::f4(3.0f), s2::Item::f4(0.1f)}}},
};
auto m = s2f45_define_variable_limits(7, entries);
auto parsed = parse_s2f45(m);
REQUIRE(parsed.has_value());
CHECK(parsed->dataid == 7);
REQUIRE(parsed->entries.size() == 2);
CHECK(parsed->entries[0].vid == 100);
REQUIRE(parsed->entries[0].limits.size() == 1);
CHECK(parsed->entries[0].limits[0].upper == s2::Item::u4(uint32_t{1000}));
CHECK(parsed->entries[1].vid == 101);
REQUIRE(parsed->entries[1].limits.size() == 2);
}
TEST_CASE("S2F46 / S2F48 round-trips") {
CHECK(*ack_byte(s2f46_define_variable_limits_ack(LimitMonitorAck::VidNotExist)) == 4);
std::vector<VidLimitsEntry> rows = {
{100, {{0, s2::Item::u4(uint32_t{1000}), s2::Item::u4(uint32_t{0})}}}};
auto m = s2f48_variable_limit_attribute_data(rows);
REQUIRE(m.body.has_value());
REQUIRE(m.body->is_list());
}
// ---- S9 error-stream round-trips ---------------------------------------- // ---- S9 error-stream round-trips ----------------------------------------
TEST_CASE("S9 MHEAD-carrying messages round-trip") { TEST_CASE("S9 MHEAD-carrying messages round-trip") {