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.
MIL: Simulated Sensor
import openhtf as htf
from openhtf.util import units
from tofupilot.openhtf import TofuPilot
class SimulatedVoltageSensor(htf.plugs.BasePlug):
"""Returns a deterministic value from a simulation model."""
def setUp(self):
pass
def read_voltage(self) -> float:
return 11.8
def tearDown(self):
pass
@htf.plug(sensor=SimulatedVoltageSensor)
@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-mil")
with TofuPilot(test):
test.execute(test_start=lambda: "UNIT-MIL-001")
if __name__ == "__main__":
main()SIL: Compiled Code Against Simulated Bus
import ctypes
import openhtf as htf
from openhtf.util import units
from tofupilot.openhtf import TofuPilot
class SILVoltageSensor(htf.plugs.BasePlug):
"""Calls compiled production C library via ctypes."""
LIB_PATH = "./build/battery_monitor.so"
def setUp(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())
def tearDown(self):
pass
@htf.plug(sensor=SILVoltageSensor)
@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-sil")
with TofuPilot(test):
test.execute(test_start=lambda: "UNIT-SIL-001")
if __name__ == "__main__":
main()HIL: Real ECU on Hardware Bench
import can
import openhtf as htf
from openhtf.util import units
from tofupilot.openhtf import TofuPilot
class 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")
with TofuPilot(test):
test.execute(test_start=lambda: "UNIT-HIL-001")
if __name__ == "__main__":
main()How TofuPilot Tracks Results Across All Levels
Each test run uploads to TofuPilot with the same measurement name (battery_voltage) regardless of level. The test_name field distinguishes the environment.
| test_name | Level | Traceability |
|---|---|---|
battery-voltage-mil | MIL | Algorithm baseline |
battery-voltage-sil | SIL | Codegen regression |
battery-voltage-hil | 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.
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 |