/** * 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; } /** * Cargo tons of a booking: the stored VGM total when present, else the sum of * its container lines (quantity × VGM per unit). The portal's container flow * stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the * total alone made every such booking weigh only its tare. * * Break-bulk (PER_ITEM) bookings overload `cargoTotalWeightVgm` with the ITEM * COUNT, so their real tonnage lives in `bulkTotalWeightTons` — prefer it, or * a 400-item / 800T booking would "weigh" 400T against the pull limit. */ export function bookingCargoTons(booking: { cargoTotalWeightVgm?: number | string | null; bulkTotalWeightTons?: number | string | null; bookingContainers?: Array<{ quantity?: number | null; vgmPerUnitTons?: number | string | null; }> | null; }): number { const itemTons = num(booking.bulkTotalWeightTons); if (itemTons > 0) return itemTons; const total = num(booking.cargoTotalWeightVgm); if (total > 0) return total; return (booking.bookingContainers ?? []).reduce( (sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons), 0, ); } /** * Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so * floor how many whole items fit one wagon, then ceil the wagon count: * 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons. * Returns 0 when the booking is not item-counted (PER_TON bulk, containers) — * callers then fall back to the pooled-tonnage math. * * `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from * cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon * that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T * (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls * back to tonnage-only (legacy cargo types without a configured fit). */ export function bulkItemWagonsRequired( booking: { freightType?: string | null; cargoTotalWeightVgm?: number | string | null; bulkTotalWeightTons?: number | string | null; }, capacityTons: number, itemsFit?: number | null, ): number { if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0; const quantity = num(booking.cargoTotalWeightVgm); const totalWeightTons = num(booking.bulkTotalWeightTons); if (!(quantity > 0) || !(totalWeightTons > 0)) return 0; const perItemTons = totalWeightTons / quantity; // ponytail: an item heavier than a whole wagon still charges 1 wagon per // item; reject such bookings at creation time if the case turns real. const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons)); const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity; const itemsPerWagon = Math.min(byTonnage, byFloor); return Math.max(1, Math.ceil(quantity / itemsPerWagon)); } type ItemFitCargoType = { wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; itemsPerWagonMap?: Record | null; tonsPerWagonMap?: Record | null; } | null; /** * Tons of THIS cargo one wagon of this type may carry: the cargo type's * configured loading limit when set, else the wagon's full rated capacity. * Sugar capped at 50T rides 50T on a 70T wagon, so 200T needs 4 wagons and each * is loaded to 50 — both the count and the fill follow from this one number. * * The configured cap is CLAMPED to the rated capacity rather than trusted: the * cargo-types service rejects a cap above capacity at save time, but a wagon * type edited DOWN afterwards would leave a stale cap that overloads the wagon. * Clamping here means no call site can ever load past the physical rating. */ export function bulkTonsPerWagon( cargoType: ItemFitCargoType | undefined, wagonTypeId: string | null | undefined, capacityTons: number | string | null | undefined, ): number { const capacity = num(capacityTons); const cap = wagonTypeId ? num(cargoType?.tonsPerWagonMap?.[wagonTypeId]) : 0; if (!(cap > 0)) return capacity; return capacity > 0 ? Math.min(cap, capacity) : cap; } /** * Wagons a PER_TON bulk booking needs on one wagon type, respecting the cargo * type's per-wagon loading limit: 200T of sugar capped at 50T → 4 wagons even * though the wagon is rated 70T. Returns 0 when there is no tonnage or no * usable per-wagon figure, so callers can fall back as before. */ export function bulkTonWagonsRequired( booking: Parameters[0], cargoType: ItemFitCargoType | undefined, wagonTypeId: string | null | undefined, capacityTons: number | string | null | undefined, ): number { const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); const tons = bookingCargoTons(booking); if (!(perWagon > 0) || !(tons > 0)) return 0; return Math.max(1, Math.ceil(tons / perWagon)); } /** * Best (fewest-wagon) PER_TON count across the cargo type's allowed wagon * types, each sized on its OWN loading limit — the tonnage twin of * {@link bulkItemWagonsForAllowedTypes}, for the call sites that have no single * wagon type fixed yet. Falls back to `fallbackCapacityTons` when the cargo * type has no usable allowed types. */ export function bulkTonWagonsForAllowedTypes( booking: Parameters[0], cargoType: ItemFitCargoType | undefined, fallbackCapacityTons: number, ): number { const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); if (!allowed.length) { return bulkTonWagonsRequired(booking, cargoType, null, fallbackCapacityTons); } let best = 0; for (const wagonType of allowed) { const wagons = bulkTonWagonsRequired( booking, cargoType, wagonType.id, wagonType.capacityTons, ); if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; } return best; } /** * Wagons a BULK booking needs, whichever way its cargo is measured: PER_ITEM * sizes by indivisible items, everything else by tonnage under the cargo type's * per-wagon loading limit. One call so no site has to remember both paths. */ export function bulkWagonsForAllowedTypes( booking: Parameters[0] & { freightType?: string | null; cargoTotalWeightVgm?: number | string | null; bulkTotalWeightTons?: number | string | null; }, cargoType: ItemFitCargoType | undefined, fallbackCapacityTons: number, ): number { return ( bulkItemWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) || bulkTonWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) ); } /** Configured whole-items fit of one wagon type for a cargo type; null if unset. */ export function bulkItemsFitFor( cargoType: ItemFitCargoType | undefined, wagonTypeId: string | null | undefined, ): number | null { const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN; return Number.isFinite(fit) && fit >= 1 ? fit : null; } /** * Break-bulk wagon count when no single wagon type is fixed yet: the best * (fewest-wagon) count across the cargo type's allowed wagon types, each * respecting its own items-fit. With no fits configured this equals the old * max-capacity estimate; with no allowed types it degrades to * `fallbackCapacityTons` tonnage-only. */ export function bulkItemWagonsForAllowedTypes( booking: { freightType?: string | null; cargoTotalWeightVgm?: number | string | null; bulkTotalWeightTons?: number | string | null; }, cargoType: ItemFitCargoType | undefined, fallbackCapacityTons: number, ): number { const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons); let best = 0; for (const wagonType of allowed) { const wagons = bulkItemWagonsRequired( booking, num(wagonType.capacityTons), bulkItemsFitFor(cargoType, wagonType.id), ); if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; } return best; } /** 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 limits for a train set, per axis: * * - **Pull weight ADDS UP.** Locomotives haul together, so two 1750T units pull * 3500T. Only CONFIGURED pull weights are summed; a set with none configured * reports Infinity (no opinion), exactly as before. * - **Weight tolerance ADDS UP**, following its axis — each locomotive brings its * own overage allowance, so 2 × 90T gives the set 180T. Unset abstains (0). * - **Length takes the MINIMUM.** Train length is a siding/loop constraint, not * a haulage one: coupling a second locomotive does not lengthen the track, so * the most restrictive locomotive still governs (and its tolerance with it). * * Returns null when no locomotives are given. */ export function combinedLocomotiveLimits( locomotives: Array< Pick & Partial> >, ): LocomotiveLimits | null { if (!locomotives.length) return null; const configuredPulls = locomotives .map((l) => num(l.maxPullWeightTons)) .filter((v) => v > 0); return { maxPullWeightTons: configuredPulls.length ? round3(configuredPulls.reduce((sum, v) => sum + v, 0)) : Infinity, maxTrainLengthMeters: Math.min( ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), overageToleranceTons: sumConfigured(locomotives.map((l) => l.overageToleranceTons)), // Paired with the length axis, so it stays the weakest CONFIGURED value — a // locomotive with no tolerance set has no opinion, it does not zero the others. 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; } function sumConfigured(values: Array): number { const configured = values.filter((v) => v != null).map((v) => num(v)); return configured.length ? round3(configured.reduce((sum, v) => sum + v, 0)) : 0; } /** * Effective limits for a whole train set: {@link combinedLocomotiveLimits} over * its linked locomotives, falling back to the legacy single `locomotive` column * for sets created before multi-loco support. Null when the set has no * locomotive at all. */ export function trainSetLocomotiveLimits( trainSet?: { locomotive?: LocomotiveLimits | null; locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null; } | null, ): LocomotiveLimits | null { if (!trainSet) return null; const linked = (trainSet.locomotives ?? []) .map((link) => link.locomotive) .filter((l): l is LocomotiveLimits => Boolean(l)); const pool = linked.length ? linked : trainSet.locomotive ? [trainSet.locomotive] : []; return combinedLocomotiveLimits(pool); } /** 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, }; }