Skip to content

4. MVP SimPy Integration

Overview

Location: popupsim/backend/src/shared/infrastructure/simulation/engines/simpy_adapter.py

SimPy provides discrete event simulation for the Retrofit Workflow Context. Integration follows a port/adapter pattern (SimulationEnginePort + SimPyEngineAdapter) to isolate SimPy dependencies.

Architecture

┌─────────────────────────────────────────┐
│   Retrofit Workflow Context            │
│   - Coordinators (Application)         │
│   - Domain Services (No SimPy)         │
└─────────────────┬───────────────────────┘
                  │
                  │ Uses
                  │
┌─────────────────▼───────────────────────┐
│   SimPy Adapter (Shared/Infrastructure) │
│   - Environment wrapper                 │
│   - Process management                  │
└─────────────────┬───────────────────────┘
                  │
                  │
┌─────────────────▼───────────────────────┐
│   SimPy Framework                       │
│   - Environment, Resource, Process      │
└─────────────────────────────────────────┘

SimPy Engine Adapter

File: shared/infrastructure/simulation/engines/simpy_adapter.py

SimPyEngineAdapter implements SimulationEnginePort and wraps a simpy.Environment:

import simpy
from collections.abc import Callable, Generator
from datetime import timedelta
from typing import Any


class SimPyEngineAdapter(SimulationEnginePort):
    """Adapter for the SimPy simulation environment."""

    @classmethod
    def create(cls) -> 'SimPyEngineAdapter':
        """Create an adapter with a fresh SimPy environment."""
        return cls(simpy.Environment())

    def current_time(self) -> float:
        """Current simulation time."""
        ...

    def delay(self, duration: float | timedelta) -> Generator[Any]:
        """Wait for the given duration (env.timeout)."""
        ...

    def schedule_process(self, process: Generator[Any] | Callable) -> Any:
        """Register a process (env.process)."""
        ...

    def create_resource(self, capacity: int, name: str | None = None) -> simpy.Resource: ...
    def create_store(self, capacity: int | None = None, name: str | None = None) -> Any: ...
    def create_event(self) -> Any: ...

    def run(self, until: float | None = None) -> None:
        """Run the simulation (env.run)."""
        ...

Coordinator Pattern

Coordinators use SimPy generators for discrete event simulation:

File: contexts/retrofit_workflow/application/coordinators/collection_coordinator.py

from typing import Generator, Any


class CollectionCoordinator:
    """Coordinates wagon collection."""

    def start(self) -> None:
        """Start coordinator process."""
        self.config.env.process(self._collection_process())

    def _collection_process(self) -> Generator[Any, Any, None]:
        """Main collection loop."""
        while True:
            # Wait for wagon
            wagon = yield self.config.collection_queue.get()

            # Collect batch
            wagons = yield from self._collect_batch(wagon)

            # Transport
            yield from self._transport_batch(wagons)

Resource Management

SimPy Resources manage limited capacity:

File: contexts/retrofit_workflow/infrastructure/resources/locomotive_resource_manager.py

The LocomotiveResourceManager uses SimPy to model the limited locomotive pool and hands out locomotives to the coordinators. Locomotives are keyed by their id. See the file for the exact API.

Event Bus Integration

External Trains Context publishes events via SimPy:

File: contexts/external_trains/application/external_trains_context.py

start_processes() schedules one SimPy process per train (via infra.engine.schedule_process(...)). Each process waits until the train's arrival time, creates the wagon entities, and publishes a TrainArrivedEvent onto the event bus:

def _process_single_train_arrival(self, train: Any) -> Any:
    """Process a single train arrival."""
    arrival_delay = datetime_to_ticks(train.arrival_time, self.scenario.start_date)
    yield from self.infra.engine.delay(arrival_delay)

    # ... create wagons ...
    event = TrainArrivedEvent(train_id=train.train_id, wagons=train_wagons, ...)
    self.event_bus.publish(event)

Testing

Unit Tests (No SimPy)

Domain services don't depend on SimPy, so they can be tested with plain objects:

def test_batch_formation() -> None:
    """Test a domain service without SimPy."""
    service = BatchFormationService()
    wagons = [Wagon(...) for _ in range(5)]

    batch = service.form_batch_for_workshop(wagons, ...)
    assert len(batch.wagon_ids) == 5

See popupsim/backend/tests/unit/contexts/retrofit_workflow/domain/ for the real tests and exact service signatures.

Integration Tests (With SimPy)

def test_collection_coordinator() -> None:
    """Test with SimPy."""
    env = simpy.Environment()
    queue = simpy.Store(env)

    coordinator = CollectionCoordinator(...)
    coordinator.start()

    # Add wagon
    queue.put(Wagon(...))

    # Run simulation
    env.run(until=100)

    # Verify wagon processed
    assert len(coordinator.processed_wagons) == 1

Best Practices

Do's

  • Keep domain logic SimPy-free
  • Use generators for coordinators
  • Isolate SimPy in infrastructure layer
  • Test domain logic without SimPy

Don'ts

  • Don't import SimPy in domain services
  • Don't put business logic in generators
  • Don't use global SimPy resources