/** * Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable: * * count — how many wagons fit end to end on the longest allowed train * length — Σ wagonType.lengthMeters over the real consist * weight — Σ (wagonType.tareWeightTons + cargoTons) over the real consist * * The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it. * The old code compared the locomotive's pull limit against cargo payload alone * and so overbooked every train by roughly the tare fraction (~27% on PW2). * * The weight axis is also driven by ACTUAL booked cargo, never by an assumed * full payload. That is what makes the real EDR numbers fall out: * * NW5 13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon * marshalling figure is length-bound, and those trains never carry 53×70T. * PW2 17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T, * which clears 3500T only via the locomotive's overage tolerance. Weight * binds first, hence "37 wagons per train". * * So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo * exists. Weight is enforced against the consist as bookings are allocated. */ /** Physical dimensions used when deriving how many wagons a locomotive can pull. */ export type WagonTypeDimensions = { lengthMeters: number; capacityTons: number; tareWeightTons: number; }; /** One occupied wagon slot in a real consist. */ export type ConsistSlot = { lengthMeters: number; tareWeightTons: number; /** Actual cargo/container weight riding on this wagon, not its rated capacity. */ cargoTons: number; }; export type LocomotiveLimits = { maxPullWeightTons: number; maxTrainLengthMeters: number; /** Allowed deviation above maxPullWeightTons before scheduling blocks the train. */ overageToleranceTons?: number | null; /** Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. */ overageToleranceMeters?: number | null; }; export type DerivedTrainCapacity = { /** Gross (tare + cargo) tons the train may weigh, tolerance included. */ maxWeightTons: number; maxLengthMeters: number; /** Length-derived slot count. Weight is enforced separately against real cargo. */ maxWagonSlots: number; /** Caps WITHOUT the overage tolerance — what batch filling budgets against. */ baseWeightTons: number; baseLengthMeters: number; /** Overage spendable only by admitting a booking whole, never by a split. */ toleranceTons: number; toleranceMeters: number; }; /** What a consist currently uses, and what is left on each axis. */ export type ConsistUsage = { wagonCount: number; usedLengthMeters: number; /** Σ (tare + cargo). */ usedGrossWeightTons: number; usedTareWeightTons: number; usedCargoWeightTons: number; remainingLengthMeters: number; remainingGrossWeightTons: number; remainingWagons: number; }; export const MAX_FALLBACK_WEIGHT = 3500; export const MAX_FALLBACK_LENGTH = 760; const DEFAULT_WAGON_LENGTH_M = 14; const DEFAULT_WAGON_CAPACITY_T = 70; /** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */ const DEFAULT_WAGON_TARE_T = 22.4; function num(value: unknown, fallback = 0): number { const n = Number(value); return Number.isFinite(n) ? n : fallback; } /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ export function grossWagonWeightTons(slot: Pick): number { return num(slot.tareWeightTons) + num(slot.cargoTons); } /** * Hard caps for a train: the locomotive's own limits, floored by the global rule * caps, then widened by the locomotive's overage tolerance. * * `base*` are the caps BEFORE the tolerance is added. The tolerance is not * general-purpose headroom: batch filling budgets against the base caps and may * spend the tolerance only to admit a booking WHOLE (never to size a split), so * both figures are returned. `base + tolerance === max` always holds, including * the fallback path. */ export function trainHardCaps( locomotive: LocomotiveLimits, ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, ): { maxWeightTons: number; maxLengthMeters: number; baseWeightTons: number; baseLengthMeters: number; toleranceTons: number; toleranceMeters: number; } { const overageTons = num(locomotive.overageToleranceTons); const overageMeters = num(locomotive.overageToleranceMeters); const baseWeight = Math.min( num(locomotive.maxPullWeightTons, Infinity) || Infinity, ruleCaps?.maxTrainWeightTons ?? Infinity, ); const baseLength = Math.min( num(locomotive.maxTrainLengthMeters, Infinity) || Infinity, ruleCaps?.maxTrainLengthMeters ?? Infinity, ); const baseWeightTons = Number.isFinite(baseWeight) ? baseWeight : MAX_FALLBACK_WEIGHT; const baseLengthMeters = Number.isFinite(baseLength) ? baseLength : MAX_FALLBACK_LENGTH; const toleranceTons = Number.isFinite(baseWeight) ? overageTons : 0; const toleranceMeters = Number.isFinite(baseLength) ? overageMeters : 0; return { maxWeightTons: baseWeightTons + toleranceTons, maxLengthMeters: baseLengthMeters + toleranceMeters, baseWeightTons, baseLengthMeters, toleranceTons, toleranceMeters, }; } /** * Derive the planning capacity of a train from its locomotive. * * `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within * the train-length cap — the optimistic slot count, since a mixed consist of * longer wagons will hit the length cap sooner. It is deliberately NOT reduced by * weight: with no bookings yet there is no cargo, and assuming every wagon rides * at full rated payload would report 37 NW5 slots where the railway marshals 53. * Weight is enforced by {@link consistUsage} / {@link consistViolations} against * the cargo actually allocated. */ export function deriveTrainCapacityFromLocomotive( locomotive: LocomotiveLimits, wagonTypes: WagonTypeDimensions[], ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, ): DerivedTrainCapacity { const caps = trainHardCaps(locomotive, ruleCaps); const lengths = wagonTypes .map((w) => num(w.lengthMeters)) .filter((l) => l > 0); const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M; const maxWagonSlots = minLength > 0 ? Math.max(0, Math.floor(caps.maxLengthMeters / minLength)) : 0; return { ...caps, maxWagonSlots }; } /** * What a real, mixed-type consist uses on all three axes, and what is left. * Every wagon contributes its own length and its own tare — no averaging over a * representative wagon type. */ export function consistUsage( slots: ConsistSlot[], caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, ): ConsistUsage { let usedLengthMeters = 0; let usedTareWeightTons = 0; let usedCargoWeightTons = 0; for (const slot of slots) { usedLengthMeters += num(slot.lengthMeters); usedTareWeightTons += num(slot.tareWeightTons); usedCargoWeightTons += num(slot.cargoTons); } const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons; return { wagonCount: slots.length, usedLengthMeters: round3(usedLengthMeters), usedGrossWeightTons: round3(usedGrossWeightTons), usedTareWeightTons: round3(usedTareWeightTons), usedCargoWeightTons: round3(usedCargoWeightTons), remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters), remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons), remainingWagons: caps.maxWagonSlots - slots.length, }; } /** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */ export function consistViolations( slots: ConsistSlot[], caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, ): string[] { const usage = consistUsage(slots, caps); const violations: string[] = []; if (usage.usedGrossWeightTons > caps.maxWeightTons) { violations.push( `Total train gross weight ${usage.usedGrossWeightTons}T ` + `(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` + `exceeds max pull weight ${round3(caps.maxWeightTons)}T`, ); } if (usage.usedLengthMeters > caps.maxLengthMeters) { violations.push( `Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`, ); } if (usage.wagonCount > caps.maxWagonSlots) { violations.push( `Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`, ); } return violations; } function round3(value: number): number { return Number.isFinite(value) ? Number(value.toFixed(3)) : value; } /** * Effective pull limits for a train set with multiple locomotives: the weakest * locomotive caps the train, so take the minimum pull weight and minimum length * across all assigned locomotives. Returns null when no locomotives are given. */ export function minLocomotiveLimits( locomotives: Array< Pick & Partial> >, ): LocomotiveLimits | null { if (!locomotives.length) return null; return { maxPullWeightTons: Math.min( ...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity), ), maxTrainLengthMeters: Math.min( ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), // Weakest CONFIGURED tolerance governs the set — a locomotive with no // tolerance set has no opinion, it does not zero out the others. overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)), overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)), }; } function minConfigured(values: Array): number { const configured = values.filter((v) => v != null).map((v) => num(v)); return configured.length ? Math.min(...configured) : 0; } /** Per-booking train length from wagon count and freight-specific wagon type length. */ export function bookingTrainLengthMeters( freightType: string | null | undefined, wagonCount: number, lengths: { container: number; bulk: number }, ): number { const perWagon = freightType === 'BULK' ? lengths.bulk : lengths.container; return wagonCount * perWagon; } /** * Gross weight a booking adds to its train: its cargo plus the tare of every * wagon it occupies. A booking is never weightless just because it is light — * the empty wagons still have to be pulled. */ export function bookingGrossWeightTons( cargoTons: number, wagonCount: number, tarePerWagonTons: number, ): number { return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons)); } /** * Size a partial (split-on-payment) offer against the room left on a train, * across ALL THREE capacity axes — not just wagon slots. Each wagon adds * `capacityTons` of payload headroom but its own tare spends the same weight * room the cargo needs, so on a weight-limited train more wagons is not always * more cargo. Scans wagon counts (the last wagon may run part-loaded) and * returns the count that maximizes the cargo carried, with the cargo cap the * caller should apply. Null when not even one part-loaded wagon fits. The * offer is a strict subset of the booking: never all `bookingWagons`. * * `fullWagonsOnly` (bulk): every offered wagon rides at its full rated payload, * so each wagon costs `capacityTons + tareWeightTons` of gross weight room and * the offer is the largest whole-wagon count whose gross fits — never a * part-loaded last wagon squeezed into leftover pull weight. */ export function sizePartialOfferWagons( room: { wagons: number; weightTons: number; lengthMeters: number }, bookingWagons: number, perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number }, opts?: { fullWagonsOnly?: boolean }, ): { wagons: number; maxCargoTons: number } | null { const maxByLength = perWagon.lengthMeters > 0 ? Math.floor(room.lengthMeters / perWagon.lengthMeters) : room.wagons; const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1); if (opts?.fullWagonsOnly) { const grossPerWagon = perWagon.capacityTons + perWagon.tareWeightTons; const maxByWeight = grossPerWagon > 0 ? Math.floor(room.weightTons / grossPerWagon) : 0; const wagons = Math.min(ceiling, maxByWeight); if (wagons < 1) return null; return { wagons, maxCargoTons: round3(wagons * perWagon.capacityTons) }; } let wagons = 0; let bestCargoTons = 0; for (let w = 1; w <= ceiling; w += 1) { const cargoAt = Math.min( w * perWagon.capacityTons, room.weightTons - w * perWagon.tareWeightTons, ); if (cargoAt > bestCargoTons) { bestCargoTons = cargoAt; wagons = w; } } if (wagons < 1) return null; return { wagons, maxCargoTons: round3(room.weightTons - wagons * perWagon.tareWeightTons) }; } export function wagonTypeDimensionsFromEntity(wt: { lengthMeters?: number | string | null; capacityTons?: number | string | null; tareWeightTons?: number | string | null; }): WagonTypeDimensions { return { lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T, }; }