Tutorial: a water pump, from model to firmware
This tutorial builds one small system in arche-lang, step by step: a pump controller that an operator panel starts and stops, and that drives a motor. On the way it checks the model, links a requirement to the part that meets it and the test that verifies it, runs that test in the simulator, places the controller on a microcontroller, generates its Rust firmware, and puts the whole project under CI.
Every arche block below goes, in order, into one file, pump.arche; put it
in an empty folder. (The tutorial's own test compiles exactly these blocks, so
they are the language as it is.) Commands are run from that folder; archec
is node <arche-lang>/dist/cli.js if it is not on your PATH.
The reference for every declaration and property is patterns.md; the commands are in commands.md.
1. The project, its data and its messages#
project Pump "Water pump" {
version "0.1"
}
type Flow = float32 unit l/min range 0..120
message START: bool "Start"
message STOP: bool "Stop"
message FLOW_RATE "Flow rate" { rate: Flow }
const MAX_FLOW = 100 unit l/min
A message is named in capitals and carries one type — here a truth value — or
fields, like rate, a Flow: a number with a unit and a range. Units are written as they are
(l/min, m/s², °C): archec checks that what is compared or added up has
the same quantity.
archec check pump.arche
2. Components and their ports#
component Controller "Pump controller" {
in cmd: can "Commands" receives START, STOP
out motor: can "Motor" sends FLOW_RATE
var target: float32 = 0 unit l/min
fn regulate "Flow loop" {
wcet 200us
```arche
if state == Running {
send motor FLOW_RATE { rate: target }
}
```
}
A port says its direction, its type (can — a port type, here the standard
one) and the messages it takes or gives. A function's body is written in the
action language, in a ```arche block: state == Running tests the
part's state, send motor FLOW_RATE { … } sends on a port, goto Fault would
change state. archec checks it — the names, their types, what the port sends —
and translates it: to the Python the Arche simulator runs, and to Rust or C
for the firmware (step 7). wcet is what it costs at worst, for the timing
analysis of step 6. (A body can still be Python, in a ```python block,
for what the action language does not say: a library, a list.)
3. A state machine#
states {
state Idle "Stopped"
state Running "Pumping"
state Fault
Idle -> Running on START [self.target <= MAX_FLOW]
Running -> Idle on STOP
* -> Fault on dry_run / log('dry run')
}
}
The machine starts in its first state. on START is a transition taken when
the message arrives; […] is its guard and / … its action. * -> Fault
leaves every state. The linter walks the machine: a state nothing reaches, a
state with no way out, a trigger no port receives are pointed out.
The rest of the system:
component Motor "Motor driver" {
in drive: can "Drive" receives FLOW_RATE
}
component Panel "Operator panel" {
out buttons: can "Buttons" sends START, STOP
}
system Station "Pumping station" {
part panel: Panel
part ctrl: Controller
part motor: Motor
panel.buttons -> ctrl.cmd
ctrl.motor -> motor.drive
}
A system is one model in Arche: its parts are instances of the components,
and a.p -> b.q links two ports. archec checks that what one end sends the
other receives.
archec build pump.arche -o out
arche --project out
out/ is the Arche project — the YAML the Arche app reads.
4. A requirement, and who meets it#
requirement REQ-START "Start on command" {
text "The pump shall start within 100 ms of a START command."
priority high
method test
}
trace satisfies Station.ctrl -> REQ-START
A requirement's text is checked for words that cannot be verified ("as fast as
possible", "TBD"…). trace satisfies says the controller meets it; the test of
the next step says it is verified.
5. A test, run in the simulator#
test-plan TP-START "Starting" {
case TC-START-01 "START starts the pump" {
verifies REQ-START
at 0ms do ctrl.target = 50.0
at 10ms do send(panel.buttons, START(True))
within 100ms expect ctrl.state == "Running"
}
}
A case is a timeline: at a moment do something, within a delay expect
something. archec test builds the project and runs its test plans in the
Arche simulator (arche-cli):
archec test pump.arche -o out --coverage
It says which cases pass, which requirements they verify, and how much of the state machines, messages and functions the tests went through.
6. Where it runs#
component Mcu "Pump MCU" {
kind processor
cores 1
hal stm32
chip "stm32f446re"
inout can0: can "CAN"
}
system PumpHw "Pump hardware" {
part mcu: Mcu
component bus "CAN bus" {
kind bus
bitrate 500kbps
}
mcu.can0 -> bus
}
deployment Onboard "On the pump" {
system Station
platform PumpHw
binary pump_fw "pump.elf" on mcu {
thread ctl "Control" { runs ctrl; period 10ms; step regulate; priority 10 }
}
}
The platform is a system too: processors and buses. A deployment places the
logical system on it without changing it: the controller runs on a thread of
the MCU, every 10 ms, calling regulate.
archec analyze pump.arche
gives each core's load and each thread's response time against its deadline (here 200 µs in 10 ms: 2 %).
7. The firmware#
archec gen pump.arche --target embassy -o generated
writes a Rust crate per binary for the Embassy async
runtime — generated/embassy/pump_fw/:
src/arche.rs, regenerated each time: the messages and their CAN codec, one channel per link, the state machine of partctrl(CtrlState,CtrlEvent,CtrlMachine::next), its behaviour trait (CtrlBehaviour), and the task of threadctl. When a START arrives, the task fires the machine before calling the part. (Names come from the part, not its component: two parts of one component are two structs.)src/app.rs, written once and then yours: the part's struct (Ctrl) withtargetat its initial value and its machine, the guard to implement (guard_target_le_max_flow), and each function:regulateis already Rust, translated from its```archebody (a Python body would be left in a comment, to port).src/main.rs,Cargo.tomland.cargo/config.tomlfor the STM32F446RE.
cd generated/embassy/pump_fw && cargo build --release
8. In CI#
archec ci pump.arche -o out --coverage-min 50 --junit report.xml
runs what a pipeline checks, in one command — lint, format, timing, TARA, build, the tests and their coverage — prints one line per gate, writes a JUnit report for the CI server, and exits 1 when a gate fails.
9. Sharing components: libraries#
A component used by several projects goes in a library: a folder with a
library.arche that names it and gives its version.
library pump-parts "Pump parts" {
version "1.2.0"
}
Versions sit side by side as shared/pump-parts@1.2.0/,
shared/pump-parts@2.0.0/. A project says where libraries are and which
versions it accepts, and includes a file of the version used:
project Pump "Water pump" {
libraries "../shared"
uses pump-parts "^1.2"
}
include "pump-parts/motor.arche"
^1.2 is 1.2 or newer, before 2.0 — the newest there is, unless arche.lock says
otherwise:
archec lib pump.arche # each library, the version used, the others there are
archec lib pump.arche lock # arche.lock: the versions and a fingerprint of their files
Once locked, the version is kept until the lock is taken again, and a library whose files changed under the same version is pointed out.
Where next#
- patterns.md — every declaration, with its properties and an example.
- commands.md — every command.
- The examples:
examples/physics(equations, 30 lines),examples/autonomous-car(a full stack: requirements, deployment, test plans, code generation),examples/kacao(a real project, 68 files).