Spaces:
Running
Running
File size: 10,535 Bytes
a1e3907 | 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 | """Tasks and plans.
A task is one week at one Barcelona container quay after something went wrong: the quay is a row of numbered
sections (the port's own numbering, about 42 m each), every ship needs a run of consecutive sections for a fixed
number of hours, and the published plan no longer works. Times are whole hours from Monday 00:00 UTC.
A plan gives every ship a berthing hour and the first (lowest-numbered) section it occupies. Tasks with quay-crane
rules (`task.rules["crane_pool"]`) also take a crane count per ship: hours alongside = ceil(workload / cranes), the
cranes working at any hour may not exceed the pool, and at most `max_moves_per_hour` ships may berth or leave in any
one hour.
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import asdict, dataclass, field
from typing import Any
MOVE_PENALTY = 5 # cost of moving a ship off its planned sections, in section-hours of delay
@dataclass
class Ship:
id: int
name: str
imo: str
length_m: float
sections: int # consecutive sections the ship needs
arrival: int # earliest hour it can berth (after any delay notice)
handling: int # hours alongside, loading and discharging
planned_hour: int | None # berthing hour in the published plan (None: unscheduled call)
planned_section: int | None
due: int # planned departure hour; leaving later costs sections x hours
from_port: str = ""
to_port: str = ""
beam_m: float | None = None
draught_m: float | None = None
workload: int | None = None # crane-hours of loading and discharging (crane-rule tasks only)
min_cranes: int | None = None
max_cranes: int | None = None
std_cranes: int | None = None # cranes the published plan assumed; `handling` is the stay at this count
weight: int = 1 # delay multiplier (live tasks: a genuine transshipment connection)
berth_deadline: int | None = None # live tasks: a genuine emergency must be berthed by this hour ...
deadline_penalty: int = 0 # ... or pay this much per hour berthed after it
@property
def planned_departure(self) -> int:
return self.due
def handling_for(self, cranes: int | None) -> int:
"""Hours alongside with `cranes` quay cranes (the published stay when cranes do not apply)."""
if self.workload is None or cranes is None:
return self.handling
return max(1, math.ceil(self.workload / cranes))
@dataclass
class Block:
"""A rectangle of quay that no ship may use: a ship already alongside, or closed sections."""
first: int
last: int # inclusive
start: int # hour
end: int # hour, exclusive
kind: str # "alongside" | "closed"
label: str = ""
cranes: int = 0 # cranes working a ship already alongside (crane-rule tasks)
@dataclass
class Task:
task_id: str
split: str
quay: str
terminal: str
first_section: int
last_section: int
section_m: float
week: int
week_start_utc: str
ships: list[Ship]
blocks: list[Block]
notices: list[str]
disruptions: list[dict]
difficulty: str
reference: dict = field(default_factory=dict) # naive/optimal costs and plans: never shown to the agent
rules: dict = field(default_factory=dict) # crane_pool, crane_outages, max_moves_per_hour, reward
@property
def n_sections(self) -> int:
return self.last_section - self.first_section + 1
def ship(self, sid: int) -> Ship:
return self.ships[sid]
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["ships"] = [Ship(**s) for s in d["ships"]]
d["blocks"] = [Block(**b) for b in d["blocks"]]
d.setdefault("reference", {})
d.setdefault("rules", {})
return cls(**d)
@property
def cranes(self) -> bool:
return bool(self.rules.get("crane_pool"))
def crane_pool_at(self, hour: int) -> int:
pool = int(self.rules["crane_pool"])
for o in self.rules.get("crane_outages", []):
if o["start"] <= hour < o["end"]:
pool -= int(o["cranes"])
return pool
def no_move_windows(self, ship: "Ship") -> list[tuple[int, int]]:
"""Merged hour windows [start, end) in which this ship may not berth or leave (wind limits by length)."""
ws = sorted((w["start"], w["end"]) for w in self.rules.get("no_moves", [])
if ship.length_m >= w.get("min_length", 0))
out: list[list[int]] = []
for a, b in ws:
if out and a <= out[-1][1]:
out[-1][1] = max(out[-1][1], b)
else:
out.append([a, b])
return [(a, b) for a, b in out]
def departure(self, ship: "Ship", finish: int) -> int:
"""A ship that finishes inside a no-movement window waits alongside until the window ends."""
for a, b in self.no_move_windows(ship):
if a <= finish < b:
return b
return finish
def cranes_of(self, plan_entry: tuple) -> int | None:
return plan_entry[2] if len(plan_entry) > 2 else None
def planned(self) -> dict[int, tuple]:
"""The published plan for ships that had one (with the standard crane count on crane-rule tasks)."""
return {s.id: (s.planned_hour, s.planned_section) + ((s.std_cranes,) if self.cranes else ())
for s in self.ships if s.planned_section is not None}
Plan = dict[int, tuple] # ship id -> (berthing hour, first section) or (berthing hour, first section, cranes)
class PlanError(ValueError):
pass
def parse_plan(task: Task, raw: Any) -> tuple[Plan, list[str]]:
"""Read a plan from what a model sends: a list of {ship, berth_hour, section} objects (or a JSON string of
one), or a {ship_id: [hour, section]} mapping. Ships may be named by id or by name. Returns the plan and a list
of problems with entries that could not be read (unknown ship, duplicate, non-integer values)."""
if isinstance(raw, str):
text = raw.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", text, re.S)
if fence:
text = fence.group(1)
try:
raw = json.loads(text)
except json.JSONDecodeError as e:
raise PlanError(f"plan is not valid JSON: {e.msg} at position {e.pos}") from None
if isinstance(raw, dict) and "plan" in raw and len(raw) == 1:
raw = raw["plan"]
entries: list[tuple[Any, Any, Any, Any]] = []
if isinstance(raw, dict):
for k, v in raw.items():
if isinstance(v, dict):
entries.append((k, v.get("berth_hour", v.get("hour")), v.get("section"), v.get("cranes")))
elif isinstance(v, (list, tuple)) and len(v) in (2, 3):
entries.append((k, v[0], v[1], v[2] if len(v) == 3 else None))
else:
entries.append((k, None, None, None))
elif isinstance(raw, list):
for e in raw:
if not isinstance(e, dict):
raise PlanError("each plan entry must be an object like {\"ship\": 3, \"berth_hour\": 40, \"section\": 12}")
entries.append((e.get("ship", e.get("id", e.get("name"))), e.get("berth_hour", e.get("hour")), e.get("section"),
e.get("cranes")))
else:
raise PlanError("plan must be a list of {ship, berth_hour, section} objects")
if len(entries) > 4 * len(task.ships) + 10:
raise PlanError(f"plan has {len(entries)} entries for {len(task.ships)} ships")
horizon = 100 * 24 * 7 # no plan needs hours beyond ~100 weeks; refusing them keeps grading cheap
by_name: dict[str, list[int]] = {}
for s in task.ships:
by_name.setdefault(s.name.upper(), []).append(s.id)
plan: Plan = {}
problems: list[str] = []
for key, hour, section, cranes in entries:
sid = None
if isinstance(key, bool):
pass
elif isinstance(key, int) or (isinstance(key, str) and key.strip().lstrip("-").isdigit()):
sid = int(key)
elif isinstance(key, str) and len(by_name.get(key.strip().upper(), [])) == 1:
sid = by_name[key.strip().upper()][0]
if sid is None or not 0 <= sid < len(task.ships):
problems.append(f"unknown ship {key!r}: use the ship ids from the table")
continue
if sid in plan:
problems.append(f"ship {sid} appears more than once; the first entry is used")
continue
try:
if isinstance(hour, bool) or isinstance(section, bool):
raise ValueError
h, sec = int(hour), int(section)
if h != float(hour) or sec != float(section):
raise ValueError
except (TypeError, ValueError, OverflowError):
problems.append(f"ship {sid}: berth_hour and section must be whole numbers")
continue
if not (-horizon <= h <= horizon and -1000 <= sec <= 1000):
problems.append(f"ship {sid}: berth_hour or section out of range")
continue
if task.cranes:
if cranes is None:
cranes = task.ships[sid].std_cranes
try:
if isinstance(cranes, bool):
raise ValueError
c = int(cranes)
if c != float(cranes) or not -1000 <= c <= 1000:
raise ValueError
except (TypeError, ValueError, OverflowError):
problems.append(f"ship {sid}: cranes must be a whole number")
continue
plan[sid] = (h, sec, c)
else:
plan[sid] = (h, sec)
return plan, problems
def plan_to_list(plan: Plan) -> list[dict]:
out = []
for sid, entry in sorted(plan.items()):
row = {"ship": sid, "berth_hour": entry[0], "section": entry[1]}
if len(entry) > 2 and entry[2] is not None:
row["cranes"] = entry[2]
out.append(row)
return out
def plan_from_list(rows: list[dict]) -> Plan:
return {int(r["ship"]): (int(r["berth_hour"]), int(r["section"])) + ((int(r["cranes"]),) if r.get("cranes") is not None else ())
for r in rows}
|