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:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -7,28 +7,22 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
rust:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: rust:1.85-bookworm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Lint
|
- name: Test and clippy
|
||||||
run: |
|
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
|
runs-on: docker
|
||||||
needs: lint
|
needs: rust
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Unit tests
|
- name: Build image (in runner DinD)
|
||||||
run: |
|
run: docker build -t ato:ci .
|
||||||
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
|
|
||||||
|
|
|
||||||
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
|
# 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
|
## Layout
|
||||||
- **Mode:** bootstrap (direct push to `main` allowed)
|
|
||||||
- **Switch to protected:** `./scripts/project-mode.sh rwsholdem ato protected`
|
|
||||||
|
|
||||||
## 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