mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
270 lines
9.9 KiB
TypeScript
270 lines
9.9 KiB
TypeScript
/**
|
||
* 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;
|
||
};
|
||
|
||
/** 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.
|
||
*/
|
||
export function trainHardCaps(
|
||
locomotive: LocomotiveLimits,
|
||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||
): { maxWeightTons: number; maxLengthMeters: number } {
|
||
const overageTons = num(locomotive.overageToleranceTons);
|
||
const overageMeters = num(locomotive.overageToleranceMeters);
|
||
|
||
const weight =
|
||
Math.min(
|
||
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
|
||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||
) + overageTons;
|
||
const length =
|
||
Math.min(
|
||
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
|
||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||
) + overageMeters;
|
||
|
||
return {
|
||
maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT,
|
||
maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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 { maxWeightTons, maxLengthMeters } = 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(maxLengthMeters / minLength)) : 0;
|
||
|
||
return { maxWeightTons, maxLengthMeters, 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 locomotive's tolerance governs the set, same as its caps.
|
||
overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))),
|
||
overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))),
|
||
};
|
||
}
|
||
|
||
/** 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));
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|