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
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.git
|
||||
target
|
||||
data/logs
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: CodeView CI
|
||||
name: CodeView ATO CI
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -7,28 +7,22 @@ on:
|
|||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
rust:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: rust:1.85-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint
|
||||
- name: Test and clippy
|
||||
run: |
|
||||
if [ -f package.json ]; then npm ci && npm run lint; else echo "no lint configured"; fi
|
||||
rustup component add clippy
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
tests:
|
||||
integration:
|
||||
runs-on: docker
|
||||
needs: lint
|
||||
needs: rust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Unit tests
|
||||
run: |
|
||||
if [ -f package.json ]; then npm test; elif [ -f pyproject.toml ]; then pip install -e . && pytest; else echo "no tests configured"; fi
|
||||
|
||||
docker-build:
|
||||
runs-on: docker
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Docker build
|
||||
run: |
|
||||
if [ -f Dockerfile ]; then docker build -t app:ci .; elif [ -f docker-compose.yml ]; then docker compose build; else echo "no docker build configured"; fi
|
||||
- name: Build image (in runner DinD)
|
||||
run: docker build -t ato:ci .
|
||||
|
|
|
|||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/target
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
112
Cargo.lock
generated
Normal file
112
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ato-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ato-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ato-core"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[workspace]
|
||||
members = ["crates/ato-core", "crates/ato-app"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
version = "0.1.0"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM rust:1.85-bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml ./
|
||||
COPY crates ./crates
|
||||
COPY config ./config
|
||||
COPY spec ./spec
|
||||
RUN cargo test --workspace && cargo build --release -p ato-app
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /src/target/release/ato /usr/local/bin/ato
|
||||
COPY config/ato.default.json /etc/ato/ato.default.json
|
||||
ENV ATO_CONFIG=/etc/ato/ato.default.json
|
||||
USER nobody
|
||||
WORKDIR /etc/ato
|
||||
ENTRYPOINT ["/usr/local/bin/ato"]
|
||||
23
README.md
23
README.md
|
|
@ -1,11 +1,22 @@
|
|||
# ato
|
||||
|
||||
First **CodeView** project — bootstrap mode.
|
||||
Standalone **Automatic Train Operation** (no ETCS, no ATP). Lean Rust controller talking to [TrainSim](../trainsim).
|
||||
|
||||
- **Forgejo:** https://git.italiatts.online/rwsholdem/ato
|
||||
- **Mode:** bootstrap (direct push to `main` allowed)
|
||||
- **Switch to protected:** `./scripts/project-mode.sh rwsholdem ato protected`
|
||||
## Layout
|
||||
|
||||
## Stack
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `crates/ato-core/` | Pure control logic (zero deps, no I/O) |
|
||||
| `crates/ato-app/` | HTTP client + control loop |
|
||||
| `config/` | Replaceable parameters (`ato.default.json`) |
|
||||
| `spec/` | Interface examples (`interface.md`, JSON samples) |
|
||||
|
||||
Scaffold your app here. Cursor and OpenClaw use this repo as the source of truth.
|
||||
## Run (via CodeView ato-lab DinD)
|
||||
|
||||
```bash
|
||||
cd /root/code-view/profiles/ato-lab
|
||||
chmod +x run-e2e.sh
|
||||
./run-e2e.sh
|
||||
```
|
||||
|
||||
Detailed ATO design notes — *TODO*.
|
||||
|
|
|
|||
7
config/ato.default.json
Normal file
7
config/ato.default.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"trainsim_url": "http://trainsim:8080",
|
||||
"target_speed_mps": 10.0,
|
||||
"tick_ms": 100,
|
||||
"run_secs": 15,
|
||||
"mode": "cruise"
|
||||
}
|
||||
7
config/ato.e2e.json
Normal file
7
config/ato.e2e.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"trainsim_url": "http://trainsim:8080",
|
||||
"target_speed_mps": 8.0,
|
||||
"tick_ms": 100,
|
||||
"run_secs": 15,
|
||||
"mode": "cruise"
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
4
spec/actuate.json
Normal file
4
spec/actuate.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"traction_pct": 0,
|
||||
"brake_pct": 0
|
||||
}
|
||||
45
spec/interface.md
Normal file
45
spec/interface.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# ATO ↔ TrainSim interface (v1)
|
||||
|
||||
Standalone. No ETCS. No ATP. JSON over HTTP.
|
||||
|
||||
Base URL (TrainSim): `http://trainsim:8080`
|
||||
|
||||
## TrainSim → ATO reads
|
||||
|
||||
`GET /train/state`
|
||||
|
||||
```json
|
||||
{
|
||||
"speed_mps": 0.0,
|
||||
"position_m": 0.0,
|
||||
"track_length_m": 1000.0,
|
||||
"sim_running": false
|
||||
}
|
||||
```
|
||||
|
||||
## ATO → TrainSim writes
|
||||
|
||||
`POST /train/actuate`
|
||||
|
||||
```json
|
||||
{
|
||||
"traction_pct": 0,
|
||||
"brake_pct": 0
|
||||
}
|
||||
```
|
||||
|
||||
Percent 0–100. Do not send both > 0 (TrainSim clamps brake wins).
|
||||
|
||||
`POST /sim/start` — start physics tick (empty body)
|
||||
|
||||
`POST /sim/stop` — pause physics (empty body)
|
||||
|
||||
Optional (TrainSim logs only): `POST /train/doors` `{ "command": "open" | "close" }`
|
||||
|
||||
## ATO configuration (env)
|
||||
|
||||
| Variable | Meaning |
|
||||
|----------|---------|
|
||||
| `TRAINSIM_URL` | TrainSim base URL |
|
||||
| `TARGET_SPEED_MPS` | Cruise target (default 10) |
|
||||
| `TICK_MS` | Control period (default 100) |
|
||||
6
spec/train_state.json
Normal file
6
spec/train_state.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"speed_mps": 0.0,
|
||||
"position_m": 0.0,
|
||||
"track_length_m": 1000.0,
|
||||
"sim_running": false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue