Skip to content
Draft
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
47 changes: 47 additions & 0 deletions editoast/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion editoast/src/views/infra/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ pub(in crate::views) async fn unlock(
#[derive(Deserialize, ToSchema)]
#[cfg_attr(test, derive(Serialize))]
pub(in crate::views) struct MatchOperationalPointsForm {
operational_point_references: Vec<OperationalPointReference>,
pub operational_point_references: Vec<OperationalPointReference>,
}

#[derive(Serialize, ToSchema)]
Expand Down
3 changes: 3 additions & 0 deletions editoast/src/views/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ fn service_router() -> router::DocumentedRouter {
.nests("/train_schedules", |path| {
path.route("/", get!(timetable::get_train_schedules))
})
.nests("/path_steps", |path| {
path.route("/local_track_names", post!(timetable::get_local_track_names))
})
.nests("/round_trips", |path| {
path.route("/train_schedules", get!(round_trips::list_train_schedules))
})
Expand Down
237 changes: 237 additions & 0 deletions editoast/src/views/timetable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use core_client::simulation::PhysicsConsist;
use database::DbConnection;
use database::DbConnectionPoolV2;
use editoast_derive::EditoastError;
use editoast_models::TrainScheduleException;
use editoast_models::prelude::*;
use editoast_models::timetable::Timetable;
use editoast_models::timetable::TimetableWithTrains;
Expand All @@ -45,6 +46,9 @@ use schemas::rolling_stock::RollingResistance;
use schemas::rolling_stock::RollingStock;
use schemas::rolling_stock::TowedRollingStock;
use schemas::rolling_stock::{EtcsBrakeParams, LoadingGaugeType};
use schemas::train_schedule::OperationalPointPartReference;
use schemas::train_schedule::OperationalPointReference;
use schemas::train_schedule::PathItemLocation;
use schemas::train_schedule::TrainScheduleLike;
use serde::Deserialize;
use serde::Serialize;
Expand All @@ -63,6 +67,8 @@ use super::path::pathfinding::PathfindingResult;
use crate::AppState;
use crate::error::Result;
use crate::views::AuthenticationExt;
use crate::views::infra::MatchOperationalPointsForm;
use crate::views::path::operational_point_cache::OperationalPointCache;
use crate::views::timetable::simulation::SimulationResponseSuccess;
use editoast_models::Infra;
use editoast_models::TrainScheduleSet;
Expand Down Expand Up @@ -609,6 +615,140 @@ fn build_trains_requirements(
)
}

/// Retrieve list of local track names for each path step of each train schedule
/// that match the given operational point references list
#[editoast_derive::route]
#[utoipa::path(
post, path = "",
tags = ["timetable"],
params(TimetableIdParam, InfraIdQueryParam),
request_body(
content = inline(MatchOperationalPointsForm),
description = "The list of operational point references to match",
),
responses(
(status = 200, description = "For each operational point in the input list, returns the set of local track names found in the timetable's train schedules path steps", body = inline(Vec<HashSet<String>>)),
),
)]
pub(in crate::views) async fn get_local_track_names(
State(AppState { db_pool, .. }): State<AppState>,
Extension(auth): AuthenticationExt,
Path(TimetableIdParam { id: timetable_id }): Path<TimetableIdParam>,
Query(InfraIdQueryParam { infra_id }): Query<InfraIdQueryParam>,
Json(MatchOperationalPointsForm {
operational_point_references,
}): Json<MatchOperationalPointsForm>,
) -> Result<Json<Vec<HashSet<String>>>> {
// Check user privilege on infra
auth.check_authorization(async |authorizer| {
authorizer
.authorize_infra(&authz::Infra(infra_id), authz::InfraPrivilege::CanRead)
.await
})
.await?;

let conn = &mut db_pool.get().await?;

Timetable::exists_or_fail(conn, timetable_id, || TimetableError::NotFound {
timetable_id,
})
.await?;

let train_schedule_set_ids =
Timetable::get_train_schedule_set_ids_from_timetable(timetable_id, conn).await?;

let train_schedules = editoast_models::TrainSchedule::list(
conn,
SelectionSettings::new().filter(move || {
editoast_models::TrainSchedule::TRAIN_SCHEDULE_SET_ID
.eq_any(train_schedule_set_ids.clone())
}),
)
.await?;

let train_ids = train_schedules.iter().map(|ts| ts.id).collect::<Vec<_>>();

let mut exceptions = TrainScheduleException::retrieve_exceptions_by_train_schedules(
conn,
timetable_id,
&train_ids,
)
.await?
.into_iter()
.map_into::<schemas::TrainScheduleException>()
.into_group_map_by(|e| e.train_schedule_id);

// Collect all occurrences from all trains
let train_occurrences = train_schedules.iter().flat_map(|train| {
train
.iter_occurrences(&exceptions.remove(&train.id).unwrap_or_default())
.collect::<Vec<_>>()
});

// Collect local_track_names and deduplicated path locations from train occurrences.
let mut local_track_names: HashMap<OperationalPointReference, HashSet<String>> = HashMap::new();
let mut seen_op_refs: HashSet<OperationalPointReference> = HashSet::new();
let mut unique_locations: Vec<PathItemLocation> = Vec::new();

train_occurrences
.flat_map(|(_, occurrence)| occurrence.path.into_iter())
.filter_map(|path_item| match path_item.location {
PathItemLocation::OperationalPointPartReference(op_ref) => Some(op_ref),
_ => None,
})
.for_each(|op_ref| {
if let Some(name) = &op_ref.local_track_name {
local_track_names
.entry(op_ref.operational_point.clone())
.or_default()
.insert(name.0.clone());
}
if seen_op_refs.insert(op_ref.operational_point.clone()) {
unique_locations.push(PathItemLocation::OperationalPointPartReference(op_ref));
}
});
// Also include the requested op_refs so the cache resolves them even if no train uses them.
for op_ref in &operational_point_references {
if seen_op_refs.insert(op_ref.clone()) {
unique_locations.push(PathItemLocation::OperationalPointPartReference(
OperationalPointPartReference {
operational_point: op_ref.clone(),
local_track_name: None,
},
));
}
}

let path_items: Vec<&PathItemLocation> = unique_locations.iter().collect();
let op_cache =
OperationalPointCache::load_path_items(db_pool.get().await?, infra_id, &path_items).await?;

// Resolve op_ref to op id, merging sets when multiple refs point to the same OP.
let op_id_to_local_track_names: HashMap<String, HashSet<String>> = local_track_names
.iter()
.filter_map(|(op_ref, names)| {
let op = op_cache.get_reference(op_ref.clone())?;
Some((op.id.0.clone(), names))
})
.fold(HashMap::new(), |mut map, (id, names)| {
map.entry(id).or_default().extend(names.iter().cloned());
map
});

let result: Vec<HashSet<String>> = operational_point_references
.iter()
.map(|op_ref| match op_cache.get_reference(op_ref.clone()) {
Some(op) => op_id_to_local_track_names
.get(&op.id.0)
.cloned()
.unwrap_or_default(),
None => local_track_names.get(op_ref).cloned().unwrap_or_default(),
})
.collect();

Ok(Json(result))
}

#[derive(Serialize, ToSchema)]
#[cfg_attr(test, derive(Deserialize))]
pub(in crate::views) struct TrainRequirementsPage {
Expand Down Expand Up @@ -940,10 +1080,15 @@ mod tests {
use pretty_assertions::assert_eq;
use schemas::fixtures::simple_rolling_stock;
use schemas::fixtures::towed_rolling_stock;

use schemas::rolling_stock::RollingResistance;
use schemas::train_schedule::PathItem;
use schemas::train_schedule::ScheduleItem;

use super::*;
use crate::error::InternalError;
use crate::fixtures::create_simple_paced_train;
use crate::fixtures::create_small_infra;
use crate::fixtures::create_timetable;
use crate::fixtures::create_timetable_with_train_schedule_set;
use crate::fixtures::create_train_schedule_exception;
Expand Down Expand Up @@ -1491,4 +1636,96 @@ mod tests {
.json_into();
assert_eq!(train_schedule_sets, vec![train_schedule_set]);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_get_local_track_names_simple_train_schedule() {
let app = TestAppBuilder::default_app();
let db_pool = app.db_pool();

let small_infra = create_small_infra(&mut db_pool.get_ok()).await;

let (timetable, train_schedule_set) =
create_timetable_with_train_schedule_set(&mut db_pool.get().await.unwrap()).await;
let _train_schedule =
create_simple_paced_train(&mut db_pool.get().await.unwrap(), train_schedule_set.id)
.await;
let mut train_schedule_base_2 = simple_paced_train_base();
train_schedule_base_2.train_occurrence.path = vec![
PathItem {
id: "Mid_West_station".into(),
location: PathItemLocation::OperationalPointPartReference(
OperationalPointPartReference {
operational_point: OperationalPointReference::Id {
operational_point: "Mid_West_station".into(),
},
local_track_name: Some("West_1".into()),
},
),
},
PathItem {
id: "Mid_East_station".into(),
location: PathItemLocation::OperationalPointPartReference(
OperationalPointPartReference {
operational_point: OperationalPointReference::Id {
operational_point: "Mid_East_station".into(),
},
local_track_name: Some("East_1".into()),
},
),
},
];
train_schedule_base_2.train_occurrence.schedule = vec![ScheduleItem::new_with_stop(
"Mid_West_station",
Duration::seconds(0),
)];

let _train_schedule_2 =
Changeset::<editoast_models::TrainSchedule>::from(train_schedule_base_2)
.train_schedule_set_id(train_schedule_set.id)
.create(&mut db_pool.get().await.unwrap())
.await
.expect("Failed to create paced train");

let form = MatchOperationalPointsForm {
operational_point_references: vec![
// MWS is the trigram of Mid_West_station — tests cross-reference resolution
OperationalPointReference::Trigram {
trigram: "MWS".into(),
secondary_code: Some("BV".into()),
},
OperationalPointReference::Uic {
uic: 8711,
secondary_code: Some("BV".into()),
},
OperationalPointReference::Id {
operational_point: "Mid_East_station".into(),
},
],
};

let request = app
.post(
format!(
"/timetable/{}/path_steps/local_track_names?infra_id={}",
timetable.id, small_infra.id
)
.as_str(),
)
.json(&form);

let response: Vec<HashSet<String>> = app
.fetch(request)
.await
.assert_status(StatusCode::OK)
.json_into();

assert_eq!(
response,
vec![
HashSet::from(["West_1".to_string()]),
HashSet::new(),
HashSet::from(["East_1".to_string()]),
]
);
}
}
1 change: 1 addition & 0 deletions front/public/locales/en/operational-studies.json
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@
"moveLocationOnMap": "Move location on the map",
"movePointOnMap": "Move this waypoint using the map",
"next": "Next",
"noComputation": "there will be no computation for this train",
"opId": "Operational point identifier",
"opName": "Operational point name",
"opType": "Type",
Expand Down
1 change: 1 addition & 0 deletions front/public/locales/fr/operational-studies.json
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@
"moveLocationOnMap": "Déplacer le point remarquable sur la carte",
"movePointOnMap": "Déplacez ce point en utilisant la carte",
"next": "Suivant",
"noComputation": "il n'y aura pas de calcul pour ce train",
"opId": "Identifiant de point",
"opName": "Nom du point remarquable",
"opType": "Type",
Expand Down
Loading
Loading