Hardware simulation and development platform
Project description
Hardwave
Hardware simulation and development platform: design circuits and systems in Python before touching any hardware.
Hardwave models hardware as a dataflow graph of components. Each component is a typed black box with input ports, output ports, and a solver that turns inputs into outputs. Connect components into a graph, run the simulation engine, and get results; from a simple voltage divider to a full robot drivetrain.
Contents
- What Hardwave Does
- Core Concepts
- Installation
- Quick Start
- Module Structure
- Working with Types
- Defining Components
- Building Simulation Graphs
- Running Simulations
- Composite Components
- Solver System
- Transient Simulation
- Hosted ML Models
- Standard Library Reference
- Component Health and Faults
- Saving Graphs and Cloud Snapshots
- Error Handling
- Design Principles
What Hardwave Does
Hardware prototyping is expensive and slow. You design something, order parts, assemble it, test it, and discover a motor draws twice the expected current. Then you iterate. This cycle takes days and costs money.
Hardwave lets you close that loop in software:
- Simulate electrical, mechanical, and thermal behaviour before buying anything
- Compose components into systems; a resistor, a motor driver, a microcontroller, all with the same interface
- Calibrate simulation models against real telemetry collected from physical hardware
- Swap solvers, start with a formula, replace it with an ML model trained on real data, all without changing any wiring
Core Concepts
| Concept | Description |
|---|---|
| Type | A named, physically-meaningful signal (e.g. DCVoltage, Torque, Temperature) |
| Port | A typed input or output slot on a component; input ports can aggregate multiple connections |
| Component | A node in the simulation graph with ports and a solver |
| Solver | The computation that transforms inputs to outputs (formula, ODE, lookup table, ML model) |
| Composite | A component built from other connected sub-components; externally identical to a primitive |
| Graph | A directed acyclic graph of connected component instances |
| Engine | Runs the graph; propagates values through it and returns a result |
| Health | Runtime component state: OK, WARNING, or FAULT |
Everything is a component. A resistor is a component. An Arduino is a component. A robot arm is a component. The abstraction never breaks.
Installation
Requirements: Python 3.11+
pip install hardwave
Or install dependencies directly:
pip install numpy scipy scikit-learn pandas
Then in Python:
import hardwave # core platform
import hardwave.stdlib # standard library (types + components)
Importing hardwave.stdlib registers all 30 built-in types and 28 primitive components with the global registries. You do not need to import submodules individually.
Quick Start
Simulate a Voltage Divider
import hardwave
import hardwave.stdlib
from hardwave.simulation import SimulationGraph, SimulationEngine
from hardwave.stdlib.components.passive import VoltageSource, Resistor
# Build the graph
graph = SimulationGraph()
graph.add_component(VoltageSource("vs", 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("vs", "voltage_out", "r1", "voltage")
graph.connect("vs", "voltage_out", "r2", "voltage")
# Validate, then simulate
graph.validate()
result = SimulationEngine(graph).run(inputs={})
print(result.get_output("r1", "current")) # → 0.009 A (9 mA)
print(result.get_output("r2", "current")) # → 0.0045 A (4.5 mA)
print(result.get_output("r1", "power")) # → 0.081 W
The simulation matches hand calculation: I = V / R = 9 / 1000 = 9 mA, exactly.
Module Structure
hardwave/
├── types/ HardwaveType base class + TypeRegistry singleton
├── ports/ Port, PortDirection, PortAggregation, input_port(), aggregating_input_port(), output_port()
├── components/ Component ABC, ComponentMeta, Parameter, ComponentHealth, @component decorator
│ ├── composite.py CompositeComponent, BuildContext, Connection
│ └── registry.py ComponentRegistry singleton
├── solvers/ Solver ABC + FormulaSolver, ODESolver, LookupTableSolver, MLModelSolver
├── simulation/ SimulationGraph, SimulationEngine, SimulationConfig, SimulationResult
├── premium/ Cloud auth, hosted ML models, remote solvers
├── serialization/ Serializable protocol, schema version constants
├── diagnostics/ Diagnostic, DiagnosticLevel, full exception hierarchy
└── stdlib/
├── types/ 30 pre-registered hardware signal types
└── components/ 28 pre-built primitive components
Working with Types
Types define the semantic meaning of signals. DCVoltage and Current are both floats at runtime, but Hardwave treats them as incompatible, you cannot accidentally wire a voltage output into a current input.
Built-in Types (selected)
import hardwave.stdlib
from hardwave.types import TypeRegistry
reg = TypeRegistry.instance()
reg.get("DCVoltage") # V, min=-10000, max=10000
reg.get("Current") # A
reg.get("Resistance") # Ω, min=0
reg.get("Power") # W
reg.get("Torque") # N·m
reg.get("AngularVelocity") # rad/s
reg.get("Temperature") # °C, min=-273.15
reg.get("PWMSignal") # dict {frequency_hz, duty_cycle}
reg.get("ControlSignal") # dimensionless, 0.0–1.0
# List everything
for t in reg.list_types():
print(f"{t.name:25s} {t.unit}")
Defining a Custom Type
from hardwave.types import HardwaveType, TypeRegistry
Luminosity = HardwaveType(
name="Luminosity",
unit="lux",
description="Illuminance",
min_value=0.0,
)
TypeRegistry.instance().register(Luminosity)
Composite / Bus Types
from dataclasses import dataclass
from typing import Any, Type
from hardwave.types import HardwaveType, TypeRegistry
@dataclass
class CANFrame(HardwaveType):
name: str = "CANFrame"
unit: str = "-"
description: str = "CAN bus frame payload"
python_type: Type = dict
def validate(self, value: Any) -> bool:
return (
isinstance(value, dict)
and "arbitration_id" in value
and "data" in value
)
TypeRegistry.instance().register(CANFrame())
Defining Components
The simplest way to define a component is with the @component decorator:
from hardwave import component, input_port, output_port, Component
@component(
name="Thermistor",
display_name="NTC Thermistor",
version="1.0.0",
description="NTC thermistor: resistance decreases with temperature",
docs="Uses the Steinhart-Hart equation approximation.",
category="Sensors",
tags=["sensor", "temperature", "thermistor"],
)
class Thermistor(Component):
ports = [
input_port("temperature", "Temperature", "Ambient temperature (°C)"),
output_port("resistance", "Resistance", "Thermistor resistance at given temperature"),
]
parameters = [
Parameter("r_nominal", "Nominal Resistance", "Ω", float, 10_000.0),
Parameter("t_nominal", "Nominal Temperature", "°C", float, 25.0),
Parameter("beta", "Beta Coefficient", "K", float, 3950.0),
]
def solve(self, inputs: dict) -> dict:
import math
T_c = inputs["temperature"]
T_k = T_c + 273.15
T0 = self.get_param("t_nominal") + 273.15
R0 = self.get_param("r_nominal")
B = self.get_param("beta")
R = R0 * math.exp(B * (1 / T_k - 1 / T0))
return {"resistance": R}
The decorator automatically:
- Attaches
ComponentMetato the class - Registers the class with
ComponentRegistry
Manual definition (without decorator)
from hardwave.components import Component, ComponentMeta, ComponentRegistry, Parameter
from hardwave.ports import input_port, output_port
class MyGasSensor(Component):
meta = ComponentMeta(
name="MyGasSensor",
display_name="Custom Gas Sensor",
version="1.0.0",
description="Reads CO2 concentration",
docs="Returns PPM concentration from a voltage signal.",
category="Sensors/Gas",
)
ports = [
input_port("analog_in", "AnalogSignal", "Sensor output voltage (0–5V)"),
output_port("co2_ppm", "AnalogSignal", "CO2 concentration in PPM"),
]
parameters = [
Parameter("sensitivity", "Sensitivity", "V/PPM", float, 0.002),
]
def solve(self, inputs: dict) -> dict:
V = inputs["analog_in"]
ppm = V / self.get_param("sensitivity")
return {"co2_ppm": ppm}
ComponentRegistry.instance().register(MyGasSensor)
Aggregating input ports
Use aggregating_input_port() when multiple upstream outputs should feed the same input. The engine collects all connected values and combines them into a single value before calling solve().
from hardwave.ports import aggregating_input_port, output_port, PortAggregation
def net_current(values, component, port):
return sum(values)
class CurrentJunction(Component):
meta = ComponentMeta(
name="CurrentJunction",
display_name="Current Junction",
version="1.0.0",
description="Sums branch currents at a node",
docs="",
category="Electrical",
)
ports = [
# Built-in sum
aggregating_input_port(
"current_in", "Current",
aggregation=PortAggregation.SUM,
description="Total current from all branches",
),
output_port("total_current", "Current", "Net current"),
]
parameters = []
def solve(self, inputs: dict) -> dict:
return {"total_current": inputs["current_in"]}
Custom aggregation with aggregate=my_fn fully replaces built-in modes. The callable receives (values, component, port) and must return a single value.
aggregating_input_port("current_in", "Current", aggregate=net_current)
Regular input_port() ports still accept only one connection. See Aggregating Input Ports for built-in modes, min_connections / max_connections, and disconnect APIs.
Building Simulation Graphs
from hardwave.simulation import SimulationGraph
graph = SimulationGraph()
# Add component instances
graph.add_component(VoltageSource("psu", param_values={"voltage": 12.0}))
graph.add_component(VoltageRegulator("ldo", param_values={"output_voltage": 5.0}))
graph.add_component(Resistor("load", param_values={"resistance": 100.0}))
# Wire them together
graph.connect("psu", "voltage_out", "ldo", "v_in")
graph.connect("ldo", "v_out", "load", "voltage")
# load_current must be provided externally or have a default
# Fan-out: one output → many inputs (e.g. same supply to two resistors)
# graph.connect("vs", "voltage_out", "r1", "voltage")
# graph.connect("vs", "voltage_out", "r2", "voltage")
# Fan-in: many outputs → one input requires aggregating_input_port() on the target
# graph.connect("src_a", "current_out", "junction", "current_in")
# graph.connect("src_b", "current_out", "junction", "current_in")
# Validate before running
diagnostics = graph.validate()
for d in diagnostics:
print(d) # warnings only; errors raise GraphValidationError
Connection rules enforced at wiring time
| Error | Cause |
|---|---|
PortNotFoundError |
Port name does not exist on that component |
PortDirectionError |
Connecting output→output or input→input |
TypeMismatchError |
Source and target types are incompatible |
DuplicateConnectionError |
Second wire to a single-connection input port |
MaxConnectionsExceededError |
More connections than max_connections allows |
Disconnect and inspect
graph.disconnect("load", "voltage") # remove all connections feeding load.voltage
graph.disconnect("node", "current_in", "src_a", "current_out") # remove one source
graph.connections_to("node", "current_in") # list Connection objects feeding a port
graph.remove_component("load") # remove instance and all its connections
Running Simulations
Steady-state
from hardwave.simulation import SimulationEngine, SimulationConfig
from hardwave.solvers import SimulationDomain
engine = SimulationEngine(graph)
result = engine.run(
inputs={
"ldo": {"load_current": 0.05}, # 50 mA load
},
config=SimulationConfig(domain=SimulationDomain.STEADY_STATE),
)
print(result.get_output("ldo", "v_out")) # 5.0
print(result.get_output("ldo", "power_loss")) # (12-5)*0.05 = 0.35 W
print(result.get_output("load", "current")) # 0.05
Accessing results
# Scalar output
value = result.get_output("instance_id", "port_name")
# Dict (JSON-serializable)
d = result.to_dict()
# Simulation warnings, faults, and diagnostics
for diag in result.diagnostics():
print(diag)
warnings = result.get_warnings()
faults = result.get_faults()
See Component Health and Faults for runtime fault detection on motors and batteries.
Composite Components
A composite component wraps an internal sub-graph. From the outside it looks identical to a primitive.
from hardwave.components import (
CompositeComponent, BuildContext, Connection,
ComponentMeta, ComponentRegistry, Parameter,
)
from hardwave.ports import input_port, output_port
from hardwave.stdlib.components.passive import Resistor
class VoltageDivider(CompositeComponent):
meta = ComponentMeta(
name="VoltageDivider",
display_name="Voltage Divider",
version="1.0.0",
description="Two-resistor voltage divider",
docs="Splits an input voltage across two resistors.",
category="Electrical/Passive",
)
ports = [
input_port("v_in", "DCVoltage", "Input voltage"),
output_port("i_r1", "Current", "Current through R1"),
output_port("i_r2", "Current", "Current through R2"),
]
parameters = [
Parameter("r1", "R1", "Ω", float, 1_000.0),
Parameter("r2", "R2", "Ω", float, 2_000.0),
]
def build(self, ctx: BuildContext) -> None:
ctx.add(Resistor("r1", param_values={"resistance": self.get_param("r1")}))
ctx.add(Resistor("r2", param_values={"resistance": self.get_param("r2")}))
# Fan the external input to both internal ports
ctx.map_input("v_in", "r1", "voltage")
ctx.map_input("v_in", "r2", "voltage")
# Expose internal outputs externally
ctx.map_output("i_r1", "r1", "current")
ctx.map_output("i_r2", "r2", "current")
# Internal connections (none needed here, both take the same input)
ComponentRegistry.instance().register(VoltageDivider)
# Use it like any other component
graph = SimulationGraph()
graph.add_component(VoltageSource("vs", param_values={"voltage": 9.0}))
graph.add_component(VoltageDivider("div"))
graph.connect("vs", "voltage_out", "div", "v_in")
result = SimulationEngine(graph).run(inputs={})
print(result.get_output("div", "i_r1")) # 0.009 A
print(result.get_output("div", "i_r2")) # 0.0045 A
Internal sub-components can also be connected to each other using ctx.connect(Connection(...)) for more complex topologies. When multiple internal connections target the same aggregating input port, the engine combines values before calling the sub-component's solver.
Solver System
Every component has a solver that computes outputs from inputs. The default solver is the component's own solve() method, but you can inject a more sophisticated one at any time without changing the component's interface.
FormulaSolver - wrap any callable
from hardwave.solvers import FormulaSolver
def my_formula(inputs, component):
V = inputs["voltage"]
R = component.get_param("resistance")
return {"current": V / R, "power": V**2 / R}
r = Resistor("r1", param_values={"resistance": 470.0})
r.set_solver(FormulaSolver(my_formula))
LookupTableSolver - interpolate measured data
from hardwave.solvers import LookupTableSolver
# Motor torque-speed curve from datasheet
table = {
"speed_rpm": [0, 500, 1000, 1500, 2000, 2500, 3000],
"torque_nm": [1.2, 1.15, 1.05, 0.90, 0.70, 0.42, 0.0],
"efficiency": [0.0, 0.55, 0.72, 0.80, 0.81, 0.75, 0.0],
}
motor.set_solver(LookupTableSolver(
table,
input_port="speed_rpm",
output_ports=["torque_nm", "efficiency"],
method="linear", # or "cubic", "nearest"
))
ODESolver - physics-based transient models
from hardwave.solvers import ODESolver
# RC circuit: dV/dt = (V_in - V_c) / (R * C)
def state_fn(t, state, inputs, comp):
R, C = comp.get_param("resistance"), comp.get_param("capacitance")
return {"V_c": (inputs["v_in"] - state["V_c"]) / (R * C)}
def output_fn(t, state, inputs, comp):
return {"v_out": state["V_c"]}
rc_solver = ODESolver(
state_fn=state_fn,
output_fn=output_fn,
initial_state={"V_c": 0.0},
integrator="rk4", # "euler" | "rk4" | "scipy_ivp"
)
my_cap.set_solver(rc_solver)
MLModelSolver - learned from data
See Telemetry and ML Solvers below.
Transient Simulation
Transient simulation steps through time and returns a time series for every output port. Built-in components like DCMotor, Capacitor, Inductor, and LiPoCell use ODESolver internally and automatically participate in transient runs.
from hardwave.simulation import SimulationGraph, SimulationEngine, SimulationConfig
from hardwave.solvers import SimulationDomain
from hardwave.stdlib.components.motors import DCMotor
graph = SimulationGraph()
graph.add_component(DCMotor("motor", param_values={
"winding_resistance": 2.0,
"winding_inductance": 5e-4,
"back_emf_constant": 0.05,
"rotor_inertia": 1e-4,
}))
engine = SimulationEngine(graph)
config = SimulationConfig(
domain=SimulationDomain.TRANSIENT,
t_start=0.0,
t_end=1.0,
dt=0.0001, # 0.1 ms - use small dt for stiff electrical dynamics
)
engine.configure(config)
result = engine.run(
inputs={"motor": {"voltage": 12.0, "load_torque": 0.2}},
config=config,
)
# Time series as numpy arrays
t, omega = result.get_time_series("motor", "angular_velocity")
t, current = result.get_time_series("motor", "current")
print(f"Final speed: {omega[-1]:.1f} rad/s")
print(f"Final current: {current[-1]*1000:.1f} mA")
# Export to pandas for plotting
df = result.to_dataframe()
print(df.head())
# Columns: time, motor.angular_velocity, motor.current, motor.winding_temp
# Serialize the result
import json
print(json.dumps(result.to_dict(), indent=2)[:300])
Interactive / real-time step mode
state = {}
for step in range(1000):
t = step * 0.001
outputs, state, diagnostics = engine.step(
inputs={"motor": {"voltage": 12.0, "load_torque": 0.2}},
t=t,
state=state,
step_index=step,
)
omega = outputs["motor"]["angular_velocity"]
for d in diagnostics:
if d.level.value == "fault":
print(f"Fault at t={t}: {d.message}")
Hosted ML Models
Each hosted model owns its training records and keeps trained versions. Append measurements, train (or retrain) in the cloud, then attach the model to a component. Requires a Builder/Crew plan.
import time
import hardwave.premium
hardwave.premium.configure(
organization_id="<your-org-uuid>",
secret_key=open("hardwave_private.pem").read(),
)
model = hardwave.premium.create_model(
"dc-motor-from-bench",
component_class="DCMotor",
input_ports=["voltage", "load_torque"],
output_ports=["angular_velocity", "winding_temp"],
)
model_id = model["id"]
records = []
for i in range(80):
v = 6.0 + (i % 10) * 0.6
load = 0.1 + (i % 5) * 0.05
records.append({
"timestamp": time.time() + i,
"inputs": {"voltage": v, "load_torque": load},
"outputs": {
"angular_velocity": v * 15.0 - load * 40.0,
"winding_temp": 25.0 + v * 1.2 + load * 8.0,
},
"source": "user",
})
hardwave.premium.append_records(model_id, records)
hardwave.premium.train_model(model_id, min_samples=50)
# After status is ready:
hardwave.premium.attach_model(motor, model_id)
# or hardwave.premium.sync() to register ML_* components
Components still log per-run I/O during simulation (export_telemetry()), which you can map into append_records payloads. See the docs page Hosted ML models for versions and staleness.
Standard Library Reference
Types (30 registered)
Electrical
| Name | Unit | Description |
|---|---|---|
DCVoltage |
V | DC electrical potential |
ACVoltage |
V_rms | AC RMS voltage |
Current |
A | Electrical current |
Resistance |
Ω | Electrical resistance |
Capacitance |
F | Capacitance |
Inductance |
H | Inductance |
Power |
W | Electrical power |
Frequency |
Hz | Signal frequency |
AnalogSignal |
V | Generic analog 0–5V |
PWMSignal |
— | dict {frequency_hz, duty_cycle} |
DigitalSignal |
— | 0 or 1 |
I2CBus |
— | dict {sda, scl, address, data} |
SPIBus |
— | dict {mosi, miso, sclk, cs} |
UARTStream |
— | dict {baud_rate, data} |
Mechanical
| Name | Unit |
|---|---|
Torque |
N·m |
AngularVelocity |
rad/s |
AngularPosition |
rad |
LinearForce |
N |
LinearVelocity |
m/s |
LinearPosition |
m |
Pressure |
Pa |
MechanicalPower |
W |
Thermal
| Name | Unit |
|---|---|
Temperature |
°C |
HeatFlux |
W/m² |
ThermalResistance |
°C/W |
Control & Data
| Name | Unit |
|---|---|
ControlSignal |
— (0.0–1.0) |
BooleanSignal |
— |
Timestamp |
s |
ByteArray |
— |
JSONPayload |
— |
Components (28 registered)
Electrical / Passive
| Component | Key Ports | Solver |
|---|---|---|
Resistor |
voltage → current, power | Formula (Ohm's Law) |
Capacitor |
current → voltage, stored_energy | ODE (dV/dt = I/C) |
Inductor |
voltage → current, stored_energy | ODE (dI/dt = V/L) |
Diode |
anode_voltage, cathode_voltage → current, power_loss | Formula (Shockley) |
VoltageSource |
set_voltage → voltage_out | Formula |
CurrentSource |
set_current → current_out | Formula |
Ground |
— → voltage (0V) | Formula |
Electrical / Active
| Component | Key Ports | Solver |
|---|---|---|
NPNTransistor |
base_current, vce → collector_current, power | Formula (piecewise) |
NMOSFET |
gate_voltage, vds → drain_current, power | Formula (square-law) |
OpAmp |
v_plus, v_minus, supply → v_out | Formula (ideal, rail-clamped) |
VoltageRegulator |
v_in, load_current → v_out, power_loss | Formula |
Electromechanical
| Component | Key Ports | Solver |
|---|---|---|
DCMotor |
voltage, load_torque → angular_velocity, current, winding_temp, health, fault_code | ODE (coupled E-M-T; thermal fault rules) |
StepperMotor |
step_pulses, load_torque → angular_position, current | Discrete |
ServoMotor |
pwm_signal, load_torque → angular_position, current | Discrete |
BrushlessMotor |
phase_voltages, load_torque → angular_velocity, phase_currents | ODE |
LinearActuator |
pwm_signal, load_force → linear_position, current | ODE |
Sensors
| Component | Key Ports | Solver |
|---|---|---|
TemperatureSensor |
ambient_temperature → analog_out | Formula + Gaussian noise |
IMU |
linear_acceleration, angular_velocity → i2c_out | Formula |
Encoder |
angular_position → pulse_count, angular_velocity | Formula |
UltrasonicSensor |
target_distance → pulse_out | Formula + noise + latency |
Camera |
scene_description → image_frame | Placeholder (V2) |
Microcontrollers
| Component | Key Ports | Solver |
|---|---|---|
Arduino |
gpio_inputs → gpio_outputs, uart | Firmware callable (signal-level) |
ESP32 |
gpio_inputs, wifi_rx → gpio_outputs, wifi_tx, uart | Firmware callable |
RaspberryPi |
gpio_inputs → gpio_outputs, uart, i2c, spi | Firmware callable |
MCU firmware is modelled at the signal level; what values appear on pins at each time step. Actual code execution is a V2 feature. Pass a Python callable as the
firmwareparameter to drive GPIO outputs.
Power
| Component | Key Ports | Solver |
|---|---|---|
LiPoCell |
charge_current, discharge_current → terminal_voltage, soc, temperature, health, fault_code | ODE (SOC/thermal; fault rules) |
BatteryPack |
charge_current, discharge_current → terminal_voltage, capacity_ah | Composite |
BuckConverter |
v_in, duty_cycle, load_current → v_out, efficiency | Formula |
BoostConverter |
v_in, duty_cycle, load_current → v_out, efficiency | Formula |
Component Health and Faults
Components can emit runtime diagnostics when operating limits are exceeded during simulation. This makes it possible to stress-test designs, validate shutdown firmware, and explore failure modes before hardware breaks.
Diagnostic levels
| Level | When | Behaviour |
|---|---|---|
INFO |
Informational | Continue |
WARNING |
Out of spec (e.g. low SOC, high temp) | Continue; surfaced in results |
ERROR |
Invalid wiring / missing inputs | Block at validate() |
FAULT |
Catastrophic failure (e.g. motor burnout) | Degrade outputs; optionally stop sim |
Built-in fault-aware components
DCMotor monitors winding temperature and current:
MOTOR_THERMAL_HIGH(WARNING) — approachingmax_winding_tempMOTOR_OVER_CURRENT(WARNING) — abovemax_continuous_currentMOTOR_THERMAL_RUNAWAY(FAULT) — windings burned out; motor stops
LiPoCell monitors SOC, voltage, temperature, and discharge current:
CELL_LOW_SOC(WARNING) — SOC belowmin_soc_warningCELL_HIGH_TEMP(WARNING) — approachingmax_temperatureCELL_OVERDISCHARGE(FAULT) — discharge cut offCELL_THERMAL_DAMAGE(FAULT) — permanent internal resistance increase
Both expose health (0=OK, 1=warning, 2=fault) and fault_code output ports for wiring into control logic.
Reading diagnostics
from hardwave.simulation import SimulationEngine, SimulationConfig, FaultMode
result = SimulationEngine(graph).run(inputs={...})
for d in result.get_warnings():
print(d)
for d in result.get_faults():
print(f"{d.code}: {d.message} (value={d.value}, limit={d.threshold})")
health = result.get_output("motor", "health")
Fault modes
# Default: continue simulating with degraded faulted outputs
SimulationConfig(fault_mode=FaultMode.CONTINUE)
# Abort on first FAULT (raises SimulationError)
SimulationConfig(fault_mode=FaultMode.STOP)
Adding faults to custom components
Override post_solve_diagnostics() to emit warnings/faults and apply_fault_outputs() to model failed behaviour:
from hardwave.components.health import make_diagnostic
from hardwave.diagnostics import DiagnosticLevel
def post_solve_diagnostics(self, inputs, outputs, *, t=None, step_index=None):
diagnostics = []
if outputs["winding_temp"] > self.get_param("max_temp"):
self.set_fault_code(100)
diagnostics.append(make_diagnostic(
DiagnosticLevel.FAULT, "MOTOR_THERMAL_RUNAWAY",
"Motor burned out", self.instance_id,
value=outputs["winding_temp"], threshold=self.get_param("max_temp"),
))
return diagnostics
def apply_fault_outputs(self, outputs):
return {**outputs, "angular_velocity": 0.0, "current": 0.0}
Full documentation: docs/guides/component-faults.mdx
Saving Graphs and Cloud Snapshots
Local JSON
import json
graph_dict = graph.to_dict()
with open("my_graph.json", "w") as f:
json.dump(graph_dict, f, indent=2)
# Reload later (all component classes must be registered first)
reloaded = SimulationGraph.from_dict(graph_dict)
Simulation result snapshots
Attach a run outcome when serializing so teammates or tooling can inspect outputs without re-executing:
from hardwave.simulation import build_simulation_snapshot
snapshot = build_simulation_snapshot(
result,
inputs={"ldo": {"load_current": 0.05}},
config=config,
)
graph_dict = graph.to_dict()
graph_dict["simulation_snapshot"] = snapshot
result.to_dict() alone captures domain, outputs, diagnostics, and time_array (for transient runs).
Cloud dashboard (Builder / Crew)
Configure premium credentials once, then save from Python. Graphs appear under Graphs in the Hardwave dashboard.
import hardwave.premium
hardwave.premium.configure(
organization_id="YOUR_ORG_ID",
secret_key=open("hardwave_private.pem").read(),
)
# Structure only
graph_id = graph.save("psu-regulator", description="5 V rail model")
# With dashboard RESULTS tab (recommended)
graph_id = graph.save(
"psu-regulator",
description="5 V rail model",
result=result,
inputs={"ldo": {"load_current": 0.05}},
)
# Or run + snapshot in one call
graph_id = graph.save("psu-regulator", inputs={"ldo": {"load_current": 0.05}})
The snapshot is stored inside graph_data.simulation_snapshot in the cloud — no extra database columns. SimulationGraph.from_dict() ignores it when reloading. Re-saving without result= replaces the cloud graph and clears any previous snapshot.
Full details: docs/graphs/saving.mdx
Error Handling
Hardwave uses a typed exception hierarchy:
HardwaveError
├── TypeRegistrationError # Registering a type name that already exists
├── TypeNotFoundError # Looking up a type not in the registry
├── ComponentRegistrationError # Duplicate component name or invalid class
├── ComponentNotFoundError # Looking up a component not in the registry
├── PortNotFoundError # Port name not found on component
├── TypeMismatchError # Incompatible types on a connection
├── PortDirectionError # Connecting input→input or output→output
├── DuplicateConnectionError # Second wire to a single-connection input port
├── MaxConnectionsExceededError # Port max_connections limit reached
├── PortConfigurationError # Invalid port aggregation declaration at registration
├── GraphValidationError # Graph fails validation (carries list of Diagnostics)
├── SolverError # Solver computation failure
├── SimulationError # Engine-level failure
├── SimulationResultError # Accessing time-series on a steady-state result
└── DeserializationError # Corrupt or incompatible serialized data
GraphValidationError carries a full list of Diagnostic objects. Runtime simulation also collects WARNING and FAULT diagnostics in SimulationResult (see Component Health and Faults).
from hardwave.diagnostics import GraphValidationError, DiagnosticLevel
try:
graph.validate()
except GraphValidationError as e:
for d in e.diagnostics:
print(f"[{d.level.value}] {d.code}: {d.message}")
if d.suggestion:
print(f" → {d.suggestion}")
Design Principles
- Everything is a component. A resistor, an Arduino, a robot arm; the abstraction never breaks.
- Components are defined in code. Python classes are the source of truth; a UI layer wraps them.
- Solvers are pluggable. Swap a formula for a lookup table or an ML model without touching any wiring.
- Strict typing at the boundary. Type errors are caught at graph construction time, not at simulation time.
- Composability is first-class. A composite component is indistinguishable from a primitive at its interface.
- Simulation semantics are explicit. Time domain is declared per solver, not inferred.
- Data drives fidelity. Components start as formulas and grow into empirically-calibrated ML models.
Development
# Install in editable mode (includes all dependencies)
pip install -e .
# Run a quick sanity check
python -c "import hardwave; import hardwave.stdlib; print('OK')"
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hardwave-0.3.1.tar.gz.
File metadata
- Download URL: hardwave-0.3.1.tar.gz
- Upload date:
- Size: 90.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
573d76a917194480abb2a45a2f8a88131ff9424c5e18fdde75bdf2b3d5e040ce
|
|
| MD5 |
ca9aab6ae954f769aa34e52b86d5ff0d
|
|
| BLAKE2b-256 |
cbb1f7471d9fb5d6bda1ff59083fe16487821413fa6b421ce3e683088e2796c2
|
Provenance
The following attestation bundles were made for hardwave-0.3.1.tar.gz:
Publisher:
release.yaml on HardwaveDev/hardwave
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hardwave-0.3.1.tar.gz -
Subject digest:
573d76a917194480abb2a45a2f8a88131ff9424c5e18fdde75bdf2b3d5e040ce - Sigstore transparency entry: 2169680039
- Sigstore integration time:
-
Permalink:
HardwaveDev/hardwave@5fa81d7539813cf0960b904bd753114f29d6e5b4 -
Branch / Tag:
refs/tags/v0.3.1 - Owner: https://github.com/HardwaveDev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yaml@5fa81d7539813cf0960b904bd753114f29d6e5b4 -
Trigger Event:
release
-
Statement type:
File details
Details for the file hardwave-0.3.1-py3-none-any.whl.
File metadata
- Download URL: hardwave-0.3.1-py3-none-any.whl
- Upload date:
- Size: 84.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7585bfb8b0c00e80db77c3eedcc15d67ef2742e84cbed96f4d438761bf157875
|
|
| MD5 |
62e8b5e07c982cc4ba918826e04eb802
|
|
| BLAKE2b-256 |
814f639e343f50590174310a0d124b6bb81bcfb7707b1277bbfad85954df797d
|
Provenance
The following attestation bundles were made for hardwave-0.3.1-py3-none-any.whl:
Publisher:
release.yaml on HardwaveDev/hardwave
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hardwave-0.3.1-py3-none-any.whl -
Subject digest:
7585bfb8b0c00e80db77c3eedcc15d67ef2742e84cbed96f4d438761bf157875 - Sigstore transparency entry: 2169680060
- Sigstore integration time:
-
Permalink:
HardwaveDev/hardwave@5fa81d7539813cf0960b904bd753114f29d6e5b4 -
Branch / Tag:
refs/tags/v0.3.1 - Owner: https://github.com/HardwaveDev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yaml@5fa81d7539813cf0960b904bd753114f29d6e5b4 -
Trigger Event:
release
-
Statement type: