diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c18c82d9..bab13f86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,8 +44,8 @@ uv run mkdocs serve This repo includes custom AI agent prompts to assist with development: -- [AGENTS.md](AGENTS.md) - General guidelines for working with the Plugboard codebase. -- [examples/AGENTS.md](examples/AGENTS.md) - Specific guidance for building example models and demos. -- Copilot-specific agents `docs`, `lint` and `test` which you can @-mention in a pull request. +- [AGENTS.md](https://github.com/plugboard-dev/plugboard/blob/main/AGENTS.md) - General guidelines for working with the Plugboard codebase. +- [examples/AGENTS.md](https://github.com/plugboard-dev/plugboard/blob/main/examples/AGENTS.md) - Specific guidance for building example models and demos. +- Copilot-specific agents `docs`, `lint` and `test`. If you use GitHub Copilot or other AI coding assistants that support the AGENTS.md convention, these prompts can help you build Plugboard models from a description of the process and/or the components that you would like to implement. We recommend using Copilot in agent mode and allowing it to implement the boilerplate code from your input prompt. diff --git a/examples/demos/fundamentals/003_factory_simulation/.meta.yml b/examples/demos/fundamentals/003_factory_simulation/.meta.yml new file mode 100644 index 00000000..dc32dbd0 --- /dev/null +++ b/examples/demos/fundamentals/003_factory_simulation/.meta.yml @@ -0,0 +1,3 @@ +tags: + - optimisation + - simulation diff --git a/examples/demos/fundamentals/003_factory_simulation/factory-simulation.ipynb b/examples/demos/fundamentals/003_factory_simulation/factory-simulation.ipynb new file mode 100644 index 00000000..4282e1ae --- /dev/null +++ b/examples/demos/fundamentals/003_factory_simulation/factory-simulation.ipynb @@ -0,0 +1,304 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Factory simulation with maintenance optimisation\n", + "\n", + "[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/plugboard-dev/plugboard/blob/main/examples/demos/fundamentals/003_factory_simulation/factory-simulation.ipynb)\n", + "\n", + "[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/plugboard-dev/plugboard)\n", + "\n", + "This demo simulates a factory containing 5 machines over 1 year (365 days). The simulation runs in steps of 1 day, with each machine exhibiting realistic behaviour:\n", + "\n", + "### Components:\n", + "\n", + "1. **Iterator** (`clock`): Drives the simulation by emitting day numbers from 0 to 364\n", + "2. **Machine** (5 instances, each with different reliability characteristics):\n", + " - Produces $10,000 of value per day when running\n", + " - Has a probability of random breakdown modelled as a sigmoid function of days since last maintenance — near zero soon after maintenance, rising to near 1 over time\n", + " - Stops for 5 days when it breaks down\n", + " - Has a proactive maintenance schedule: stops for 1 day at regular intervals\n", + "3. **Factory**: Aggregates daily output from all machines and tracks the cumulative total value\n", + "\n", + "### Machine reliability profiles:\n", + "\n", + "| Machine | Sigmoid steepness | Sigmoid midpoint | Profile |\n", + "|---------|------------------|-----------------|----------|\n", + "| 1 | 0.10 | 50 days | Reliable |\n", + "| 2 | 0.12 | 45 days | Average |\n", + "| 3 | 0.15 | 40 days | Average |\n", + "| 4 | 0.18 | 35 days | Fragile |\n", + "| 5 | 0.20 | 30 days | Very fragile |\n", + "\n", + "We can then use the model to find optimal proactive maintenance intervals for each machine to maximise total output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "# Install plugboard and dependencies for Google Colab\n", + "!pip install -q plugboard[ray]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "from plugboard.connector import AsyncioConnector\n", + "from plugboard.process import LocalProcess\n", + "from plugboard.schemas import ConnectorSpec\n", + "\n", + "from factory_simulation import Iterator, Machine, Factory, MACHINE_CONFIGS" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "The components are defined in `factory_simulation.py`:\n", + "\n", + "- **`Iterator`** emits a sequence of day numbers and closes once the configured number of days is reached.\n", + "- **`Machine`** tracks its own state (running, broken, or under maintenance). At each step it checks whether it is due for proactive maintenance, whether a random breakdown occurs (using a sigmoid probability curve), and outputs its daily production value.\n", + "- **`Factory`** sums daily values from all machines and maintains a running total.\n", + "\n", + "Each machine has different sigmoid parameters that control its breakdown probability curve, making some machines more reliable than others." + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "Now assemble the components into a `Process` and connect them together. Each machine receives the day count from the clock, and sends its daily output to the factory aggregator." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "connect = lambda s, t: AsyncioConnector(spec=ConnectorSpec(source=s, target=t))\n", + "\n", + "components = [Iterator(name=\"clock\", num_days=365)]\n", + "connectors = []\n", + "\n", + "for i, cfg in enumerate(MACHINE_CONFIGS, start=1):\n", + " components.append(Machine(name=f\"machine_{i}\", maintenance_interval=30, **cfg))\n", + " connectors.append(connect(\"clock.day\", f\"machine_{i}.day\"))\n", + " connectors.append(connect(f\"machine_{i}.daily_value\", f\"factory.value_{i}\"))\n", + "\n", + "components.append(Factory(name=\"factory\"))\n", + "\n", + "process = LocalProcess(components=components, connectors=connectors)\n", + "\n", + "print(f\"Process has {len(process.components)} components and {len(process.connectors)} connectors\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "We can create a diagram of the process to make a visual check.\n", + "\n", + "![Process Diagram](https://mermaid.ink/img/pako:eNrNlLEOgjAURX_lpSOBQYGFGOJkYqKLs4kp7SMQW2pqiTGEf1dAHZowUBzY7z3vnuU1hCmOJCG5UA9WUG3gcDpXAEwodt02cC_oDRPQqq44ch8EzVAksDeoqVF6k-nU8_qw50ELQZCCpKwoK7ysxuvHITK0f_mO0N2eBegn5JS9xz3H-7shMPQ_6e_5P6ivJy5f2-pOgEWohxOXh7a6E2AR6tHE5ZGt7gRYhHo8cXlsqzsB5qkTn0jUkpacJA0xBcruEXLMaS0MadsXZtO5rA==)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize the process flow\n", + "from plugboard.diagram import MermaidDiagram\n", + "\n", + "diagram_url = MermaidDiagram.from_process(process).url\n", + "print(diagram_url)" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Run the simulation for 365 days with a default maintenance interval of 30 days for all machines." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "# Run the simulation\n", + "async with process:\n", + " await process.run()" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "At the end of the simulation, we can read the cumulative total value from the `Factory` component." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "total_value = process.components[\"factory\"].total_value\n", + "max_possible = 365 * 5 * 10_000\n", + "print(f\"Total factory output over 365 days: ${total_value:,.0f}\")\n", + "print(f\"Maximum possible output: ${max_possible:,.0f}\")\n", + "print(f\"Efficiency: {total_value / max_possible:.1%}\")" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "Now suppose we want to find the optimal proactive maintenance interval for each machine. Maintaining too frequently wastes productive days, but maintaining too rarely leads to expensive breakdowns (5 days of downtime vs 1 day for scheduled maintenance).\n", + "\n", + "We can set up an optimisation to maximise `total_value` by varying the `maintenance_interval` argument on each machine. First, we build a `ProcessSpec` that describes the simulation programmatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "from plugboard.schemas import ProcessArgsSpec, ProcessSpec\n", + "\n", + "spec_components = [\n", + " {\"type\": \"factory_simulation.Iterator\", \"args\": {\"name\": \"clock\", \"num_days\": 365}},\n", + "]\n", + "spec_connectors = []\n", + "\n", + "for i, cfg in enumerate(MACHINE_CONFIGS, start=1):\n", + " spec_components.append(\n", + " {\n", + " \"type\": \"factory_simulation.Machine\",\n", + " \"args\": {\"name\": f\"machine_{i}\", \"maintenance_interval\": 30, **cfg},\n", + " }\n", + " )\n", + " spec_connectors.append({\"source\": \"clock.day\", \"target\": f\"machine_{i}.day\"})\n", + " spec_connectors.append({\"source\": f\"machine_{i}.daily_value\", \"target\": f\"factory.value_{i}\"})\n", + "\n", + "spec_components.append({\"type\": \"factory_simulation.Factory\", \"args\": {\"name\": \"factory\"}})\n", + "\n", + "spec = ProcessSpec(\n", + " args=ProcessArgsSpec(components=spec_components, connectors=spec_connectors),\n", + " type=\"plugboard.process.LocalProcess\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "Now configure the `Tuner` to search for the best `maintenance_interval` for each machine (between 5 and 60 days), maximising the factory's `total_value`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "from plugboard.schemas import IntParameterSpec, ObjectiveSpec\n", + "from plugboard.tune import Tuner\n", + "\n", + "tuner = Tuner(\n", + " objective=ObjectiveSpec(\n", + " object_type=\"component\",\n", + " object_name=\"factory\",\n", + " field_type=\"field\",\n", + " field_name=\"total_value\",\n", + " ),\n", + " parameters=[\n", + " IntParameterSpec(\n", + " object_type=\"component\",\n", + " object_name=f\"machine_{i}\",\n", + " field_type=\"arg\",\n", + " field_name=\"maintenance_interval\",\n", + " lower=5,\n", + " upper=60,\n", + " )\n", + " for i in range(1, 6)\n", + " ],\n", + " num_samples=40,\n", + " max_concurrent=4,\n", + " mode=\"max\",\n", + ")\n", + "\n", + "result = tuner.run(spec=spec)\n", + "\n", + "print(\"=== Optimisation Results ===\")\n", + "for i in range(1, 6):\n", + " key = f\"component.machine_{i}.arg.maintenance_interval\"\n", + " print(f\" Machine {i} optimal maintenance interval: {result.config[key]} days\")\n", + "print(f\" Maximum total value: ${result.metrics['component.factory.field.total_value']:,.0f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "Since each machine has different reliability characteristics, the optimal maintenance schedule will differ — more fragile machines benefit from more frequent maintenance, while reliable machines can run longer between stops.\n", + "\n", + "Alternatively, the same optimisation can be launched via the CLI using the YAML config in `factory-simulation.yaml`:\n", + "\n", + "```sh\n", + "plugboard process tune factory-simulation.yaml\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbformat_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/demos/fundamentals/003_factory_simulation/factory-simulation.yaml b/examples/demos/fundamentals/003_factory_simulation/factory-simulation.yaml new file mode 100644 index 00000000..e73298c0 --- /dev/null +++ b/examples/demos/fundamentals/003_factory_simulation/factory-simulation.yaml @@ -0,0 +1,112 @@ +plugboard: + process: + args: + components: + - type: factory_simulation.Iterator + args: + name: clock + num_days: 365 + - type: factory_simulation.Machine + args: + name: machine_1 + maintenance_interval: 30 + sigmoid_steepness: 0.10 + sigmoid_midpoint: 50.0 + seed: 101 + - type: factory_simulation.Machine + args: + name: machine_2 + maintenance_interval: 30 + sigmoid_steepness: 0.12 + sigmoid_midpoint: 45.0 + seed: 202 + - type: factory_simulation.Machine + args: + name: machine_3 + maintenance_interval: 30 + sigmoid_steepness: 0.15 + sigmoid_midpoint: 40.0 + seed: 303 + - type: factory_simulation.Machine + args: + name: machine_4 + maintenance_interval: 30 + sigmoid_steepness: 0.18 + sigmoid_midpoint: 35.0 + seed: 404 + - type: factory_simulation.Machine + args: + name: machine_5 + maintenance_interval: 30 + sigmoid_steepness: 0.20 + sigmoid_midpoint: 30.0 + seed: 505 + - type: factory_simulation.Factory + args: + name: factory + connectors: + - source: clock.day + target: machine_1.day + - source: clock.day + target: machine_2.day + - source: clock.day + target: machine_3.day + - source: clock.day + target: machine_4.day + - source: clock.day + target: machine_5.day + - source: machine_1.daily_value + target: factory.value_1 + - source: machine_2.daily_value + target: factory.value_2 + - source: machine_3.daily_value + target: factory.value_3 + - source: machine_4.daily_value + target: factory.value_4 + - source: machine_5.daily_value + target: factory.value_5 + tune: + args: + objective: + object_name: factory + field_type: field + field_name: total_value + parameters: + - type: ray.tune.randint + object_type: component + object_name: machine_1 + field_type: arg + field_name: maintenance_interval + lower: 5 + upper: 60 + - type: ray.tune.randint + object_type: component + object_name: machine_2 + field_type: arg + field_name: maintenance_interval + lower: 5 + upper: 60 + - type: ray.tune.randint + object_type: component + object_name: machine_3 + field_type: arg + field_name: maintenance_interval + lower: 5 + upper: 60 + - type: ray.tune.randint + object_type: component + object_name: machine_4 + field_type: arg + field_name: maintenance_interval + lower: 5 + upper: 60 + - type: ray.tune.randint + object_type: component + object_name: machine_5 + field_type: arg + field_name: maintenance_interval + lower: 5 + upper: 60 + num_samples: 40 + mode: max + max_concurrent: 4 diff --git a/examples/demos/fundamentals/003_factory_simulation/factory_simulation.py b/examples/demos/fundamentals/003_factory_simulation/factory_simulation.py new file mode 100644 index 00000000..dd43c545 --- /dev/null +++ b/examples/demos/fundamentals/003_factory_simulation/factory_simulation.py @@ -0,0 +1,151 @@ +"""Factory simulation demo. + +Simulates a factory with 5 machines over 1 year (365 days). Each machine: +- Produces $10,000 of value per day when running. +- Has a probability of random breakdown that increases with days since last maintenance + (modelled as a sigmoid function with different parameters per machine). +- Stops for 5 days when it breaks down. +- Has a proactive maintenance schedule: stops for 1 day at regular intervals. + +The simulation optimises the maintenance interval for each machine to maximise total output. +""" + +import math +import random +import typing as _t + +from plugboard.component import Component, IOController as IO +from plugboard.schemas import ComponentArgsDict + + +class Iterator(Component): + """Drives the simulation by emitting a sequence of day numbers.""" + + io = IO(outputs=["day"]) + + def __init__(self, num_days: int = 365, **kwargs: _t.Unpack[ComponentArgsDict]) -> None: + super().__init__(**kwargs) + self._num_days = num_days + + async def init(self) -> None: + self._seq = iter(range(self._num_days)) + + async def step(self) -> None: + try: + self.day = next(self._seq) + except StopIteration: + await self.io.close() + + +class Machine(Component): + """Simulates a single factory machine. + + The machine can be in one of three states: running, broken, or under maintenance. + When running, it produces a fixed daily output. Breakdown probability is modelled + as a sigmoid function of days since last maintenance, with configurable steepness + and midpoint parameters. Proactive maintenance occurs at regular intervals. + + Args: + maintenance_interval: Number of days between proactive maintenance stops. + breakdown_days: Number of days the machine is offline after a breakdown. + maintenance_days: Number of days the machine is offline for proactive maintenance. + daily_output: Value produced per day when running. + sigmoid_steepness: Controls how quickly breakdown probability rises. + sigmoid_midpoint: Days since maintenance at which breakdown probability is 50%. + seed: Random seed for reproducible results. + """ + + io = IO(inputs=["day"], outputs=["daily_value"]) + + def __init__( + self, + maintenance_interval: int = 30, + breakdown_days: int = 5, + maintenance_days: int = 1, + daily_output: float = 10_000.0, + sigmoid_steepness: float = 0.15, + sigmoid_midpoint: float = 40.0, + seed: int = 42, + **kwargs: _t.Unpack[ComponentArgsDict], + ) -> None: + super().__init__(**kwargs) + self._maintenance_interval = maintenance_interval + self._breakdown_days = breakdown_days + self._maintenance_days = maintenance_days + self._daily_output = daily_output + self._sigmoid_steepness = sigmoid_steepness + self._sigmoid_midpoint = sigmoid_midpoint + self._seed = seed + + async def init(self) -> None: + self._rng = random.Random(self._seed) + self._days_since_maintenance = 0 + self._downtime_remaining = 0 + + def _breakdown_probability(self) -> float: + """Sigmoid function: probability near 0 soon after maintenance, rising to ~1.""" + return 1.0 / ( + 1.0 + + math.exp( + -self._sigmoid_steepness * (self._days_since_maintenance - self._sigmoid_midpoint) + ) + ) + + async def step(self) -> None: + # If machine is down (breakdown or maintenance), count down + if self._downtime_remaining > 0: + self._downtime_remaining -= 1 + self.daily_value = 0.0 + if self._downtime_remaining == 0: + self._days_since_maintenance = 0 + return + + # Check for proactive maintenance + if ( + self._maintenance_interval > 0 + and self._days_since_maintenance > 0 + and self._days_since_maintenance % self._maintenance_interval == 0 + ): + self._downtime_remaining = self._maintenance_days + self.daily_value = 0.0 + return + + # Check for random breakdown + if self._rng.random() < self._breakdown_probability(): + self._downtime_remaining = self._breakdown_days + self.daily_value = 0.0 + return + + # Machine is running normally + self.daily_value = self._daily_output + self._days_since_maintenance += 1 + + +class Factory(Component): + """Aggregates daily output from all 5 machines and tracks total value.""" + + io = IO( + inputs=["value_1", "value_2", "value_3", "value_4", "value_5"], + outputs=["total_value"], + ) + + def __init__(self, **kwargs: _t.Unpack[ComponentArgsDict]) -> None: + super().__init__(**kwargs) + + async def init(self) -> None: + self._total = 0.0 + + async def step(self) -> None: + daily = self.value_1 + self.value_2 + self.value_3 + self.value_4 + self.value_5 + self._total += daily + self.total_value = self._total + + +# Machine configurations: each has different sigmoid parameters +MACHINE_CONFIGS: list[dict[str, _t.Any]] = [ + {"sigmoid_steepness": 0.10, "sigmoid_midpoint": 50.0, "seed": 101}, # Reliable + {"sigmoid_steepness": 0.12, "sigmoid_midpoint": 45.0, "seed": 202}, # Average + {"sigmoid_steepness": 0.15, "sigmoid_midpoint": 40.0, "seed": 303}, # Average + {"sigmoid_steepness": 0.18, "sigmoid_midpoint": 35.0, "seed": 404}, # Fragile + {"sigmoid_steepness": 0.20, "sigmoid_midpoint": 30.0, "seed": 505}, # Very fragile +] diff --git a/mkdocs.yaml b/mkdocs.yaml index 8cefcca5..199c291e 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -134,6 +134,7 @@ nav: - Fundamentals: - Simple model: examples/demos/fundamentals/001_simple_model/simple-model.ipynb - Production line: examples/demos/fundamentals/002_production_line_optimisation/production-line.ipynb + - Factory simulation: examples/demos/fundamentals/003_factory_simulation/factory-simulation.ipynb - LLMs: - Data filtering: examples/demos/llm/001_data_filter/llm-filtering.ipynb - Websocket streaming: examples/demos/llm/002_bluesky_websocket/bluesky-websocket.ipynb