Metadata-Version: 2.4
Name: hardwave
Version: 0.3.0
Summary: Hardware simulation and development platform
Home-page: https://github.com/HardwaveDev/hardwave
Author: Hardwave
Author-email: hello@hardwave.dev
License: MIT
Project-URL: Bug Reports, https://github.com/HardwaveDev/hardwave/issues
Project-URL: Source, https://github.com/HardwaveDev/hardwave
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24.0
Requires-Dist: scipy>=1.10.0
Requires-Dist: scikit-learn>=1.3.0
Requires-Dist: joblib>=1.3.0
Requires-Dist: pandas>=2.0.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: cryptography>=42.0.0
Provides-Extra: onnx
Requires-Dist: skl2onnx>=1.16.0; extra == "onnx"
Requires-Dist: onnx>=1.15.0; extra == "onnx"
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: project-url
Dynamic: provides-extra
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# 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

1. [What Hardwave Does](#what-hardwave-does)
2. [Core Concepts](#core-concepts)
3. [Installation](#installation)
4. [Quick Start](#quick-start)
5. [Module Structure](#module-structure)
6. [Working with Types](#working-with-types)
7. [Defining Components](#defining-components)
8. [Building Simulation Graphs](#building-simulation-graphs)
9. [Running Simulations](#running-simulations)
10. [Composite Components](#composite-components)
11. [Solver System](#solver-system)
12. [Transient Simulation](#transient-simulation)
13. [Hosted ML Models](#hosted-ml-models)
14. [Standard Library Reference](#standard-library-reference)
15. [Component Health and Faults](#component-health-and-faults)
16. [Saving Graphs and Cloud Snapshots](#saving-graphs-and-cloud-snapshots)
17. [Error Handling](#error-handling)
18. [Design Principles](#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+

```bash
pip install hardwave
```

Or install dependencies directly:

```bash
pip install numpy scipy scikit-learn pandas
```

Then in Python:

```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

```python
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)

```python
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

```python
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

```python
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:

```python
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 `ComponentMeta` to the class
- Registers the class with `ComponentRegistry`

### Manual definition (without decorator)

```python
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()`.

```python
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.

```python
aggregating_input_port("current_in", "Current", aggregate=net_current)
```

Regular `input_port()` ports still accept only one connection. See [Aggregating Input Ports](docs/guides/aggregating-ports.mdx) for built-in modes, `min_connections` / `max_connections`, and disconnect APIs.

---

## Building Simulation Graphs

```python
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

```python
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

```python
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

```python
# 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](#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.

```python
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

```python
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

```python
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

```python
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](#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.

```python
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

```python
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/Team plan.

```python
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 `firmware` parameter 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) — approaching `max_winding_temp`
- `MOTOR_OVER_CURRENT` (WARNING) — above `max_continuous_current`
- `MOTOR_THERMAL_RUNAWAY` (FAULT) — windings burned out; motor stops

**LiPoCell** monitors SOC, voltage, temperature, and discharge current:

- `CELL_LOW_SOC` (WARNING) — SOC below `min_soc_warning`
- `CELL_HIGH_TEMP` (WARNING) — approaching `max_temperature`
- `CELL_OVERDISCHARGE` (FAULT) — discharge cut off
- `CELL_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

```python
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

```python
# 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:

```python
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](docs/guides/component-faults.mdx)

---

## Saving Graphs and Cloud Snapshots

### Local JSON

```python
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:

```python
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 / Team)

Configure premium credentials once, then save from Python. Graphs appear under **Graphs** in the Hardwave dashboard.

```python
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](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](#component-health-and-faults)).

```python
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

1. **Everything is a component.** A resistor, an Arduino, a robot arm; the abstraction never breaks.
2. **Components are defined in code.** Python classes are the source of truth; a UI layer wraps them.
3. **Solvers are pluggable.** Swap a formula for a lookup table or an ML model without touching any wiring.
4. **Strict typing at the boundary.** Type errors are caught at graph construction time, not at simulation time.
5. **Composability is first-class.** A composite component is indistinguishable from a primitive at its interface.
6. **Simulation semantics are explicit.** Time domain is declared per solver, not inferred.
7. **Data drives fidelity.** Components start as formulas and grow into empirically-calibrated ML models.

---

## Development

```bash
# Install in editable mode (includes all dependencies)
pip install -e .

# Run a quick sanity check
python -c "import hardwave; import hardwave.stdlib; print('OK')"
```
