bpHigh's picture
RailSimEnv v1: OpenEnv server, eval explorer, 3D replay and train graph
31252db verified
Raw History Blame Contribute Delete
6.75 kB
"""Disruptions and tiers: what can go wrong on the line, from the 2025 record, and how tasks are drawn.
A disruption in the Rijden de Treinen record has a cause, a start, an end and the stations it was linked to. Its
effect in the model depends on the cause (an assumption, stated in SOURCE.md):
broken down train, collision, emergency call, defective track, person or animal on the track, power failure
-> the sections between the outermost stations named are closed for the whole disruption
signal failure, level crossing failure
-> trains run on sight there: every section in the span takes `SLOW_EXTRA` minutes longer
points failure
-> one track out of use at the passing station(s) in the span (no crossing there if it had two)
A disruption linked to every station of the line has no usable location; such causes are replayed on a section
drawn with the task's seed.
"""
from __future__ import annotations
import random
from .model import Closure
from .network import Network
SLOW_EXTRA = 4
CLOSE = ("broken down", "collision", "emergency", "defective railway track", "person", "animal", "power failure",
"damaged", "fire", "accident", "vandalism", "copper theft")
SLOW = ("signal", "level crossing")
POINTS = ("points",)
LOOPS = ("HDG", "FWD", "BP", "ZH")
def effect(cause: str) -> str:
c = cause.lower()
if any(k in c for k in POINTS) and not any(k in c for k in SLOW):
return "track"
if any(k in c for k in SLOW):
return "slow"
return "closed"
def span(net: Network, codes: list[str]) -> list[str] | None:
"""[west, east] of the stations named, or None when the record names (nearly) the whole line."""
idx = sorted(net.index[c] for c in codes if c in net.index)
if len(idx) < 2 or (idx[0] == 0 and idx[-1] == len(net.points) - 1 and len(idx) >= 8):
return None
return [net.points[idx[0]].code, net.points[idx[-1]].code]
def closures_for(net: Network, cause: str, codes: list[str], start: int, end: int, rng: random.Random,
real: bool = True) -> list[Closure]:
kind = effect(cause)
where = span(net, codes)
if where is None: # no location in the record: draw one
where = _draw_span(net, kind, rng)
if kind == "track":
lo, hi = net.index[where[0]], net.index[where[-1]]
loops = [p.code for p in net.points[lo:hi + 1] if p.code in LOOPS] or [rng.choice(LOOPS)]
return [Closure("track", [c], start, end, cause=cause, real=real) for c in loops[:1]]
if kind == "slow":
return [Closure("slow", where, start, end, extra=SLOW_EXTRA, cause=cause, real=real)]
return [Closure("closed", where, start, end, cause=cause, real=real)]
def _draw_span(net: Network, kind: str, rng: random.Random) -> list[str]:
stations = [p.code for p in net.points if p.kind != "junction"]
if kind == "track":
c = rng.choice(LOOPS)
return [c, c]
i = rng.randrange(len(stations) - 1)
j = min(len(stations) - 1, i + rng.choice((1, 1, 2)))
return [stations[i], stations[j]]
# ---------------------------------------------------------------- tiers and candidates
import csv
from datetime import date as _date
from functools import lru_cache
from .data import LINE_DIR, days as _days, disruptions as _real_disruptions
from .model import hhmm
# Eval days lie in these ISO weeks of 2025 (42 days); train days are every other day of January-June.
HELD_OUT = {4, 7, 11, 17, 22, 25}
PEAKS = [(6 * 60 + 30, 9 * 60), (15 * 60 + 30, 18 * 60 + 30)]
LATE_CAUSES = ("a technical inspection", "a late driver", "a door fault being fixed", "a cleaning crew overrun",
"coupling problems")
TIERS = {
# window minutes; what is seeded (each disruption's cause and duration drawn from the 2024-2025 record)
"real": dict(window=(150, 210)), # the record's own disruption, on its own day
"blockage": dict(window=(150, 210), closed=1, late=(0, 1)),
"loop": dict(window=(150, 210), track=1, slow=(0, 1), late=(0, 1)),
"compound": dict(window=(180, 240), closed=1, track=(0, 1), slow=(0, 1), late=(1, 3)),
}
def split_of(day: str) -> str:
return "eval" if _date.fromisoformat(day).isocalendar()[1] in HELD_OUT else "train"
def split_days(split: str) -> list[str]:
return [d for d in _days() if split_of(d) == split]
@lru_cache(maxsize=1)
def profiles() -> list[dict]:
with open(LINE_DIR / "disruption_profiles.csv", encoding="utf-8") as f:
rows = [r for r in csv.DictReader(f) if r["duration_minutes"]]
for r in rows:
r["effect"] = effect(r["cause_en"])
r["minutes"] = max(10, min(240, round(float(r["duration_minutes"]))))
return rows
def real_disruptions(split: str | None = None) -> list[dict]:
"""The record's own disruptions on the line, as (day, start, end, cause, stations)."""
out = []
for d in _real_disruptions():
day = d["start_time"][:10]
if split and split_of(day) != split:
continue
hh, mm = int(d["start_time"][11:13]), int(d["start_time"][14:16])
start = hh * 60 + mm
dur = round(float(d["duration_minutes"] or 0))
if dur < 15 or not 5 * 60 <= start <= 22 * 60:
continue
out.append({"day": day, "start": start, "end": start + dur, "cause": d["cause_en"],
"stations": [c.strip() for c in d["rdt_station_codes"].split(",")]})
return out
def draw(rng: random.Random, kind: str) -> dict:
pool = [p for p in profiles() if p["effect"] == kind] or profiles()
return rng.choice(pool)
def seeded_closures(net: Network, tier: str, start: int, window: int, rng: random.Random) -> tuple[list, list[str]]:
"""Closures for a seeded tier, starting within the first half hour of the window."""
r = TIERS[tier]
out: list[Closure] = []
def count(key):
v = r.get(key, 0)
return rng.randint(*v) if isinstance(v, tuple) else v
for kind, key in (("closed", "closed"), ("track", "track"), ("slow", "slow")):
for _ in range(count(key)):
p = draw(rng, kind)
t0 = start + rng.choice((0, 0, 5, 10, 20, 30))
minutes = min(p["minutes"], window - 20) if kind == "closed" else p["minutes"]
codes = [c.strip() for c in p["rdt_station_codes"].split(",")]
where = span(net, codes) or _draw_span(net, kind, rng)
out += closures_for(net, p["cause_en"], where, t0, t0 + minutes, rng, real=False)
notes = []
return out, notes
def candidate_id(day: str, start: int, window: int, tier: str, seed: int) -> str:
return f"gnlw-{day}-{hhmm(start).replace(':', '')}-{window}m-{tier}-{seed}"