File size: 14,436 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
// Plan semantics shared by the chart, tables and 3D scene. Mirrors berth_core/check.py (`evaluate`,
// `ship_delay_cost`) and berth_core/model.py (`departure`, `no_move_windows`, `crane_pool_at`).
// A plan entry {ship, berth_hour, section[, cranes]} occupies sections section..section+sections-1 from
// berth_hour until it leaves. On crane-rule tasks: work = ceil(workload / cranes), finish = berth_hour + work,
// and a ship that finishes inside a wind window that applies to it waits alongside until the window ends.
// Cost = sum(sections x hours late x weight + deadline penalty) + 5 x ships moved off their planned sections.

export const MOVE_PENALTY = 5;
export const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

/** Crane-rule ("dock") task: plans carry a crane count per ship. */
export const hasCranes = (task) => !!(task && task.rules && task.rules.crane_pool);

export function cranePoolAt(task, hour) {
  let pool = Number(task.rules.crane_pool);
  for (const o of task.rules.crane_outages || []) if (o.start <= hour && hour < o.end) pool -= Number(o.cranes);
  return pool;
}

/** Merged [start, end) windows in which this ship may not dock or leave (wind limits by length). */
export function noMoveWindows(task, ship) {
  const ws = ((task.rules && task.rules.no_moves) || [])
    .filter((w) => ship.length_m >= (w.min_length || 0))
    .map((w) => [w.start, w.end])
    .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  const out = [];
  for (const [a, b] of ws) {
    if (out.length && a <= out[out.length - 1][1]) out[out.length - 1][1] = Math.max(out[out.length - 1][1], b);
    else out.push([a, b]);
  }
  return out;
}

/** A ship that finishes inside a no-movement window waits alongside until the window ends. */
export function departureAfter(task, ship, finish) {
  for (const [a, b] of noMoveWindows(task, ship)) if (a <= finish && finish < b) return b;
  return finish;
}

export function handlingFor(ship, cranes) {
  if (ship.workload == null || cranes == null) return ship.handling;
  return Math.max(1, Math.ceil(ship.workload / cranes));
}

export const movesOf = (task, ship) => (ship.workload != null ? ship.workload * Number((task.rules && task.rules.crane_rate) || 28) : null);

export function shipDelayCost(ship, berthHour, delayH) {
  let cost = ship.sections * delayH * (ship.weight || 1);
  if (ship.berth_deadline != null && berthHour > ship.berth_deadline) cost += (ship.deadline_penalty || 0) * (berthHour - ship.berth_deadline);
  return cost;
}

export function publishedPlan(task) {
  const cr = hasCranes(task);
  return task.ships
    .filter((s) => s.planned_hour != null && s.planned_section != null)
    .map((s) => ({ ship: s.id, berth_hour: s.planned_hour, section: s.planned_section, ...(cr ? { cranes: s.std_cranes } : {}) }));
}

/** The published slot of a ship: planned docking hour until its planned departure (= due). */
export function publishedSlot(ship) {
  if (ship.planned_hour == null || ship.planned_section == null) return null;
  return { start: ship.planned_hour, end: Math.max(ship.due, ship.planned_hour + 1), first: ship.planned_section, last: ship.planned_section + ship.sections - 1 };
}

const overlap = (a0, a1, b0, b1) => Math.max(a0, b0) < Math.min(a1, b1);
const span = (a0, a1, b0, b1) => [Math.max(a0, b0), Math.min(a1, b1)];
const spanTxt = (a, b) => (a === b ? `${a}` : `${a}-${b}`);

function hoursTxt(hs) {
  const out = [];
  let run = [];
  for (const h of [...new Set(hs)].sort((a, b) => a - b)) {
    if (run.length && h === run[run.length - 1] + 1) run.push(h);
    else {
      if (run.length) out.push(spanTxt(run[0], run[run.length - 1]));
      run = [h];
    }
  }
  if (run.length) out.push(spanTxt(run[0], run[run.length - 1]));
  return out.slice(0, 4).join(", ") + (out.length > 4 ? " …" : "");
}

/** Evaluate a plan locally: per-ship timing, delay, moves, cost and rule problems (as help; the env grades). */
export function evaluatePlan(task, plan) {
  const cr = hasCranes(task);
  const entries = new Map();
  const duplicates = [];
  for (const p of Array.isArray(plan) ? plan : []) {
    if (!p || typeof p !== "object") continue;
    const id = Number(p.ship);
    if (entries.has(id)) duplicates.push(id);
    else entries.set(id, { berth_hour: Number(p.berth_hour), section: Number(p.section), cranes: p.cranes == null ? null : Number(p.cranes) });
  }
  const issues = [];
  const rows = task.ships.map((s) => {
    const e = entries.get(s.id);
    if (!e || !Number.isFinite(e.berth_hour) || !Number.isFinite(e.section)) {
      return { ship: s, id: s.id, placed: false, conflicts: [], cost: null, delay: null, moved: false };
    }
    const h = e.berth_hour;
    const c = cr ? (e.cranes != null && Number.isFinite(e.cranes) ? e.cranes : s.std_cranes) : null;
    const badCranes = cr && !(s.min_cranes <= c && c <= s.max_cranes);
    const work = handlingFor(s, cr ? (badCranes ? s.std_cranes : c) : null);
    const finish = h + work;
    const dep = cr ? departureAfter(task, s, finish) : finish;
    const delay = Math.max(0, dep - s.due);
    const moved = s.planned_section != null && e.section !== s.planned_section;
    return {
      ship: s,
      id: s.id,
      placed: true,
      berth: h,
      section: e.section,
      last: e.section + s.sections - 1,
      cranes: c,
      work,
      finish,
      dep,
      hold: dep - finish,
      delay,
      moved,
      wait: Math.max(0, h - s.arrival),
      cost: shipDelayCost(s, h, delay) + (moved ? MOVE_PENALTY : 0),
      late: s.berth_deadline != null && h > s.berth_deadline,
      conflicts: [],
    };
  });
  const add = (r, problem) => {
    issues.push({ ships: [r.id], problem });
    r.conflicts.push(problem);
  };
  const placed = rows.filter((r) => r.placed);
  const lo = task.first_section;
  const hi = task.last_section;
  for (const r of placed) {
    const s = r.ship;
    if (cr) for (const [a, b] of noMoveWindows(task, s)) if (a <= r.berth && r.berth < b) add(r, `docks at hour ${r.berth}, inside the no-movement window ${a}-${b}`);
    if (cr && !(s.min_cranes <= r.cranes && r.cranes <= s.max_cranes)) add(r, `gets ${r.cranes} cranes but can be worked by ${s.min_cranes}-${s.max_cranes}`);
    if (r.berth < s.arrival) add(r, `docks at hour ${r.berth} but cannot arrive before hour ${s.arrival}`);
    if (r.section < lo || r.last > hi) add(r, `needs sections ${spanTxt(r.section, r.last)} but the quay has sections ${lo}-${hi}`);
    for (const b of task.blocks || []) {
      if (r.section <= b.last && b.first <= r.last && r.berth < b.end && b.start < r.dep) {
        const what = b.kind === "alongside" ? `${b.label} (alongside)` : "closed sections";
        add(r, `overlaps ${what} ${spanTxt(b.first, b.last)} during hours ${b.start}-${b.end}`);
      }
    }
  }
  for (let i = 0; i < placed.length; i++) {
    for (let j = i + 1; j < placed.length; j++) {
      const a = placed[i];
      const b = placed[j];
      if (overlap(a.berth, a.dep, b.berth, b.dep) && a.section <= b.last && b.section <= a.last) {
        const [s0, s1] = span(a.section, a.last + 1, b.section, b.last + 1);
        const [h0, h1] = span(a.berth, a.dep, b.berth, b.dep);
        const secs = spanTxt(s0, s1 - 1);
        issues.push({ ships: [a.id, b.id], problem: `${a.ship.name} and ${b.ship.name} overlap in sections ${secs} during hours ${h0}-${h1}` });
        a.conflicts.push(`overlaps ship ${b.id} (${b.ship.name}) in sections ${secs} during hours ${h0}-${h1}`);
        b.conflicts.push(`overlaps ship ${a.id} (${a.ship.name}) in sections ${secs} during hours ${h0}-${h1}`);
      }
    }
  }
  let series = null;
  if (cr) series = craneAndMoveLimits(task, placed, add);
  const missing = rows.filter((r) => !r.placed).map((r) => r.id);
  const delayCost = placed.reduce((acc, r) => acc + shipDelayCost(r.ship, r.berth, r.delay), 0);
  const moves = placed.filter((r) => r.moved).length;
  return {
    rows,
    byId: new Map(rows.map((r) => [r.id, r])),
    issues,
    missing,
    duplicates,
    delayCost,
    moves,
    cost: delayCost + MOVE_PENALTY * moves,
    feasible: issues.length === 0 && missing.length === 0,
    series,
  };
}

/** Crane pool and movement limit hour by hour (check.py `_crane_and_move_limits`); returns the hourly series. */
function craneAndMoveLimits(task, placed, add) {
  const use = new Map();
  const moves = new Map();
  const fixed = new Map();
  const bump = (m, k, v) => m.set(k, (m.get(k) || 0) + v);
  for (const b of task.blocks || []) {
    if (b.kind !== "alongside") continue;
    for (let t = b.start; t < b.end; t++) bump(use, t, b.cranes || 0);
    bump(fixed, b.end, 1);
  }
  for (const r of placed) {
    for (let t = r.berth; t < r.finish; t++) bump(use, t, r.cranes || 0);
    if (!moves.has(r.berth)) moves.set(r.berth, []);
    moves.get(r.berth).push(r);
    if (!moves.has(r.dep)) moves.set(r.dep, []);
    moves.get(r.dep).push(r);
  }
  const over = new Set([...use].filter(([t, u]) => u > cranePoolAt(task, t)).map(([t]) => t));
  if (over.size) {
    for (const r of placed) {
      const hs = [];
      for (let t = r.berth; t < r.finish; t++) if (over.has(t)) hs.push(t);
      if (!hs.length) continue;
      const worst = hs.reduce((a, t) => (use.get(t) - cranePoolAt(task, t) > use.get(a) - cranePoolAt(task, a) ? t : a), hs[0]);
      add(r, `cranes over the pool at hours ${hoursTxt(hs)} (e.g. hour ${worst}: ${use.get(worst)} in use, ${cranePoolAt(task, worst)} available)`);
    }
  }
  const cap = task.rules.max_moves_per_hour;
  const moveCount = new Map();
  for (const t of new Set([...moves.keys(), ...fixed.keys()])) moveCount.set(t, (moves.get(t) || []).length + (fixed.get(t) || 0));
  if (cap) {
    for (const [t, rs] of [...moves].sort((a, b) => a[0] - b[0])) {
      const n = moveCount.get(t);
      if (n > cap) for (const r of rs) add(r, `${r.berth === t ? "docks" : "leaves"} at hour ${t}, when ${n} ships move (limit ${cap} per hour)`);
    }
  }
  return { use, moves: moveCount, cap: cap || null, over };
}

/** Last hour worth showing: every departure, block end and arrival, plus time to sail out. */
export function horizonOf(task, plans) {
  let h = 0;
  for (const s of task.ships) h = Math.max(h, s.arrival, s.due);
  for (const b of task.blocks || []) h = Math.max(h, b.end);
  for (const w of (task.rules && task.rules.no_moves) || []) h = Math.max(h, w.end);
  for (const o of (task.rules && task.rules.crane_outages) || []) h = Math.max(h, o.end);
  const cr = hasCranes(task);
  for (const plan of plans) {
    if (!Array.isArray(plan)) continue;
    for (const p of plan) {
      if (!p || typeof p !== "object") continue;
      const s = task.ships.find((x) => x.id === Number(p.ship));
      if (!s) continue;
      const finish = Number(p.berth_hour) + handlingFor(s, cr ? (p.cranes != null ? Number(p.cranes) : s.std_cranes) : null);
      h = Math.max(h, cr ? departureAfter(task, s, finish) : finish);
    }
  }
  return Math.ceil(h + 4);
}

/** Ships without a published slot: {kind: "divert", from} for calls diverted from the other quay, else {kind: "extra"}. */
export function callKinds(task) {
  const out = new Map();
  for (const d of task.disruptions || []) {
    if (d.type === "divert") for (const id of d.ships || []) out.set(Number(id), { kind: "divert", from: d.from_quay, start: d.start, end: d.end });
  }
  for (const s of task.ships) {
    if (!out.has(s.id) && (s.planned_section == null || s.planned_hour == null)) out.set(s.id, { kind: "extra" });
  }
  return out;
}

export const divertWindows = (task) => (task.disruptions || []).filter((d) => d.type === "divert");

export const DIFF_RANK = { easy: 0, medium: 1, hard: 2, expert: 3, calm: 4, busy: 5, gale: 6, storm: 7 };

export function ago(unixSeconds) {
  if (unixSeconds == null) return "–";
  const s = Math.max(0, Date.now() / 1000 - Number(unixSeconds));
  if (s < 60) return `${Math.round(s)} s ago`;
  if (s < 3600) return `${Math.round(s / 60)} min ago`;
  if (s < 86400) return `${Math.round(s / 3600)} h ago`;
  return `${Math.round(s / 86400)} d ago`;
}

export function clockAt(task, hour) {
  const t0 = Date.parse(task.week_start_utc || "2024-01-01T00:00:00Z");
  const d = new Date(t0 + hour * 3600e3);
  const hh = String(d.getUTCHours()).padStart(2, "0");
  const mm = String(d.getUTCMinutes()).padStart(2, "0");
  return { day: DAYS[d.getUTCDay()], date: `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`, hm: `${hh}:${mm}`, dayIndex: Math.floor(hour / 24) };
}

export const fmtHour = (task, h) => {
  const c = clockAt(task, h);
  return `${c.day} ${c.hm}`;
};

export function hashStr(str) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

export function mulberry32(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export const fmtNum = (v, d = 0) => (v == null || Number.isNaN(v) ? "–" : Number(v).toFixed(d));
export const fmtPct = (v) => (v == null || Number.isNaN(v) ? "–" : `${Math.round(v * 100)}%`);
export const sectionsLabel = (a, b) => (a === b ? `${a}` : `${a}–${b}`);

/** Parse a plan out of a tool-call argument string (or object); null when it is not a plan. */
export function parsePlanArgs(args) {
  let obj = args;
  if (typeof args === "string") {
    try {
      obj = JSON.parse(args);
    } catch {
      return null;
    }
  }
  if (Array.isArray(obj)) return obj;
  if (obj && Array.isArray(obj.plan)) return obj.plan;
  return null;
}

export function escapeHtml(s) {
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
}

/** Small inline badges for priority (×3) and emergency (dock by h N) ships. */
export function badgesHtml(s) {
  let out = "";
  if ((s.weight || 1) > 1) out += ` <span class="bdg" title="Priority: each hour late counts ×${s.weight}">×${s.weight}</span>`;
  if (s.berth_deadline != null) out += ` <span class="bdg red" title="Emergency: must dock by hour ${s.berth_deadline}; ${s.deadline_penalty} per hour later">dock by h ${s.berth_deadline}</span>`;
  return out;
}