RailSimEnv / core /rail_core /check.py
bpHigh's picture
RailSimEnv v1: OpenEnv server, eval explorer, 3D replay and train graph
31252db verified
Raw History Blame Contribute Delete
17.2 kB
"""The physics and the grader: a plan in, every train's times, every rule it breaks and its cost out. Pure Python and
deterministic; nothing here needs a solver.
Physics (`simulate`). Each train set works its runs in order. A run leaves its first point at the latest of: when its
train is ready there, its scheduled departure (or passing minute), and the plan's hold. It then runs at its minimum
running times, but never reaches a point before its timetable minute there (drivers run to the timetable; a late
train makes up time). At a scheduled stop it leaves at the latest of arriving, its scheduled departure and any hold; at a
point where it has no stop it passes, unless held past its passing minute: then it stops, losing `stop_loss` minutes
braking and `stop_loss` accelerating. Junctions cannot hold a train. After a run its train is ready again `turn_min`
minutes after arriving, at the point where the run ended.
Rules (`evaluate`), each broken one is a violation:
single track two trains going opposite ways may not be in a single-track section at once; the second enters
at least h_opp minutes after the first has left
headway trains going the same way keep h_follow minutes apart at both ends of every section, so nothing
overtakes between points
platforms no more trains at a station than it has tracks (minus any out of use); at a directional station
(double track) one per direction
closed no train in a closed section while it is closed (a train already stuck inside waits there)
continuity a run starts where its train is; short-turns only at turn points; a running train cannot be
cancelled or turned behind itself; every train ends the window where its next run leaves from
max delay no stop reached more than max_delay minutes late
Cost, in stop-minutes (lower is better): the arrival delay at every scheduled stop a train serves, plus cancel_cost
for every scheduled stop it does not serve, plus, for each train set, its lateness for its first run after the
window times that run's number of stops (delay handed on to the next window).
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from .model import Order, Plan, Run, Task, hhmm
from .network import Network
NEG = -10**9
@dataclass
class RunResult:
train: str
unit: str
cancelled: bool = False
first: int | None = None # route index of its first served point
last: int | None = None # route index of its last served point
arr: dict[int, int] = field(default_factory=dict)
dep: dict[int, int] = field(default_factory=dict)
stopped: dict[int, bool] = field(default_factory=dict)
delay: int = 0
unserved: int = 0
max_late: int = 0
cost: int = 0
problems: list[str] = field(default_factory=list)
@dataclass
class PlanResult:
feasible: bool
cost: int | None
delay_cost: int
cancel_cost: int
carry_cost: int
cancelled: int
short_turned: int
runs: list[RunResult]
violations: list[dict]
@dataclass
class _Occ: # a train in a section
sec: int
dir: int
enter: int
exit: int
train: str
history: bool = False
@dataclass
class _Pres: # a train standing at (or passing) a station, minutes inclusive
point: str
dir: int
t0: int
t1: int
train: str
def network(task: Task) -> Network:
n = task.__dict__.get("_net")
if n is None:
n = task.__dict__["_net"] = Network.from_dict(task.network)
return n
def run_dir(net: Network, r: Run) -> int:
return net.direction(r.origin, r.dest)
def served_part(task: Task, r: Run, o: Order) -> tuple[int, int, list[str]]:
"""(first, last, problems) for the part of the route the plan runs."""
net = network(task)
probs = []
s = r.idx(o.start) if o.start else 0
e = r.idx(o.end) if o.end else len(r.route) - 1
if o.start and o.start != r.origin:
if r.started:
probs.append(f"{r.id} is already running from {r.origin}: it cannot start at {o.start}")
s = 0
elif not net.by_code[o.start].turn:
probs.append(f"{r.id} cannot start at {o.start}: trains can only be turned at "
+ ", ".join(p.code for p in net.points if p.turn))
if o.end and o.end != r.dest and not net.by_code[o.end].turn:
probs.append(f"{r.id} cannot end at {o.end}: trains can only be turned at "
+ ", ".join(p.code for p in net.points if p.turn))
if e <= s:
probs.append(f"{r.id}: 'to' {r.route[e]} must come after where it starts ({r.route[s]})")
e = len(r.route) - 1
if r.started and e < r.fixed_to:
probs.append(f"{r.id} is already past {r.route[e]}: it can end at {r.route[r.fixed_to]} at the earliest")
e = len(r.route) - 1
return s, e, probs
def simulate(task: Task, plan: Plan):
"""Times for every run under the plan. Returns (results by train, structural violations, section occupancies,
station presences, carry cost per unit)."""
net = network(task)
loss, turn = task.rule("stop_loss"), task.rule("turn_min")
runs = task.run_map
results: dict[str, RunResult] = {}
viol: list[dict] = []
occ: list[_Occ] = []
pres: list[_Pres] = []
carry: dict[str, int] = {}
slow = [c for c in task.closures if c.kind == "slow"]
def slow_extra(sec: int, t: int) -> int:
x = 0
for c in slow:
lo, hi = sorted((net.index[c.where[0]], net.index[c.where[-1]]))
if lo <= sec < hi and c.start <= t < c.end:
x = max(x, c.extra)
return x
def finite(code: str) -> bool:
p = net.by_code[code]
return p.kind != "junction" and p.tracks is not None
for u in task.units:
pos, ready = u.at, u.ready
wait_from: int | None = task.start if (u.at and finite(u.at)) else None # standing at a station at start
for rid in u.runs:
r = runs[rid]
o = plan.get(rid) or Order()
res = RunResult(train=rid, unit=u.id)
results[rid] = res
d = run_dir(net, r)
if o.cancel:
if r.started:
res.problems.append(f"{rid} is already running: it cannot be cancelled (short-turn it instead)")
o = Order(end=o.end, hold=o.hold)
else:
res.cancelled = True
res.unserved = r.n_stops
continue
s, e, probs = served_part(task, r, o)
res.problems += probs
k = r.fixed_to if r.started else s
if not r.started and r.route[s] != pos:
res.problems.append(f"{rid} would start at {r.route[s]}, but its train ({u.id}) is at {pos}")
for code in o.hold:
i = r.idx(code)
if i < k or i >= e:
res.problems.append(f"{rid}: hold at {code} is outside the part it runs "
f"({r.route[k]} to {r.route[e]}, holds only where it leaves from)")
elif net.by_code[code].kind == "junction":
res.problems.append(f"{rid}: trains cannot be held at the junction {code}")
res.first, res.last = s, e
arr, dep, stopped = res.arr, res.dep, res.stopped
last_occ = None
if r.started:
arr[k] = r.fixed_arr
if r.fixed_from is not None:
last_occ = _Occ(net.section(r.route[k - 1], r.route[k]), d, r.fixed_from, r.fixed_arr, rid, True)
occ.append(last_occ)
i = k
while True:
code = r.route[i]
pt = net.by_code[code]
h = o.hold.get(code) if s <= i < e else None
accel = 0
if i == e:
stopped[i] = True
break
if i == k and not r.started: # where the run starts
t = max(ready if ready is not None else NEG, r.dep[i], h if h is not None else NEG)
stopped[i] = True
accel = 0 if r.stops[i] else loss
if wait_from is not None and finite(code):
pres.append(_Pres(code, d, wait_from, t, rid))
elif r.stops[i]:
t = max(arr[i], r.dep[i], h if h is not None else NEG)
stopped[i] = True
elif pt.kind != "junction" and h is not None and h > arr[i]:
arr[i] += loss # an unscheduled stop: it brakes ...
if last_occ is not None:
last_occ.exit = arr[i] # ... still in the section it is leaving
t = max(arr[i], h)
stopped[i] = True
accel = loss # ... and accelerates again
else:
t = arr[i]
stopped[i] = False
if r.started and i == k:
t = max(t, task.start) # standing there when the task starts
dep[i] = t
if (i != k or r.started) and finite(code):
pres.append(_Pres(code, d, arr[i], t, rid))
sec = net.section(code, r.route[i + 1])
nxt = t + r.seg[i] + accel + slow_extra(sec, t)
if r.arr[i + 1] is not None:
nxt = max(nxt, r.arr[i + 1]) # never ahead of the timetable
if i + 1 == e and not r.stops[e]:
nxt += loss # brakes to end where it has no scheduled stop
arr[i + 1] = nxt
last_occ = _Occ(sec, d, t, nxt, rid)
occ.append(last_occ)
i += 1
# cost
for j in range(k if r.started else s + 1, e + 1):
if r.stops[j] and r.arr[j] is not None and arr[j] >= task.start:
late = arr[j] - r.arr[j]
res.delay += max(0, late)
res.max_late = max(res.max_late, late)
res.unserved = sum(1 for j, st in enumerate(r.stops) if st and not (s <= j <= e))
pos, ready = r.route[e], arr[e] + turn
wait_from = arr[e] if finite(r.route[e]) else None
if wait_from is not None and pos is not None:
pres.append(_Pres(pos, 0, wait_from, task.end + 24 * 60, u.id))
if u.after:
if pos != u.after["station"]:
viol.append({"rule": "continuity", "trains": [u.after["run"]], "where": pos,
"detail": f"train set {u.id} ends the window at {pos}, but its next run "
f"{u.after['run']} leaves {u.after['station']} at {hhmm(u.after['dep'])}"})
elif ready is not None:
carry[u.id] = max(0, ready - u.after["dep"]) * int(u.after["stops"])
return results, viol, occ, pres, carry
def evaluate(task: Task, plan: Plan) -> PlanResult:
net = network(task)
results, viol, occ, pres, carry = simulate(task, plan)
hf, ho, maxd = task.rule("h_follow"), task.rule("h_opp"), task.rule("max_delay")
bad: dict[str, set] = defaultdict(set)
def add(rule: str, trains: list[str], where: str, time: int | None, detail: str):
viol.append({"rule": rule, "trains": trains, "where": where, "time": hhmm(time), "detail": detail})
for t in trains:
if t in results:
bad[t].add(rule)
for rid, res in results.items():
for p in res.problems:
add("continuity", [rid], "", None, p)
if res.max_late > maxd:
add("max_delay", [rid], "", None, f"{rid} reaches a stop {res.max_late} minutes late (limit {maxd}): "
f"short-turn or cancel it")
# sections: headways and single track
by_sec: dict[int, list[_Occ]] = defaultdict(list)
for o in occ:
by_sec[o.sec].append(o)
for sec, lst in by_sec.items():
s = net.sections[sec]
name = f"{s.a}-{s.b}"
lst.sort(key=lambda o: (o.enter, o.exit))
for i, a in enumerate(lst):
for b in lst[i + 1:]:
if b.enter > a.exit + max(hf, ho) and b.enter > a.enter + hf:
break
if a.train == b.train:
continue
if a.dir == b.dir:
if b.enter - a.enter < hf or b.exit - a.exit < hf:
what = "overtakes" if b.exit < a.exit else "follows too closely"
add("headway", [a.train, b.train], name, b.enter,
f"{b.train} {what} {a.train} in {name}: enter {hhmm(a.enter)}/{hhmm(b.enter)}, "
f"leave {hhmm(a.exit)}/{hhmm(b.exit)} (same direction: {hf} min apart at both ends, "
f"no overtaking between points)")
elif s.single and not (a.exit + ho <= b.enter or b.exit + ho <= a.enter):
add("single_track", [a.train, b.train], name, b.enter,
f"{a.train} ({hhmm(a.enter)}-{hhmm(a.exit)}) and {b.train} ({hhmm(b.enter)}-{hhmm(b.exit)}) "
f"meet head-on in single-track {name}")
# closures
for c in task.closures:
if c.kind != "closed":
continue
lo, hi = sorted((net.index[c.where[0]], net.index[c.where[-1]]))
for o in occ:
if lo <= o.sec < hi and not o.history and o.enter < c.end and o.exit > c.start:
s = net.sections[o.sec]
add("closed", [o.train], f"{s.a}-{s.b}", o.enter,
f"{o.train} is in {s.a}-{s.b} ({hhmm(o.enter)}-{hhmm(o.exit)}) while it is closed "
f"{hhmm(c.start)}-{hhmm(c.end)} ({c.cause})")
# platforms
out_of_use: dict[str, list] = defaultdict(list)
for c in task.closures:
if c.kind == "track":
out_of_use[c.where[0]].append((c.start, c.end))
by_pt: dict[str, list[_Pres]] = defaultdict(list)
for p in pres:
by_pt[p.point].append(p)
for code, lst in by_pt.items():
pt = net.by_code[code]
groups = [[p for p in lst if p.dir == d] for d in (1, -1)] if pt.directional else [lst]
for g in groups:
cap = 1 if pt.directional else pt.tracks
events = sorted({p.t0 for p in g} | {a for a, _ in out_of_use.get(code, [])})
reported = set()
for t in events:
here = [p for p in g if p.t0 <= t <= p.t1]
c_t = cap - sum(1 for a, b in out_of_use.get(code, []) if a <= t < b)
if len(here) > c_t:
key = tuple(sorted(p.train for p in here))
if key in reported:
continue
reported.add(key)
tracks = ("one track each way" if pt.directional else f"{c_t} track" + ("s" if c_t != 1 else ""))
add("platforms", list(key), code, t,
f"{len(here)} trains at {code} at {hhmm(t)} but it has {tracks}: "
+ ", ".join(f"{p.train} {hhmm(p.t0)}-{hhmm(p.t1) if p.t1 < task.end + 1440 else 'end'}"
for p in here))
delay = sum(r.delay for r in results.values())
cancel = task.rule("cancel_cost") * sum(r.unserved for r in results.values())
carry_cost = sum(carry.values())
for rid, res in results.items():
res.cost = res.delay + task.rule("cancel_cost") * res.unserved
res.problems = sorted(bad.get(rid, set()))
feasible = not viol
n_cxl = sum(1 for r in results.values() if r.cancelled)
n_st = sum(1 for rid, r in results.items()
if not r.cancelled and (r.first != 0 or r.last != len(task.run(rid).route) - 1))
return PlanResult(feasible, delay + cancel + carry_cost if feasible else None, delay, cancel, carry_cost, n_cxl,
n_st, list(results.values()), viol)
def timetable(task: Task, res: RunResult) -> list[dict]:
"""The served points of one run with arrival and departure (passing) minutes, for display."""
r = task.run(res.train)
net = network(task)
out = []
if res.cancelled:
return out
for i in sorted(set(res.arr) | set(res.dep)):
code = r.route[i]
if net.by_code[code].kind == "junction":
continue
out.append({"at": code, "arr": hhmm(res.arr.get(i)), "dep": hhmm(res.dep.get(i)),
"stop": bool(res.stopped.get(i)), "sched": hhmm(r.arr[i] if r.stops[i] and r.arr[i] is not None
else r.dep[i] if r.stops[i] else None),
"late": (res.arr[i] - r.arr[i]) if (r.stops[i] and r.arr[i] is not None and i in res.arr) else None})
return out