> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hardwave.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Greenhouse Vent Tutorial

> Build a complete greenhouse vent controller with custom types, components, and a composite assembly

This tutorial walks through building an automated greenhouse vent simulation: a thermostat controller reads temperature and commands a servo; a position sensor reports how far the vent is open.

By the end you will have:

* Defined a custom signal type (`VentAngle`)
* Written two custom components from scratch
* Wired a multi-component simulation graph
* Run steady-state and transient simulations
* Added per-run I/O logging (`export_telemetry`)
* Optionally trained a hosted ML model from measurements
* Wrapped everything into a reusable composite component

All code is executable. No hardware required.

## The system

```
(ambient temp) ──▶ [ThermostatController] ──pwm──▶ [ServoMotor] ──▶ [VentPositionSensor] ──▶ vent angle
```

<Info>
  A real greenhouse would also include a `TemperatureSensor`, ADC, and power supply. This tutorial feeds temperature directly into the controller so you can focus on component authoring and graph wiring.
</Info>

## 1. Project setup

```bash theme={null}
pip install hardwave
```

```python theme={null}
import hardwave
import hardwave.stdlib
```

## 2. Custom type: VentAngle

```python theme={null}
from hardwave.types import HardwaveType, TypeRegistry

VentAngle = HardwaveType(
    name="VentAngle",
    unit="°",
    description="Greenhouse vent opening angle",
    min_value=0.0,
    max_value=90.0,
)
TypeRegistry.instance().register(VentAngle)
```

## 3. ThermostatController

Maps temperature linearly to vent openness (0-1) and encodes it as a 50 Hz PWM signal:

```python theme={null}
from hardwave.components import Component, ComponentMeta, ComponentRegistry, Parameter
from hardwave.ports import input_port, output_port

class ThermostatController(Component):
    meta = ComponentMeta(
        name="ThermostatController",
        display_name="Greenhouse Thermostat Controller",
        version="1.0.0",
        description="Converts temperature to a servo PWM command",
        category="Control",
    )
    ports = [
        input_port("temperature", "Temperature", "Ambient air temperature (°C)"),
        output_port("vent_state", "ControlSignal", "Vent openness: 0.0 = shut, 1.0 = fully open"),
        output_port("pwm_out",    "PWMSignal",     "50 Hz PWM signal for the servo"),
    ]
    parameters = [
        Parameter("t_min", "Lower Threshold", "°C", float, 20.0),
        Parameter("t_max", "Upper Threshold", "°C", float, 35.0),
    ]

    def solve(self, inputs: dict) -> dict:
        T = inputs["temperature"]
        t_min, t_max = self.get_param("t_min"), self.get_param("t_max")
        openness = max(0.0, min(1.0, (T - t_min) / (t_max - t_min)))
        duty = (1.0 + openness * 0.5) / 20.0
        return {
            "vent_state": openness,
            "pwm_out": {"frequency_hz": 50.0, "duty_cycle": duty},
        }

ComponentRegistry.instance().register(ThermostatController)
```

## 4. VentPositionSensor

```python theme={null}
import math

class VentPositionSensor(Component):
    meta = ComponentMeta(
        name="VentPositionSensor",
        display_name="Vent Position Sensor",
        version="1.0.0",
        description="Converts servo angle (rad) to vent angle (degrees)",
        category="Sensors",
    )
    ports = [
        input_port("servo_angle_rad", "AngularPosition", "Servo shaft angle (rad)"),
        output_port("vent_angle_deg", "VentAngle",      "Vent opening angle (°)"),
        output_port("is_open",        "BooleanSignal",   "True if vent > 5° open"),
    ]
    parameters = [
        Parameter("gear_ratio", "Gear Ratio", "-", float, 1.0),
    ]

    def solve(self, inputs: dict) -> dict:
        degrees = math.degrees(inputs["servo_angle_rad"]) * self.get_param("gear_ratio")
        degrees = max(0.0, min(90.0, degrees))
        return {"vent_angle_deg": degrees, "is_open": 1 if degrees > 5.0 else 0}

ComponentRegistry.instance().register(VentPositionSensor)
```

## 5. Wire the graph

```python theme={null}
from hardwave.simulation import SimulationGraph, SimulationEngine
from hardwave.stdlib.components.motors import ServoMotor

graph = SimulationGraph()
graph.add_component(ThermostatController("ctrl", param_values={"t_min": 20.0, "t_max": 35.0}))
graph.add_component(ServoMotor("servo"))
graph.add_component(VentPositionSensor("pos_sensor"))

graph.connect("ctrl", "pwm_out", "servo", "pwm_signal")
graph.connect("servo", "angular_position", "pos_sensor", "servo_angle_rad")

graph.validate(external_inputs={
    "ctrl":  {"temperature": 28.0},
    "servo": {"load_torque": 0.0},
})
```

<Note>
  **Fan-out vs fan-in.** One output can connect to many inputs (fan-out) on any port. Multiple outputs into one input (fan-in) requires `aggregating_input_port()` on the target. See [Aggregating Input Ports](/guides/aggregating-ports).
</Note>

## 6. Steady-state sweep

```python theme={null}
engine = SimulationEngine(graph)

for T in [18.0, 22.0, 25.0, 27.5, 30.0, 35.0, 40.0]:
    r = engine.run(inputs={"ctrl": {"temperature": T}, "servo": {"load_torque": 0.0}})
    openness = r.get_output("ctrl", "vent_state")
    angle    = r.get_output("pos_sensor", "vent_angle_deg")
    print(f"{T:>5.1f}°C  openness={openness:.0%}  angle={angle:.1f}°")
```

## 7. Composite assembly

Package the full control loop as a single reusable component. See [Composite Components](/guides/composite-components) for the `BuildContext` API.

```python theme={null}
from hardwave.components import CompositeComponent, BuildContext
from hardwave.components.composite import Connection

class GreenhouseVentAssembly(CompositeComponent):
    meta = ComponentMeta(
        name="GreenhouseVentAssembly",
        display_name="Greenhouse Vent Assembly",
        version="1.0.0",
        description="Autonomous vent: reads temperature, drives servo, reports angle",
        category="Greenhouse",
    )
    ports = [
        input_port("temperature",  "Temperature",  "Ambient air temperature"),
        input_port("load_torque",  "Torque",       "Load on servo shaft",
                   required=False, default=0.0),
        output_port("vent_angle_deg", "VentAngle",     "Vent angle (°)"),
        output_port("vent_state",     "ControlSignal", "Openness 0-1"),
        output_port("is_open",        "BooleanSignal", "True if vent > 5°"),
    ]
    parameters = [
        Parameter("t_min", "Lower Threshold", "°C", float, 20.0),
        Parameter("t_max", "Upper Threshold", "°C", float, 35.0),
    ]

    def build(self, ctx: BuildContext) -> None:
        ctx.add(ThermostatController("_ctrl", param_values={
            "t_min": self.get_param("t_min"),
            "t_max": self.get_param("t_max"),
        }))
        ctx.add(ServoMotor("_servo"))
        ctx.add(VentPositionSensor("_pos"))

        ctx.connect(Connection("_ctrl",  "pwm_out",          "_servo", "pwm_signal"))
        ctx.connect(Connection("_servo", "angular_position", "_pos",   "servo_angle_rad"))

        ctx.map_input("temperature", "_ctrl",  "temperature")
        ctx.map_input("load_torque", "_servo", "load_torque")
        ctx.map_output("vent_angle_deg", "_pos",  "vent_angle_deg")
        ctx.map_output("vent_state",     "_ctrl", "vent_state")
        ctx.map_output("is_open",        "_pos",  "is_open")

ComponentRegistry.instance().register(GreenhouseVentAssembly)
```

Use it as a drop-in single component:

```python theme={null}
graph2 = SimulationGraph()
graph2.add_component(GreenhouseVentAssembly("vent", param_values={"t_min": 20.0, "t_max": 35.0}))
result = SimulationEngine(graph2).run(inputs={"vent": {"temperature": 30.0}})
print(result.get_output("vent", "vent_angle_deg"))  # 60.0°
```

## What you learned

| Concept                 | Where                                                                                                             |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Custom signal types     | `VentAngle`                                                                                                       |
| Component definition    | `ThermostatController`, `VentPositionSensor`                                                                      |
| Graph assembly          | `SimulationGraph.add_component()`, `.connect()`                                                                   |
| Steady-state simulation | `engine.run()` + temperature sweep                                                                                |
| Transient simulation    | `SimulationDomain.TRANSIENT` (see [Transient Simulation](/simulation/transient))                                  |
| Hosted ML models        | `hardwave.premium.create_model` / `append_records` / `train_model` (see [Hosted ML models](/reference/ml-models)) |
| Composite components    | `GreenhouseVentAssembly`                                                                                          |
| Serialization           | `graph.to_dict()` (see [Saving Graphs](/graphs/saving))                                                           |
| Cloud snapshots         | `graph.save(..., result=result)` (attach run outputs for the dashboard **RESULTS** tab)                           |
| Component faults        | [Component Faults and Health](/guides/component-faults) (motor thermal runaway, battery over-discharge)           |

<Tip>
  The full step-by-step tutorial with hosted ML models, transient, and fault examples is in the `TUTORIAL.md` file in the Hardwave repository.
</Tip>
