diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 4f41693fd52..d5aadba7a2a 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -5139,7 +5139,8 @@ components: - $ref: '#/components/schemas/EditoastOperationErrorObjectNotFound' - $ref: '#/components/schemas/EditoastPacedTrainErrorBatchPacedTrainNotFound' - $ref: '#/components/schemas/EditoastPacedTrainErrorDatabase' - - $ref: '#/components/schemas/EditoastPacedTrainErrorPacedTrainNotFound' + - $ref: '#/components/schemas/EditoastPacedTrainErrorInfraNotFound' + - $ref: '#/components/schemas/EditoastPacedTrainErrorNotFound' - $ref: '#/components/schemas/EditoastPaginationErrorInvalidPage' - $ref: '#/components/schemas/EditoastPaginationErrorInvalidPageSize' - $ref: '#/components/schemas/EditoastPathfindingErrorInfraNotFound' @@ -5677,7 +5678,31 @@ components: type: string enum: - editoast:paced_train:Database - EditoastPacedTrainErrorPacedTrainNotFound: + 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 @@ -5700,7 +5725,7 @@ components: type: type: string enum: - - editoast:paced_train:PacedTrainNotFound + - editoast:paced_train:NotFound EditoastPaginationErrorInvalidPage: type: object required: @@ -8567,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 014e766443a..b742a980573 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 9d8badaa9c9..17674794bc3 100644 --- a/editoast/src/views/paced_train.rs +++ b/editoast/src/views/paced_train.rs @@ -1,6 +1,13 @@ use std::collections::HashMap; 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; use axum::extract::Path; use axum::extract::Query; @@ -10,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; @@ -17,17 +25,14 @@ 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; use super::SimulationSummaryResult; -use crate::core::simulation::SimulationResponse; -use crate::error::Result; use crate::models::paced_train::PacedTrain; -use crate::models::prelude::*; -use crate::views::projection::ProjectPathTrainResult; use crate::views::AuthorizationError; -use crate::views::ListId; crate::routes! { "/paced_train" => { @@ -55,9 +60,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)] - #[error("Paced train {paced_train_id} does not exist")] - PacedTrainNotFound { paced_train_id: i64 }, + 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), @@ -65,7 +73,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, @@ -119,7 +127,7 @@ async fn get_by_id( let conn = &mut db_pool.get().await?; let paced_train = PacedTrain::retrieve_or_fail(conn, paced_train_id, || { - PacedTrainError::PacedTrainNotFound { paced_train_id } + PacedTrainError::NotFound { paced_train_id } }) .await?; @@ -200,19 +208,62 @@ 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 @@ -260,23 +311,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 @@ -313,19 +394,28 @@ async fn project_path( #[cfg(test)] mod tests { - use axum::http::StatusCode; - use pretty_assertions::assert_eq; + 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::error::InternalError; - use crate::models::fixtures::create_simple_paced_train; - use crate::models::fixtures::create_timetable; - use crate::models::fixtures::simple_paced_train_base; - use crate::models::paced_train::PacedTrain; + use crate::models::fixtures::create_fast_rolling_stock; + use crate::models::fixtures::create_small_infra; + use crate::models::paced_train::PacedTrainChangeset; use crate::models::prelude::*; - use crate::views::paced_train::PacedTrainResult; - use crate::views::test_app::TestAppBuilder; + use crate::views::test_app::TestApp; + use crate::views::train_schedule::tests::mocked_core_pathfinding_sim_and_proj; + use crate::views::InternalError; + use crate::{ + models::{ + fixtures::{create_simple_paced_train, create_timetable, simple_paced_train_base}, + paced_train::PacedTrain, + }, + views::{paced_train::PacedTrainResult, test_app::TestAppBuilder}, + }; + use axum::http::StatusCode; #[rstest] async fn paced_train_post() { @@ -380,7 +470,6 @@ mod tests { "editoast:paced_train:PacedTrainNotFound" ) } - #[rstest] async fn get_paced_train() { let app = TestAppBuilder::default_app(); @@ -404,4 +493,60 @@ mod tests { ); assert_eq!(paced_train.step, response.paced_train.paced.step.into()); } + + 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 timetable = create_timetable(&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 + .timetable_id(timetable.id) + .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); + } + + #[rstest] + async fn paced_train_simulation_summary() { + let (app, infra_id, paced_train_id) = + app_infra_id_paced_train_id_for_simulation_tests().await; + let request = app.post("/paced_train/simulation_summary").json(&json!({ + "infra_id": infra_id, + "ids": vec![paced_train_id], + })); + app.fetch(request).assert_status(StatusCode::OK); + } } diff --git a/editoast/src/views/timetable.rs b/editoast/src/views/timetable.rs index 2901a5bee5b..d5bcd81aa2e 100644 --- a/editoast/src/views/timetable.rs +++ b/editoast/src/views/timetable.rs @@ -313,7 +313,7 @@ async fn post_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..94c271ec92d 100644 --- a/editoast/src/views/train_schedule.rs +++ b/editoast/src/views/train_schedule.rs @@ -636,6 +636,45 @@ 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 +735,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 +1005,7 @@ async fn project_path( } #[cfg(test)] -mod tests { +pub mod tests { use axum::http::StatusCode; use chrono::DateTime; use chrono::Utc; @@ -1112,7 +1114,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 55427b17ba4..dfbfabc822f 100644 --- a/front/public/locales/en/errors.json +++ b/front/public/locales/en/errors.json @@ -241,8 +241,9 @@ }, "paced_train": { "Database": "Internal error (database)", - "BatchPacedTrainNotFound": "Some Paced trains could not be found", - "PacedTrainNotFound": "Paced train '{{paced_train_id}}' not 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 6e5cbc03b48..6b9db0d4600 100644 --- a/front/public/locales/fr/errors.json +++ b/front/public/locales/fr/errors.json @@ -238,8 +238,9 @@ }, "paced_train": { "Database": "Erreur interne (base de données)", - "BatchPacedTrainNotFound": "Certaines missions sont introuvables", - "PacedTrainNotFound": "Mission '{{paced_train_id}}' non trouvée" + "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 cb0a77fea7b..1e14be1e96e 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 = {