mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
398 lines
15 KiB
TypeScript
398 lines
15 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;
|
||
/** 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.
|
||
*/
|
||
export function bookingCargoTons(booking: {
|
||
cargoTotalWeightVgm?: number | string | null;
|
||
bookingContainers?: Array<{
|
||
quantity?: number | null;
|
||
vgmPerUnitTons?: number | string | null;
|
||
}> | null;
|
||
}): number {
|
||
const total = num(booking.cargoTotalWeightVgm);
|
||
if (total > 0) return total;
|
||
return (booking.bookingContainers ?? []).reduce(
|
||
(sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons),
|
||
0,
|
||
);
|
||
}
|
||
|
||
/** 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 | null | undefined>): number {
|
||
const configured = values.filter((v) => v != null).map((v) => num(v));
|
||
return configured.length ? Math.min(...configured) : 0;
|
||
}
|
||
|
||
/**
|
||
* Effective limits for a whole train set: min across 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 minLocomotiveLimits(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,
|
||
};
|
||
}
|