Add tareWeightTons property to CreateWagonTypeDto and update related components

This commit is contained in:
Marshal
2026-07-09 06:05:34 +00:00
parent bedcdca78b
commit 1b2b3f68f8
19 changed files with 979 additions and 263 deletions

View File

@@ -1,7 +1,40 @@
/**
* 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 = {
@@ -14,69 +47,166 @@ export type LocomotiveLimits = {
};
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);
}
/**
* Derive train capacity from locomotive pull weight and train length.
* Wagon count is NOT a fixed 53 — it is the minimum of:
* - floor(maxLength / shortest wagon type length)
* - floor(maxWeight / lightest wagon type capacity)
* 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 overageTons = Number(locomotive.overageToleranceTons) || 0;
const overageMeters = Number(locomotive.overageToleranceMeters) || 0;
const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps);
const maxWeightTons =
Math.min(
Number(locomotive.maxPullWeightTons) || Infinity,
ruleCaps?.maxTrainWeightTons ?? Infinity,
) + overageTons;
const maxLengthMeters =
Math.min(
Number(locomotive.maxTrainLengthMeters) || Infinity,
ruleCaps?.maxTrainLengthMeters ?? Infinity,
) + overageMeters;
const lengths = wagonTypes
.map((w) => num(w.lengthMeters))
.filter((l) => l > 0);
const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M;
const types =
wagonTypes.length > 0
? wagonTypes
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
const maxWagonSlots =
minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0;
const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M));
const minCapacity = Math.min(
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
);
return { maxWeightTons, maxLengthMeters, maxWagonSlots };
}
const byLength =
minLength > 0 && Number.isFinite(maxLengthMeters)
? Math.floor(maxLengthMeters / minLength)
: 0;
const byWeight =
minCapacity > 0 && Number.isFinite(maxWeightTons)
? Math.floor(maxWeightTons / minCapacity)
: byLength;
/**
* 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;
const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight));
for (const slot of slots) {
usedLengthMeters += num(slot.lengthMeters);
usedTareWeightTons += num(slot.tareWeightTons);
usedCargoWeightTons += num(slot.cargoTons);
}
const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons;
return {
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
maxWagonSlots,
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,
};
}
export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;
/** 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
@@ -92,18 +222,14 @@ export function minLocomotiveLimits(
if (!locomotives.length) return null;
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
),
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
...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) => Number(l.overageToleranceTons) || 0),
),
overageToleranceMeters: Math.min(
...locomotives.map((l) => Number(l.overageToleranceMeters) || 0),
),
overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))),
overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))),
};
}
@@ -117,12 +243,27 @@ export function bookingTrainLengthMeters(
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: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T,
};
}