Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts
2026-07-22 12:34:08 +00:00

354 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): 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<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): 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 | string | null | undefined>): 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,
};
}