mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
leg-aware capacity and wagon sharing
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user