Spaces:
Running on Zero
Running on Zero
File size: 12,348 Bytes
31252db | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 | """Tasks and plans.
A task is a window of one real 2025 day on the Groningen - Leeuwarden line after something went wrong. The agent is
the dispatcher: every train (a *run* from one end of the line to the other) runs by default exactly as scheduled and
as early as the physics allow (never ahead of its timetable). The plan says only what to change:
hold keep a train at a station until a minute (to let an opposing train through a single-track section,
or to wait for a section to reopen)
to short-turn: end the run early at a turn point, so the same train can start back from there
from start a run late at a turn point (where its train was short-turned)
cancel do not run it at all (its train stays where it is)
The physics, given a plan, are deterministic (`check.simulate`): a train leaves each point at the latest of when it
is ready, its scheduled departure (never early at a scheduled stop) and its hold; it runs at its minimum running
time. A train held where it has no scheduled stop loses `stop_loss` minutes braking and `stop_loss` accelerating.
Whether the result is legal (single-track and headway conflicts, platform tracks, closed sections, where each train
ends up) is `check.evaluate`.
Times are whole minutes after 00:00 of the service day (00:30 the next night is 24:30, minute 1470); plans may give
them as integers or "HH:MM".
"""
from __future__ import annotations
import json
import re
from dataclasses import asdict, dataclass, field
from typing import Any
RULES = {
"h_follow": 3, # minutes between two trains going the same way, at both ends of a section
"h_opp": 0, # minutes after a train leaves a single-track section before an opposing train may enter it
# (0: in the same minute, as the timetable's crossings are planned)
"turn_min": 4, # minutes for a train to turn back (driver changes ends)
"stop_loss": 1, # minutes lost braking, and again accelerating, at a stop that is not in the timetable
"cancel_cost": 30, # per scheduled stop a train does not serve (the next train of its kind is 30 minutes later)
"max_delay": 120, # no train may reach a stop more than this many minutes late
"gap_k": 100, # reward: added to the avoidable cost (about three unserved stops)
}
@dataclass
class Run:
"""One scheduled train from one end of its route to the other."""
id: str # train number
kind: str # stop (Stoptrein: every station) | fast (Sneltrein)
route: list[str] # every point from origin to destination in travel order, junctions included
stops: list[bool] # scheduled stop at route[i]
arr: list[int | None] # scheduled arrival at a stop, the timetable's passing minute elsewhere; None at origin
dep: list[int | None] # scheduled departure at a stop, the passing minute elsewhere; None at destination
seg: list[int] # minimum running minutes route[i] -> route[i + 1] for this train's stopping pattern
unit: str # the train (rolling-stock unit) that runs it
fixed_to: int = 0 # in progress: route index of the point it is at or approaching when the task starts
fixed_arr: int | None = None # in progress: its arrival there (cannot change)
fixed_from: int | None = None # in progress and between points: the minute it left route[fixed_to - 1]
stuck: bool = False # in progress inside a section that closed: it waits there for the reopening
@property
def origin(self) -> str:
return self.route[0]
@property
def dest(self) -> str:
return self.route[-1]
@property
def started(self) -> bool:
return self.fixed_arr is not None
@property
def n_stops(self) -> int:
return sum(self.stops)
def idx(self, code: str) -> int:
return self.route.index(code)
@dataclass
class Unit:
"""A train set and the runs it works through the window, in order."""
id: str
runs: list[str]
at: str | None # where it stands before its first run (None: its first run is already moving)
ready: int | None # when it may leave `at`
after: dict | None = None # its first run after the window: {"run", "station", "dep", "stops"}
@dataclass
class Closure:
kind: str # closed (no train may be in the sections) | slow (+extra minutes) | track (one
# platform track out of use at a station)
where: list[str] # closed/slow: [a, b], every section between points a and b; track: [station]
start: int
end: int # exclusive
extra: int = 0
cause: str = ""
real: bool = False # from the 2025 disruption record (else generated)
@dataclass
class Task:
task_id: str
split: str
line: str
day: str
start: int # "now": the minute the agent takes over
end: int # runs leaving their origin before this minute are in the task
network: dict
runs: list[Run]
units: list[Unit]
closures: list[Closure]
notices: list[str]
disruptions: list[dict]
difficulty: str
reference: dict = field(default_factory=dict) # optimum, floor, baselines, the real outcome: never shown
rules: dict = field(default_factory=lambda: dict(RULES))
def to_dict(self, public: bool = False) -> dict:
d = asdict(self)
if public:
d.pop("reference")
return d
@classmethod
def from_dict(cls, d: dict) -> "Task":
d = dict(d)
d["runs"] = [Run(**r) for r in d["runs"]]
d["units"] = [Unit(**u) for u in d["units"]]
d["closures"] = [Closure(**c) for c in d["closures"]]
d.setdefault("reference", {})
d["rules"] = {**RULES, **(d.get("rules") or {})}
return cls(**d)
def run(self, rid: str) -> Run:
return self.run_map[rid]
@property
def run_map(self) -> dict[str, Run]:
m = self.__dict__.get("_run_map")
if m is None:
m = self.__dict__["_run_map"] = {r.id: r for r in self.runs}
return m
def rule(self, k: str) -> int:
return int(self.rules.get(k, RULES[k]))
# ---------------------------------------------------------------- plans
@dataclass
class Order:
"""What the plan changes for one run (absent from the plan: runs in full, as early as possible)."""
cancel: bool = False
start: str | None = None # "from": start at this turn point instead of the origin
end: str | None = None # "to": end at this turn point instead of the destination
hold: dict[str, int] = field(default_factory=dict) # point -> do not leave before this minute
def to_json(self, rid: str) -> dict:
d: dict[str, Any] = {"train": rid}
if self.cancel:
d["cancel"] = True
return d
if self.start:
d["from"] = self.start
if self.end:
d["to"] = self.end
if self.hold:
d["hold"] = {k: hhmm(v) for k, v in self.hold.items()}
return d
Plan = dict[str, Order]
class PlanError(ValueError):
"""The plan cannot be read at all."""
def hhmm(m: int | None) -> str | None:
if m is None:
return None
return f"{m // 60:02d}:{m % 60:02d}"
_TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*$")
def parse_time(v: Any) -> int:
if isinstance(v, bool):
raise ValueError(f"not a time: {v!r}")
if isinstance(v, int):
m = v
elif isinstance(v, float):
if v != v or v in (float("inf"), float("-inf")) or v != int(v):
raise ValueError(f"not a whole minute: {v!r}")
m = int(v)
elif isinstance(v, str):
s = v.strip()
if re.fullmatch(r"-?\d+", s):
m = int(s)
else:
g = _TIME.match(s)
if not g:
raise ValueError(f'not a time: {v!r} (use "HH:MM" or minutes after midnight)')
h, mi = int(g.group(1)), int(g.group(2))
if mi > 59:
raise ValueError(f"not a time: {v!r}")
m = h * 60 + mi
else:
raise ValueError(f"not a time: {v!r}")
if not 0 <= m <= 48 * 60:
raise ValueError(f"time out of range: {v!r}")
return m
def _load(raw: Any) -> Any:
if isinstance(raw, str):
s = raw.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", s, re.S)
if fence:
s = fence.group(1).strip()
if not s.startswith(("[", "{")):
i = min([k for k in (s.find("["), s.find("{")) if k >= 0], default=-1)
if i < 0:
raise PlanError("the plan is not JSON: give a list like "
'[{"train": "37413", "hold": {"HDG": "07:31"}}, {"train": "37324", "cancel": true}]')
s = s[i:]
try:
raw, _ = json.JSONDecoder().raw_decode(s)
except json.JSONDecodeError as e:
raise PlanError(f"the plan is not valid JSON: {e}") from None
if isinstance(raw, dict):
if "plan" in raw and isinstance(raw["plan"], (list, dict)):
return _load(raw["plan"])
# {"37413": {...}, ...}
raw = [{"train": k, **(v if isinstance(v, dict) else {"cancel": v} if isinstance(v, bool) else {"bad": v})}
for k, v in raw.items()]
if not isinstance(raw, list):
raise PlanError("the plan must be a list of entries, one per train you change")
return raw
def parse_plan(task: Task, raw: Any) -> tuple[Plan, list[str]]:
"""(plan, entry problems). Raises PlanError only when nothing can be read. An empty list is a valid plan:
every train runs in full, as early as it can."""
entries = _load(raw)
runs = task.run_map
plan: Plan = {}
problems: list[str] = []
for n, e in enumerate(entries):
if not isinstance(e, dict):
problems.append(f"entry {n}: not an object")
continue
rid = e.get("train", e.get("run", e.get("id")))
if isinstance(rid, bool) or rid is None:
problems.append(f"entry {n}: no train number")
continue
rid = str(rid).strip()
if rid not in runs:
problems.append(f"entry {n}: unknown train {rid!r}")
continue
if rid in plan:
problems.append(f"entry {n}: train {rid} appears twice")
continue
r = runs[rid]
o = Order()
unknown = set(e) - {"train", "run", "id", "cancel", "from", "start", "to", "end", "hold", "note", "reason"}
if unknown:
problems.append(f"train {rid}: unknown field(s) {sorted(unknown)}")
c = e.get("cancel", False)
if c not in (True, False, None):
problems.append(f"train {rid}: cancel must be true or false")
o.cancel = c is True
for key, alt, attr in (("from", "start", "start"), ("to", "end", "end")):
v = e.get(key, e.get(alt))
if v is None:
continue
if not isinstance(v, str) or v.strip().upper() not in r.route:
problems.append(f"train {rid}: {key} {v!r} is not on its route {'-'.join(r.route)}")
continue
setattr(o, attr, v.strip().upper())
h = e.get("hold") or {}
if not isinstance(h, dict):
problems.append(f'train {rid}: hold must be an object like {{"HDG": "07:31"}}')
h = {}
for k, v in h.items():
code = str(k).strip().upper()
if code not in r.route:
problems.append(f"train {rid}: hold at {k!r}, which is not on its route")
continue
try:
o.hold[code] = parse_time(v)
except ValueError as ex:
problems.append(f"train {rid}: hold at {code}: {ex}")
plan[rid] = o
return plan, problems
def plan_to_list(plan: Plan) -> list[dict]:
return [o.to_json(rid) for rid, o in plan.items()]
def plan_from_list(task: Task, entries: list[dict]) -> Plan:
plan, problems = parse_plan(task, entries)
if problems:
raise PlanError("; ".join(problems))
return plan
|