Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

front: align compute margin to spec in timestops table #9823

Merged
merged 2 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { keyBy } from 'lodash';
import { describe, it, expect } from 'vitest';

import type { TrainScheduleResult } from 'common/api/osrdEditoastApi';
import type { ScheduleEntry } from 'modules/timesStops/types';

import computeMargins from '../computeMargins';
import computeMargins, { getTheoreticalMargins } from '../computeMargins';

describe('computeMargins', () => {
const path = [
Expand All @@ -18,33 +20,92 @@ describe('computeMargins', () => {
id: 'c',
uic: 3,
},
{
id: 'd',
uic: 4,
},
{
id: 'e',
uic: 5,
},
];
const margins = { boundaries: ['c'], values: ['10%', '0%'] };
const margins = { boundaries: ['c'], values: ['10%', '5%'] };
const pathItemTimes = {
base: [0, 100 * 1000, 200 * 1000],
provisional: [0, 110 * 1000, 220 * 1000],
final: [0, 115 * 1000, 230 * 1000],
base: [0, 100 * 1000, 200 * 1000, 400 * 1000, 500 * 1000],
provisional: [0, 110 * 1000, 220 * 1000, 430 * 1000, 535 * 1000],
final: [0, 115 * 1000, 230 * 1000, 440 * 1000, 545 * 1000],
};
const schedule = [
{
at: 'a',
},
{
at: 'c',
},
{
at: 'd',
},
{
at: 'e',
},
];

it('should compute simple margin', () => {
const train = { path, margins } as TrainScheduleResult;
expect(computeMargins(train, 0, pathItemTimes)).toEqual({
const train = { path, margins, schedule } as TrainScheduleResult;
const scheduleByAt: Record<string, ScheduleEntry> = keyBy(train.schedule, 'at');
const theoreticalMargins = getTheoreticalMargins(train);
expect(computeMargins(theoreticalMargins, train, scheduleByAt, 0, pathItemTimes)).toEqual({
theoreticalMargin: '10 %',
theoreticalMarginSeconds: '10 s',
calculatedMargin: '15 s',
diffMargins: '5 s',
});
expect(computeMargins(train, 1, pathItemTimes)).toEqual({
theoreticalMargin: '',
isTheoreticalMarginBoundary: true,
theoreticalMarginSeconds: '20 s',
calculatedMargin: '30 s',
diffMargins: '10 s',
});
expect(computeMargins(train, 2, pathItemTimes)).toEqual({
expect(computeMargins(theoreticalMargins, train, scheduleByAt, 1, pathItemTimes)).toEqual({
theoreticalMargin: undefined,
isTheoreticalMarginBoundary: undefined,
theoreticalMarginSeconds: undefined,
calculatedMargin: undefined,
diffMargins: undefined,
});
expect(computeMargins(theoreticalMargins, train, scheduleByAt, 2, pathItemTimes)).toEqual({
theoreticalMargin: '5 %',
isTheoreticalMarginBoundary: true,
theoreticalMarginSeconds: '10 s',
calculatedMargin: '10 s',
diffMargins: '0 s',
});
expect(computeMargins(theoreticalMargins, train, scheduleByAt, 3, pathItemTimes)).toEqual({
theoreticalMargin: '5 %',
isTheoreticalMarginBoundary: false,
theoreticalMarginSeconds: '5 s',
calculatedMargin: '5 s',
diffMargins: '0 s',
});
expect(computeMargins(theoreticalMargins, train, scheduleByAt, 4, pathItemTimes)).toEqual({
theoreticalMargin: undefined,
isTheoreticalMarginBoundary: undefined,
theoreticalMarginSeconds: undefined,
calculatedMargin: undefined,
diffMargins: undefined,
});
});
});

describe('getTheoreticalMargins', () => {
it('should compute theoretical margins with boundaries correctly', () => {
const path = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
const margins = { boundaries: ['c', 'd'], values: ['10%', '0%', '10 min/100km'] };
const trainSchedule = { path, margins } as TrainScheduleResult;

const theoreticalMargins = getTheoreticalMargins(trainSchedule);

expect(theoreticalMargins).toEqual({
a: { theoreticalMargin: '10%', isBoundary: true },
b: { theoreticalMargin: '10%', isBoundary: false },
c: { theoreticalMargin: '0%', isBoundary: true },
d: { theoreticalMargin: '10 min/100km', isBoundary: true },
e: { theoreticalMargin: '10 min/100km', isBoundary: false },
});
});
});
83 changes: 48 additions & 35 deletions front/src/modules/timesStops/helpers/computeMargins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,48 @@ import type { TrainScheduleWithDetails } from 'modules/trainschedule/components/
import { ms2sec } from 'utils/timeManipulation';

import { formatDigitsAndUnit } from './utils';
import type { ScheduleEntry, TheoreticalMarginsRecord } from '../types';

function getTheoreticalMargin(selectedTrainSchedule: TrainScheduleResult, pathStepId: string) {
if (selectedTrainSchedule.path.length === 0) {
/** Extracts the theoretical margin for each path step in the train schedule,
* and marks whether margins are repeated or correspond to a boundary between margin values */
export function getTheoreticalMargins(selectedTrainSchedule: TrainScheduleResult) {
clarani marked this conversation as resolved.
Show resolved Hide resolved
const { margins } = selectedTrainSchedule;
if (!margins) {
return undefined;
}
// pathStep is starting point => we take the first margin
if (selectedTrainSchedule.path[0].id === pathStepId) {
return selectedTrainSchedule.margins?.values[0];
}
const theoreticalMarginBoundaryIndex = selectedTrainSchedule.margins?.boundaries?.findIndex(
(id) => id === pathStepId
);
if (
theoreticalMarginBoundaryIndex === undefined ||
theoreticalMarginBoundaryIndex < 0 ||
theoreticalMarginBoundaryIndex > selectedTrainSchedule.margins!.values.length - 2
) {
return undefined;
}

return selectedTrainSchedule.margins!.values[theoreticalMarginBoundaryIndex + 1];
const theoreticalMargins: TheoreticalMarginsRecord = {};
let marginIndex = 0;
selectedTrainSchedule.path.forEach((step, index) => {
let isBoundary = index === 0;
if (step.id === selectedTrainSchedule.margins?.boundaries[marginIndex]) {
marginIndex += 1;
isBoundary = true;
}
theoreticalMargins[step.id] = {
theoreticalMargin: margins.values[marginIndex],
isBoundary,
};
});
return theoreticalMargins;
}

/** Compute all margins to display for a given train schedule path step */
function computeMargins(
theoreticalMargins: TheoreticalMarginsRecord | undefined,
selectedTrainSchedule: TrainScheduleResult,
scheduleByAt: Record<string, ScheduleEntry>,
pathStepIndex: number,
pathItemTimes: NonNullable<TrainScheduleWithDetails['pathItemTimes']> // in ms
) {
const { path, margins } = selectedTrainSchedule;
const pathStepId = path[pathStepIndex].id;
const schedule = scheduleByAt[pathStepId];
const stepTheoreticalMarginInfo = theoreticalMargins?.[pathStepId];
if (
!margins ||
(margins.values.length === 1 && margins.values[0] === '0%') ||
pathStepIndex === selectedTrainSchedule.path.length - 1
pathStepIndex === selectedTrainSchedule.path.length - 1 ||
!stepTheoreticalMarginInfo ||
!(schedule || stepTheoreticalMarginInfo.isBoundary)
) {
return {
theoreticalMargin: undefined,
Expand All @@ -45,14 +54,17 @@ function computeMargins(
};
}

const pathStepId = path[pathStepIndex].id;
const theoreticalMargin = getTheoreticalMargin(selectedTrainSchedule, pathStepId);
const { theoreticalMargin, isBoundary } = stepTheoreticalMarginInfo;

// find the next pathStep where constraints are defined
let nextIndex = path.length - 1;

// find the previous pathStep where margin was defined
let prevIndex = 0;
for (let index = 1; index < pathStepIndex; index += 1) {
if (margins.boundaries.includes(path[index].id)) {
prevIndex = index;
for (let index = pathStepIndex + 1; index < path.length; index += 1) {
const curStepId = path[index].id;
const curStepSchedule = scheduleByAt[curStepId];
if (theoreticalMargins[curStepId]?.isBoundary || curStepSchedule) {
nextIndex = index;
break;
}
}

Expand All @@ -61,19 +73,20 @@ function computeMargins(
// provisional = margins
// final = margins + requested arrival times
const { base, provisional, final } = pathItemTimes;
const baseDuration = ms2sec(base[pathStepIndex + 1] - base[prevIndex]);
const provisionalDuration = ms2sec(provisional[pathStepIndex + 1] - provisional[prevIndex]);
const finalDuration = ms2sec(final[pathStepIndex + 1] - final[prevIndex]);
const baseDuration = ms2sec(base[nextIndex] - base[pathStepIndex]);
const provisionalDuration = ms2sec(provisional[nextIndex] - provisional[pathStepIndex]);
const finalDuration = ms2sec(final[nextIndex] - final[pathStepIndex]);

// how much longer it took (s) with the margin than without
const provisionalLostTime = provisionalDuration - baseDuration;
const finalLostTime = finalDuration - baseDuration;
const provisionalLostTime = Math.round(provisionalDuration - baseDuration);
const finalLostTime = Math.round(finalDuration - baseDuration);

return {
theoreticalMargin: formatDigitsAndUnit(theoreticalMargin),
theoreticalMarginSeconds: `${Math.round(provisionalLostTime)} s`,
calculatedMargin: `${Math.round(finalLostTime)} s`,
diffMargins: `${Math.round(finalLostTime - provisionalLostTime)} s`,
isTheoreticalMarginBoundary: isBoundary,
theoreticalMarginSeconds: `${provisionalLostTime} s`,
calculatedMargin: `${finalLostTime} s`,
diffMargins: `${finalLostTime - provisionalLostTime} s`,
};
}

Expand Down
2 changes: 1 addition & 1 deletion front/src/modules/timesStops/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const getDigits = (unit: string | undefined) =>
unit === MarginUnit.second || unit === MarginUnit.percent ? 0 : 1;

export function formatDigitsAndUnit(fullValue: string | number | undefined, unit?: string) {
if (fullValue === undefined || fullValue === '0%') {
if (fullValue === undefined) {
clarani marked this conversation as resolved.
Show resolved Hide resolved
return '';
}
if (typeof fullValue === 'number') {
Expand Down
20 changes: 17 additions & 3 deletions front/src/modules/timesStops/hooks/useOutputTableData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { calculateTimeDifferenceInSeconds } from 'utils/timeManipulation';

import { ARRIVAL_TIME_ACCEPTABLE_ERROR_MS } from '../consts';
import { computeInputDatetimes } from '../helpers/arrivalTime';
import computeMargins from '../helpers/computeMargins';
import computeMargins, { getTheoreticalMargins } from '../helpers/computeMargins';
import { formatSchedule } from '../helpers/scheduleData';
import { type ScheduleEntry, type TimeStopsRow } from '../types';

Expand All @@ -29,6 +29,8 @@ const useOutputTableData = (
const { t } = useTranslation('timesStops');

const scheduleByAt: Record<string, ScheduleEntry> = keyBy(selectedTrainSchedule?.schedule, 'at');
const theoreticalMargins = selectedTrainSchedule && getTheoreticalMargins(selectedTrainSchedule);

const startDatetime = selectedTrainSchedule
? new Date(selectedTrainSchedule.start_time)
: undefined;
Expand All @@ -48,8 +50,19 @@ const useOutputTableData = (
computedArrival,
schedule
);
const { theoreticalMargin, theoreticalMarginSeconds, calculatedMargin, diffMargins } =
computeMargins(selectedTrainSchedule, index, pathItemTimes);
const {
theoreticalMargin,
isTheoreticalMarginBoundary,
theoreticalMarginSeconds,
calculatedMargin,
diffMargins,
} = computeMargins(
theoreticalMargins,
selectedTrainSchedule,
scheduleByAt,
index,
pathItemTimes
);

const { theoreticalArrival, arrival, departure, refDate } = computeInputDatetimes(
startDatetime,
Expand Down Expand Up @@ -78,6 +91,7 @@ const useOutputTableData = (
onStopSignal,
shortSlipDistance,
theoreticalMargin,
isTheoreticalMarginBoundary,

theoreticalMarginSeconds,
calculatedMargin,
Expand Down
33 changes: 29 additions & 4 deletions front/src/modules/timesStops/hooks/useTimeStopsColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { keyColumn, type Column, checkboxColumn, createTextColumn } from 'react-
import type { CellComponent } from 'react-datasheet-grid/dist/types';
import { useTranslation } from 'react-i18next';

import { NO_BREAK_SPACE } from 'utils/strings';

import { marginRegExValidation } from '../consts';
import { disabledTextColumn } from '../helpers/utils';
import ReadOnlyTime from '../ReadOnlyTime';
Expand Down Expand Up @@ -158,7 +160,7 @@ export const useTimeStopsColumns = <T extends TimeStopsRow>(
continuousUpdates: false,
placeholder: !isOutputTable ? t('theoreticalMarginPlaceholder') : '',
formatBlurredInput: (value) => {
if (!value || value === '0%') return '';
if (!value) return '';
if (!isOutputTable && !marginRegExValidation.test(value)) {
return `${value}${t('theoreticalMarginPlaceholder')}`;
}
Expand All @@ -167,12 +169,35 @@ export const useTimeStopsColumns = <T extends TimeStopsRow>(
alignRight: true,
})
),
...(isOutputTable && {
component: ({ rowData }) => {
if (!rowData.theoreticalMargin) return null;
const [digits, unit] = rowData.theoreticalMargin.split(NO_BREAK_SPACE);
return (
<span className="dsg-input dsg-input-align-right self-center text-nowrap">
{digits}
{NO_BREAK_SPACE}
{unit === 'min/100km' ? (
<span className="small-unit-container">
<span>min/</span>
<br />
<span>100km</span>
</span>
) : (
unit
)}
</span>
);
},
}),
cellClassName: ({ rowData }) =>
cx({ invalidCell: !isOutputTable && !rowData.isMarginValid }),
cx({
invalidCell: !isOutputTable && !rowData.isMarginValid,
repeatedValue: rowData.isTheoreticalMarginBoundary === false, // the class should be added on false but not undefined
}),
title: t('theoreticalMargin'),
headerClassName: 'padded-header',
minWidth: 100,
maxWidth: 130,
...fixedWidth(isOutputTable ? 75 : 110),
disabled: ({ rowIndex }) => isOutputTable || rowIndex === allWaypoints.length - 1,
},
...extraOutputColumns,
Expand Down
20 changes: 18 additions & 2 deletions front/src/modules/timesStops/styles/_timesStopsDatasheet.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
--dsg-header-text-color: var(--grey50);
--dsg-header-active-text-color: var(--black100);

.invalidCell {
color: red !important;
}

.padded-header > * {
padding-inline: 8px 16px;
}
Expand All @@ -19,8 +23,20 @@
color: var(--grey50);
}

.invalidCell {
color: red !important;
.self-center {
align-self: center;
}

.small-unit-container {
display: inline-block;
font-size: 0.5rem;
line-height: 8px;
text-align: left;
vertical-align: -10%;
}

.repeatedValue {
color: var(--grey30);
}

.warning-schedule {
Expand Down
Loading
Loading