leg-aware capacity and wagon sharing

This commit is contained in:
Marshal
2026-07-30 17:21:44 +00:00
parent 8f25fb3e8b
commit acef6870e9
15 changed files with 653 additions and 119 deletions

View File

@@ -53,18 +53,25 @@ export type FlexPlanResult = {
type OpenSlot = {
slot: WagonPlanSlot;
teuUsed: number;
/**
* TEU occupied PER CORRIDOR EDGE. Containers on different legs share the
* same physical wagon as long as no single edge exceeds the wagon's TEU
* geometry — an intercity 20ft alighting at Adama frees its slot for a 20ft
* boarding there, and two overlapping-leg 20fts coexist while both ride.
*/
teuPerEdge: number[];
kind: SlotLoadType;
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
cargoTypeId: string | null;
freeCapacityTons: number;
/**
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
* share a slot when their legs are identical — mixing corridors in one slot
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
* re-occupy edges the cargo never rides.
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
* prefer a same-leg slot but may extend onto a different-leg one (span
* grows to the union); bulk still shares only on an identical leg.
*/
legKey: string;
/** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */
covered: { from: number; to: number };
};
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
@@ -227,16 +234,54 @@ export function planWagonsWithStock(params: {
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
teuUsed: 0,
teuPerEdge: new Array<number>(edgeCount).fill(0),
kind,
cargoTypeId,
freeCapacityTons: Number(chosen.capacityTons),
legKey: legKeyOf(leg),
covered: { ...leg },
};
openSlots.push(open);
return open;
};
/** TEU room on every edge of the unit's leg. */
const teuFits = (open: OpenSlot, leg: BookingLeg, teu: number): boolean => {
for (let e = leg.from; e < leg.to; e += 1) {
if ((open.teuPerEdge[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) return false;
}
return true;
};
/**
* Whether the slot's ridden span can grow to include this leg: every NEW
* edge (outside the current span) must still have a physical wagon of the
* slot's type spare — extending the span puts this wagon on those edges.
*/
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0;
const row = usedPerEdge.get(open.slot.wagonTypeId);
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
if (e >= open.covered.from && e < open.covered.to) continue;
if (total - (row?.[e] ?? 0) <= 0) return false;
}
return true;
};
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
const row = usedRow(open.slot.wagonTypeId);
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
if (e >= open.covered.from && e < open.covered.to) continue;
row[e] = (row[e] ?? 0) + 1;
}
open.covered = { from, to };
};
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
const leg = legFor(booking);
const legKey = legKeyOf(leg);
@@ -260,17 +305,23 @@ export function planWagonsWithStock(params: {
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
let target = openSlots.find(
(open) =>
open.kind === 'CONTAINER' &&
open.legKey === legKey &&
allowedIds.has(open.slot.wagonTypeId) &&
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
);
const fitsSlot = (open: OpenSlot): boolean =>
open.kind === 'CONTAINER' &&
allowedIds.has(open.slot.wagonTypeId) &&
teuFits(open, leg, teu) &&
canExtendSpan(open, leg);
// Same-leg slots first (keeps legacy packing byte-identical), then any
// open wagon with per-edge TEU room — an intercity 20ft rides an
// export wagon's spare slot instead of appending a new wagon.
let target =
openSlots.find((open) => open.legKey === legKey && fitsSlot(open)) ??
openSlots.find(fitsSlot);
if (!target) {
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
if ('message' in openedSlot) return openedSlot;
target = openedSlot;
} else {
extendSpan(target, leg);
}
addAllocation(
target.slot,
@@ -279,7 +330,9 @@ export function planWagonsWithStock(params: {
unit.grossWeightTons,
AllocationLoadType.Container,
);
target.teuUsed += teu;
for (let e = leg.from; e < leg.to; e += 1) {
target.teuPerEdge[e] = (target.teuPerEdge[e] ?? 0) + teu;
}
}
return null;
}
@@ -343,7 +396,8 @@ export function planWagonsWithStock(params: {
);
const slotCountSnapshot = openSlots.length;
const slotStateSnapshot = openSlots.map((open) => ({
teuUsed: open.teuUsed,
teuPerEdge: [...open.teuPerEdge],
covered: { ...open.covered },
freeCapacityTons: open.freeCapacityTons,
assignedWeightTons: open.slot.assignedWeightTons,
allocationCount: open.slot.allocations.length,
@@ -363,7 +417,8 @@ export function planWagonsWithStock(params: {
openSlots.forEach((open, index) => {
const snap = slotStateSnapshot[index];
if (!snap) return;
open.teuUsed = snap.teuUsed;
open.teuPerEdge = [...snap.teuPerEdge];
open.covered = { ...snap.covered };
open.freeCapacityTons = snap.freeCapacityTons;
open.slot.assignedWeightTons = snap.assignedWeightTons;
open.slot.allocations.length = snap.allocationCount;