mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
Add tareWeightTons property to CreateWagonTypeDto and update related components
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user