From 6d14fda5122d9a4dce5c9ed15bbda31a6fbb9756 Mon Sep 17 00:00:00 2001 From: Youness CHRIFI ALAOUI Date: Tue, 4 Mar 2025 11:05:13 +0100 Subject: [PATCH] editoast: add simulation summary and simulation endpoints for paced train Signed-off-by: Youness CHRIFI ALAOUI --- editoast/openapi.yaml | 52 +++++- editoast/src/models/paced_train.rs | 23 +++ editoast/src/views/mod.rs | 2 +- editoast/src/views/paced_train.rs | 177 ++++++++++++++++--- editoast/src/views/timetable.rs | 2 +- editoast/src/views/train_schedule.rs | 84 ++++----- front/public/locales/en/errors.json | 4 +- front/public/locales/fr/errors.json | 4 +- front/src/common/api/generatedEditoastApi.ts | 2 +- 9 files changed, 283 insertions(+), 67 deletions(-) diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 4299b4800a6..1fede5c3a4d 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -5139,6 +5139,8 @@ components: - $ref: '#/components/schemas/EditoastOperationErrorObjectNotFound' - $ref: '#/components/schemas/EditoastPacedTrainErrorBatchPacedTrainNotFound' - $ref: '#/components/schemas/EditoastPacedTrainErrorDatabase' + - $ref: '#/components/schemas/EditoastPacedTrainErrorInfraNotFound' + - $ref: '#/components/schemas/EditoastPacedTrainErrorNotFound' - $ref: '#/components/schemas/EditoastPaginationErrorInvalidPage' - $ref: '#/components/schemas/EditoastPaginationErrorInvalidPageSize' - $ref: '#/components/schemas/EditoastPathfindingErrorInfraNotFound' @@ -5676,6 +5678,54 @@ components: type: string enum: - editoast:paced_train:Database + EditoastPacedTrainErrorInfraNotFound: + type: object + required: + - type + - status + - message + properties: + context: + type: object + required: + - infra_id + properties: + infra_id: + type: integer + message: + type: string + status: + type: integer + enum: + - 404 + type: + type: string + enum: + - editoast:paced_train:InfraNotFound + EditoastPacedTrainErrorNotFound: + type: object + required: + - type + - status + - message + properties: + context: + type: object + required: + - paced_train_id + properties: + paced_train_id: + type: integer + message: + type: string + status: + type: integer + enum: + - 404 + type: + type: string + enum: + - editoast:paced_train:NotFound EditoastPaginationErrorInvalidPage: type: object required: @@ -8542,7 +8592,7 @@ components: timetable_id: type: integer format: int64 - description: Timetable attached to the train schedule + description: Timetable attached to the paced train nullable: true PacedTrainResult: allOf: diff --git a/editoast/src/models/paced_train.rs b/editoast/src/models/paced_train.rs index 0cf7b591d2b..f2e6023c7d6 100644 --- a/editoast/src/models/paced_train.rs +++ b/editoast/src/models/paced_train.rs @@ -1,3 +1,4 @@ +use crate::models::train_schedule::TrainSchedule; use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Utc; @@ -101,3 +102,25 @@ impl From for PacedTrainBase { } } } + +impl From for TrainSchedule { + fn from(paced_train: PacedTrain) -> Self { + Self { + id: paced_train.id, + train_name: paced_train.train_name, + labels: paced_train.labels.into(), + rolling_stock_name: paced_train.rolling_stock_name, + timetable_id: paced_train.timetable_id, + path: paced_train.path, + start_time: paced_train.start_time, + schedule: paced_train.schedule, + margins: paced_train.margins, + initial_speed: paced_train.initial_speed, + comfort: paced_train.comfort, + constraint_distribution: paced_train.constraint_distribution, + speed_limit_tag: paced_train.speed_limit_tag, + power_restrictions: paced_train.power_restrictions, + options: paced_train.options, + } + } +} diff --git a/editoast/src/views/mod.rs b/editoast/src/views/mod.rs index 9a0151b4208..980afae21f0 100644 --- a/editoast/src/views/mod.rs +++ b/editoast/src/views/mod.rs @@ -172,7 +172,7 @@ pub struct InfraIdQueryParam { #[derive(Debug, Serialize, ToSchema)] #[serde(tag = "status", rename_all = "snake_case")] -enum SimulationSummaryResult { +pub enum SimulationSummaryResult { /// Minimal information on a simulation's result Success { /// Length of a path in mm diff --git a/editoast/src/views/paced_train.rs b/editoast/src/views/paced_train.rs index fd1c9103b41..cc8916447da 100644 --- a/editoast/src/views/paced_train.rs +++ b/editoast/src/views/paced_train.rs @@ -4,6 +4,8 @@ use std::collections::HashSet; use crate::core::simulation::SimulationResponse; use crate::error::Result; use crate::models::prelude::*; +use crate::models::train_schedule::TrainSchedule; +use crate::models::Infra; use crate::views::projection::ProjectPathTrainResult; use crate::views::ListId; use axum::extract::Json; @@ -15,6 +17,7 @@ use editoast_authz::BuiltinRole; use editoast_derive::EditoastError; use editoast_models::DbConnectionPoolV2; use editoast_schemas::paced_train::PacedTrainBase; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use thiserror::Error; use utoipa::IntoParams; @@ -22,6 +25,8 @@ use utoipa::ToSchema; use super::path::pathfinding::PathfindingResult; use super::projection::ProjectPathForm; +use super::train_schedule::simulation_response; +use super::train_schedule::train_simulation_batch; use super::AppState; use super::AuthenticationExt; use super::InfraIdQueryParam; @@ -54,6 +59,12 @@ enum PacedTrainError { #[error("{count} paced train(s) could not be found")] #[editoast_error(status = 404)] BatchPacedTrainNotFound { count: usize }, + #[error("Paced train '{paced_train_id}', could not be found")] + #[editoast_error(status = 404)] + NotFound { paced_train_id: i64 }, + #[error("Infra '{infra_id}', could not be found")] + #[editoast_error(status = 404)] + InfraNotFound { infra_id: i64 }, #[error(transparent)] #[editoast_error(status = 500)] Database(#[from] editoast_models::model::Error), @@ -61,7 +72,7 @@ enum PacedTrainError { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PacedTrainForm { - /// Timetable attached to the train schedule + /// Timetable attached to the paced train pub timetable_id: Option, #[serde(flatten)] pub paced_train_base: PacedTrainBase, @@ -181,19 +192,61 @@ struct SimulationBatchForm { )] async fn simulation_summary( State(AppState { - db_pool: _db_pool, - valkey: _valkey_client, - core_client: _core, + db_pool, + valkey: valkey_client, + core_client: core, .. }): State, - Extension(_auth): AuthenticationExt, + Extension(auth): AuthenticationExt, Json(SimulationBatchForm { - infra_id: _infra_id, - electrical_profile_set_id: _electrical_profile_set_id, - ids: _paced_train_ids, + infra_id, + electrical_profile_set_id, + ids: paced_train_ids, }): Json, ) -> Result>> { - todo!(); + let authorized = auth + .check_roles([BuiltinRole::InfraRead, BuiltinRole::TimetableRead].into()) + .await + .map_err(AuthorizationError::AuthError)?; + if !authorized { + return Err(AuthorizationError::Forbidden.into()); + } + + let conn = &mut db_pool.get().await?; + + let infra = Infra::retrieve_or_fail(conn, infra_id, || PacedTrainError::InfraNotFound { + infra_id, + }) + .await?; + + let paced_trains: Vec = + PacedTrain::retrieve_batch_or_fail(conn, paced_train_ids, |missing| { + PacedTrainError::BatchPacedTrainNotFound { + count: missing.len(), + } + }) + .await?; + let paced_trains_to_ts: Vec = paced_trains.clone().into_iter().map_into().collect(); + + let simulations = train_simulation_batch( + conn, + valkey_client, + core, + &paced_trains_to_ts, + &infra, + electrical_profile_set_id, + ) + .await?; + + // Transform simulations to simulation summary + let mut simulation_summaries = HashMap::new(); + for (paced_train, sim) in paced_trains.into_iter().zip(simulations) { + let (sim, _) = sim; + let simulation_summary_result = simulation_response(sim); + simulation_summaries.insert(paced_train.id, simulation_summary_result); + } + + Ok(Json(simulation_summaries)) } /// Get a path from a paced train given an infrastructure id and a paced train id @@ -241,23 +294,53 @@ pub struct ElectricalProfileSetIdQueryParam { )] async fn simulation( State(AppState { - valkey: _valkey_client, - core_client: _core_client, - db_pool: _db_pool, + valkey: valkey_client, + core_client, + db_pool, .. }): State, - Extension(_auth): AuthenticationExt, - Path(PacedTrainIdParam { - id: _paced_train_id, - }): Path, - Query(InfraIdQueryParam { - infra_id: _infra_id, - }): Query, + Extension(auth): AuthenticationExt, + Path(PacedTrainIdParam { id: paced_train_id }): Path, + Query(InfraIdQueryParam { infra_id }): Query, Query(ElectricalProfileSetIdQueryParam { - electrical_profile_set_id: _electrical_profile_set_id, + electrical_profile_set_id, }): Query, ) -> Result> { - todo!(); + let authorized = auth + .check_roles([BuiltinRole::InfraRead, BuiltinRole::TimetableRead].into()) + .await + .map_err(AuthorizationError::AuthError)?; + if !authorized { + return Err(AuthorizationError::Forbidden.into()); + } + + // Retrieve infra or fail + let infra = Infra::retrieve_or_fail(&mut db_pool.get().await?, infra_id, || { + PacedTrainError::InfraNotFound { infra_id } + }) + .await?; + + // Retrieve paced_train or fail + let paced_train = + PacedTrain::retrieve_or_fail(&mut db_pool.get().await?, paced_train_id, || { + PacedTrainError::NotFound { paced_train_id } + }) + .await?; + + // Compute simulation of a paced_train + let (simulation, _) = train_simulation_batch( + &mut db_pool.get().await?, + valkey_client, + core_client, + &[paced_train.into()], + &infra, + electrical_profile_set_id, + ) + .await? + .pop() + .unwrap(); + + Ok(Json(simulation)) } /// Projects the space time curves and paths of a number of paced trains onto a given path @@ -294,10 +377,17 @@ async fn project_path( #[cfg(test)] mod tests { + use chrono::Duration; + use editoast_models::DbConnectionPoolV2; + use editoast_schemas::paced_train::{Paced, PacedTrainBase}; + use editoast_schemas::train_schedule::TrainScheduleBase; use rstest::rstest; use serde_json::json; + use crate::models::paced_train::PacedTrainChangeset; use crate::models::prelude::*; + use crate::views::test_app::TestApp; + use crate::views::train_schedule::tests::mocked_core_pathfinding_sim_and_proj; use crate::{ models::{ fixtures::{create_simple_paced_train, create_timetable, simple_paced_train_base}, @@ -305,6 +395,8 @@ mod tests { }, views::{paced_train::PacedTrainResult, test_app::TestAppBuilder}, }; + use crate::models::fixtures::create_small_infra; + use crate::models::fixtures::create_fast_rolling_stock; use axum::http::StatusCode; #[rstest] @@ -344,4 +436,47 @@ mod tests { assert!(!exists); } + + async fn app_infra_id_paced_train_id_for_simulation_tests() -> (TestApp, i64, i64) { + let db_pool = DbConnectionPoolV2::for_tests(); + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "simulation_rolling_stock").await; + let paced_train_base: PacedTrainBase = PacedTrainBase { + train_schedule_base: TrainScheduleBase { + rolling_stock_name: rolling_stock.name.clone(), + ..serde_json::from_str(include_str!("../tests/train_schedules/simple.json")) + .expect("Unable to parse") + }, + paced: Paced { + duration: Duration::hours(1).try_into().unwrap(), + step: Duration::minutes(15).try_into().unwrap(), + }, + }; + let paced_train: PacedTrainChangeset = paced_train_base.into(); + let paced_train = paced_train + .create(&mut db_pool.get_ok()) + .await + .expect("Failed to create paced train"); + let core = mocked_core_pathfinding_sim_and_proj(paced_train.id); + let app = TestAppBuilder::new() + .db_pool(db_pool.clone()) + .core_client(core.into()) + .build(); + (app, small_infra.id, paced_train.id) + } + + #[rstest] + async fn paced_train_simulation() { + let (app, infra_id, train_schedule_id) = + app_infra_id_paced_train_id_for_simulation_tests().await; + let request = app.get( + format!( + "/paced_train/{}/simulation/?infra_id={}", + train_schedule_id, infra_id + ) + .as_str(), + ); + app.fetch(request).assert_status(StatusCode::OK); + } } diff --git a/editoast/src/views/timetable.rs b/editoast/src/views/timetable.rs index 8070a785728..7fba01635a9 100644 --- a/editoast/src/views/timetable.rs +++ b/editoast/src/views/timetable.rs @@ -310,7 +310,7 @@ async fn paced_train( let changesets = paced_trains .into_iter() .map(PacedTrainChangeset::from) - .map(|cs| cs.timetable_id(timetable_id)) + .map(|cs: PacedTrainChangeset| cs.timetable_id(timetable_id)) .collect::>(); // Create a batch of paced trains diff --git a/editoast/src/views/train_schedule.rs b/editoast/src/views/train_schedule.rs index 7f9bf55d1ba..51973be8d99 100644 --- a/editoast/src/views/train_schedule.rs +++ b/editoast/src/views/train_schedule.rs @@ -636,6 +636,47 @@ struct SimulationBatchForm { ids: HashSet, } +pub fn simulation_response(sim: SimulationResponse) -> SimulationSummaryResult { + match sim { + SimulationResponse::Success { + final_output, + provisional, + base, + .. + } => { + let report = final_output.report_train; + SimulationSummaryResult::Success { + length: *report.positions.last().unwrap(), + time: *report.times.last().unwrap(), + energy_consumption: report.energy_consumption, + path_item_times_final: report.path_item_times.clone(), + path_item_times_provisional: provisional.path_item_times.clone(), + path_item_times_base: base.path_item_times.clone(), + } + } + SimulationResponse::PathfindingFailed { pathfinding_failed } => { + match pathfinding_failed { + PathfindingFailure::InternalError { core_error } => { + SimulationSummaryResult::PathfindingFailure { core_error } + } + + PathfindingFailure::PathfindingInputError(input_error) => { + SimulationSummaryResult::PathfindingInputError(input_error) + } + + PathfindingFailure::PathfindingNotFound(not_found) => { + SimulationSummaryResult::PathfindingNotFound(not_found) + } + } + } + SimulationResponse::SimulationFailed { core_error } => { + SimulationSummaryResult::SimulationFailed { + error_type: core_error.get_type().into(), + } + } + } +} + /// Associate each train id with its simulation summary response /// If the simulation fails, it associates the reason: pathfinding failed or running time failed #[utoipa::path( @@ -696,44 +737,7 @@ async fn simulation_summary( let mut simulation_summaries = HashMap::new(); for (train_schedule, sim) in train_schedules.iter().zip(simulations) { let (sim, _) = sim; - let simulation_summary_result = match sim { - SimulationResponse::Success { - final_output, - provisional, - base, - .. - } => { - let report = final_output.report_train; - SimulationSummaryResult::Success { - length: *report.positions.last().unwrap(), - time: *report.times.last().unwrap(), - energy_consumption: report.energy_consumption, - path_item_times_final: report.path_item_times.clone(), - path_item_times_provisional: provisional.path_item_times.clone(), - path_item_times_base: base.path_item_times.clone(), - } - } - SimulationResponse::PathfindingFailed { pathfinding_failed } => { - match pathfinding_failed { - PathfindingFailure::InternalError { core_error } => { - SimulationSummaryResult::PathfindingFailure { core_error } - } - - PathfindingFailure::PathfindingInputError(input_error) => { - SimulationSummaryResult::PathfindingInputError(input_error) - } - - PathfindingFailure::PathfindingNotFound(not_found) => { - SimulationSummaryResult::PathfindingNotFound(not_found) - } - } - } - SimulationResponse::SimulationFailed { core_error } => { - SimulationSummaryResult::SimulationFailed { - error_type: core_error.get_type().into(), - } - } - }; + let simulation_summary_result = simulation_response(sim); simulation_summaries.insert(train_schedule.id, simulation_summary_result); } @@ -1003,7 +1007,7 @@ async fn project_path( } #[cfg(test)] -mod tests { +pub mod tests { use axum::http::StatusCode; use chrono::DateTime; use chrono::Utc; @@ -1112,7 +1116,7 @@ mod tests { ) } - fn mocked_core_pathfinding_sim_and_proj(train_id: i64) -> MockingClient { + pub fn mocked_core_pathfinding_sim_and_proj(train_id: i64) -> MockingClient { let mut core = MockingClient::new(); core.stub("/v2/pathfinding/blocks") .method(reqwest::Method::POST) diff --git a/front/public/locales/en/errors.json b/front/public/locales/en/errors.json index 2b131a1330a..dfbfabc822f 100644 --- a/front/public/locales/en/errors.json +++ b/front/public/locales/en/errors.json @@ -241,7 +241,9 @@ }, "paced_train": { "Database": "Internal error (database)", - "BatchPacedTrainNotFound": "Some Paced trains could not be found" + "BatchPacedTrainNotFound": "'{{count}}' paced trains could not be found", + "NotFound": "Paced train '{{paced_train_id}}' could not be found", + "InfraNotFound": "Infrastructure '{{infra_id}}' could not be found" }, "url": { "InvalidUrl": "Invalid url '{{url}}'" diff --git a/front/public/locales/fr/errors.json b/front/public/locales/fr/errors.json index aedc4618fd3..6b9db0d4600 100644 --- a/front/public/locales/fr/errors.json +++ b/front/public/locales/fr/errors.json @@ -238,7 +238,9 @@ }, "paced_train": { "Database": "Erreur interne (base de données)", - "BatchPacedTrainNotFound": "Certaines missions sont introuvables" + "BatchPacedTrainNotFound": "'{{count}}' missions sont introuvables", + "NotFound": "Mission '{{paced_train_id}}' non trouvée", + "InfraNotFound": "Infrastructure '{{infra_id}}' non trouvée" }, "url": { "InvalidUrl": "Url invalide '{{url}}'" diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index 696ebf0580e..b70186228ce 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -3270,7 +3270,7 @@ export type PacedTrainResult = PacedTrainBase & { timetable_id: number; }; export type PacedTrainForm = PacedTrainBase & { - /** Timetable attached to the train schedule */ + /** Timetable attached to the paced train */ timetable_id?: number | null; }; export type ReportTrain = {