diff --git a/editoast/Cargo.lock b/editoast/Cargo.lock index e162f3bb31b..1d1434c7bf7 100644 --- a/editoast/Cargo.lock +++ b/editoast/Cargo.lock @@ -1392,6 +1392,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "uom", "url", "utoipa", "uuid", @@ -1421,6 +1422,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.9", + "uom", "utoipa", ] @@ -1481,6 +1483,7 @@ dependencies = [ "chrono", "derivative", "editoast_common", + "editoast_derive", "enum-map", "geojson", "iso8601", @@ -1489,6 +1492,7 @@ dependencies = [ "serde_json", "strum", "thiserror 2.0.9", + "uom", "utoipa", "uuid", ] @@ -5074,6 +5078,16 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uom" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd36e5350a65d112584053ee91843955826bf9e56ec0d1351214e01f6d7cd9c" +dependencies = [ + "num-traits", + "typenum", +] + [[package]] name = "url" version = "2.5.4" diff --git a/editoast/Cargo.toml b/editoast/Cargo.toml index a1fc2fffdcf..3e11c5cd026 100644 --- a/editoast/Cargo.toml +++ b/editoast/Cargo.toml @@ -78,6 +78,7 @@ tracing = { version = "0.1.41", default-features = false, features = [ "attributes", "log", ] } +uom = { version = "0.36.0", default-features = false, features = ["f64", "si"] } url = { version = "2.5.4", features = ["serde"] } urlencoding = "2.1.3" utoipa = { version = "4.2.3", features = ["chrono", "uuid"] } @@ -186,6 +187,7 @@ tracing-opentelemetry = { version = "0.28.0", default-features = false, features "tracing-log", ] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +uom.workspace = true url.workspace = true utoipa.workspace = true uuid.workspace = true diff --git a/editoast/editoast_common/Cargo.toml b/editoast/editoast_common/Cargo.toml index 0b71df445b6..c6f66a1a448 100644 --- a/editoast/editoast_common/Cargo.toml +++ b/editoast/editoast_common/Cargo.toml @@ -11,6 +11,7 @@ rangemap.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true +uom.workspace = true utoipa.workspace = true [lints] diff --git a/editoast/editoast_common/src/lib.rs b/editoast/editoast_common/src/lib.rs index c7e38d3b550..950ad831e51 100644 --- a/editoast/editoast_common/src/lib.rs +++ b/editoast/editoast_common/src/lib.rs @@ -2,6 +2,7 @@ pub mod geometry; mod hash_rounded_float; pub mod rangemap_utils; pub mod schemas; +pub mod units; pub use hash_rounded_float::hash_float; pub use hash_rounded_float::hash_float_slice; diff --git a/editoast/editoast_common/src/units.rs b/editoast/editoast_common/src/units.rs new file mode 100644 index 00000000000..64b1405b221 --- /dev/null +++ b/editoast/editoast_common/src/units.rs @@ -0,0 +1,162 @@ +//! Module to allow the use of serde with uom quantities +//! +//! The serde feature of uom doesn’t allow to specify in which unit the value will be serialized. +//! +//! Two helpers are provided for convenience: +//! * `unit::new` (e.g. `meter::new(32)`) to build a new quantity form a f64 in the given unit +//! * `unit::from` (e.g. `millimeter::from(length)`) to have the quantity as f64 in the given unit +//! +//! ## Usage +//! +//! ```ignore +//! use editoast_model::units::*; +//! #[derive(Debug, Serialize, Derivative)] +//! struct Train { +//! // This means that serde with read and write the velocity in meters per second +//! #[serde(with="meter_per_second")] +//! max_speed: Velocity, +//! // When using optional values, we must add `default` and use ::option unit +//! // See https://stackoverflow.com/a/44303505 +//! #[serde(default, with="meter::option")] +//! length: Option, +//! } +//! +//! impl Train { +//! fn from_meter_per_seconds(mps: f64) -> Self { +//! Self { +//! max_speed: meter_per_second::new(mps), +//! } +//! } +//! +//! fn print(&self) { +//! println!("The max speed is: {} km/h", kilometer_per_hour::from(self.max_speed)); +//! } +//! } +//! ``` + +/// Re-export the Quantities that are used in OSRD +pub use uom::si::f64::{ + Acceleration, Force, Frequency, Length, LinearMassDensity, LinearNumberDensity, Mass, MassRate, + Time, Velocity, +}; + +macro_rules! quantity_to_path { + (Length, $unit:ident) => { + uom::si::length::$unit + }; + (Velocity, $unit:ident) => { + uom::si::velocity::$unit + }; + (Acceleration, $unit:ident) => { + uom::si::acceleration::$unit + }; + (Mass, $unit:ident) => { + uom::si::mass::$unit + }; + (Force, $unit:ident) => { + uom::si::force::$unit + }; + (MassRate, $unit:ident) => { + uom::si::mass_rate::$unit + }; + (Frequency, $unit:ident) => { + uom::si::frequency::$unit + }; + (LinearMassDensity, $unit:ident) => { + uom::si::linear_mass_density::$unit + }; + (LinearNumberDensity, $unit:ident) => { + uom::si::linear_number_density::$unit + }; + (Time, $unit:ident) => { + uom::si::time::$unit + }; +} + +macro_rules! define_unit { + ($unit:ident, $quantity:ident) => { + pub mod $unit { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use uom::si::f64::*; + type Unit = quantity_to_path!($quantity, $unit); + pub type ReprType = f64; + + pub fn serialize(value: &$quantity, serializer: S) -> Result + where + S: Serializer, + { + value.get::().serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result<$quantity, D::Error> + where + D: Deserializer<'de>, + { + let value = ReprType::deserialize(deserializer)?; + Ok($quantity::new::(value)) + } + + pub fn new(value: ReprType) -> $quantity { + $quantity::new::(value) + } + + pub fn from(qty: $quantity) -> ReprType { + qty.get::() + } + + pub fn hash(value: &$quantity, state: &mut H) { + crate::hash_float::<5, H>(&from(*value), state); + } + + pub mod option { + use super::*; + pub type ReprType = Option; + + pub fn serialize( + value: &Option<$quantity>, + serializer: S, + ) -> Result + where + S: Serializer, + { + value.map(|value| value.get::()).serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let value = Option::deserialize(deserializer)?; + Ok(value.map(|value| $quantity::new::(value))) + } + + pub fn new(value: ReprType) -> Option<$quantity> { + value.map(|v| $quantity::new::(v)) + } + + pub fn from(qty: Option<$quantity>) -> ReprType { + qty.map(|q| q.get::()) + } + + pub fn hash(value: &Option<$quantity>, state: &mut H) { + super::hash(&value.unwrap_or_default(), state); + } + } + } + }; +} + +// Any new value here must also be added in editoast_derive/src/annotate_units.rs +define_unit!(meter, Length); +define_unit!(millimeter, Length); +define_unit!(meter_per_second, Velocity); +define_unit!(kilometer_per_hour, Velocity); +define_unit!(meter_per_second_squared, Acceleration); +define_unit!(kilogram, Mass); +define_unit!(newton, Force); +define_unit!(kilogram_per_second, MassRate); +define_unit!(hertz, Frequency); +define_unit!(kilogram_per_meter, LinearMassDensity); +define_unit!(per_meter, LinearNumberDensity); +define_unit!(second, Time); +define_unit!(millisecond, Time); diff --git a/editoast/editoast_derive/src/annotate_units.rs b/editoast/editoast_derive/src/annotate_units.rs new file mode 100644 index 00000000000..e26c1f2a3c9 --- /dev/null +++ b/editoast/editoast_derive/src/annotate_units.rs @@ -0,0 +1,54 @@ +use darling::Result; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{parse_quote, DeriveInput, LitStr}; + +pub fn get_abbreviation(value: String) -> Option<&'static str> { + // Any new value here must also be added in editoast_common/src/units.rs + match value.replace("::option", "").as_str() { + "second" => Some("Duration in s"), + "millisecond" => Some("Duration in ms"), + "meter" => Some("Length in m"), + "millimeter" => Some("Length in mm"), + "meter_per_second" => Some("Velocity in m·s⁻¹"), + "kilometer_per_hour" => Some("Velocity in km·h⁻¹"), + "meter_per_second_squared" => Some("Acceleration in m·s⁻²"), + "kilogram" => Some("Mass in kg"), + "newton" => Some("Force in N"), + "hertz" => Some("in s⁻¹"), + "kilogram_per_meter" => Some("in kg·m⁻¹"), + "kilogram_per_second" => Some("in kg·s⁻¹"), + "per_meter" => Some("Aerodynamic drag per kg in m⁻¹"), + _ => None, + } +} + +pub fn annotate_units(input: &mut DeriveInput) -> Result { + // We look for fields that have #[sered(with="meter")] attributes + // and we push two new attributes to it to improve the OpenAPI + if let syn::Data::Struct(s) = &mut input.data { + for f in s.fields.iter_mut() { + for attr in f.attrs.clone() { + if attr.path().is_ident("serde") { + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("with") { + let value = meta.value()?; + let s: LitStr = value.parse()?; + if let Some(abbreviation) = get_abbreviation(s.value()) { + if s.value().ends_with("::option") { + f.attrs + .push(parse_quote! {#[schema(value_type = Option)]}); + } else { + f.attrs.push(parse_quote! {#[schema(value_type = f64)]}); + } + f.attrs.push(parse_quote! {#[doc = #abbreviation]}); + } + } + Ok(()) + }); + } + } + } + } + Ok(quote! {#input}) +} diff --git a/editoast/editoast_derive/src/lib.rs b/editoast/editoast_derive/src/lib.rs index bcb42c39053..e76859a2b34 100644 --- a/editoast/editoast_derive/src/lib.rs +++ b/editoast/editoast_derive/src/lib.rs @@ -1,5 +1,6 @@ extern crate proc_macro; +mod annotate_units; mod error; mod model; mod search; @@ -217,6 +218,7 @@ pub fn search_config_store(input: proc_macro::TokenStream) -> proc_macro::TokenS /// * `#[model(to_string)]`: calls `to_string()` before writing the field to the database and calls `String::from` after reading (diesel column type: String) /// * `#[model(to_enum)]`: is converted as `u8` before writing the field to the database and calls `FromRepr::from_repr` after reading (diesel column type: TinyInt) /// * `#[model(remote = "T")]`: calls `Into::::into` before writing the field to the database and calls `T::from` after reading (diesel column type: T) +/// * `#[model(uom_unit = "T")]`: the value is the path to an unit defined in editoast_common, e.g. `"editoast_common::units::meter"` /// * `#[model(geo)]` **TODO**: TBD /// /// #### A note on identifiers @@ -269,3 +271,20 @@ mod test_utils { #[cfg(test)] use test_utils::assert_macro_expansion; +/// Annotates fields of a structs with documentation and value_type for a better utoipa schema +/// +/// It must be used on structs that use #[derive(ToSchema)] +/// +/// On every field that has an attribute such as #[serde(with="millimeter")] +/// It will add: +/// * #[schema(value_type = f64)] +/// * /// Length in mm +#[proc_macro_attribute] +pub fn annotate_units(_attr: TokenStream, input: TokenStream) -> TokenStream { + // We are using a macro attribute to modify in place the attributes of fields to annotate + // This requires to mutate the input + let mut input = parse_macro_input!(input as DeriveInput); + annotate_units::annotate_units(&mut input) + .unwrap_or_else(darling::Error::write_errors) + .into() +} diff --git a/editoast/editoast_derive/src/model/args.rs b/editoast/editoast_derive/src/model/args.rs index 1d9479b5146..0a218b811a1 100644 --- a/editoast/editoast_derive/src/model/args.rs +++ b/editoast/editoast_derive/src/model/args.rs @@ -79,6 +79,8 @@ pub(super) struct ModelFieldArgs { pub(super) to_enum: bool, #[darling(default)] pub(super) remote: Option, + #[darling(default)] + pub(super) uom_unit: Option, } impl GeneratedTypeArgs { diff --git a/editoast/editoast_derive/src/model/config.rs b/editoast/editoast_derive/src/model/config.rs index a62f3ed311c..bf837bfbde6 100644 --- a/editoast/editoast_derive/src/model/config.rs +++ b/editoast/editoast_derive/src/model/config.rs @@ -45,6 +45,7 @@ pub(crate) enum FieldTransformation { Geo, ToString, ToEnum(syn::Type), + UomUnit(syn::Path), } #[derive(Debug, PartialEq)] @@ -132,6 +133,9 @@ impl ModelField { Some(FieldTransformation::ToEnum(_)) => { parse_quote! { #expr as i16 } } + Some(FieldTransformation::UomUnit(ref unit)) => { + parse_quote! { #unit::from(#expr) } + } None => parse_quote! { #expr }, } } @@ -146,6 +150,9 @@ impl ModelField { Some(FieldTransformation::ToEnum(ref ty)) => { parse_quote! { #ty::from_repr(#expr as usize).expect("Invalid variant repr") } } + Some(FieldTransformation::UomUnit(ref unit)) => { + parse_quote! { #unit::new(#expr) } + } None => parse_quote! { #expr }, } } @@ -158,6 +165,7 @@ impl ModelField { Some(FieldTransformation::Geo) => unimplemented!("to be designed"), Some(FieldTransformation::ToString) => parse_quote! { String }, Some(FieldTransformation::ToEnum(_)) => parse_quote! { i16 }, + Some(FieldTransformation::UomUnit(ref unit)) => parse_quote! { #unit::ReprType }, None => ty.clone(), } } diff --git a/editoast/editoast_derive/src/model/parsing.rs b/editoast/editoast_derive/src/model/parsing.rs index 368480fcaea..c0e54dae6cd 100644 --- a/editoast/editoast_derive/src/model/parsing.rs +++ b/editoast/editoast_derive/src/model/parsing.rs @@ -165,6 +165,7 @@ impl ModelField { value.geo, value.to_string, to_enum, + value.uom_unit, ) .map_err(|e| e.with_span(&ident))?; Ok(Self { @@ -188,16 +189,18 @@ impl FieldTransformation { geo: bool, to_string: bool, to_enum: Option, + uom_unit: Option, ) -> darling::Result> { - match (remote, json, geo, to_string, to_enum) { - (Some(ty), false, false, false, None) => Ok(Some(Self::Remote(ty))), - (None, true, false, false, None) => Ok(Some(Self::Json)), - (None, false, true, false, None) => Ok(Some(Self::Geo)), - (None, false, false, true, None) => Ok(Some(Self::ToString)), - (None, false, false, false, Some(ty)) => Ok(Some(Self::ToEnum(ty))), - (None, false, false, false, None) => Ok(None), + match (remote, json, geo, to_string, to_enum, uom_unit) { + (Some(ty), false, false, false, None, None) => Ok(Some(Self::Remote(ty))), + (None, true, false, false, None, None) => Ok(Some(Self::Json)), + (None, false, true, false, None, None) => Ok(Some(Self::Geo)), + (None, false, false, true, None, None) => Ok(Some(Self::ToString)), + (None, false, false, false, Some(ty), None) => Ok(Some(Self::ToEnum(ty))), + (None, false, false, false, None, Some(ty)) => Ok(Some(Self::UomUnit(ty))), + (None, false, false, false, None, None) => Ok(None), _ => Err(Error::custom( - "Model: remote, json, geo, to_string and to_enum attributes are mutually exclusive", + "Model: remote, json, geo, to_string, to_enum and uom_unit attributes are mutually exclusive", )), } } diff --git a/editoast/editoast_schemas/Cargo.toml b/editoast/editoast_schemas/Cargo.toml index 142b4c00e14..154a97ec6c4 100644 --- a/editoast/editoast_schemas/Cargo.toml +++ b/editoast/editoast_schemas/Cargo.toml @@ -8,6 +8,7 @@ edition.workspace = true chrono.workspace = true derivative.workspace = true editoast_common.workspace = true +editoast_derive.workspace = true enum-map.workspace = true geojson.workspace = true iso8601 = "0.6.1" @@ -16,6 +17,7 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true +uom.workspace = true utoipa.workspace = true uuid.workspace = true diff --git a/editoast/editoast_schemas/src/rolling_stock.rs b/editoast/editoast_schemas/src/rolling_stock.rs index 721788d29d8..6a1c2a38e27 100644 --- a/editoast/editoast_schemas/src/rolling_stock.rs +++ b/editoast/editoast_schemas/src/rolling_stock.rs @@ -31,6 +31,7 @@ pub use rolling_stock_livery::RollingStockLiveryMetadata; mod towed_rolling_stock; pub use towed_rolling_stock::TowedRollingStock; +use editoast_common::units::*; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; @@ -53,21 +54,24 @@ pub struct RollingStock { pub locked: bool, pub effort_curves: EffortCurves, pub base_power_class: Option, - /// In m - pub length: f64, - /// In m/s - pub max_speed: f64, - pub startup_time: f64, - /// In m/s² - pub startup_acceleration: f64, - /// In m/s² - pub comfort_acceleration: f64, + #[serde(with = "meter")] + pub length: Length, + #[serde(with = "meter_per_second")] + pub max_speed: Velocity, + #[serde(with = "second")] + pub startup_time: Time, + #[serde(with = "meter_per_second_squared")] + pub startup_acceleration: Acceleration, + #[serde(with = "meter_per_second_squared")] + pub comfort_acceleration: Acceleration, // The constant gamma braking coefficient used when NOT circulating - // under ETCS/ERTMS signaling system in m/s^2 - pub const_gamma: f64, - pub inertia_coefficient: f64, - /// In kg - pub mass: f64, + // under ETCS/ERTMS signaling system + #[serde(with = "meter_per_second_squared")] + pub const_gamma: Acceleration, + #[serde(with = "meter_per_second_squared")] + pub inertia_coefficient: Acceleration, + #[serde(with = "kilogram")] + pub mass: Mass, pub rolling_resistance: RollingResistance, pub loading_gauge: LoadingGaugeType, /// Mapping of power restriction code to power class @@ -77,11 +81,12 @@ pub struct RollingStock { pub energy_sources: Vec, /// The time the train takes before actually using electrical power (in seconds). /// Is null if the train is not electric. - pub electrical_power_startup_time: Option, + #[serde(default, with = "second::option")] + pub electrical_power_startup_time: Option