← Blog
13 min read

How I Built an Open Source Simulink Alternative in Python

The architecture and tradeoffs behind building a Python-native, open source alternative to Simulink for early-stage hardware teams.

  • simulink alternative
  • open source
  • python
  • hardware simulation

I wanted a way to model a complete hardware system before committing to another prototype.

The tools I had were good at individual pieces. A spreadsheet could size a motor. SPICE could inspect a circuit. A notebook could test control logic. But the product itself crossed electrical, mechanical, thermal, and software boundaries. The useful question was rarely "does this resistor behave correctly?" It was "does the motor, battery, load, and controller still work together in the worst case?"

Simulink addresses that class of problem well, but I wanted a different workflow for small, Python-capable hardware teams: models defined in ordinary code, readable in Git, easy to extend, and free to run locally.

That became Hardwave, an MIT-licensed Python hardware simulation framework.

This is how I designed it, which shortcuts worked, and where it is deliberately not a replacement for Simulink.

I did not try to clone Simulink

Trying to reproduce decades of MathWorks engineering, domain libraries, code generation, and verification tooling would have been the wrong starting point.

I narrowed the problem to what an early-stage hardware team needs before the next order or respin:

  • Compose a system from electrical, mechanical, thermal, and control components
  • Catch invalid connections before simulation
  • Run steady-state and transient models
  • Replace a simple equation with a better solver without rebuilding the system
  • Keep models in Python so they work with Git, tests, notebooks, and CI
  • Feed measurements from the bench back into the model

That is the same class of question covered in why simulate hardware before you build it, but expressed as a composable Python model instead of a spreadsheet, a SPICE fragment, and a disconnected notebook.

The goal was not feature parity. It was to preserve the most useful idea behind block-diagram simulation, composable system models, while removing the parts that make adoption difficult for a team of one to fifteen engineers.

If you are deciding whether a mature Model-Based Design environment is already justified, read Do You Need Simulink?. This article is about what I built for the teams that need a smaller starting point.

The first design decision: everything is a component

The core abstraction is intentionally small:

  1. A type gives a signal physical meaning
  2. A port defines where a component accepts or produces that signal
  3. A component owns parameters and behavior
  4. A graph connects component instances
  5. A solver turns inputs into outputs
  6. An engine executes the graph

A resistor is a component. A motor is a component. A battery pack is a component. A controller or complete drivetrain can also be a component.

This matters because the abstraction does not change as the model grows. A composite subsystem presents the same interface as a primitive component, so a graph can start as a few rough equations and become a hierarchy without forcing a rewrite.

Here is a minimal voltage-divider model:

import hardwave.stdlib

from hardwave.simulation import SimulationEngine, SimulationGraph
from hardwave.stdlib.components.passive import Resistor, VoltageSource

graph = SimulationGraph()
graph.add_component(
    VoltageSource("source", param_values={"voltage": 9.0})
)
graph.add_component(
    Resistor("r1", param_values={"resistance": 1_000.0})
)
graph.add_component(
    Resistor("r2", param_values={"resistance": 2_000.0})
)

graph.connect("source", "voltage_out", "r1", "voltage")
graph.connect("source", "voltage_out", "r2", "voltage")

result = SimulationEngine(graph).run(inputs={})
print(result.get_output("r1", "current"))

The syntax is less important than the structure. Components know their local behavior. The graph knows their relationships. The engine decides how and when to evaluate them.

That separation became the foundation for every later feature.

Why I made ports physically typed

Most simulation signals eventually become numbers. If every port accepts a float, however, a graph can connect voltage to torque or temperature to current without an immediate error.

I wanted mistakes at the system boundary to fail early. Hardwave therefore treats DCVoltage, Current, Torque, Temperature, and other physical quantities as distinct signal types, even when their runtime representation is a Python float.

When a connection is created, the graph checks:

  • Both components exist
  • The named ports exist
  • The source is an output
  • The target is an input
  • The signal types are compatible
  • The target can accept another connection

Invalid wiring fails while the graph is being built, not halfway through a long simulation.

This is not full dimensional analysis. Units still require discipline inside component equations. But typed boundaries catch a valuable class of integration mistakes with little authoring overhead.

Why the graph is a directed acyclic graph

The first engine uses a directed acyclic graph, or DAG. That constraint makes execution predictable: validate the model, topologically sort the components, evaluate upstream components first, and propagate their outputs downstream.

For steady-state simulation, the basic loop is:

for component in graph.topological_order():
    inputs = gather_inputs(component)
    outputs = component.solve(inputs)
    store_outputs(component, outputs)

The real implementation also handles diagnostics, telemetry, pluggable solvers, external inputs, and ports that aggregate multiple sources. The underlying model remains simple enough to understand without a specialized runtime.

The tradeoff is important: a DAG does not naturally represent every physical network or algebraic loop. General circuit simulation, tightly coupled implicit systems, and arbitrary bidirectional energy flow need more sophisticated equation assembly and solving.

I accepted that limitation to make the first version reliable for signal-flow and early system models. A clear constraint is better than pretending to solve a class of systems incorrectly.

An important architectural choice: separate components from solvers

My first instinct could have been to put every equation directly inside each component. That works for a resistor. It becomes limiting as fidelity changes.

A motor might begin as a datasheet formula, then use a lookup table, then an ODE model, and later use a model fitted to bench measurements. The motor's interface should not change every time its internal behavior improves.

Hardwave separates the component contract from the solver implementation. The same component can use:

  • A direct Python formula
  • An interpolated lookup table
  • An ordinary differential equation
  • A machine-learning model
  • A remote or custom solver

The graph continues to connect to the same input and output ports.

This became one of the most important design principles: fidelity should improve without rewiring the product model.

It also keeps the framework honest. A generic formula model is useful for screening design options, but it is not manufacturer-verified truth. Teams can start with an approximation, measure real hardware, and replace the solver while preserving the system structure and tests around it.

Adding time without changing the component model

Steady-state calculations answer many early questions, but motors accelerate, capacitors charge, batteries discharge, and thermal mass stores heat. Those systems require state that evolves over time.

I added transient simulation around an ODESolver. Each ODE-based component defines:

  • Initial state
  • A function that computes state derivatives
  • A function that maps current state to output ports
  • An integration method

The framework supports Euler, fourth-order Runge-Kutta, and SciPy's solve_ivp integration. The simulation engine advances the model with an explicit time step and records output time series.

For example, a DC motor can couple electrical, mechanical, and thermal state:

  • Winding current changes with applied voltage, resistance, inductance, and back EMF
  • Angular speed changes with torque, load, friction, and inertia
  • Winding temperature changes with resistive heating and thermal loss

That model can answer a system question a static torque calculation cannot: does a motor that survives the first second overheat during the actual duty cycle? For the sizing work that usually comes before that question, see how to size a motor without guessing.

The framework does not make numerical choices disappear. Time step, stiffness, initial conditions, and model validity still matter. A Python API should make those choices visible, not imply that a successful run guarantees a correct result.

Fan-out was easy; fan-in needed explicit semantics

One output often feeds several inputs. The same supply voltage might drive multiple loads, so fan-out is straightforward.

Multiple outputs feeding one input are different. Should the values be summed, averaged, minimized, maximized, or processed by a custom rule?

I added aggregating input ports rather than hiding that decision inside the engine. A component author must declare how multiple incoming values combine.

That made junctions and shared loads possible while keeping ordinary input ports strict. It also prevented silent behavior where connection order could change a result.

This was a useful lesson from building the graph API: when a physical meaning is ambiguous, make the model author state the rule.

Faults had to be first-class outputs

Most introductory simulations focus on nominal outputs. Early hardware teams often care more about the edge of failure:

  • Does motor current exceed the continuous rating? (A common bench surprise; see why drivetrains draw more current than expected)
  • Does the winding temperature continue rising?
  • Does a battery enter overdischarge?
  • Does control logic react when a component fails?

I added runtime diagnostics with warning and fault levels. Components can detect operating-limit violations, report structured diagnostic codes, and optionally degrade their outputs.

A motor that reaches a thermal failure state can stop producing speed rather than continuing to behave like an ideal equation. A battery can cut off after overdischarge. The engine can continue to observe downstream behavior or stop on the first fault.

This is still model behavior, not a guarantee of how a physical failure will unfold. Its value is in making destructive scenarios testable before the bench is at risk.

Why Python is the interface

Python was not chosen because it is the perfect language for every numerical workload. It was chosen because it already sits at the intersection of the work this tool must connect:

  • NumPy and SciPy for numerical methods
  • pandas for test and telemetry data
  • scikit-learn and ONNX-compatible workflows for fitted models
  • pytest for regression tests
  • notebooks for exploration
  • Git and CI for collaboration
  • Existing scripts used by firmware and hardware teams

Text-based models also make ordinary engineering practices available. A model can be reviewed in a pull request, generated from parameters, tested against known cases, and reproduced in a clean environment.

There is a cost. A graphical block diagram can communicate topology faster than code, especially to a mixed-discipline team. Hardwave treats Python classes as the source of truth, with visual tooling as a layer over that model rather than a separate format.

That choice keeps the model portable even if the UI changes.

Open source was a product decision, not only a license choice

The core package is MIT licensed. Anyone can inspect the equations, run simulations locally, define new components, replace solvers, and keep models in their own repository.

For an engineering tool, inspectability matters. If a simulation influences a motor purchase or thermal limit, the team should be able to read the implementation that produced the result.

Open source also changes adoption. A founder can install the package and test an actual design question without procurement or a sales call:

pip install hardwave

The commercial boundary is around hosted capabilities such as cloud models, training, and shared graph storage, not the ability to run the core local simulation.

That boundary follows the cost structure. Local formulas and ODEs are cheap to distribute. Managed model training, inference, storage, and team collaboration create ongoing infrastructure costs.

The standard library started with systems, not catalogs

A component library can become an endless attempt to reproduce every manufacturer and part number. I deliberately started with generic building blocks:

  • Passive and active electrical components
  • DC, stepper, servo, and brushless motors
  • Linear actuators
  • Batteries and power converters
  • Common sensors
  • Signal-level microcontroller models

The intention is not to claim that a generic motor predicts a specific SKU perfectly. It is to let a team connect the full system, expose missing parameters, and test architecture-level decisions early.

Custom components are part of the expected workflow. A product with a unique mechanism, load profile, or sensor will need models that no generic catalog can provide.

That is a meaningful difference from mature commercial tools. Simulink and its related products have far deeper libraries, specialized workflows, and validation history. Hardwave currently optimizes for composability and adaptation, not catalog completeness.

What I would build differently

The framework is young, and several tradeoffs are now clearer.

Start with stronger dimensional checks

Typed signal names catch incompatible categories, but unit conversion and dimensional consistency deserve a more systematic representation. A future version should make it harder to mix radians and degrees, RPM and radians per second, or Celsius differences and absolute temperatures.

Test numerical behavior before expanding the catalog

Adding a component is visible progress. Testing solver convergence, repeatability, serialization, and fault behavior is less visible but more important. A smaller library with trusted models is more useful than a large library of plausible equations.

Keep explicit limits in the documentation

Signal-level MCU models are not instruction-accurate firmware simulation. A DAG is not a general acausal physical solver. Generic components are not verified manufacturer models. Some stdlib pieces are still placeholders, including the camera sensor model.

Those boundaries should be prominent because simulation becomes dangerous when a convenient approximation is mistaken for evidence.

Build migration paths, not lock-in

Models should serialize to understandable data, and component equations should remain accessible. Even an open source tool can create lock-in if users cannot extract parameters, topology, test vectors, and results.

What Hardwave can and cannot replace today

Hardwave can be a practical Simulink alternative when the job is:

  • Early system architecture and component tradeoffs
  • Python-native steady-state and transient simulation
  • Custom electromechanical models
  • Parameter sweeps scripted in ordinary Python loops or pytest, plus regression tests around known cases
  • Fault exploration before physical testing
  • Improving model fidelity with measured data

It is not a drop-in replacement when the job requires:

  • Certified or qualified code-generation workflows
  • Mature SIL, PIL, or HIL infrastructure
  • Extensive manufacturer and domain blocksets
  • General acausal multidomain equation solving
  • AUTOSAR, aerospace, or automotive compliance tooling
  • Decades of organization-specific Simulink models

Calling every block-diagram tool a complete Simulink replacement is not useful. The relevant question is whether a smaller tool can answer the engineering question your team has now.

For a broader comparison of the workflows, see Open Source vs. Proprietary Hardware Simulation Tools.

The architecture in one sentence

Hardwave is a typed graph of Python components whose solvers can evolve independently from the system wiring.

That sentence captures the design:

  • Typed, so invalid connections fail early
  • Graph-based, so complete systems can be composed
  • Python-native, so models fit existing engineering workflows
  • Solver-independent, so fidelity can grow with the product
  • Open source, so the result stays inspectable and extensible

The first version did not need to reproduce everything Simulink can do. It needed to make model-based hardware design accessible before a small team had enterprise-scale requirements.

That remains the goal: help engineers turn assumptions into executable models, use those models to avoid expensive mistakes, and fold what the bench teaches them back into the next design.

The source is available on GitHub, and the package can be installed from PyPI with pip install hardwave.