Add standalone ATO controller in lean Rust.
Some checks are pending
CodeView ATO CI / rust (push) Waiting to run
CodeView ATO CI / integration (push) Blocked by required conditions

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:
Mona Lisa 2026-06-14 20:37:21 +00:00
parent b4e580af11
commit a8d40b466b
20 changed files with 748 additions and 24 deletions

View 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
View 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 0100 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);
}
}

View file

@ -0,0 +1,64 @@
use std::fmt;
/// Validated 0100 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)
}
}