ILRBCSim
ADR-029 Interlocking simulator — grants/extends Movement Authority against the RBC wire protocol
Loading...
Searching...
No Matches
il_sim.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Interlocking (IL) sim (ADR-029) - a real, dual-homed TCP client that
3sets/releases routes for one train's session, ON COMMAND from a test
4harness (tests/robot/) rather than a fixed wall-clock schedule - see
5control_server.py's own header for the command channel design. Dual-homed
6the same way train_sim.py is (see dual_link.py's own header) - sends
7ROUTE_ADD/ROUTE_RELEASE to both sites unconditionally, since only
8whichever site is currently ONLINE will actually act on it.
9
10IL<->RBC route-identity pass: routes are now identified on the wire by
11their own (start_signal, end_signal) pair (the real signal pair a route
12runs between - see rbc_wire_types.h's own doc), not a synthetic id, and
13IL now genuinely RECEIVES something meaningful too -
14TrainPositionInRoute, RBC's own report of whether a route currently has
15a train authorized through it. Before this pass IL was send-only in
16practice (see this file's own git history) - the main loop's own
17poll_recv() drained and ignored everything; it now decodes real inbound
18messages the same way train_sim.py/ctc_sim.py already do.
19
20Configuration: one JSON file (common/sim_config.py, SIM_CONFIG_PATH env
21var, default /app/config.json) supplies everything instance-specific -
22trainIndex (0 or 1, selects which of this project's
23SAFEAPI_EXAMPLE_MAX_TRAINS train slots this IL instance sets routes for -
24train_id is trainIndex + 1; il-west sets routes for train-west's session,
25il-east for train-east's, by this shared convention - ADR-029),
26siteTag, rbcWest/rbcEast, controlServer, and routeLengthM (the default
27ADD_ROUTE grant, replaces the old RBC_ROUTE_LENGTH_M constant). See
28sims/il/config/*.json for the actual files. Message vocabulary comes
29from message_catalog.json, right next to this script.
30
31Commands (controlServer.port, config file - see common/control_server.py):
32 ADD_ROUTE [length_m] Sends one ROUTE_ADD (default length
33 config's own routeLengthM if omitted) - no route
34 identity, see this handler's own doc for why
35 that's a real limitation now. Replies "OK\\n".
36 PING Replies "PONG\\n" - liveness check for a test
37 harness before it starts asserting anything.
38 SET_IL_STATUS <s> Sets + sends this IL's own RBC_MSG_IL_STATUS
39 (s = down|restarting|up). IL announces "up" at
40 startup and re-announces every
41 ROUTE_STATUS_PERIOD_S; this overrides it for
42 tests of the RBC's DOWN/RESTARTING handling.
43 Replies "OK\\n".
44
45Structured JSON commands (tests/robot/design/DESIGN.md,
46SIM_INTERFACES.md - same port, see common/control_server.py's own doc):
47 sendMessage {"cmd":"sendMessage","nidEngine":N,"message":"ROUTE_ADD",
48 "fields":{"route_len":500,"start_signal":100,
49 "end_signal":101,"route_type":0}}
50 Sends one ROUTE_ADD/ROUTE_RELEASE/etc NOW with the given
51 fields - the structured equivalent of ADD_ROUTE above
52 (route_len defaults to config's own routeLengthM if the
53 field is left unset for ROUTE_ADD, same as ADD_ROUTE's
54 own default behavior; route identity fields have no
55 default - the caller must supply them for a real grant).
56 message "IL_ROUTE_CMD" / "IL_STATUS" use the dedicated
57 packed IL protocol (il_wire.py, mirrors ab_ga_il_wire.h)
58 instead of the flat codec: IL_ROUTE_CMD fields
59 route_type/route_status/degraded_status/
60 release_route_request/first_route_in_path (enum ints,
61 first value = default) + route_id/start_signal/end_signal
62 (-1 = unset); IL_STATUS field il_status (0/1/2).
63 getMessage {"cmd":"getMessage","nidEngine":N,"message":"TrainPositionInRoute"}
64 Returns the most recently received packet of that kind
65 for that nidEngine, or an ERR if none has arrived yet -
66 same "most recent value, not a queue" contract
67 train_sim.py's own getMessage already has (DESIGN.md
68 section 4.2).
69"""
70
71import logging
72import os
73import signal
74import sys
75import time
76
77from simcore import sim_config
78from simcore.control_server import ControlServer
79from simcore.dual_link import DualHomedLink
80from simcore.rbc_messages import MessageCatalog
81from simcore.rbc_wire import ENVELOPE_SIZE
82from simcore.status_report import StatusReporter
83
84from . import site_data
85from . import il_wire
86
87# Phase D: periodic all-route-status broadcast cadence (2s, per direct
88# instruction - "IL sim shall send all route status each 2s").
89ROUTE_STATUS_PERIOD_S = 2.0
90
91# Enum-int -> name, for readable IL_ROUTE_CMD / IL_ROUTE_INDICATION log
92# lines (rbc_wire_types.h's rbc_il_* enums, mirrored in il_wire.py).
93_CMD_ROUTE_TYPE_NAME = {il_wire.ROUTE_TYPE_NO_ROUTE: "NoRoute", il_wire.ROUTE_TYPE_LOCKED: "Locked"}
94_CMD_ROUTE_STATUS_NAME = {
95 il_wire.ROUTE_STATUS_NO_STATUS: "NoStatus", il_wire.ROUTE_STATUS_FS: "FS",
96 il_wire.ROUTE_STATUS_OS: "OS", il_wire.ROUTE_STATUS_SH: "SH",
97 il_wire.ROUTE_STATUS_USED: "Used", il_wire.ROUTE_STATUS_DEGRADED: "Degraded",
98}
99
100CATALOG = MessageCatalog(os.path.join(os.path.dirname(os.path.abspath(__file__)), "message_catalog.json"))
101
102# RBC_MSG_RELAY_KEEPALIVE (rbc_wire_types.h) - a pure wire-level liveness
103# frame, not a real domain message, and not listed in this sim's own
104# catalog (SimCore stays kind-blind by design - this filtering is this
105# sim's own application-layer job, same layer as message_catalog.json).
106# Unlike train_sim.py, which rarely if ever receives one, IL's own relay
107# channel gets ONE OF THESE EVERY SINGLE CYCLE regardless of whether
108# real data is also sent that cycle (ab_gp_channel_send_il_keepalive_if_online(),
109# ab_gp_channel.c) - without filtering it out before decode_to_message(),
110# every ordinary idle cycle would log a spurious "malformed envelope"
111# warning, drowning out genuinely rare real malformed frames.
112_RELAY_KEEPALIVE_KIND = 9
113
114
116 return len(data) >= 1 and data[0] == _RELAY_KEEPALIVE_KIND
117
118
119def main():
120 config = sim_config.load()
121 site_tag = sim_config.require(config, "siteTag")
122 # IL itself tracks no per-train state at all (unlike train_sim.py -
123 # every real command, structured sendMessage/getMessage, already
124 # takes an explicit nidEngine with no "is this train known" gate).
125 # This is only for the legacy, non-structured ADD_ROUTE/RELEASE_ROUTE
126 # commands, which predate nidEngine-explicit fields entirely and have
127 # nowhere to take one from - always targets train 1 (no roster to
128 # pick a "first configured train" from any more; see
129 # TrainRBCSim/src/train/train_sim.py's own "no default trains" doc
130 # for the identical principle on the Train side). Use sendMessage
131 # with an explicit nidEngine for any other train.
132 train_id = 1
133 route_length_m = int(config.get("routeLengthM", 500))
134 control_host = sim_config.require(config, "controlServer", "bindHost")
135 control_port = sim_config.require(config, "controlServer", "port")
136
137 # "[%(levelname)s] " prefix - see train_sim.py's own identical change
138 # for why (the test env's own listener should collect a real level,
139 # not guess/default one).
140 logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s", stream=sys.stdout)
141 log = logging.getLogger("il")
142
143 targets = {
144 "WEST": (sim_config.require(config, "rbcWest", "host"), sim_config.require(config, "rbcWest", "port")),
145 "EAST": (sim_config.require(config, "rbcEast", "host"), sim_config.require(config, "rbcEast", "port")),
146 }
147 link = DualHomedLink(log, targets, ENVELOPE_SIZE)
148 routes_sent = 0
149 # {nid_engine: {message_name: fields_dict}} - most-recently-received
150 # value per (nid_engine, message), same "not a queue" convention
151 # train_sim.py's own last_received already uses (DESIGN.md 4.2).
152 last_received = {}
153
154 # Phase D: real route table from the RailML file (site_data.py, built
155 # in an earlier phase but unwired until now) + this IL instance's OWN
156 # locally-tracked lock state per route - "free" until THIS IL issues
157 # a ROUTE_ADD for it, "locked" until THIS IL releases it. Genuinely
158 # local bookkeeping, not fed back from the RBC's own TrainPositionInRoute
159 # replies (a different, RBC-side-authoritative signal already handled
160 # separately above) - matches the plan's own "each defaulting to
161 # free, updated locally whenever this same IL instance itself issues
162 # a ROUTE_ADD/ROUTE_RELEASE" design.
163 try:
164 route_table = site_data.routes()
165 except Exception as exc:
166 log.warning("[GENERAL] [%s] [internal] [CONFIG] [could not load route table from RailML - "
167 "Phase D route-status broadcast disabled] [%s]", f"il-{site_tag.lower()}", exc)
168 route_table = []
169 route_lock_state = {(r["start_signal"], r["end_signal"]): False for r in route_table}
170
171 def _mark_route(start_signal, end_signal, locked):
172 key = (int(start_signal), int(end_signal))
173 if key in route_lock_state:
174 route_lock_state[key] = locked
175
176 # RBC_MSG_IL_STATUS: this IL's own reported health. Announced UP on
177 # startup and re-announced every ROUTE_STATUS_PERIOD_S (the RBC starts
178 # assuming DOWN). Overridable via the Set IL Status keyword / control
179 # command below, for tests that exercise the RBC's DOWN/RESTARTING
180 # handling.
181 il_status = {"value": il_wire.IL_STATUS_UP}
182 _IL_STATUS_BY_NAME = {
183 "down": il_wire.IL_STATUS_DOWN,
184 "restarting": il_wire.IL_STATUS_RESTARTING,
185 "up": il_wire.IL_STATUS_UP,
186 }
187
188 def _send_il_status():
189 link.send_all(il_wire.encode_il_status(il_status["value"]))
190
191 def handle_set_il_status(args):
192 name = (args[0] if args else "up").strip().lower()
193 if name not in _IL_STATUS_BY_NAME:
194 return "ERR unknown status (down|restarting|up)\n"
195 il_status["value"] = _IL_STATUS_BY_NAME[name]
196 _send_il_status()
197 log.info("[IO] [%s] [c-west,c-east] [IL_STATUS] [IL status set] [il_status=%d]",
198 f"il-{site_tag.lower()}", il_status["value"])
199 return "OK\n"
200
201 def handle_add_route(args):
202 nonlocal routes_sent
203 length_m = int(args[0]) if args else route_length_m
204 routes_sent += 1
205 log.info(
206 "route #%d added (+%dm, no route identity) for train_id=%d [commanded] - RBC will reject this: "
207 "the plaintext ADD_ROUTE command has no route identity fields; use sendMessage/the Robot "
208 "'Set IL Route' keyword for a real grant",
209 routes_sent, length_m, train_id,
210 )
211 link.send_all(CATALOG.build_envelope("ROUTE_ADD", train_id, {"route_len": length_m}))
212 return "OK\n"
213
214 def handle_release_route(args):
215 start_signal, end_signal = int(args[0]), int(args[1])
216 log.info(
217 "route release requested (start_signal=%d end_signal=%d) for train_id=%d [commanded]",
218 start_signal, end_signal, train_id,
219 )
220 link.send_all(CATALOG.build_envelope(
221 "ROUTE_RELEASE", train_id, {"start_signal": start_signal, "end_signal": end_signal}
222 ))
223 _mark_route(start_signal, end_signal, False)
224 return "OK\n"
225
226 def handle_ping(_args):
227 return "PONG\n"
228
229 def handle_send_message(request):
230 nonlocal routes_sent
231 nid_engine = request["nidEngine"]
232 message = request["message"]
233 fields = dict(request.get("fields", {}))
234
235 # Dedicated IL messages (25/26) - their own packed layout, not
236 # SimCore's flat 96-byte codec. il_wire.py mirrors ab_ga_il_wire.h.
237 if message == "IL_ROUTE_CMD":
238 # Fully-resolved field set (every field, defaults applied) - so
239 # the log shows exactly what went on the wire, not just what
240 # the caller happened to pass.
241 sent = {
242 "route_type": int(fields.get("route_type", il_wire.ROUTE_TYPE_NO_ROUTE)),
243 "route_status": int(fields.get("route_status", il_wire.ROUTE_STATUS_NO_STATUS)),
244 "degraded_status": int(fields.get("degraded_status", il_wire.DEGRADED_STATUS_NO_STATUS)),
245 "release_route_request": int(fields.get("release_route_request", 0)),
246 "first_route_in_path": int(fields.get("first_route_in_path", 0)),
247 "route_id": int(fields.get("route_id", -1)),
248 "start_signal": int(fields.get("start_signal", -1)),
249 "end_signal": int(fields.get("end_signal", -1)),
250 }
251 frame = il_wire.encode_route_cmd(**sent)
252 link.send_all(frame)
253 if sent["start_signal"] != -1 and sent["end_signal"] != -1:
254 locked = (sent["route_type"] == il_wire.ROUTE_TYPE_LOCKED
255 and not sent["release_route_request"])
256 _mark_route(sent["start_signal"], sent["end_signal"], locked)
257 fields_str = " ".join(f"{k}={v}" for k, v in sorted(sent.items()))
258 log.info("[IO] [%s] [c-west,c-east] [IL_ROUTE_CMD] [IL route command sent] "
259 "[%s type=%s status=%s]",
260 f"il-{site_tag.lower()}", fields_str,
261 _CMD_ROUTE_TYPE_NAME.get(sent["route_type"], sent["route_type"]),
262 _CMD_ROUTE_STATUS_NAME.get(sent["route_status"], sent["route_status"]))
263 return {"status": "OK"}
264 if message == "IL_STATUS":
265 il_status["value"] = int(fields.get("il_status", il_wire.IL_STATUS_UP))
266 _send_il_status()
267 log.info("[IO] [%s] [c-west,c-east] [IL_STATUS] [IL status sent] [il_status=%d]",
268 f"il-{site_tag.lower()}", il_status["value"])
269 return {"status": "OK"}
270
271 if message == "ROUTE_ADD" and "route_len" not in fields:
272 fields["route_len"] = route_length_m
273 link.send_all(CATALOG.build_envelope(message, nid_engine, fields))
274 if message == "ROUTE_ADD":
275 routes_sent += 1
276 if "start_signal" in fields and "end_signal" in fields:
277 _mark_route(fields["start_signal"], fields["end_signal"], True)
278 elif message == "ROUTE_RELEASE" and "start_signal" in fields and "end_signal" in fields:
279 _mark_route(fields["start_signal"], fields["end_signal"], False)
280 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
281 log.info("[IO] [%s] [c-west,c-east] [%s] [IL message sent] [nid_engine=%d %s]",
282 f"il-{site_tag.lower()}", message, nid_engine, fields_str)
283 return {"status": "OK"}
284
285 def handle_get_message(request):
286 nid_engine = request["nidEngine"]
287 message = request["message"]
288 packets = last_received.get(nid_engine, {}).get(message)
289 if packets is None:
290 return {"status": "ERR", "reason": f"no {message} received yet for nidEngine {nid_engine}"}
291 return {"status": "OK", "message": message, "packets": {message: packets}}
292
293 def handle_clear_messages(_request):
294 last_received.clear()
295 return {"status": "OK"}
296
297 control = ControlServer(
298 log,
299 control_port,
300 {"ADD_ROUTE": handle_add_route, "RELEASE_ROUTE": handle_release_route, "PING": handle_ping,
301 "SET_IL_STATUS": handle_set_il_status},
302 json_handlers={
303 "sendMessage": handle_send_message,
304 "getMessage": handle_get_message,
305 "clearMessages": handle_clear_messages,
306 },
307 bind_host=control_host,
308 )
309
310 running = True
311
312 def _stop(signum, _frame):
313 nonlocal running
314 log.info("[GENERAL] [%s] [internal] [SIGNAL] [IL sim shutting down] [signal=%d]",
315 f"il-{site_tag.lower()}", signum)
316 running = False
317
318 signal.signal(signal.SIGTERM, _stop)
319 signal.signal(signal.SIGINT, _stop)
320
321 log.info("[GENERAL] [%s] [internal] [INIT] [IL sim starting] [port=%d]",
322 f"il-{site_tag.lower()}", control_port)
323
324 # ADR-038: own-perspective status - startup + every 5s, to both sites.
325 # IL tracks no per-train roster (see main()'s own doc) - this "trains"
326 # field is a fixed 1 (legacy compatibility with the status frame
327 # shape, not a real live count).
328 status = StatusReporter(log, link, f"SIM-IL/{site_tag}", f"il-{site_tag.lower()}")
329 status.emit({"trains": 1})
330 _send_il_status() # announce health once at startup (RBC assumes DOWN)
331
332 next_route_status_report = 0.0
333 next_il_status_report = 0.0
334
335 while running:
336 link.ensure_connected()
337 control.poll(timeout=0.2)
338 status.tick({"trains": 1})
339
340 # Phase D: broadcast this IL's own locked/free state for EVERY
341 # route it knows about, every 2s, unconditionally - not gated on
342 # any train being connected (this is IL's own liveness/state
343 # feed, independent of Train's cadence). One RBC_MSG_ROUTE_STATUS_REPORT
344 # frame per route (same "one small frame per item" convention
345 # ROUTE_ADD/ROUTE_RELEASE already use, not a batched shape).
346 now = time.monotonic()
347 if now >= next_il_status_report:
348 _send_il_status()
349 next_il_status_report = now + ROUTE_STATUS_PERIOD_S
350 if route_table and now >= next_route_status_report:
351 for route in route_table:
352 key = (route["start_signal"], route["end_signal"])
353 locked = route_lock_state.get(key, False)
354 link.send_all(CATALOG.build_envelope("ROUTE_STATUS_REPORT", train_id, {
355 "start_signal": route["start_signal"],
356 "end_signal": route["end_signal"],
357 "route_status": 1 if locked else 0,
358 }))
359 log.info("[IO] [%s] [c-west,c-east] [ROUTE_STATUS_REPORT] [IL periodic route-status broadcast] "
360 "[route_count=%d locked_count=%d]",
361 f"il-{site_tag.lower()}", len(route_table), sum(1 for v in route_lock_state.values() if v))
362 next_route_status_report = now + ROUTE_STATUS_PERIOD_S
363
364 for site, data in link.poll_recv(timeout=0.0):
365 if _is_keepalive(data):
366 continue
367
368 # Dedicated RBC -> IL indication (kind 27) - own packed layout,
369 # decoded before SimCore's flat codec (il_wire.py mirrors
370 # ab_ga_il_wire.h). Stored under "IL_ROUTE_INDICATION" keyed by
371 # allocated_train_nid_engine (-1 when no train) so getMessage /
372 # the Robot verify keyword can read it back.
373 if data and data[0] == il_wire.KIND_IL_ROUTE_INDICATION:
374 ind = il_wire.decode_route_indication(data)
375 if ind is not None:
376 # Two lookup keys, both live: by allocated train
377 # (allocated_train_nid_engine, -1 = no train) AND by
378 # route (-(1000 + route_id), well out of any real
379 # nid_engine range) so a test can query ONE specific
380 # route's latest indication without the "-1 shared
381 # key, most-recent-wins" ambiguity when several
382 # trainless routes are toggled.
383 last_received.setdefault(ind["allocated_train_nid_engine"], {})["IL_ROUTE_INDICATION"] = ind
384 if ind["route_id"] is not None and ind["route_id"] >= 0:
385 last_received.setdefault(-(1000 + ind["route_id"]), {})["IL_ROUTE_INDICATION"] = ind
386 # Every field, plus a readable name for the FSM state.
387 fields_str = " ".join(f"{k}={v}" for k, v in sorted(ind.items()))
388 log.info("[IO] [c-%s] [%s] [IL_ROUTE_INDICATION] [RBC route indication received] "
389 "[%s fsm_state_name=%s]",
390 site.lower(), f"il-{site_tag.lower()}", fields_str,
391 il_wire.route_fsm_state_name(ind["route_fsm_state"]))
392 continue
393
394 decoded = CATALOG.decode_to_message(data)
395 if decoded is None:
396 log.warning("[%s] [internal] [MALFORMED] [Malformed envelope dropped] [site=%s]",
397 f"il-{site_tag.lower()}", site)
398 continue
399 nid_engine, message, fields = decoded
400 last_received.setdefault(nid_engine, {})[message] = fields
401 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
402 if message == "TrainPositionInRoute":
403 log.info("[IO] [c-%s] [%s] [TrainPositionInRoute] [Train position in route received] [nid_engine=%d %s]",
404 site.lower(), f"il-{site_tag.lower()}", nid_engine, fields_str)
405 # Phase D's own route_lock_state sync point: the RBC is the
406 # real authority on whether a route is occupied, and this
407 # is the ONE feedback channel IL already has for it -
408 # syncing here keeps route_lock_state correct for ANY
409 # reason a route freed up (an explicit ROUTE_RELEASE this
410 # IL sent itself, GP's own auto-release when a train
411 # disconnects, EoM session termination, etc.), not just
412 # the release path _mark_route() already covers. Found
413 # live: IL sim has NO disconnectTrain handler at all (only
414 # train_sim.py does), so a test's own "Disconnect Train"
415 # teardown was never seen by THIS process at all - without
416 # this sync, route_lock_state would keep reporting a route
417 # as locked after the train that held it was long gone.
418 if "start_signal" in fields and "end_signal" in fields and "route_status" in fields:
419 _mark_route(fields["start_signal"], fields["end_signal"], bool(fields["route_status"]))
420
421 log.info("stopped after %d route(s) added", routes_sent)
422
423
424if __name__ == "__main__":
425 main()
_is_keepalive(data)
Definition il_sim.py:115