CTCRBCSim
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 main():
59 config = sim_config.load()
60 control_host = sim_config.require(config, "controlServer", "bindHost")
61 control_port = sim_config.require(config, "controlServer", "port")
62
63 # "[%(levelname)s] " prefix - see TrainRBCSim/src/train/train_sim.py's
64 # own identical change for why (the test env's own listener should
65 # collect a real level, not guess/default one).
66 logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s", stream=sys.stdout)
67 log = logging.getLogger("ctc")
68
69 targets = {
70 "WEST": (sim_config.require(config, "rbcWest", "host"), sim_config.require(config, "rbcWest", "port")),
71 "EAST": (sim_config.require(config, "rbcEast", "host"), sim_config.require(config, "rbcEast", "port")),
72 }
73 link = DualHomedLink(log, targets, ENVELOPE_SIZE)
74
75 # Most-recently-received indication per (nid_engine, message name) -
76 # tests/robot/design/DESIGN.md section 4.2. Indexed by nid_engine
77 # (the TRAIN the indication is about, not a CTC identity - there is
78 # only one CTC instance - SIM_INTERFACES.md's own note).
79 last_received = {}
80 site_health = {"WEST": {"last_seen": None, "msg_count": 0}, "EAST": {"last_seen": None, "msg_count": 0}}
81 train_status = {}
82 alarms = []
83 start_time = time.monotonic()
84
85 def handle_ping(_args):
86 return "PONG\n"
87
88 def handle_get_message(request):
89 nid_engine = request["nidEngine"]
90 message = request["message"]
91 packets = last_received.get(nid_engine, {}).get(message)
92 if packets is None:
93 return {"status": "ERR", "reason": f"no {message} received yet for nidEngine {nid_engine}"}
94 return {"status": "OK", "message": message, "packets": {message: packets}}
95
96 def handle_get_status(_request):
97 connected_sites = link.connected_sites()
98 sites_res = {}
99 for s in ("WEST", "EAST"):
100 tgt = targets.get(s, ("127.0.0.1", 0))
101 sites_res[s] = {
102 "connected": s in connected_sites,
103 "host": tgt[0],
104 "port": tgt[1],
105 "last_seen": site_health.get(s, {}).get("last_seen"),
106 "msg_count": site_health.get(s, {}).get("msg_count", 0),
107 }
108 return {
109 "status": "OK",
110 "sites": sites_res,
111 "trains": train_status,
112 "alarms": alarms[-20:],
113 "stats": {
114 "indications_seen": seen,
115 "connected_sites": connected_sites,
116 "uptime_s": round(time.monotonic() - start_time, 1),
117 },
118 }
119
120 def handle_clear_messages(_request):
121 last_received.clear()
122 train_status.clear()
123 return {"status": "OK"}
124
125 control = ControlServer(
126 log,
127 control_port,
128 {"PING": handle_ping},
129 json_handlers={
130 "getMessage": handle_get_message,
131 "getStatus": handle_get_status,
132 "getRbcStatus": handle_get_status,
133 "clearMessages": handle_clear_messages,
134 },
135 bind_host=control_host,
136 )
137
138 running = True
139
140 def _stop(signum, _frame):
141 nonlocal running
142 log.info("[GENERAL] [ctc] [internal] [SIGNAL] [CTC sim shutting down] [signal=%d]", signum)
143 running = False
144
145 signal.signal(signal.SIGTERM, _stop)
146 signal.signal(signal.SIGINT, _stop)
147
148 log.info("[GENERAL] [ctc] [internal] [INIT] [CTC sim starting - overseeing WEST, EAST] []")
149 seen = 0
150
151 # ADR-038: own-perspective status - startup + every 5s, to both sites.
152 status = StatusReporter(log, link, "SIM-CTC", "ctc")
153 status.emit()
154
155 while running:
156 link.ensure_connected()
157 control.poll(timeout=0.0)
158 status.tick()
159 for site, data in link.poll_recv(timeout=0.2):
160 if _is_keepalive(data):
161 continue
162 decoded = CATALOG.decode_to_message(data)
163 if decoded is None:
164 log.warning("[%s] [internal] [MALFORMED] [Malformed indication dropped] [site=%s]", "ctc", site)
165 continue
166 nid_engine, message, fields = decoded
167 last_received.setdefault(nid_engine, {})[message] = fields
168 seen += 1
169 now_ts = time.time()
170 site_health.setdefault(site, {})["last_seen"] = now_ts
171 site_health[site]["msg_count"] = site_health[site].get("msg_count", 0) + 1
172
173 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
174 if message == "CTC_CONNECTED":
175 train_status[nid_engine] = {
176 "connected": True,
177 "d_lrbg": fields.get("d_lrbg", 0),
178 "site": site,
179 "last_seen": now_ts,
180 }
181 log.info("[IO] [c-%s] [ctc] [CTC_CONNECTED] [CTC Connected indication received] [nid_engine=%d %s]",
182 site.lower(), nid_engine, fields_str)
183 elif message == "CTC_MA_GRANTED":
184 t_entry = train_status.setdefault(nid_engine, {"connected": True})
185 t_entry["ma_length"] = fields.get("ma_length")
186 t_entry["ma_seq"] = fields.get("ma_seq")
187 t_entry["site"] = site
188 t_entry["last_seen"] = now_ts
189 log.info("[IO] [c-%s] [ctc] [CTC_MA_GRANTED] [CTC Movement Authority Granted received] [nid_engine=%d %s]",
190 site.lower(), nid_engine, fields_str)
191 elif message == "CTC_MA_EXTENDED":
192 t_entry = train_status.setdefault(nid_engine, {"connected": True})
193 t_entry["ma_length"] = fields.get("ma_length")
194 t_entry["ma_seq"] = fields.get("ma_seq")
195 t_entry["site"] = site
196 t_entry["last_seen"] = now_ts
197 log.info("[IO] [c-%s] [ctc] [CTC_MA_EXTENDED] [CTC Movement Authority Extended received] [nid_engine=%d %s]",
198 site.lower(), nid_engine, fields_str)
199 elif message == "CTC_TRAIN_INFO":
200 t_entry = train_status.setdefault(nid_engine, {"connected": True})
201 t_entry["d_lrbg"] = fields.get("d_lrbg")
202 t_entry["ma_status"] = "GRANTED" if fields.get("msg_type") == 1 else "NONE"
203 t_entry["ma_target_signal"] = fields.get("route_len")
204 t_entry["front_signal"] = fields.get("t_train")
205 t_entry["dist_to_front_signal"] = fields.get("cycle")
206 t_entry["ma_seq"] = fields.get("ma_seq")
207 # ma_length rides the wire as a SIGNED value's raw
208 # two's-complement uint32 bit pattern (see this message's
209 # own message_catalog.json comment) - convert back to
210 # signed here, the one place this sim exposes it.
211 raw_offset = fields.get("ma_length", 0)
212 ma_offset = raw_offset - 0x100000000 if raw_offset >= 0x80000000 else raw_offset
213 t_entry["ma_offset"] = ma_offset
214 t_entry["ma_length_from_balise"] = -ma_offset
215 t_entry["nid_lrbg"] = fields.get("nid_lrbg")
216 t_entry["v_train"] = fields.get("v_train")
217 t_entry["m_mode"] = fields.get("m_mode")
218 t_entry["site"] = site
219 t_entry["last_seen"] = now_ts
220 log.info("[IO] [c-%s] [ctc] [CTC_TRAIN_INFO] [CTC Train Info received] [nid_engine=%d %s]",
221 site.lower(), nid_engine, fields_str)
222 elif message == "CTC_ALARM":
223 severity = fields.get("msg_type")
224 level = _ALARM_SEVERITY_LOG.get(severity, logging.WARNING)
225 severity_name = _ALARM_SEVERITY_NAME.get(severity, f"UNKNOWN({severity})")
226 alarms.append({
227 "site": site,
228 "severity": severity_name,
229 "severity_code": severity,
230 "ma_seq": fields.get("ma_seq"),
231 "cycle": fields.get("cycle"),
232 "t_train": fields.get("t_train"),
233 "timestamp": now_ts,
234 })
235 # The severity-differentiated log level here (not always
236 # log.info like every other kind above) is the actual
237 # point of this message kind - see ctc_sim.py's own
238 # module doc: a dispatcher watching this sim's own log
239 # output needs to see an ERROR-severity alarm stand out
240 # from routine indications, not just another INFO line.
241 log.log(level, "[IO] [c-%s] [ctc] [CTC_ALARM] [%s alarm from RBC] [%s]",
242 site.lower(), severity_name, fields_str)
243 else:
244 log.info("[IO] [c-%s] [ctc] [%s] [CTC indication received] [nid_engine=%d %s]",
245 site.lower(), message, nid_engine, fields_str)
246
247 log.info("[GENERAL] [ctc] [internal] [INIT] [CTC sim stopped] [indications_seen=%d]", seen)
248
249
250if __name__ == "__main__":
251 main()
_is_keepalive(data)
Definition ctc_sim.py:54