Add standalone ATO controller in lean Rust.
Pure ato-core logic, minimal HTTP client, JSON config and spec files. Docker build with tests; integrates with TrainSim over simple REST API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b4e580af11
commit
a8d40b466b
20 changed files with 748 additions and 24 deletions
13
crates/ato-core/Cargo.toml
Normal file
13
crates/ato-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "ato-core"
|
||||
description = "Pure ATO control logic (no I/O)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
114
crates/ato-core/src/lib.rs
Normal file
114
crates/ato-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! Standalone automatic train operation — core logic only.
|
||||
//! No ETCS. No ATP. No I/O.
|
||||
|
||||
mod percent;
|
||||
|
||||
pub use percent::{Actuation, Percent};
|
||||
|
||||
/// Train motion snapshot (meters, m/s).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Motion {
|
||||
pub speed_mps: f64,
|
||||
pub position_m: f64,
|
||||
}
|
||||
|
||||
/// Compute traction/brake to hold a target cruise speed.
|
||||
///
|
||||
/// Simple bang-bang with deadband — easy to test and reason about.
|
||||
pub fn cruise_actuation(speed_mps: f64, target_mps: f64) -> Actuation {
|
||||
const DEADBAND: f64 = 0.05;
|
||||
if !speed_mps.is_finite() || !target_mps.is_finite() || target_mps < 0.0 {
|
||||
return Actuation::full_brake();
|
||||
}
|
||||
if speed_mps + DEADBAND < target_mps {
|
||||
Actuation::traction(Percent::MAX)
|
||||
} else if speed_mps - DEADBAND > target_mps {
|
||||
Actuation::brake(Percent::new(40))
|
||||
} else {
|
||||
Actuation::coast()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deceleration (m/s²) needed to stop within `distance_m` at current speed.
|
||||
pub fn decel_to_stop(speed_mps: f64, distance_m: f64) -> Option<f64> {
|
||||
if speed_mps <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
if distance_m <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(speed_mps * speed_mps / (2.0 * distance_m))
|
||||
}
|
||||
|
||||
/// Brake effort 0–100 from required deceleration and comfort limit.
|
||||
pub fn brake_pct_for_decel(required_decel: f64, comfort_decel: f64) -> Percent {
|
||||
if required_decel <= 0.0 {
|
||||
return Percent::ZERO;
|
||||
}
|
||||
let ratio = (required_decel / comfort_decel).clamp(0.0, 1.0);
|
||||
Percent::new((ratio * 100.0).round() as u8)
|
||||
}
|
||||
|
||||
/// Stop at a track position (m). Uses comfort decel until urgent.
|
||||
pub fn stop_at_position(motion: Motion, stop_at_m: f64, track_length_m: f64) -> Actuation {
|
||||
let comfort = 0.8_f64;
|
||||
let dist = distance_ahead(motion.position_m, stop_at_m, track_length_m);
|
||||
let Some(decel) = decel_to_stop(motion.speed_mps, dist) else {
|
||||
return Actuation::full_brake();
|
||||
};
|
||||
if decel <= comfort {
|
||||
return Actuation::brake(brake_pct_for_decel(decel, comfort));
|
||||
}
|
||||
Actuation::full_brake()
|
||||
}
|
||||
|
||||
/// Distance along track to target, wrapping on a loop.
|
||||
pub fn distance_ahead(position_m: f64, target_m: f64, track_length_m: f64) -> f64 {
|
||||
if track_length_m <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let pos = normalize_pos(position_m, track_length_m);
|
||||
let tgt = normalize_pos(target_m, track_length_m);
|
||||
if tgt >= pos {
|
||||
tgt - pos
|
||||
} else {
|
||||
track_length_m - pos + tgt
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_pos(p: f64, length: f64) -> f64 {
|
||||
let mut x = p % length;
|
||||
if x < 0.0 {
|
||||
x += length;
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cruise_speeds_up_from_standstill() {
|
||||
let a = cruise_actuation(0.0, 10.0);
|
||||
assert_eq!(a.traction, Percent::MAX);
|
||||
assert_eq!(a.brake, Percent::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cruise_brakes_when_fast() {
|
||||
let a = cruise_actuation(12.0, 10.0);
|
||||
assert_eq!(a.traction, Percent::ZERO);
|
||||
assert!(a.brake.value() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decel_formula() {
|
||||
assert!((decel_to_stop(10.0, 50.0).unwrap() - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_on_loop() {
|
||||
assert!((distance_ahead(900.0, 100.0, 1000.0) - 200.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
64
crates/ato-core/src/percent.rs
Normal file
64
crates/ato-core/src/percent.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use std::fmt;
|
||||
|
||||
/// Validated 0–100 percent command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Percent(u8);
|
||||
|
||||
impl Percent {
|
||||
pub const ZERO: Self = Self(0);
|
||||
pub const MAX: Self = Self(100);
|
||||
|
||||
#[must_use]
|
||||
pub fn new(value: u8) -> Self {
|
||||
Self(value.min(100))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Traction and brake command (mutually applied via builder methods).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Actuation {
|
||||
pub traction: Percent,
|
||||
pub brake: Percent,
|
||||
}
|
||||
|
||||
impl Actuation {
|
||||
#[must_use]
|
||||
pub fn coast() -> Self {
|
||||
Self {
|
||||
traction: Percent::ZERO,
|
||||
brake: Percent::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn traction(p: Percent) -> Self {
|
||||
Self {
|
||||
traction: p,
|
||||
brake: Percent::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn brake(p: Percent) -> Self {
|
||||
Self {
|
||||
traction: Percent::ZERO,
|
||||
brake: p,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn full_brake() -> Self {
|
||||
Self::brake(Percent::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Percent {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}%", self.0)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue