src/mine_sim/events.py

โ† Back to submission ยท View raw on GitHub

"""Event-log record schema for the mine throughput simulation.

``event_log.csv`` carries a fixed column set (prompt.md)::

    time_min, replication, scenario_id, truck_id, event_type, from_node,
    to_node, location, loaded, payload_tonnes, resource_id, queue_length

Defining the schema as a frozen dataclass means:

* The simulation cannot accidentally emit a malformed record โ€” every field
  is named at construction time.
* The CSV header order is centralised here, derived from the dataclass, so
  it cannot drift between the writer and any reader (e.g. the animation).
* Tests can construct synthetic events without importing SimPy.

Records are append-only and exist purely for traceability and animation.
KPI computation runs on a separate pure-Python accumulator
(:mod:`mine_sim.metrics`) so the two paths evolve independently.
"""

from __future__ import annotations

from dataclasses import dataclass, fields
from typing import Final

# ---------------------------------------------------------------------------
# Canonical event-type strings. The simulation must use these exact values so
# downstream tooling (event_log.csv, animation, tests) can pattern-match
# without copy-paste typos.
# ---------------------------------------------------------------------------
EVENT_DISPATCH: Final[str] = "dispatch"
EVENT_ARRIVE_LOADER: Final[str] = "arrive_loader"
EVENT_START_LOAD: Final[str] = "start_load"
EVENT_END_LOAD: Final[str] = "end_load"
EVENT_DEPART_LOADER: Final[str] = "depart_loader"
EVENT_ARRIVE_CRUSHER: Final[str] = "arrive_crusher"
EVENT_START_DUMP: Final[str] = "start_dump"
EVENT_END_DUMP: Final[str] = "end_dump"
EVENT_DEPART_CRUSHER: Final[str] = "depart_crusher"
EVENT_EDGE_ENTER: Final[str] = "edge_enter"
EVENT_EDGE_LEAVE: Final[str] = "edge_leave"


@dataclass(frozen=True)
class EventRecord:
    """One row of ``event_log.csv``.

    Fields that do not apply to a particular event type carry ``None``
    (rendered as the empty string / blank in CSV).
    """

    time_min: float
    replication: int
    scenario_id: str
    truck_id: str
    event_type: str
    from_node: str | None
    to_node: str | None
    location: str | None
    loaded: bool | None
    payload_tonnes: float | None
    resource_id: str | None
    queue_length: int | None

    def to_csv_row(self) -> dict[str, object]:
        """Render the record as a flat dict suitable for ``csv.DictWriter``."""
        return {
            "time_min": round(self.time_min, 6),
            "replication": self.replication,
            "scenario_id": self.scenario_id,
            "truck_id": self.truck_id,
            "event_type": self.event_type,
            "from_node": _blank_if_none(self.from_node),
            "to_node": _blank_if_none(self.to_node),
            "location": _blank_if_none(self.location),
            "loaded": _bool_to_csv(self.loaded),
            "payload_tonnes": (
                round(self.payload_tonnes, 6)
                if self.payload_tonnes is not None
                else ""
            ),
            "resource_id": _blank_if_none(self.resource_id),
            "queue_length": (
                int(self.queue_length) if self.queue_length is not None else ""
            ),
        }


def _blank_if_none(value: str | None) -> str:
    return "" if value is None else value


def _bool_to_csv(value: bool | None) -> str:
    if value is None:
        return ""
    return "true" if value else "false"


#: CSV column order. Derived from the dataclass so writer and schema cannot drift.
EVENT_CSV_COLUMNS: Final[tuple[str, ...]] = tuple(f.name for f in fields(EventRecord))


__all__ = [
    "EVENT_ARRIVE_CRUSHER",
    "EVENT_ARRIVE_LOADER",
    "EVENT_CSV_COLUMNS",
    "EVENT_DEPART_CRUSHER",
    "EVENT_DEPART_LOADER",
    "EVENT_DISPATCH",
    "EVENT_EDGE_ENTER",
    "EVENT_EDGE_LEAVE",
    "EVENT_END_DUMP",
    "EVENT_END_LOAD",
    "EVENT_START_DUMP",
    "EVENT_START_LOAD",
    "EventRecord",
]