From 79b6e32765f4b926afb5bc69b05832d1de8c960a Mon Sep 17 00:00:00 2001 From: Loup Federico <16464925+Sh099078@users.noreply.github.com> Date: Thu, 17 Oct 2024 16:14:08 +0200 Subject: [PATCH] editoast, front: refactor temporary speed limit import endpoint Split the endpoint in two: - One endpoint remains responsible from importing temporary speed limits and their list of track ranges. - A new infra endpoint `delimited_area` returns the list of track ranges inside an area delimited by a list of entries and exits (directed locations on the tracks, quite similar to the position and direction of signs). Signed-off-by: Loup Federico <16464925+Sh099078@users.noreply.github.com> --- editoast/editoast_models/src/tables.rs | 2 + .../down.sql | 2 + .../up.sql | 2 + editoast/openapi.yaml | 129 +++ editoast/src/infra_cache/graph.rs | 11 + editoast/src/models/fixtures.rs | 13 + .../src/models/stdcm_search_environment.rs | 38 +- editoast/src/views/infra/delimited_area.rs | 929 ++++++++++++++++++ editoast/src/views/infra/mod.rs | 3 + .../src/views/stdcm_search_environment.rs | 25 +- editoast/src/views/temporary_speed_limits.rs | 202 ++-- front/public/locales/en/errors.json | 3 + front/public/locales/fr/errors.json | 3 + .../applications/stdcm/hooks/useStdcmEnv.tsx | 1 + .../stdcm/utils/formatStdcmConf.ts | 4 + front/src/common/api/generatedEditoastApi.ts | 24 + .../src/reducers/osrdconf/stdcmConf/index.ts | 4 + front/src/reducers/osrdconf/types.ts | 1 + 18 files changed, 1313 insertions(+), 83 deletions(-) create mode 100644 editoast/migrations/2024-11-06-175550_add_temporary_speed_limit_group_to_stdcm_search_environment/down.sql create mode 100644 editoast/migrations/2024-11-06-175550_add_temporary_speed_limit_group_to_stdcm_search_environment/up.sql create mode 100644 editoast/src/views/infra/delimited_area.rs diff --git a/editoast/editoast_models/src/tables.rs b/editoast/editoast_models/src/tables.rs index 22df341aa3f..87e7ff1fc30 100644 --- a/editoast/editoast_models/src/tables.rs +++ b/editoast/editoast_models/src/tables.rs @@ -608,6 +608,7 @@ diesel::table! { timetable_id -> Int8, search_window_begin -> Timestamptz, search_window_end -> Timestamptz, + temporary_speed_limit_group_id -> Nullable, } } @@ -798,6 +799,7 @@ diesel::joinable!(search_signal -> infra_object_signal (id)); diesel::joinable!(search_study -> study (id)); diesel::joinable!(stdcm_search_environment -> electrical_profile_set (electrical_profile_set_id)); diesel::joinable!(stdcm_search_environment -> infra (infra_id)); +diesel::joinable!(stdcm_search_environment -> temporary_speed_limit_group (temporary_speed_limit_group_id)); diesel::joinable!(stdcm_search_environment -> timetable (timetable_id)); diesel::joinable!(stdcm_search_environment -> work_schedule_group (work_schedule_group_id)); diesel::joinable!(study -> project (project_id)); diff --git a/editoast/migrations/2024-11-06-175550_add_temporary_speed_limit_group_to_stdcm_search_environment/down.sql b/editoast/migrations/2024-11-06-175550_add_temporary_speed_limit_group_to_stdcm_search_environment/down.sql new file mode 100644 index 00000000000..f75eb03a05e --- /dev/null +++ b/editoast/migrations/2024-11-06-175550_add_temporary_speed_limit_group_to_stdcm_search_environment/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE stdcm_search_environment +DROP COLUMN IF EXISTS temporary_speed_limit_group_id; diff --git a/editoast/migrations/2024-11-06-175550_add_temporary_speed_limit_group_to_stdcm_search_environment/up.sql b/editoast/migrations/2024-11-06-175550_add_temporary_speed_limit_group_to_stdcm_search_environment/up.sql new file mode 100644 index 00000000000..2803d3ddb4f --- /dev/null +++ b/editoast/migrations/2024-11-06-175550_add_temporary_speed_limit_group_to_stdcm_search_environment/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE stdcm_search_environment +ADD COLUMN temporary_speed_limit_group_id int8 REFERENCES temporary_speed_limit_group(id); diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 26fd401fe76..f9f30de05d7 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -674,6 +674,51 @@ paths: minimum: 0 '404': description: Infra ID not found + /infra/{infra_id}/delimited_area: + get: + tags: + - delimited_area + summary: Computes all tracks between a set of `entries` locations and a set of `exits` locations + description: |- + Returns any track between one of the `entries` and one of the `exits`, i.e. any track that can + be reached from an entry before reaching an exit. + To prevent a missing exit to cause the graph traversal to never stop exploring, the exploration + stops when a maximum distance is reached and no exit has been found. + parameters: + - name: infra_id + in: path + description: An existing infra ID + required: true + schema: + type: integer + format: int64 + requestBody: + content: + application/json: + schema: + type: object + required: + - track_ranges + properties: + track_ranges: + type: array + items: + $ref: '#/components/schemas/DirectionalTrackRange' + required: true + responses: + '200': + description: The track ranges between a list entries and exits. + content: + application/json: + schema: + type: object + required: + - track_ranges + properties: + track_ranges: + type: array + items: + $ref: '#/components/schemas/DirectionalTrackRange' /infra/{infra_id}/errors: get: tags: @@ -3486,6 +3531,15 @@ components: force: type: boolean description: force the deletion even if it's used + DelimitedAreaResponse: + type: object + required: + - track_ranges + properties: + track_ranges: + type: array + items: + $ref: '#/components/schemas/DirectionalTrackRange' Detector: type: object required: @@ -3519,6 +3573,22 @@ components: maxLength: 255 minLength: 1 additionalProperties: false + DirectedLocation: + type: object + required: + - track + - position + - direction + properties: + direction: + $ref: '#/components/schemas/Direction' + position: + type: number + format: double + track: + type: string + maxLength: 255 + minLength: 1 Direction: type: string enum: @@ -4061,6 +4131,30 @@ components: type: string enum: - editoast:DatabaseAccessError + EditoastDelimitedAreaErrorInvalidLocations: + type: object + required: + - type + - status + - message + properties: + context: + type: object + required: + - invalid_locations + properties: + invalid_locations: + type: array + message: + type: string + status: + type: integer + enum: + - 400 + type: + type: string + enum: + - editoast:delimited_area:InvalidLocations EditoastDocumentErrorsNotFound: type: object required: @@ -4213,6 +4307,7 @@ components: - $ref: '#/components/schemas/EditoastCoreErrorGenericCoreError' - $ref: '#/components/schemas/EditoastCoreErrorUnparsableErrorOutput' - $ref: '#/components/schemas/EditoastDatabaseAccessErrorDatabaseAccessError' + - $ref: '#/components/schemas/EditoastDelimitedAreaErrorInvalidLocations' - $ref: '#/components/schemas/EditoastDocumentErrorsNotFound' - $ref: '#/components/schemas/EditoastEditionErrorInfraIsLocked' - $ref: '#/components/schemas/EditoastEditionErrorSplitTrackSectionBadOffset' @@ -6538,6 +6633,33 @@ components: properties: state: $ref: '#/components/schemas/InfraState' + InputError: + oneOf: + - type: object + required: + - TrackDoesNotExist + properties: + TrackDoesNotExist: + type: string + - type: object + required: + - LocationOutOfBounds + properties: + LocationOutOfBounds: + type: object + required: + - track + - position + - track_length + properties: + position: + type: number + format: double + track: + type: string + track_length: + type: number + format: double InternalError: type: object required: @@ -9916,6 +10038,9 @@ components: search_window_end: type: string format: date-time + temporary_speed_limit_group_id: + type: integer + format: int64 timetable_id: type: integer format: int64 @@ -9943,6 +10068,10 @@ components: search_window_end: type: string format: date-time + temporary_speed_limit_group_id: + type: integer + format: int64 + nullable: true timetable_id: type: integer format: int64 diff --git a/editoast/src/infra_cache/graph.rs b/editoast/src/infra_cache/graph.rs index f160a5ef575..03154114590 100644 --- a/editoast/src/infra_cache/graph.rs +++ b/editoast/src/infra_cache/graph.rs @@ -82,6 +82,17 @@ impl<'a> Graph<'a> { .map(|groups| groups.keys().cloned().collect()) .unwrap_or_default() } + + /// Given an endpoint return all its neighbours indiscriminately + /// of their group. + pub fn get_all_neighbours(&'a self, track_endpoint: &TrackEndpoint) -> Vec<&'a TrackEndpoint> { + let groups = self.links.get(track_endpoint); + if let Some(groups) = groups { + groups.values().copied().collect::>() + } else { + Vec::new() + } + } } #[cfg(test)] diff --git a/editoast/src/models/fixtures.rs b/editoast/src/models/fixtures.rs index 1ed01eb9b7b..e06c38cb9fd 100644 --- a/editoast/src/models/fixtures.rs +++ b/editoast/src/models/fixtures.rs @@ -31,6 +31,8 @@ use crate::models::Scenario; use crate::models::Study; use crate::models::Tags; +use super::temporary_speed_limits::TemporarySpeedLimitGroup; + pub fn project_changeset(name: &str) -> Changeset { Project::changeset() .name(name.to_owned()) @@ -296,6 +298,17 @@ pub async fn create_work_schedule_group(conn: &mut DbConnection) -> WorkSchedule .expect("Failed to create empty work schedule group") } +pub async fn create_temporary_speed_limit_group( + conn: &mut DbConnection, +) -> TemporarySpeedLimitGroup { + TemporarySpeedLimitGroup::changeset() + .name("Empty temporary speed limit group".to_string()) + .creation_date(Utc::now().naive_utc()) + .create(conn) + .await + .expect("Failed to create empty temporary speed limit group") +} + pub async fn create_work_schedules_fixture_set( conn: &mut DbConnection, work_schedules: Vec>, diff --git a/editoast/src/models/stdcm_search_environment.rs b/editoast/src/models/stdcm_search_environment.rs index dddfdc63216..084833c3c95 100644 --- a/editoast/src/models/stdcm_search_environment.rs +++ b/editoast/src/models/stdcm_search_environment.rs @@ -30,6 +30,9 @@ pub struct StdcmSearchEnvironment { pub timetable_id: i64, pub search_window_begin: NaiveDateTime, pub search_window_end: NaiveDateTime, + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] + pub temporary_speed_limit_group_id: Option, } impl StdcmSearchEnvironment { @@ -69,9 +72,10 @@ pub mod test { use super::*; use crate::models::electrical_profiles::ElectricalProfileSet; use crate::models::fixtures::{ - create_electrical_profile_set, create_empty_infra, create_timetable, - create_work_schedule_group, + create_electrical_profile_set, create_empty_infra, create_temporary_speed_limit_group, + create_timetable, create_work_schedule_group, }; + use crate::models::temporary_speed_limits::TemporarySpeedLimitGroup; use crate::models::timetable::Timetable; use crate::models::work_schedules::WorkScheduleGroup; use crate::models::Infra; @@ -80,16 +84,24 @@ pub mod test { pub async fn stdcm_search_env_fixtures( conn: &mut DbConnection, - ) -> (Infra, Timetable, WorkScheduleGroup, ElectricalProfileSet) { + ) -> ( + Infra, + Timetable, + WorkScheduleGroup, + TemporarySpeedLimitGroup, + ElectricalProfileSet, + ) { let infra = create_empty_infra(conn).await; let timetable = create_timetable(conn).await; let work_schedule_group = create_work_schedule_group(conn).await; + let temporary_speed_limit_group = create_temporary_speed_limit_group(conn).await; let electrical_profile_set = create_electrical_profile_set(conn).await; ( infra, timetable, work_schedule_group, + temporary_speed_limit_group, electrical_profile_set, ) } @@ -101,13 +113,19 @@ pub mod test { StdcmSearchEnvironment::count(&mut db_pool.get_ok(), Default::default()) .await .expect("failed to count STDCM envs"); - let (infra, timetable, work_schedule_group, electrical_profile_set) = - stdcm_search_env_fixtures(&mut db_pool.get_ok()).await; + let ( + infra, + timetable, + work_schedule_group, + temporary_speed_limit_group, + electrical_profile_set, + ) = stdcm_search_env_fixtures(&mut db_pool.get_ok()).await; let changeset_1 = StdcmSearchEnvironment::changeset() .infra_id(infra.id) .electrical_profile_set_id(Some(electrical_profile_set.id)) .work_schedule_group_id(Some(work_schedule_group.id)) + .temporary_speed_limit_group_id(Some(temporary_speed_limit_group.id)) .timetable_id(timetable.id) .search_window_begin(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().into()) .search_window_end(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap().into()); @@ -158,13 +176,19 @@ pub mod test { StdcmSearchEnvironment::delete_all(&mut db_pool.get_ok()) .await .expect("failed to delete envs"); - let (infra, timetable, work_schedule_group, electrical_profile_set) = - stdcm_search_env_fixtures(&mut db_pool.get_ok()).await; + let ( + infra, + timetable, + work_schedule_group, + temporary_speed_limit_group, + electrical_profile_set, + ) = stdcm_search_env_fixtures(&mut db_pool.get_ok()).await; let too_old = StdcmSearchEnvironment::changeset() .infra_id(infra.id) .electrical_profile_set_id(Some(electrical_profile_set.id)) .work_schedule_group_id(Some(work_schedule_group.id)) + .temporary_speed_limit_group_id(Some(temporary_speed_limit_group.id)) .timetable_id(timetable.id) .search_window_begin(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().into()) .search_window_end(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap().into()); diff --git a/editoast/src/views/infra/delimited_area.rs b/editoast/src/views/infra/delimited_area.rs new file mode 100644 index 00000000000..0ac51ae9b87 --- /dev/null +++ b/editoast/src/views/infra/delimited_area.rs @@ -0,0 +1,929 @@ +use crate::error::Result; +use crate::infra_cache::{Graph, InfraCache}; +use crate::models::Infra; +use crate::views::infra::{InfraApiError, InfraIdParam}; +use crate::views::{AuthenticationExt, AuthorizationError}; +use crate::AppState; +use crate::Retrieve; +use axum::extract::{Path, State}; +use axum::{Extension, Json}; +use editoast_authz::BuiltinRole; +use editoast_derive::EditoastError; +use editoast_schemas::{ + infra::{Direction, DirectionalTrackRange, Endpoint, Sign, TrackEndpoint}, + primitives::Identifier, +}; +use itertools::{Either, Itertools}; +use serde::{Deserialize, Serialize}; +use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, + result::Result as StdResult, +}; +use thiserror::Error; +use utoipa::ToSchema; + +crate::routes! { + "/delimited_area" => delimited_area, +} + +editoast_common::schemas! { + DelimitedAreaResponse, + DirectedLocation, + InputError, +} + +// Maximum distance the graph can be explored from a speed limit execution signal +// without finding any legitimate ending to the speed limit before it is considered +// there is not valid limit on the portion of the graph that is being explored. +// TODO Magic number for now. Make it configurable ? +const MAXIMUM_DISTANCE: f64 = 5000.; + +#[derive(Deserialize, ToSchema)] +struct DelimitedAreaForm { + #[schema(inline)] + entries: Vec, + #[schema(inline)] + exits: Vec, +} + +#[derive(Deserialize, Serialize, ToSchema)] +struct DelimitedAreaResponse { + track_ranges: Vec, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +struct DirectedLocation { + #[schema(inline)] + track: Identifier, + position: f64, + direction: Direction, +} + +#[derive(Debug, Error, Serialize, Deserialize, EditoastError)] +#[editoast_error(base_id = "delimited_area")] +enum DelimitedAreaError { + #[error("Some locations were invalid")] + #[editoast_error(status = 400)] + InvalidLocations { + invalid_locations: Vec<(DirectedLocation, InputError)>, + }, +} + +#[derive(Debug, Error, Serialize, Deserialize, ToSchema)] +enum InputError { + #[error("Track '{0}' does not exist")] + TrackDoesNotExist(String), + #[error("Invalid input position '{position}' on track '{track}' of length '{track_length}'")] + LocationOutOfBounds { + track: String, + position: f64, + track_length: f64, + }, +} + +#[derive(Debug, Error, Serialize, Deserialize)] +enum TrackRangeConstructionError { + #[error("Track identifiers do not match")] + TrackIdentifierMissmatch, + #[error( + "The input directions or locations on track do not allow to build a valid track range" + )] + InvalidRelativeLocations, + #[error("The location is not on track")] + LocationNotOnTrack, +} + +impl From for DirectedLocation { + fn from(value: Sign) -> Self { + let Sign { + track, + position, + direction, + .. + } = value; + DirectedLocation { + track, + position, + direction, + } + } +} + +#[utoipa::path( + get, path = "", + tag = "delimited_area", + params(InfraIdParam), + request_body = inline(DelimitedAreaResponse), + responses( + (status = 200, body = inline(DelimitedAreaResponse), description = "The track ranges between a list entries and exits." ), + ) +)] +/// Computes all tracks between a set of `entries` locations and a set of `exits` locations +/// +/// Returns any track between one of the `entries` and one of the `exits`, i.e. any track that can +/// be reached from an entry before reaching an exit. +/// To prevent a missing exit to cause the graph traversal to never stop exploring, the exploration +/// stops when a maximum distance is reached and no exit has been found. +async fn delimited_area( + Extension(auth): AuthenticationExt, + State(AppState { + infra_caches, + db_pool_v2: db_pool, + .. + }): State, + Path(InfraIdParam { infra_id }): Path, + Json(DelimitedAreaForm { entries, exits }): Json, +) -> Result> { + // TODO in case of a missing exit, return an empty list of track ranges instead of returning all + // the track ranges explored until the stopping condition ? + let authorized = auth + .check_roles([BuiltinRole::InfraWrite].into()) + .await + .map_err(AuthorizationError::AuthError)?; + if !authorized { + return Err(AuthorizationError::Unauthorized.into()); + } + + // Retrieve the infra + + let conn = &mut db_pool.get().await?; + let infra = + Infra::retrieve_or_fail(conn, infra_id, || InfraApiError::NotFound { infra_id }).await?; + let infra_cache = InfraCache::get_or_load(conn, &infra_caches, &infra).await?; + let graph = Graph::load(&infra_cache); + + // Validate user input + + let (valid_entries, invalid_entries): (Vec<_>, Vec<_>) = + entries + .into_iter() + .partition_map(|entry| match validate_location(&entry, &infra_cache) { + Ok(_) => Either::Left(entry), + Err(e) => Either::Right((entry, e)), + }); + let (valid_exits, invalid_exits): (Vec<_>, Vec<_>) = + exits + .into_iter() + .partition_map(|exit| match validate_location(&exit, &infra_cache) { + Ok(_) => Either::Left(exit), + Err(e) => Either::Right((exit, e)), + }); + + if !(invalid_exits.is_empty() && invalid_entries.is_empty()) { + let invalid_locations = invalid_entries + .into_iter() + .chain(invalid_exits.into_iter()) + .collect::>(); + return Err(DelimitedAreaError::InvalidLocations { invalid_locations }.into()); + } + + // Retrieve the track ranges + + Ok(Json(DelimitedAreaResponse { + track_ranges: track_ranges_from_locations(valid_entries, valid_exits, &graph, &infra_cache), + })) +} + +/// Check whether a location is valid on a given infra cache, i.e. if it matches a track on the infra +/// and if it is within bounds. +fn validate_location( + location: &DirectedLocation, + infra_cache: &InfraCache, +) -> StdResult<(), InputError> { + // Check if the location track exists on the infra + + let track_length = infra_cache + .track_sections() + .get(&location.track.0) + .ok_or(InputError::TrackDoesNotExist(location.track.0.clone()))? + .unwrap_track_section() + .length; + + // Check if the location is within bounds on the track + + if location.position < 0. || track_length < location.position { + Err(InputError::LocationOutOfBounds { + track: location.track.0.clone(), + position: location.position, + track_length, + }) + } else { + Ok(()) + } +} + +fn track_ranges_from_locations( + entries: Vec, + exits: Vec, + graph: &Graph, + infra_cache: &InfraCache, +) -> Vec { + entries + .iter() + .flat_map(|entry| { + impacted_tracks( + entry, + exits.iter().collect::>(), + graph, + infra_cache, + MAXIMUM_DISTANCE, + ) + }) + .collect::>() +} + +fn impacted_tracks( + entry: &DirectedLocation, + exits: Vec<&DirectedLocation>, + graph: &Graph, + infra_cache: &InfraCache, + max_distance: f64, +) -> Vec { + // Map track identifiers to their list of associated exits: + let exits = { + let mut tracks_to_exits: HashMap<&Identifier, Vec<&DirectedLocation>> = HashMap::new(); + for exit in exits { + tracks_to_exits.entry(&exit.track).or_default().push(exit); + } + tracks_to_exits + }; + + // Directional track ranges reachable from `entry` during the graph exploration. + let mut related_track_ranges: Vec = Vec::new(); + + // TrackEndpoint right after the entry location (in the correct direction): + let first_track_endpoint = TrackEndpoint { + endpoint: match entry.direction { + Direction::StartToStop => Endpoint::End, + Direction::StopToStart => Endpoint::Begin, + }, + track: entry.track.clone(), + }; + + if let Some(immediate_exit) = closest_exit_from_entry(entry, exits.get(&entry.track)) { + let only_track_range = track_range_between_two_locations(entry, immediate_exit) + .expect("Failed to build track range"); + return vec![only_track_range]; + } else { + let first_track_length = infra_cache + .track_sections() + .get(&first_track_endpoint.track.0) + .expect("Error while retrieving a track range from the infra cache") + .unwrap_track_section() + .length; + let first_track_range = track_range_between_endpoint_and_location( + entry, + &first_track_endpoint, + first_track_length, + true, + ) + .expect("Failed to build track range"); + related_track_ranges.push(first_track_range); + }; + + // Identifiers of the track sections that have already been reached and should be ignored: + let mut visited_tracks: HashSet<&TrackEndpoint> = HashSet::new(); + + // Neighbors of the explored tracks, i.e. the tracks that should be visited next: + let mut next_tracks: Vec<(&TrackEndpoint, f64)> = Vec::new(); + let remaining_distance = + max_distance - (related_track_ranges[0].end - related_track_ranges[0].begin); + if 0. < remaining_distance { + let neighbours = graph + .get_all_neighbours(&first_track_endpoint) + .into_iter() + .map(|neighbour| (neighbour, remaining_distance)) + .collect::>(); + next_tracks.extend(neighbours); + } + + while let Some((curr_track_endpoint, remaining_distance)) = next_tracks.pop() { + let curr_track_id = &curr_track_endpoint.track; + + if !visited_tracks.insert(curr_track_endpoint) { + // Track already visited + continue; + } + + let track_length = infra_cache + .track_sections() + .get(&curr_track_endpoint.track.0) + .expect("Error while retrieving a track range from the infra cache") + .unwrap_track_section() + .length; + + // Check if there is an exit location on that track range + if let Some(exit) = + closest_exit_from_endpoint(curr_track_endpoint, exits.get(&curr_track_id)) + { + // End the search on that track, add the current track with the correct offset + let track_range = track_range_between_endpoint_and_location( + exit, + curr_track_endpoint, + track_length, + false, + ) + .expect("Failed to build track range"); + related_track_ranges.push(track_range); + } else { + let track_range = + track_range_from_endpoint(curr_track_endpoint, remaining_distance, track_length) + .expect("Failed to build track range"); + let neighbours_remaining_distance = + remaining_distance - (track_range.end - track_range.begin); + related_track_ranges.push(track_range); + if 0. < neighbours_remaining_distance { + let opposite_track_endpoint = TrackEndpoint { + endpoint: match curr_track_endpoint.endpoint { + Endpoint::Begin => Endpoint::End, + Endpoint::End => Endpoint::Begin, + }, + track: curr_track_endpoint.track.clone(), + }; + let neighbours = graph + .get_all_neighbours(&opposite_track_endpoint) + .into_iter() + .map(|neighbour| (neighbour, neighbours_remaining_distance)) + .collect::>(); + next_tracks.extend(neighbours); + } + } + } + related_track_ranges +} + +/// Return the closest exit that applies on a track from a starting endpoint. +/// To be applicable, an exit must be in the correct direction. +fn closest_exit_from_endpoint<'a>( + track_endpoint: &TrackEndpoint, + exits: Option<&'a Vec<&DirectedLocation>>, +) -> Option<&'a DirectedLocation> { + exits.map(|exits| { + exits + .iter() + .filter(|exit| exit.track == track_endpoint.track) + .filter(|exit| match track_endpoint.endpoint { + Endpoint::Begin => exit.direction == Direction::StartToStop, + Endpoint::End => exit.direction == Direction::StopToStart, + }) + .sorted_by( + |e_1, e_2| match (track_endpoint.endpoint, e_1.position < e_2.position) { + (Endpoint::Begin, true) | (Endpoint::End, false) => Ordering::Less, + (Endpoint::Begin, false) | (Endpoint::End, true) => Ordering::Greater, + }, + ) + .map(|exit| &**exit) + .next() + })? +} + +/// Return the closest applicable exit that is on the same track as the `entry`, or `None`. +/// if there is none. +fn closest_exit_from_entry<'a>( + entry: &DirectedLocation, + exits: Option<&'a Vec<&DirectedLocation>>, +) -> Option<&'a DirectedLocation> { + exits.map(|exits| { + exits + .iter() + .filter(|exit| exit.track == entry.track) + .filter(|exit| entry.direction == exit.direction) + .filter(|exit| match entry.direction { + Direction::StartToStop => entry.position < exit.position, + Direction::StopToStart => exit.position < entry.position, + }) + .sorted_by( + |e_1, e_2| match (entry.direction, e_1.position < e_2.position) { + (Direction::StartToStop, true) | (Direction::StopToStart, false) => { + Ordering::Less + } + (Direction::StartToStop, false) | (Direction::StopToStart, true) => { + Ordering::Greater + } + }, + ) + .map(|exit| &**exit) + .next() + })? +} + +/// Return the directional track range starting at `entry` finishing at `exit`, or an error +/// if no track range can be built from them. +fn track_range_between_two_locations( + entry: &DirectedLocation, + exit: &DirectedLocation, +) -> StdResult { + let exit_before_entry = match entry.direction { + Direction::StartToStop => exit.position < entry.position, + Direction::StopToStart => entry.position < exit.position, + }; + if entry.direction != exit.direction || exit_before_entry { + Err(TrackRangeConstructionError::InvalidRelativeLocations) + } else if entry.track != exit.track { + Err(TrackRangeConstructionError::TrackIdentifierMissmatch) + } else { + Ok(DirectionalTrackRange { + track: entry.track.clone(), + begin: f64::min(entry.position, exit.position), + end: f64::max(entry.position, exit.position), + direction: entry.direction, + }) + } +} + +/// Return the directional track range delimited by a location and a track endpoint. +/// Panics a valid track range on `infra_cache` cannot be built from `location` and `endpoint`. +fn track_range_between_endpoint_and_location( + location: &DirectedLocation, + endpoint: &TrackEndpoint, + track_length: f64, + entry: bool, +) -> StdResult { + let mut location_on_correct_direction = !matches!( + (location.direction, endpoint.endpoint), + (Direction::StartToStop, Endpoint::End) | (Direction::StopToStart, Endpoint::Begin) + ); + if entry { + location_on_correct_direction = !location_on_correct_direction; + } + + let same_track = location.track == endpoint.track; + let location_inside_track = 0. <= location.position && location.position <= track_length; + + if !location_on_correct_direction { + Err(TrackRangeConstructionError::InvalidRelativeLocations) + } else if !same_track { + Err(TrackRangeConstructionError::TrackIdentifierMissmatch) + } else if !location_inside_track { + Err(TrackRangeConstructionError::LocationNotOnTrack) + } else { + let (begin_offset, end_offset) = match endpoint.endpoint { + Endpoint::Begin => (0., location.position), + Endpoint::End => (location.position, track_length), + }; + let track_range = DirectionalTrackRange { + track: location.track.clone(), + begin: begin_offset, + end: end_offset, + direction: location.direction, + }; + Ok(track_range) + } +} + +/// Build a directional track range starting at `track_endpoint` and stopping at the end of the track +/// range if it is shorter than `remaining_distance`, or at `remaining_distance` from `track_endpoint` +/// otherwise. Returns: the built track range or `None` if the track does not exist in `infra_cache`. +fn track_range_from_endpoint( + track_endpoint: &TrackEndpoint, + remaining_distance: f64, + track_length: f64, +) -> StdResult { + let direction = match track_endpoint.endpoint { + Endpoint::Begin => Direction::StartToStop, + Endpoint::End => Direction::StopToStart, + }; + let track_range_length = if track_length < remaining_distance { + track_length + } else { + remaining_distance + }; + let (begin_offset, end_offset) = match direction { + Direction::StartToStop => (0., track_range_length), + Direction::StopToStart => (track_length - track_range_length, track_length), + }; + Ok(DirectionalTrackRange { + track: track_endpoint.track.clone(), + begin: begin_offset, + end: end_offset, + direction, + }) +} + +#[cfg(test)] +mod tests { + use crate::models::fixtures::create_small_infra; + use crate::models::Infra; + use crate::views::infra::delimited_area::DelimitedAreaResponse; + use crate::views::test_app::TestAppBuilder; + use axum::http::StatusCode; + use editoast_schemas::infra::{Direction, DirectionalTrackRange}; + use rstest::rstest; + use serde_json::json; + + use super::DirectedLocation; + + /// Create a temporary speed limit through with a given signal list and `small_infra` id through + /// the creation endpoint, then retrieve from the database the persisted track sections for that + /// speed limit. + async fn get_track_ranges_request( + entries: Vec, + exits: Vec, + ) -> Vec { + let app = TestAppBuilder::default_app(); + let pool = app.db_pool(); + let Infra { id: infra_id, .. } = create_small_infra(&mut pool.get_ok()).await; + let request = app + .get(&format!("/infra/{infra_id}/delimited_area")) + .json(&json!({ + "infra_id": infra_id, + "entries": entries, + "exits": exits, + } + )); + let DelimitedAreaResponse { track_ranges } = + app.fetch(request).assert_status(StatusCode::OK).json_into(); + track_ranges + } + + #[rstest] + async fn same_track_start_to_stop() { + let entries = vec![DirectedLocation { + track: "TH1".into(), + position: 100., + direction: Direction::StartToStop, + }]; + let exits = vec![DirectedLocation { + track: "TH1".into(), + position: 200., + direction: Direction::StartToStop, + }]; + let retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let expected_track_ranges = vec![DirectionalTrackRange { + track: "TH1".into(), + begin: 100., + end: 200., + direction: Direction::StartToStop, + }]; + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn same_track_stop_to_start() { + let entries = vec![DirectedLocation { + track: "TH1".into(), + position: 200., + direction: Direction::StopToStart, + }]; + let exits = vec![DirectedLocation { + track: "TH1".into(), + position: 100., + direction: Direction::StopToStart, + }]; + let retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let expected_track_ranges = vec![DirectionalTrackRange { + track: "TH1".into(), + begin: 100., + end: 200., + direction: Direction::StopToStart, + }]; + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn tunnel_on_two_tracks() { + let entries = vec![DirectedLocation { + track: "TF1".into(), + position: 100., + direction: Direction::StopToStart, + }]; + let exits = vec![DirectedLocation { + track: "TF0".into(), + position: 2., + direction: Direction::StopToStart, + }]; + let retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let expected_track_ranges = vec![ + DirectionalTrackRange { + track: "TF1".into(), + begin: 0., + end: 100., + direction: Direction::StopToStart, + }, + DirectionalTrackRange { + track: "TF0".into(), + begin: 2., + end: 3., + direction: Direction::StopToStart, + }, + ]; + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn both_point_switch_directions_get_explored() { + let entries = vec![DirectedLocation { + track: "TG1".into(), + position: 100., + direction: Direction::StartToStop, + }]; + let exits = vec![ + DirectedLocation { + track: "TG3".into(), + position: 50., + direction: Direction::StartToStop, + }, + DirectedLocation { + track: "TG4".into(), + position: 150., + direction: Direction::StartToStop, + }, + ]; + let mut retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let mut expected_track_ranges = vec![ + DirectionalTrackRange { + track: "TG1".into(), + begin: 100., + end: 4000., + direction: Direction::StartToStop, + }, + DirectionalTrackRange { + track: "TG3".into(), + begin: 0., + end: 50., + direction: Direction::StartToStop, + }, + DirectionalTrackRange { + track: "TG4".into(), + begin: 0., + end: 150., + direction: Direction::StartToStop, + }, + ]; + expected_track_ranges.sort_by(|lhs, rhs| lhs.track.0.cmp(&rhs.track.0)); + retrieved_track_ranges.sort_by(|lhs, rhs| lhs.track.0.cmp(&rhs.track.0)); + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn multiple_isolated_entry_signals() { + let entries = vec![ + DirectedLocation { + track: "TF1".into(), + position: 100., + direction: Direction::StopToStart, + }, + DirectedLocation { + track: "TG1".into(), + position: 100., + direction: Direction::StartToStop, + }, + ]; + let exits = vec![ + DirectedLocation { + track: "TF0".into(), + position: 2., + direction: Direction::StopToStart, + }, + DirectedLocation { + track: "TG3".into(), + position: 50., + direction: Direction::StartToStop, + }, + DirectedLocation { + track: "TG4".into(), + position: 150., + direction: Direction::StartToStop, + }, + ]; + let mut retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let mut expected_track_ranges = vec![ + DirectionalTrackRange { + track: "TF1".into(), + begin: 0., + end: 100., + direction: Direction::StopToStart, + }, + DirectionalTrackRange { + track: "TF0".into(), + begin: 2., + end: 3., + direction: Direction::StopToStart, + }, + DirectionalTrackRange { + track: "TG1".into(), + begin: 100., + end: 4000., + direction: Direction::StartToStop, + }, + DirectionalTrackRange { + track: "TG3".into(), + begin: 0., + end: 50., + direction: Direction::StartToStop, + }, + DirectionalTrackRange { + track: "TG4".into(), + begin: 0., + end: 150., + direction: Direction::StartToStop, + }, + ]; + expected_track_ranges.sort_by(|lhs, rhs| lhs.track.0.cmp(&rhs.track.0)); + retrieved_track_ranges.sort_by(|lhs, rhs| lhs.track.0.cmp(&rhs.track.0)); + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn signals_facing_opposite_direction_are_ignored() { + let entries = vec![DirectedLocation { + track: "TF1".into(), + position: 100., + direction: Direction::StopToStart, + }]; + let exits = vec![ + DirectedLocation { + track: "TF0".into(), + position: 2., + direction: Direction::StartToStop, + }, + DirectedLocation { + track: "TF0".into(), + position: 1., + direction: Direction::StopToStart, + }, + ]; + let retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let expected_track_ranges = vec![ + DirectionalTrackRange { + track: "TF1".into(), + begin: 0., + end: 100., + direction: Direction::StopToStart, + }, + DirectionalTrackRange { + track: "TF0".into(), + begin: 1., + end: 3., + direction: Direction::StopToStart, + }, + ]; + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn track_range_is_built_from_the_closest_exit() { + let entries = vec![DirectedLocation { + track: "TF1".into(), + position: 100., + direction: Direction::StopToStart, + }]; + let exits = vec![ + DirectedLocation { + track: "TF0".into(), + position: 2., + direction: Direction::StopToStart, + }, + DirectedLocation { + track: "TF0".into(), + position: 1., + direction: Direction::StopToStart, + }, + ]; + let retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let expected_track_ranges = vec![ + DirectionalTrackRange { + track: "TF1".into(), + begin: 0., + end: 100., + direction: Direction::StopToStart, + }, + DirectionalTrackRange { + track: "TF0".into(), + begin: 2., + end: 3., + direction: Direction::StopToStart, + }, + ]; + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn exit_before_entry_is_ignored() { + // The graph exploration should not stop if there is an exit signal on the same track + // as the entry signal when the exit signal is behind the entry signal. + let entries = vec![DirectedLocation { + track: "TF1".into(), + position: 100., + direction: Direction::StopToStart, + }]; + let exits = vec![ + DirectedLocation { + track: "TF1".into(), + position: 150., + direction: Direction::StopToStart, + }, + DirectedLocation { + track: "TF0".into(), + position: 2., + direction: Direction::StopToStart, + }, + ]; + let retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let expected_track_ranges = vec![ + DirectionalTrackRange { + track: "TF1".into(), + begin: 0., + end: 100., + direction: Direction::StopToStart, + }, + DirectionalTrackRange { + track: "TF0".into(), + begin: 2., + end: 3., + direction: Direction::StopToStart, + }, + ]; + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn closest_exit_ignores_exits_before_entry() { + // If the LTV is a single track range, it should ignore the signals behind it when + // checking which one is the closest. + let entries = vec![DirectedLocation { + track: "TF1".into(), + position: 400., + direction: Direction::StopToStart, + }]; + let exits = vec![ + DirectedLocation { + track: "TF1".into(), + position: 500., + direction: Direction::StopToStart, + }, + DirectedLocation { + track: "TF1".into(), + position: 100., + direction: Direction::StopToStart, + }, + ]; + let retrieved_track_ranges = get_track_ranges_request(entries, exits).await; + let expected_track_ranges = vec![DirectionalTrackRange { + track: "TF1".into(), + begin: 100., + end: 400., + direction: Direction::StopToStart, + }]; + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + async fn exploration_stops_when_resume_signal_is_missing_and_maximum_distance_is_reached() { + let entries = vec![DirectedLocation { + track: "TE0".into(), + position: 500., + direction: Direction::StartToStop, + }]; + let mut retrieved_track_ranges = get_track_ranges_request(entries, vec![]).await; + let mut expected_track_ranges = vec![ + DirectionalTrackRange { + track: "TE0".into(), + begin: 500., + end: 1500., + direction: Direction::StartToStop, + }, + DirectionalTrackRange { + track: "TF0".into(), + begin: 0., + end: 3., + direction: Direction::StartToStop, + }, + DirectionalTrackRange { + track: "TF1".into(), + begin: 0., + end: 3997., + direction: Direction::StartToStop, + }, + ]; + expected_track_ranges.sort_by(|lhs, rhs| lhs.track.0.cmp(&rhs.track.0)); + retrieved_track_ranges.sort_by(|lhs, rhs| lhs.track.0.cmp(&rhs.track.0)); + assert_eq!(expected_track_ranges, retrieved_track_ranges); + } + + #[rstest] + #[ignore] + async fn track_section_can_be_explored_in_both_directions() { + // TODO find a way to test it on small_infra or make a specific infra for this test + todo!() + } + + #[rstest] + #[ignore] + async fn adjacent_track_ranges_are_merged() { + // If two directional track ranges are adjacent and have the same direction, + // they should be merged into a single bigger directional track range. + // N.B. This is mostly a performance issue. + unimplemented!(); + } + + #[rstest] + #[ignore] + async fn request_with_invalid_locations_is_rejected() { + // Invalid locations (invalid track number, location position not on the track...) + // get rejected with a 400 error code and the response contains context about + // which locations were invalid and how they were invalid. + todo!() + } +} diff --git a/editoast/src/views/infra/mod.rs b/editoast/src/views/infra/mod.rs index 0e12f47ab9b..aaae2636112 100644 --- a/editoast/src/views/infra/mod.rs +++ b/editoast/src/views/infra/mod.rs @@ -1,5 +1,6 @@ mod attached; mod auto_fixes; +mod delimited_area; mod edition; mod errors; mod lines; @@ -60,6 +61,7 @@ crate::routes! { &attached, &edition, &errors, + &delimited_area, get, "/load" => load, @@ -77,6 +79,7 @@ crate::routes! { editoast_common::schemas! { pathfinding::schemas(), + delimited_area::schemas(), InfraState, InfraWithState, } diff --git a/editoast/src/views/stdcm_search_environment.rs b/editoast/src/views/stdcm_search_environment.rs index f25ced7549b..f60c082b852 100644 --- a/editoast/src/views/stdcm_search_environment.rs +++ b/editoast/src/views/stdcm_search_environment.rs @@ -40,6 +40,7 @@ struct StdcmSearchEnvironmentCreateForm { infra_id: i64, electrical_profile_set_id: Option, work_schedule_group_id: Option, + temporary_speed_limit_group_id: Option, timetable_id: i64, search_window_begin: NaiveDateTime, // TODO: move to DateTime search_window_end: NaiveDateTime, @@ -56,6 +57,7 @@ impl<'de> Deserialize<'de> for StdcmSearchEnvironmentCreateForm { infra_id: i64, electrical_profile_set_id: Option, work_schedule_group_id: Option, + temporary_speed_limit_group_id: Option, timetable_id: i64, search_window_begin: NaiveDateTime, search_window_end: NaiveDateTime, @@ -74,6 +76,7 @@ impl<'de> Deserialize<'de> for StdcmSearchEnvironmentCreateForm { infra_id: internal.infra_id, electrical_profile_set_id: internal.electrical_profile_set_id, work_schedule_group_id: internal.work_schedule_group_id, + temporary_speed_limit_group_id: internal.temporary_speed_limit_group_id, timetable_id: internal.timetable_id, search_window_begin: internal.search_window_begin, search_window_end: internal.search_window_end, @@ -87,6 +90,7 @@ impl From for Changeset Deserialize<'de> for TemporarySpeedLimitItemForm { obj_id, } = Internal::deserialize(deserializer)?; - // Check dates + // Validation checks + if end_date_time <= start_date_time { return Err(SerdeError::custom(format!( "The temporary_speed_limit start date '{}' must be before the end date '{}'", @@ -172,9 +173,13 @@ async fn create_temporary_speed_limit_group( #[cfg(test)] mod tests { use crate::{ - models::temporary_speed_limits::TemporarySpeedLimitGroup, List, Retrieve, SelectionSettings, + models::temporary_speed_limits::TemporarySpeedLimitGroup, views::test_app::TestApp, List, + Retrieve, SelectionSettings, }; use axum::http::StatusCode; + use axum_test::TestRequest; + use chrono::{Duration, NaiveDateTime, Utc}; + use editoast_schemas::infra::{Direction, DirectionalTrackRange}; use rstest::rstest; use serde_json::json; use uuid::Uuid; @@ -186,6 +191,94 @@ mod tests { }, }; + struct TimePeriod { + start_date_time: NaiveDateTime, + end_date_time: NaiveDateTime, + } + + impl TestApp { + fn create_temporary_speed_limit_group_request( + &self, + RequestParameters { + group_name, + obj_id, + time_period: + TimePeriod { + start_date_time, + end_date_time, + }, + track_ranges, + }: RequestParameters, + ) -> TestRequest { + self.post("/temporary_speed_limit_group").json(&json!( + { + "speed_limit_group_name": group_name, + "speed_limits": [ { + "start_date_time": start_date_time, + "end_date_time": end_date_time, + "track_ranges": track_ranges, + "speed_limit": 80., + "obj_id": obj_id, + }] + } + )) + } + } + + struct RequestParameters { + group_name: String, + obj_id: String, + time_period: TimePeriod, + track_ranges: Vec, + } + + impl RequestParameters { + fn new() -> Self { + RequestParameters { + group_name: Uuid::new_v4().to_string(), + obj_id: Uuid::new_v4().to_string(), + time_period: TimePeriod { + start_date_time: Utc::now().naive_utc(), + end_date_time: Utc::now().naive_utc() + Duration::days(1), + }, + track_ranges: vec![ + DirectionalTrackRange { + track: "TA0".into(), + begin: 0., + end: 2000., + direction: Direction::StartToStop, + }, + DirectionalTrackRange { + track: "TA1".into(), + begin: 0., + end: 1950., + direction: Direction::StartToStop, + }, + ], + } + } + + fn with_group_name(mut self, group_name: String) -> Self { + self.group_name = group_name; + self + } + + fn with_obj_id(mut self, obj_id: String) -> Self { + self.obj_id = obj_id; + self + } + + fn with_time_period(mut self, time_period: TimePeriod) -> Self { + self.time_period = time_period; + self + } + + fn with_track_ranges(mut self, track_ranges: Vec) -> Self { + self.track_ranges = track_ranges; + self + } + } + #[rstest] async fn create_temporary_speed_limits_succeeds() { let app = TestAppBuilder::default_app(); @@ -193,26 +286,17 @@ mod tests { let group_name = Uuid::new_v4().to_string(); let request_obj_id = Uuid::new_v4().to_string(); - let request_speed_limit = 80.; - let request = app.post("/temporary_speed_limit_group").json(&json!( - { - "speed_limit_group_name": group_name, - "speed_limits": [ - { - "start_date_time": "2024-01-01T08:00:00", - "end_date_time": "2024-01-01T09:00:00", - "track_ranges": [], - "speed_limit": request_speed_limit, - "obj_id": request_obj_id, - }] - } - )); + + let request = app.create_temporary_speed_limit_group_request( + RequestParameters::new() + .with_group_name(group_name.clone()) + .with_obj_id(request_obj_id.clone()), + ); // Speed limit group checks let TemporarySpeedLimitCreateResponse { group_id } = app.fetch(request).assert_status(StatusCode::OK).json_into(); - let created_group = TemporarySpeedLimitGroup::retrieve(&mut pool.get_ok(), group_id) .await .expect("Failed to retrieve the created temporary speed limit group") @@ -239,52 +323,17 @@ mod tests { let app = TestAppBuilder::default_app(); let group_name = Uuid::new_v4().to_string(); - let request = app.post("/temporary_speed_limit_group").json(&json!( - { - "speed_limit_group_name": group_name, - "speed_limits": [ - { - "start_date_time": "2024-01-01T08:00:00", - "end_date_time": "2024-01-01T09:00:00", - "track_ranges": [], - "speed_limit": 80, - "obj_id": Uuid::new_v4().to_string(), - }] - } - )); - + let request = app.create_temporary_speed_limit_group_request( + RequestParameters::new().with_group_name(group_name.clone()), + ); let _ = app.fetch(request).assert_status(StatusCode::OK); - let request = app.post("/temporary_speed_limit_group").json(&json!( - { - "speed_limit_group_name": Uuid::new_v4().to_string(), - "speed_limits": [ - { - "start_date_time": "2024-01-01T08:00:00", - "end_date_time": "2024-01-01T09:00:00", - "track_ranges": [], - "speed_limit": 80, - "obj_id": Uuid::new_v4().to_string(), - }] - } - )); - + let request = app.create_temporary_speed_limit_group_request(RequestParameters::new()); let _ = app.fetch(request).assert_status(StatusCode::OK); - let request = app.post("/temporary_speed_limit_group").json(&json!( - { - "speed_limit_group_name": group_name, - "speed_limits": [ - { - "start_date_time": "2024-01-01T08:00:00", - "end_date_time": "2024-01-01T09:00:00", - "track_ranges": [], - "speed_limit": 80, - "obj_id": Uuid::new_v4().to_string(), - }] - } - )); - + let request = app.create_temporary_speed_limit_group_request( + RequestParameters::new().with_group_name(group_name.clone()), + ); let _ = app.fetch(request).assert_status(StatusCode::BAD_REQUEST); } @@ -292,19 +341,28 @@ mod tests { async fn create_ltv_with_invalid_invalid_time_period_fails() { let app = TestAppBuilder::default_app(); - let request = app.post("/temporary_speed_limit_group").json(&json!( - { - "speed_limit_group_name": Uuid::new_v4().to_string(), - "speed_limits": [ - { - "start_date_time": "2024-01-01T08:00:00", - "end_date_time": "2023-01-01T09:00:00", - "track_ranges": [], - "speed_limit": 80, - "obj_id": Uuid::new_v4().to_string(), - }, - ]} - )); + let time_period = TimePeriod { + start_date_time: Utc::now().naive_utc() + Duration::days(1), + end_date_time: Utc::now().naive_utc(), + }; + + let request = app.create_temporary_speed_limit_group_request( + RequestParameters::new().with_time_period(time_period), + ); + + let _ = app + .fetch(request) + .assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[rstest] + #[ignore] // TODO is this something we want to enforce ? + async fn create_ltv_with_no_tracks_fails() { + let app = TestAppBuilder::default_app(); + + let request = app.create_temporary_speed_limit_group_request( + RequestParameters::new().with_track_ranges(vec![]), + ); let _ = app .fetch(request) diff --git a/front/public/locales/en/errors.json b/front/public/locales/en/errors.json index 3e9835e3139..0df6200f511 100644 --- a/front/public/locales/en/errors.json +++ b/front/public/locales/en/errors.json @@ -51,6 +51,9 @@ "PoolChannelFail": "Core: message queue: channel failure" }, "DatabaseAccessError": "Database access fatal error", + "delimited_area": { + "InvalidLocations": "Some locations are invalid" + }, "document": { "NotFound": "Document '{{document_key}}' not found" }, diff --git a/front/public/locales/fr/errors.json b/front/public/locales/fr/errors.json index b3c92756092..2316ef93e8d 100644 --- a/front/public/locales/fr/errors.json +++ b/front/public/locales/fr/errors.json @@ -50,6 +50,9 @@ "ConnectionDoesNotExist": "Core: file d'attente de messages: connexion non établie", "PoolChannelFail": "Core: file d'attente de messages: erreur de pool des channels rabbitmq" }, + "delimited_area": { + "InvalidLocations": "Certaines localisations sont invalides" + }, "document": { "NotFound": "Document '{{document_key}}' non trouvé" }, diff --git a/front/src/applications/stdcm/hooks/useStdcmEnv.tsx b/front/src/applications/stdcm/hooks/useStdcmEnv.tsx index d43169c424b..01495c98cad 100644 --- a/front/src/applications/stdcm/hooks/useStdcmEnv.tsx +++ b/front/src/applications/stdcm/hooks/useStdcmEnv.tsx @@ -29,6 +29,7 @@ export default function useStdcmEnvironment() { timetableID: data.timetable_id, electricalProfileSetId: data.electrical_profile_set_id, workScheduleGroupId: data.work_schedule_group_id, + temporarySpeedLimitGroupId: data.temporary_speed_limit_group_id, searchDatetimeWindow: { begin: new Date(data.search_window_begin), end: new Date(data.search_window_end), diff --git a/front/src/applications/stdcm/utils/formatStdcmConf.ts b/front/src/applications/stdcm/utils/formatStdcmConf.ts index c2013c860aa..d81e4512345 100644 --- a/front/src/applications/stdcm/utils/formatStdcmConf.ts +++ b/front/src/applications/stdcm/utils/formatStdcmConf.ts @@ -31,6 +31,7 @@ type ValidStdcmConfig = { gridMarginBefore?: number; gridMarginAfter?: number; workScheduleGroupId?: number; + temporarySpeedLimitGroupId?: number; electricalProfileSetId?: number; }; @@ -51,6 +52,7 @@ export const checkStdcmConf = ( gridMarginAfter, searchDatetimeWindow, workScheduleGroupId, + temporarySpeedLimitGroupId, electricalProfileSetId, totalLength, totalMass, @@ -195,6 +197,7 @@ export const checkStdcmConf = ( gridMarginBefore, gridMarginAfter, workScheduleGroupId, + temporarySpeedLimitGroupId, electricalProfileSetId, }; }; @@ -219,6 +222,7 @@ export const formatStdcmPayload = ( time_gap_after: toMsOrUndefined(validConfig.gridMarginBefore), time_gap_before: toMsOrUndefined(validConfig.gridMarginAfter), work_schedule_group_id: validConfig.workScheduleGroupId, + temporary_speed_limit_group_id: validConfig.temporarySpeedLimitGroupId, electrical_profile_set_id: validConfig.electricalProfileSetId, }, }); diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index 3cd4cd0ec64..976b0d0a657 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -6,6 +6,7 @@ export const addTagTypes = [ 'electrical_profiles', 'infra', 'rolling_stock', + 'delimited_area', 'pathfinding', 'routes', 'layers', @@ -219,6 +220,16 @@ const injectedRtkApi = api }), invalidatesTags: ['infra'], }), + getInfraByInfraIdDelimitedArea: build.query< + GetInfraByInfraIdDelimitedAreaApiResponse, + GetInfraByInfraIdDelimitedAreaApiArg + >({ + query: (queryArg) => ({ + url: `/infra/${queryArg.infraId}/delimited_area`, + body: queryArg.body, + }), + providesTags: ['delimited_area'], + }), getInfraByInfraIdErrors: build.query< GetInfraByInfraIdErrorsApiResponse, GetInfraByInfraIdErrorsApiArg @@ -1083,6 +1094,17 @@ export type PostInfraByInfraIdCloneApiArg = { /** The name of the new infra */ name: string; }; +export type GetInfraByInfraIdDelimitedAreaApiResponse = + /** status 200 The track ranges between a list entries and exits. */ { + track_ranges: DirectionalTrackRange[]; + }; +export type GetInfraByInfraIdDelimitedAreaApiArg = { + /** An existing infra ID */ + infraId: number; + body: { + track_ranges: DirectionalTrackRange[]; + }; +}; export type GetInfraByInfraIdErrorsApiResponse = /** status 200 A paginated list of errors */ PaginationStats & { results: { @@ -3026,6 +3048,7 @@ export type StdcmSearchEnvironment = { infra_id: number; search_window_begin: string; search_window_end: string; + temporary_speed_limit_group_id?: number; timetable_id: number; work_schedule_group_id?: number; }; @@ -3034,6 +3057,7 @@ export type StdcmSearchEnvironmentCreateForm = { infra_id: number; search_window_begin: string; search_window_end: string; + temporary_speed_limit_group_id?: number | null; timetable_id: number; work_schedule_group_id?: number | null; }; diff --git a/front/src/reducers/osrdconf/stdcmConf/index.ts b/front/src/reducers/osrdconf/stdcmConf/index.ts index 03d3605081a..44280701968 100644 --- a/front/src/reducers/osrdconf/stdcmConf/index.ts +++ b/front/src/reducers/osrdconf/stdcmConf/index.ts @@ -68,6 +68,7 @@ export const stdcmConfSlice = createSlice({ | 'timetableID' | 'electricalProfileSetId' | 'workScheduleGroupId' + | 'temporarySpeedLimitGroupId' | 'searchDatetimeWindow' > > @@ -79,6 +80,9 @@ export const stdcmConfSlice = createSlice({ if (action.payload.workScheduleGroupId) { state.workScheduleGroupId = action.payload.workScheduleGroupId; } + if (action.payload.temporarySpeedLimitGroupId) { + state.temporarySpeedLimitGroupId = action.payload.temporarySpeedLimitGroupId; + } }, updateOriginArrival( state: Draft, diff --git a/front/src/reducers/osrdconf/types.ts b/front/src/reducers/osrdconf/types.ts index aed1d2783be..a18177a8fef 100644 --- a/front/src/reducers/osrdconf/types.ts +++ b/front/src/reducers/osrdconf/types.ts @@ -25,6 +25,7 @@ export interface OsrdConfState extends InfraState { timetableID?: number; electricalProfileSetId?: number; workScheduleGroupId?: number; + temporarySpeedLimitGroupId?: number; searchDatetimeWindow?: { begin: Date; end: Date }; rollingStockID?: number; speedLimitByTag?: string;