Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts

543 lines
22 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.

import {
bookingCargoTons,
bookingGrossWeightTons,
bookingTrainLengthMeters,
bulkItemWagonsForAllowedTypes,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonWagonsForAllowedTypes,
bulkTonWagonsRequired,
bulkWagonsForAllowedTypes,
consistUsage,
consistViolations,
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
combinedLocomotiveLimits,
sizePartialOfferWagons,
trainSetLocomotiveLimits,
} from './train-capacity.util';
describe('train-capacity.util', () => {
// Real EDR wagon specs.
const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 };
const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 };
const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 };
const caps = (over = {}) => ({
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonSlots: 54,
...over,
});
const slots = (n: number, type: typeof nw5, cargoTons: number) =>
Array.from({ length: n }, () => ({
lengthMeters: type.lengthMeters,
tareWeightTons: type.tareWeightTons,
cargoTons,
}));
describe('bulkItemWagonsRequired (break-bulk PER_ITEM)', () => {
// cargoTotalWeightVgm carries the ITEM COUNT for PER_ITEM cargo; the real
// tonnage rides in bulkTotalWeightTons.
const breakBulk = (quantity: number, weightTons: number) => ({
freightType: 'BULK',
cargoTotalWeightVgm: quantity,
bulkTotalWeightTons: weightTons,
});
it('floors items per wagon, then ceils wagons: 400 items / 800T on 69T wagons → 12', () => {
// 800/400 = 2T per item; floor(69/2) = 34 per wagon; ceil(400/34) = 12.
expect(bulkItemWagonsRequired(breakBulk(400, 800), 69)).toBe(12);
});
it('needs more wagons than raw tonnage suggests when the floor loses capacity', () => {
// 3 items × 40T on 69T wagons: by weight ceil(120/69) = 2, but only ONE
// whole 40T item fits a wagon → 3 wagons.
expect(bulkItemWagonsRequired(breakBulk(3, 120), 69)).toBe(3);
});
it('charges one wagon per item when a single item outweighs a wagon', () => {
expect(bulkItemWagonsRequired(breakBulk(2, 200), 69)).toBe(2);
});
it('returns 0 for PER_TON bulk (no stored weight) and container bookings', () => {
expect(
bulkItemWagonsRequired(
{ freightType: 'BULK', cargoTotalWeightVgm: 500, bulkTotalWeightTons: null },
69,
),
).toBe(0);
expect(
bulkItemWagonsRequired(
{ freightType: 'CONTAINER', cargoTotalWeightVgm: 100, bulkTotalWeightTons: 100 },
69,
),
).toBe(0);
});
it('returns 0 on zero/invalid capacity or amounts', () => {
expect(bulkItemWagonsRequired(breakBulk(400, 800), 0)).toBe(0);
expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0);
expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0);
});
describe('configured items-fit (floor space vs tonnage)', () => {
it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => {
// floor(70/20) = 3 by tonnage < 4 by floor space.
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17);
});
it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => {
// floor(70/10) = 7 by tonnage, but only 4 fit physically.
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13);
});
it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => {
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17);
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17);
// floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons.
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8);
});
});
});
describe('bulkItemWagonsForAllowedTypes', () => {
const breakBulk = (quantity: number, weightTons: number) => ({
freightType: 'BULK',
cargoTotalWeightVgm: quantity,
bulkTotalWeightTons: weightTons,
});
it('picks the fewest-wagon allowed type, each capped by its own fit', () => {
const cargoType = {
wagonTypes: [
{ id: 'nw5', capacityTons: 70 },
{ id: 'nw7', capacityTons: 80 },
],
itemsPerWagonMap: { nw5: 4, nw7: 6 },
};
// 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons;
// NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13.
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13);
});
it('equals the old max-capacity estimate when no fits are configured', () => {
const cargoType = {
wagonTypes: [
{ id: 'a', capacityTons: 50 },
{ id: 'b', capacityTons: 70 },
],
};
// Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17.
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17);
});
it('falls back to the given capacity when the relation is missing', () => {
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17);
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17);
});
});
describe('bulkTonsPerWagon / bulkTonWagonsRequired (PER_TON loading cap)', () => {
// Sugar is loaded 50T per wagon even on a 70T wagon.
const sugar = { wagonTypes: [{ id: 'nw5', capacityTons: 70 }], tonsPerWagonMap: { nw5: 50 } };
const bulk = (tons: number) => ({ freightType: 'BULK', cargoTotalWeightVgm: tons });
it('uses the configured cap instead of the rated capacity', () => {
expect(bulkTonsPerWagon(sugar, 'nw5', 70)).toBe(50);
});
it('falls back to rated capacity when the cargo type caps nothing', () => {
expect(bulkTonsPerWagon(null, 'nw5', 70)).toBe(70);
expect(bulkTonsPerWagon({ wagonTypes: [] }, 'nw5', 70)).toBe(70);
expect(bulkTonsPerWagon({ tonsPerWagonMap: { other: 50 } }, 'nw5', 70)).toBe(70);
});
it('clamps a stale cap that now exceeds the rating (wagon type edited down)', () => {
// Saved when NW5 was rated 70T; the type was later re-rated to 45T.
expect(bulkTonsPerWagon(sugar, 'nw5', 45)).toBe(45);
});
it('sizes 200T of capped sugar at 4 wagons, not the 3 raw capacity implies', () => {
expect(bulkTonWagonsRequired(bulk(200), sugar, 'nw5', 70)).toBe(4);
// Same booking, no cap → the old 3-wagon answer.
expect(bulkTonWagonsRequired(bulk(200), null, 'nw5', 70)).toBe(3);
});
it('picks the fewest-wagon allowed type, each on its own cap', () => {
const cargoType = {
wagonTypes: [
{ id: 'nw5', capacityTons: 70 },
{ id: 'nw7', capacityTons: 80 },
],
tonsPerWagonMap: { nw5: 50 },
};
// NW5 capped 50 → 4 wagons; NW7 uncapped 80 → 3 wagons. Best = 3.
expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3);
});
it('routes PER_ITEM and PER_TON through one call', () => {
expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4);
// PER_ITEM still wins where an item count is present.
const cars = {
wagonTypes: [{ id: 'nw5', capacityTons: 70 }],
itemsPerWagonMap: { nw5: 4 },
};
expect(
bulkWagonsForAllowedTypes(
{ freightType: 'BULK', cargoTotalWeightVgm: 50, bulkTotalWeightTons: 1000 },
cars,
70,
),
).toBe(17);
});
});
describe('bookingCargoTons (break-bulk weight preference)', () => {
it('prefers bulkTotalWeightTons over the item-count VGM column', () => {
expect(
bookingCargoTons({ cargoTotalWeightVgm: 400, bulkTotalWeightTons: 800 }),
).toBe(800);
});
it('falls back to cargoTotalWeightVgm when no break-bulk weight is stored', () => {
expect(
bookingCargoTons({ cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }),
).toBe(500);
});
});
describe('deriveTrainCapacityFromLocomotive', () => {
it('derives wagon slots from train length, not a fixed 53', () => {
const shortLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
[nw5],
);
expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966)
expect(shortLoco.maxWagonSlots).not.toBe(53);
});
it('does not shrink slots by assuming every wagon rides at full payload', () => {
// A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a
// LENGTH figure — the cargo that decides weight does not exist yet.
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
[nw5],
);
expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30
expect(derived.maxWeightTons).toBe(2100);
});
it('admits the railway 53-wagon NW5 marshalling figure', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5],
);
expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53);
});
it('uses the shortest wagon type when mixed types are present', () => {
const mixed = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5, pw2, gw2],
);
expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62
});
it('extends weight/length caps by the locomotive overage tolerance', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
[pw2],
);
expect(derived.maxWeightTons).toBe(3590);
});
it('reports the base caps and tolerance separately so filling can budget on base', () => {
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
overageToleranceTons: 90,
overageToleranceMeters: 20,
},
[pw2],
);
expect(derived.baseWeightTons).toBe(3500);
expect(derived.baseLengthMeters).toBe(760);
expect(derived.toleranceTons).toBe(90);
expect(derived.toleranceMeters).toBe(20);
expect(derived.baseWeightTons + derived.toleranceTons).toBe(derived.maxWeightTons);
expect(derived.baseLengthMeters + derived.toleranceMeters).toBe(derived.maxLengthMeters);
});
it('ignores overage tolerance when unset (strict cap)', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5],
);
expect(derived.maxWeightTons).toBe(3500);
expect(derived.maxLengthMeters).toBe(760);
});
it('floors the locomotive by the global rule caps', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 5000, maxTrainLengthMeters: 900 },
[nw5],
{ maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 },
);
expect(derived.maxWeightTons).toBe(3500);
expect(derived.maxLengthMeters).toBe(760);
});
});
describe('gross weight', () => {
it('counts the wagon as well as its cargo', () => {
expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2);
});
it('charges a booking one tare per wagon it occupies', () => {
// 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel.
expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2);
});
it('is cargo alone when the wagon type has no tare on record', () => {
expect(bookingGrossWeightTons(100, 3, 0)).toBe(100);
});
});
describe('consistUsage', () => {
it('sums each wagon own length and tare rather than averaging a type', () => {
const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)];
const usage = consistUsage(mixed, caps());
expect(usage.wagonCount).toBe(3);
expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066
expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2
expect(usage.usedCargoWeightTons).toBe(40);
expect(usage.usedGrossWeightTons).toBe(110);
expect(usage.remainingGrossWeightTons).toBe(3390);
expect(usage.remainingWagons).toBe(51);
});
it('reports an empty consist as fully available', () => {
const usage = consistUsage([], caps());
expect(usage.usedGrossWeightTons).toBe(0);
expect(usage.remainingLengthMeters).toBe(760);
expect(usage.remainingWagons).toBe(54);
});
});
describe('consistViolations', () => {
it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => {
// 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T.
const consist = slots(37, pw2, 70);
expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([
expect.stringContaining('3522.4T'),
]);
expect(
consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })),
).toEqual([]);
});
it('blocks a train the old cargo-only math would have waved through', () => {
// Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T.
const consist = slots(37, pw2, 70);
const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0);
expect(cargoOnly).toBeLessThan(3500);
expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]);
});
it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => {
// 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T.
expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]);
});
it('flags an over-length consist', () => {
const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 }));
expect(violations).toEqual([expect.stringContaining('exceeds max train length')]);
});
it('flags an over-count consist', () => {
const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 }));
expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]);
});
it('reports every broken axis at once', () => {
expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3);
});
});
it('computes booking length by freight type', () => {
expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28);
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
it('SUMS pull weight and weight tolerance across a multi-locomotive set', () => {
// Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
]);
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(180);
// A single locomotive is just its own limit — no doubling, no halving.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => {
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 },
]);
expect(limits?.maxTrainLengthMeters).toBe(700);
expect(limits?.overageToleranceMeters).toBe(5);
});
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
// keep the 90 rather than collapse to 0 — an unset value abstains.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null },
]);
expect(limits?.overageToleranceTons).toBe(90);
// All unconfigured → no tolerance.
const none = combinedLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
]);
expect(none?.overageToleranceTons).toBe(0);
});
it('reports no pull limit when NO locomotive has one configured', () => {
// Summing must not turn "unset" into 0 and strand every booking; an
// all-unset set keeps the old "no opinion" behaviour.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
]);
expect(limits?.maxPullWeightTons).toBe(Infinity);
// One configured, one not → only the configured one contributes.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null };
expect(
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
).toEqual({
maxPullWeightTons: 3550,
maxTrainLengthMeters: 700,
overageToleranceTons: 90,
overageToleranceMeters: 0,
});
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750);
expect(trainSetLocomotiveLimits(null)).toBeNull();
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
});
describe('sizePartialOfferWagons', () => {
it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => {
// The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2
// wagons: 1000 + 378 tare = 1378), leaving 744T of pull weight but plenty
// of slots/length. The boundary 1000T booking (15 wagons) must be offered
// the largest part 744T can carry: 8 wagons whose tare is 201.6T, hauling
// 542.4T of cargo — gross exactly 744.
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 744, lengthMeters: 500 },
15,
pw2,
);
expect(offer).toEqual({ wagons: 8, maxCargoTons: 542.4 });
});
it('still sizes by wagon slots when they bind first (legacy behavior)', () => {
const offer = sizePartialOfferWagons(
{ wagons: 3, weightTons: 100000, lengthMeters: 100000 },
15,
pw2,
);
expect(offer?.wagons).toBe(3);
});
it('sizes by the LENGTH axis when it binds first', () => {
// 60m of train left → 3 PW2 (17.066m) fit, the 4th does not.
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 100000, lengthMeters: 60 },
15,
pw2,
);
expect(offer?.wagons).toBe(3);
});
it('never offers all of the booking — a split is a strict subset', () => {
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 100000, lengthMeters: 100000 },
15,
pw2,
);
expect(offer?.wagons).toBe(14);
});
it('returns null when not even one part-loaded wagon fits the weight room', () => {
expect(
sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2),
).toBeNull();
});
describe('fullWagonsOnly (bulk)', () => {
it('offers only whole full wagons — each costs capacity + tare of gross room', () => {
// 704T of pull weight left. A full PW2 wagon is 70 + 25.2 = 95.2T gross,
// so 7 fit (666.4T) and the 8th (761.6T) does not. Cargo is exactly
// 7 × 70 = 490T — the last wagon is never part-loaded into the leftover.
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 704, lengthMeters: 500 },
9,
pw2,
{ fullWagonsOnly: true },
);
expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 });
});
it('never squeezes a part-loaded wagon into leftover weight room', () => {
// Same 744T room as the part-load scenario above: the scan would pick
// 8 wagons hauling 542.4T (last wagon at 52.4/70). Full-wagon sizing
// stops at 7 fully loaded wagons.
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 744, lengthMeters: 500 },
15,
pw2,
{ fullWagonsOnly: true },
);
expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 });
});
it('returns null when the room cannot take even one FULL wagon', () => {
// 67.6T left (3590 cap 3522.4 boarded): a part-loaded wagon would fit
// (25.2 tare + 42.4 cargo) but a full one (95.2 gross) does not — the
// booking must be skipped entirely, not trimmed onto the train.
expect(
sizePartialOfferWagons(
{ wagons: 40, weightTons: 67.6, lengthMeters: 500 },
3,
pw2,
{ fullWagonsOnly: true },
),
).toBeNull();
});
});
});
});