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}