RBC_Test_Sim_CTC 0.1.0 build 4d0b082
ADR-029 CTC simulator — receive-only observer of train-connected/MA indications
Loading...
Searching...
No Matches
ctc_sim.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""CTC (Centralized Traffic Control) sim (ADR-029) - a real, dual-homed
3TCP client. A single instance oversees both sites (unlike train/il, which
4run one pair per train slot) - dual-homed to both sites' CTC port the
5same way train_sim.py/il_sim.py are (see dual_link.py's own header),
6receive-only: this scenario never has CTC send anything back to the RBC,
7it only observes the one-way status pushes (train connected/MA granted/
8MA extended) and logs them, tagged by train_id.
9
10Configuration: one JSON file (common/sim_config.py, SIM_CONFIG_PATH env
11var, default /app/config.json) - rbcWest/rbcEast and controlServer only
12(no trainIndex - CTC is not per-train). See config/ctc.json, right next
13to this script. Message vocabulary comes from message_catalog.json, also
14right next to this script.
15
16Commands (controlServer.port, config file - see common/control_server.py):
17 PING Replies "PONG\\n" - liveness check.
18
19Structured JSON commands (tests/robot/design/DESIGN.md,
20SIM_INTERFACES.md - same port, see common/control_server.py's own doc).
21No sendMessage - CTC never transmits in this scenario:
22 getMessage {"cmd":"getMessage","nidEngine":N,"message":"CTC_MA_GRANTED"}
23 Returns the most recently received indication of that
24 kind concerning train nidEngine, or an ERR if none has
25 arrived yet.
26"""
27
28import logging
29import os
30import signal
31import sys
32import time
33
34from simcore import sim_config
35from simcore.control_server import ControlServer
36from simcore.dual_link import DualHomedLink
37from simcore.rbc_messages import MessageCatalog
38from simcore.rbc_wire import ENVELOPE_SIZE
39from simcore.status_report import StatusReporter
40
41CATALOG = MessageCatalog(os.path.join(os.path.dirname(os.path.abspath(__file__)), "message_catalog.json"))
42
43
44_RELAY_KEEPALIVE_KIND = 9
45
46# CTC_ALARM's own msg_type field (message_catalog.json) carries
47# rbc_alarm_severity_t (rbc_wire_types.h) as a raw uint32 - this is the
48# Python-side mirror of that same small, closed vocabulary (0=INFO/
49# 1=WARNING/2=ERROR), used only to pick this sim's own log level below.
50_ALARM_SEVERITY_LOG = {0: logging.INFO, 1: logging.WARNING, 2: logging.ERROR}
51_ALARM_SEVERITY_NAME = {0: "INFO", 1: "WARNING", 2: "ERROR"}
52
53
54def _is_keepalive(data):
55 return len(data) >= 1 and data[0] == _RELAY_KEEPALIVE_KIND
56
57
58def _decode_ma_offset(raw_offset):
59 """ma_length rides the wire as a SIGNED value's raw two's-complement uint32 bit pattern
60 (CTC_TRAIN_INFO's own message_catalog.json comment) - converts it back to a real signed
61 Python int. Pure extraction (2026-09-24, for unit-testability - this used to be inline in
62 main()'s own receive loop, matching the "pure extraction, no logic changed" precedent
63 RBC_GP's own C code already uses for the same reason, e.g. rte_voter.c's
64 voter_group_matching_responses())."""
65 return raw_offset - 0x100000000 if raw_offset >= 0x80000000 else raw_offset
66
67
68def main():
69 config = sim_config.load()
70 control_host = sim_config.require(config, "controlServer", "bindHost")
71 control_port = sim_config.require(config, "controlServer", "port")
72
73 # "[%(levelname)s] " prefix - see RBC_Test_Sim_Train/src/train/train_sim.py's
74 # own identical change for why (the test env's own listener should
75 # collect a real level, not guess/default one).
76 logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s", stream=sys.stdout)
77 log = logging.getLogger("ctc")
78
79 targets = {
80 "WEST": (sim_config.require(config, "rbcWest", "host"), sim_config.require(config, "rbcWest", "port")),
81 "EAST": (sim_config.require(config, "rbcEast", "host"), sim_config.require(config, "rbcEast", "port")),
82 }
83 link = DualHomedLink(log, targets, ENVELOPE_SIZE)
84
85 # Most-recently-received indication per (nid_engine, message name) -
86 # tests/robot/design/DESIGN.md section 4.2. Indexed by nid_engine
87 # (the TRAIN the indication is about, not a CTC identity - there is
88 # only one CTC instance - SIM_INTERFACES.md's own note).
89 last_received = {}
90 site_health = {"WEST": {"last_seen": None, "msg_count": 0}, "EAST": {"last_seen": None, "msg_count": 0}}
91 train_status = {}
92 alarms = []
93 start_time = time.monotonic()
94
95 def handle_ping(_args):
96 return "PONG\n"
97
98 def handle_get_message(request):
99 nid_engine = request["nidEngine"]
100 message = request["message"]
101 packets = last_received.get(nid_engine, {}).get(message)
102 if packets is None:
103 return {"status": "ERR", "reason": f"no {message} received yet for nidEngine {nid_engine}"}
104 return {"status": "OK", "message": message, "packets": {message: packets}}
105
106 def handle_get_status(_request):
107 connected_sites = link.connected_sites()
108 sites_res = {}
109 for s in ("WEST", "EAST"):
110 tgt = targets.get(s, ("127.0.0.1", 0))
111 sites_res[s] = {
112 "connected": s in connected_sites,
113 "host": tgt[0],
114 "port": tgt[1],
115 "last_seen": site_health.get(s, {}).get("last_seen"),
116 "msg_count": site_health.get(s, {}).get("msg_count", 0),
117 }
118 return {
119 "status": "OK",
120 "sites": sites_res,
121 "trains": train_status,
122 "alarms": alarms[-20:],
123 "stats": {
124 "indications_seen": seen,
125 "connected_sites": connected_sites,
126 "uptime_s": round(time.monotonic() - start_time, 1),
127 },
128 }
129
130 def handle_clear_messages(_request):
131 last_received.clear()
132 train_status.clear()
133 return {"status": "OK"}
134
135 def handle_cleanup(_request):
136 """Comprehensive reset of this sim's own local state (per direct
137 request: sims shall forget historical data between tests, the
138 connection can stay up). Same as clearMessages plus alarms -
139 clearMessages deliberately left alarms alone (a running audit
140 trail useful mid-test), but a between-tests Cleanup should not
141 leave a prior test's alarms visible to the next one."""
142 last_received.clear()
143 train_status.clear()
144 alarms.clear()
145 return {"status": "OK"}
146
147 control = ControlServer(
148 log,
149 control_port,
150 {"PING": handle_ping},
151 json_handlers={
152 "getMessage": handle_get_message,
153 "getStatus": handle_get_status,
154 "getRbcStatus": handle_get_status,
155 "clearMessages": handle_clear_messages,
156 "cleanup": handle_cleanup,
157 },
158 bind_host=control_host,
159 )
160
161 running = True
162
163 def _stop(signum, _frame):
164 nonlocal running
165 log.info("[GENERAL] [CTC] [internal] [SIGNAL] [CTC sim shutting down] [signal=%d]", signum)
166 running = False
167
168 signal.signal(signal.SIGTERM, _stop)
169 signal.signal(signal.SIGINT, _stop)
170
171 log.info("[GENERAL] [CTC] [internal] [INIT] [CTC sim starting - overseeing WEST, EAST] []")
172 seen = 0
173
174 # ADR-038: own-perspective status - startup + every 5s, to both sites.
175 status = StatusReporter(log, link, "SIM-CTC", "ctc")
176 status.emit()
177
178 while running:
179 link.ensure_connected()
180 control.poll(timeout=0.0)
181 status.tick()
182 for site, data in link.poll_recv(timeout=0.2):
183 if _is_keepalive(data):
184 continue
185 decoded = CATALOG.decode_to_message(data)
186 if decoded is None:
187 log.warning("[CTC] [internal] [MALFORMED] [Malformed indication dropped] [site=%s]", site)
188 continue
189 nid_engine, message, fields = decoded
190 last_received.setdefault(nid_engine, {})[message] = fields
191 seen += 1
192 now_ts = time.time()
193 site_health.setdefault(site, {})["last_seen"] = now_ts
194 site_health[site]["msg_count"] = site_health[site].get("msg_count", 0) + 1
195
196 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
197 rbc_label = f"RBC {site.title()}"
198 if message == "CTC_CONNECTED":
199 train_status[nid_engine] = {
200 "connected": True,
201 "d_lrbg": fields.get("d_lrbg", 0),
202 "site": site,
203 "last_seen": now_ts,
204 }
205 log.info("[IO] [%s] [CTC] [CTC_CONNECTED] [CTC Connected indication received] [nid_engine=%d %s]",
206 rbc_label, nid_engine, fields_str)
207 elif message == "CTC_MA_GRANTED":
208 t_entry = train_status.setdefault(nid_engine, {"connected": True})
209 t_entry["ma_length"] = fields.get("ma_length")
210 t_entry["ma_seq"] = fields.get("ma_seq")
211 t_entry["site"] = site
212 t_entry["last_seen"] = now_ts
213 log.info("[IO] [%s] [CTC] [CTC_MA_GRANTED] [CTC Movement Authority Granted received] [nid_engine=%d %s]",
214 rbc_label, nid_engine, fields_str)
215 elif message == "CTC_MA_EXTENDED":
216 t_entry = train_status.setdefault(nid_engine, {"connected": True})
217 t_entry["ma_length"] = fields.get("ma_length")
218 t_entry["ma_seq"] = fields.get("ma_seq")
219 t_entry["site"] = site
220 t_entry["last_seen"] = now_ts
221 log.info("[IO] [%s] [CTC] [CTC_MA_EXTENDED] [CTC Movement Authority Extended received] [nid_engine=%d %s]",
222 rbc_label, nid_engine, fields_str)
223 elif message == "CTC_TRAIN_INFO":
224 t_entry = train_status.setdefault(nid_engine, {"connected": True})
225 t_entry["d_lrbg"] = fields.get("d_lrbg")
226 t_entry["ma_status"] = "GRANTED" if fields.get("msg_type") == 1 else "NONE"
227 t_entry["ma_target_signal"] = fields.get("route_len")
228 t_entry["front_signal"] = fields.get("t_train")
229 t_entry["dist_to_front_signal"] = fields.get("cycle")
230 t_entry["ma_seq"] = fields.get("ma_seq")
231 ma_offset = _decode_ma_offset(fields.get("ma_length", 0))
232 t_entry["ma_offset"] = ma_offset
233 t_entry["ma_length_from_balise"] = -ma_offset
234 t_entry["nid_lrbg"] = fields.get("nid_lrbg")
235 t_entry["v_train"] = fields.get("v_train")
236 t_entry["m_mode"] = fields.get("m_mode")
237 t_entry["site"] = site
238 t_entry["last_seen"] = now_ts
239 log.info("[IO] [%s] [CTC] [CTC_TRAIN_INFO] [CTC Train Info received] [nid_engine=%d %s]",
240 rbc_label, nid_engine, fields_str)
241 elif message == "CTC_ALARM":
242 severity = fields.get("msg_type")
243 level = _ALARM_SEVERITY_LOG.get(severity, logging.WARNING)
244 severity_name = _ALARM_SEVERITY_NAME.get(severity, f"UNKNOWN({severity})")
245 alarms.append({
246 "site": site,
247 "severity": severity_name,
248 "severity_code": severity,
249 "ma_seq": fields.get("ma_seq"),
250 "cycle": fields.get("cycle"),
251 "t_train": fields.get("t_train"),
252 "timestamp": now_ts,
253 })
254 # The severity-differentiated log level here (not always
255 # log.info like every other kind above) is the actual
256 # point of this message kind - see ctc_sim.py's own
257 # module doc: a dispatcher watching this sim's own log
258 # output needs to see an ERROR-severity alarm stand out
259 # from routine indications, not just another INFO line.
260 log.log(level, "[IO] [%s] [CTC] [CTC_ALARM] [%s alarm from RBC] [%s]",
261 rbc_label, severity_name, fields_str)
262 else:
263 log.info("[IO] [%s] [CTC] [%s] [CTC indication received] [nid_engine=%d %s]",
264 rbc_label, message, nid_engine, fields_str)
265
266 log.info("[GENERAL] [CTC] [internal] [INIT] [CTC sim stopped] [indications_seen=%d]", seen)
267
268
269if __name__ == "__main__":
270 main()
_decode_ma_offset(raw_offset)
Definition ctc_sim.py:58
_is_keepalive(data)
Definition ctc_sim.py:54