leg-aware capacity and wagon sharing

This commit is contained in:
Marshal
2026-07-30 19:08:06 +00:00
parent acef6870e9
commit f891f6abe2
7 changed files with 113 additions and 25 deletions

View File

@@ -6925,6 +6925,42 @@ export class TrainSchedulingService {
reverseWagonOrder: schedule.reverseWagonOrder, reverseWagonOrder: schedule.reverseWagonOrder,
}); });
// Heaviest-edge consist usage. Cross-leg slot sharing means plain sums
// over-report a multi-stop train — a wagon reused Gelan→Adama and
// Adama→Doraleh is two slots but ONE physical wagon, and the train is never
// heavier/longer than its heaviest single leg. Same math as the pull-limit
// enforcement; coupled-but-empty consist wagons ride every edge.
const heaviestLeg = schedule.trainSet
? (() => {
const usage = maxEdgeConsistUsage(
[
...(schedule.trainSet.wagons ?? []).map((w) => ({
lengthMeters: Number(w.lengthMeters),
tareWeightTons: w.wagonType
? Number(w.wagonType.tareWeightTons)
: 0,
assignedWeightTons: Number(w.assignedWeightTons),
boardYardId: w.boardYardId ?? null,
alightYardId: w.alightYardId ?? null,
allocations: w.allocations ?? [],
})),
...emptyConsistWagons.map((w) => ({
lengthMeters: w.lengthMeters,
tareWeightTons: Number(w.tareWeightTons ?? 0),
assignedWeightTons: 0,
allocations: [],
})),
],
this.mapScheduleStops(schedule).map((s) => s.yardId),
);
return {
grossWeightTons: roundTons(usage.grossWeightTons),
lengthMeters: roundTons(usage.lengthMeters),
loadedWagonCount: usage.loadedWagonCount,
};
})()
: null;
return { return {
id: schedule.id, id: schedule.id,
reference: schedule.reference ?? null, reference: schedule.reference ?? null,
@@ -6997,6 +7033,9 @@ export class TrainSchedulingService {
wagonCount: schedule.trainSet.wagonCount, wagonCount: schedule.trainSet.wagonCount,
totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)), totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)),
totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)), totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)),
// What the locomotives actually haul: usage on the corridor's
// heaviest edge, not the sum of every leg's slots.
heaviestLeg,
locomotive: schedule.trainSet.locomotive locomotive: schedule.trainSet.locomotive
? { ? {
id: schedule.trainSet.locomotive.id, id: schedule.trainSet.locomotive.id,

View File

@@ -308,6 +308,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
expect(maxEdgeConsistUsage(plan, stops)).toEqual({ expect(maxEdgeConsistUsage(plan, stops)).toEqual({
grossWeightTons: 89, grossWeightTons: 89,
lengthMeters: 14, lengthMeters: 14,
loadedWagonCount: 1,
}); });
}); });
@@ -328,6 +329,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({ expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
grossWeightTons: 178, grossWeightTons: 178,
lengthMeters: 28, lengthMeters: 28,
loadedWagonCount: 2,
}); });
}); });
}); });

View File

@@ -565,9 +565,22 @@ export function validateMixedTrainLimitsPerEdge(
return [...violations]; return [...violations];
} }
/**
* The slot fields per-edge usage math actually reads — lets callers feed
* persisted TrainSetWagon rows (or any structural subset), not only plan slots.
*/
export type EdgeUsageSlot = Pick<
WagonPlanSlot,
'lengthMeters' | 'tareWeightTons' | 'assignedWeightTons'
> & {
boardYardId?: string | null;
alightYardId?: string | null;
allocations?: unknown[];
};
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ /** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans( function slotSpans(
wagonPlan: WagonPlanSlot[], wagonPlan: EdgeUsageSlot[],
stops: string[], stops: string[],
): Array<{ from: number; to: number }> { ): Array<{ from: number; to: number }> {
const lastIdx = stops.length - 1; const lastIdx = stops.length - 1;
@@ -587,26 +600,28 @@ function slotSpans(
* Two stops or fewer degrade to the whole-train totals. * Two stops or fewer degrade to the whole-train totals.
*/ */
export function maxEdgeConsistUsage( export function maxEdgeConsistUsage(
wagonPlan: WagonPlanSlot[], wagonPlan: EdgeUsageSlot[],
stops: string[], stops: string[],
): { grossWeightTons: number; lengthMeters: number } { ): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
const totals = (slots: WagonPlanSlot[]) => ({ const totals = (slots: EdgeUsageSlot[]) => ({
grossWeightTons: slots.reduce( grossWeightTons: slots.reduce(
(sum, w) => (sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
0, 0,
), ),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
}); });
if (stops.length <= 2) return totals(wagonPlan); if (stops.length <= 2) return totals(wagonPlan);
const spans = slotSpans(wagonPlan, stops); const spans = slotSpans(wagonPlan, stops);
const usage = { grossWeightTons: 0, lengthMeters: 0 }; const usage = { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 };
for (let edge = 0; edge < stops.length - 1; edge += 1) { for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = totals( const active = totals(
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to), wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
); );
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons); usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters); usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
usage.loadedWagonCount = Math.max(usage.loadedWagonCount, active.loadedWagonCount);
} }
return usage; return usage;
} }

View File

@@ -559,11 +559,14 @@ export function TrainCompositionDiagram({
// ceiling the allocation engine spends from. // ceiling the allocation engine spends from.
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0); const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
const grossWeight = totalWeight + totalTare; const grossWeight = totalWeight + totalTare;
// Weakest locomotive caps the set — same rule the allocation engine applies. // Engine rule (combinedLocomotiveLimits): coupled locomotives pull
// TOGETHER, so their pull limits SUM.
const pullLimits = locos const pullLimits = locos
.map((l) => Number(l.maxPullWeightTons)) .map((l) => Number(l.maxPullWeightTons))
.filter((v) => Number.isFinite(v) && v > 0); .filter((v) => Number.isFinite(v) && v > 0);
const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null; const pullLimit = pullLimits.length
? pullLimits.reduce((sum, v) => sum + v, 0)
: null;
return { return {
total: normalized.length, total: normalized.length,
assigned, assigned,

View File

@@ -132,17 +132,24 @@ export const TrainConsistView = ({
0, 0,
); );
const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0); const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0);
const weightUsed = cargoUsed + tareUsed; // Prefer the server's heaviest-edge figures: on a multi-stop corridor a
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); // wagon reused across legs is several slots but one physical wagon, so the
// plain sums over-report the train against the pull/length/slot caps.
const heaviest = trainSet?.heaviestLeg;
const weightUsed = heaviest?.grossWeightTons ?? cargoUsed + tareUsed;
const lengthUsed =
heaviest?.lengthMeters ?? wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
const wagonsUsed = heaviest?.loadedWagonCount ?? loadedCount;
// Weakest locomotive caps the set — same rule the allocation engine applies. // Engine rule (combinedLocomotiveLimits): coupled locomotives pull TOGETHER,
// so pull caps SUM; the track doesn't lengthen, so the length cap is the MIN.
const locos = trainSet?.locomotives?.length const locos = trainSet?.locomotives?.length
? trainSet.locomotives ? trainSet.locomotives
: trainSet?.locomotive : trainSet?.locomotive
? [trainSet.locomotive] ? [trainSet.locomotive]
: []; : [];
const weightMax = locos.length const weightMax = locos.length
? Math.min(...locos.map((l) => l.maxPullWeightTons)) ? locos.reduce((sum, l) => sum + l.maxPullWeightTons, 0)
: null; : null;
const lengthCaps = locos const lengthCaps = locos
.map((l) => l.maxTrainLengthMeters) .map((l) => l.maxTrainLengthMeters)
@@ -156,7 +163,7 @@ export const TrainConsistView = ({
weightMax={weightMax} weightMax={weightMax}
lengthUsed={lengthUsed} lengthUsed={lengthUsed}
lengthMax={lengthMax} lengthMax={lengthMax}
wagonCount={loadedCount} wagonCount={wagonsUsed}
wagonMax={maxWagons} wagonMax={maxWagons}
/> />

View File

@@ -1103,19 +1103,31 @@ export default function TrainScheduleV2DetailPage() {
}, },
{ {
label: "Wagons / load", label: "Wagons / load",
// Gross: cargo load + the tare of every wagon in the consist — the // Gross: cargo load + wagon tare — the weight the locomotives
// weight the locomotive actually hauls. // actually haul. Heaviest corridor edge when the server sends it;
value: `${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${ // plain consist sums over-report a multi-stop train (cross-leg
Math.round( // wagon sharing counts one physical wagon as several slots).
((schedule.trainSet?.totalWeightTons ?? 0) + value: (() => {
(schedule.trainSet?.wagons ?? []).reduce( const heaviest = schedule.trainSet?.heaviestLeg;
(sum, w) => sum + (Number(w.tareWeightTons) || 0), const wagonCount =
0, heaviest?.loadedWagonCount ??
)) * schedule.trainSet?.wagonCount ??
100, displayWagonPlan.length;
) / 100 const grossTons =
}T`, heaviest?.grossWeightTons ??
hint: "gross · wagon tare + cargo", Math.round(
((schedule.trainSet?.totalWeightTons ?? 0) +
(schedule.trainSet?.wagons ?? []).reduce(
(sum, w) => sum + (Number(w.tareWeightTons) || 0),
0,
)) *
100,
) / 100;
return `${wagonCount} · ${grossTons}T`;
})(),
hint: schedule.trainSet?.heaviestLeg
? "heaviest leg · wagon tare + cargo"
: "gross · wagon tare + cargo",
icon: Weight, icon: Weight,
}, },
{ {

View File

@@ -608,6 +608,16 @@ export interface TrainScheduleDetail {
wagonCount: number; wagonCount: number;
totalWeightTons: number; totalWeightTons: number;
totalLengthMeters: number; totalLengthMeters: number;
/**
* Usage on the corridor's heaviest edge — what the locomotives actually
* haul. Cross-leg wagon sharing makes plain slot sums over-report a
* multi-stop train. Null without a train set.
*/
heaviestLeg?: {
grossWeightTons: number;
lengthMeters: number;
loadedWagonCount: number;
} | null;
locomotive?: { locomotive?: {
id: string; id: string;
code: string; code: string;