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
22
crates/ato-app/Cargo.toml
Normal file
22
crates/ato-app/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "ato-app"
|
||||
description = "ATO controller process — talks to TrainSim over HTTP"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "ato"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ato-core = { path = "../ato-core" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
57
crates/ato-app/src/config.rs
Normal file
57
crates/ato-app/src/config.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use serde::Deserialize;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct AtoConfig {
|
||||
pub trainsim_url: String,
|
||||
pub target_speed_mps: f64,
|
||||
pub tick_ms: u64,
|
||||
#[serde(default)]
|
||||
pub run_secs: u64,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
fn default_mode() -> String {
|
||||
"cruise".to_string()
|
||||
}
|
||||
|
||||
impl AtoConfig {
|
||||
pub fn load() -> Result<Self, String> {
|
||||
let path = std::env::var("ATO_CONFIG").unwrap_or_else(|_| "config/ato.default.json".to_string());
|
||||
Self::load_from(&path)
|
||||
}
|
||||
|
||||
pub fn load_from(path: &str) -> Result<Self, String> {
|
||||
let text = fs::read_to_string(Path::new(path)).map_err(|e| format!("read {path}: {e}"))?;
|
||||
let cfg: Self = serde_json::from_str(&text).map_err(|e| format!("parse {path}: {e}"))?;
|
||||
cfg.validate()
|
||||
}
|
||||
|
||||
fn validate(self) -> Result<Self, String> {
|
||||
if self.trainsim_url.is_empty() {
|
||||
return Err("trainsim_url empty".into());
|
||||
}
|
||||
if !self.target_speed_mps.is_finite() || self.target_speed_mps < 0.0 {
|
||||
return Err("target_speed_mps invalid".into());
|
||||
}
|
||||
if self.tick_ms == 0 {
|
||||
return Err("tick_ms must be > 0".into());
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_inline_json() {
|
||||
let cfg: AtoConfig = serde_json::from_str(
|
||||
r#"{"trainsim_url":"http://x","target_speed_mps":10.0,"tick_ms":100,"run_secs":0,"mode":"cruise"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.mode, "cruise");
|
||||
}
|
||||
}
|
||||
74
crates/ato-app/src/http.rs
Normal file
74
crates/ato-app/src/http.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! Tiny HTTP/1.1 client (std only).
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn get(url: &str) -> Result<(u16, String), String> {
|
||||
request("GET", url, None)
|
||||
}
|
||||
|
||||
pub fn post_json(url: &str, body: &str) -> Result<u16, String> {
|
||||
request("POST", url, Some(body)).map(|(s, _)| s)
|
||||
}
|
||||
|
||||
fn request(method: &str, url: &str, body: Option<&str>) -> Result<(u16, String), String> {
|
||||
let (host, port, path) = parse_url(url)?;
|
||||
let mut stream = TcpStream::connect((host.as_str(), port))
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(3)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let payload = body.unwrap_or("");
|
||||
let req = format!(
|
||||
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{payload}",
|
||||
payload.len()
|
||||
);
|
||||
stream.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut raw = String::new();
|
||||
stream.read_to_string(&mut raw).map_err(|e| e.to_string())?;
|
||||
parse_response(&raw)
|
||||
}
|
||||
|
||||
fn parse_response(raw: &str) -> Result<(u16, String), String> {
|
||||
let (head, body) = raw.split_once("\r\n\r\n").ok_or("bad response")?;
|
||||
let status_line = head.lines().next().ok_or("no status")?;
|
||||
let code: u16 = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.ok_or("no code")?
|
||||
.parse()
|
||||
.map_err(|_| "bad code".to_string())?;
|
||||
Ok((code, body.to_string()))
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Result<(String, u16, String), String> {
|
||||
let rest = url.strip_prefix("http://").ok_or("only http supported")?;
|
||||
let (host_port, path) = match rest.split_once('/') {
|
||||
Some((hp, p)) => (hp, format!("/{p}")),
|
||||
None => (rest, "/".to_string()),
|
||||
};
|
||||
let (host, port) = match host_port.split_once(':') {
|
||||
Some((h, p)) => (h.to_string(), p.parse().map_err(|_| "bad port")?),
|
||||
None => (host_port.to_string(), 80),
|
||||
};
|
||||
Ok((host, port, path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_url() {
|
||||
let (h, p, path) = parse_url("http://trainsim:8080/train/state").unwrap();
|
||||
assert_eq!(h, "trainsim");
|
||||
assert_eq!(p, 8080);
|
||||
assert_eq!(path, "/train/state");
|
||||
}
|
||||
}
|
||||
67
crates/ato-app/src/main.rs
Normal file
67
crates/ato-app/src/main.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
mod config;
|
||||
mod http;
|
||||
mod sim_client;
|
||||
|
||||
use ato_core::{cruise_actuation, Motion};
|
||||
use config::AtoConfig;
|
||||
use sim_client::{ActuateBody, SimClient};
|
||||
use std::{thread, time::Duration};
|
||||
|
||||
fn main() {
|
||||
if let Err(msg) = run() {
|
||||
eprintln!("ato: {msg}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let cfg = AtoConfig::load()?;
|
||||
let client = SimClient::new(&cfg.trainsim_url);
|
||||
wait_for_sim(&client)?;
|
||||
|
||||
client.sim_start()?;
|
||||
eprintln!(
|
||||
"ato: mode={} target={} m/s",
|
||||
cfg.mode, cfg.target_speed_mps
|
||||
);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
let state = client.read_state()?;
|
||||
let cmd = cruise_actuation(
|
||||
Motion {
|
||||
speed_mps: state.speed_mps,
|
||||
position_m: state.position_m,
|
||||
}
|
||||
.speed_mps,
|
||||
cfg.target_speed_mps,
|
||||
);
|
||||
client.actuate(&ActuateBody {
|
||||
traction_pct: cmd.traction.value(),
|
||||
brake_pct: cmd.brake.value(),
|
||||
})?;
|
||||
|
||||
if cfg.run_secs > 0 && start.elapsed().as_secs() >= cfg.run_secs {
|
||||
client.actuate(&ActuateBody {
|
||||
traction_pct: 0,
|
||||
brake_pct: 100,
|
||||
})?;
|
||||
client.sim_stop()?;
|
||||
eprintln!("ato: finished after {}s", cfg.run_secs);
|
||||
break;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(cfg.tick_ms));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_sim(client: &SimClient) -> Result<(), String> {
|
||||
for _ in 0..60 {
|
||||
if client.health().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
Err("TrainSim not reachable".into())
|
||||
}
|
||||
90
crates/ato-app/src/sim_client.rs
Normal file
90
crates/ato-app/src/sim_client.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
//! Minimal HTTP client for TrainSim (see spec/interface.md).
|
||||
|
||||
use crate::http::{get, post_json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct TrainState {
|
||||
pub speed_mps: f64,
|
||||
pub position_m: f64,
|
||||
pub track_length_m: f64,
|
||||
pub sim_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ActuateBody {
|
||||
pub traction_pct: u8,
|
||||
pub brake_pct: u8,
|
||||
}
|
||||
|
||||
pub struct SimClient {
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl SimClient {
|
||||
#[must_use]
|
||||
pub fn new(base: &str) -> Self {
|
||||
Self {
|
||||
base: base.trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health(&self) -> Result<(), String> {
|
||||
let (code, _) = get(&format!("{}/health", self.base))?;
|
||||
if code == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("health status {code}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_state(&self) -> Result<TrainState, String> {
|
||||
let (code, body) = get(&format!("{}/train/state", self.base))?;
|
||||
if code != 200 {
|
||||
return Err(format!("state status {code}"));
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn actuate(&self, body: &ActuateBody) -> Result<(), String> {
|
||||
let json = serde_json::to_string(body).map_err(|e| e.to_string())?;
|
||||
let code = post_json(&format!("{}/train/actuate", self.base), &json)?;
|
||||
if code == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("actuate status {code}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sim_start(&self) -> Result<(), String> {
|
||||
post_ok(&self.base, "/sim/start")
|
||||
}
|
||||
|
||||
pub fn sim_stop(&self) -> Result<(), String> {
|
||||
post_ok(&self.base, "/sim/stop")
|
||||
}
|
||||
}
|
||||
|
||||
fn post_ok(base: &str, path: &str) -> Result<(), String> {
|
||||
let code = post_json(&format!("{base}{path}"), "{}")?;
|
||||
if code == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("post {path} status {code}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn actuate_body_serializes() {
|
||||
let b = ActuateBody {
|
||||
traction_pct: 50,
|
||||
brake_pct: 0,
|
||||
};
|
||||
let j = serde_json::to_string(&b).unwrap();
|
||||
assert!(j.contains("50"));
|
||||
}
|
||||
}
|
||||
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