MIL, SIL, and HIL are three levels of hardware-in-the-loop testing that trade cost and speed for fidelity. Use MIL during algorithm development, SIL to validate compiled code, and HIL to verify behavior against real hardware before production.
What MIL, SIL, and HIL Mean
Model-in-the-Loop (MIL) runs your control algorithm as a simulation model against a simulated plant. No hardware involved. Fast iteration, low cost, but lowest fidelity.
Software-in-the-Loop (SIL) compiles your production code and runs it on a host machine against a simulated environment. Same binary, no hardware. Catches code generation bugs MIL misses.
Hardware-in-the-Loop (HIL) runs your production code on the target ECU or microcontroller, connected to a real-time simulator that emulates the physical environment. Highest fidelity, highest cost.
Comparison Matrix
| Dimension | MIL | SIL | HIL |
|---|---|---|---|
| Cost | Low | Low-medium | High |
| Speed | Fast (seconds) | Medium (minutes) | Slow (minutes-hours) |
| Fidelity | Low | Medium | High |
| Hardware required | None | None | Target ECU + simulator rack |
| Typical use | Algorithm design | Code verification | System validation |
| CI-friendly | Yes | Yes | No (dedicated bench) |
| Bug discovery | Design errors | Codegen errors | Integration, timing |
When to Use Each
MIL: Early Algorithm Development
Use MIL when the algorithm is still changing, you need rapid iteration, hardware is not yet available, or you're running parametric sweeps. MIL is wrong when you need to validate timing behavior, interrupt latency, or bus communication.
SIL: Code Verification Before Hardware
Use SIL when code generation is complete, you want to catch regressions in CI, you're validating numerical equivalence between model and generated code, or hardware bench slots are scarce. SIL misses real-time timing faults and hardware-specific peripheral behavior.
HIL: Pre-Production System Validation
Use HIL when the ECU firmware is frozen, you need to validate timing and bus behavior, certification requires hardware evidence, or you're running overnight regression suites. HIL is expensive to set up and slow to iterate.
Example: Voltage Limit Test at Each Level
The same test logic runs at all three levels. Only the plug changes.
With the TofuPilot Framework, that property is structural: the procedure declares the measurement and its limits once, and each level supplies a different plug for the same sensor parameter.
The shared procedure
procedure.yaml24 lines
name: Battery Voltage Limitversion: 1.0.0unit: serial_number: default_value: "UNIT000001" part_number: default_value: "ECU-100"plugs: - name: sensor python: plugs.sensor:VoltageSensormain: - name: Measure Battery Voltage python: phases.measure_battery_voltage measurements: - name: Battery Voltage unit: V validators: - operator: ">=" expected_value: 10.0 - operator: "<=" expected_value: 14.5def measure_battery_voltage(measurements, sensor): measurements.battery_voltage = sensor.read_voltage()Record which level produced the run so the dashboard can separate them:
def record_level(run): run.metadata["test_level"] = "HIL" # MIL | SIL | HILMIL: simulated sensor
class VoltageSensor: """MIL: returns a deterministic value from a simulation model.""" def read_voltage(self) -> float: return 11.8SIL: compiled production code
import ctypesclass VoltageSensor: """SIL: calls the compiled production C library via ctypes.""" LIB_PATH = "./build/battery_monitor.so" def __init__(self): self._lib = ctypes.CDLL(self.LIB_PATH) self._lib.get_battery_voltage.restype = ctypes.c_float def read_voltage(self) -> float: return float(self._lib.get_battery_voltage())HIL: real ECU over CAN
plugs/sensor.py19 lines
import canclass VoltageSensor: """HIL: reads voltage from the real ECU over the CAN bus.""" CHANNEL = "can0" BUSTYPE = "socketcan" def __init__(self): self._bus = can.interface.Bus(channel=self.CHANNEL, bustype=self.BUSTYPE) def read_voltage(self) -> float: msg = self._bus.recv(timeout=1.0) raw = msg.data[2] return raw * 0.1 def close(self): self._bus.shutdown()Keep the three plugs in separate directories (or select one with an environment variable) and point the procedure at the level you are running. The phase, the measurement name and the limits never change, which is what makes a MIL/SIL/HIL discrepancy meaningful rather than an artifact of three differently-written tests.
The same thing in OpenHTF
If you prefer everything in Python with no YAML layer, OpenHTF expresses the same pattern with a plug per level:
tests/hil/test_voltage_limit.py42 lines
import canimport openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass HILVoltageSensor(htf.plugs.BasePlug): """Reads voltage from real ECU over CAN bus.""" CHANNEL = "can0" BUSTYPE = "socketcan" def setUp(self): self._bus = can.interface.Bus(channel=self.CHANNEL, bustype=self.BUSTYPE) def read_voltage(self) -> float: msg = self._bus.recv(timeout=1.0) raw = msg.data[2] return raw * 0.1 def tearDown(self): self._bus.shutdown()@htf.plug(sensor=HILVoltageSensor)@htf.measures( htf.Measurement("battery_voltage") .in_range(minimum=10.0, maximum=14.5) .with_units(units.VOLT),)def measure_battery_voltage(test, sensor): test.measurements.battery_voltage = sensor.read_voltage()def main(): test = htf.Test(measure_battery_voltage, test_name="battery-voltage-hil") test.add_output_callbacks(upload()) test.execute(test_start=lambda: "UNIT-HIL-001")if __name__ == "__main__": main()Swap HILVoltageSensor for a simulated or ctypes-backed plug to get the MIL and SIL variants. Install with pip install "tofupilot[openhtf]", or run the suite with tofupilot run and skip the callback.
How TofuPilot Tracks Results Across All Levels
Each run uploads with the same measurement name (battery_voltage) regardless of level. A metadata key or the procedure name distinguishes the environment.
| Level | Distinguishing field | Traceability |
|---|---|---|
| MIL | test_level: MIL | Algorithm baseline |
| SIL | test_level: SIL | Codegen regression |
| HIL | test_level: HIL | Hardware validation |
A unit that passes MIL but fails SIL indicates a code generation issue. A unit that passes SIL but fails HIL indicates a hardware integration issue. Because the limits are declared in one place, that comparison is valid.
Running MIL and SIL in CI
MIL and SIL have no operator and no hardware, so they belong in the pipeline. Resolve the identify prompt from the procedure and pre-bake any UI values so nothing blocks:
tofupilot run ./procedure.yaml --json --ui-values ui.json --ui-timeout 300HIL needs the bench, so it runs on a dedicated station wired to a self-hosted CI runner instead.
Decision Framework
| Activity | Recommended Level |
|---|---|
| Tuning control gains | MIL |
| Regression testing in CI | SIL |
| Verifying code generation output | SIL |
| Validating CAN/LIN bus behavior | HIL |
| Interrupt and timing verification | HIL |
| Overnight fault injection suite | HIL |
| Parametric sweep (100+ cases) | MIL or SIL |
| Pre-release sign-off | HIL |
| Root-cause analysis of field issue | HIL |
For the bench-side detail (GPIO, ADC, PWM plugs and CI wiring), see HIL testing with Python.
