Spaces:
Running
Running
File size: 19,462 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 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 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 | """Build tasks: a real week at a Barcelona quay, then a seeded disruption the published plan cannot absorb.
The 2024 record is almost conflict-free (ETA is effectively berthing time), so an undisturbed week has nothing to
optimise. Each task applies one to four disruptions a berth planner meets in practice:
closure a run of 5-9 sections closed for 18-48 h (crane maintenance, fender repair, dredging)
late 1-3 ships arrive 4-20 h after their planned berthing hour
overrun one ship needs 30-60 % longer alongside (a quay crane breaks down)
extra an unscheduled call (a real ship from another 2024 week at this quay) asks for a berth
divert the other container quay closes for 2-3 days and its real calls in that window come here (expert)
and stores the naive re-plan and the CP-SAT optimum as the reward anchors.
"""
from __future__ import annotations
import random
from datetime import date, datetime, timedelta, timezone
from .check import evaluate
from .data import QUAYS, Call, load_calls
from .model import Block, Ship, Task, plan_to_list
from .solve import naive_replan, optimal_plan
CLOSURE_REASONS = ("STS crane maintenance", "fender repair", "dredging along the berth", "quay crane rail repair")
DIFFICULTIES = {
"easy": [("closure",), ("late",)],
"medium": [("closure", "late"), ("closure", "overrun"), ("late", "extra")],
"hard": [("closure", "late", "overrun"), ("closure", "late", "extra"), ("closure", "late", "overrun", "extra")],
"expert": [("divert", "closure", "late"), ("divert", "late", "overrun"), ("divert", "closure", "overrun")],
}
def week_start(week: int, year: int = 2024) -> datetime:
return datetime.combine(date.fromisocalendar(year, week, 1), datetime.min.time(), tzinfo=timezone.utc)
def _hour(t0: datetime, ts: datetime) -> int:
return int(round((ts - t0).total_seconds() / 3600))
def real_week(calls: list[Call], quay: str, week: int) -> tuple[list[Ship], list[Block], int]:
"""Ships berthing in the week, the ships already alongside at hour 0, and how many 1-hour overlaps in the
published record were trimmed (data noise: the earlier ship's stay is cut to end when the next one berths)."""
q = QUAYS[quay]
t0 = week_start(week)
t1 = t0 + timedelta(days=7)
rows = sorted((c for c in calls if c.quay == quay and c.eta < t1 and c.etd > t0), key=lambda c: (c.eta, c.call))
ships: list[Ship] = []
blocks: list[Block] = []
for c in rows:
first, last = max(q.first_section, c.first), min(q.last_section, c.last)
a, e = _hour(t0, c.eta), _hour(t0, c.etd)
if e <= 0:
continue
if a < 0:
blocks.append(Block(first, last, 0, e, "alongside", c.ship))
continue
ships.append(Ship(id=len(ships), name=c.ship, imo=c.imo, length_m=c.length_m, sections=last - first + 1,
arrival=a, handling=max(1, e - a), planned_hour=a, planned_section=first, due=max(a + 1, e),
from_port=c.from_port, to_port=c.to_port, beam_m=c.beam_m, draught_m=c.draught_m))
# Make the published plan consistent: trim stays that run into a later berthing on shared sections.
trims = 0
rects = [("b", i, b.start, b.end, b.first, b.last) for i, b in enumerate(blocks)] + \
[("s", s.id, s.planned_hour, s.planned_hour + s.handling, s.planned_section,
s.planned_section + s.sections - 1) for s in ships]
for _ in range(3):
changed = False
for i in range(len(rects)):
for j in range(len(rects)):
ki, ii, a0, a1, m0, m1 = rects[i]
kj, jj, b0, b1, n0, n1 = rects[j]
if i == j or not (a0 <= b0 < a1 and m0 <= n1 and n0 <= m1):
continue
if (a0, i) >= (b0, j):
continue
if b0 > a0: # cut the earlier stay
if ki == "s":
ships[ii].handling = b0 - a0
ships[ii].due = min(ships[ii].due, b0)
else:
blocks[ii].end = b0
rects[i] = (ki, ii, a0, b0, m0, m1)
elif kj == "s": # same hour: the later one waits
s = ships[jj]
s.planned_hour = s.arrival = a1
s.due = max(s.due, a1 + s.handling)
rects[j] = (kj, jj, a1, a1 + s.handling, n0, n1)
trims += 1
changed = True
if not changed:
break
return ships, blocks, trims
def _inside(blocks, first, last, start, end) -> bool:
return any(first <= b.last and b.first <= last and start < b.end and b.start < end for b in blocks)
def disrupt(ships: list[Ship], blocks: list[Block], calls: list[Call], quay: str, week: int, kinds, rng: random.Random):
q = QUAYS[quay]
notices: list[str] = []
events: list[dict] = []
if "divert" in kinds:
other = "24B" if quay == "36A" else "36A"
days = rng.randint(2, 3)
d0 = rng.randint(0, 7 - days)
t0 = week_start(week)
lo, hi = t0 + timedelta(days=d0), t0 + timedelta(days=d0 + days)
moved = []
for c in sorted((c for c in calls if c.quay == other and lo <= c.eta < hi), key=lambda c: (c.eta, c.call)):
n = c.last - c.first + 1
a = _hour(t0, c.eta)
h = max(1, _hour(t0, c.etd) - a)
s = Ship(id=len(ships), name=c.ship, imo=c.imo, length_m=c.length_m, sections=min(n, q.last_section - q.first_section + 1),
arrival=a, handling=h, planned_hour=None, planned_section=None, due=a + h, from_port=c.from_port,
to_port=c.to_port, beam_m=c.beam_m, draught_m=c.draught_m)
ships.append(s)
moved.append(s.id)
name = QUAYS[other].terminal
notices.append(f"Quay {other} ({name}) is closed from hour {24 * d0} to hour {24 * (d0 + days)}; its {len(moved)} calls "
f"in that window are diverted here (ships {', '.join(map(str, moved))}). They have no planned berth on "
f"this quay, so placing them costs no move penalty, and they keep their planned departure hours.")
events.append({"type": "divert", "from_quay": other, "start": 24 * d0, "end": 24 * (d0 + days), "ships": moved})
if "closure" in kinds:
scheduled = [s for s in ships if s.planned_section is not None]
target = rng.choice(sorted(scheduled, key=lambda s: -s.sections * s.handling)[:5])
span = rng.randint(5, 9)
first = max(q.first_section, min(q.last_section - span + 1, target.planned_section + rng.randint(-2, 2)))
last = first + span - 1
start = max(0, target.planned_hour - rng.randint(0, 12))
dur = rng.randint(18, 48)
for b in blocks: # never close sections under a ship that is already alongside
if b.kind == "alongside" and first <= b.last and b.first <= last:
start = max(start, b.end)
reason = rng.choice(CLOSURE_REASONS)
blocks.append(Block(first, last, start, start + dur, "closed", reason))
notices.append(f"Sections {first}-{last} are closed from hour {start} to hour {start + dur} ({reason}).")
events.append({"type": "closure", "first": first, "last": last, "start": start, "end": start + dur, "reason": reason})
if "late" in kinds:
scheduled = [s for s in ships if s.planned_section is not None]
for s in rng.sample(scheduled, min(len(scheduled), rng.randint(1, 3))):
d = rng.randint(4, 20)
s.arrival += d
where = f" leaving {s.from_port}" if s.from_port else ""
notices.append(f"Ship {s.id} {s.name} was delayed{where}: it now arrives at hour {s.arrival} "
f"(planned berthing hour {s.planned_hour}). Its planned departure stays hour {s.due}.")
events.append({"type": "late", "ship": s.id, "hours": d})
if "overrun" in kinds:
scheduled = [s for s in ships if s.planned_section is not None]
s = rng.choice([s for s in scheduled if s.handling >= 8] or scheduled)
extra = max(4, int(round(s.handling * rng.uniform(0.3, 0.6))))
notices.append(f"A quay crane assigned to ship {s.id} {s.name} broke down: it now needs {s.handling + extra} "
f"hours alongside instead of {s.handling}. Its planned departure stays hour {s.due}.")
events.append({"type": "overrun", "ship": s.id, "hours": extra})
s.handling += extra
if "extra" in kinds:
others = [c for c in calls if c.quay == quay and abs(c.eta.isocalendar()[1] - week) > 2
and 4 <= (c.etd - c.eta).total_seconds() / 3600 <= 36]
c = rng.choice(others)
first, last = max(q.first_section, c.first), min(q.last_section, c.last)
handling = max(4, int(round((c.etd - c.eta).total_seconds() / 3600)))
arrival = rng.randint(12, 140)
due = arrival + handling + rng.randint(4, 12)
s = Ship(id=len(ships), name=c.ship, imo=c.imo, length_m=c.length_m, sections=last - first + 1, arrival=arrival,
handling=handling, planned_hour=None, planned_section=None, due=due, from_port=c.from_port,
to_port=c.to_port, beam_m=c.beam_m, draught_m=c.draught_m)
ships.append(s)
notices.append(f"Unscheduled call: ship {s.id} {s.name} ({s.length_m:.0f} m, {s.sections} sections) arrives at "
f"hour {arrival}, needs {handling} hours alongside and asks to sail by hour {due}. "
f"It has no planned berth, so placing it anywhere costs no move penalty.")
events.append({"type": "extra", "ship": s.id})
return notices, events
def build_task(calls: list[Call], quay: str, week: int, seed: int, difficulty: str, split: str,
time_limit: float = 60.0, workers: int = 8) -> tuple[Task | None, dict]:
"""One candidate task. Returns (task or None, stats) - None when the instance is too small or the solver did not
prove the optimum."""
rng = random.Random(f"{quay}-{week}-{seed}-{difficulty}")
ships, blocks, trims = real_week(calls, quay, week)
if len(ships) < 6:
return None, {"reason": "fewer than 6 ships"}
kinds = rng.choice(DIFFICULTIES[difficulty])
notices, events = disrupt(ships, blocks, calls, quay, week, kinds, rng)
q = QUAYS[quay]
task = Task(task_id=f"bcn-{quay}-w{week:02d}-{difficulty}-{seed}", split=split, quay=quay, terminal=q.terminal,
first_section=q.first_section, last_section=q.last_section, section_m=q.section_m, week=week,
week_start_utc=week_start(week).strftime("%Y-%m-%dT%H:%M:%SZ"), ships=ships, blocks=blocks,
notices=notices, disruptions=events, difficulty=difficulty)
published = evaluate(task, task.planned())
broken = sum(1 for r in published.ships if r.problems and r.problems != ["missing from the plan"])
naive = naive_replan(task)
naive_cost = evaluate(task, naive).cost
plan, cost, bound, status = optimal_plan(task, time_limit=time_limit, workers=workers, hint=naive)
stats = {"ships": len(ships), "kinds": list(kinds), "naive": naive_cost, "optimal": cost, "bound": bound,
"status": status, "trims": trims, "broken": broken}
if status != "OPTIMAL":
return None, {**stats, "reason": f"solver {status}"}
assert evaluate(task, plan).cost == cost
task.reference = {"naive_cost": naive_cost, "naive_plan": plan_to_list(naive), "optimal_cost": cost,
"optimal_plan": plan_to_list(plan), "proven_optimal": True, "published_conflicts": broken,
"record_trims": trims, "solver": "ortools-cpsat"}
return task, stats
__all__ = ["build_task", "real_week", "disrupt", "load_calls", "week_start", "DIFFICULTIES"]
# ---------------------------------------------------------------- frontier tier: cranes, movements, two weeks
# Quay cranes. Pools are the terminals' 2024 ship-to-shore cranes: BEST (quay 36A) 13; APM Terminals had 14 on a
# 1,515 m quay of which quay 24B is about 880 m, so 24B gets 9. The standard allocation by length (1/2/3/4 cranes
# below 180/260/330 m and above) is calibrated so the 2024 plans fit those pools: peaks of 12-13 cranes at 36A and 8-9
# at 24B across every fortnight. The most cranes a ship can take is one per 50 m of length, at most 7.
CRANE_POOL = {"36A": 13, "24B": 9}
CRANE_RATE = 28 # gross moves per crane-hour (European benchmarks 25-31)
BASE_MOVES_PER_HOUR = {"36A": 3, "24B": 2} # the 2024 record never exceeds these at either quay
def crane_class(length_m: float) -> tuple[int, int, int]:
std = 1 if length_m < 180 else 2 if length_m < 260 else 3 if length_m < 330 else 4
hi = max(std, min(7, int(length_m // 50)))
lo = 1 if length_m < 330 else 2
return lo, hi, std
def _with_cranes(ships: list[Ship], blocks: list[Block]) -> None:
for s in ships:
lo, hi, std = crane_class(s.length_m)
s.min_cranes, s.max_cranes, s.std_cranes = lo, hi, std
s.workload = s.handling * std
for b in blocks:
if b.kind == "alongside":
b.cranes = 2 # a ship part-way through its call: assume two cranes until it leaves
def _published_peaks(ships: list[Ship], blocks: list[Block]) -> tuple[int, int]:
use: dict[int, int] = {}
moves: dict[int, int] = {}
for b in blocks:
if b.kind == "alongside":
for t in range(b.start, b.end):
use[t] = use.get(t, 0) + b.cranes
moves[b.end] = moves.get(b.end, 0) + 1
for s in ships:
if s.planned_hour is None:
continue
for t in range(s.planned_hour, s.planned_hour + s.handling):
use[t] = use.get(t, 0) + s.std_cranes
moves[s.planned_hour] = moves.get(s.planned_hour, 0) + 1
moves[s.planned_hour + s.handling] = moves.get(s.planned_hour + s.handling, 0) + 1
return max(use.values(), default=0), max(moves.values(), default=0)
def real_fortnight(calls: list[Call], quay: str, week: int) -> tuple[list[Ship], list[Block], int]:
"""Two consecutive ISO weeks as one horizon (hour 0 = Monday of `week`)."""
a, blocks_a, trims_a = real_week(calls, quay, week)
b, blocks_b, trims_b = real_week(calls, quay, week + 1)
seen = {(s.name, s.planned_hour) for s in a}
for s in b: # week 2 in week-1 hours; ships that straddle the boundary appear in both and are kept once
s2 = Ship(**{**s.__dict__})
s2.arrival += 168
s2.planned_hour += 168
s2.due += 168
if (s2.name, s2.planned_hour) in seen:
continue
s2.id = len(a)
a.append(s2)
return a, blocks_a, trims_a + trims_b
def build_frontier_task(calls: list[Call], quay: str, week: int, seed: int, split: str, time_limit: float = 180.0,
workers: int = 8, max_gap: float = 0.05) -> tuple[Task | None, dict]:
"""A fortnight at the quay's real crane count with a movement limit, hit by a gale (the port's wind rules stop
ships of 300 m and more above 25 kn and every ship above 30 kn), a closure, late ships, a crane outage and extra
traffic (diverted from 24B at 36A, an unscheduled call at 24B). Kept when CP-SAT's best plan is within `max_gap`
of its bound."""
rng = random.Random(f"frontier-{quay}-{week}-{seed}")
ships, blocks, trims = real_fortnight(calls, quay, week)
_with_cranes(ships, blocks)
peak_cranes, peak_moves = _published_peaks(ships, blocks)
q = QUAYS[quay]
kinds = ["closure", "late"] + (["divert"] if quay == "36A" else ["extra"])
notices, events = disrupt(ships, blocks, calls, quay, week, kinds, rng)
for s in ships: # diverted / extra calls get crane data too
if s.workload is None:
lo, hi, std = crane_class(s.length_m)
s.min_cranes, s.max_cranes, s.std_cranes, s.workload = lo, hi, std, s.handling * std
pool = max(CRANE_POOL[quay], peak_cranes)
# gale: a 25 kn window for ships of 300 m and more, with a 30 kn core for everyone
g0 = rng.randint(30, 150)
g1 = g0 + rng.randint(16, 30)
c0 = g0 + rng.randint(2, 6)
c1 = min(g1 - 1, c0 + rng.randint(6, 12))
no_moves = [{"start": g0, "end": g1, "min_length": 300, "reason": "wind above 25 kn"},
{"start": c0, "end": c1, "min_length": 0, "reason": "wind above 30 kn"}]
for b in blocks: # a ship already alongside that is due out in the gale waits too
if b.kind == "alongside" and c0 <= b.end < c1:
b.end = c1
out_start, out_len, out_n = rng.randint(24, 260), rng.randint(24, 48), rng.randint(2, 3)
rules = {"crane_pool": pool, "crane_rate": CRANE_RATE,
"max_moves_per_hour": max(BASE_MOVES_PER_HOUR[quay], peak_moves),
"crane_outages": [{"start": out_start, "end": out_start + out_len, "cranes": out_n}],
"no_moves": no_moves, "reward": 2}
notices.insert(0, f"Gale warning. From hour {g0} to hour {g1} the wind is above 25 kn: under the port's traffic rules "
f"ships of 300 m or more may not berth or leave. From hour {c0} to hour {c1} it is above 30 kn: no "
f"ship may berth or leave. Cranes keep working; a ship that finishes inside a window waits "
f"alongside until the window ends.")
events.insert(0, {"type": "gale", "start": g0, "end": g1, "core_start": c0, "core_end": c1})
notices.append(f"{out_n} of the terminal's {pool} quay cranes are out of service from hour {out_start} to hour "
f"{out_start + out_len} (gantry repair): {pool - out_n} cranes in that window.")
events.append({"type": "crane_outage", "start": out_start, "end": out_start + out_len, "cranes": out_n})
task = Task(task_id=f"bcn-{quay}-w{week:02d}-frontier-{seed}", split=split, quay=quay, terminal=q.terminal,
first_section=q.first_section, last_section=q.last_section, section_m=q.section_m, week=week,
week_start_utc=week_start(week).strftime("%Y-%m-%dT%H:%M:%SZ"), ships=ships, blocks=blocks,
notices=notices, disruptions=events, difficulty="frontier", rules=rules)
naive = naive_replan(task)
naive_cost = evaluate(task, naive).cost
plan, cost, bound, status = optimal_plan(task, time_limit=time_limit, workers=workers, hint=naive)
published = evaluate(task, task.planned())
broken = sum(1 for r in published.ships if r.problems and r.problems != ["missing from the plan"])
stats = {"ships": len(ships), "pool": pool, "moves_cap": rules["max_moves_per_hour"], "naive": naive_cost,
"optimal": cost, "bound": bound, "status": status, "trims": trims, "broken": broken}
if plan is None or (cost - bound) > max_gap * max(cost, 1):
return None, {**stats, "reason": f"solver {status}, gap {cost}-{bound}" if plan else f"solver {status}"}
assert evaluate(task, plan).cost == cost
task.reference = {"naive_cost": naive_cost, "naive_plan": plan_to_list(naive), "optimal_cost": cost,
"optimal_plan": plan_to_list(plan), "proven_optimal": status == "OPTIMAL", "lower_bound": bound,
"published_conflicts": broken, "solver": "ortools-cpsat", "record_trims": trims}
return task, stats
|