Skip to content

Python API Reference

This page exposes the current PHIDS Python API using mkdocstrings.

Schemas and ingress

phids.api.schemas.base

Shared base model and annotated species-id aliases.

All schema submodules import StrictBaseModel, SpeciesId, HerbivoreId, and SubstanceId from here to avoid repetition and ensure consistent Rule-of-16 bound application across the entire ingress boundary.

StrictBaseModel

Bases: BaseModel

Base class enabling strict Pydantic v2 validation for all nested fields.

Source code in src/phids/api/schemas/base.py
class StrictBaseModel(BaseModel):
    """Base class enabling strict Pydantic v2 validation for all nested fields."""

    model_config = ConfigDict(strict=True)

phids.api.schemas.ecs

Pydantic schemata mirroring ECS component state for REST and WebSocket serialisation.

These models support inspection endpoints and state queries. They are never used to parameterise engine construction - see phids.api.schemas.simulation for that.

PlantComponentSchema

Bases: StrictBaseModel

Pydantic schema for the Plant ECS component.

Source code in src/phids/api/schemas/ecs.py
class PlantComponentSchema(StrictBaseModel):
    """Pydantic schema for the Plant ECS component."""

    entity_id: int = Field(..., description="Unique ECS entity identifier.")
    species_id: SpeciesId = Field(..., description="Flora species index [0, MAX_FLORA_SPECIES).")
    x: int = Field(..., ge=0, description="Grid x-coordinate.")
    y: int = Field(..., ge=0, description="Grid y-coordinate.")
    energy: float = Field(..., ge=0.0, description="[Absolute] Current energy reserve (E_i,j).")
    max_energy: float = Field(..., gt=0.0, description="[Absolute] Species-specific energy capacity (E_max).")
    base_energy: float = Field(..., gt=0.0, description="[Absolute] Initial energy E_i,j(0).")
    growth_rate: float = Field(
        ..., ge=0.0, description="[% Rate] Per-tick growth rate r_i,j. Expressed as a fraction (e.g. 0.05 = 5%)."
    )
    survival_threshold: float = Field(..., ge=0.0, description="[Absolute] Minimum energy B_i,j before death.")
    reproduction_interval: int = Field(..., gt=0, description="[Ticks] Ticks between reproduction attempts (T_i).")
    seed_min_dist: float = Field(..., ge=0.0, description="[Absolute] Minimum seed dispersal distance d_min.")
    seed_max_dist: float = Field(..., gt=0.0, description="[Absolute] Maximum seed dispersal distance d_max.")
    seed_energy_cost: float = Field(..., ge=0.0, description="[Absolute] Energy cost per reproduction event.")
    camouflage: bool = Field(default=False, description="[Flag] Constitutive gradient attenuation flag.")
    camouflage_factor: float = Field(default=1.0, ge=0.0, le=1.0, description="[%] Gradient multiplier (0.0 to 1.0).")
    last_reproduction_tick: int = Field(default=0, description="[Ticks] Last tick of reproduction.")
    apparent_nutrition_factor: float = Field(
        default=1.0, ge=0.0, le=1.0, description="[%] Current stress-induced nutrient discount (0.0 to 1.0)."
    )
    withdrawal_ticks_remaining: int = Field(
        default=0, ge=0, description="[Ticks] Ticks until apparent_nutrition_factor resets to 1.0."
    )

SubstanceComponentSchema

Bases: StrictBaseModel

Pydantic schema for a Substance (signal or toxin) ECS component.

Source code in src/phids/api/schemas/ecs.py
class SubstanceComponentSchema(StrictBaseModel):
    """Pydantic schema for a Substance (signal or toxin) ECS component."""

    entity_id: int = Field(..., description="Unique ECS entity identifier.")
    substance_id: SubstanceId = Field(..., description="Substance layer index.")
    owner_plant_id: int = Field(..., description="[ID] ECS entity id of the producing plant.")
    is_toxin: bool = Field(default=False, description="[Flag] True for toxins, False for signals.")
    synthesis_remaining: int = Field(default=0, ge=0, description="[Ticks] Ticks before substance becomes active.")
    active: bool = Field(default=False, description="[Flag] Whether the substance is currently active.")
    aftereffect_ticks: int = Field(default=0, ge=0, description="[Ticks] Remaining aftereffect duration T_k.")
    lethal: bool = Field(default=False, description="[Flag] Lethal toxin flag.")
    lethality_rate: float = Field(default=0.0, ge=0.0, description="[Absolute] Individuals eliminated per tick.")
    repellent: bool = Field(default=False, description="[Flag] Repellent toxin flag.")
    repellent_walk_ticks: int = Field(default=0, ge=0, description="[Ticks] Random-walk duration k on repel trigger.")
    energy_cost_per_tick: float = Field(
        default=0.0, ge=0.0, description="[Absolute] Energy cost drained from the owner plant per active tick."
    )
    irreversible: bool = Field(
        default=False,
        description="Whether activation is irreversible once the substance becomes active.",
    )

SwarmComponentSchema

Bases: StrictBaseModel

Pydantic schema for the Herbivore Swarm ECS component.

Source code in src/phids/api/schemas/ecs.py
class SwarmComponentSchema(StrictBaseModel):
    """Pydantic schema for the Herbivore Swarm ECS component."""

    entity_id: int = Field(..., description="Unique ECS entity identifier.")
    species_id: HerbivoreId = Field(..., description="Herbivore species index [0, MAX_HERBIVORE_SPECIES).")
    x: int = Field(..., ge=0, description="Grid x-coordinate.")
    y: int = Field(..., ge=0, description="Grid y-coordinate.")
    population: int = Field(..., gt=0, description="[Absolute] Current swarm head-count n(t).")
    initial_population: int = Field(..., gt=0, description="[Absolute] Initial population n(0) for mitosis.")
    energy: float = Field(..., ge=0.0, description="[Absolute] Current energy reserve.")
    energy_min: float = Field(..., gt=0.0, description="[Absolute] Minimum energy per individual E_min(e_h).")
    velocity: int = Field(..., gt=0, description="[Ticks] Ticks between moves. Higher is slower.")
    consumption_rate: float = Field(..., gt=0.0, description="[Absolute] Per-tick consumption scalar η(C_i).")
    energy_upkeep_per_individual: float = Field(
        default=0.05,
        ge=0.0,
        description="[Absolute] Per-individual metabolic upkeep scalar applied each tick.",
    )
    split_population_threshold: int = Field(
        default=10,
        gt=0,
        description="[Absolute] Explicit mitosis population threshold.",
    )
    repelled: bool = Field(default=False, description="Currently repelled by toxin.")
    repelled_ticks_remaining: int = Field(default=0, description="Ticks remaining in repelled random-walk.")

phids.api.schemas.conditions

Activation condition schemas for the compound chemical-defense trigger tree.

This module defines a recursive algebraic type tree composed of five node types, discriminated by their kind literal field. The ConditionNode union is a PEP 695 type alias that references both AllOfConditionSchema and AnyOfConditionSchema - both of which in turn reference ConditionNode in their conditions fields. This forward-reference cycle requires all five schemas and the alias to live in the same module, and the two composite schemas must call model_rebuild() after the alias is defined.

AllOfConditionSchema

Bases: StrictBaseModel

Boolean AND over nested activation predicates.

Source code in src/phids/api/schemas/conditions.py
class AllOfConditionSchema(StrictBaseModel):
    """Boolean AND over nested activation predicates."""

    kind: Literal["all_of"] = "all_of"
    conditions: list[ConditionNode] = Field(
        ...,
        min_length=1,
        description="All child predicates must evaluate to true.",
    )

AnyOfConditionSchema

Bases: StrictBaseModel

Boolean OR over nested activation predicates.

Source code in src/phids/api/schemas/conditions.py
class AnyOfConditionSchema(StrictBaseModel):
    """Boolean OR over nested activation predicates."""

    kind: Literal["any_of"] = "any_of"
    conditions: list[ConditionNode] = Field(
        ...,
        min_length=1,
        description="At least one child predicate must evaluate to true.",
    )

EnvironmentalSignalConditionSchema

Bases: StrictBaseModel

Leaf predicate requiring a minimum ambient signal concentration at the owner's cell.

Source code in src/phids/api/schemas/conditions.py
class EnvironmentalSignalConditionSchema(StrictBaseModel):
    """Leaf predicate requiring a minimum ambient signal concentration at the owner's cell."""

    kind: Literal["environmental_signal"] = "environmental_signal"
    signal_id: SubstanceId = Field(..., description="Signal layer identifier to read from the environment.")
    min_concentration: float = Field(
        default=0.01,
        ge=0.0,
        description="Minimum concentration required for this predicate.",
    )

HerbivorePresenceConditionSchema

Bases: StrictBaseModel

Leaf predicate requiring a herbivore species at the owner's cell.

Source code in src/phids/api/schemas/conditions.py
class HerbivorePresenceConditionSchema(StrictBaseModel):
    """Leaf predicate requiring a herbivore species at the owner's cell."""

    kind: Literal["herbivore_presence"] = "herbivore_presence"
    herbivore_species_id: HerbivoreId
    min_herbivore_population: int = Field(
        default=1,
        gt=0,
        description="Minimum co-located herbivore population required for this predicate.",
    )

SubstanceActiveConditionSchema

Bases: StrictBaseModel

Leaf predicate requiring another substance to already be active.

Source code in src/phids/api/schemas/conditions.py
class SubstanceActiveConditionSchema(StrictBaseModel):
    """Leaf predicate requiring another substance to already be active."""

    kind: Literal["substance_active"] = "substance_active"
    substance_id: SubstanceId = Field(..., description="Active substance required for this predicate.")

phids.api.schemas.triggers

Trigger action schemas and the TriggerConditionSchema interaction-matrix entry.

TriggerAction is a discriminated union of two concrete action types: SynthesizeSubstanceAction (active chemical defense) and ResourceWithdrawalAction (apparent nutrition reduction / stress response). TriggerConditionSchema maps a (plant, herbivore) species pair to an action and an optional compound activation-condition predicate tree.

EnvironmentalSignalInitiator

Bases: StrictBaseModel

Initiator that triggers when an environmental signal reaches a concentration threshold.

Source code in src/phids/api/schemas/triggers.py
class EnvironmentalSignalInitiator(StrictBaseModel):
    """Initiator that triggers when an environmental signal reaches a concentration threshold."""

    type: Literal["environmental_signal"] = "environmental_signal"
    signal_id: int = Field(..., ge=0, description="The substance ID of the environmental signal to monitor.")
    min_concentration: float = Field(
        default=0.01, ge=0.0, description="Minimum concentration threshold in the flow field."
    )
    response_curve: Literal["step", "hill", "logarithmic"] = Field(
        default="step", description="Dose-response curve type: step, hill (sigmoidal kinetics), or logarithmic."
    )
    hill_cooperativity: float = Field(
        default=2.0, gt=0.0, description="Hill cooperativity exponent n controlling dose-response steepness."
    )
    half_saturation: float = Field(
        default=0.05, gt=0.0, description="Semi-saturation constant Kd (concentration where response is 50%)."
    )

HerbivoreAttackInitiator

Bases: StrictBaseModel

Initiator that triggers when a herbivore population reaches a threshold.

Source code in src/phids/api/schemas/triggers.py
class HerbivoreAttackInitiator(StrictBaseModel):
    """Initiator that triggers when a herbivore population reaches a threshold."""

    type: Literal["herbivore_attack"] = "herbivore_attack"
    herbivore_species_id: HerbivoreId
    min_herbivore_population: int = Field(..., gt=0, description="Minimum swarm size n_i,min to trigger the rule.")

PassiveDefensesSchema

Bases: StrictBaseModel

Morphological (passive) defenses of a flora species.

Source code in src/phids/api/schemas/triggers.py
class PassiveDefensesSchema(StrictBaseModel):
    """Morphological (passive) defenses of a flora species."""

    mechanical_damage_per_bite: float = Field(
        default=0.0, ge=0.0, description="[Absolute] Thorns/spines damage per feeding event."
    )
    digestibility_modifier: float = Field(
        default=1.0,
        ge=0.0,
        le=1.0,
        description="[%] Lignin/silica calorie discount multiplier (e.g. 0.5 = 50% metabolized).",
    )

ResourceWithdrawalAction

Bases: StrictBaseModel

Action to trigger apparent nutrition withdrawal (stress response).

Source code in src/phids/api/schemas/triggers.py
class ResourceWithdrawalAction(StrictBaseModel):
    """Action to trigger apparent nutrition withdrawal (stress response)."""

    type: Literal["resource_withdrawal"] = "resource_withdrawal"
    apparent_nutrition_factor: float = Field(
        default=1.0, ge=0.0, le=1.0, description="[%] Multiplier for energy apparent to herbivores and flow field."
    )
    withdrawal_duration: int = Field(
        default=10,
        gt=0,
        description=(
            "[Ticks] Duration of the nutrition withdrawal. It determines how many ticks the apparent "
            "nutrition factor will remain dimmed. Note that the duration is immediately decremented by 1 at the end "
            "of the tick the trigger fires, so a duration of 1 means it recovers on the next tick."
        ),
    )

SynthesizeSubstanceAction

Bases: StrictBaseModel

Action to synthesize a specific chemical substance.

Source code in src/phids/api/schemas/triggers.py
class SynthesizeSubstanceAction(StrictBaseModel):
    """Action to synthesize a specific chemical substance."""

    type: Literal["synthesize_substance"] = "synthesize_substance"
    substance_id: SubstanceId = Field(..., description="[ID] Substance to synthesise.")
    synthesis_duration: int = Field(..., gt=0, description="[Ticks] Ticks to synthesise T(s_x).")
    is_toxin: bool = Field(default=False, description="[Flag] True for toxins, False for signals.")
    lethal: bool = Field(default=False, description="[Flag] Lethal toxin flag.")
    lethality_rate: float = Field(default=0.0, ge=0.0, description="[Absolute] Individuals eliminated per tick.")
    repellent: bool = Field(default=False, description="[Flag] Repellent toxin flag.")
    repellent_walk_ticks: int = Field(default=0, ge=0, description="[Ticks] Random-walk duration k on repel trigger.")
    energy_cost_per_tick: float = Field(
        default=0.0, ge=0.0, description="[Absolute] Energy drained from the plant per tick while active."
    )
    irreversible: bool = Field(
        default=False,
        description="If true, activation is irreversible: once active, the substance remains active until owner death.",
    )

TriggerConditionSchema

Bases: StrictBaseModel

Trigger condition for defense actions (Interaction Matrix entry).

Maps an initiator (e.g. herbivore presence, environmental signal) to an action that should be executed when the trigger conditions are met.

Source code in src/phids/api/schemas/triggers.py
class TriggerConditionSchema(StrictBaseModel):
    """Trigger condition for defense actions (Interaction Matrix entry).

    Maps an initiator (e.g. herbivore presence, environmental signal) to an action
    that should be executed when the trigger conditions are met.
    """

    model_config = ConfigDict(extra="forbid")

    initiator: TriggerInitiator = Field(..., description="The condition that initiates this rule.")
    aftereffect_ticks: int = Field(
        default=0,
        ge=0,
        description="Aftereffect duration T_k (action effect lingers after trigger ceases).",
    )
    activation_condition: ConditionNode | None = Field(
        default=None,
        description=(
            "Optional nested predicate tree controlling whether the configured action may activate. "
            "Supports explicit all_of/any_of composition over herbivore_presence and substance_active leaves."
        ),
    )
    action: TriggerAction = Field(..., description="The action to perform when triggered.")

    @model_validator(mode="before")
    @classmethod
    def _map_legacy_trigger_fields(cls, data: dict[str, Any]) -> dict[str, Any]:
        """Maps legacy `herbivore_species_id` and `min_herbivore_population` to `initiator`."""
        if isinstance(data, dict) and "initiator" not in data:
            if "herbivore_species_id" in data:
                data["initiator"] = {
                    "type": "herbivore_attack",
                    "herbivore_species_id": data.pop("herbivore_species_id"),
                    "min_herbivore_population": data.pop("min_herbivore_population", 5),
                }
        return data

phids.api.schemas.species

Per-species parameter schemas for flora and herbivore species, plus the diet matrix.

FloraSpeciesParams and HerbivoreSpeciesParams parameterise SimulationLoop construction. DietCompatibilityMatrix enforces Rule-of-16 shape bounds on both dimensions of the herbivore-flora edibility matrix.

DietCompatibilityMatrix

Bases: StrictBaseModel

Boolean matrix [herbivore_species, flora_species] indicating edibility.

Source code in src/phids/api/schemas/species.py
class DietCompatibilityMatrix(StrictBaseModel):
    """Boolean matrix [herbivore_species, flora_species] indicating edibility."""

    rows: list[list[bool]] = Field(
        ...,
        description=(
            "Outer index = herbivore species id, inner index = flora species id. "
            "True means the herbivore can consume that flora species."
        ),
    )

    @model_validator(mode="after")
    def _validate_shape(self) -> DietCompatibilityMatrix:
        n_herbivore = len(self.rows)
        if n_herbivore > MAX_HERBIVORE_SPECIES:
            raise ValueError(f"DietCompatibilityMatrix has {n_herbivore} rows, max is {MAX_HERBIVORE_SPECIES}.")
        for row in self.rows:
            if len(row) > MAX_FLORA_SPECIES:
                raise ValueError(f"DietCompatibilityMatrix row length {len(row)} exceeds {MAX_FLORA_SPECIES}.")
        return self

FloraSpeciesParams

Bases: StrictBaseModel

Per-species parameters for flora.

Source code in src/phids/api/schemas/species.py
class FloraSpeciesParams(StrictBaseModel):
    """Per-species parameters for flora."""

    species_id: SpeciesId
    name: str
    base_energy: float = Field(..., gt=0.0)
    max_energy: float = Field(..., gt=0.0)
    growth_rate: float = Field(..., ge=0.0)
    survival_threshold: float = Field(..., ge=0.0)
    reproduction_interval: int = Field(..., gt=0)
    seed_min_dist: float = Field(default=1.0, ge=0.0)
    seed_max_dist: float = Field(default=3.0, gt=0.0)
    seed_energy_cost: float = Field(default=5.0, ge=0.0)
    seed_drop_height: float = Field(
        default=SEED_DROP_HEIGHT_DEFAULT,
        gt=0.0,
        description=("Approximate seed release height used for wind-flight-time estimation in anemochorous dispersal."),
    )
    seed_terminal_velocity: float = Field(
        default=SEED_TERMINAL_VELOCITY_DEFAULT,
        gt=0.0,
        description=("Approximate seed terminal fall velocity used for wind-driven downwind shift estimation."),
    )
    camouflage: bool = False
    camouflage_factor: float = Field(default=1.0, ge=0.0, le=1.0)
    translocation_rate: float = Field(
        default=0.2, ge=0.0, le=1.0, description="Rate of nutrient translocation during resource withdrawal."
    )
    mycorrhizal_tax_per_link: float = Field(
        default=0.0, ge=0.0, description="Continuous energy maintenance fee deducted per active root link per tick."
    )
    passive_defenses: PassiveDefensesSchema = Field(default_factory=PassiveDefensesSchema)
    triggers: list[TriggerConditionSchema] = Field(default_factory=list, max_length=MAX_SUBSTANCE_TYPES)

HerbivoreResistancesSchema

Bases: StrictBaseModel

Herbivore resistances to passive plant defenses.

Source code in src/phids/api/schemas/species.py
class HerbivoreResistancesSchema(StrictBaseModel):
    """Herbivore resistances to passive plant defenses."""

    morphological_adaptation: float = Field(
        default=0.0,
        ge=0.0,
        le=1.0,
        description="[%] Resistance to physical plant defenses like thorns or spines (0.0 to 1.0).",
    )
    chemical_neutralization: float = Field(
        default=0.0, ge=0.0, le=1.0, description="[%] Metabolic ability to neutralize ingested toxins (0.0 to 1.0)."
    )
    digestive_efficiency: float = Field(
        default=1.0,
        ge=0.0,
        description="[%] Ability to extract calories from tough plant matter (0.0+ multiplier).",
    )

HerbivoreSpeciesParams

Bases: StrictBaseModel

Per-species parameters for herbivore swarms.

Source code in src/phids/api/schemas/species.py
class HerbivoreSpeciesParams(StrictBaseModel):
    """Per-species parameters for herbivore swarms."""

    species_id: HerbivoreId
    name: str
    energy_min: float = Field(..., gt=0.0)
    velocity: int = Field(..., gt=0)
    consumption_rate: float = Field(..., gt=0.0)
    handling_time: float = Field(
        default=0.0,
        ge=0.0,
        description="Handling time per calorie eaten (Holling Type II functional response). 0.0 retains linear intake.",
    )
    behavior_paradigm: SwarmBehaviorParadigm = Field(
        default="macro_swarm",
        description="Behavioral paradigm governing movement and foraging kinetics.",
    )
    reproduction_energy_divisor: float = Field(
        default=1.0,
        gt=0.0,
        validation_alias=AliasChoices("reproduction_energy_divisor", "reproduction_divisor"),
        description="Denominator for φ(e_h,t) = floor(R(C_i,t) / E_min(e_h)).",
    )
    energy_upkeep_per_individual: float = Field(
        default=0.05,
        ge=0.0,
        description="Per-individual metabolic upkeep scalar applied every interaction tick.",
    )
    resistances: HerbivoreResistancesSchema = Field(default_factory=HerbivoreResistancesSchema)
    split_population_threshold: int = Field(
        default=10,
        gt=0,
        description="Explicit population threshold for mitosis.",
    )

phids.api.schemas.placement

Initial placement and procedural placement strategy schemas.

InitialPlantPlacement and InitialSwarmPlacement define manually positioned entities at simulation start. PlacementStrategy is a discriminated union of three procedural seeding algorithms used when placement_mode = "procedural".

BandedPlacement

Bases: StrictBaseModel

Entities placed in dense lines/stripes.

Source code in src/phids/api/schemas/placement.py
class BandedPlacement(StrictBaseModel):
    """Entities placed in dense lines/stripes."""

    type: Literal["banded"] = "banded"
    band_count: int = Field(..., ge=1)
    orientation: Literal["horizontal", "vertical"] = "horizontal"

ClusteredPlacement

Bases: StrictBaseModel

Groups of entities clustered around random centroids.

Source code in src/phids/api/schemas/placement.py
class ClusteredPlacement(StrictBaseModel):
    """Groups of entities clustered around random centroids."""

    type: Literal["clustered"] = "clustered"
    cluster_count: int = Field(..., ge=1)
    variance: float = Field(..., gt=0.0)

InitialPlantPlacement

Bases: StrictBaseModel

Single plant to place at simulation start.

Source code in src/phids/api/schemas/placement.py
class InitialPlantPlacement(StrictBaseModel):
    """Single plant to place at simulation start."""

    species_id: SpeciesId
    x: int = Field(..., ge=0)
    y: int = Field(..., ge=0)
    energy: float = Field(..., gt=0.0)

InitialSwarmPlacement

Bases: StrictBaseModel

Single swarm to place at simulation start.

Source code in src/phids/api/schemas/placement.py
class InitialSwarmPlacement(StrictBaseModel):
    """Single swarm to place at simulation start."""

    species_id: HerbivoreId
    x: int = Field(..., ge=0)
    y: int = Field(..., ge=0)
    population: int = Field(..., gt=0)
    energy: float = Field(..., gt=0.0)

UniformPlacement

Bases: StrictBaseModel

Randomly scattered entities.

Source code in src/phids/api/schemas/placement.py
class UniformPlacement(StrictBaseModel):
    """Randomly scattered entities."""

    type: Literal["uniform"] = "uniform"
    density: float = Field(..., ge=0.0, le=1.0)

phids.api.schemas.simulation

Authoritative simulation configuration schema.

SimulationConfig is the single validated ingress container for all data that parameterises SimulationLoop construction. Its model_validator enforces cross-field reference integrity between placement species identifiers and the declared species lists before any engine state is allocated.

SimulationConfig

Bases: StrictBaseModel

Complete simulation configuration payload (REST /api/scenario/load body).

Source code in src/phids/api/schemas/simulation.py
class SimulationConfig(StrictBaseModel):
    """Complete simulation configuration payload (REST /api/scenario/load body)."""

    grid_width: int = Field(default=40, ge=1, le=200)
    grid_height: int = Field(default=40, ge=1, le=200)
    max_ticks: int = Field(default=1000, gt=0)
    tick_rate_hz: float = Field(default=10.0, gt=0.0, description="WebSocket stream tick rate.")

    num_signals: int = Field(default=4, ge=1, le=MAX_SUBSTANCE_TYPES)
    num_toxins: int = Field(default=4, ge=1, le=MAX_SUBSTANCE_TYPES)

    wind_x: float = Field(default=0.0, description="Initial wind vector x-component.")
    wind_y: float = Field(default=0.0, description="Initial wind vector y-component.")

    flora_species: list[FloraSpeciesParams] = Field(..., min_length=1, max_length=MAX_FLORA_SPECIES)
    herbivore_species: list[HerbivoreSpeciesParams] = Field(..., min_length=1, max_length=MAX_HERBIVORE_SPECIES)
    diet_matrix: DietCompatibilityMatrix

    placement_mode: Literal["manual", "procedural"] = "manual"
    flora_placement_strategy: PlacementStrategy | None = None
    herbivore_placement_strategy: PlacementStrategy | None = None

    initial_plants: list[InitialPlantPlacement] = Field(default_factory=list)
    initial_swarms: list[InitialSwarmPlacement] = Field(default_factory=list)

    # Symbiotic network settings
    mycorrhizal_inter_species: bool = Field(default=False, description="Allow inter-species root connections.")
    mycorrhizal_connection_cost: float = Field(default=1.0, ge=0.0, description="Energy cost to establish a root link.")
    mycorrhizal_growth_interval_ticks: int = Field(
        default=8,
        ge=1,
        le=256,
        description=("Ticks between mycorrhizal growth attempts. At most one new root link is formed per interval."),
    )
    mycorrhizal_signal_velocity: int = Field(default=1, gt=0, description="Signal transfer speed t_g (ticks per hop).")

    # Termination conditions
    z2_flora_species_extinction: int = Field(
        default=-1, description="Halt when this flora species id goes extinct (-1 = disabled)."
    )
    z4_herbivore_species_extinction: int = Field(
        default=-1,
        description="Halt when this herbivore species id goes extinct (-1 = disabled).",
    )
    z6_max_total_flora_energy: float = Field(
        default=-1.0, description="Halt when total flora energy exceeds this value (-1 = disabled)."
    )
    z7_max_total_herbivore_population: int = Field(
        default=-1,
        description="Halt when total herbivore population exceeds this value (-1 = disabled).",
    )

    # Configurable Chemotaxis and Navigation Parameters
    chemotaxis_alpha: float = Field(
        default=1.0,
        ge=0.0,
        description="Weighting coefficient for botanical attractants.",
        json_schema_extra={
            "ui_category": "Chemotaxis & Navigation",
            "sensitivity": "High Impact",
            "effects": ("Increasing this makes swarms more desperate to reach food, potentially ignoring toxins."),
        },
    )
    chemotaxis_beta: float = Field(
        default=1.0,
        ge=0.0,
        description="Weighting coefficient for toxic repellents.",
        json_schema_extra={
            "ui_category": "Chemotaxis & Navigation",
            "sensitivity": "High Impact",
            "effects": (
                "Increasing this makes swarms extremely averse to toxins, "
                "potentially starving before crossing a defensive perimeter."
            ),
        },
    )
    chemotaxis_decay: float = Field(
        default=0.6,
        ge=0.0,
        le=1.0,
        description="Propagation decay factor for the flow field.",
        json_schema_extra={
            "ui_category": "Chemotaxis & Navigation",
            "sensitivity": "Advanced Tuning",
            "effects": (
                "Higher values allow the chemotaxis gradient to propagate further distances, "
                "effectively increasing the sensory horizon of swarms."
            ),
        },
    )
    chemotaxis_truncate_threshold: float = Field(
        default=1e-4,
        ge=0.0,
        description="Subnormal truncation threshold.",
        json_schema_extra={
            "ui_category": "Chemotaxis & Navigation",
            "sensitivity": "Advanced Math Tuning",
            "effects": (
                "Prevents float denormalization slowdowns in the Numba JIT solver "
                "by zeroing out infinitesimal gradients."
            ),
        },
    )

    # Configurable diffusion / emission constants (runtime-overridable via DSE)
    signal_decay_factor: float = Field(
        default=0.85,
        gt=0.0,
        le=1.0,
        description=(
            "Per-tick airborne signal retention after Gaussian diffusion (0.0-1.0). "
            "1.0 = no decay; values closer to 0.0 cause total dissipation each tick. "
            "Exposed in the UI as Signal Decay (%)."
        ),
    )
    substance_emit_rate: float = Field(
        default=0.1,
        gt=0.0,
        le=1.0,
        description=(
            "Concentration increment added to a signal or toxin layer per tick "
            "when an active SubstanceComponent emits into the environment. "
            "Exposed in the UI as Substance Emit Rate (%)."
        ),
    )

    # Replay backend selection
    replay_backend: str = Field(
        default="zarr",
        description="Replay storage backend.",
        pattern="^zarr$",
    )

    @model_validator(mode="after")
    def _validate_species_ids(self) -> SimulationConfig:
        flora_ids = {s.species_id for s in self.flora_species}
        herbivore_ids = {s.species_id for s in self.herbivore_species}
        for plant_placement in self.initial_plants:
            if plant_placement.species_id not in flora_ids:
                raise ValueError(
                    f"InitialPlantPlacement references unknown flora species {plant_placement.species_id}."
                )
        for swarm_placement in self.initial_swarms:
            if swarm_placement.species_id not in herbivore_ids:
                raise ValueError(
                    f"InitialSwarmPlacement references unknown herbivore species {swarm_placement.species_id}."
                )
        return self

phids.api.schemas.responses

REST API response and request payload schemas.

These models are used exclusively at the HTTP boundary and carry no engine-internal state. They are never passed into SimulationLoop construction.

BatchJobState

Bases: StrictBaseModel

Runtime state record for a single Monte Carlo batch simulation job.

Each batch job corresponds to N independent simulation runs dispatched to a :class:concurrent.futures.ProcessPoolExecutor. Progress is tracked as completed run count relative to the total, and the final aggregate summary is persisted to disk for retrieval via the ledger and view endpoints.

Attributes:

Name Type Description
job_id str

Universally unique identifier assigned at job creation.

status Literal['queued', 'running', 'done', 'failed']

Lifecycle state of the job.

completed int

Number of runs that have completed (successfully or not).

total int

Total number of runs requested.

scenario_name str

Display label derived from the source scenario config.

started_at str

ISO-8601 timestamp of job creation.

finished_at str | None

ISO-8601 timestamp of completion, or None if pending.

max_ticks int

Maximum tick count per individual run.

Source code in src/phids/api/schemas/responses.py
class BatchJobState(StrictBaseModel):
    """Runtime state record for a single Monte Carlo batch simulation job.

    Each batch job corresponds to ``N`` independent simulation runs dispatched
    to a :class:`concurrent.futures.ProcessPoolExecutor`. Progress is tracked as
    completed run count relative to the total, and the final aggregate summary is
    persisted to disk for retrieval via the ledger and view endpoints.

    Attributes:
        job_id: Universally unique identifier assigned at job creation.
        status: Lifecycle state of the job.
        completed: Number of runs that have completed (successfully or not).
        total: Total number of runs requested.
        scenario_name: Display label derived from the source scenario config.
        started_at: ISO-8601 timestamp of job creation.
        finished_at: ISO-8601 timestamp of completion, or ``None`` if pending.
        max_ticks: Maximum tick count per individual run.

    """

    job_id: str
    status: Literal["queued", "running", "done", "failed"]
    completed: int = 0
    total: int = 1
    scenario_name: str = "unnamed"
    started_at: str = ""
    finished_at: str | None = None
    max_ticks: int = 500

BatchStartPayload

Bases: StrictBaseModel

HTTP request payload for initiating a Monte Carlo batch simulation job.

Encapsulates the simulation scenario and batch execution parameters submitted via POST /api/batch/start. The scenario field accepts a complete :class:SimulationConfig that overrides the current server-side draft, enabling fully reproducible parameterized batch studies.

Attributes:

Name Type Description
runs int

Number of independent simulation runs to execute in parallel.

max_ticks int

Maximum simulation tick count per run.

scenario_name str

Optional display label for the ledger.

Source code in src/phids/api/schemas/responses.py
class BatchStartPayload(StrictBaseModel):
    """HTTP request payload for initiating a Monte Carlo batch simulation job.

    Encapsulates the simulation scenario and batch execution parameters
    submitted via ``POST /api/batch/start``. The ``scenario`` field accepts a
    complete :class:`SimulationConfig` that overrides the current server-side
    draft, enabling fully reproducible parameterized batch studies.

    Attributes:
        runs: Number of independent simulation runs to execute in parallel.
        max_ticks: Maximum simulation tick count per run.
        scenario_name: Optional display label for the ledger.

    """

    runs: int = Field(default=10, ge=1, le=256, description="Number of parallel Monte Carlo runs.")
    max_ticks: int = Field(default=500, gt=0, description="Maximum ticks per run.")
    scenario_name: str = Field(default="", description="Optional display label for the job ledger.")

SimulationStatusResponse

Bases: StrictBaseModel

Response model for simulation state queries.

Source code in src/phids/api/schemas/responses.py
class SimulationStatusResponse(StrictBaseModel):
    """Response model for simulation state queries."""

    tick: int
    tick_rate_hz: float
    running: bool
    paused: bool
    terminated: bool
    termination_reason: str | None = None

TickRateUpdatePayload

Bases: StrictBaseModel

REST payload for dynamically updating live simulation tick speed.

Source code in src/phids/api/schemas/responses.py
class TickRateUpdatePayload(StrictBaseModel):
    """REST payload for dynamically updating live simulation tick speed."""

    tick_rate_hz: float = Field(default=10.0, gt=0.0)

WindUpdatePayload

Bases: StrictBaseModel

REST payload for dynamically updating wind vectors.

Source code in src/phids/api/schemas/responses.py
class WindUpdatePayload(StrictBaseModel):
    """REST payload for dynamically updating wind vectors."""

    wind_x: float
    wind_y: float

phids.io.scenario

Scenario I/O helpers for loading, validating, and serialising SimulationConfig instances.

This module provides three convenience functions that bridge external JSON representations of simulation scenarios and the Pydantic-validated SimulationConfig model. Validation is performed by :func:load_scenario_from_dict via SimulationConfig.model_validate, which enforces all Rule-of-16 cardinality bounds, species-placement reference integrity, diet-matrix shape constraints, and termination-threshold range checks before any engine state is allocated. Any violation raises a pydantic.ValidationError, preventing malformed configuration data from reaching the GridEnvironment or ECSWorld constructors.

:func:load_scenario_from_json reads a UTF-8 encoded JSON file, decodes it with the standard library json module, and delegates to :func:load_scenario_from_dict. :func:scenario_to_json serialises a validated configuration back to canonical JSON using Pydantic's model_dump_json method, which preserves all default values and respects custom field serialisers defined in the schema layer. Optionally, the output may be written to a file path, enabling scenario export from the REST API and from command-line scripting workflows.

load_scenario_from_dict(data: JSONMapping) -> SimulationConfig

Parse and validate a simulation configuration from a mapping.

Parameters:

Name Type Description Default
data JSONMapping

Raw configuration mapping (typically decoded from JSON).

required

Returns:

Name Type Description
SimulationConfig SimulationConfig

Validated Pydantic configuration instance.

Raises:

Type Description
ValidationError

If the configuration is invalid.

Source code in src/phids/io/scenario.py
def load_scenario_from_dict(data: JSONMapping) -> SimulationConfig:
    """Parse and validate a simulation configuration from a mapping.

    Args:
        data: Raw configuration mapping (typically decoded from JSON).

    Returns:
        SimulationConfig: Validated Pydantic configuration instance.

    Raises:
        pydantic.ValidationError: If the configuration is invalid.

    """
    config = SimulationConfig.model_validate(dict(data))
    logger.debug(
        "Scenario validated from mapping (grid=%dx%d, flora=%d, herbivores=%d)",
        config.grid_width,
        config.grid_height,
        len(config.flora_species),
        len(config.herbivore_species),
    )
    return config

load_scenario_from_json(path: str | Path) -> SimulationConfig

Load and validate a simulation configuration from a JSON file.

Parameters:

Name Type Description Default
path str | Path

Path to the JSON scenario file.

required

Returns:

Name Type Description
SimulationConfig SimulationConfig

Validated Pydantic configuration instance.

Source code in src/phids/io/scenario.py
def load_scenario_from_json(path: str | Path) -> SimulationConfig:
    """Load and validate a simulation configuration from a JSON file.

    Args:
        path: Path to the JSON scenario file.

    Returns:
        SimulationConfig: Validated Pydantic configuration instance.

    """
    source = Path(path)
    raw = source.read_text(encoding="utf-8")
    decoded: JSONValue = json.loads(raw)
    if not isinstance(decoded, dict):
        raise ValueError(f"Scenario JSON root must be an object: {source}")
    data: dict[str, JSONValue] = decoded
    config = load_scenario_from_dict(data)
    logger.info("Scenario loaded from %s", source)
    return config

scenario_to_json(config: SimulationConfig, path: str | Path | None = None) -> str

Serialise a SimulationConfig to a JSON string or file.

Parameters:

Name Type Description Default
config SimulationConfig

Configuration to serialise.

required
path str | Path | None

Optional file path to write the JSON to. If None, only the JSON string is returned.

None

Returns:

Name Type Description
str str

JSON representation of the configuration.

Source code in src/phids/io/scenario.py
def scenario_to_json(config: SimulationConfig, path: str | Path | None = None) -> str:
    """Serialise a SimulationConfig to a JSON string or file.

    Args:
        config: Configuration to serialise.
        path: Optional file path to write the JSON to. If ``None``, only the
            JSON string is returned.

    Returns:
        str: JSON representation of the configuration.

    """
    serialised = config.model_dump_json(indent=2)
    if path is not None:
        destination = Path(path)
        destination.write_text(serialised, encoding="utf-8")
        logger.info("Scenario written to %s", destination)
    return serialised

API and UI state surfaces

phids.api.main

FastAPI composition root governing live runtime state and API boundary coordination.

This module defines the canonical PHIDS application object and the mutable singleton references that bind operator actions to a live SimulationLoop instance. The implementation mediates the transition between draft-state configuration and executable simulation state, and wires router modules that partition control, telemetry, and builder responsibilities. WebSocket transport loops are delegated to dedicated manager classes, while this module retains ownership of endpoint registration and runtime state access.

The architecture enforces the central methodological invariant of the PHIDS interface layer: editable draft configuration remains distinct from live ecological state until explicit load operations occur. This separation preserves deterministic replayability of trophic interaction, signal propagation, and metabolic attrition trajectories.

log_http_requests(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response async

Record request-latency diagnostics for interactive API and UI surfaces.

The middleware emits lightweight observability events for HTMX and REST traffic so operator workflows can be correlated with backend latency and error rates. The policy is asymmetric by design: client/server failures are elevated to warning level, whereas successful paths are kept at debug level to avoid telemetry inflation under nominal workloads.

Parameters:

Name Type Description Default
request Request

Incoming HTTP request object.

required
call_next Callable[[Request], Awaitable[Response]]

Downstream ASGI callable that resolves the response.

required

Returns:

Type Description
Response

Response returned by downstream middleware and route handling.

Source code in src/phids/api/main.py
@app.middleware("http")
async def log_http_requests(
    request: Request,
    call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
    """Record request-latency diagnostics for interactive API and UI surfaces.

    The middleware emits lightweight observability events for HTMX and REST traffic so operator
    workflows can be correlated with backend latency and error rates. The policy is asymmetric by
    design: client/server failures are elevated to warning level, whereas successful paths are kept
    at debug level to avoid telemetry inflation under nominal workloads.

    Args:
        request: Incoming HTTP request object.
        call_next: Downstream ASGI callable that resolves the response.

    Returns:
        Response returned by downstream middleware and route handling.

    """
    started = time.perf_counter()
    response = await call_next(request)
    duration_ms = (time.perf_counter() - started) * 1000.0

    is_interactive_path = request.url.path.startswith(("/api/", "/ui/")) or request.url.path == "/"
    if response.status_code >= 400 and is_interactive_path:
        logger.warning(
            "HTTP %s %s -> %d in %.2fms",
            request.method,
            request.url.path,
            response.status_code,
            duration_ms,
        )
    elif is_interactive_path and logger.isEnabledFor(logging.DEBUG):
        logger.debug(
            "HTTP %s %s -> %d in %.2fms%s",
            request.method,
            request.url.path,
            response.status_code,
            duration_ms,
            " [HTMX]" if _is_htmx_request(request) else "",
        )

    return response

simulation_stream(websocket: WebSocket) -> None async

Delegate binary simulation streaming to the simulation stream manager.

Parameters:

Name Type Description Default
websocket WebSocket

Connected client socket endpoint.

required
Notes

The manager enforces msgpack+zlib encoding, tick-synchronous emission, and policy-close semantics when no live scenario is loaded.

Source code in src/phids/api/main.py
@app.websocket("/ws/simulation/stream")
async def simulation_stream(websocket: WebSocket) -> None:
    """Delegate binary simulation streaming to the simulation stream manager.

    Args:
        websocket: Connected client socket endpoint.

    Notes:
        The manager enforces msgpack+zlib encoding, tick-synchronous emission, and policy-close
        semantics when no live scenario is loaded.

    """
    await _simulation_stream_manager.handle_connection(websocket, _sim_loop)

ui_cell_details(x: int, y: int, expected_tick: int | None = None) -> JSONResponse async

Return rich grid-cell details for dashboard tooltips.

When a live simulation exists, data is sourced from the current ECS world and environment layers. Otherwise the endpoint returns draft-preview payloads so the placement editor can expose deterministic pre-runtime inspection.

Parameters:

Name Type Description Default
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required
expected_tick int | None

Optional optimistic-concurrency marker from UI polling state.

None

Returns:

Type Description
JSONResponse

JSON response containing either live cell diagnostics or draft-preview details.

Raises:

Type Description
HTTPException

Upstream presenter validation rejects out-of-bounds coordinates.

Source code in src/phids/api/main.py
@app.get("/api/ui/cell-details", summary="Detailed tooltip payload for one grid cell")
async def ui_cell_details(x: int, y: int, expected_tick: int | None = None) -> JSONResponse:
    """Return rich grid-cell details for dashboard tooltips.

    When a live simulation exists, data is sourced from the current ECS world and environment
    layers. Otherwise the endpoint returns draft-preview payloads so the placement editor can expose
    deterministic pre-runtime inspection.

    Args:
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.
        expected_tick: Optional optimistic-concurrency marker from UI polling state.

    Returns:
        JSON response containing either live cell diagnostics or draft-preview details.

    Raises:
        HTTPException: Upstream presenter validation rejects out-of-bounds coordinates.

    """
    if _sim_loop is not None and expected_tick is not None and expected_tick != _sim_loop.tick:
        return JSONResponse(
            status_code=409,
            content={
                "detail": "Live simulation advanced before tooltip details were fetched.",
                "expected_tick": expected_tick,
                "tick": _sim_loop.tick,
            },
        )

    payload = (
        build_live_cell_details(_sim_loop, x, y, substance_names=_sim_substance_names)
        if _sim_loop is not None
        else build_preview_cell_details(x, y, draft=get_draft(), substance_names=_sim_substance_names)
    )
    return JSONResponse(content=payload)

ui_status_badge() -> HTMLResponse async

Return a small status <span> for HTMX outerHTML swap.

Returns:

Type Description
HTMLResponse

Styled <span id="sim-status"> fragment.

Source code in src/phids/api/main.py
@app.get("/api/ui/status-badge", summary="Simulation status badge HTML")
async def ui_status_badge() -> HTMLResponse:
    """Return a small status ``<span>`` for HTMX outerHTML swap.

    Returns:
        Styled ``<span id="sim-status">`` fragment.

    """
    return HTMLResponse(content=render_status_badge_html(_sim_loop))

ui_stream(websocket: WebSocket) -> None async

Delegate live UI JSON streaming to the UI stream manager.

Parameters:

Name Type Description Default
websocket WebSocket

Connected client socket endpoint.

required
Notes

The manager polls live-loop availability, emits payloads only on state-signature change, and applies tick-rate cadence constraints.

Source code in src/phids/api/main.py
@app.websocket("/ws/ui/stream")
async def ui_stream(websocket: WebSocket) -> None:
    """Delegate live UI JSON streaming to the UI stream manager.

    Args:
        websocket: Connected client socket endpoint.

    Notes:
        The manager polls live-loop availability, emits payloads only on state-signature change,
        and applies tick-rate cadence constraints.

    """
    await _ui_stream_manager.handle_connection(websocket, lambda: _sim_loop)

ui_tick() -> Response async

Return the current tick as plain text for HTMX innerHTML swap.

Returns:

Type Description
Response

Plain-text tick content for lightweight polling updates.

Source code in src/phids/api/main.py
@app.get("/api/ui/tick", summary="Current simulation tick (plain text)")
async def ui_tick() -> Response:
    """Return the current tick as plain text for HTMX innerHTML swap.

    Returns:
        Plain-text tick content for lightweight polling updates.

    """
    tick = _sim_loop.tick if _sim_loop is not None else 0
    return Response(content=str(tick), media_type="text/plain")

phids.api.ui_state

UI state data models and draft configuration.

phids.api.websockets.manager

Connection managers for deterministic PHIDS WebSocket transport.

This module isolates asynchronous WebSocket stream orchestration from the FastAPI composition root. The managers preserve protocol-specific invariants while reducing route-level control flow in phids.api.main: the simulation stream emits msgpack+zlib binary snapshots keyed to simulation ticks, and the UI stream emits compact JSON payloads keyed to rendered state signatures. By keeping loop progression checks, payload encoding, sleep cadence, and disconnect handling in dedicated classes, the runtime maintains strict draft-versus-live boundaries while exposing low-latency observability of ecological dynamics.

DSEStreamManager

Connection manager for real-time Design Space Exploration UI updates.

Broadcasts NSGA-II Pareto Front generation payloads directly to the active HTMX client connection over /ws/dse/stream.

Source code in src/phids/api/websockets/manager.py
class DSEStreamManager:
    """Connection manager for real-time Design Space Exploration UI updates.

    Broadcasts NSGA-II Pareto Front generation payloads directly to the active
    HTMX client connection over ``/ws/dse/stream``.
    """

    def __init__(self) -> None:
        """Initialise the DSE stream connection roster."""
        self.active_dse_connections: list[WebSocket] = []

    async def connect_dse(self, websocket: WebSocket) -> None:
        """Accept and register a new DSE websocket connection.

        Args:
            websocket: The WebSocket connection to establish.
        """
        await websocket.accept()
        self.active_dse_connections.append(websocket)
        logger.info("DSE client connected (active=%d)", len(self.active_dse_connections))

    def disconnect_dse(self, websocket: WebSocket) -> None:
        """Remove a DSE websocket connection.

        Args:
            websocket: The WebSocket connection to terminate.
        """
        if websocket in self.active_dse_connections:
            self.active_dse_connections.remove(websocket)
            logger.info("DSE client disconnected (active=%d)", len(self.active_dse_connections))

    async def broadcast_dse(self, payload: dict[str, Any]) -> None:
        """Broadcast a JSON DSE payload to all connected clients.

        Args:
            payload: JSON-serializable dictionary containing generation metrics.

        """
        disconnected: list[WebSocket] = []
        for connection in self.active_dse_connections:
            try:
                await connection.send_json(payload)
            except Exception as e:
                logger.warning("DSE client failed receive, dropping connection. %s", str(e))
                disconnected.append(connection)

        for connection in disconnected:
            self.disconnect_dse(connection)

__init__() -> None

Initialise the DSE stream connection roster.

Source code in src/phids/api/websockets/manager.py
def __init__(self) -> None:
    """Initialise the DSE stream connection roster."""
    self.active_dse_connections: list[WebSocket] = []

broadcast_dse(payload: dict[str, Any]) -> None async

Broadcast a JSON DSE payload to all connected clients.

Parameters:

Name Type Description Default
payload dict[str, Any]

JSON-serializable dictionary containing generation metrics.

required
Source code in src/phids/api/websockets/manager.py
async def broadcast_dse(self, payload: dict[str, Any]) -> None:
    """Broadcast a JSON DSE payload to all connected clients.

    Args:
        payload: JSON-serializable dictionary containing generation metrics.

    """
    disconnected: list[WebSocket] = []
    for connection in self.active_dse_connections:
        try:
            await connection.send_json(payload)
        except Exception as e:
            logger.warning("DSE client failed receive, dropping connection. %s", str(e))
            disconnected.append(connection)

    for connection in disconnected:
        self.disconnect_dse(connection)

connect_dse(websocket: WebSocket) -> None async

Accept and register a new DSE websocket connection.

Parameters:

Name Type Description Default
websocket WebSocket

The WebSocket connection to establish.

required
Source code in src/phids/api/websockets/manager.py
async def connect_dse(self, websocket: WebSocket) -> None:
    """Accept and register a new DSE websocket connection.

    Args:
        websocket: The WebSocket connection to establish.
    """
    await websocket.accept()
    self.active_dse_connections.append(websocket)
    logger.info("DSE client connected (active=%d)", len(self.active_dse_connections))

disconnect_dse(websocket: WebSocket) -> None

Remove a DSE websocket connection.

Parameters:

Name Type Description Default
websocket WebSocket

The WebSocket connection to terminate.

required
Source code in src/phids/api/websockets/manager.py
def disconnect_dse(self, websocket: WebSocket) -> None:
    """Remove a DSE websocket connection.

    Args:
        websocket: The WebSocket connection to terminate.
    """
    if websocket in self.active_dse_connections:
        self.active_dse_connections.remove(websocket)
        logger.info("DSE client disconnected (active=%d)", len(self.active_dse_connections))

SimulationStreamManager

Manage binary simulation-state stream connections.

The manager enforces the machine-facing stream contract for /ws/simulation/stream: payloads are derived from SimulationLoop snapshots, serialized with json, compressed with zlib, and sent only when the tick changes. A single cached compressed payload is retained per (loop identity, tick) pair to avoid redundant encoding work under multiple subscribers.

Attributes:

Name Type Description
_cache_loop_id

Identity of the loop instance used for the current cache entry.

_cache_tick

Tick number represented by the cached payload.

_cache_payload

Compressed binary frame for the cached loop/tick pair.

Source code in src/phids/api/websockets/manager.py
class SimulationStreamManager:
    """Manage binary simulation-state stream connections.

    The manager enforces the machine-facing stream contract for
    ``/ws/simulation/stream``: payloads are derived from ``SimulationLoop`` snapshots,
    serialized with json, compressed with zlib, and sent only when the tick changes.
    A single cached compressed payload is retained per ``(loop identity, tick)`` pair to
    avoid redundant encoding work under multiple subscribers.

    Attributes:
        _cache_loop_id: Identity of the loop instance used for the current cache entry.
        _cache_tick: Tick number represented by the cached payload.
        _cache_payload: Compressed binary frame for the cached loop/tick pair.

    """

    def __init__(self) -> None:
        """Initialize cache slots for snapshot encoding reuse."""
        self._cache_loop_id = -1
        self._cache_tick = -1
        self._cache_payload = b""

    def _encoded_snapshot_bytes(self, loop: SimulationLoop) -> bytes:
        """Return cached compressed bytes for the current loop tick.

        Args:
            loop: Live simulation loop whose state snapshot is encoded.

        Returns:
            Compressed binary payload for transport.

        """
        loop_id = id(loop)
        if loop_id != self._cache_loop_id or loop.tick != self._cache_tick:
            snapshot = loop.get_state_snapshot()
            packed = json.dumps(snapshot).encode("utf-8")
            self._cache_payload = zlib.compress(packed, level=1)
            self._cache_loop_id = loop_id
            self._cache_tick = loop.tick
        return self._cache_payload

    @staticmethod
    async def _safe_close(websocket: WebSocket, *, code: int = 1000, reason: str | None = None) -> None:
        """Close a WebSocket connection without propagating shutdown exceptions.

        Args:
            websocket: Connected socket endpoint.
            code: WebSocket close code.
            reason: Optional close reason.

        """
        try:
            await websocket.close(code=code, reason=reason)
        except RuntimeError:
            return

    async def handle_connection(self, websocket: WebSocket, loop: SimulationLoop | None) -> None:
        """Handle one client connection for the binary simulation stream.

        Args:
            websocket: Accepted socket client.
            loop: Active simulation loop at connection time.

        Notes:
            The stream is intentionally rejected when no live simulation exists. This prevents
            clients from observing ambiguous placeholder transport states and keeps runtime
            semantics explicit.

        """
        await websocket.accept()
        logger.debug("WebSocket connected: /ws/simulation/stream")

        if loop is None:
            logger.warning("Closing /ws/simulation/stream because no scenario is loaded")
            await self._safe_close(websocket, code=1008, reason="No scenario loaded.")
            return

        last_tick = -1
        try:
            while True:
                if loop.terminated:
                    if loop.tick != last_tick:
                        await websocket.send_bytes(self._encoded_snapshot_bytes(loop))
                    break

                if loop.tick != last_tick:
                    await websocket.send_bytes(self._encoded_snapshot_bytes(loop))
                    last_tick = loop.tick

                await asyncio.sleep(1.0 / max(1.0, loop.config.tick_rate_hz))
        except WebSocketDisconnect:
            logger.info("WebSocket client disconnected from /ws/simulation/stream")
        finally:
            await self._safe_close(websocket)

__init__() -> None

Initialize cache slots for snapshot encoding reuse.

Source code in src/phids/api/websockets/manager.py
def __init__(self) -> None:
    """Initialize cache slots for snapshot encoding reuse."""
    self._cache_loop_id = -1
    self._cache_tick = -1
    self._cache_payload = b""

handle_connection(websocket: WebSocket, loop: SimulationLoop | None) -> None async

Handle one client connection for the binary simulation stream.

Parameters:

Name Type Description Default
websocket WebSocket

Accepted socket client.

required
loop SimulationLoop | None

Active simulation loop at connection time.

required
Notes

The stream is intentionally rejected when no live simulation exists. This prevents clients from observing ambiguous placeholder transport states and keeps runtime semantics explicit.

Source code in src/phids/api/websockets/manager.py
async def handle_connection(self, websocket: WebSocket, loop: SimulationLoop | None) -> None:
    """Handle one client connection for the binary simulation stream.

    Args:
        websocket: Accepted socket client.
        loop: Active simulation loop at connection time.

    Notes:
        The stream is intentionally rejected when no live simulation exists. This prevents
        clients from observing ambiguous placeholder transport states and keeps runtime
        semantics explicit.

    """
    await websocket.accept()
    logger.debug("WebSocket connected: /ws/simulation/stream")

    if loop is None:
        logger.warning("Closing /ws/simulation/stream because no scenario is loaded")
        await self._safe_close(websocket, code=1008, reason="No scenario loaded.")
        return

    last_tick = -1
    try:
        while True:
            if loop.terminated:
                if loop.tick != last_tick:
                    await websocket.send_bytes(self._encoded_snapshot_bytes(loop))
                break

            if loop.tick != last_tick:
                await websocket.send_bytes(self._encoded_snapshot_bytes(loop))
                last_tick = loop.tick

            await asyncio.sleep(1.0 / max(1.0, loop.config.tick_rate_hz))
    except WebSocketDisconnect:
        logger.info("WebSocket client disconnected from /ws/simulation/stream")
    finally:
        await self._safe_close(websocket)

UIStreamManager

Manage lightweight UI dashboard stream connections.

The manager enforces rendering-oriented stream behavior for /ws/ui/stream. It polls the active loop provider, emits JSON payloads only when a visible state signature changes, and uses the configured tick rate to regulate cadence. This preserves responsive visual telemetry while avoiding redundant frame emission during static states.

Attributes:

Name Type Description
_payload_builder

Callable that assembles dashboard payload dictionaries.

Source code in src/phids/api/websockets/manager.py
class UIStreamManager:
    """Manage lightweight UI dashboard stream connections.

    The manager enforces rendering-oriented stream behavior for ``/ws/ui/stream``. It polls the
    active loop provider, emits JSON payloads only when a visible state signature changes, and uses
    the configured tick rate to regulate cadence. This preserves responsive visual telemetry while
    avoiding redundant frame emission during static states.

    Attributes:
        _payload_builder: Callable that assembles dashboard payload dictionaries.

    """

    def __init__(
        self,
        payload_builder: Callable[[dict[str, Any]], dict[str, Any]],
        snapshot_extractor: Callable[[SimulationLoop], dict[str, Any]] | None = None,
    ) -> None:
        """Initialize the UI stream manager.

        Args:
            payload_builder: Function mapping a snapshot to a JSON dictionary.
            snapshot_extractor: Function mapping a loop to a snapshot dictionary.
        """
        self._payload_builder = payload_builder
        self._snapshot_extractor = snapshot_extractor
        self._cache_signature: tuple[int, int, int, bool, bool, bool] | None = None
        self._cache_text = ""

    def _encoded_payload(self, snapshot: dict[str, Any]) -> str:
        """Return cached compact JSON text for the current UI-visible loop state.

        Args:
            snapshot: Extracted thread-safe dictionary snapshot of the loop state.

        Returns:
            Compact JSON payload text for websocket transmission.

        """
        payload = self._payload_builder(snapshot)
        return json.dumps(payload, separators=(",", ":"))

    @staticmethod
    async def _safe_close(websocket: WebSocket, *, code: int = 1000, reason: str | None = None) -> None:
        """Close a WebSocket connection without propagating shutdown exceptions.

        Args:
            websocket: Connected socket endpoint.
            code: WebSocket close code.
            reason: Optional close reason.

        """
        try:
            await websocket.close(code=code, reason=reason)
        except RuntimeError:
            return

    async def _send_update_if_changed(
        self,
        websocket: WebSocket,
        loop: SimulationLoop,
        last_signature: tuple[int, int, int, bool, bool, bool] | None,
    ) -> tuple[int, int, int, bool, bool, bool] | None:
        """Send update if the loop state has changed since the last call.

        Args:
            websocket: The WebSocket connection.
            loop: The simulation loop.
            last_signature: The last signature.

        Returns:
            The new signature.
        """
        state_signature = (
            id(loop),
            loop.tick,
            loop.state_revision,
            loop.running,
            loop.paused,
            loop.terminated,
        )
        if state_signature == last_signature:
            return last_signature

        try:
            async with loop._lock:
                if self._snapshot_extractor is not None:
                    snapshot = self._snapshot_extractor(loop)
                else:
                    from phids.api.presenters.dashboard.payloads import extract_ui_snapshot

                    snapshot = extract_ui_snapshot(loop)

            payload_text = await asyncio.to_thread(self._encoded_payload, snapshot)
            await websocket.send_text(payload_text)
        except RuntimeError as exc:
            if "Unexpected ASGI message 'websocket.send'" in str(exc) or "after sending 'websocket.close'" in str(exc):
                logger.info("WebSocket client disconnected from /ws/ui/stream (RuntimeError)")
                raise WebSocketDisconnect() from exc
            raise

        return state_signature

    async def handle_connection(
        self,
        websocket: WebSocket,
        get_loop: Callable[[], SimulationLoop | None],
    ) -> None:
        """Handle one client connection for the UI JSON stream.

        Args:
            websocket: Accepted socket client.
            get_loop: Callable returning the currently active simulation loop.

        Notes:
            The connection remains open while no loop is loaded. This allows browser clients to
            subscribe once and begin receiving payloads immediately after a scenario is loaded.
        """
        await websocket.accept()
        logger.debug("WebSocket connected: /ws/ui/stream")

        last_state_signature: tuple[int, int, int, bool, bool, bool] | None = None
        try:
            while True:
                loop = get_loop()
                if loop is None:
                    await asyncio.sleep(0.5)
                    continue

                last_state_signature = await self._send_update_if_changed(websocket, loop, last_state_signature)
                await asyncio.sleep(1.0 / max(1.0, loop.config.tick_rate_hz))
        except WebSocketDisconnect:
            logger.info("WebSocket client disconnected from /ws/ui/stream")
        finally:
            await self._safe_close(websocket)

__init__(payload_builder: Callable[[dict[str, Any]], dict[str, Any]], snapshot_extractor: Callable[[SimulationLoop], dict[str, Any]] | None = None) -> None

Initialize the UI stream manager.

Parameters:

Name Type Description Default
payload_builder Callable[[dict[str, Any]], dict[str, Any]]

Function mapping a snapshot to a JSON dictionary.

required
snapshot_extractor Callable[[SimulationLoop], dict[str, Any]] | None

Function mapping a loop to a snapshot dictionary.

None
Source code in src/phids/api/websockets/manager.py
def __init__(
    self,
    payload_builder: Callable[[dict[str, Any]], dict[str, Any]],
    snapshot_extractor: Callable[[SimulationLoop], dict[str, Any]] | None = None,
) -> None:
    """Initialize the UI stream manager.

    Args:
        payload_builder: Function mapping a snapshot to a JSON dictionary.
        snapshot_extractor: Function mapping a loop to a snapshot dictionary.
    """
    self._payload_builder = payload_builder
    self._snapshot_extractor = snapshot_extractor
    self._cache_signature: tuple[int, int, int, bool, bool, bool] | None = None
    self._cache_text = ""

handle_connection(websocket: WebSocket, get_loop: Callable[[], SimulationLoop | None]) -> None async

Handle one client connection for the UI JSON stream.

Parameters:

Name Type Description Default
websocket WebSocket

Accepted socket client.

required
get_loop Callable[[], SimulationLoop | None]

Callable returning the currently active simulation loop.

required
Notes

The connection remains open while no loop is loaded. This allows browser clients to subscribe once and begin receiving payloads immediately after a scenario is loaded.

Source code in src/phids/api/websockets/manager.py
async def handle_connection(
    self,
    websocket: WebSocket,
    get_loop: Callable[[], SimulationLoop | None],
) -> None:
    """Handle one client connection for the UI JSON stream.

    Args:
        websocket: Accepted socket client.
        get_loop: Callable returning the currently active simulation loop.

    Notes:
        The connection remains open while no loop is loaded. This allows browser clients to
        subscribe once and begin receiving payloads immediately after a scenario is loaded.
    """
    await websocket.accept()
    logger.debug("WebSocket connected: /ws/ui/stream")

    last_state_signature: tuple[int, int, int, bool, bool, bool] | None = None
    try:
        while True:
            loop = get_loop()
            if loop is None:
                await asyncio.sleep(0.5)
                continue

            last_state_signature = await self._send_update_if_changed(websocket, loop, last_state_signature)
            await asyncio.sleep(1.0 / max(1.0, loop.config.tick_rate_hz))
    except WebSocketDisconnect:
        logger.info("WebSocket client disconnected from /ws/ui/stream")
    finally:
        await self._safe_close(websocket)

API Presenters

phids.api.presenters.dashboard.shared

Pure structural utility helpers for the UI dashboard presenters.

Provides deterministic coercion, coordinate validation, and fallback rendering logic used across multiple presenter domains (mycorrhizal, substances, cell details).

validate_cell_coordinates(x: int, y: int, width: int, height: int) -> None

Assert that a pair of cell coordinates falls within the simulation grid bounds.

Parameters:

Name Type Description Default
x int

Column index to validate.

required
y int

Row index to validate.

required
width int

Total grid width.

required
height int

Total grid height.

required

Raises:

Type Description
HTTPException

Raises HTTP 404 with a descriptive detail message if the coordinates are out of bounds.

Source code in src/phids/api/presenters/dashboard/shared.py
def validate_cell_coordinates(x: int, y: int, width: int, height: int) -> None:
    """Assert that a pair of cell coordinates falls within the simulation grid bounds.

    Args:
        x: Column index to validate.
        y: Row index to validate.
        width: Total grid width.
        height: Total grid height.

    Raises:
        HTTPException: Raises HTTP 404 with a descriptive detail message if the
            coordinates are out of bounds.

    """
    if not (0 <= x < width) or not (0 <= y < height):
        raise HTTPException(
            status_code=404,
            detail=f"Cell coordinates ({x}, {y}) out of bounds for grid {width}x{height}",
        )

phids.api.presenters.dashboard.payloads

Dashboard presenter for full telemetry/UI payload.

Assembles and serializes the complete live dashboard state (flora/swarm populations, environmental layers, mycorrhizal root connections) streamed to the UI client.

build_live_dashboard_payload(snapshot: dict[str, Any], *, substance_names: dict[int, str]) -> dict[str, object]

Assemble the full JSON payload streamed to the browser canvas over the UI WebSocket.

This function constructs the authoritative rendering payload consumed by /ws/ui/stream. It collects and serialises data from a pre-extracted snapshot.

Parameters:

Name Type Description Default
snapshot dict[str, Any]

The extracted thread-safe dictionary snapshot of the loop state.

required
substance_names dict[int, str]

Mapping from substance identifier to display name.

required

Returns:

Type Description
dict[str, object]

A dictionary conforming to the full canvas payload schema.

Source code in src/phids/api/presenters/dashboard/payloads.py
def build_live_dashboard_payload(
    snapshot: dict[str, Any],
    *,
    substance_names: dict[int, str],
) -> dict[str, object]:
    """Assemble the full JSON payload streamed to the browser canvas over the UI WebSocket.

    This function constructs the authoritative rendering payload consumed by
    ``/ws/ui/stream``.  It collects and serialises data from a pre-extracted snapshot.

    Args:
        snapshot: The extracted thread-safe dictionary snapshot of the loop state.
        substance_names: Mapping from substance identifier to display name.

    Returns:
        A dictionary conforming to the full canvas payload schema.
    """
    _ = substance_names

    plant_energy_layer = snapshot["plant_energy_layer"]
    signal_layers = snapshot["signal_layers"]
    toxin_layers = snapshot["toxin_layers"]

    max_e = float(plant_energy_layer.max()) or 1.0
    signal_overlay = signal_layers.max(axis=0) if signal_layers is not None else None
    toxin_overlay = toxin_layers.max(axis=0) if toxin_layers is not None else None

    flora_names = {species.species_id: species.name for species in snapshot["flora_species"]}
    herbivore_names = {species.species_id: species.name for species in snapshot["herbivore_species"]}

    owned_substances: dict[int, list[dict[str, Any]]] = {}
    for sub in snapshot["substances"]:
        owned_substances.setdefault(sub["owner_plant_id"], []).append(sub)

    plants = _collect_live_plants(snapshot, flora_names, owned_substances)
    swarms = _collect_live_swarms(snapshot, herbivore_names)

    live_flora_species_ids = {
        sid for sid in (_coerce_int(species_id, default=-1) for species_id in plants["species_id"]) if sid >= 0
    }

    all_flora_species, species_energy = _collect_flora_species(
        snapshot["flora_species"],
        snapshot["plant_energy_by_species"],
        snapshot["width"],
        snapshot["height"],
        live_flora_species_ids,
    )

    return {
        "contract_version": 1,
        "tick": snapshot["tick"],
        "grid_width": snapshot["width"],
        "grid_height": snapshot["height"],
        "max_energy": max_e,
        "plant_energy": plant_energy_layer.tolist(),
        "species_energy": species_energy,
        "all_flora_species": all_flora_species,
        "signal_overlay": signal_overlay.tolist() if signal_overlay is not None else [],
        "toxin_overlay": toxin_overlay.tolist() if toxin_overlay is not None else [],
        "max_signal": float(signal_overlay.max()) if signal_overlay is not None else 0.0,
        "max_toxin": float(toxin_overlay.max()) if toxin_overlay is not None else 0.0,
        "plants": plants,
        "mycorrhizal_links": _build_live_mycorrhizal_links_from_snapshot(snapshot),
        "swarms": swarms,
        "terminated": snapshot["terminated"],
        "termination_reason": snapshot["termination_reason"],
        "running": snapshot["running"],
        "paused": snapshot["paused"],
    }

extract_ui_snapshot(loop: SimulationLoop) -> dict[str, Any]

Extract a fast, thread-safe shallow copy of UI-required state.

This function runs synchronously while holding the simulation lock. It returns a dictionary containing primitive values, copied NumPy arrays, and lightweight dicts representing the components needed for UI streaming.

Source code in src/phids/api/presenters/dashboard/payloads.py
def extract_ui_snapshot(loop: SimulationLoop) -> dict[str, Any]:
    """Extract a fast, thread-safe shallow copy of UI-required state.

    This function runs synchronously while holding the simulation lock. It returns a
    dictionary containing primitive values, copied NumPy arrays, and lightweight dicts
    representing the components needed for UI streaming.
    """
    from phids.engine.components.plant import PlantComponent
    from phids.engine.components.substances import SubstanceComponent
    from phids.engine.components.swarm import SwarmComponent

    env = loop.env
    world = loop.world

    snapshot: dict[str, Any] = {
        "tick": loop.tick,
        "width": env.width,
        "height": env.height,
        "terminated": loop.terminated,
        "termination_reason": loop.termination_reason,
        "running": loop.running,
        "paused": loop.paused,
        "num_signals": env.num_signals,
        "num_toxins": env.num_toxins,
        "flora_species": loop.config.flora_species,
        "herbivore_species": loop.config.herbivore_species,
        "plant_energy_layer": env.plant_energy_layer.copy(),
        "signal_layers": env.signal_layers.copy() if env.num_signals > 0 else None,
        "toxin_layers": env.toxin_layers.copy() if env.num_toxins > 0 else None,
        "plant_energy_by_species": env.plant_energy_by_species.copy(),
    }

    plants = []
    for entity in world.query(PlantComponent):
        p = entity.get_component(PlantComponent)
        plants.append(
            {
                "entity_id": p.entity_id,
                "species_id": p.species_id,
                "x": p.x,
                "y": p.y,
                "energy": float(p.energy),
                "root_link_count": len(p.mycorrhizal_connections),
                "mycorrhizal_connections": set(p.mycorrhizal_connections),
            }
        )
    snapshot["plants"] = plants

    swarms = []
    for entity in world.query(SwarmComponent):
        s = entity.get_component(SwarmComponent)
        swarms.append(
            {
                "species_id": s.species_id,
                "x": s.x,
                "y": s.y,
                "population": s.population,
                "energy": float(s.energy),
                "energy_min": s.energy_min,
                "repelled": s.repelled,
                "repelled_ticks_remaining": s.repelled_ticks_remaining,
            }
        )
    snapshot["swarms"] = swarms

    substances = []
    for entity in world.query(SubstanceComponent):
        sub_comp = entity.get_component(SubstanceComponent)
        is_visible = (
            sub_comp.active
            or sub_comp.synthesis_remaining > 0
            or sub_comp.aftereffect_remaining_ticks > 0
            or sub_comp.triggered_this_tick
        )
        substances.append(
            {
                "owner_plant_id": sub_comp.owner_plant_id,
                "substance_id": sub_comp.substance_id,
                "is_toxin": sub_comp.is_toxin,
                "is_visible": is_visible,
            }
        )
    snapshot["substances"] = substances

    return snapshot

phids.api.presenters.diagnostics.model

Diagnostics model context presenters.

EnergyDeficitSwarmRow

Bases: TypedDict

One leaderboard row describing a swarm with positive metabolic energy deficit.

Source code in src/phids/api/presenters/diagnostics/model.py
class EnergyDeficitSwarmRow(TypedDict):
    """One leaderboard row describing a swarm with positive metabolic energy deficit."""

    entity_id: int
    name: str
    population: int
    energy_deficit: float
    x: int
    y: int
    repelled: bool

LiveSummary

Bases: TypedDict

Structured live-runtime counters for diagnostics and status rendering.

Source code in src/phids/api/presenters/diagnostics/model.py
class LiveSummary(TypedDict):
    """Structured live-runtime counters for diagnostics and status rendering."""

    tick: int
    running: bool
    paused: bool
    terminated: bool
    termination_reason: str | None
    plants: int
    swarms: int
    active_substances: int

build_energy_deficit_swarms(sim_loop: SimulationLoop | None) -> list[EnergyDeficitSwarmRow]

Rank live swarms by metabolic energy deficit severity.

Parameters:

Name Type Description Default
sim_loop SimulationLoop | None

Active simulation loop instance, or None if draft mode.

required

Returns:

Type Description
list[EnergyDeficitSwarmRow]

Sorted stress records for swarm entities with positive energy deficits.

Source code in src/phids/api/presenters/diagnostics/model.py
def build_energy_deficit_swarms(sim_loop: SimulationLoop | None) -> list[EnergyDeficitSwarmRow]:
    """Rank live swarms by metabolic energy deficit severity.

    Args:
        sim_loop: Active simulation loop instance, or None if draft mode.

    Returns:
        Sorted stress records for swarm entities with positive energy deficits.
    """
    if sim_loop is None:
        return []

    herbivore_names = {species.species_id: species.name for species in sim_loop.config.herbivore_species}
    energy_stressed: list[EnergyDeficitSwarmRow] = []
    for entity in sim_loop.world.query(SwarmComponent):
        swarm = entity.get_component(SwarmComponent)
        energy_deficit = float(max(0.0, swarm.population * swarm.energy_min - swarm.energy))
        if energy_deficit <= 0.0:
            continue
        energy_stressed.append(
            {
                "entity_id": swarm.entity_id,
                "name": herbivore_names.get(swarm.species_id, f"Herbivore {swarm.species_id}"),
                "population": swarm.population,
                "energy_deficit": energy_deficit,
                "x": swarm.x,
                "y": swarm.y,
                "repelled": swarm.repelled,
            }
        )
    energy_stressed.sort(
        key=lambda swarm: (
            -swarm["energy_deficit"],
            swarm["name"],
        )
    )
    return energy_stressed[:12]

build_live_summary(sim_loop: SimulationLoop | None) -> LiveSummary | None

Aggregate coarse live-model counters for diagnostics surfaces.

Parameters:

Name Type Description Default
sim_loop SimulationLoop | None

Active simulation loop instance, or None if draft mode.

required

Returns:

Type Description
LiveSummary | None

Summary counters when a live loop exists, otherwise None.

Source code in src/phids/api/presenters/diagnostics/model.py
def build_live_summary(sim_loop: SimulationLoop | None) -> LiveSummary | None:
    """Aggregate coarse live-model counters for diagnostics surfaces.

    Args:
        sim_loop: Active simulation loop instance, or None if draft mode.

    Returns:
        Summary counters when a live loop exists, otherwise ``None``.
    """
    if sim_loop is None:
        return None

    world = sim_loop.world
    plants = sum(1 for _ in world.query(PlantComponent))
    swarms = sum(1 for _ in world.query(SwarmComponent))
    active_substances = 0
    for entity in world.query(SubstanceComponent):
        substance = entity.get_component(SubstanceComponent)
        if substance.active or substance.synthesis_remaining > 0 or substance.aftereffect_remaining_ticks > 0:
            active_substances += 1

    return {
        "tick": sim_loop.tick,
        "running": sim_loop.running,
        "paused": sim_loop.paused,
        "terminated": sim_loop.terminated,
        "termination_reason": sim_loop.termination_reason,
        "plants": plants,
        "swarms": swarms,
        "active_substances": active_substances,
    }

phids.api.presenters.diagnostics.badge

Simulation status badge presenter.

render_status_badge_html(sim_loop: SimulationLoop | None) -> str

Render the HTMX-polled simulation status badge fragment.

Parameters:

Name Type Description Default
sim_loop SimulationLoop | None

Active simulation loop instance, or None if draft mode.

required

Returns:

Type Description
str

HTML fragment encoding current lifecycle state with semantic coloring.

Source code in src/phids/api/presenters/diagnostics/badge.py
def render_status_badge_html(sim_loop: SimulationLoop | None) -> str:
    """Render the HTMX-polled simulation status badge fragment.

    Args:
        sim_loop: Active simulation loop instance, or None if draft mode.

    Returns:
        HTML fragment encoding current lifecycle state with semantic coloring.
    """
    if sim_loop is None:
        label, colour = "Idle", "bg-slate-100 text-slate-500"
    elif sim_loop.terminated:
        label, colour = "Terminated", "bg-red-100 text-red-600"
    elif sim_loop.paused:
        label, colour = "Paused", "bg-amber-100 text-amber-600"
    elif sim_loop.running:
        label, colour = "Running", "bg-emerald-100 text-emerald-600"
    else:
        label, colour = "Loaded", "bg-indigo-100 text-indigo-600"

    return (
        f'<span id="sim-status" style="display:none!important" '
        f'hx-get="/api/ui/status-badge" hx-trigger="every 2s" hx-swap="outerHTML" '
        f'class="text-xs px-2 py-1 rounded {colour}">{label}</span>'
    )

phids.api.presenters.telemetry.svg

Telemetry SVG chart presenter.

build_telemetry_svg(df: object) -> str

Generate an inline SVG line chart from telemetry data.

Parameters:

Name Type Description Default
df object

Tabular telemetry object with columns tick, flora_population, herbivore_population, total_flora_energy.

required

Returns:

Type Description
str

SVG markup suitable for innerHTML injection.

Notes

The chart intentionally overlays flora population, herbivore population, and aggregate flora energy on a shared temporal axis to support rapid diagnosis of trophic oscillation and metabolic collapse onset.

Source code in src/phids/api/presenters/telemetry/svg.py
def build_telemetry_svg(df: object) -> str:
    """Generate an inline SVG line chart from telemetry data.

    Args:
        df: Tabular telemetry object with columns ``tick``, ``flora_population``,
            ``herbivore_population``, ``total_flora_energy``.

    Returns:
        SVG markup suitable for ``innerHTML`` injection.

    Notes:
        The chart intentionally overlays flora population, herbivore population, and aggregate flora
        energy on a shared temporal axis to support rapid diagnosis of trophic oscillation and
        metabolic collapse onset.

    """
    import polars as pl

    if not isinstance(df, pl.DataFrame) or df.is_empty() or len(df) < 2:
        return (
            '<svg width="100%" height="80" viewBox="0 0 800 80">'
            '<text x="400" y="44" text-anchor="middle" fill="#94a3b8" font-size="13">'
            "No telemetry data yet."
            "</text></svg>"
        )

    w, h, pad = 800, 160, 30
    ticks: list[int] = df["tick"].to_list()
    flora_pop: list[int] = df["flora_population"].to_list()
    herbivore_pop: list[int] = df["herbivore_population"].to_list()
    flora_e: list[float] = df["total_flora_energy"].to_list()

    max_tick = max(ticks) or 1
    max_pop = max(max(flora_pop, default=1), max(herbivore_pop, default=1)) or 1
    max_energy = max(flora_e, default=1.0) or 1.0

    def sx(t: int) -> float:
        return pad + (t / max_tick) * (w - 2 * pad)

    def sy_pop(v: int) -> float:
        return h - pad - (v / max_pop) * (h - 2 * pad)

    def sy_e(v: float) -> float:
        return h - pad - (v / max_energy) * (h - 2 * pad)

    n = len(ticks)
    fp_path = " ".join(f"{'M' if i == 0 else 'L'}{sx(ticks[i]):.1f},{sy_pop(flora_pop[i]):.1f}" for i in range(n))
    pp_path = " ".join(f"{'M' if i == 0 else 'L'}{sx(ticks[i]):.1f},{sy_pop(herbivore_pop[i]):.1f}" for i in range(n))
    fe_path = " ".join(f"{'M' if i == 0 else 'L'}{sx(ticks[i]):.1f},{sy_e(flora_e[i]):.1f}" for i in range(n))

    return (
        f'<svg width="100%" height="{h}" viewBox="0 0 {w} {h}" class="w-full">'
        f'<path d="{fp_path}" stroke="#22c55e" stroke-width="2" fill="none"/>'
        f'<path d="{pp_path}" stroke="#ef4444" stroke-width="2" fill="none"/>'
        f'<path d="{fe_path}" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-dasharray="4 2"/>'
        f"</svg>"
    )

phids.api.presenters.trigger_rules.context

Trigger rules context presentation logic.

trigger_rules_template_context(draft: DraftState) -> dict[str, object]

Assemble the canonical template context for trigger-rule partial rendering.

Parameters:

Name Type Description Default
draft DraftState

Active draft scenario state used as the authoritative builder source.

required

Returns:

Type Description
dict[str, object]

Template context dictionary containing species registries, trigger rows, condition summaries,

dict[str, object]

and condition-node editing metadata.

Source code in src/phids/api/presenters/trigger_rules/context.py
def trigger_rules_template_context(draft: DraftState) -> dict[str, object]:
    """Assemble the canonical template context for trigger-rule partial rendering.

    Args:
        draft: Active draft scenario state used as the authoritative builder source.

    Returns:
        Template context dictionary containing species registries, trigger rows, condition summaries,
        and condition-node editing metadata.
    """
    herbivore_names = {
        getattr(species, "species_id", index): getattr(species, "name", f"Herbivore {index}")
        for index, species in enumerate(draft.herbivore_species)
    }
    substance_names = {definition.substance_id: definition.name for definition in draft.substance_definitions}
    return {
        "flora_species": draft.flora_species,
        "herbivore_species": draft.herbivore_species,
        "trigger_rules": draft.trigger_rules,
        "substances": draft.substance_definitions,
        "trigger_rule_condition_json": {
            index: json.dumps(rule.activation_condition, indent=2) if rule.activation_condition is not None else ""
            for index, rule in enumerate(draft.trigger_rules)
        },
        "trigger_rule_condition_summary": {
            index: _describe_activation_condition(
                rule.activation_condition,
                herbivore_names=herbivore_names,
                substance_names=substance_names,
            )
            for index, rule in enumerate(draft.trigger_rules)
        },
        "condition_group_kinds": ["all_of", "any_of"],
        "condition_leaf_kinds": [
            "herbivore_presence",
            "substance_active",
            "environmental_signal",
        ],
    }

API Services

phids.api.services.draft.biotope

Draft-state mutation module for biotope parameters.

This module provides pure functions for updating the global biotope settings of a draft scenario, including grid dimensions, termination thresholds, and atmospheric factors.

update_biotope(draft: DraftState, *, grid_width: int, grid_height: int, max_ticks: int, tick_rate_hz: float, wind_x: float, wind_y: float, num_signals: int, num_toxins: int, z2_flora_species_extinction: int, z4_herbivore_species_extinction: int, z6_max_total_flora_energy: float, z7_max_total_herbivore_population: int, mycorrhizal_inter_species: bool, mycorrhizal_connection_cost: float, mycorrhizal_growth_interval_ticks: int, mycorrhizal_signal_velocity: int, signal_decay_factor: float = 0.85, substance_emit_rate: float = 0.1) -> bool

Normalize and persist global biotope parameters into the draft.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
grid_width int

Requested biotope width.

required
grid_height int

Requested biotope height.

required
max_ticks int

Requested simulation tick horizon.

required
tick_rate_hz float

Requested UI stream rate.

required
wind_x float

Requested uniform wind x-component.

required
wind_y float

Requested uniform wind y-component.

required
num_signals int

Requested number of signal layers.

required
num_toxins int

Requested number of toxin layers.

required
z2_flora_species_extinction int

Requested species-specific flora-extinction termination rule.

required
z4_herbivore_species_extinction int

Requested species-specific herbivore-extinction rule.

required
z6_max_total_flora_energy float

Requested upper bound for total flora energy termination.

required
z7_max_total_herbivore_population int

Requested upper bound for herbivore population termination.

required
mycorrhizal_inter_species bool

Requested root-link species policy.

required
mycorrhizal_connection_cost float

Requested root-link establishment cost.

required
mycorrhizal_growth_interval_ticks int

Requested root-growth interval.

required
mycorrhizal_signal_velocity int

Requested root-network signal velocity.

required
signal_decay_factor float

Requested per-tick airborne signal retention (0.0-1.0).

0.85
substance_emit_rate float

Requested concentration increment per active emit tick (0.0-1.0).

0.1

Returns:

Type Description
bool

True when at least one submitted scalar required clamping.

Source code in src/phids/api/services/draft/biotope.py
def update_biotope(
    draft: DraftState,
    *,
    grid_width: int,
    grid_height: int,
    max_ticks: int,
    tick_rate_hz: float,
    wind_x: float,
    wind_y: float,
    num_signals: int,
    num_toxins: int,
    z2_flora_species_extinction: int,
    z4_herbivore_species_extinction: int,
    z6_max_total_flora_energy: float,
    z7_max_total_herbivore_population: int,
    mycorrhizal_inter_species: bool,
    mycorrhizal_connection_cost: float,
    mycorrhizal_growth_interval_ticks: int,
    mycorrhizal_signal_velocity: int,
    signal_decay_factor: float = 0.85,
    substance_emit_rate: float = 0.1,
) -> bool:
    """Normalize and persist global biotope parameters into the draft.

    Args:
        draft: Draft state mutated in place.
        grid_width: Requested biotope width.
        grid_height: Requested biotope height.
        max_ticks: Requested simulation tick horizon.
        tick_rate_hz: Requested UI stream rate.
        wind_x: Requested uniform wind x-component.
        wind_y: Requested uniform wind y-component.
        num_signals: Requested number of signal layers.
        num_toxins: Requested number of toxin layers.
        z2_flora_species_extinction: Requested species-specific flora-extinction termination rule.
        z4_herbivore_species_extinction: Requested species-specific herbivore-extinction rule.
        z6_max_total_flora_energy: Requested upper bound for total flora energy termination.
        z7_max_total_herbivore_population: Requested upper bound for herbivore population
            termination.
        mycorrhizal_inter_species: Requested root-link species policy.
        mycorrhizal_connection_cost: Requested root-link establishment cost.
        mycorrhizal_growth_interval_ticks: Requested root-growth interval.
        mycorrhizal_signal_velocity: Requested root-network signal velocity.
        signal_decay_factor: Requested per-tick airborne signal retention (0.0-1.0).
        substance_emit_rate: Requested concentration increment per active emit tick (0.0-1.0).

    Returns:
        ``True`` when at least one submitted scalar required clamping.

    """
    clamped_grid_width = max(10, min(200, grid_width))
    clamped_grid_height = max(10, min(200, grid_height))
    clamped_max_ticks = max(1, max_ticks)
    clamped_tick_rate_hz = max(0.1, tick_rate_hz)
    clamped_num_signals = max(1, min(16, num_signals))
    clamped_num_toxins = max(1, min(16, num_toxins))
    clamped_z2 = max(-1, min(15, z2_flora_species_extinction))
    clamped_z4 = max(-1, min(15, z4_herbivore_species_extinction))
    clamped_z6 = max(-1.0, z6_max_total_flora_energy)
    clamped_z7 = max(-1, z7_max_total_herbivore_population)
    clamped_connection_cost = max(0.0, mycorrhizal_connection_cost)
    clamped_growth_interval = max(1, min(256, mycorrhizal_growth_interval_ticks))
    clamped_signal_velocity = max(1, mycorrhizal_signal_velocity)
    clamped_signal_decay = max(0.01, min(1.0, signal_decay_factor))
    clamped_substance_emit = max(0.01, min(1.0, substance_emit_rate))

    draft.grid_width = clamped_grid_width
    draft.grid_height = clamped_grid_height
    draft.max_ticks = clamped_max_ticks
    draft.tick_rate_hz = clamped_tick_rate_hz
    draft.wind_x = wind_x
    draft.wind_y = wind_y
    draft.num_signals = clamped_num_signals
    draft.num_toxins = clamped_num_toxins
    draft.z2_flora_species_extinction = clamped_z2
    draft.z4_herbivore_species_extinction = clamped_z4
    draft.z6_max_total_flora_energy = clamped_z6
    draft.z7_max_total_herbivore_population = clamped_z7
    draft.mycorrhizal_inter_species = mycorrhizal_inter_species
    draft.mycorrhizal_connection_cost = clamped_connection_cost
    draft.mycorrhizal_growth_interval_ticks = clamped_growth_interval
    draft.mycorrhizal_signal_velocity = clamped_signal_velocity
    draft.signal_decay_factor = clamped_signal_decay
    draft.substance_emit_rate = clamped_substance_emit

    return any(
        (
            clamped_grid_width != grid_width,
            clamped_grid_height != grid_height,
            clamped_max_ticks != max_ticks,
            clamped_tick_rate_hz != tick_rate_hz,
            clamped_num_signals != num_signals,
            clamped_num_toxins != num_toxins,
            clamped_z2 != z2_flora_species_extinction,
            clamped_z4 != z4_herbivore_species_extinction,
            clamped_z6 != z6_max_total_flora_energy,
            clamped_z7 != z7_max_total_herbivore_population,
            clamped_connection_cost != mycorrhizal_connection_cost,
            clamped_growth_interval != mycorrhizal_growth_interval_ticks,
            clamped_signal_velocity != mycorrhizal_signal_velocity,
            clamped_signal_decay != signal_decay_factor,
            clamped_substance_emit != substance_emit_rate,
        )
    )

phids.api.services.draft.diet

Draft-state mutation module for diet compatibility matrix.

Provides pure functions to mutate herbivore-flora interaction policies within the draft matrix representation.

set_diet_compatibility(draft: DraftState, herbivore_idx: int, flora_idx: int, compatible: str = 'toggle') -> bool | None

Toggle or assign one herbivore-flora edibility matrix cell.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
herbivore_idx int

Herbivore row index.

required
flora_idx int

The integer column index representing the specific flora species.

required
compatible str

Requested boolean state or the literal "toggle".

'toggle'

Returns:

Type Description
bool | None

The updated boolean cell value, or None when the indices are out of range.

Source code in src/phids/api/services/draft/diet.py
def set_diet_compatibility(
    draft: DraftState,
    herbivore_idx: int,
    flora_idx: int,
    compatible: str = "toggle",
) -> bool | None:
    """Toggle or assign one herbivore-flora edibility matrix cell.

    Args:
        draft: Draft state mutated in place.
        herbivore_idx: Herbivore row index.
        flora_idx: The integer column index representing the specific flora species.
        compatible: Requested boolean state or the literal ``"toggle"``.

    Returns:
        The updated boolean cell value, or ``None`` when the indices are out of range.

    """
    if herbivore_idx >= len(draft.diet_matrix) or herbivore_idx < 0:
        return None
    if flora_idx >= len(draft.diet_matrix[herbivore_idx]) or flora_idx < 0:
        return None

    if compatible == "toggle":
        draft.diet_matrix[herbivore_idx][flora_idx] = not draft.diet_matrix[herbivore_idx][flora_idx]
    else:
        draft.diet_matrix[herbivore_idx][flora_idx] = is_truthy_flag(compatible)
    return draft.diet_matrix[herbivore_idx][flora_idx]

phids.api.services.draft.helpers

Shared helper functions for draft state mutations.

These pure functions provide utility operations for the draft-state services, including truthy flag interpretation, substance lookup, diet matrix resizing, and species index compaction. They are decoupled from the routing layer to support deterministic scenario editing.

find_substance_index(draft: DraftState, substance_id: int) -> int | None

Locate the list index for one substance identifier.

Parameters:

Name Type Description Default
draft DraftState

Draft state whose substance registry is searched.

required
substance_id int

Substance identifier to resolve.

required

Returns:

Type Description
int | None

The list index of the matching substance definition, or None if absent.

Source code in src/phids/api/services/draft/helpers.py
def find_substance_index(draft: DraftState, substance_id: int) -> int | None:
    """Locate the list index for one substance identifier.

    Args:
        draft: Draft state whose substance registry is searched.
        substance_id: Substance identifier to resolve.

    Returns:
        The list index of the matching substance definition, or ``None`` if absent.

    """
    return next(
        (i for i, substance in enumerate(draft.substance_definitions) if substance.substance_id == substance_id),
        None,
    )

is_truthy_flag(value: str | bool) -> bool

Interpret HTML-form boolean payloads as deterministic Python truth values.

Parameters:

Name Type Description Default
value str | bool

Raw route payload representing a checkbox or toggle state.

required

Returns:

Type Description
bool

True when the submitted value represents the affirmative state.

Source code in src/phids/api/services/draft/helpers.py
def is_truthy_flag(value: str | bool) -> bool:
    """Interpret HTML-form boolean payloads as deterministic Python truth values.

    Args:
        value: Raw route payload representing a checkbox or toggle state.

    Returns:
        True when the submitted value represents the affirmative state.

    """
    if isinstance(value, bool):
        return value
    return value.lower() in ("true", "1", "yes", "on")

rebuild_species_ids(draft: DraftState) -> None

Reassign sequential species identifiers after species-list mutations.

Parameters:

Name Type Description Default
draft DraftState

Draft state whose species collections require index compaction.

required
Source code in src/phids/api/services/draft/helpers.py
def rebuild_species_ids(draft: DraftState) -> None:
    """Reassign sequential species identifiers after species-list mutations.

    Args:
        draft: Draft state whose species collections require index compaction.

    """
    from phids.api.schemas.species import (
        FloraSpeciesParams,
        HerbivoreSpeciesParams,
    )

    draft.flora_species = [
        fp.model_copy(update={"species_id": i})
        for i, fp in enumerate(draft.flora_species)
        if isinstance(fp, FloraSpeciesParams)
    ]
    draft.herbivore_species = [
        pp.model_copy(update={"species_id": i})
        for i, pp in enumerate(draft.herbivore_species)
        if isinstance(pp, HerbivoreSpeciesParams)
    ]

resize_diet_matrix(draft: DraftState) -> None

Resize the diet matrix to match current herbivore and flora list lengths.

Parameters:

Name Type Description Default
draft DraftState

Draft state whose matrix dimensions are compacted or extended.

required
Source code in src/phids/api/services/draft/helpers.py
def resize_diet_matrix(draft: DraftState) -> None:
    """Resize the diet matrix to match current herbivore and flora list lengths.

    Args:
        draft: Draft state whose matrix dimensions are compacted or extended.

    """
    n_herbivore = len(draft.herbivore_species)
    n_flora = len(draft.flora_species)

    while len(draft.diet_matrix) < n_herbivore:
        draft.diet_matrix.append([False] * n_flora)
    draft.diet_matrix = draft.diet_matrix[:n_herbivore]
    for row in draft.diet_matrix:
        while len(row) < n_flora:
            row.append(False)
        del row[n_flora:]

phids.api.services.draft.placements

Draft-state mutation module for initial spatial placements.

Provides pure functions to add, remove, and clear initial plant and swarm placements within the draft scenario grid.

add_plant_placement(draft: DraftState, species_id: int, x: int, y: int, energy: float) -> None

Append one plant placement to the draft placement ledger.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
species_id int

Flora species identifier.

required
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required
energy float

Initial plant energy reserve.

required
Source code in src/phids/api/services/draft/placements.py
def add_plant_placement(
    draft: DraftState,
    species_id: int,
    x: int,
    y: int,
    energy: float,
) -> None:
    """Append one plant placement to the draft placement ledger.

    Args:
        draft: Draft state mutated in place.
        species_id: Flora species identifier.
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.
        energy: Initial plant energy reserve.
    """
    draft.initial_plants.append(PlacedPlant(species_id=species_id, x=x, y=y, energy=energy))

add_swarm_placement(draft: DraftState, species_id: int, x: int, y: int, population: int, energy: float) -> None

Append one swarm placement to the draft placement ledger.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
species_id int

Herbivore species identifier.

required
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required
population int

Initial swarm population.

required
energy float

Initial swarm energy reserve.

required
Source code in src/phids/api/services/draft/placements.py
def add_swarm_placement(
    draft: DraftState,
    species_id: int,
    x: int,
    y: int,
    population: int,
    energy: float,
) -> None:
    """Append one swarm placement to the draft placement ledger.

    Args:
        draft: Draft state mutated in place.
        species_id: Herbivore species identifier.
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.
        population: Initial swarm population.
        energy: Initial swarm energy reserve.
    """
    draft.initial_swarms.append(
        PlacedSwarm(
            species_id=species_id,
            x=x,
            y=y,
            population=population,
            energy=energy,
        )
    )

clear_placements(draft: DraftState) -> None

Clear all plant and swarm placements from the draft.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
Source code in src/phids/api/services/draft/placements.py
def clear_placements(draft: DraftState) -> None:
    """Clear all plant and swarm placements from the draft.

    Args:
        draft: Draft state mutated in place.
    """
    cleared_plants = len(draft.initial_plants)
    cleared_swarms = len(draft.initial_swarms)
    draft.initial_plants.clear()
    draft.initial_swarms.clear()
    logger.debug(
        "Draft placements cleared (plants=%d, swarms=%d)",
        cleared_plants,
        cleared_swarms,
    )

clear_plant_placements(draft: DraftState) -> None

Clear all plant placements from the draft.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
Source code in src/phids/api/services/draft/placements.py
def clear_plant_placements(draft: DraftState) -> None:
    """Clear all plant placements from the draft.

    Args:
        draft: Draft state mutated in place.
    """
    cleared_plants = len(draft.initial_plants)
    draft.initial_plants.clear()
    logger.debug(
        "Draft plant placements cleared (plants=%d)",
        cleared_plants,
    )

clear_swarm_placements(draft: DraftState) -> None

Clear all swarm placements from the draft.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
Source code in src/phids/api/services/draft/placements.py
def clear_swarm_placements(draft: DraftState) -> None:
    """Clear all swarm placements from the draft.

    Args:
        draft: Draft state mutated in place.
    """
    cleared_swarms = len(draft.initial_swarms)
    draft.initial_swarms.clear()
    logger.debug(
        "Draft swarm placements cleared (swarms=%d)",
        cleared_swarms,
    )

remove_plant_placement(draft: DraftState, index: int) -> None

Remove one plant placement by list index.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Placement index to remove.

required

Raises:

Type Description
IndexError

The plant placement index is out of range.

Source code in src/phids/api/services/draft/placements.py
def remove_plant_placement(draft: DraftState, index: int) -> None:
    """Remove one plant placement by list index.

    Args:
        draft: Draft state mutated in place.
        index: Placement index to remove.

    Raises:
        IndexError: The plant placement index is out of range.
    """
    removed = draft.initial_plants[index]
    del draft.initial_plants[index]
    logger.debug(
        "Draft plant placement removed (index=%d, species_id=%d, x=%d, y=%d, total_plants=%d)",
        index,
        removed.species_id,
        removed.x,
        removed.y,
        len(draft.initial_plants),
    )

remove_swarm_placement(draft: DraftState, index: int) -> None

Remove one swarm placement by list index.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Placement index to remove.

required

Raises:

Type Description
IndexError

The swarm placement index is out of range.

Source code in src/phids/api/services/draft/placements.py
def remove_swarm_placement(draft: DraftState, index: int) -> None:
    """Remove one swarm placement by list index.

    Args:
        draft: Draft state mutated in place.
        index: Placement index to remove.

    Raises:
        IndexError: The swarm placement index is out of range.
    """
    removed = draft.initial_swarms[index]
    del draft.initial_swarms[index]
    logger.debug(
        "Draft swarm placement removed (index=%d, species_id=%d, x=%d, y=%d, total_swarms=%d)",
        index,
        removed.species_id,
        removed.x,
        removed.y,
        len(draft.initial_swarms),
    )

phids.api.services.draft.species

Draft-state mutation module for species configurations.

Provides pure functions to add or remove flora and herbivore species definitions within the draft. Species removal automatically compacts dependencies such as the diet matrix, trigger rules, and initial placements to maintain a consistent state.

add_flora(draft: DraftState, params: FloraSpeciesParams) -> None

Append one flora species and expand dependent matrix state.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
params FloraSpeciesParams

Flora species parameter object.

required
Source code in src/phids/api/services/draft/species.py
def add_flora(draft: DraftState, params: FloraSpeciesParams) -> None:
    """Append one flora species and expand dependent matrix state.

    Args:
        draft: Draft state mutated in place.
        params: Flora species parameter object.

    """
    draft.flora_species.append(params)
    rebuild_species_ids(draft)
    resize_diet_matrix(draft)
    logger.debug(
        "Draft flora added (species_id=%s, total_flora=%d)",
        getattr(params, "species_id", "?"),
        len(draft.flora_species),
    )

add_herbivore(draft: DraftState, params: HerbivoreSpeciesParams) -> None

Append one herbivore species and expand dependent matrix state.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
params HerbivoreSpeciesParams

Herbivore species parameter object.

required
Source code in src/phids/api/services/draft/species.py
def add_herbivore(draft: DraftState, params: HerbivoreSpeciesParams) -> None:
    """Append one herbivore species and expand dependent matrix state.

    Args:
        draft: Draft state mutated in place.
        params: Herbivore species parameter object.

    """
    draft.herbivore_species.append(params)
    rebuild_species_ids(draft)
    resize_diet_matrix(draft)
    logger.debug(
        "Draft herbivore added (species_id=%s, total_herbivores=%d)",
        getattr(params, "species_id", "?"),
        len(draft.herbivore_species),
    )

remove_flora(draft: DraftState, species_id: int) -> None

Remove one flora species and compact all dependent references.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
species_id int

Flora species identifier to remove.

required

Raises:

Type Description
ValueError

No flora species with the requested identifier exists.

Source code in src/phids/api/services/draft/species.py
def remove_flora(draft: DraftState, species_id: int) -> None:
    """Remove one flora species and compact all dependent references.

    Args:
        draft: Draft state mutated in place.
        species_id: Flora species identifier to remove.

    Raises:
        ValueError: No flora species with the requested identifier exists.

    """
    from phids.api.schemas.species import FloraSpeciesParams

    idx = next(
        (
            i
            for i, fp in enumerate(draft.flora_species)
            if isinstance(fp, FloraSpeciesParams) and fp.species_id == species_id
        ),
        None,
    )
    if idx is None:
        raise ValueError(f"Flora species_id {species_id} not found.")

    del draft.flora_species[idx]
    for row in draft.diet_matrix:
        if idx < len(row):
            del row[idx]

    new_rules: list[TriggerRule] = []
    for rule in draft.trigger_rules:
        if rule.flora_species_id == species_id:
            continue
        new_rule = dataclasses.replace(rule)
        if new_rule.flora_species_id > species_id:
            new_rule.flora_species_id -= 1
        new_rules.append(new_rule)
    draft.trigger_rules = new_rules

    draft.initial_plants = [p for p in draft.initial_plants if p.species_id != species_id]
    rebuild_species_ids(draft)
    resize_diet_matrix(draft)
    logger.debug(
        "Draft flora removed (species_id=%d, total_flora=%d, remaining_trigger_rules=%d)",
        species_id,
        len(draft.flora_species),
        len(draft.trigger_rules),
    )

remove_herbivore(draft: DraftState, species_id: int) -> None

Remove one herbivore species and compact all dependent references.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
species_id int

Herbivore species identifier to remove.

required

Raises:

Type Description
ValueError

No herbivore species with the requested identifier exists.

Source code in src/phids/api/services/draft/species.py
def remove_herbivore(draft: DraftState, species_id: int) -> None:
    """Remove one herbivore species and compact all dependent references.

    Args:
        draft: Draft state mutated in place.
        species_id: Herbivore species identifier to remove.

    Raises:
        ValueError: No herbivore species with the requested identifier exists.

    """
    from phids.api.schemas.species import HerbivoreSpeciesParams

    idx = next(
        (
            i
            for i, pp in enumerate(draft.herbivore_species)
            if isinstance(pp, HerbivoreSpeciesParams) and pp.species_id == species_id
        ),
        None,
    )
    if idx is None:
        raise ValueError(f"Herbivore species_id {species_id} not found.")

    del draft.herbivore_species[idx]
    if idx < len(draft.diet_matrix):
        del draft.diet_matrix[idx]

    new_rules: list[TriggerRule] = []
    for rule in draft.trigger_rules:
        if rule.herbivore_species_id == species_id:
            continue
        new_rule = dataclasses.replace(rule)
        if new_rule.herbivore_species_id > species_id:
            new_rule.herbivore_species_id -= 1
        new_rule.activation_condition = _remap_condition_references(
            deepcopy(new_rule.activation_condition),
            removed_herbivore_id=species_id,
        )
        new_rules.append(new_rule)
    draft.trigger_rules = new_rules

    draft.initial_swarms = [s for s in draft.initial_swarms if s.species_id != species_id]
    rebuild_species_ids(draft)
    resize_diet_matrix(draft)
    logger.debug(
        "Draft herbivore removed (species_id=%d, total_herbivores=%d, remaining_trigger_rules=%d)",
        species_id,
        len(draft.herbivore_species),
        len(draft.trigger_rules),
    )

phids.api.services.draft.substances

Draft-state mutation module for substance definitions.

Provides pure functions to add, update, and remove substance definitions within the draft. Removing a substance correctly updates remaining definitions and purges associated trigger rules to maintain state integrity.

add_substance(draft: DraftState, *, name: str, is_toxin: str | bool = False, lethal: str | bool = False, repellent: str | bool = False, synthesis_duration: int = 3, aftereffect_ticks: int = 0, lethality_rate: float = 0.0, repellent_walk_ticks: int = 3, energy_cost_per_tick: float = 1.0, irreversible: str | bool = False) -> SubstanceDefinition

Append one substance definition to the bounded registry.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
name str

Operator-facing substance label.

required
is_toxin str | bool

Substance class toggle.

False
lethal str | bool

Lethal-toxin toggle.

False
repellent str | bool

Repellent-toxin toggle.

False
synthesis_duration int

Requested synthesis latency.

3
aftereffect_ticks int

Requested persistence duration after deactivation.

0
lethality_rate float

Requested lethal damage rate.

0.0
repellent_walk_ticks int

Requested repel walk duration.

3
energy_cost_per_tick float

Requested per-tick maintenance cost.

1.0
irreversible str | bool

Irreversible activation toggle.

False

Returns:

Type Description
SubstanceDefinition

The created SubstanceDefinition entry.

Raises:

Type Description
ValueError

The Rule of 16 ceiling for substances has been reached.

Source code in src/phids/api/services/draft/substances.py
def add_substance(
    draft: DraftState,
    *,
    name: str,
    is_toxin: str | bool = False,
    lethal: str | bool = False,
    repellent: str | bool = False,
    synthesis_duration: int = 3,
    aftereffect_ticks: int = 0,
    lethality_rate: float = 0.0,
    repellent_walk_ticks: int = 3,
    energy_cost_per_tick: float = 1.0,
    irreversible: str | bool = False,
) -> SubstanceDefinition:
    """Append one substance definition to the bounded registry.

    Args:
        draft: Draft state mutated in place.
        name: Operator-facing substance label.
        is_toxin: Substance class toggle.
        lethal: Lethal-toxin toggle.
        repellent: Repellent-toxin toggle.
        synthesis_duration: Requested synthesis latency.
        aftereffect_ticks: Requested persistence duration after deactivation.
        lethality_rate: Requested lethal damage rate.
        repellent_walk_ticks: Requested repel walk duration.
        energy_cost_per_tick: Requested per-tick maintenance cost.
        irreversible: Irreversible activation toggle.

    Returns:
        The created ``SubstanceDefinition`` entry.

    Raises:
        ValueError: The Rule of 16 ceiling for substances has been reached.

    """
    if len(draft.substance_definitions) >= 16:
        raise ValueError("Rule of 16: maximum substances reached.")

    definition = SubstanceDefinition(
        substance_id=len(draft.substance_definitions),
        name=name,
        is_toxin=is_truthy_flag(is_toxin),
        lethal=is_truthy_flag(lethal),
        repellent=is_truthy_flag(repellent),
        synthesis_duration=max(1, synthesis_duration),
        aftereffect_ticks=max(0, aftereffect_ticks),
        lethality_rate=max(0.0, lethality_rate),
        repellent_walk_ticks=max(0, repellent_walk_ticks),
        energy_cost_per_tick=max(0.0, energy_cost_per_tick),
        irreversible=is_truthy_flag(irreversible),
    )
    draft.substance_definitions.append(definition)
    logger.debug(
        "Draft substance added (substance_id=%d, name=%s, is_toxin=%s, total_substances=%d)",
        definition.substance_id,
        definition.name,
        definition.is_toxin,
        len(draft.substance_definitions),
    )
    return definition

remove_substance(draft: DraftState, substance_id: int) -> None

Remove one substance definition and compact all dependent references.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
substance_id int

Substance identifier to remove.

required

Raises:

Type Description
ValueError

No substance with the requested identifier exists.

Source code in src/phids/api/services/draft/substances.py
def remove_substance(draft: DraftState, substance_id: int) -> None:
    """Remove one substance definition and compact all dependent references.

    Args:
        draft: Draft state mutated in place.
        substance_id: Substance identifier to remove.

    Raises:
        ValueError: No substance with the requested identifier exists.

    """
    idx = find_substance_index(draft, substance_id)
    if idx is None:
        raise ValueError(f"Substance {substance_id} not found.")

    del draft.substance_definitions[idx]
    for new_id, definition in enumerate(draft.substance_definitions):
        definition.substance_id = new_id

    remaining_rules: list[TriggerRule] = []
    removed_rules = 0
    for rule in draft.trigger_rules:
        if rule.substance_id == substance_id:
            removed_rules += 1
            continue
        new_rule = dataclasses.replace(rule)
        if new_rule.substance_id > substance_id:
            new_rule.substance_id -= 1
        new_rule.activation_condition = _remap_condition_references(
            deepcopy(new_rule.activation_condition),
            removed_substance_id=substance_id,
        )
        remaining_rules.append(new_rule)
    draft.trigger_rules = remaining_rules

    logger.debug(
        (
            "Draft substance removed (substance_id=%d, total_substances=%d, "
            "remaining_trigger_rules=%d, removed_trigger_rules=%d)"
        ),
        substance_id,
        len(draft.substance_definitions),
        len(draft.trigger_rules),
        removed_rules,
    )

update_substance(draft: DraftState, substance_id: int, *, name: str | None = None, type_label: str | None = None, synthesis_duration: int | None = None, aftereffect_ticks: int | None = None, lethality_rate: float | None = None, repellent_walk_ticks: int | None = None, energy_cost_per_tick: float | None = None, irreversible: str | bool | None = None) -> SubstanceDefinition

Patch one substance definition in place.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
substance_id int

Substance identifier to modify.

required
name str | None

Optional replacement name.

None
type_label str | None

Optional UI type label controlling toxin flags.

None
synthesis_duration int | None

Optional replacement synthesis latency.

None
aftereffect_ticks int | None

Optional replacement persistence duration.

None
lethality_rate float | None

Optional replacement lethal damage rate.

None
repellent_walk_ticks int | None

Optional replacement repel walk duration.

None
energy_cost_per_tick float | None

Optional replacement maintenance cost.

None
irreversible str | bool | None

Optional replacement irreversible flag.

None

Returns:

Type Description
SubstanceDefinition

The mutated SubstanceDefinition entry.

Raises:

Type Description
ValueError

No substance with the requested identifier exists.

Source code in src/phids/api/services/draft/substances.py
def update_substance(
    draft: DraftState,
    substance_id: int,
    *,
    name: str | None = None,
    type_label: str | None = None,
    synthesis_duration: int | None = None,
    aftereffect_ticks: int | None = None,
    lethality_rate: float | None = None,
    repellent_walk_ticks: int | None = None,
    energy_cost_per_tick: float | None = None,
    irreversible: str | bool | None = None,
) -> SubstanceDefinition:
    """Patch one substance definition in place.

    Args:
        draft: Draft state mutated in place.
        substance_id: Substance identifier to modify.
        name: Optional replacement name.
        type_label: Optional UI type label controlling toxin flags.
        synthesis_duration: Optional replacement synthesis latency.
        aftereffect_ticks: Optional replacement persistence duration.
        lethality_rate: Optional replacement lethal damage rate.
        repellent_walk_ticks: Optional replacement repel walk duration.
        energy_cost_per_tick: Optional replacement maintenance cost.
        irreversible: Optional replacement irreversible flag.

    Returns:
        The mutated ``SubstanceDefinition`` entry.

    Raises:
        ValueError: No substance with the requested identifier exists.

    """
    idx = find_substance_index(draft, substance_id)
    if idx is None:
        raise ValueError(f"Substance {substance_id} not found.")

    definition = draft.substance_definitions[idx]
    if name is not None:
        definition.name = name
    if type_label is not None:
        definition.is_toxin = type_label in (
            "Lethal Toxin",
            "Repellent Toxin",
            "Repelling Toxin",
            "Toxin",
        )
        definition.lethal = type_label == "Lethal Toxin"
        definition.repellent = type_label in ("Repellent Toxin", "Repelling Toxin")
    if synthesis_duration is not None:
        definition.synthesis_duration = max(1, synthesis_duration)
    if aftereffect_ticks is not None:
        definition.aftereffect_ticks = max(0, aftereffect_ticks)
    if lethality_rate is not None:
        definition.lethality_rate = max(0.0, lethality_rate)
    if repellent_walk_ticks is not None:
        definition.repellent_walk_ticks = max(0, repellent_walk_ticks)
    if energy_cost_per_tick is not None:
        definition.energy_cost_per_tick = max(0.0, energy_cost_per_tick)
    if irreversible is not None:
        definition.irreversible = is_truthy_flag(irreversible)

    logger.debug(
        "Draft substance updated (substance_id=%d, name=%s, is_toxin=%s)",
        substance_id,
        definition.name,
        definition.is_toxin,
    )
    return definition

phids.api.services.draft.trigger_rules

Draft-state mutation module for trigger rules and conditions.

Provides pure functions to add, remove, and update trigger rules, as well as complex tree-manipulation functions for editing the hierarchical activation condition nodes. Also exposes read-only query helpers (e.g. get_condition_node) so callers never need to import private ui_state path-resolution utilities directly.

add_trigger_rule(draft: DraftState, flora_species_id: int, herbivore_species_id: int = 0, substance_id: int = 0, action_type: Literal['synthesize_substance', 'resource_withdrawal'] = 'synthesize_substance', apparent_nutrition_factor: float = 0.2, withdrawal_duration: int = 10, aftereffect_ticks: int = 10, min_herbivore_population: int = 5, activation_condition: ActivationConditionNode | None = None, initiator_type: Literal['herbivore_attack', 'environmental_signal'] = 'herbivore_attack', initiator_signal_id: int = 0, initiator_min_concentration: float = 0.01) -> None

Append one trigger rule to the draft trigger ledger.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
flora_species_id int

Flora species identifier.

required
herbivore_species_id int

Herbivore species identifier.

0
substance_id int

Substance identifier synthesized by the rule.

0
action_type Literal['synthesize_substance', 'resource_withdrawal']

"synthesize_substance" or "resource_withdrawal".

'synthesize_substance'
apparent_nutrition_factor float

Factor for resource_withdrawal.

0.2
withdrawal_duration int

Duration of the nutrition withdrawal.

10
aftereffect_ticks int

Duration of aftereffect.

10
min_herbivore_population int

Minimum herbivore population threshold.

5
activation_condition ActivationConditionNode | None

Optional nested activation-condition tree.

None
initiator_type Literal['herbivore_attack', 'environmental_signal']

The type of trigger initiator.

'herbivore_attack'
initiator_signal_id int

The ID of the environmental signal.

0
initiator_min_concentration float

Minimum signal concentration.

0.01
Source code in src/phids/api/services/draft/trigger_rules.py
def add_trigger_rule(
    draft: DraftState,
    flora_species_id: int,
    herbivore_species_id: int = 0,
    substance_id: int = 0,
    action_type: Literal["synthesize_substance", "resource_withdrawal"] = "synthesize_substance",
    apparent_nutrition_factor: float = 0.2,
    withdrawal_duration: int = 10,
    aftereffect_ticks: int = 10,
    min_herbivore_population: int = 5,
    activation_condition: ActivationConditionNode | None = None,
    initiator_type: Literal["herbivore_attack", "environmental_signal"] = "herbivore_attack",
    initiator_signal_id: int = 0,
    initiator_min_concentration: float = 0.01,
) -> None:
    """Append one trigger rule to the draft trigger ledger.

    Args:
        draft: Draft state mutated in place.
        flora_species_id: Flora species identifier.
        herbivore_species_id: Herbivore species identifier.
        substance_id: Substance identifier synthesized by the rule.
        action_type: "synthesize_substance" or "resource_withdrawal".
        apparent_nutrition_factor: Factor for resource_withdrawal.
        withdrawal_duration: Duration of the nutrition withdrawal.
        aftereffect_ticks: Duration of aftereffect.
        min_herbivore_population: Minimum herbivore population threshold.
        activation_condition: Optional nested activation-condition tree.
        initiator_type: The type of trigger initiator.
        initiator_signal_id: The ID of the environmental signal.
        initiator_min_concentration: Minimum signal concentration.

    """
    draft.trigger_rules.append(
        TriggerRule(
            flora_species_id=flora_species_id,
            initiator_type=initiator_type,
            herbivore_species_id=herbivore_species_id,
            min_herbivore_population=min_herbivore_population,
            initiator_signal_id=initiator_signal_id,
            initiator_min_concentration=initiator_min_concentration,
            substance_id=substance_id,
            action_type=action_type,
            apparent_nutrition_factor=apparent_nutrition_factor,
            withdrawal_duration=withdrawal_duration,
            aftereffect_ticks=aftereffect_ticks,
            activation_condition=deepcopy(activation_condition),
        )
    )
    logger.debug(
        "Draft trigger rule added (flora_species_id=%d, herbivore_species_id=%d, substance_id=%d, total_rules=%d)",
        flora_species_id,
        herbivore_species_id,
        substance_id,
        len(draft.trigger_rules),
    )

append_trigger_rule_condition_child(draft: DraftState, index: int, parent_path: str, condition: ActivationConditionNode) -> None

Append one child condition to a group node in a trigger tree.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Trigger-rule index in the draft list.

required
parent_path str

Dotted path to the parent group node.

required
condition ActivationConditionNode

Child node payload to append.

required

Raises:

Type Description
IndexError

The parent node is missing or is not a valid group node.

Source code in src/phids/api/services/draft/trigger_rules.py
def append_trigger_rule_condition_child(
    draft: DraftState,
    index: int,
    parent_path: str,
    condition: ActivationConditionNode,
) -> None:
    """Append one child condition to a group node in a trigger tree.

    Args:
        draft: Draft state mutated in place.
        index: Trigger-rule index in the draft list.
        parent_path: Dotted path to the parent group node.
        condition: Child node payload to append.

    Raises:
        IndexError: The parent node is missing or is not a valid group node.

    """
    rule = draft.trigger_rules[index]
    if rule.activation_condition is None:
        raise IndexError("Trigger rule has no activation condition to append to.")
    root = deepcopy(rule.activation_condition)
    parent = _condition_node_at_path(root, _parse_condition_path(parent_path))
    if parent.get("kind") not in {"all_of", "any_of"}:
        raise IndexError("Condition parent is not a group node.")
    children = parent.setdefault("conditions", [])
    if not isinstance(children, list):
        raise IndexError("Condition parent has an invalid child list.")
    children.append(deepcopy(condition))
    rule.activation_condition = root

default_activation_condition_for_rule(draft: DraftState, rule: TriggerRule, node_kind: str) -> ActivationConditionNode

Construct a default activation-condition node compatible with a trigger rule.

Parameters:

Name Type Description Default
draft DraftState

Active draft state containing species and substance registries.

required
rule TriggerRule

Trigger rule being edited.

required
node_kind str

Requested node discriminator.

required

Returns:

Type Description
ActivationConditionNode

Default node payload suitable for insertion into a condition tree.

Raises:

Type Description
HTTPException

node_kind is unsupported by the condition editor.

Source code in src/phids/api/services/draft/trigger_rules.py
def default_activation_condition_for_rule(
    draft: DraftState,
    rule: TriggerRule,
    node_kind: str,
) -> ActivationConditionNode:
    """Construct a default activation-condition node compatible with a trigger rule.

    Args:
        draft: Active draft state containing species and substance registries.
        rule: Trigger rule being edited.
        node_kind: Requested node discriminator.

    Returns:
        Default node payload suitable for insertion into a condition tree.

    Raises:
        HTTPException: ``node_kind`` is unsupported by the condition editor.
    """
    default_herbivore_species_id = rule.herbivore_species_id
    default_substance_id = rule.substance_id
    for definition in draft.substance_definitions:
        if definition.substance_id != rule.substance_id:
            default_substance_id = definition.substance_id
            break

    if node_kind == "herbivore_presence":
        return {
            "kind": "herbivore_presence",
            "herbivore_species_id": default_herbivore_species_id,
            "min_herbivore_population": max(1, rule.min_herbivore_population),
        }
    if node_kind == "substance_active":
        return {"kind": "substance_active", "substance_id": default_substance_id}
    if node_kind == "environmental_signal":
        return {
            "kind": "environmental_signal",
            "signal_id": rule.substance_id,
            "min_concentration": 0.01,
        }
    if node_kind in {"all_of", "any_of"}:
        return {
            "kind": node_kind,
            "conditions": [
                {
                    "kind": "herbivore_presence",
                    "herbivore_species_id": default_herbivore_species_id,
                    "min_herbivore_population": max(1, rule.min_herbivore_population),
                }
            ],
        }
    raise HTTPException(status_code=400, detail=f"Unsupported condition node kind: {node_kind}")

delete_trigger_rule_condition_node(draft: DraftState, index: int, path: str) -> None

Delete one condition node by dotted path and prune empty groups.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Trigger-rule index in the draft list.

required
path str

Dotted child index path to remove.

required

Raises:

Type Description
IndexError

The path or parent node does not resolve to a removable child slot.

Source code in src/phids/api/services/draft/trigger_rules.py
def delete_trigger_rule_condition_node(draft: DraftState, index: int, path: str) -> None:
    """Delete one condition node by dotted path and prune empty groups.

    Args:
        draft: Draft state mutated in place.
        index: Trigger-rule index in the draft list.
        path: Dotted child index path to remove.

    Raises:
        IndexError: The path or parent node does not resolve to a removable child slot.

    """
    rule = draft.trigger_rules[index]
    if rule.activation_condition is None:
        return
    if not path:
        rule.activation_condition = None
        return
    root = deepcopy(rule.activation_condition)
    path_indices = _parse_condition_path(path)
    parent = _condition_node_at_path(root, path_indices[:-1])
    if parent.get("kind") not in {"all_of", "any_of"}:
        raise IndexError("Condition parent is not a group node.")
    children = parent.get("conditions")
    if not isinstance(children, list):
        raise IndexError("Condition parent has no child list.")
    child_index = path_indices[-1]
    if child_index < 0 or child_index >= len(children):
        raise IndexError("Condition node index is out of range.")
    del children[child_index]
    rule.activation_condition = _prune_empty_condition_groups(root)

get_condition_node(rule_condition: ActivationConditionNode, path: str) -> ActivationConditionNode

Resolve one condition node by a dotted path string.

This is the public surface for condition-tree reads so that callers never need to import the private _condition_node_at_path / _parse_condition_path helpers from ui_state directly.

Parameters:

Name Type Description Default
rule_condition ActivationConditionNode

Root condition node of a trigger rule.

required
path str

Dotted integer-index path (e.g. "0.1"). Empty string returns the root.

required

Returns:

Type Description
ActivationConditionNode

The ActivationConditionNode dict at the requested path.

Raises:

Type Description
IndexError

The path does not resolve to a valid node.

Source code in src/phids/api/services/draft/trigger_rules.py
def get_condition_node(
    rule_condition: ActivationConditionNode,
    path: str,
) -> ActivationConditionNode:
    """Resolve one condition node by a dotted path string.

    This is the public surface for condition-tree reads so that callers never
    need to import the private ``_condition_node_at_path`` / ``_parse_condition_path``
    helpers from ``ui_state`` directly.

    Args:
        rule_condition: Root condition node of a trigger rule.
        path: Dotted integer-index path (e.g. ``"0.1"``). Empty string returns the root.

    Returns:
        The ``ActivationConditionNode`` dict at the requested path.

    Raises:
        IndexError: The path does not resolve to a valid node.

    """
    if not path:
        return rule_condition
    return _condition_node_at_path(rule_condition, _parse_condition_path(path))

parse_activation_condition_json(raw: str | None) -> ActivationConditionNode | None

Parse and validate a serialized activation-condition tree from builder input.

Parameters:

Name Type Description Default
raw str | None

Raw JSON text submitted from trigger-rule editing controls.

required

Returns:

Type Description
ActivationConditionNode | None

Normalized condition dictionary, or None when the input is absent/blank.

Raises:

Type Description
HTTPException

Condition JSON is syntactically invalid or violates schema constraints.

Source code in src/phids/api/services/draft/trigger_rules.py
def parse_activation_condition_json(raw: str | None) -> ActivationConditionNode | None:
    """Parse and validate a serialized activation-condition tree from builder input.

    Args:
        raw: Raw JSON text submitted from trigger-rule editing controls.

    Returns:
        Normalized condition dictionary, or ``None`` when the input is absent/blank.

    Raises:
        HTTPException: Condition JSON is syntactically invalid or violates schema constraints.
    """
    if raw is None:
        return None
    text = raw.strip()
    if not text:
        return None
    try:
        payload = json.loads(text)
    except json.JSONDecodeError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid condition JSON: {exc.msg}") from exc

    try:
        condition = _condition_adapter.validate_python(payload)
    except ValidationError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid activation condition: {exc}") from exc
    return condition.model_dump(mode="json")

remove_trigger_rule(draft: DraftState, index: int) -> None

Remove one trigger rule by list index.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Trigger-rule index in the draft list.

required

Raises:

Type Description
IndexError

The requested trigger-rule index is out of range.

Source code in src/phids/api/services/draft/trigger_rules.py
def remove_trigger_rule(draft: DraftState, index: int) -> None:
    """Remove one trigger rule by list index.

    Args:
        draft: Draft state mutated in place.
        index: Trigger-rule index in the draft list.

    Raises:
        IndexError: The requested trigger-rule index is out of range.

    """
    removed = draft.trigger_rules[index]
    del draft.trigger_rules[index]
    logger.debug(
        (
            "Draft trigger rule removed (index=%d, flora_species_id=%d, "
            "herbivore_species_id=%d, substance_id=%d, total_rules=%d)"
        ),
        index,
        removed.flora_species_id,
        removed.herbivore_species_id,
        removed.substance_id,
        len(draft.trigger_rules),
    )

replace_trigger_rule_condition_node(draft: DraftState, index: int, path: str, condition: ActivationConditionNode) -> None

Replace one condition node addressed by a dotted path.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Trigger-rule index in the draft list.

required
path str

Dotted child index path identifying the node to replace.

required
condition ActivationConditionNode

Replacement node payload.

required

Raises:

Type Description
IndexError

The path or parent node does not resolve to a mutable child slot.

Source code in src/phids/api/services/draft/trigger_rules.py
def replace_trigger_rule_condition_node(
    draft: DraftState,
    index: int,
    path: str,
    condition: ActivationConditionNode,
) -> None:
    """Replace one condition node addressed by a dotted path.

    Args:
        draft: Draft state mutated in place.
        index: Trigger-rule index in the draft list.
        path: Dotted child index path identifying the node to replace.
        condition: Replacement node payload.

    Raises:
        IndexError: The path or parent node does not resolve to a mutable child slot.

    """
    rule = draft.trigger_rules[index]
    if not path:
        rule.activation_condition = deepcopy(condition)
        return
    if rule.activation_condition is None:
        raise IndexError("Trigger rule has no activation condition to replace.")
    root = deepcopy(rule.activation_condition)
    path_indices = _parse_condition_path(path)
    parent = _condition_node_at_path(root, path_indices[:-1])
    if parent.get("kind") not in {"all_of", "any_of"}:
        raise IndexError("Condition parent is not a group node.")
    children = parent.get("conditions")
    if not isinstance(children, list):
        raise IndexError("Condition parent has no child list.")
    child_index = path_indices[-1]
    if child_index < 0 or child_index >= len(children):
        raise IndexError("Condition node index is out of range.")
    children[child_index] = deepcopy(condition)
    rule.activation_condition = root

set_trigger_rule_activation_condition(draft: DraftState, index: int, condition: ActivationConditionNode | None) -> None

Replace the full activation-condition tree for one trigger rule.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Trigger-rule index in the draft list.

required
condition ActivationConditionNode | None

Full replacement condition tree.

required
Source code in src/phids/api/services/draft/trigger_rules.py
def set_trigger_rule_activation_condition(
    draft: DraftState,
    index: int,
    condition: ActivationConditionNode | None,
) -> None:
    """Replace the full activation-condition tree for one trigger rule.

    Args:
        draft: Draft state mutated in place.
        index: Trigger-rule index in the draft list.
        condition: Full replacement condition tree.

    """
    draft.trigger_rules[index].activation_condition = deepcopy(condition)

trigger_rule_by_index(draft: DraftState, index: int) -> TriggerRule

Return one trigger rule from draft state with HTTP-oriented bounds checking.

Parameters:

Name Type Description Default
draft DraftState

Active draft state containing trigger rules.

required
index int

Positional index requested by route handlers.

required

Returns:

Type Description
TriggerRule

Trigger rule at the requested index.

Raises:

Type Description
HTTPException

Index is outside the current trigger-rule list bounds.

Source code in src/phids/api/services/draft/trigger_rules.py
def trigger_rule_by_index(draft: DraftState, index: int) -> TriggerRule:
    """Return one trigger rule from draft state with HTTP-oriented bounds checking.

    Args:
        draft: Active draft state containing trigger rules.
        index: Positional index requested by route handlers.

    Returns:
        Trigger rule at the requested index.

    Raises:
        HTTPException: Index is outside the current trigger-rule list bounds.
    """
    if index < 0 or index >= len(draft.trigger_rules):
        raise HTTPException(status_code=404, detail=f"Trigger rule {index} not found.")
    return draft.trigger_rules[index]

update_trigger_rule(draft: DraftState, index: int, *, flora_species_id: int | None = None, herbivore_species_id: int | None = None, initiator_type: Literal['herbivore_attack', 'environmental_signal'] | None = None, initiator_signal_id: int | None = None, initiator_min_concentration: float | None = None, substance_id: int | None = None, action_type: Literal['synthesize_substance', 'resource_withdrawal'] | None = None, apparent_nutrition_factor: float | None = None, withdrawal_duration: int | None = None, aftereffect_ticks: int | None = None, min_herbivore_population: int | None = None, activation_condition: ActivationConditionNode | None = None) -> None

Patch selected fields on one trigger rule.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Trigger-rule index in the draft list.

required
flora_species_id int | None

Optional replacement flora species identifier.

None
herbivore_species_id int | None

Optional replacement herbivore species identifier.

None
initiator_type Literal['herbivore_attack', 'environmental_signal'] | None

Optional replacement initiator type.

None
initiator_signal_id int | None

Optional replacement signal identifier.

None
initiator_min_concentration float | None

Optional replacement minimum concentration.

None
substance_id int | None

Optional replacement substance identifier.

None
action_type Literal['synthesize_substance', 'resource_withdrawal'] | None

Optional replacement action type.

None
apparent_nutrition_factor float | None

Optional replacement nutrition factor.

None
withdrawal_duration int | None

Optional replacement nutrition withdrawal duration.

None
aftereffect_ticks int | None

Optional replacement aftereffect ticks.

None
min_herbivore_population int | None

Optional replacement threshold.

None
activation_condition ActivationConditionNode | None

Optional replacement condition tree.

None

Raises:

Type Description
IndexError

The requested trigger-rule index is out of range.

Source code in src/phids/api/services/draft/trigger_rules.py
def update_trigger_rule(
    draft: DraftState,
    index: int,
    *,
    flora_species_id: int | None = None,
    herbivore_species_id: int | None = None,
    initiator_type: Literal["herbivore_attack", "environmental_signal"] | None = None,
    initiator_signal_id: int | None = None,
    initiator_min_concentration: float | None = None,
    substance_id: int | None = None,
    action_type: Literal["synthesize_substance", "resource_withdrawal"] | None = None,
    apparent_nutrition_factor: float | None = None,
    withdrawal_duration: int | None = None,
    aftereffect_ticks: int | None = None,
    min_herbivore_population: int | None = None,
    activation_condition: ActivationConditionNode | None = None,
) -> None:
    """Patch selected fields on one trigger rule.

    Args:
        draft: Draft state mutated in place.
        index: Trigger-rule index in the draft list.
        flora_species_id: Optional replacement flora species identifier.
        herbivore_species_id: Optional replacement herbivore species identifier.
        initiator_type: Optional replacement initiator type.
        initiator_signal_id: Optional replacement signal identifier.
        initiator_min_concentration: Optional replacement minimum concentration.
        substance_id: Optional replacement substance identifier.
        action_type: Optional replacement action type.
        apparent_nutrition_factor: Optional replacement nutrition factor.
        withdrawal_duration: Optional replacement nutrition withdrawal duration.
        aftereffect_ticks: Optional replacement aftereffect ticks.
        min_herbivore_population: Optional replacement threshold.
        activation_condition: Optional replacement condition tree.

    Raises:
        IndexError: The requested trigger-rule index is out of range.

    """
    rule = draft.trigger_rules[index]
    if flora_species_id is not None:
        rule.flora_species_id = flora_species_id
    if herbivore_species_id is not None:
        rule.herbivore_species_id = herbivore_species_id
    if initiator_type is not None:
        rule.initiator_type = initiator_type
    if initiator_signal_id is not None:
        rule.initiator_signal_id = initiator_signal_id
    if initiator_min_concentration is not None:
        rule.initiator_min_concentration = initiator_min_concentration
    if substance_id is not None:
        rule.substance_id = substance_id
    if action_type is not None:
        rule.action_type = action_type
    if apparent_nutrition_factor is not None:
        rule.apparent_nutrition_factor = apparent_nutrition_factor
    if withdrawal_duration is not None:
        rule.withdrawal_duration = withdrawal_duration
    if aftereffect_ticks is not None:
        rule.aftereffect_ticks = aftereffect_ticks
    if min_herbivore_population is not None:
        rule.min_herbivore_population = min_herbivore_population
    if activation_condition is not None:
        rule.activation_condition = deepcopy(activation_condition)
    logger.debug(
        "Draft trigger rule updated (index=%d, flora_species_id=%d, herbivore_species_id=%d, substance_id=%d)",
        index,
        rule.flora_species_id,
        rule.herbivore_species_id,
        rule.substance_id,
    )

update_trigger_rule_condition_node(draft: DraftState, index: int, path: str, **fields: ConditionValue) -> None

Patch selected key-value fields on one condition node.

Parameters:

Name Type Description Default
draft DraftState

Draft state mutated in place.

required
index int

Trigger-rule index in the draft list.

required
path str

Dotted path to the condition node.

required
**fields ConditionValue

Replacement key-value fields merged into the node.

{}

Raises:

Type Description
IndexError

The trigger rule has no condition tree or path resolution fails.

Source code in src/phids/api/services/draft/trigger_rules.py
def update_trigger_rule_condition_node(
    draft: DraftState,
    index: int,
    path: str,
    **fields: ConditionValue,
) -> None:
    """Patch selected key-value fields on one condition node.

    Args:
        draft: Draft state mutated in place.
        index: Trigger-rule index in the draft list.
        path: Dotted path to the condition node.
        **fields: Replacement key-value fields merged into the node.

    Raises:
        IndexError: The trigger rule has no condition tree or path resolution fails.

    """
    rule = draft.trigger_rules[index]
    if rule.activation_condition is None:
        raise IndexError("Trigger rule has no activation condition to update.")
    root = deepcopy(rule.activation_condition)
    node = _condition_node_at_path(root, _parse_condition_path(path))
    node.update(fields)
    rule.activation_condition = root

phids.api.services.dse.task_manager

DSE background task manager.

Manages running, cancelling, and streaming live progress metrics from the Design Space Exploration (DSE) genetic algorithm task in a non-blocking background worker thread.

DSETaskManager

Manages the background execution of the DSE Optimizer.

Attributes:

Name Type Description
websocket_manager

The websocket manager instance used to broadcast generation progress.

pareto_cache list[SimulationConfig]

Cache of the current Pareto front candidate configs.

Source code in src/phids/api/services/dse/task_manager.py
class DSETaskManager:
    """Manages the background execution of the DSE Optimizer.

    Attributes:
        websocket_manager: The websocket manager instance used to broadcast generation progress.
        pareto_cache: Cache of the current Pareto front candidate configs.

    """

    def __init__(self, websocket_manager: "DSEStreamManager") -> None:
        """Initialize the DSE Task Manager.

        Args:
            websocket_manager: WS stream manager for DSE metrics.

        """
        self.websocket_manager = websocket_manager
        self._active_task: asyncio.Task[Any] | None = None
        self._cancel_event: threading.Event | None = None
        self._main_loop: asyncio.AbstractEventLoop | None = None
        self.pareto_cache: list[SimulationConfig] = []

    def _broadcast_payload(self) -> Callable[[dict[str, Any], list[SimulationConfig]], None]:
        """Create a synchronous closure that safely schedules the broadcast on the main event loop.

        Returns:
            A callback closure matching the DSE optimizer callback interface.

        """

        def callback(payload: dict[str, Any], configs: list[SimulationConfig]) -> None:
            self.pareto_cache = configs
            if self._main_loop is not None:
                asyncio.run_coroutine_threadsafe(self.websocket_manager.broadcast_dse(payload), self._main_loop)

        return callback

    def start_dse_task(self, config: SimulationConfig) -> None:
        """Start the DSE optimization in a background thread.

        Args:
            config: Base simulation config blueprint.

        """
        if self._active_task is not None and not self._active_task.done():
            logger.warning("Attempted to start DSE task, but one is already running.")
            return

        self._main_loop = asyncio.get_running_loop()
        self._cancel_event = threading.Event()

        optimizer = DSEOptimizer(base_config=config)

        def _run_optimizer() -> None:
            try:
                # Run the CPU-bound optimizer with the sync callback hook
                optimizer.run(
                    sync_callback=self._broadcast_payload(),
                    cancel_event=self._cancel_event,
                )
            except Exception:
                logger.exception("DSE Optimizer task failed.")

        # Run in a separate thread so we don't block the FastAPI event loop
        self._active_task = asyncio.create_task(asyncio.to_thread(_run_optimizer))
        logger.info("DSE background task started.")

    def stop_dse_task(self) -> None:
        """Gracefully stop the DSE optimization."""
        if self._cancel_event:
            self._cancel_event.set()

        # We don't cancel the task directly as it runs in a thread
        # Setting the event allows the optimizer loop to break gracefully

        self._active_task = None
        logger.info("DSE background task stop requested.")

__init__(websocket_manager: DSEStreamManager) -> None

Initialize the DSE Task Manager.

Parameters:

Name Type Description Default
websocket_manager DSEStreamManager

WS stream manager for DSE metrics.

required
Source code in src/phids/api/services/dse/task_manager.py
def __init__(self, websocket_manager: "DSEStreamManager") -> None:
    """Initialize the DSE Task Manager.

    Args:
        websocket_manager: WS stream manager for DSE metrics.

    """
    self.websocket_manager = websocket_manager
    self._active_task: asyncio.Task[Any] | None = None
    self._cancel_event: threading.Event | None = None
    self._main_loop: asyncio.AbstractEventLoop | None = None
    self.pareto_cache: list[SimulationConfig] = []

start_dse_task(config: SimulationConfig) -> None

Start the DSE optimization in a background thread.

Parameters:

Name Type Description Default
config SimulationConfig

Base simulation config blueprint.

required
Source code in src/phids/api/services/dse/task_manager.py
def start_dse_task(self, config: SimulationConfig) -> None:
    """Start the DSE optimization in a background thread.

    Args:
        config: Base simulation config blueprint.

    """
    if self._active_task is not None and not self._active_task.done():
        logger.warning("Attempted to start DSE task, but one is already running.")
        return

    self._main_loop = asyncio.get_running_loop()
    self._cancel_event = threading.Event()

    optimizer = DSEOptimizer(base_config=config)

    def _run_optimizer() -> None:
        try:
            # Run the CPU-bound optimizer with the sync callback hook
            optimizer.run(
                sync_callback=self._broadcast_payload(),
                cancel_event=self._cancel_event,
            )
        except Exception:
            logger.exception("DSE Optimizer task failed.")

    # Run in a separate thread so we don't block the FastAPI event loop
    self._active_task = asyncio.create_task(asyncio.to_thread(_run_optimizer))
    logger.info("DSE background task started.")

stop_dse_task() -> None

Gracefully stop the DSE optimization.

Source code in src/phids/api/services/dse/task_manager.py
def stop_dse_task(self) -> None:
    """Gracefully stop the DSE optimization."""
    if self._cancel_event:
        self._cancel_event.set()

    # We don't cancel the task directly as it runs in a thread
    # Setting the event allows the optimizer loop to break gracefully

    self._active_task = None
    logger.info("DSE background task stop requested.")

get_dse_manager(ws_manager: DSEStreamManager) -> DSETaskManager

Return the global DSE Task Manager.

Parameters:

Name Type Description Default
ws_manager DSEStreamManager

The websocket manager to initialize with.

required

Returns:

Type Description
DSETaskManager

The singleton DSETaskManager instance.

Source code in src/phids/api/services/dse/task_manager.py
def get_dse_manager(ws_manager: "DSEStreamManager") -> DSETaskManager:
    """Return the global DSE Task Manager.

    Args:
        ws_manager: The websocket manager to initialize with.

    Returns:
        The singleton DSETaskManager instance.

    """
    global dse_task_manager
    if dse_task_manager is None:
        dse_task_manager = DSETaskManager(ws_manager)
    return dse_task_manager

Engine orchestration

phids.engine.loop

Simulation loop orchestration for deterministic, double-buffered ecosystem advancement.

This module implements the principal simulation driver for PHIDS, responsible for advancing the grid environment and ECS world through a rigorously ordered sequence of systems: flow field, lifecycle, interaction, signaling, and telemetry/termination. The simulation loop enforces deterministic update ordering via an asyncio.Lock, ensuring reproducibility and scientific validity. Double-buffering is employed to maintain a strict separation between read and write states, preventing race conditions and guaranteeing the integrity of biological phenomena such as systemic acquired resistance, metabolic attrition, and mitosis. Per-tick snapshots are captured for replay and telemetry, supporting comprehensive analysis of emergent behaviours and ecological dynamics. The architectural design reflects the project's commitment to data-oriented modelling, O(1) spatial hash lookups, and the Rule of 16 for memory allocation, thereby simulating complex plant-herbivore interactions with maximal computational efficiency and biological fidelity.

SimulationLoop

Orchestrate deterministic, double-buffered simulation ticks.

Double-buffering is achieved by keeping a read-copy of the grid state while writing results to live objects; these writes become the read state for the next tick. Concurrent access is protected by asyncio.Lock.

Parameters:

Name Type Description Default
config SimulationConfig

Validated :class:~phids.api.schemas.SimulationConfig.

required
Source code in src/phids/engine/loop.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
class SimulationLoop:
    """Orchestrate deterministic, double-buffered simulation ticks.

    Double-buffering is achieved by keeping a read-copy of the grid state
    while writing results to live objects; these writes become the read
    state for the next tick. Concurrent access is protected by
    ``asyncio.Lock``.

    Args:
        config: Validated :class:`~phids.api.schemas.SimulationConfig`.

    """

    def __init__(self, config: SimulationConfig, *, disable_replay: bool = False) -> None:
        """Initialise the SimulationLoop with the provided configuration.

        Args:
            config: Validated SimulationConfig instance from the API payload.
            disable_replay: If True, disables Zarr replay recording to disk.

        """
        self.config = config
        self.tick: int = 0
        self.running: bool = False
        self.paused: bool = False
        self.terminated: bool = False
        self.termination_reason: str | None = None
        self._lock: asyncio.Lock = asyncio.Lock()
        self._debug_tick_interval: int = get_simulation_debug_interval()
        self._state_revision: int = 0
        self._cached_snapshot_tick: int = -1
        self._cached_snapshot: ReplayState | None = None
        self.run_id: str = uuid.uuid4().hex

        # Build environment
        self.env = GridEnvironment(
            width=config.grid_width,
            height=config.grid_height,
            num_signals=config.num_signals,
            num_toxins=config.num_toxins,
        )
        self.env.set_uniform_wind(config.wind_x, config.wind_y)

        # Build ECS
        self.world = ECSWorld()

        # Telemetry
        self.telemetry = TelemetryRecorder()
        # Deterministic replay state frames using Zarr
        if disable_replay:
            from phids.io.zarr_replay import NoOpReplayBuffer

            self.replay: Any = NoOpReplayBuffer()
            self._replay_supports_raw_arrays = True
            logger.info("Using NoOp replay backend (disabling disk storage)")
        else:
            self.replay = ReplayBuffer(max_frames=MAX_REPLAY_FRAMES)
            self._replay_supports_raw_arrays = True
            logger.info("Using Zarr replay backend (max_frames=%d)", MAX_REPLAY_FRAMES)

        # Pre-compute species parameter lookups
        self._flora_params: dict[int, FloraSpeciesParams] = {sp.species_id: sp for sp in config.flora_species}
        self._herbivore_params: dict[int, HerbivoreSpeciesParams] = {
            sp.species_id: sp for sp in config.herbivore_species
        }
        self._trigger_conditions: dict[int, list[TriggerConditionSchema]] = {
            sp.species_id: list(sp.triggers) for sp in config.flora_species
        }
        self._diet_matrix: list[list[bool]] = config.diet_matrix.rows

        # Spawn initial entities
        self._spawn_initial_entities()
        logger.info(
            (
                "SimulationLoop initialised (grid=%dx%d, flora_species=%d, "
                "herbivore_species=%d, signals=%d, toxins=%d, tick_rate_hz=%.2f)"
            ),
            config.grid_width,
            config.grid_height,
            len(config.flora_species),
            len(config.herbivore_species),
            config.num_signals,
            config.num_toxins,
            config.tick_rate_hz,
        )

    # ------------------------------------------------------------------
    # Initialisation helpers
    # ------------------------------------------------------------------

    def _spawn_initial_entities(self) -> None:
        """Place initial plants and swarms from the configuration.

        The method creates entity instances, attaches components, registers
        spatial positions in the :class:`ECSWorld`, and populates the
        environment's plant energy buffers.
        """
        spawned_plants = 0
        spawned_swarms = 0

        for plant_placement in self.config.initial_plants:
            params = self._flora_params.get(plant_placement.species_id)
            if params is None:
                logger.warning(
                    "Skipping initial plant placement with unknown flora species_id=%d at (%d, %d)",
                    plant_placement.species_id,
                    plant_placement.x,
                    plant_placement.y,
                )
                continue
            entity = self.world.create_entity()
            plant = PlantComponent(
                entity_id=entity.entity_id,
                species_id=plant_placement.species_id,
                x=plant_placement.x,
                y=plant_placement.y,
                energy=plant_placement.energy,
                max_energy=params.max_energy,
                base_energy=params.base_energy,
                growth_rate=params.growth_rate,
                survival_threshold=params.survival_threshold,
                reproduction_interval=params.reproduction_interval,
                seed_min_dist=params.seed_min_dist,
                seed_max_dist=params.seed_max_dist,
                seed_energy_cost=params.seed_energy_cost,
                seed_drop_height=params.seed_drop_height,
                seed_terminal_velocity=params.seed_terminal_velocity,
                camouflage=params.camouflage,
                camouflage_factor=params.camouflage_factor,
            )
            self.world.add_component(entity.entity_id, plant)
            self.world.register_position(entity.entity_id, plant_placement.x, plant_placement.y)
            self.env.set_plant_energy(
                plant_placement.x,
                plant_placement.y,
                plant_placement.species_id,
                plant_placement.energy,
            )
            spawned_plants += 1

        for swarm_placement in self.config.initial_swarms:
            entity = self.world.create_entity()
            swarm = SwarmComponent(
                entity_id=entity.entity_id,
                species_id=swarm_placement.species_id,
                x=swarm_placement.x,
                y=swarm_placement.y,
                population=swarm_placement.population,
                initial_population=swarm_placement.population,
                energy=swarm_placement.energy,
                energy_min=self._get_herbivore_energy_min(swarm_placement.species_id),
                velocity=self._get_herbivore_velocity(swarm_placement.species_id),
                consumption_rate=self._get_herbivore_consumption_rate(swarm_placement.species_id),
                reproduction_energy_divisor=self._get_herbivore_reproduction_divisor(swarm_placement.species_id),
                energy_upkeep_per_individual=self._get_herbivore_energy_upkeep(swarm_placement.species_id),
                split_population_threshold=self._get_herbivore_split_threshold(swarm_placement.species_id),
            )
            self.world.add_component(entity.entity_id, swarm)
            self.world.register_position(entity.entity_id, swarm_placement.x, swarm_placement.y)
            spawned_swarms += 1

        self.env.rebuild_energy_layer()
        logger.info(
            "Initial entities spawned (plants=%d, swarms=%d)",
            spawned_plants,
            spawned_swarms,
        )

    def _get_herbivore_energy_min(self, species_id: int) -> float:
        """Return the configured minimum energy for a herbivore species.

        Args:
            species_id: Herbivore species identifier to look up.

        Returns:
            float: Configured minimum energy if found, otherwise a sensible
            default of 1.0.

        """
        params = self._herbivore_params.get(species_id)
        if params is not None:
            return float(params.energy_min)
        return 1.0

    def _get_herbivore_velocity(self, species_id: int) -> int:
        """Return the configured movement period (velocity) for a herbivore.

        Args:
            species_id: Herbivore species identifier to look up.

        Returns:
            int: Movement period in ticks; defaults to 1 when not found.

        """
        params = self._herbivore_params.get(species_id)
        if params is not None:
            return int(params.velocity)
        return 1

    def _get_herbivore_consumption_rate(self, species_id: int) -> float:
        """Return the per-tick consumption rate for a herbivore species.

        Args:
            species_id: Herbivore species identifier to look up.

        Returns:
            float: Consumption rate if present, otherwise 1.0 by default.

        """
        params = self._herbivore_params.get(species_id)
        if params is not None:
            return float(params.consumption_rate)
        return 1.0

    def _get_herbivore_reproduction_divisor(self, species_id: int) -> float:
        """Return the configured reproduction divisor for a herbivore species.

        Args:
            species_id: Herbivore species identifier to look up.

        Returns:
            float: Reproduction divisor if present, otherwise 1.0.

        """
        params = self._herbivore_params.get(species_id)
        if params is not None:
            return float(params.reproduction_energy_divisor)
        return 1.0

    def _get_herbivore_energy_upkeep(self, species_id: int) -> float:
        """Return the configured per-individual metabolic upkeep scalar for a herbivore species.

        Args:
            species_id: Herbivore species identifier to look up.

        Returns:
            Configured upkeep scalar if found; otherwise 0.05 as a sensible default.

        """
        params = self._herbivore_params.get(species_id)
        if params is not None:
            return float(params.energy_upkeep_per_individual)
        return 0.05

    def _get_herbivore_split_threshold(self, species_id: int) -> int:
        """Return the configured explicit mitosis population threshold for a herbivore species.

        Args:
            species_id: Herbivore species identifier to look up.

        Returns:
            Configured split threshold if found; otherwise 10.

        """
        params = self._herbivore_params.get(species_id)
        if params is not None:
            return int(params.split_population_threshold)
        return 10

    # ------------------------------------------------------------------
    # Simulation control
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Mark the simulation as running.

        Sets running state to True and clears the paused flag.
        """
        self.running = True
        self.paused = False
        logger.info("Simulation loop started/resumed at tick %d", self.tick)

    def pause(self) -> None:
        """Toggle the paused state.

        Flips the ``paused`` boolean.
        """
        self.paused = not self.paused
        logger.info("Simulation loop %s at tick %d", "paused" if self.paused else "resumed", self.tick)

    def stop(self) -> None:
        """Halt the simulation by clearing the running flag."""
        self.running = False
        logger.info("Simulation loop stopped at tick %d", self.tick)

    def _should_log_debug_summary(self) -> bool:
        """Return whether the current tick should emit a DEBUG summary."""
        return (
            logger.isEnabledFor(logging.DEBUG)
            and self._debug_tick_interval > 0
            and self.tick % self._debug_tick_interval == 0
        )

    def _append_replay_frame(self) -> None:
        """Append one replay frame using the backend-specific ingestion path."""
        if hasattr(self.replay, "append_raw_arrays"):
            self.replay.append_raw_arrays(
                tick=self.tick,
                env=self.env,
                termination_state=(self.terminated, self.termination_reason),
            )
        else:
            self.replay.append(self.get_state_snapshot())

    def _log_debug_tick_summary(
        self,
        *,
        latest_metrics: TelemetryRow | None,
        tick_metrics: TickMetrics,
        phase_timings_ms: dict[str, float],
    ) -> None:
        """Emit a coarse DEBUG snapshot for the current tick."""
        flora_energy = float(tick_metrics.total_flora_energy)
        if latest_metrics is not None:
            flora_energy = _metric_float(
                latest_metrics.get("total_flora_energy", tick_metrics.total_flora_energy),
                flora_energy,
            )

        flora_population = int(tick_metrics.flora_population)
        if latest_metrics is not None:
            flora_population = _metric_int(
                latest_metrics.get("flora_population", tick_metrics.flora_population),
                flora_population,
            )

        herbivore_clusters = int(tick_metrics.herbivore_clusters)
        if latest_metrics is not None:
            herbivore_clusters = _metric_int(
                latest_metrics.get("herbivore_clusters", tick_metrics.herbivore_clusters),
                herbivore_clusters,
            )

        herbivore_population = int(tick_metrics.herbivore_population)
        if latest_metrics is not None:
            herbivore_population = _metric_int(
                latest_metrics.get("herbivore_population", tick_metrics.herbivore_population),
                herbivore_population,
            )

        logger.debug(
            (
                "Tick summary (tick=%d, flora_energy=%.3f, flora_population=%d, "
                "herbivore_clusters=%d, herbivore_population=%d, replay_frames=%d, "
                "phase_timings_ms=%s)"
            ),
            self.tick,
            flora_energy,
            flora_population,
            herbivore_clusters,
            herbivore_population,
            len(self.replay),
            phase_timings_ms,
        )

    # ------------------------------------------------------------------
    # Core tick
    # ------------------------------------------------------------------

    async def step(self) -> TerminationResult:
        """Execute one deterministic simulation tick.

        The method performs the ordered phases of the simulation (flow-field
        update, lifecycle, interaction, signaling, telemetry) while holding
        an asyncio lock to ensure async-safety. After processing it
        evaluates termination conditions.

        Returns:
            TerminationResult: Termination state after the tick.

        """
        async with self._lock:
            if self.terminated:
                logger.debug(
                    "Simulation step skipped because loop is already terminated at tick %d",
                    self.tick,
                )
                return TerminationResult(terminated=True, reason=self.termination_reason or "")

            debug_summary = self._should_log_debug_summary()
            phase_timings_ms: dict[str, float] = {}
            plant_death_causes = {
                "death_reproduction": 0,
                "death_mycorrhiza": 0,
                "death_defense_maintenance": 0,
                "death_herbivore_feeding": 0,
                "death_background_deficit": 0,
            }
            herbivore_death_causes = {
                "death_starvation": 0,
                "death_lethal_toxin": 0,
            }
            phase_started = time.perf_counter()

            # --------------------------------------------------------
            # Phase 1: Flow-field update (uses current read state)
            # --------------------------------------------------------
            self.env.flow_field = compute_flow_field(
                self.env.plant_energy_layer,
                self.env.apparent_nutrition_layer,
                self.env.toxin_layers,
                self.env.width,
                self.env.height,
                self.env._flow_field_base,
                self.env._flow_field_current,
                self.env._flow_field_nxt,
                self.config.chemotaxis_alpha,
                self.config.chemotaxis_beta,
                self.config.chemotaxis_decay,
                self.config.chemotaxis_truncate_threshold,
            )
            if debug_summary:
                phase_timings_ms["flow_field"] = (time.perf_counter() - phase_started) * 1000.0
                phase_started = time.perf_counter()

            # Apply camouflage attenuations
            for entity in self.world.query(PlantComponent):
                plant: PlantComponent = entity.get_component(PlantComponent)
                if plant.camouflage:
                    apply_camouflage(self.env.flow_field, plant.x, plant.y, plant.camouflage_factor)

            # --------------------------------------------------------
            # Phase 2: Lifecycle (grow, connect, reproduce, cull)
            # --------------------------------------------------------
            run_lifecycle(
                self.world,
                self.env,
                self.tick,
                cast("dict[int, object]", self._flora_params),
                mycorrhizal_connection_cost=self.config.mycorrhizal_connection_cost,
                mycorrhizal_growth_interval_ticks=self.config.mycorrhizal_growth_interval_ticks,
                mycorrhizal_inter_species=self.config.mycorrhizal_inter_species,
                plant_death_causes=plant_death_causes,
            )
            if debug_summary:
                phase_timings_ms["lifecycle"] = (time.perf_counter() - phase_started) * 1000.0
                phase_started = time.perf_counter()

            # --------------------------------------------------------
            # Phase 3: Interaction (movement, feeding, starvation, mitosis)
            # --------------------------------------------------------
            run_interaction(
                self.world,
                self.env,
                self._diet_matrix,
                list(self.config.flora_species),
                list(self.config.herbivore_species),
                self.tick,
                plant_death_causes=plant_death_causes,
                herbivore_death_causes=herbivore_death_causes,
            )
            if debug_summary:
                phase_timings_ms["interaction"] = (time.perf_counter() - phase_started) * 1000.0
                phase_started = time.perf_counter()

            # --------------------------------------------------------
            # Phase 4: Signaling (substance synthesis, diffusion, toxins)
            # --------------------------------------------------------
            run_signaling(
                self.world,
                self.env,
                self._trigger_conditions,
                self.config.mycorrhizal_inter_species,
                self.config.mycorrhizal_signal_velocity,
                self.tick,
                plant_death_causes=plant_death_causes,
                substance_emit_rate=self.config.substance_emit_rate,
                signal_decay_factor=self.config.signal_decay_factor,
            )
            if debug_summary:
                phase_timings_ms["signaling"] = (time.perf_counter() - phase_started) * 1000.0
                phase_started = time.perf_counter()

            # Commit all energy depletion from feeding and defense upkeep before
            # telemetry sampling and next-tick flow-field evaluation.
            self.env.rebuild_energy_layer()

            # Build one shared metrics snapshot for telemetry and termination.
            tick_metrics: TickMetrics = collect_tick_metrics(self.world)
            tick_metrics.plant_death_causes = plant_death_causes
            tick_metrics.herbivore_death_causes = herbivore_death_causes

            # --------------------------------------------------------
            # Phase 5: Telemetry
            # --------------------------------------------------------
            self.telemetry.record(
                self.world,
                self.tick,
                tick_metrics=tick_metrics,
            )
            self._append_replay_frame()
            latest_metrics = self.telemetry.get_latest_metrics()
            if debug_summary:
                phase_timings_ms["telemetry_replay"] = (time.perf_counter() - phase_started) * 1000.0
                phase_started = time.perf_counter()

            # --------------------------------------------------------
            # Phase 6: Termination check (double-buffer swap happens here
            #          implicitly - all writes committed before check)
            # --------------------------------------------------------
            result = check_termination(
                self.world,
                self.tick,
                max_ticks=self.config.max_ticks,
                z2_flora_species=self.config.z2_flora_species_extinction,
                z4_herbivore_species=self.config.z4_herbivore_species_extinction,
                z6_max_flora_energy=self.config.z6_max_total_flora_energy,
                z7_max_total_herbivore_population=self.config.z7_max_total_herbivore_population,
                tick_metrics=tick_metrics,
            )
            if debug_summary:
                phase_timings_ms["termination"] = (time.perf_counter() - phase_started) * 1000.0

            self.tick += 1
            if debug_summary:
                self._log_debug_tick_summary(
                    latest_metrics=latest_metrics,
                    tick_metrics=tick_metrics,
                    phase_timings_ms=phase_timings_ms,
                )

            if result.terminated:
                self.terminated = True
                self.running = False
                self.termination_reason = result.reason
                logger.info("Simulation terminated at tick %d: %s", self.tick, result.reason)

            return result

    async def run(self) -> None:
        """Run the simulation loop until termination at configured tick rate.

        The loop respects ``paused`` and sleeps to maintain ``tick_rate_hz``.
        """
        self.start()
        logger.info("Simulation run loop entering background execution")

        while self.running and not self.terminated:
            tick_interval = 1.0 / max(0.1, self.config.tick_rate_hz)
            if self.paused:
                await asyncio.sleep(tick_interval)
                continue

            t0 = time.monotonic()
            result = await self.step()
            if result.terminated:
                break
            elapsed = time.monotonic() - t0
            sleep_time = max(0.0, tick_interval - elapsed)
            await asyncio.sleep(sleep_time)

        logger.info(
            "Simulation run loop exited (tick=%d, terminated=%s, reason=%s)",
            self.tick,
            self.terminated,
            self.termination_reason,
        )

    def update_tick_rate(self, tick_rate_hz: float) -> float:
        """Update live simulation tick speed while preserving safe lower bounds.

        Args:
            tick_rate_hz: Requested simulation ticks per second.

        Returns:
            Applied tick-rate value after clamping.

        """
        applied = max(0.1, float(tick_rate_hz))
        self.config.tick_rate_hz = applied
        logger.info("Simulation tick rate updated to %.2f Hz", applied)
        return applied

    # ------------------------------------------------------------------
    # Wind update (REST API integration point)
    # ------------------------------------------------------------------

    def update_wind(self, vx: float, vy: float) -> None:
        """Update the environment uniform wind vector.

        Args:
            vx: The horizontal vector component of the globally applied wind force.
            vy: The vertical vector component of the globally applied wind force.

        """
        self.env.set_uniform_wind(vx, vy)
        # Wind can change snapshot content without advancing ticks.
        self._state_revision += 1
        self._cached_snapshot_tick = -1
        self._cached_snapshot = None
        logger.info("Simulation wind updated to (vx=%.3f, vy=%.3f)", vx, vy)

    @property
    def state_revision(self) -> int:
        """Return a monotonic token for non-tick state mutations relevant to stream payloads."""
        return self._state_revision

    # ------------------------------------------------------------------
    # State snapshot for WebSocket streaming
    # ------------------------------------------------------------------

    def get_state_snapshot(self) -> ReplayState:
        """Return a serialisable snapshot of the current grid state.

        Returns:
            ReplayState: Snapshot containing tick, termination state and
            environment dictionary (from :meth:`GridEnvironment.to_dict`).

        """
        if self._cached_snapshot_tick == self.tick and self._cached_snapshot is not None:
            return self._cached_snapshot

        snapshot = cast(
            "ReplayState",
            {
                "tick": self.tick,
                "terminated": self.terminated,
                "termination_reason": self.termination_reason,
                "state_revision": self._state_revision,
                **self.env.to_dict(),
            },
        )
        self._cached_snapshot_tick = self.tick
        self._cached_snapshot = snapshot
        return snapshot

state_revision: int property

Return a monotonic token for non-tick state mutations relevant to stream payloads.

__init__(config: SimulationConfig, *, disable_replay: bool = False) -> None

Initialise the SimulationLoop with the provided configuration.

Parameters:

Name Type Description Default
config SimulationConfig

Validated SimulationConfig instance from the API payload.

required
disable_replay bool

If True, disables Zarr replay recording to disk.

False
Source code in src/phids/engine/loop.py
def __init__(self, config: SimulationConfig, *, disable_replay: bool = False) -> None:
    """Initialise the SimulationLoop with the provided configuration.

    Args:
        config: Validated SimulationConfig instance from the API payload.
        disable_replay: If True, disables Zarr replay recording to disk.

    """
    self.config = config
    self.tick: int = 0
    self.running: bool = False
    self.paused: bool = False
    self.terminated: bool = False
    self.termination_reason: str | None = None
    self._lock: asyncio.Lock = asyncio.Lock()
    self._debug_tick_interval: int = get_simulation_debug_interval()
    self._state_revision: int = 0
    self._cached_snapshot_tick: int = -1
    self._cached_snapshot: ReplayState | None = None
    self.run_id: str = uuid.uuid4().hex

    # Build environment
    self.env = GridEnvironment(
        width=config.grid_width,
        height=config.grid_height,
        num_signals=config.num_signals,
        num_toxins=config.num_toxins,
    )
    self.env.set_uniform_wind(config.wind_x, config.wind_y)

    # Build ECS
    self.world = ECSWorld()

    # Telemetry
    self.telemetry = TelemetryRecorder()
    # Deterministic replay state frames using Zarr
    if disable_replay:
        from phids.io.zarr_replay import NoOpReplayBuffer

        self.replay: Any = NoOpReplayBuffer()
        self._replay_supports_raw_arrays = True
        logger.info("Using NoOp replay backend (disabling disk storage)")
    else:
        self.replay = ReplayBuffer(max_frames=MAX_REPLAY_FRAMES)
        self._replay_supports_raw_arrays = True
        logger.info("Using Zarr replay backend (max_frames=%d)", MAX_REPLAY_FRAMES)

    # Pre-compute species parameter lookups
    self._flora_params: dict[int, FloraSpeciesParams] = {sp.species_id: sp for sp in config.flora_species}
    self._herbivore_params: dict[int, HerbivoreSpeciesParams] = {
        sp.species_id: sp for sp in config.herbivore_species
    }
    self._trigger_conditions: dict[int, list[TriggerConditionSchema]] = {
        sp.species_id: list(sp.triggers) for sp in config.flora_species
    }
    self._diet_matrix: list[list[bool]] = config.diet_matrix.rows

    # Spawn initial entities
    self._spawn_initial_entities()
    logger.info(
        (
            "SimulationLoop initialised (grid=%dx%d, flora_species=%d, "
            "herbivore_species=%d, signals=%d, toxins=%d, tick_rate_hz=%.2f)"
        ),
        config.grid_width,
        config.grid_height,
        len(config.flora_species),
        len(config.herbivore_species),
        config.num_signals,
        config.num_toxins,
        config.tick_rate_hz,
    )

get_state_snapshot() -> ReplayState

Return a serialisable snapshot of the current grid state.

Returns:

Name Type Description
ReplayState ReplayState

Snapshot containing tick, termination state and

ReplayState

environment dictionary (from :meth:GridEnvironment.to_dict).

Source code in src/phids/engine/loop.py
def get_state_snapshot(self) -> ReplayState:
    """Return a serialisable snapshot of the current grid state.

    Returns:
        ReplayState: Snapshot containing tick, termination state and
        environment dictionary (from :meth:`GridEnvironment.to_dict`).

    """
    if self._cached_snapshot_tick == self.tick and self._cached_snapshot is not None:
        return self._cached_snapshot

    snapshot = cast(
        "ReplayState",
        {
            "tick": self.tick,
            "terminated": self.terminated,
            "termination_reason": self.termination_reason,
            "state_revision": self._state_revision,
            **self.env.to_dict(),
        },
    )
    self._cached_snapshot_tick = self.tick
    self._cached_snapshot = snapshot
    return snapshot

pause() -> None

Toggle the paused state.

Flips the paused boolean.

Source code in src/phids/engine/loop.py
def pause(self) -> None:
    """Toggle the paused state.

    Flips the ``paused`` boolean.
    """
    self.paused = not self.paused
    logger.info("Simulation loop %s at tick %d", "paused" if self.paused else "resumed", self.tick)

run() -> None async

Run the simulation loop until termination at configured tick rate.

The loop respects paused and sleeps to maintain tick_rate_hz.

Source code in src/phids/engine/loop.py
async def run(self) -> None:
    """Run the simulation loop until termination at configured tick rate.

    The loop respects ``paused`` and sleeps to maintain ``tick_rate_hz``.
    """
    self.start()
    logger.info("Simulation run loop entering background execution")

    while self.running and not self.terminated:
        tick_interval = 1.0 / max(0.1, self.config.tick_rate_hz)
        if self.paused:
            await asyncio.sleep(tick_interval)
            continue

        t0 = time.monotonic()
        result = await self.step()
        if result.terminated:
            break
        elapsed = time.monotonic() - t0
        sleep_time = max(0.0, tick_interval - elapsed)
        await asyncio.sleep(sleep_time)

    logger.info(
        "Simulation run loop exited (tick=%d, terminated=%s, reason=%s)",
        self.tick,
        self.terminated,
        self.termination_reason,
    )

start() -> None

Mark the simulation as running.

Sets running state to True and clears the paused flag.

Source code in src/phids/engine/loop.py
def start(self) -> None:
    """Mark the simulation as running.

    Sets running state to True and clears the paused flag.
    """
    self.running = True
    self.paused = False
    logger.info("Simulation loop started/resumed at tick %d", self.tick)

step() -> TerminationResult async

Execute one deterministic simulation tick.

The method performs the ordered phases of the simulation (flow-field update, lifecycle, interaction, signaling, telemetry) while holding an asyncio lock to ensure async-safety. After processing it evaluates termination conditions.

Returns:

Name Type Description
TerminationResult TerminationResult

Termination state after the tick.

Source code in src/phids/engine/loop.py
async def step(self) -> TerminationResult:
    """Execute one deterministic simulation tick.

    The method performs the ordered phases of the simulation (flow-field
    update, lifecycle, interaction, signaling, telemetry) while holding
    an asyncio lock to ensure async-safety. After processing it
    evaluates termination conditions.

    Returns:
        TerminationResult: Termination state after the tick.

    """
    async with self._lock:
        if self.terminated:
            logger.debug(
                "Simulation step skipped because loop is already terminated at tick %d",
                self.tick,
            )
            return TerminationResult(terminated=True, reason=self.termination_reason or "")

        debug_summary = self._should_log_debug_summary()
        phase_timings_ms: dict[str, float] = {}
        plant_death_causes = {
            "death_reproduction": 0,
            "death_mycorrhiza": 0,
            "death_defense_maintenance": 0,
            "death_herbivore_feeding": 0,
            "death_background_deficit": 0,
        }
        herbivore_death_causes = {
            "death_starvation": 0,
            "death_lethal_toxin": 0,
        }
        phase_started = time.perf_counter()

        # --------------------------------------------------------
        # Phase 1: Flow-field update (uses current read state)
        # --------------------------------------------------------
        self.env.flow_field = compute_flow_field(
            self.env.plant_energy_layer,
            self.env.apparent_nutrition_layer,
            self.env.toxin_layers,
            self.env.width,
            self.env.height,
            self.env._flow_field_base,
            self.env._flow_field_current,
            self.env._flow_field_nxt,
            self.config.chemotaxis_alpha,
            self.config.chemotaxis_beta,
            self.config.chemotaxis_decay,
            self.config.chemotaxis_truncate_threshold,
        )
        if debug_summary:
            phase_timings_ms["flow_field"] = (time.perf_counter() - phase_started) * 1000.0
            phase_started = time.perf_counter()

        # Apply camouflage attenuations
        for entity in self.world.query(PlantComponent):
            plant: PlantComponent = entity.get_component(PlantComponent)
            if plant.camouflage:
                apply_camouflage(self.env.flow_field, plant.x, plant.y, plant.camouflage_factor)

        # --------------------------------------------------------
        # Phase 2: Lifecycle (grow, connect, reproduce, cull)
        # --------------------------------------------------------
        run_lifecycle(
            self.world,
            self.env,
            self.tick,
            cast("dict[int, object]", self._flora_params),
            mycorrhizal_connection_cost=self.config.mycorrhizal_connection_cost,
            mycorrhizal_growth_interval_ticks=self.config.mycorrhizal_growth_interval_ticks,
            mycorrhizal_inter_species=self.config.mycorrhizal_inter_species,
            plant_death_causes=plant_death_causes,
        )
        if debug_summary:
            phase_timings_ms["lifecycle"] = (time.perf_counter() - phase_started) * 1000.0
            phase_started = time.perf_counter()

        # --------------------------------------------------------
        # Phase 3: Interaction (movement, feeding, starvation, mitosis)
        # --------------------------------------------------------
        run_interaction(
            self.world,
            self.env,
            self._diet_matrix,
            list(self.config.flora_species),
            list(self.config.herbivore_species),
            self.tick,
            plant_death_causes=plant_death_causes,
            herbivore_death_causes=herbivore_death_causes,
        )
        if debug_summary:
            phase_timings_ms["interaction"] = (time.perf_counter() - phase_started) * 1000.0
            phase_started = time.perf_counter()

        # --------------------------------------------------------
        # Phase 4: Signaling (substance synthesis, diffusion, toxins)
        # --------------------------------------------------------
        run_signaling(
            self.world,
            self.env,
            self._trigger_conditions,
            self.config.mycorrhizal_inter_species,
            self.config.mycorrhizal_signal_velocity,
            self.tick,
            plant_death_causes=plant_death_causes,
            substance_emit_rate=self.config.substance_emit_rate,
            signal_decay_factor=self.config.signal_decay_factor,
        )
        if debug_summary:
            phase_timings_ms["signaling"] = (time.perf_counter() - phase_started) * 1000.0
            phase_started = time.perf_counter()

        # Commit all energy depletion from feeding and defense upkeep before
        # telemetry sampling and next-tick flow-field evaluation.
        self.env.rebuild_energy_layer()

        # Build one shared metrics snapshot for telemetry and termination.
        tick_metrics: TickMetrics = collect_tick_metrics(self.world)
        tick_metrics.plant_death_causes = plant_death_causes
        tick_metrics.herbivore_death_causes = herbivore_death_causes

        # --------------------------------------------------------
        # Phase 5: Telemetry
        # --------------------------------------------------------
        self.telemetry.record(
            self.world,
            self.tick,
            tick_metrics=tick_metrics,
        )
        self._append_replay_frame()
        latest_metrics = self.telemetry.get_latest_metrics()
        if debug_summary:
            phase_timings_ms["telemetry_replay"] = (time.perf_counter() - phase_started) * 1000.0
            phase_started = time.perf_counter()

        # --------------------------------------------------------
        # Phase 6: Termination check (double-buffer swap happens here
        #          implicitly - all writes committed before check)
        # --------------------------------------------------------
        result = check_termination(
            self.world,
            self.tick,
            max_ticks=self.config.max_ticks,
            z2_flora_species=self.config.z2_flora_species_extinction,
            z4_herbivore_species=self.config.z4_herbivore_species_extinction,
            z6_max_flora_energy=self.config.z6_max_total_flora_energy,
            z7_max_total_herbivore_population=self.config.z7_max_total_herbivore_population,
            tick_metrics=tick_metrics,
        )
        if debug_summary:
            phase_timings_ms["termination"] = (time.perf_counter() - phase_started) * 1000.0

        self.tick += 1
        if debug_summary:
            self._log_debug_tick_summary(
                latest_metrics=latest_metrics,
                tick_metrics=tick_metrics,
                phase_timings_ms=phase_timings_ms,
            )

        if result.terminated:
            self.terminated = True
            self.running = False
            self.termination_reason = result.reason
            logger.info("Simulation terminated at tick %d: %s", self.tick, result.reason)

        return result

stop() -> None

Halt the simulation by clearing the running flag.

Source code in src/phids/engine/loop.py
def stop(self) -> None:
    """Halt the simulation by clearing the running flag."""
    self.running = False
    logger.info("Simulation loop stopped at tick %d", self.tick)

update_tick_rate(tick_rate_hz: float) -> float

Update live simulation tick speed while preserving safe lower bounds.

Parameters:

Name Type Description Default
tick_rate_hz float

Requested simulation ticks per second.

required

Returns:

Type Description
float

Applied tick-rate value after clamping.

Source code in src/phids/engine/loop.py
def update_tick_rate(self, tick_rate_hz: float) -> float:
    """Update live simulation tick speed while preserving safe lower bounds.

    Args:
        tick_rate_hz: Requested simulation ticks per second.

    Returns:
        Applied tick-rate value after clamping.

    """
    applied = max(0.1, float(tick_rate_hz))
    self.config.tick_rate_hz = applied
    logger.info("Simulation tick rate updated to %.2f Hz", applied)
    return applied

update_wind(vx: float, vy: float) -> None

Update the environment uniform wind vector.

Parameters:

Name Type Description Default
vx float

The horizontal vector component of the globally applied wind force.

required
vy float

The vertical vector component of the globally applied wind force.

required
Source code in src/phids/engine/loop.py
def update_wind(self, vx: float, vy: float) -> None:
    """Update the environment uniform wind vector.

    Args:
        vx: The horizontal vector component of the globally applied wind force.
        vy: The vertical vector component of the globally applied wind force.

    """
    self.env.set_uniform_wind(vx, vy)
    # Wind can change snapshot content without advancing ticks.
    self._state_revision += 1
    self._cached_snapshot_tick = -1
    self._cached_snapshot = None
    logger.info("Simulation wind updated to (vx=%.3f, vy=%.3f)", vx, vy)

phids.engine.batch

Headless Monte Carlo batch processing engine for PHIDS ecosystem simulations.

This module implements the :class:BatchRunner, which executes a configurable number of deterministic simulation runs in parallel using a :class:concurrent.futures.ProcessPoolExecutor isolated from the FastAPI event loop. Each run is seeded with a unique integer so that, while the per-tick mechanics remain deterministic for a given seed, population-level stochasticity (e.g., probabilistic flow-field navigation, natal dispersal) produces meaningfully varied trajectories across the ensemble, enabling Monte Carlo estimation of extinction probabilities and Lotka-Volterra orbital stability.

The module-level function :func:_run_single_headless is intentionally not a class method. Module-level callability is a strict requirement for :mod:multiprocessing serialisation via pickle when using the spawn start method, which is mandatory on platforms where forking a process that has loaded Numba JIT-compiled functions would cause undefined behaviour. Each worker process independently JIT-compiles the flow-field kernel on first invocation; subsequent runs within the same worker process reuse the cached native code.

Statistical aggregation is performed by :func:aggregate_batch_telemetry, which aligns the per-run telemetry row lists to the minimum observed tick count, stacks them into NumPy arrays, and computes per-tick mean and standard deviation for flora population, herbivore population, and per-species sub-populations. The resulting aggregate dictionary is serialised to {output_dir}/{job_id}_summary.json for persistent retrieval and Chart.js rendering of confidence bands in the batch dashboard.

BatchResult dataclass

Aggregated result of a completed batch simulation run.

Attributes:

Name Type Description
job_id str

Unique identifier for the batch job.

runs int

Number of individual simulation runs completed.

per_run_telemetry TelemetryRuns

Nested list of raw telemetry row dicts per run.

aggregate BatchAggregate

Statistical summary produced by :func:aggregate_batch_telemetry.

Source code in src/phids/engine/batch.py
@dataclass
class BatchResult:
    """Aggregated result of a completed batch simulation run.

    Attributes:
        job_id: Unique identifier for the batch job.
        runs: Number of individual simulation runs completed.
        per_run_telemetry: Nested list of raw telemetry row dicts per run.
        aggregate: Statistical summary produced by
            :func:`aggregate_batch_telemetry`.

    """

    job_id: str
    runs: int
    per_run_telemetry: TelemetryRuns = field(default_factory=list)
    aggregate: BatchAggregate = field(default_factory=dict)

BatchRunner

Orchestrate parallel Monte Carlo simulation runs using ProcessPoolExecutor.

The :class:BatchRunner dispatches :func:_run_and_save to a ProcessPoolExecutor configured with the spawn multiprocessing context to avoid Numba/asyncio fork conflicts. Progress is reported via an optional on_progress callback invoked in the main process after each completed future, enabling the FastAPI background task to update the BatchJobState without blocking the event loop.

Aggregate results are written to {output_dir}/{job_id}_summary.json upon completion of all runs, making them available for retrieval via the GET /api/batch/view/{job_id} endpoint.

Source code in src/phids/engine/batch.py
class BatchRunner:
    """Orchestrate parallel Monte Carlo simulation runs using ProcessPoolExecutor.

    The :class:`BatchRunner` dispatches :func:`_run_and_save` to a
    ``ProcessPoolExecutor`` configured with the ``spawn`` multiprocessing
    context to avoid Numba/asyncio fork conflicts. Progress is reported via
    an optional ``on_progress`` callback invoked in the main process after
    each completed future, enabling the FastAPI background task to update the
    ``BatchJobState`` without blocking the event loop.

    Aggregate results are written to ``{output_dir}/{job_id}_summary.json``
    upon completion of all runs, making them available for retrieval via the
    ``GET /api/batch/view/{job_id}`` endpoint.
    """

    def execute_batch(
        self,
        scenario_dict: dict[str, object],
        runs: int,
        max_ticks: int,
        job_id: str,
        output_dir: Path | None = None,
        on_progress: Callable[[int], None] | None = None,
        scenario_name: str | None = None,
    ) -> BatchResult:
        """Execute ``runs`` independent simulation trajectories in parallel.

        Dispatches all runs to a :class:`concurrent.futures.ProcessPoolExecutor`
        using the ``spawn`` start method, collects telemetry as futures complete,
        and computes statistical aggregates. The summary JSON is written to
        ``{output_dir}/{job_id}_summary.json``.

        Args:
            scenario_dict: JSON-serialisable ``SimulationConfig`` representation.
            runs: Number of independent simulation runs to execute.
            max_ticks: Maximum tick count per run.
            job_id: Unique batch job identifier for file naming.
            output_dir: Directory for output files; defaults to ``data/batches``.
            on_progress: Optional callback invoked with completed count as each
                future resolves.
            scenario_name: Optional display label persisted into the summary so
                restored ledgers can retain operator-selected names.

        Returns:
            Completed result with all per-run telemetry and aggregate.
        """
        save_dir = output_dir or _DEFAULT_BATCH_DIR
        save_dir.mkdir(parents=True, exist_ok=True)

        max_workers = min(runs, os.cpu_count() or 1)
        mp_ctx = multiprocessing.get_context("spawn")
        per_run_telemetry: TelemetryRuns = []
        completed = 0

        logger.info(
            "Batch job %s starting (runs=%d, max_ticks=%d, workers=%d)",
            job_id,
            runs,
            max_ticks,
            max_workers,
        )

        args_list = [
            (scenario_dict, max_ticks, seed, job_id, idx, str(save_dir)) for idx, seed in enumerate(range(runs))
        ]

        with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers, mp_context=mp_ctx) as executor:
            futures = {executor.submit(_run_and_save, args): i for i, args in enumerate(args_list)}
            for future in concurrent.futures.as_completed(futures):
                try:
                    rows = future.result()
                    per_run_telemetry.append(rows)
                except Exception:
                    logger.exception("Batch run %s failed", futures[future])
                    per_run_telemetry.append([])

                completed += 1
                if on_progress is not None:
                    on_progress(completed)

        aggregate = aggregate_batch_telemetry(per_run_telemetry)
        persisted_scenario_name = (scenario_name or str(scenario_dict.get("scenario_name", ""))).strip()
        aggregate["scenario_name"] = persisted_scenario_name or "unnamed"
        aggregate = cast("BatchAggregate", _sanitize_for_json(aggregate))

        summary_path = save_dir / f"{job_id}_summary.json"
        with summary_path.open("w", encoding="utf-8") as fp:
            json.dump(aggregate, fp, allow_nan=False)
        logger.info("Batch job %s complete; summary written to %s", job_id, summary_path)

        return BatchResult(
            job_id=job_id,
            runs=runs,
            per_run_telemetry=per_run_telemetry,
            aggregate=aggregate,
        )

execute_batch(scenario_dict: dict[str, object], runs: int, max_ticks: int, job_id: str, output_dir: Path | None = None, on_progress: Callable[[int], None] | None = None, scenario_name: str | None = None) -> BatchResult

Execute runs independent simulation trajectories in parallel.

Dispatches all runs to a :class:concurrent.futures.ProcessPoolExecutor using the spawn start method, collects telemetry as futures complete, and computes statistical aggregates. The summary JSON is written to {output_dir}/{job_id}_summary.json.

Parameters:

Name Type Description Default
scenario_dict dict[str, object]

JSON-serialisable SimulationConfig representation.

required
runs int

Number of independent simulation runs to execute.

required
max_ticks int

Maximum tick count per run.

required
job_id str

Unique batch job identifier for file naming.

required
output_dir Path | None

Directory for output files; defaults to data/batches.

None
on_progress Callable[[int], None] | None

Optional callback invoked with completed count as each future resolves.

None
scenario_name str | None

Optional display label persisted into the summary so restored ledgers can retain operator-selected names.

None

Returns:

Type Description
BatchResult

Completed result with all per-run telemetry and aggregate.

Source code in src/phids/engine/batch.py
def execute_batch(
    self,
    scenario_dict: dict[str, object],
    runs: int,
    max_ticks: int,
    job_id: str,
    output_dir: Path | None = None,
    on_progress: Callable[[int], None] | None = None,
    scenario_name: str | None = None,
) -> BatchResult:
    """Execute ``runs`` independent simulation trajectories in parallel.

    Dispatches all runs to a :class:`concurrent.futures.ProcessPoolExecutor`
    using the ``spawn`` start method, collects telemetry as futures complete,
    and computes statistical aggregates. The summary JSON is written to
    ``{output_dir}/{job_id}_summary.json``.

    Args:
        scenario_dict: JSON-serialisable ``SimulationConfig`` representation.
        runs: Number of independent simulation runs to execute.
        max_ticks: Maximum tick count per run.
        job_id: Unique batch job identifier for file naming.
        output_dir: Directory for output files; defaults to ``data/batches``.
        on_progress: Optional callback invoked with completed count as each
            future resolves.
        scenario_name: Optional display label persisted into the summary so
            restored ledgers can retain operator-selected names.

    Returns:
        Completed result with all per-run telemetry and aggregate.
    """
    save_dir = output_dir or _DEFAULT_BATCH_DIR
    save_dir.mkdir(parents=True, exist_ok=True)

    max_workers = min(runs, os.cpu_count() or 1)
    mp_ctx = multiprocessing.get_context("spawn")
    per_run_telemetry: TelemetryRuns = []
    completed = 0

    logger.info(
        "Batch job %s starting (runs=%d, max_ticks=%d, workers=%d)",
        job_id,
        runs,
        max_ticks,
        max_workers,
    )

    args_list = [
        (scenario_dict, max_ticks, seed, job_id, idx, str(save_dir)) for idx, seed in enumerate(range(runs))
    ]

    with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers, mp_context=mp_ctx) as executor:
        futures = {executor.submit(_run_and_save, args): i for i, args in enumerate(args_list)}
        for future in concurrent.futures.as_completed(futures):
            try:
                rows = future.result()
                per_run_telemetry.append(rows)
            except Exception:
                logger.exception("Batch run %s failed", futures[future])
                per_run_telemetry.append([])

            completed += 1
            if on_progress is not None:
                on_progress(completed)

    aggregate = aggregate_batch_telemetry(per_run_telemetry)
    persisted_scenario_name = (scenario_name or str(scenario_dict.get("scenario_name", ""))).strip()
    aggregate["scenario_name"] = persisted_scenario_name or "unnamed"
    aggregate = cast("BatchAggregate", _sanitize_for_json(aggregate))

    summary_path = save_dir / f"{job_id}_summary.json"
    with summary_path.open("w", encoding="utf-8") as fp:
        json.dump(aggregate, fp, allow_nan=False)
    logger.info("Batch job %s complete; summary written to %s", job_id, summary_path)

    return BatchResult(
        job_id=job_id,
        runs=runs,
        per_run_telemetry=per_run_telemetry,
        aggregate=aggregate,
    )

aggregate_batch_telemetry(per_run: TelemetryRuns) -> BatchAggregate

Compute per-tick statistical summaries across an ensemble of simulation runs.

Aligns all runs to the minimum tick count observed in the ensemble (to handle early-termination runs without padding), then stacks scalar population and energy metrics into NumPy arrays for vectorised mean and standard deviation computation. Per-species populations are similarly aggregated where the union of all species identifiers seen across all runs is used as the index.

The extinction probability is estimated as the fraction of runs in which the total flora population reached zero at any tick, providing a coarse measure of ecosystem collapse risk under the configured parameter regime. A per-tick survival curve is also computed as the fraction of runs that retain strictly positive flora population at each aligned tick.

Parameters:

Name Type Description Default
per_run TelemetryRuns

List of per-run row lists, each produced by :func:_run_single_headless.

required

Returns:

Name Type Description
BatchAggregate BatchAggregate

Aggregate summary containing mean, std dev, and extinction metrics.

Source code in src/phids/engine/batch.py
def aggregate_batch_telemetry(
    per_run: TelemetryRuns,
) -> BatchAggregate:
    """Compute per-tick statistical summaries across an ensemble of simulation runs.

    Aligns all runs to the minimum tick count observed in the ensemble (to handle
    early-termination runs without padding), then stacks scalar population and
    energy metrics into NumPy arrays for vectorised mean and standard deviation
    computation. Per-species populations are similarly aggregated where the union
    of all species identifiers seen across all runs is used as the index.

    The extinction probability is estimated as the fraction of runs in which the
    total flora population reached zero at any tick, providing a coarse measure of
    ecosystem collapse risk under the configured parameter regime.
    A per-tick survival curve is also computed as the fraction of runs that retain
    strictly positive flora population at each aligned tick.

    Args:
        per_run: List of per-run row lists, each produced by :func:`_run_single_headless`.

    Returns:
        BatchAggregate: Aggregate summary containing mean, std dev, and extinction metrics.
    """
    if not per_run:
        return {}

    max_len = max(len(rows) for rows in per_run)
    longest_run = max(per_run, key=len)
    ticks = [_coerce_int(r.get("tick", 0)) for r in longest_run]

    aligned = _pad_telemetry_runs(per_run, max_len)
    scalars = _stack_scalar_aggregates(aligned)
    all_flora_ids, all_herb_ids = _extract_species_ids(aligned)
    species_aggs = _compute_species_aggregates(aligned, all_flora_ids, all_herb_ids)

    result: BatchAggregate = {
        "ticks": ticks,
        "runs_completed": len(per_run),
        # Unpack scalars
        **scalars,
        # Unpack species aggs
        **species_aggs,
    }

    logger.info(
        "Batch aggregation complete (runs=%d, max_len=%d, extinction_prob=%.3f)",
        len(per_run),
        max_len,
        scalars["extinction_probability"],
    )
    return result

ECS components

phids.engine.components.plant

Plant ECS component dataclass encoding per-entity flora runtime state.

This module defines :class:PlantComponent, the data container attached to every flora entity in the PHIDS Entity-Component-System world. Each plant entity carries its own independent energy reserve, spatial grid coordinates, species-level growth and reproduction parameters, camouflage properties, and the set of identifiers of currently connected mycorrhizal partners. The strict separation between species-level parameters (which reside in the scenario configuration) and per-entity mutable state (which resides in PlantComponent) is central to the data-oriented design: the lifecycle and signaling systems iterate over PlantComponent instances via the ECS query interface without requiring access to the configuration layer.

The energy field encodes the biological fitness proxy E_i,j(t); its dynamics are governed by the growth term applied each lifecycle tick, the seed dispersal cost deducted at reproduction, the connection cost subtracted when a new mycorrhizal link is established, the herbivory loss inflicted by co-located swarms in the interaction phase, and the defense maintenance cost imposed by active SubstanceComponent entities in the signaling phase. A plant entity is culled when energy < survival_threshold, with the cause of terminal energy loss attributed via last_energy_loss_cause for per-category death diagnostics.

PlantComponent dataclass

Holds runtime state for a single plant entity.

Attributes:

Name Type Description
entity_id int

ECS entity identifier.

species_id int

Flora species index.

x, y

Current grid coordinates.

energy float

Current energy reserve E_i,j(t).

max_energy float

Species-specific energy capacity E_max.

base_energy float

Initial energy used by growth formula.

growth_rate float

Per-tick growth rate in percent.

survival_threshold float

Energy threshold below which the plant dies.

reproduction_interval int

Ticks between reproduction attempts.

seed_min_dist float

Minimum seed dispersal distance.

seed_max_dist float

Maximum seed dispersal distance.

seed_energy_cost float

Energy cost paid for reproduction.

seed_drop_height float

Effective release height used to estimate airborne seed flight time.

seed_terminal_velocity float

Effective terminal velocity used in wind-shift estimation.

camouflage bool

Whether constitutive camouflage is active.

camouflage_factor float

Gradient multiplier when camouflaged.

last_reproduction_tick int

Tick of the most recent reproduction.

last_energy_loss_cause str | None

Most recent energetically relevant action label used for death diagnostics attribution.

mycorrhizal_connections set[int]

Set of connected plant entity ids.

apparent_nutrition_factor float

Stress-induced nutrient discount modifier.

withdrawal_ticks_remaining int

Ticks until nutrition factor reverts to 1.0.

Source code in src/phids/engine/components/plant.py
@dataclass(slots=True)
class PlantComponent:
    """Holds runtime state for a single plant entity.

    Attributes:
        entity_id: ECS entity identifier.
        species_id: Flora species index.
        x, y: Current grid coordinates.
        energy: Current energy reserve E_i,j(t).
        max_energy: Species-specific energy capacity E_max.
        base_energy: Initial energy used by growth formula.
        growth_rate: Per-tick growth rate in percent.
        survival_threshold: Energy threshold below which the plant dies.
        reproduction_interval: Ticks between reproduction attempts.
        seed_min_dist: Minimum seed dispersal distance.
        seed_max_dist: Maximum seed dispersal distance.
        seed_energy_cost: Energy cost paid for reproduction.
        seed_drop_height: Effective release height used to estimate airborne seed flight time.
        seed_terminal_velocity: Effective terminal velocity used in wind-shift estimation.
        camouflage: Whether constitutive camouflage is active.
        camouflage_factor: Gradient multiplier when camouflaged.
        last_reproduction_tick: Tick of the most recent reproduction.
        last_energy_loss_cause: Most recent energetically relevant action label
            used for death diagnostics attribution.
        mycorrhizal_connections: Set of connected plant entity ids.
        apparent_nutrition_factor: Stress-induced nutrient discount modifier.
        withdrawal_ticks_remaining: Ticks until nutrition factor reverts to 1.0.

    """

    entity_id: int
    species_id: int
    x: int
    y: int
    energy: float
    max_energy: float
    base_energy: float
    growth_rate: float
    survival_threshold: float
    reproduction_interval: int
    seed_min_dist: float
    seed_max_dist: float
    seed_energy_cost: float
    seed_drop_height: float = 1.25
    seed_terminal_velocity: float = 0.8
    camouflage: bool = False
    camouflage_factor: float = 1.0
    last_reproduction_tick: int = 0
    last_energy_loss_cause: str | None = None
    mycorrhizal_connections: set[int] = field(default_factory=set)
    mycorrhizal_tax_per_link: float = 0.0
    apparent_nutrition_factor: float = 1.0
    target_nutrition_factor: float = 1.0
    translocation_rate: float = 0.2
    withdrawal_ticks_remaining: int = 0

phids.engine.components.swarm

Herbivore swarm ECS component dataclass encoding per-entity herbivore runtime state.

This module defines :class:SwarmComponent, the data container attached to every herbivore swarm entity in the PHIDS Entity-Component-System world. Each swarm entity represents a spatially co-located cohort of individual herbivores sharing a common species identity, energy pool, and movement state. The population field tracks the integer head-count of the cohort; it is decremented by metabolic attrition when the swarm's energy reserve falls below the individual-level minimum (energy_min) and incremented by reproduction when sufficient surplus energy accumulates. When population reaches the split_population_threshold, the swarm undergoes mitosis: the cohort is divided into two halves and a new entity carrying the offspring half is registered in the ECS world and spatial hash.

The velocity field encodes the movement period in ticks between grid-cell relocations; together with move_cooldown, it implements a discrete movement-frequency mechanism that decouples slow-moving species from the per-tick grid update cycle. Movement decisions are mediated by the scalar flow-field gradient, which encodes plant energy attractors and toxin repellers computed by the Numba-accelerated flow-field kernel. When a swarm encounters a repellent toxin, repelled is set and repelled_ticks_remaining governs the duration of the subsequent random-walk dispersal phase that overrides gradient navigation.

SwarmComponent dataclass

Holds runtime state for a single herbivore swarm entity.

Attributes:

Name Type Description
entity_id int

ECS entity identifier.

species_id int

Herbivore species index.

x, y

Current grid coordinates.

population int

Current swarm head-count.

initial_population int

Head-count at spawn; used for mitosis checks.

energy float

Current energy reserve.

energy_min float

Minimum energy per individual.

velocity int

Movement period in ticks between moves.

consumption_rate float

Per-tick consumption scalar.

reproduction_energy_divisor float

Species-level growth throttle.

energy_upkeep_per_individual float

Metabolic upkeep scalar applied each tick.

split_population_threshold int

Explicit population threshold for mitosis.

repelled bool

Whether the swarm is currently repelled by toxin.

repelled_ticks_remaining int

Remaining ticks of repelled behavior.

move_cooldown int

Ticks remaining until the next movement.

last_dx int

Last movement delta on the x-axis (-1, 0, 1).

last_dy int

Last movement delta on the y-axis (-1, 0, 1).

Source code in src/phids/engine/components/swarm.py
@dataclass(slots=True)
class SwarmComponent:
    """Holds runtime state for a single herbivore swarm entity.

    Attributes:
        entity_id: ECS entity identifier.
        species_id: Herbivore species index.
        x, y: Current grid coordinates.
        population: Current swarm head-count.
        initial_population: Head-count at spawn; used for mitosis checks.
        energy: Current energy reserve.
        energy_min: Minimum energy per individual.
        velocity: Movement period in ticks between moves.
        consumption_rate: Per-tick consumption scalar.
        reproduction_energy_divisor: Species-level growth throttle.
        energy_upkeep_per_individual: Metabolic upkeep scalar applied each tick.
        split_population_threshold: Explicit population threshold for mitosis.
        repelled: Whether the swarm is currently repelled by toxin.
        repelled_ticks_remaining: Remaining ticks of repelled behavior.
        move_cooldown: Ticks remaining until the next movement.
        last_dx: Last movement delta on the x-axis (-1, 0, 1).
        last_dy: Last movement delta on the y-axis (-1, 0, 1).

    """

    entity_id: int
    species_id: int
    x: int
    y: int
    population: int
    initial_population: int
    energy: float
    energy_min: float
    velocity: int
    consumption_rate: float
    reproduction_energy_divisor: float = 1.0
    energy_upkeep_per_individual: float = 0.05
    split_population_threshold: int = 0
    repelled: bool = False
    repelled_ticks_remaining: int = 0
    move_cooldown: int = 0
    last_dx: int = 0
    last_dy: int = 0
    behavior_paradigm: str = "macro_swarm"
    aversion_memory: float = 0.0

phids.engine.components.substances

Substance ECS component dataclass encoding chemical-defense synthesis and activation state.

This module defines :class:SubstanceComponent, the data container attached to substance entities in the PHIDS ECS world. A substance entity is associated with a single owner plant entity and represents either a volatile organic compound (VOC) signal emitted into airborne signal layers for detection by neighbouring plants, or a defensive toxin emitted into local toxin layers to deter, repel, or kill co-located herbivore swarms. The substance lifecycle consists of a configurable synthesis delay (synthesis_duration ticks during which the plant invests metabolic resources), followed by an active emission phase, and an optional aftereffect window (aftereffect_ticks) during which emission persists after the triggering herbivore has departed.

Substances are dynamically created by the signaling system when a TriggerConditionSchema rule is satisfied, and are garbage-collected when their owner plant dies or when the aftereffect window expires and the trigger condition is no longer met. The irreversible flag encodes constitutive systemic acquired resistance: once activated, the substance remains permanently emitting until owner death, regardless of subsequent herbivore presence. The nested activation_condition predicate tree enables compound chemical-defense cascades, such as alarm-chain scenarios in which a secondary toxin activates only after a primary VOC signal is already present, modelling coordinated multi-species defense networks.

SubstanceComponent dataclass

Holds runtime state for a single substance entity.

A substance represents either a volatile signal (VOC) or a toxin.

Attributes:

Name Type Description
entity_id int

ECS entity identifier.

substance_id int

Layer index into signal or toxin layers.

owner_plant_id int

Entity id of the producing plant.

is_toxin bool

True for toxins, False for signals.

synthesis_duration int

Configured synthesis duration in ticks.

synthesis_remaining int

Ticks remaining before activation.

active bool

Whether the substance is currently active.

aftereffect_ticks int

Configured aftereffect duration after trigger removal.

aftereffect_remaining_ticks int

Remaining aftereffect duration at runtime.

lethal bool

Whether the toxin is lethal.

lethality_rate float

Individuals eliminated per tick when lethal.

repellent bool

Whether the toxin repels swarms.

repellent_walk_ticks int

Duration of repelled random-walk in ticks.

activation_condition dict[str, object] | None

Optional nested activation predicate tree stored in JSON-serialisable form for runtime evaluation and tooltip display.

energy_cost_per_tick float

Energy drained from the owner plant per active tick.

irreversible bool

Whether activation remains permanently on once active.

triggered_this_tick bool

Whether the trigger condition was satisfied in the current signaling pass.

Source code in src/phids/engine/components/substances.py
@dataclass(slots=True)
class SubstanceComponent:
    """Holds runtime state for a single substance entity.

    A substance represents either a volatile signal (VOC) or a toxin.

    Attributes:
        entity_id: ECS entity identifier.
        substance_id: Layer index into signal or toxin layers.
        owner_plant_id: Entity id of the producing plant.
        is_toxin: True for toxins, False for signals.
        synthesis_duration: Configured synthesis duration in ticks.
        synthesis_remaining: Ticks remaining before activation.
        active: Whether the substance is currently active.
        aftereffect_ticks: Configured aftereffect duration after trigger removal.
        aftereffect_remaining_ticks: Remaining aftereffect duration at runtime.
        lethal: Whether the toxin is lethal.
        lethality_rate: Individuals eliminated per tick when lethal.
        repellent: Whether the toxin repels swarms.
        repellent_walk_ticks: Duration of repelled random-walk in ticks.
        activation_condition: Optional nested activation predicate tree stored
            in JSON-serialisable form for runtime evaluation and tooltip display.
        energy_cost_per_tick: Energy drained from the owner plant per active tick.
        irreversible: Whether activation remains permanently on once active.
        triggered_this_tick: Whether the trigger condition was satisfied in the
            current signaling pass.

    """

    entity_id: int
    substance_id: int
    owner_plant_id: int
    is_toxin: bool = False
    synthesis_duration: int = 0
    synthesis_remaining: int = 0
    active: bool = False
    aftereffect_ticks: int = 0
    aftereffect_remaining_ticks: int = 0
    lethal: bool = False
    lethality_rate: float = 0.0
    repellent: bool = False
    repellent_walk_ticks: int = 0
    activation_condition: dict[str, object] | None = None
    energy_cost_per_tick: float = 0.0
    irreversible: bool = False
    triggered_this_tick: bool = False

Core runtime structures

phids.engine.core.biotope

GridEnvironment: NumPy-backed biotope with 2-D convolution diffusion and explicit double-buffering.

This module manages the continuous environmental state (e.g., plant energy layers, VOC gradients, wind fields, and apparent nutrition). It enforces strict read/write double-buffering across all layers to prevent race conditions and maintain exact tick-level determinism during concurrent simulation phases. No allocations are made during the diffusion loop. All layers are pre-allocated according to the Rule of 16, ensuring fixed memory allocation and avoiding dynamic resizing during simulation. The environment employs explicit read/write double-buffering to prevent race conditions and guarantee deterministic simulation of biological phenomena such as Gaussian diffusion, systemic acquired resistance, and metabolic attrition. The convolution kernel is pre-computed and its tails are truncated to eliminate subnormal floats below SIGNAL_EPSILON, maintaining computational efficiency and scientific accuracy. The architectural design is tightly coupled to the :class:~phids.engine.core.ecs.ECSWorld and flow-field systems, supporting O(1) spatial hash lookups and reproducible ecological dynamics.

GridEnvironment

Manage vectorised biotope layers and diffusion helpers.

Parameters:

Name Type Description Default
width int

Grid width W (1 ≤ W ≤ GRID_W_MAX).

40
height int

Grid height H (1 ≤ H ≤ GRID_H_MAX).

40
num_signals int

Number of signal substance layers (1 ≤ n ≤ MAX_SUBSTANCE_TYPES).

4
num_toxins int

Number of toxin substance layers (1 ≤ n ≤ MAX_SUBSTANCE_TYPES).

4
Source code in src/phids/engine/core/biotope.py
class GridEnvironment:
    """Manage vectorised biotope layers and diffusion helpers.

    Args:
        width: Grid width W (1 ≤ W ≤ GRID_W_MAX).
        height: Grid height H (1 ≤ H ≤ GRID_H_MAX).
        num_signals: Number of signal substance layers
            (1 ≤ n ≤ MAX_SUBSTANCE_TYPES).
        num_toxins: Number of toxin substance layers
            (1 ≤ n ≤ MAX_SUBSTANCE_TYPES).
    """

    def __init__(
        self,
        width: int = 40,
        height: int = 40,
        num_signals: int = 4,
        num_toxins: int = 4,
    ) -> None:
        """Initialise grid layers and double-buffered storage.

        Args:
            width: Grid width in cells.
            height: Grid height in cells.
            num_signals: Number of airborne signal layers.
            num_toxins: Number of toxin layers.
        """
        if not (1 <= width <= GRID_W_MAX):
            raise ValueError(f"width {width} out of range [1, {GRID_W_MAX}].")
        if not (1 <= height <= GRID_H_MAX):
            raise ValueError(f"height {height} out of range [1, {GRID_H_MAX}].")
        if not (1 <= num_signals <= MAX_SUBSTANCE_TYPES):
            raise ValueError(f"num_signals {num_signals} out of range [1, {MAX_SUBSTANCE_TYPES}].")
        if not (1 <= num_toxins <= MAX_SUBSTANCE_TYPES):
            raise ValueError(f"num_toxins {num_toxins} out of range [1, {MAX_SUBSTANCE_TYPES}].")

        self.width = width
        self.height = height
        self.num_signals = num_signals
        self.num_toxins = num_toxins

        shape: tuple[int, int] = (width, height)

        # ------------------------------------------------------------------
        # Plant energy layers (read/write buffers)
        # ------------------------------------------------------------------
        self.plant_energy_layer: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate
        self._plant_energy_layer_write: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

        # Global aggregate apparent nutrition factor
        self.apparent_nutrition_layer: npt.NDArray[np.float64] = np.ones(shape, dtype=np.float64)  # pragma: no mutate
        self._apparent_nutrition_layer_write: npt.NDArray[np.float64] = np.ones(
            shape, dtype=np.float64
        )  # pragma: no mutate

        # Per-species energy layers (Rule of 16 pre-allocation)
        self.plant_energy_by_species: npt.NDArray[np.float64] = np.zeros(
            (MAX_FLORA_SPECIES, width, height), dtype=np.float64
        )
        self._plant_energy_by_species_write: npt.NDArray[np.float64] = np.zeros_like(self.plant_energy_by_species)

        # ------------------------------------------------------------------
        # Wind layers (dynamic, updated via REST API)
        # ------------------------------------------------------------------
        self.wind_vector_x: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate
        self.wind_vector_y: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

        # ------------------------------------------------------------------
        # Signal layers  [num_signals, W, H] - read buffer
        # ------------------------------------------------------------------
        self.signal_layers: npt.NDArray[np.float64] = np.zeros(
            (num_signals, width, height), dtype=np.float64
        )  # pragma: no mutate
        # Write buffer for double-buffering
        self._signal_layers_write: npt.NDArray[np.float64] = np.zeros_like(self.signal_layers)

        # ------------------------------------------------------------------
        # Toxin layers  [num_toxins, W, H] (local plant-tissue fields)
        # ------------------------------------------------------------------
        self.toxin_layers: npt.NDArray[np.float64] = np.zeros(
            (num_toxins, width, height), dtype=np.float64
        )  # pragma: no mutate
        self._toxin_layers_write: npt.NDArray[np.float64] = np.zeros_like(self.toxin_layers)

        # ------------------------------------------------------------------
        # Flow-field gradient (scalar attraction field, WxH)
        # ------------------------------------------------------------------
        self.flow_field: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

        # Pre-allocated scratch buffers for flow field JIT calculations
        self._flow_field_base: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate
        self._flow_field_current: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate
        self._flow_field_nxt: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

        # Pre-allocated scratch buffer for diffusion JIT calculations
        self._advected_scratch: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

    # ------------------------------------------------------------------
    # Wind helpers
    # ------------------------------------------------------------------

    def set_uniform_wind(self, vx: float, vy: float) -> None:
        """Fill wind layers with a spatially uniform vector.

        Args:
            vx: X component of the wind.
            vy: Y component of the wind.
        """
        self.wind_vector_x[:] = vx
        self.wind_vector_y[:] = vy

    def update_wind_at(self, x: int, y: int, vx: float, vy: float) -> None:
        """Update the wind vector at a single grid cell.

        Args:
            x: The X-axis spatial grid coordinate.
            y: The Y-axis spatial grid coordinate.
            vx: X component of the wind.
            vy: Y component of the wind.
        """
        self.wind_vector_x[x, y] = vx
        self.wind_vector_y[x, y] = vy

    # ------------------------------------------------------------------
    # Diffusion
    # ------------------------------------------------------------------

    def diffuse_signals(self, signal_decay_factor: float = 0.85) -> None:
        """Compute one diffusion tick for all signal layers.

        This applies local semi-Lagrangian advection using per-cell wind vectors,
        followed by isotropic Gaussian diffusion and decay. The transport update
        respects heterogeneous wind fields across the grid and avoids global-mean
        wind averaging artefacts.

        Args:
            signal_decay_factor: Per-tick airborne signal retention (0.0-1.0).
                Defaults to the ``SIGNAL_DECAY_FACTOR`` module-level constant (0.85).
                Pass ``loop.config.signal_decay_factor`` to use the scenario-level value.
        """
        for s in range(self.num_signals):
            layer: npt.NDArray[np.float64] = self.signal_layers[s]
            if layer.max() < SIGNAL_EPSILON:
                self._signal_layers_write[s].fill(0.0)
                continue

            _numba_diffuse_signal_layer(  # type: ignore[type-var, call-arg]
                self.width,
                self.height,
                layer,
                self.wind_vector_x,
                self.wind_vector_y,
                signal_decay_factor,
                SIGNAL_EPSILON,
                DIFFUSION_KERNEL,
                self._signal_layers_write[s],
                self._advected_scratch,
            )

        # Swap buffers
        self.signal_layers, self._signal_layers_write = (
            self._signal_layers_write,
            self.signal_layers,
        )

    # ------------------------------------------------------------------
    # Plant energy helpers
    # ------------------------------------------------------------------

    def rebuild_energy_layer(self) -> None:
        """Recompute aggregate plant energy layer and swap buffers.

        Aggregates per-species write buffers into the global write buffer,
        then swaps read/write buffers so that subsequent reads observe the
        newly-written values.
        """
        np.sum(self._plant_energy_by_species_write, axis=0, out=self._plant_energy_layer_write)
        self.plant_energy_by_species, self._plant_energy_by_species_write = (
            self._plant_energy_by_species_write,
            self.plant_energy_by_species,
        )
        self.plant_energy_layer, self._plant_energy_layer_write = (
            self._plant_energy_layer_write,
            self.plant_energy_layer,
        )
        self._plant_energy_by_species_write[:] = self.plant_energy_by_species
        self._plant_energy_layer_write[:] = self.plant_energy_layer

        self.apparent_nutrition_layer, self._apparent_nutrition_layer_write = (
            self._apparent_nutrition_layer_write,
            self.apparent_nutrition_layer,
        )
        self._apparent_nutrition_layer_write.fill(1.0)

    def set_plant_energy(self, x: int, y: int, species_id: int, value: float) -> None:
        """Set a species-specific energy contribution in the write buffer.

        Args:
            x: The X-axis spatial grid coordinate.
            y: The Y-axis spatial grid coordinate.
            species_id: The integer index representing the specific phylogenetic species associated with this operation.
            value: Energy contribution (clamped to >= 0).
        """
        self._plant_energy_by_species_write[species_id, x, y] = max(0.0, value)

    def set_apparent_nutrition(self, x: int, y: int, value: float) -> None:
        """Set apparent nutrition factor in the write buffer.

        Args:
            x: The X-axis spatial grid coordinate.
            y: The Y-axis spatial grid coordinate.
            value: The apparent nutrition value to store.
        """
        self._apparent_nutrition_layer_write[x, y] = value

    def clear_plant_energy(self, x: int, y: int, species_id: int) -> None:
        """Clear a species-specific energy contribution in the write buffer.

        Args:
            x: The X-axis spatial grid coordinate.
            y: The Y-axis spatial grid coordinate.
            species_id: The integer index representing the specific phylogenetic species associated with this operation.
        """
        self._plant_energy_by_species_write[species_id, x, y] = 0.0

    # ------------------------------------------------------------------
    # State snapshot (for serialisation / streaming)
    # ------------------------------------------------------------------

    def to_dict(self) -> dict[str, object]:
        """Returns a dict representation of the biotope state suitable for serialization.

        This is used by the streaming interface to serialize the current state of the
        biotope to a dictionary.

        Returns:
            Mapping containing numpy arrays converted to nested lists.
        """
        return {
            "plant_energy_layer": self.plant_energy_layer.tolist(),
            "signal_layers": self.signal_layers.tolist(),
            "toxin_layers": self.toxin_layers.tolist(),
            "flow_field": self.flow_field.tolist(),
            "wind_vector_x": self.wind_vector_x.tolist(),
            "wind_vector_y": self.wind_vector_y.tolist(),
        }

__init__(width: int = 40, height: int = 40, num_signals: int = 4, num_toxins: int = 4) -> None

Initialise grid layers and double-buffered storage.

Parameters:

Name Type Description Default
width int

Grid width in cells.

40
height int

Grid height in cells.

40
num_signals int

Number of airborne signal layers.

4
num_toxins int

Number of toxin layers.

4
Source code in src/phids/engine/core/biotope.py
def __init__(
    self,
    width: int = 40,
    height: int = 40,
    num_signals: int = 4,
    num_toxins: int = 4,
) -> None:
    """Initialise grid layers and double-buffered storage.

    Args:
        width: Grid width in cells.
        height: Grid height in cells.
        num_signals: Number of airborne signal layers.
        num_toxins: Number of toxin layers.
    """
    if not (1 <= width <= GRID_W_MAX):
        raise ValueError(f"width {width} out of range [1, {GRID_W_MAX}].")
    if not (1 <= height <= GRID_H_MAX):
        raise ValueError(f"height {height} out of range [1, {GRID_H_MAX}].")
    if not (1 <= num_signals <= MAX_SUBSTANCE_TYPES):
        raise ValueError(f"num_signals {num_signals} out of range [1, {MAX_SUBSTANCE_TYPES}].")
    if not (1 <= num_toxins <= MAX_SUBSTANCE_TYPES):
        raise ValueError(f"num_toxins {num_toxins} out of range [1, {MAX_SUBSTANCE_TYPES}].")

    self.width = width
    self.height = height
    self.num_signals = num_signals
    self.num_toxins = num_toxins

    shape: tuple[int, int] = (width, height)

    # ------------------------------------------------------------------
    # Plant energy layers (read/write buffers)
    # ------------------------------------------------------------------
    self.plant_energy_layer: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate
    self._plant_energy_layer_write: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

    # Global aggregate apparent nutrition factor
    self.apparent_nutrition_layer: npt.NDArray[np.float64] = np.ones(shape, dtype=np.float64)  # pragma: no mutate
    self._apparent_nutrition_layer_write: npt.NDArray[np.float64] = np.ones(
        shape, dtype=np.float64
    )  # pragma: no mutate

    # Per-species energy layers (Rule of 16 pre-allocation)
    self.plant_energy_by_species: npt.NDArray[np.float64] = np.zeros(
        (MAX_FLORA_SPECIES, width, height), dtype=np.float64
    )
    self._plant_energy_by_species_write: npt.NDArray[np.float64] = np.zeros_like(self.plant_energy_by_species)

    # ------------------------------------------------------------------
    # Wind layers (dynamic, updated via REST API)
    # ------------------------------------------------------------------
    self.wind_vector_x: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate
    self.wind_vector_y: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

    # ------------------------------------------------------------------
    # Signal layers  [num_signals, W, H] - read buffer
    # ------------------------------------------------------------------
    self.signal_layers: npt.NDArray[np.float64] = np.zeros(
        (num_signals, width, height), dtype=np.float64
    )  # pragma: no mutate
    # Write buffer for double-buffering
    self._signal_layers_write: npt.NDArray[np.float64] = np.zeros_like(self.signal_layers)

    # ------------------------------------------------------------------
    # Toxin layers  [num_toxins, W, H] (local plant-tissue fields)
    # ------------------------------------------------------------------
    self.toxin_layers: npt.NDArray[np.float64] = np.zeros(
        (num_toxins, width, height), dtype=np.float64
    )  # pragma: no mutate
    self._toxin_layers_write: npt.NDArray[np.float64] = np.zeros_like(self.toxin_layers)

    # ------------------------------------------------------------------
    # Flow-field gradient (scalar attraction field, WxH)
    # ------------------------------------------------------------------
    self.flow_field: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

    # Pre-allocated scratch buffers for flow field JIT calculations
    self._flow_field_base: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate
    self._flow_field_current: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate
    self._flow_field_nxt: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

    # Pre-allocated scratch buffer for diffusion JIT calculations
    self._advected_scratch: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)  # pragma: no mutate

clear_plant_energy(x: int, y: int, species_id: int) -> None

Clear a species-specific energy contribution in the write buffer.

Parameters:

Name Type Description Default
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required
species_id int

The integer index representing the specific phylogenetic species associated with this operation.

required
Source code in src/phids/engine/core/biotope.py
def clear_plant_energy(self, x: int, y: int, species_id: int) -> None:
    """Clear a species-specific energy contribution in the write buffer.

    Args:
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.
        species_id: The integer index representing the specific phylogenetic species associated with this operation.
    """
    self._plant_energy_by_species_write[species_id, x, y] = 0.0

diffuse_signals(signal_decay_factor: float = 0.85) -> None

Compute one diffusion tick for all signal layers.

This applies local semi-Lagrangian advection using per-cell wind vectors, followed by isotropic Gaussian diffusion and decay. The transport update respects heterogeneous wind fields across the grid and avoids global-mean wind averaging artefacts.

Parameters:

Name Type Description Default
signal_decay_factor float

Per-tick airborne signal retention (0.0-1.0). Defaults to the SIGNAL_DECAY_FACTOR module-level constant (0.85). Pass loop.config.signal_decay_factor to use the scenario-level value.

0.85
Source code in src/phids/engine/core/biotope.py
def diffuse_signals(self, signal_decay_factor: float = 0.85) -> None:
    """Compute one diffusion tick for all signal layers.

    This applies local semi-Lagrangian advection using per-cell wind vectors,
    followed by isotropic Gaussian diffusion and decay. The transport update
    respects heterogeneous wind fields across the grid and avoids global-mean
    wind averaging artefacts.

    Args:
        signal_decay_factor: Per-tick airborne signal retention (0.0-1.0).
            Defaults to the ``SIGNAL_DECAY_FACTOR`` module-level constant (0.85).
            Pass ``loop.config.signal_decay_factor`` to use the scenario-level value.
    """
    for s in range(self.num_signals):
        layer: npt.NDArray[np.float64] = self.signal_layers[s]
        if layer.max() < SIGNAL_EPSILON:
            self._signal_layers_write[s].fill(0.0)
            continue

        _numba_diffuse_signal_layer(  # type: ignore[type-var, call-arg]
            self.width,
            self.height,
            layer,
            self.wind_vector_x,
            self.wind_vector_y,
            signal_decay_factor,
            SIGNAL_EPSILON,
            DIFFUSION_KERNEL,
            self._signal_layers_write[s],
            self._advected_scratch,
        )

    # Swap buffers
    self.signal_layers, self._signal_layers_write = (
        self._signal_layers_write,
        self.signal_layers,
    )

rebuild_energy_layer() -> None

Recompute aggregate plant energy layer and swap buffers.

Aggregates per-species write buffers into the global write buffer, then swaps read/write buffers so that subsequent reads observe the newly-written values.

Source code in src/phids/engine/core/biotope.py
def rebuild_energy_layer(self) -> None:
    """Recompute aggregate plant energy layer and swap buffers.

    Aggregates per-species write buffers into the global write buffer,
    then swaps read/write buffers so that subsequent reads observe the
    newly-written values.
    """
    np.sum(self._plant_energy_by_species_write, axis=0, out=self._plant_energy_layer_write)
    self.plant_energy_by_species, self._plant_energy_by_species_write = (
        self._plant_energy_by_species_write,
        self.plant_energy_by_species,
    )
    self.plant_energy_layer, self._plant_energy_layer_write = (
        self._plant_energy_layer_write,
        self.plant_energy_layer,
    )
    self._plant_energy_by_species_write[:] = self.plant_energy_by_species
    self._plant_energy_layer_write[:] = self.plant_energy_layer

    self.apparent_nutrition_layer, self._apparent_nutrition_layer_write = (
        self._apparent_nutrition_layer_write,
        self.apparent_nutrition_layer,
    )
    self._apparent_nutrition_layer_write.fill(1.0)

set_apparent_nutrition(x: int, y: int, value: float) -> None

Set apparent nutrition factor in the write buffer.

Parameters:

Name Type Description Default
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required
value float

The apparent nutrition value to store.

required
Source code in src/phids/engine/core/biotope.py
def set_apparent_nutrition(self, x: int, y: int, value: float) -> None:
    """Set apparent nutrition factor in the write buffer.

    Args:
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.
        value: The apparent nutrition value to store.
    """
    self._apparent_nutrition_layer_write[x, y] = value

set_plant_energy(x: int, y: int, species_id: int, value: float) -> None

Set a species-specific energy contribution in the write buffer.

Parameters:

Name Type Description Default
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required
species_id int

The integer index representing the specific phylogenetic species associated with this operation.

required
value float

Energy contribution (clamped to >= 0).

required
Source code in src/phids/engine/core/biotope.py
def set_plant_energy(self, x: int, y: int, species_id: int, value: float) -> None:
    """Set a species-specific energy contribution in the write buffer.

    Args:
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.
        species_id: The integer index representing the specific phylogenetic species associated with this operation.
        value: Energy contribution (clamped to >= 0).
    """
    self._plant_energy_by_species_write[species_id, x, y] = max(0.0, value)

set_uniform_wind(vx: float, vy: float) -> None

Fill wind layers with a spatially uniform vector.

Parameters:

Name Type Description Default
vx float

X component of the wind.

required
vy float

Y component of the wind.

required
Source code in src/phids/engine/core/biotope.py
def set_uniform_wind(self, vx: float, vy: float) -> None:
    """Fill wind layers with a spatially uniform vector.

    Args:
        vx: X component of the wind.
        vy: Y component of the wind.
    """
    self.wind_vector_x[:] = vx
    self.wind_vector_y[:] = vy

to_dict() -> dict[str, object]

Returns a dict representation of the biotope state suitable for serialization.

This is used by the streaming interface to serialize the current state of the biotope to a dictionary.

Returns:

Type Description
dict[str, object]

Mapping containing numpy arrays converted to nested lists.

Source code in src/phids/engine/core/biotope.py
def to_dict(self) -> dict[str, object]:
    """Returns a dict representation of the biotope state suitable for serialization.

    This is used by the streaming interface to serialize the current state of the
    biotope to a dictionary.

    Returns:
        Mapping containing numpy arrays converted to nested lists.
    """
    return {
        "plant_energy_layer": self.plant_energy_layer.tolist(),
        "signal_layers": self.signal_layers.tolist(),
        "toxin_layers": self.toxin_layers.tolist(),
        "flow_field": self.flow_field.tolist(),
        "wind_vector_x": self.wind_vector_x.tolist(),
        "wind_vector_y": self.wind_vector_y.tolist(),
    }

update_wind_at(x: int, y: int, vx: float, vy: float) -> None

Update the wind vector at a single grid cell.

Parameters:

Name Type Description Default
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required
vx float

X component of the wind.

required
vy float

Y component of the wind.

required
Source code in src/phids/engine/core/biotope.py
def update_wind_at(self, x: int, y: int, vx: float, vy: float) -> None:
    """Update the wind vector at a single grid cell.

    Args:
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.
        vx: X component of the wind.
        vy: Y component of the wind.
    """
    self.wind_vector_x[x, y] = vx
    self.wind_vector_y[x, y] = vy

phids.engine.core.ecs

Entity-Component-System (ECS) registry with O(1) spatial hash support for deterministic ecosystem simulation.

components and pre-allocated buffers (Rule of 16). The spatial hash is central to the simulation's ability to model emergent ecological phenomena with deterministic reproducibility and scientific rigour.

ECSWorld

Central ECS registry managing entities, components and spatial hash.

The world provides helpers for entity lifecycle, component indexing and a spatial hash grid for efficient cell membership queries.

Source code in src/phids/engine/core/ecs.py
class ECSWorld:
    """Central ECS registry managing entities, components and spatial hash.

    The world provides helpers for entity lifecycle, component indexing and
    a spatial hash grid for efficient cell membership queries.
    """

    def __init__(self) -> None:
        """Initialise the ECS world and its internal indices.

        Attributes initialized:
            _next_id: Next entity id to allocate.
            _entities: Mapping of entity id to Entity.
            _component_index: Index mapping component types to entity id sets.
            _spatial_hash: Grid cell roster mapping (x, y) to entity id sets.
            _entity_positions: Reverse index mapping entity ids to their current cell.
        """
        self._next_id: int = 0
        self._entities: dict[int, Entity] = {}
        # component_type -> set of entity ids
        self._component_index: dict[type[object], set[int]] = defaultdict(set)
        # (x, y) -> set of entity ids (Spatial Hash / Grid Cell Roster)
        self._spatial_hash: dict[tuple[int, int], set[int]] = defaultdict(set)
        # entity_id -> (x, y) reverse lookup for O(1) spatial cleanup on move/destroy
        self._entity_positions: dict[int, tuple[int, int]] = {}
        # Caching layer for query optimization
        self._structural_version: int = 0
        self._query_cache: dict[tuple[type[object], ...], tuple[int, list[Entity]]] = {}

    # ------------------------------------------------------------------
    # Entity lifecycle
    # ------------------------------------------------------------------

    def create_entity(self) -> Entity:
        """Allocate and register a new entity.

        Returns:
            Entity: Newly created entity object.

        """
        eid = self._next_id
        self._next_id += 1
        entity = Entity(entity_id=eid)
        self._entities[eid] = entity
        self._structural_version += 1
        return entity

    def destroy_entity(self, entity_id: int) -> None:
        """Remove an entity and clean up index and spatial hash references.

        Args:
            entity_id: Identifier of the entity to destroy.

        """
        entity = self._entities.pop(entity_id, None)
        if entity is None:
            return
        self._structural_version += 1
        # Clean component index
        for ctype in list(entity._components.keys()):
            self._component_index[ctype].discard(entity_id)
        # O(1) spatial cleanup using the reverse position index.
        position = self._entity_positions.pop(entity_id, None)
        if position is not None:
            self._remove_from_cell(entity_id, position)

    def has_entity(self, entity_id: int) -> bool:
        """Return True if the entity exists.

        Args:
            entity_id: The unique integer identifier of the target entity within the ECS world registry.

        Returns:
            bool: True if present.

        """
        return entity_id in self._entities

    def get_entity(self, entity_id: int) -> Entity:
        """Return the entity instance for the given id.

        Args:
            entity_id: The unique integer identifier of the target entity within the ECS world registry.

        Returns:
            Entity: Matching entity.

        """
        return self._entities[entity_id]

    # ------------------------------------------------------------------
    # Component helpers
    # ------------------------------------------------------------------

    def add_component(self, entity_id: int, component: object) -> None:
        """Attach a component to an entity and update the component index.

        Args:
            entity_id: The unique integer identifier of the target entity within the ECS world registry.
            component: Component instance to attach.

        """
        entity = self._entities[entity_id]
        entity.add_component(component)
        self._component_index[type(component)].add(entity_id)
        self._structural_version += 1

    def remove_component(self, entity_id: int, component_type: type[object]) -> None:
        """Detach a component of the specified type from an entity.

        Args:
            entity_id: The unique integer identifier of the target entity within the ECS world registry.
            component_type: Component class/type to remove.

        """
        entity = self._entities[entity_id]
        entity.remove_component(component_type)
        self._component_index[component_type].discard(entity_id)
        self._structural_version += 1

    def query(self, *component_types: type[object]) -> list[Entity]:
        """Return a list of all entities that possess all listed component types.

        Args:
            *component_types: Component classes/types to require.

        Returns:
            list[Entity]: Materialized list of entities matching the component set.

        """
        if not component_types:
            return list(self._entities.values())

        entities = self._entities

        cache_key = component_types
        cached = self._query_cache.get(cache_key)
        if cached is not None and cached[0] == self._structural_version:
            return cached[1]

        # Fast path for single component query (highly common in hot loop)
        if len(component_types) == 1:
            ct = component_types[0]
            # âš¡ Bolt Optimization:
            # We assume strict synchronization between `_entities`, `_component_index`,
            # and `_components` via the ECS lifecycle methods. Thus, we can safely
            # skip redundant dictionary lookups (`eid in entities` and `ct in entities[eid]._components`)
            # for a measurable O(N) reduction in lookup overhead during hot-path iterations.
            result = [entities[eid] for eid in self._component_index.get(ct, set())]
            self._query_cache[cache_key] = (self._structural_version, result)
            return result

        # Fast path C-level set intersection for multi-component queries
        sets: list[set[int]] = []
        for component_type in component_types:
            indexed_ids = self._component_index.get(component_type)
            if not indexed_ids:
                return []
            sets.append(indexed_ids)

        sets.sort(key=len)

        # We must copy the smallest set so we don't mutate the component index!
        intersection = set(sets[0])
        for s in sets[1:]:
            intersection.intersection_update(s)

        result = [entities[eid] for eid in intersection]
        self._query_cache[cache_key] = (self._structural_version, result)
        return result

    # ------------------------------------------------------------------
    # Spatial Hash
    # ------------------------------------------------------------------

    def register_position(self, entity_id: int, x: int, y: int) -> None:
        """Register an entity at grid cell (x, y).

        Args:
            entity_id: The unique integer identifier of the target entity within the ECS world registry.
            x: X coordinate of the cell.
            y: Y coordinate of the cell.

        """
        new_position = (x, y)
        old_position = self._entity_positions.get(entity_id)
        if old_position == new_position:
            return
        if old_position is not None:
            self._remove_from_cell(entity_id, old_position)
        self._spatial_hash[new_position].add(entity_id)
        self._entity_positions[entity_id] = new_position

    def unregister_position(self, entity_id: int, x: int, y: int) -> None:
        """Remove an entity from a grid cell.

        Args:
            entity_id: The unique integer identifier of the target entity within the ECS world registry.
            x: X coordinate of the cell.
            y: Y coordinate of the cell.

        """
        position = (x, y)
        self._remove_from_cell(entity_id, position)
        if self._entity_positions.get(entity_id) == position:
            self._entity_positions.pop(entity_id, None)

    def move_entity(self, entity_id: int, old_x: int, old_y: int, new_x: int, new_y: int) -> None:
        """Atomically update spatial hash when an entity moves.

        Args:
            entity_id: The unique integer identifier of the target entity within the ECS world registry.
            old_x: Previous X coordinate.
            old_y: Previous Y coordinate.
            new_x: The updated X-axis grid coordinate for the entity.
            new_y: The updated Y-axis grid coordinate for the entity.

        """
        self.unregister_position(entity_id, old_x, old_y)
        self.register_position(entity_id, new_x, new_y)

    def entities_at(self, x: int, y: int) -> set[int]:
        """Return the set of entity ids occupying a cell.

        Args:
            x: The X-axis spatial grid coordinate.
            y: The Y-axis spatial grid coordinate.

        Returns:
            set[int]: Entity ids occupying the cell.

        """
        return self._spatial_hash.get((x, y), set())

    def _remove_from_cell(self, entity_id: int, cell: tuple[int, int]) -> None:
        """Detach an entity from a cell and prune empty cell buckets."""
        roster = self._spatial_hash.get(cell)
        if roster is None:
            return
        roster.discard(entity_id)
        if not roster:
            self._spatial_hash.pop(cell, None)

    # ------------------------------------------------------------------
    # Garbage collection
    # ------------------------------------------------------------------

    def collect_garbage(self, dead_entity_ids: list[int]) -> None:
        """Bulk destroy a list of dead entities.

        Args:
            dead_entity_ids: List of entity ids to remove.

        """
        for eid in dead_entity_ids:
            self.destroy_entity(eid)

__init__() -> None

Initialise the ECS world and its internal indices.

Attributes initialized

_next_id: Next entity id to allocate. _entities: Mapping of entity id to Entity. _component_index: Index mapping component types to entity id sets. _spatial_hash: Grid cell roster mapping (x, y) to entity id sets. _entity_positions: Reverse index mapping entity ids to their current cell.

Source code in src/phids/engine/core/ecs.py
def __init__(self) -> None:
    """Initialise the ECS world and its internal indices.

    Attributes initialized:
        _next_id: Next entity id to allocate.
        _entities: Mapping of entity id to Entity.
        _component_index: Index mapping component types to entity id sets.
        _spatial_hash: Grid cell roster mapping (x, y) to entity id sets.
        _entity_positions: Reverse index mapping entity ids to their current cell.
    """
    self._next_id: int = 0
    self._entities: dict[int, Entity] = {}
    # component_type -> set of entity ids
    self._component_index: dict[type[object], set[int]] = defaultdict(set)
    # (x, y) -> set of entity ids (Spatial Hash / Grid Cell Roster)
    self._spatial_hash: dict[tuple[int, int], set[int]] = defaultdict(set)
    # entity_id -> (x, y) reverse lookup for O(1) spatial cleanup on move/destroy
    self._entity_positions: dict[int, tuple[int, int]] = {}
    # Caching layer for query optimization
    self._structural_version: int = 0
    self._query_cache: dict[tuple[type[object], ...], tuple[int, list[Entity]]] = {}

add_component(entity_id: int, component: object) -> None

Attach a component to an entity and update the component index.

Parameters:

Name Type Description Default
entity_id int

The unique integer identifier of the target entity within the ECS world registry.

required
component object

Component instance to attach.

required
Source code in src/phids/engine/core/ecs.py
def add_component(self, entity_id: int, component: object) -> None:
    """Attach a component to an entity and update the component index.

    Args:
        entity_id: The unique integer identifier of the target entity within the ECS world registry.
        component: Component instance to attach.

    """
    entity = self._entities[entity_id]
    entity.add_component(component)
    self._component_index[type(component)].add(entity_id)
    self._structural_version += 1

collect_garbage(dead_entity_ids: list[int]) -> None

Bulk destroy a list of dead entities.

Parameters:

Name Type Description Default
dead_entity_ids list[int]

List of entity ids to remove.

required
Source code in src/phids/engine/core/ecs.py
def collect_garbage(self, dead_entity_ids: list[int]) -> None:
    """Bulk destroy a list of dead entities.

    Args:
        dead_entity_ids: List of entity ids to remove.

    """
    for eid in dead_entity_ids:
        self.destroy_entity(eid)

create_entity() -> Entity

Allocate and register a new entity.

Returns:

Name Type Description
Entity Entity

Newly created entity object.

Source code in src/phids/engine/core/ecs.py
def create_entity(self) -> Entity:
    """Allocate and register a new entity.

    Returns:
        Entity: Newly created entity object.

    """
    eid = self._next_id
    self._next_id += 1
    entity = Entity(entity_id=eid)
    self._entities[eid] = entity
    self._structural_version += 1
    return entity

destroy_entity(entity_id: int) -> None

Remove an entity and clean up index and spatial hash references.

Parameters:

Name Type Description Default
entity_id int

Identifier of the entity to destroy.

required
Source code in src/phids/engine/core/ecs.py
def destroy_entity(self, entity_id: int) -> None:
    """Remove an entity and clean up index and spatial hash references.

    Args:
        entity_id: Identifier of the entity to destroy.

    """
    entity = self._entities.pop(entity_id, None)
    if entity is None:
        return
    self._structural_version += 1
    # Clean component index
    for ctype in list(entity._components.keys()):
        self._component_index[ctype].discard(entity_id)
    # O(1) spatial cleanup using the reverse position index.
    position = self._entity_positions.pop(entity_id, None)
    if position is not None:
        self._remove_from_cell(entity_id, position)

entities_at(x: int, y: int) -> set[int]

Return the set of entity ids occupying a cell.

Parameters:

Name Type Description Default
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required

Returns:

Type Description
set[int]

set[int]: Entity ids occupying the cell.

Source code in src/phids/engine/core/ecs.py
def entities_at(self, x: int, y: int) -> set[int]:
    """Return the set of entity ids occupying a cell.

    Args:
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.

    Returns:
        set[int]: Entity ids occupying the cell.

    """
    return self._spatial_hash.get((x, y), set())

get_entity(entity_id: int) -> Entity

Return the entity instance for the given id.

Parameters:

Name Type Description Default
entity_id int

The unique integer identifier of the target entity within the ECS world registry.

required

Returns:

Name Type Description
Entity Entity

Matching entity.

Source code in src/phids/engine/core/ecs.py
def get_entity(self, entity_id: int) -> Entity:
    """Return the entity instance for the given id.

    Args:
        entity_id: The unique integer identifier of the target entity within the ECS world registry.

    Returns:
        Entity: Matching entity.

    """
    return self._entities[entity_id]

has_entity(entity_id: int) -> bool

Return True if the entity exists.

Parameters:

Name Type Description Default
entity_id int

The unique integer identifier of the target entity within the ECS world registry.

required

Returns:

Name Type Description
bool bool

True if present.

Source code in src/phids/engine/core/ecs.py
def has_entity(self, entity_id: int) -> bool:
    """Return True if the entity exists.

    Args:
        entity_id: The unique integer identifier of the target entity within the ECS world registry.

    Returns:
        bool: True if present.

    """
    return entity_id in self._entities

move_entity(entity_id: int, old_x: int, old_y: int, new_x: int, new_y: int) -> None

Atomically update spatial hash when an entity moves.

Parameters:

Name Type Description Default
entity_id int

The unique integer identifier of the target entity within the ECS world registry.

required
old_x int

Previous X coordinate.

required
old_y int

Previous Y coordinate.

required
new_x int

The updated X-axis grid coordinate for the entity.

required
new_y int

The updated Y-axis grid coordinate for the entity.

required
Source code in src/phids/engine/core/ecs.py
def move_entity(self, entity_id: int, old_x: int, old_y: int, new_x: int, new_y: int) -> None:
    """Atomically update spatial hash when an entity moves.

    Args:
        entity_id: The unique integer identifier of the target entity within the ECS world registry.
        old_x: Previous X coordinate.
        old_y: Previous Y coordinate.
        new_x: The updated X-axis grid coordinate for the entity.
        new_y: The updated Y-axis grid coordinate for the entity.

    """
    self.unregister_position(entity_id, old_x, old_y)
    self.register_position(entity_id, new_x, new_y)

query(*component_types: type[object]) -> list[Entity]

Return a list of all entities that possess all listed component types.

Parameters:

Name Type Description Default
*component_types type[object]

Component classes/types to require.

()

Returns:

Type Description
list[Entity]

list[Entity]: Materialized list of entities matching the component set.

Source code in src/phids/engine/core/ecs.py
def query(self, *component_types: type[object]) -> list[Entity]:
    """Return a list of all entities that possess all listed component types.

    Args:
        *component_types: Component classes/types to require.

    Returns:
        list[Entity]: Materialized list of entities matching the component set.

    """
    if not component_types:
        return list(self._entities.values())

    entities = self._entities

    cache_key = component_types
    cached = self._query_cache.get(cache_key)
    if cached is not None and cached[0] == self._structural_version:
        return cached[1]

    # Fast path for single component query (highly common in hot loop)
    if len(component_types) == 1:
        ct = component_types[0]
        # âš¡ Bolt Optimization:
        # We assume strict synchronization between `_entities`, `_component_index`,
        # and `_components` via the ECS lifecycle methods. Thus, we can safely
        # skip redundant dictionary lookups (`eid in entities` and `ct in entities[eid]._components`)
        # for a measurable O(N) reduction in lookup overhead during hot-path iterations.
        result = [entities[eid] for eid in self._component_index.get(ct, set())]
        self._query_cache[cache_key] = (self._structural_version, result)
        return result

    # Fast path C-level set intersection for multi-component queries
    sets: list[set[int]] = []
    for component_type in component_types:
        indexed_ids = self._component_index.get(component_type)
        if not indexed_ids:
            return []
        sets.append(indexed_ids)

    sets.sort(key=len)

    # We must copy the smallest set so we don't mutate the component index!
    intersection = set(sets[0])
    for s in sets[1:]:
        intersection.intersection_update(s)

    result = [entities[eid] for eid in intersection]
    self._query_cache[cache_key] = (self._structural_version, result)
    return result

register_position(entity_id: int, x: int, y: int) -> None

Register an entity at grid cell (x, y).

Parameters:

Name Type Description Default
entity_id int

The unique integer identifier of the target entity within the ECS world registry.

required
x int

X coordinate of the cell.

required
y int

Y coordinate of the cell.

required
Source code in src/phids/engine/core/ecs.py
def register_position(self, entity_id: int, x: int, y: int) -> None:
    """Register an entity at grid cell (x, y).

    Args:
        entity_id: The unique integer identifier of the target entity within the ECS world registry.
        x: X coordinate of the cell.
        y: Y coordinate of the cell.

    """
    new_position = (x, y)
    old_position = self._entity_positions.get(entity_id)
    if old_position == new_position:
        return
    if old_position is not None:
        self._remove_from_cell(entity_id, old_position)
    self._spatial_hash[new_position].add(entity_id)
    self._entity_positions[entity_id] = new_position

remove_component(entity_id: int, component_type: type[object]) -> None

Detach a component of the specified type from an entity.

Parameters:

Name Type Description Default
entity_id int

The unique integer identifier of the target entity within the ECS world registry.

required
component_type type[object]

Component class/type to remove.

required
Source code in src/phids/engine/core/ecs.py
def remove_component(self, entity_id: int, component_type: type[object]) -> None:
    """Detach a component of the specified type from an entity.

    Args:
        entity_id: The unique integer identifier of the target entity within the ECS world registry.
        component_type: Component class/type to remove.

    """
    entity = self._entities[entity_id]
    entity.remove_component(component_type)
    self._component_index[component_type].discard(entity_id)
    self._structural_version += 1

unregister_position(entity_id: int, x: int, y: int) -> None

Remove an entity from a grid cell.

Parameters:

Name Type Description Default
entity_id int

The unique integer identifier of the target entity within the ECS world registry.

required
x int

X coordinate of the cell.

required
y int

Y coordinate of the cell.

required
Source code in src/phids/engine/core/ecs.py
def unregister_position(self, entity_id: int, x: int, y: int) -> None:
    """Remove an entity from a grid cell.

    Args:
        entity_id: The unique integer identifier of the target entity within the ECS world registry.
        x: X coordinate of the cell.
        y: Y coordinate of the cell.

    """
    position = (x, y)
    self._remove_from_cell(entity_id, position)
    if self._entity_positions.get(entity_id) == position:
        self._entity_positions.pop(entity_id, None)

Entity dataclass

Lightweight wrapper holding an entity id and attached components.

Source code in src/phids/engine/core/ecs.py
@dataclass(slots=True)
class Entity:
    """Lightweight wrapper holding an entity id and attached components."""

    entity_id: int
    _components: dict[type[object], object] = field(default_factory=dict, repr=False)

    def add_component(self, component: object) -> None:
        """Attach a component instance keyed by its type.

        Args:
            component: Component instance to attach.

        """
        self._components[type(component)] = component

    def get_component(self, component_type: type[C]) -> C:
        """Return attached component of the given type.

        Args:
            component_type: The component class/type to retrieve.

        Returns:
            The component instance for the entity.

        """
        return cast("C", self._components[component_type])

    def has_component(self, component_type: type[object]) -> bool:
        """Return True if the entity has a component of the given type.

        Args:
            component_type: Component class/type to check for.

        Returns:
            bool: True if present, False otherwise.

        """
        return component_type in self._components

    def remove_component(self, component_type: type[object]) -> None:
        """Detach a component of the given type (no-op if absent).

        Args:
            component_type: Component class/type to remove.

        """
        self._components.pop(component_type, None)

add_component(component: object) -> None

Attach a component instance keyed by its type.

Parameters:

Name Type Description Default
component object

Component instance to attach.

required
Source code in src/phids/engine/core/ecs.py
def add_component(self, component: object) -> None:
    """Attach a component instance keyed by its type.

    Args:
        component: Component instance to attach.

    """
    self._components[type(component)] = component

get_component(component_type: type[C]) -> C

Return attached component of the given type.

Parameters:

Name Type Description Default
component_type type[C]

The component class/type to retrieve.

required

Returns:

Type Description
C

The component instance for the entity.

Source code in src/phids/engine/core/ecs.py
def get_component(self, component_type: type[C]) -> C:
    """Return attached component of the given type.

    Args:
        component_type: The component class/type to retrieve.

    Returns:
        The component instance for the entity.

    """
    return cast("C", self._components[component_type])

has_component(component_type: type[object]) -> bool

Return True if the entity has a component of the given type.

Parameters:

Name Type Description Default
component_type type[object]

Component class/type to check for.

required

Returns:

Name Type Description
bool bool

True if present, False otherwise.

Source code in src/phids/engine/core/ecs.py
def has_component(self, component_type: type[object]) -> bool:
    """Return True if the entity has a component of the given type.

    Args:
        component_type: Component class/type to check for.

    Returns:
        bool: True if present, False otherwise.

    """
    return component_type in self._components

remove_component(component_type: type[object]) -> None

Detach a component of the given type (no-op if absent).

Parameters:

Name Type Description Default
component_type type[object]

Component class/type to remove.

required
Source code in src/phids/engine/core/ecs.py
def remove_component(self, component_type: type[object]) -> None:
    """Detach a component of the given type (no-op if absent).

    Args:
        component_type: Component class/type to remove.

    """
    self._components.pop(component_type, None)

phids.engine.core.flow_field

Flow-field gradient generation accelerated with Numba @njit for deterministic ecological simulation.

This module provides the Jacobi iteration solver for pathfinding. It strictly adheres to Numba compilation constraints: no Python dictionaries, lists, or custom classes are used inside @njit kernels. All array operations rely on pre-allocated buffers and contiguous layouts to prevent memory allocation latency during the hot-path evaluation phase. The global attraction gradient is computed by combining plant attraction and toxin repulsion base values, then propagating them across the grid via a multi-iteration neighbourhood averaging pass with configurable decay. The resulting scalar field is intended to populate GridEnvironment.flow_field, supporting O(1) spatial hash-mediated swarm navigation and deterministic simulation of emergent plant-herbivore dynamics. The design strictly adheres to data-oriented principles, using pre-allocated NumPy arrays and truncating subnormal floats (values with absolute magnitude below 1e-4) to zero after propagation to maintain computational efficiency. Camouflage is applied post-computation via apply_camouflage, which attenuates the gradient at specific plant-occupied cells to model constitutive gradient masking.

apply_camouflage(flow_field: npt.NDArray[np.float64], x: int, y: int, factor: float) -> None

Attenuate the flow-field gradient at cell (x, y) in-place.

Parameters:

Name Type Description Default
flow_field NDArray[float64]

Mutable gradient array (W, H).

required
x int

The X-axis spatial grid coordinate.

required
y int

The Y-axis spatial grid coordinate.

required
factor float

Multiplier in [0, 1]; 0 = invisible, 1 = no attenuation.

required
Source code in src/phids/engine/core/flow_field.py
def apply_camouflage(
    flow_field: npt.NDArray[np.float64],
    x: int,
    y: int,
    factor: float,
) -> None:
    """Attenuate the flow-field gradient at cell (x, y) in-place.

    Args:
        flow_field: Mutable gradient array ``(W, H)``.
        x: The X-axis spatial grid coordinate.
        y: The Y-axis spatial grid coordinate.
        factor: Multiplier in [0, 1]; 0 = invisible, 1 = no attenuation.
    """
    flow_field[x, y] *= factor

compute_flow_field(plant_energy: npt.NDArray[np.float64], apparent_nutrition_layer: npt.NDArray[np.float64], toxin_layers: npt.NDArray[np.float64], width: int, height: int, base: npt.NDArray[np.float64] | None = None, current: npt.NDArray[np.float64] | None = None, nxt: npt.NDArray[np.float64] | None = None, alpha: float = 1.0, beta: float = 1.0, decay: float = 0.6, truncate_threshold: float = 0.0001) -> npt.NDArray[np.float64]

Public wrapper: sum toxin layers and delegate to the Numba kernel.

Parameters:

Name Type Description Default
plant_energy NDArray[float64]

Shape (W, H) aggregate plant energy.

required
apparent_nutrition_layer NDArray[float64]

Shape (W, H) apparent nutrition modifiers.

required
toxin_layers NDArray[float64]

Shape (num_toxins, W, H) toxin concentration layers.

required
width int

The horizontal bounds of the simulation grid environment.

required
height int

The vertical bounds of the simulation grid environment.

required
base NDArray[float64] | None

Pre-allocated 2-D scratch array.

None
current NDArray[float64] | None

Pre-allocated 2-D scratch array.

None
nxt NDArray[float64] | None

Pre-allocated 2-D scratch array.

None
alpha float

Attractant weight.

1.0
beta float

Repellent weight.

1.0
decay float

Decay factor.

0.6
truncate_threshold float

Truncation threshold.

0.0001

Returns:

Type Description
NDArray[float64]

npt.NDArray[np.float64]: Flow-field gradient of shape (W, H).

Source code in src/phids/engine/core/flow_field.py
def compute_flow_field(
    plant_energy: npt.NDArray[np.float64],
    apparent_nutrition_layer: npt.NDArray[np.float64],
    toxin_layers: npt.NDArray[np.float64],
    width: int,
    height: int,
    base: npt.NDArray[np.float64] | None = None,
    current: npt.NDArray[np.float64] | None = None,
    nxt: npt.NDArray[np.float64] | None = None,
    alpha: float = 1.0,
    beta: float = 1.0,
    decay: float = 0.6,
    truncate_threshold: float = 1e-4,
) -> npt.NDArray[np.float64]:
    """Public wrapper: sum toxin layers and delegate to the Numba kernel.

    Args:
        plant_energy: Shape ``(W, H)`` aggregate plant energy.
        apparent_nutrition_layer: Shape ``(W, H)`` apparent nutrition modifiers.
        toxin_layers: Shape ``(num_toxins, W, H)`` toxin concentration layers.
        width: The horizontal bounds of the simulation grid environment.
        height: The vertical bounds of the simulation grid environment.
        base: Pre-allocated 2-D scratch array.
        current: Pre-allocated 2-D scratch array.
        nxt: Pre-allocated 2-D scratch array.
        alpha: Attractant weight.
        beta: Repellent weight.
        decay: Decay factor.
        truncate_threshold: Truncation threshold.

    Returns:
        npt.NDArray[np.float64]: Flow-field gradient of shape ``(W, H)``.
    """
    if base is None:
        base = np.zeros((width, height), dtype=np.float64)  # pragma: no mutate
    if current is None:
        current = np.zeros((width, height), dtype=np.float64)  # pragma: no mutate
    if nxt is None:
        nxt = np.zeros((width, height), dtype=np.float64)  # pragma: no mutate

    result = np.asarray(
        _compute_flow_field(
            plant_energy,
            apparent_nutrition_layer,
            toxin_layers,
            width,
            height,
            base,
            current,
            nxt,
            alpha,
            beta,
            decay,
            truncate_threshold,
        ),
        dtype=np.float64,
    )
    return result

phids.engine.core.placement

Spatial placement generation helpers.

Provides functions to compute coordinate vectors for uniform, clustered, and banded initial entity distributions on the biotope grid.

generate_banded(width: int, height: int, band_count: int, orientation: str) -> list[tuple[int, int]]

Place entities in dense lines/stripes across the grid.

Parameters:

Name Type Description Default
width int

The horizontal bounds of the simulation grid environment.

required
height int

The vertical bounds of the simulation grid environment.

required
band_count int

Number of bands to split the grid into.

required
orientation str

The orientation direction ('horizontal' or 'vertical').

required

Returns:

Type Description
list[tuple[int, int]]

A list of generated (x, y) coordinates.

Source code in src/phids/engine/core/placement.py
def generate_banded(width: int, height: int, band_count: int, orientation: str) -> list[tuple[int, int]]:
    """Place entities in dense lines/stripes across the grid.

    Args:
        width: The horizontal bounds of the simulation grid environment.
        height: The vertical bounds of the simulation grid environment.
        band_count: Number of bands to split the grid into.
        orientation: The orientation direction ('horizontal' or 'vertical').

    Returns:
        A list of generated (x, y) coordinates.

    """
    if orientation == "horizontal":
        return _generate_horizontal_band(width, height, band_count)
    return _generate_vertical_band(width, height, band_count)

generate_clustered(width: int, height: int, cluster_count: int, variance: float) -> list[tuple[int, int]]

Create clusters of entities using a simple Gaussian spread.

Parameters:

Name Type Description Default
width int

The horizontal bounds of the simulation grid environment.

required
height int

The vertical bounds of the simulation grid environment.

required
cluster_count int

Number of clusters to generate.

required
variance float

The spread variance scale around each cluster center.

required

Returns:

Type Description
list[tuple[int, int]]

A list of unique generated (x, y) coordinates.

Source code in src/phids/engine/core/placement.py
def generate_clustered(width: int, height: int, cluster_count: int, variance: float) -> list[tuple[int, int]]:
    """Create clusters of entities using a simple Gaussian spread.

    Args:
        width: The horizontal bounds of the simulation grid environment.
        height: The vertical bounds of the simulation grid environment.
        cluster_count: Number of clusters to generate.
        variance: The spread variance scale around each cluster center.

    Returns:
        A list of unique generated (x, y) coordinates.

    """
    coords = set()
    for _ in range(cluster_count):
        cx = random.randint(0, width - 1)
        cy = random.randint(0, height - 1)
        # Generate roughly 10-50 entities per cluster based on variance scale
        points_in_cluster = int(max(10, variance * 10))
        for _ in range(points_in_cluster):
            # Simple Gaussian spread around centroid
            px = int(random.gauss(cx, variance))
            py = int(random.gauss(cy, variance))
            if 0 <= px < width and 0 <= py < height:
                coords.add((px, py))
    return list(coords)

generate_uniform(width: int, height: int, density: float) -> list[tuple[int, int]]

Randomly scatter entities across the grid based on density.

Parameters:

Name Type Description Default
width int

The horizontal bounds of the simulation grid environment.

required
height int

The vertical bounds of the simulation grid environment.

required
density float

Target density ratio.

required

Returns:

Type Description
list[tuple[int, int]]

A list of generated (x, y) coordinates.

Source code in src/phids/engine/core/placement.py
def generate_uniform(width: int, height: int, density: float) -> list[tuple[int, int]]:
    """Randomly scatter entities across the grid based on density.

    Args:
        width: The horizontal bounds of the simulation grid environment.
        height: The vertical bounds of the simulation grid environment.
        density: Target density ratio.

    Returns:
        A list of generated (x, y) coordinates.

    """
    coords = []
    for x in range(width):
        for y in range(height):
            if random.random() < density:
                coords.append((x, y))
    return coords

Engine systems

phids.engine.systems.lifecycle

Lifecycle system: plant growth, mycorrhizal network formation, reproduction, and death.

This module implements the first of three ordered per-tick simulation phases executed by the PHIDS SimulationLoop. The lifecycle phase applies deterministic physiological dynamics to all registered plant entities before any herbivore interactions are resolved, ensuring that the energy state observed by the interaction and signaling phases reflects the current-tick growth outcome.

Per-tick growth increments the energy reserve of each plant by base_energy * (growth_rate / 100), clamped to max_energy. Reproduction is attempted on each tick that satisfies the reproduction_interval constraint and leaves sufficient energy surplus above seed_energy_cost; the seed is dispersed to a randomly sampled polar coordinate within [seed_min_dist, seed_max_dist] from the parent, and germination is rejected if the target cell is already occupied by any plant entity registered in the spatial hash, preventing overcrowding without requiring dense distance scans. Mycorrhizal root-network formation occurs at configurable intervals (mycorrhizal_growth_interval_ticks), pairing adjacent plants (Manhattan distance 1) that share sufficient energy surplus above their respective survival thresholds; each connection costs both participants connection_cost energy units and is bidirectionally recorded in their PlantComponent.mycorrhizal_connections sets. Plants whose energy falls below survival_threshold are unregistered from the spatial hash, removed from the energy layer write buffer, and queued for bulk entity destruction via ECSWorld.collect_garbage. Per-cause death counts are accumulated in the plant_death_causes dict for telemetry attribution.

run_lifecycle(world: ECSWorld, env: GridEnvironment, tick: int, flora_species_params: dict[int, object], mycorrhizal_connection_cost: float = 1.0, mycorrhizal_growth_interval_ticks: int = 8, mycorrhizal_inter_species: bool = False, plant_death_causes: dict[str, int] | None = None) -> None

Execute one lifecycle tick: grow, connect, reproduce, and cull.

Parameters:

Name Type Description Default
world ECSWorld

The ECS world registry.

required
env GridEnvironment

The GridEnvironment instance.

required
tick int

Current simulation tick index.

required
flora_species_params dict[int, object]

Mapping of species_id to species parameters.

required
mycorrhizal_connection_cost float

Energy cost per new root connection.

1.0
mycorrhizal_growth_interval_ticks int

Ticks between new root-growth attempts. At most one new link is created per attempt.

8
mycorrhizal_inter_species bool

Allow inter-species root connections.

False
plant_death_causes dict[str, int] | None

Mapping of death causes to their respective counts.

None
Source code in src/phids/engine/systems/lifecycle.py
def run_lifecycle(
    world: ECSWorld,
    env: GridEnvironment,
    tick: int,
    flora_species_params: dict[int, object],
    mycorrhizal_connection_cost: float = 1.0,
    mycorrhizal_growth_interval_ticks: int = 8,
    mycorrhizal_inter_species: bool = False,
    plant_death_causes: dict[str, int] | None = None,
) -> None:
    """Execute one lifecycle tick: grow, connect, reproduce, and cull.

    Args:
        world: The ECS world registry.
        env: The GridEnvironment instance.
        tick: Current simulation tick index.
        flora_species_params: Mapping of species_id to species parameters.
        mycorrhizal_connection_cost: Energy cost per new root connection.
        mycorrhizal_growth_interval_ticks: Ticks between new root-growth
            attempts. At most one new link is created per attempt.
        mycorrhizal_inter_species: Allow inter-species root connections.
        plant_death_causes: Mapping of death causes to their respective counts.

    """
    dead: list[int] = []

    for entity in world.query(PlantComponent):
        plant: PlantComponent = entity.get_component(PlantComponent)
        plant.last_energy_loss_cause = None

        # Growth
        _grow(plant, tick)

        # Apply continuous mycorrhizal carbon tax
        if plant.mycorrhizal_tax_per_link > 0.0 and plant.mycorrhizal_connections:
            plant.energy -= plant.mycorrhizal_tax_per_link * len(plant.mycorrhizal_connections)

        # Reproduction
        _attempt_reproduction(plant, tick, world, env, flora_species_params)

        # Update biotope energy
        env.set_plant_energy(plant.x, plant.y, plant.species_id, plant.energy)
        env.set_apparent_nutrition(plant.x, plant.y, plant.apparent_nutrition_factor)

        # Prune dead mycorrhizal links
        plant.mycorrhizal_connections = {eid for eid in plant.mycorrhizal_connections if world.has_entity(eid)}

        # Survival check
        if plant.energy < plant.survival_threshold:
            cause_key = plant.last_energy_loss_cause or "death_background_deficit"
            if plant_death_causes is not None:
                plant_death_causes[cause_key] = plant_death_causes.get(cause_key, 0) + 1
            env.clear_plant_energy(plant.x, plant.y, plant.species_id)
            world.unregister_position(entity.entity_id, plant.x, plant.y)
            dead.append(entity.entity_id)

    # Establish new mycorrhizal root connections between adjacent plants
    if _should_attempt_mycorrhizal_growth(tick, mycorrhizal_growth_interval_ticks):
        _, mycorrhiza_dead = _establish_mycorrhizal_connections(
            world,
            env,
            mycorrhizal_connection_cost,
            mycorrhizal_inter_species,
            excluded_entity_ids=set(dead),
            plant_death_causes=plant_death_causes,
        )
        dead.extend(mycorrhiza_dead)

    world.collect_garbage(dead)

phids.engine.systems.interaction.feeding

Herbivory logic for swarms feeding on flora in the interaction system.

phids.engine.systems.interaction.metabolism

Metabolism, reproduction, and mitosis logic for swarms in the interaction system.

phids.engine.systems.interaction.movement

Movement and pathfinding logic for swarms in the interaction system.

phids.engine.systems.interaction.population

Population utilities for interaction system.

phids.engine.systems.signaling

Signaling system: substance synthesis, activation, emission, diffusion, and toxin effects.

This module implements the third and final per-tick simulation phase of the PHIDS engine, governing the full lifecycle of volatile organic compound (VOC) signals and defensive toxins. The signaling phase is executed after both the lifecycle and interaction phases have committed their energy mutations, ensuring that plant survival status and herbivore co-location data reflect the current tick's resolved state before chemical-defense decisions are made.

The phase proceeds through six ordered sub-steps. First, orphaned substance entities whose owner plants were destroyed in earlier phases are garbage-collected. Second, trigger-condition trees are evaluated for each living plant against the per-cell herbivore census index (_build_swarm_population_index): direct herbivore co-presence (herbivore_presence nodes) or indirect conditions (substance_active, environmental_signal, all_of, any_of composites) can independently satisfy a trigger. Third, synthesis countdown timers are decremented for triggered substances; substances with zero remaining countdown and satisfied activation conditions are transitioned to active state. Fourth, active substances emit concentration increments (SUBSTANCE_EMIT_RATE) into signal or toxin environment layers, deduct energy_cost_per_tick from the owner plant, relay VOC signals through mycorrhizal root networks, and record toxin property aggregates for batch application. Fifth, toxin effects (lethality and repellency) are applied to all co-located swarms via _apply_toxin_to_swarms, with immediate spatial-hash deregistration and garbage collection of swarms annihilated by chemical defense. Sixth, Gaussian diffusion is delegated to GridEnvironment.diffuse_signals, which convolves each airborne signal layer with the pre-computed kernel and applies the SIGNAL_EPSILON sparsity threshold to eliminate subnormal tail values.

run_signaling(world: ECSWorld, env: GridEnvironment, trigger_conditions: dict[int, list[TriggerConditionSchema]], mycorrhizal_inter_species: bool, signal_velocity: int, tick: int, plant_death_causes: dict[str, int] | None = None, substance_emit_rate: float = 0.1, signal_decay_factor: float = 0.85) -> None

Execute one signaling tick, handling synthesis, emission and diffusion.

Parameters:

Name Type Description Default
world ECSWorld

The central ECSWorld instance containing all entity component mappings and active systems.

required
env GridEnvironment

Grid environment holding signal/toxin layers.

required
trigger_conditions dict[int, list[TriggerConditionSchema]]

Mapping of flora species_id to trigger schemas.

required
mycorrhizal_inter_species bool

Whether inter-species mycorrhizal signaling is permitted.

required
signal_velocity int

Ticks per hop for root-network relays.

required
tick int

Current simulation tick.

required
plant_death_causes dict[str, int] | None

Mapping of death causes to their respective counts.

None
substance_emit_rate float

Concentration increment added per tick when an active SubstanceComponent emits. Defaults to 0.1 (module-level constant value).

0.1
signal_decay_factor float

Per-tick airborne signal retention after Gaussian diffusion (0.0-1.0). Defaults to 0.85 (module-level constant value).

0.85
Source code in src/phids/engine/systems/signaling/__init__.py
def run_signaling(
    world: ECSWorld,
    env: GridEnvironment,
    trigger_conditions: dict[int, list[TriggerConditionSchema]],
    mycorrhizal_inter_species: bool,
    signal_velocity: int,
    tick: int,  # noqa: ARG001
    plant_death_causes: dict[str, int] | None = None,
    substance_emit_rate: float = 0.1,
    signal_decay_factor: float = 0.85,
) -> None:
    """Execute one signaling tick, handling synthesis, emission and diffusion.

    Args:
        world: The central ECSWorld instance containing all entity component mappings and active systems.
        env: Grid environment holding signal/toxin layers.
        trigger_conditions: Mapping of flora species_id to trigger schemas.
        mycorrhizal_inter_species: Whether inter-species mycorrhizal signaling
            is permitted.
        signal_velocity: Ticks per hop for root-network relays.
        tick: Current simulation tick.
        plant_death_causes: Mapping of death causes to their respective counts.
        substance_emit_rate: Concentration increment added per tick when an active
            SubstanceComponent emits. Defaults to 0.1 (module-level constant value).
        signal_decay_factor: Per-tick airborne signal retention after Gaussian diffusion
            (0.0-1.0). Defaults to 0.85 (module-level constant value).

    """
    dead_substances: list[int] = []
    dead_plants: list[int] = []
    dead_plant_ids: set[int] = set()
    owner_substance_by_key, active_substance_ids_by_owner, substance_entities = _phase_index_and_clean_substances(
        world, dead_substances
    )

    swarm_population_by_cell_species = _build_swarm_population_index(world)

    env.toxin_layers[:] = 0.0
    env._toxin_layers_write[:] = 0.0

    _phase_evaluate_triggers(
        world,
        env,
        trigger_conditions,
        owner_substance_by_key,
        swarm_population_by_cell_species,
        active_substance_ids_by_owner,
        substance_entities,
    )

    _phase_manage_nutrition_recovery(world)

    _phase_advance_synthesis(
        world,
        substance_entities,
        env,
        swarm_population_by_cell_species,
        active_substance_ids_by_owner,
        dead_substances,
    )

    _phase_emit_signals_and_toxins(
        world,
        substance_entities,
        env,
        substance_emit_rate,
        mycorrhizal_inter_species,
        signal_velocity,
        active_substance_ids_by_owner,
        dead_plant_ids,
        dead_substances,
        dead_plants,
        plant_death_causes,
    )

    _phase_process_aftereffects(
        world,
        substance_entities,
        active_substance_ids_by_owner,
        dead_plant_ids,
        dead_substances,
    )

    env.diffuse_signals(signal_decay_factor=signal_decay_factor)

    world.collect_garbage(dead_plants)
    world.collect_garbage(dead_substances)

Design Space Exploration (DSE)

phids.analytics.bio_database

Biological database component for matching traits and parameters.

Provides Pydantic models for flora and herbivore profiles, and the main BioDatabase service for Mode A matching and Mode B bounds query logic.

BioDatabase

Provides Generative (Mode A) and Constrained (Mode B) database queries.

Attributes:

Name Type Description
data

The validated BioDatabaseModel payload loaded from JSON.

Source code in src/phids/analytics/bio_database.py
class BioDatabase:
    """Provides Generative (Mode A) and Constrained (Mode B) database queries.

    Attributes:
        data: The validated BioDatabaseModel payload loaded from JSON.

    """

    def __init__(self, db_path: str = "src/phids/analytics/bio_database.json", data: BioDatabaseModel | None = None):
        """Initialise the bio database from the given JSON file path or model.

        Args:
            db_path: Path to the bio_database.json file.
            data: Explicit BioDatabaseModel instance (overrides db_path).

        """
        if data is not None:
            self.data = data
        else:
            with open(Path(db_path)) as f:
                self.data = BioDatabaseModel(**json.load(f))

    @classmethod
    def from_duckdb(cls, db_path: str = "src/phids/analytics/bio_database.duckdb") -> "BioDatabase":
        """Initialise the bio database directly from the DuckDB file.

        Args:
            db_path: Path to the bio_database.duckdb file.

        Returns:
            A new BioDatabase instance populated from DuckDB.

        Raises:
            ImportError: If duckdb is not installed.
        """
        try:
            import duckdb
        except ImportError:
            raise ImportError("duckdb is required to use from_duckdb()") from None

        conn = duckdb.connect(db_path, read_only=True)

        flora_dict: dict[str, FloraProfile] = {}
        for row in conn.execute(
            "SELECT canonical_name, growth_rate, max_energy, survival_threshold, "
            "seed_cost, seed_dispersion_radius, mechanical_damage_per_bite, "
            "digestibility_modifier FROM flora_species"
        ).fetchall():
            name, growth, max_e, surv, seed_c, seed_r, mech, digest = row
            flora_dict[name] = FloraProfile(
                growth_rate=growth,
                max_energy=max_e,
                survival_threshold=surv,
                seed_cost=seed_c,
                seed_dispersion_radius=seed_r,
                passive_defenses={
                    "mechanical_damage_per_bite": mech,
                    "digestibility_modifier": digest,
                },
            )

        herb_dict: dict[str, HerbivoreProfile] = {}
        for row in conn.execute(
            "SELECT canonical_name, metabolism_upkeep, consumption_rate, "
            "mitosis_threshold, split_ratio, morphological_adaptation, "
            "chemical_neutralization, digestive_efficiency FROM herbivore_species"
        ).fetchall():
            name, metab, cons, mito, split, morph, chem, digest = row
            herb_dict[name] = HerbivoreProfile(
                metabolism_upkeep=metab,
                consumption_rate=cons,
                mitosis_threshold=mito,
                split_ratio=split,
                resistances={
                    "morphological_adaptation": morph,
                    "chemical_neutralization": chem,
                    "digestive_efficiency": digest,
                },
            )

        conn.close()
        return cls(data=BioDatabaseModel(flora=flora_dict, herbivores=herb_dict))

    def _euclidean_distance(self, v1: list[float], v2: list[float]) -> float:
        """Calculate the Euclidean distance between two vectors.

        Args:
            v1: First vector.
            v2: Second vector.

        Returns:
            The float distance value.

        """
        return math.sqrt(sum((a - b) ** 2 for a, b in zip(v1, v2, strict=True)))

    def mode_a_match_flora(self, target_vector: list[float]) -> str:
        """Matches a target vector to the closest database flora species name.

        Args:
            target_vector: A list of floats containing [growth_rate, max_energy, seed_cost].

        Returns:
            The name of the closest flora species found in the database.

        """
        best_match = None
        min_dist = float("inf")
        for name, profile in self.data.flora.items():
            db_vector = [profile.growth_rate, profile.max_energy, profile.seed_cost]
            # In production, normalize these vectors to prevent max_energy from dominating
            dist = self._euclidean_distance(target_vector, db_vector)
            if dist < min_dist:
                min_dist = dist
                best_match = name
        return best_match if best_match is not None else ""

    def mode_a_match_herbivore(self, target_vector: list[float]) -> str:
        """Matches a target vector to the closest database herbivore species name.

        Args:
            target_vector: A list of floats containing [metabolism_upkeep, mitosis_threshold].

        Returns:
            The name of the closest herbivore species found in the database.

        """
        best_match = None
        min_dist = float("inf")
        for name, profile in self.data.herbivores.items():
            db_vector = [profile.metabolism_upkeep, profile.mitosis_threshold]
            dist = self._euclidean_distance(target_vector, db_vector)
            if dist < min_dist:
                min_dist = dist
                best_match = name
        return best_match if best_match is not None else ""

    def mode_b_get_bounds_flora(self, species_name: str) -> dict[str, tuple[float, float]]:
        """Returns ±20% mutation bounds for a specific flora species.

        Args:
            species_name: Name of the flora species to lookup.

        Returns:
            A dictionary mapping trait keys to (min_bound, max_bound) tuples.

        Raises:
            ValueError: If the species_name is not found in the database.

        """
        if species_name not in self.data.flora:
            raise ValueError(f"Species {species_name} not found.")
        profile = self.data.flora[species_name].model_dump()
        return {k: (max(1e-4, v * 0.8), v * 1.2) for k, v in profile.items()}

    def mode_b_get_bounds_herbivore(self, species_name: str) -> dict[str, tuple[float, float]]:
        """Returns ±20% mutation bounds for a specific herbivore species.

        Args:
            species_name: Name of the herbivore species to lookup.

        Returns:
            A dictionary mapping trait keys to (min_bound, max_bound) tuples.

        Raises:
            ValueError: If the species_name is not found in the database.

        """
        if species_name not in self.data.herbivores:
            raise ValueError(f"Species {species_name} not found.")
        profile = self.data.herbivores[species_name].model_dump()
        return {k: (max(1e-4, v * 0.8), v * 1.2) for k, v in profile.items()}

__init__(db_path: str = 'src/phids/analytics/bio_database.json', data: BioDatabaseModel | None = None)

Initialise the bio database from the given JSON file path or model.

Parameters:

Name Type Description Default
db_path str

Path to the bio_database.json file.

'src/phids/analytics/bio_database.json'
data BioDatabaseModel | None

Explicit BioDatabaseModel instance (overrides db_path).

None
Source code in src/phids/analytics/bio_database.py
def __init__(self, db_path: str = "src/phids/analytics/bio_database.json", data: BioDatabaseModel | None = None):
    """Initialise the bio database from the given JSON file path or model.

    Args:
        db_path: Path to the bio_database.json file.
        data: Explicit BioDatabaseModel instance (overrides db_path).

    """
    if data is not None:
        self.data = data
    else:
        with open(Path(db_path)) as f:
            self.data = BioDatabaseModel(**json.load(f))

from_duckdb(db_path: str = 'src/phids/analytics/bio_database.duckdb') -> BioDatabase classmethod

Initialise the bio database directly from the DuckDB file.

Parameters:

Name Type Description Default
db_path str

Path to the bio_database.duckdb file.

'src/phids/analytics/bio_database.duckdb'

Returns:

Type Description
BioDatabase

A new BioDatabase instance populated from DuckDB.

Raises:

Type Description
ImportError

If duckdb is not installed.

Source code in src/phids/analytics/bio_database.py
@classmethod
def from_duckdb(cls, db_path: str = "src/phids/analytics/bio_database.duckdb") -> "BioDatabase":
    """Initialise the bio database directly from the DuckDB file.

    Args:
        db_path: Path to the bio_database.duckdb file.

    Returns:
        A new BioDatabase instance populated from DuckDB.

    Raises:
        ImportError: If duckdb is not installed.
    """
    try:
        import duckdb
    except ImportError:
        raise ImportError("duckdb is required to use from_duckdb()") from None

    conn = duckdb.connect(db_path, read_only=True)

    flora_dict: dict[str, FloraProfile] = {}
    for row in conn.execute(
        "SELECT canonical_name, growth_rate, max_energy, survival_threshold, "
        "seed_cost, seed_dispersion_radius, mechanical_damage_per_bite, "
        "digestibility_modifier FROM flora_species"
    ).fetchall():
        name, growth, max_e, surv, seed_c, seed_r, mech, digest = row
        flora_dict[name] = FloraProfile(
            growth_rate=growth,
            max_energy=max_e,
            survival_threshold=surv,
            seed_cost=seed_c,
            seed_dispersion_radius=seed_r,
            passive_defenses={
                "mechanical_damage_per_bite": mech,
                "digestibility_modifier": digest,
            },
        )

    herb_dict: dict[str, HerbivoreProfile] = {}
    for row in conn.execute(
        "SELECT canonical_name, metabolism_upkeep, consumption_rate, "
        "mitosis_threshold, split_ratio, morphological_adaptation, "
        "chemical_neutralization, digestive_efficiency FROM herbivore_species"
    ).fetchall():
        name, metab, cons, mito, split, morph, chem, digest = row
        herb_dict[name] = HerbivoreProfile(
            metabolism_upkeep=metab,
            consumption_rate=cons,
            mitosis_threshold=mito,
            split_ratio=split,
            resistances={
                "morphological_adaptation": morph,
                "chemical_neutralization": chem,
                "digestive_efficiency": digest,
            },
        )

    conn.close()
    return cls(data=BioDatabaseModel(flora=flora_dict, herbivores=herb_dict))

mode_a_match_flora(target_vector: list[float]) -> str

Matches a target vector to the closest database flora species name.

Parameters:

Name Type Description Default
target_vector list[float]

A list of floats containing [growth_rate, max_energy, seed_cost].

required

Returns:

Type Description
str

The name of the closest flora species found in the database.

Source code in src/phids/analytics/bio_database.py
def mode_a_match_flora(self, target_vector: list[float]) -> str:
    """Matches a target vector to the closest database flora species name.

    Args:
        target_vector: A list of floats containing [growth_rate, max_energy, seed_cost].

    Returns:
        The name of the closest flora species found in the database.

    """
    best_match = None
    min_dist = float("inf")
    for name, profile in self.data.flora.items():
        db_vector = [profile.growth_rate, profile.max_energy, profile.seed_cost]
        # In production, normalize these vectors to prevent max_energy from dominating
        dist = self._euclidean_distance(target_vector, db_vector)
        if dist < min_dist:
            min_dist = dist
            best_match = name
    return best_match if best_match is not None else ""

mode_a_match_herbivore(target_vector: list[float]) -> str

Matches a target vector to the closest database herbivore species name.

Parameters:

Name Type Description Default
target_vector list[float]

A list of floats containing [metabolism_upkeep, mitosis_threshold].

required

Returns:

Type Description
str

The name of the closest herbivore species found in the database.

Source code in src/phids/analytics/bio_database.py
def mode_a_match_herbivore(self, target_vector: list[float]) -> str:
    """Matches a target vector to the closest database herbivore species name.

    Args:
        target_vector: A list of floats containing [metabolism_upkeep, mitosis_threshold].

    Returns:
        The name of the closest herbivore species found in the database.

    """
    best_match = None
    min_dist = float("inf")
    for name, profile in self.data.herbivores.items():
        db_vector = [profile.metabolism_upkeep, profile.mitosis_threshold]
        dist = self._euclidean_distance(target_vector, db_vector)
        if dist < min_dist:
            min_dist = dist
            best_match = name
    return best_match if best_match is not None else ""

mode_b_get_bounds_flora(species_name: str) -> dict[str, tuple[float, float]]

Returns ±20% mutation bounds for a specific flora species.

Parameters:

Name Type Description Default
species_name str

Name of the flora species to lookup.

required

Returns:

Type Description
dict[str, tuple[float, float]]

A dictionary mapping trait keys to (min_bound, max_bound) tuples.

Raises:

Type Description
ValueError

If the species_name is not found in the database.

Source code in src/phids/analytics/bio_database.py
def mode_b_get_bounds_flora(self, species_name: str) -> dict[str, tuple[float, float]]:
    """Returns ±20% mutation bounds for a specific flora species.

    Args:
        species_name: Name of the flora species to lookup.

    Returns:
        A dictionary mapping trait keys to (min_bound, max_bound) tuples.

    Raises:
        ValueError: If the species_name is not found in the database.

    """
    if species_name not in self.data.flora:
        raise ValueError(f"Species {species_name} not found.")
    profile = self.data.flora[species_name].model_dump()
    return {k: (max(1e-4, v * 0.8), v * 1.2) for k, v in profile.items()}

mode_b_get_bounds_herbivore(species_name: str) -> dict[str, tuple[float, float]]

Returns ±20% mutation bounds for a specific herbivore species.

Parameters:

Name Type Description Default
species_name str

Name of the herbivore species to lookup.

required

Returns:

Type Description
dict[str, tuple[float, float]]

A dictionary mapping trait keys to (min_bound, max_bound) tuples.

Raises:

Type Description
ValueError

If the species_name is not found in the database.

Source code in src/phids/analytics/bio_database.py
def mode_b_get_bounds_herbivore(self, species_name: str) -> dict[str, tuple[float, float]]:
    """Returns ±20% mutation bounds for a specific herbivore species.

    Args:
        species_name: Name of the herbivore species to lookup.

    Returns:
        A dictionary mapping trait keys to (min_bound, max_bound) tuples.

    Raises:
        ValueError: If the species_name is not found in the database.

    """
    if species_name not in self.data.herbivores:
        raise ValueError(f"Species {species_name} not found.")
    profile = self.data.herbivores[species_name].model_dump()
    return {k: (max(1e-4, v * 0.8), v * 1.2) for k, v in profile.items()}

BioDatabaseModel

Bases: BaseModel

Container model matching the JSON structure of the biological database.

Attributes:

Name Type Description
flora dict[str, FloraProfile]

Dictionary mapping flora species names to their profiles.

herbivores dict[str, HerbivoreProfile]

Dictionary mapping herbivore species names to their profiles.

Source code in src/phids/analytics/bio_database.py
class BioDatabaseModel(BaseModel):
    """Container model matching the JSON structure of the biological database.

    Attributes:
        flora: Dictionary mapping flora species names to their profiles.
        herbivores: Dictionary mapping herbivore species names to their profiles.

    """

    flora: dict[str, FloraProfile]
    herbivores: dict[str, HerbivoreProfile]

FloraProfile

Bases: BaseModel

Profile parameters representing a specific flora species.

Attributes:

Name Type Description
growth_rate float

Photosynthetic growth rate percentage per tick.

max_energy float

Maximum physiological energy capacity.

survival_threshold float

Energy reserve threshold below which the plant dies.

seed_cost float

Caloric cost to reproduce/drop a seed.

seed_dispersion_radius float

Maximum radius for seed dispersal.

passive_defenses dict[str, float]

Morphological defense configuration.

Source code in src/phids/analytics/bio_database.py
class FloraProfile(BaseModel):
    """Profile parameters representing a specific flora species.

    Attributes:
        growth_rate: Photosynthetic growth rate percentage per tick.
        max_energy: Maximum physiological energy capacity.
        survival_threshold: Energy reserve threshold below which the plant dies.
        seed_cost: Caloric cost to reproduce/drop a seed.
        seed_dispersion_radius: Maximum radius for seed dispersal.
        passive_defenses: Morphological defense configuration.

    """

    growth_rate: float
    max_energy: float
    survival_threshold: float
    seed_cost: float
    seed_dispersion_radius: float
    passive_defenses: dict[str, float]

HerbivoreProfile

Bases: BaseModel

Profile parameters representing a specific herbivore species.

Attributes:

Name Type Description
metabolism_upkeep float

Tick-by-tick base metabolic energy cost.

consumption_rate float

Feeding consumption rate per tick.

mitosis_threshold float

Energy threshold required to undergo mitosis.

split_ratio float

Energy and population allocation ratio on split.

resistances dict[str, float]

Herbivore resistances to passive plant defenses.

Source code in src/phids/analytics/bio_database.py
class HerbivoreProfile(BaseModel):
    """Profile parameters representing a specific herbivore species.

    Attributes:
        metabolism_upkeep: Tick-by-tick base metabolic energy cost.
        consumption_rate: Feeding consumption rate per tick.
        mitosis_threshold: Energy threshold required to undergo mitosis.
        split_ratio: Energy and population allocation ratio on split.
        resistances: Herbivore resistances to passive plant defenses.

    """

    metabolism_upkeep: float
    consumption_rate: float
    mitosis_threshold: float
    split_ratio: float
    resistances: dict[str, float]

phids.analytics.dse_genotype

Genotype definitions representing the ecosystem design space.

Defines structural and parametric genes representing discrete choices and continuous variables of the Mixed-Integer Non-Linear Programming (MINLP) genotype.

DSEGenotype

Bases: BaseModel

The complete Hierarchical MINLP Genotype representation.

Attributes:

Name Type Description
scenario_name str

Name of the candidate scenario.

structural StructuralGenes

The structural/discrete genes component.

parametric ParametricGenes

The parametric/continuous genes component.

Source code in src/phids/analytics/dse_genotype.py
class DSEGenotype(BaseModel):
    """The complete Hierarchical MINLP Genotype representation.

    Attributes:
        scenario_name: Name of the candidate scenario.
        structural: The structural/discrete genes component.
        parametric: The parametric/continuous genes component.

    """

    scenario_name: str = "DSE_Candidate"
    structural: StructuralGenes
    parametric: ParametricGenes

ParametricGenes

Bases: BaseModel

Continuous, tuneable float values representing biological traits.

Attributes:

Name Type Description
flora_traits dict[str, FloraProfile]

Dictionary mapping flora species to their trait profiles.

herbivore_traits dict[str, HerbivoreProfile]

Dictionary mapping herbivore species to their trait profiles.

Source code in src/phids/analytics/dse_genotype.py
class ParametricGenes(BaseModel):
    """Continuous, tuneable float values representing biological traits.

    Attributes:
        flora_traits: Dictionary mapping flora species to their trait profiles.
        herbivore_traits: Dictionary mapping herbivore species to their trait profiles.

    """

    flora_traits: dict[str, FloraProfile]
    herbivore_traits: dict[str, HerbivoreProfile]

StructuralGenes

Bases: BaseModel

Discrete, structural choices of the ecosystem.

Attributes:

Name Type Description
flora_placement PlacementStrategy

Spatial distribution strategy for flora.

herbivore_placement PlacementStrategy

Spatial distribution strategy for herbivores.

diet_matrix list[list[bool]]

A 16x16 boolean matrix defining diet compatibility.

trigger_matrix list[list[int]]

A 16x16 integer mapping of defensive trigger rules.

Source code in src/phids/analytics/dse_genotype.py
class StructuralGenes(BaseModel):
    """Discrete, structural choices of the ecosystem.

    Attributes:
        flora_placement: Spatial distribution strategy for flora.
        herbivore_placement: Spatial distribution strategy for herbivores.
        diet_matrix: A 16x16 boolean matrix defining diet compatibility.
        trigger_matrix: A 16x16 integer mapping of defensive trigger rules.

    """

    flora_placement: PlacementStrategy
    herbivore_placement: PlacementStrategy
    # 16x16 flattened or list-of-lists for Diet Compatibility
    diet_matrix: list[list[bool]]
    # 16x16 integer mapping of which plant triggers which toxin against which herbivore
    trigger_matrix: list[list[int]]

    @field_validator("diet_matrix", "trigger_matrix")
    @classmethod
    def validate_rule_of_16(cls, matrix: list[list[bool]] | list[list[int]]) -> list[list[bool]] | list[list[int]]:
        """Ensure matrix dimensions do not exceed 16x16.

        Args:
            matrix: The nested list matrix to validate.

        Returns:
            The validated matrix.

        Raises:
            ValueError: If the matrix violates the 16x16 dimension limits.

        """
        if len(matrix) > 16 or any(len(row) > 16 for row in matrix):
            raise ValueError("Matrix violates the Rule of 16 bounds.")
        return matrix

validate_rule_of_16(matrix: list[list[bool]] | list[list[int]]) -> list[list[bool]] | list[list[int]] classmethod

Ensure matrix dimensions do not exceed 16x16.

Parameters:

Name Type Description Default
matrix list[list[bool]] | list[list[int]]

The nested list matrix to validate.

required

Returns:

Type Description
list[list[bool]] | list[list[int]]

The validated matrix.

Raises:

Type Description
ValueError

If the matrix violates the 16x16 dimension limits.

Source code in src/phids/analytics/dse_genotype.py
@field_validator("diet_matrix", "trigger_matrix")
@classmethod
def validate_rule_of_16(cls, matrix: list[list[bool]] | list[list[int]]) -> list[list[bool]] | list[list[int]]:
    """Ensure matrix dimensions do not exceed 16x16.

    Args:
        matrix: The nested list matrix to validate.

    Returns:
        The validated matrix.

    Raises:
        ValueError: If the matrix violates the 16x16 dimension limits.

    """
    if len(matrix) > 16 or any(len(row) > 16 for row in matrix):
        raise ValueError("Matrix violates the Rule of 16 bounds.")
    return matrix

phids.analytics.dse_optimizer

NSGA-II multi-objective optimizer for Design Space Exploration (DSE).

Contains class and methods to run a genetic algorithm over the MINLP genotype to find stable, high-biomass, and diverse plant-herbivore configurations.

DSEOptimizer

Multi-objective NSGA-II optimizer for ecosystem exploration.

Attributes:

Name Type Description
base_config

Base template simulation configuration.

pop_size

Size of the genetic algorithm population.

generations

Number of generations to iterate.

toolbox

DEAP toolbox for genetic operators registration.

Source code in src/phids/analytics/dse_optimizer.py
class DSEOptimizer:
    """Multi-objective NSGA-II optimizer for ecosystem exploration.

    Attributes:
        base_config: Base template simulation configuration.
        pop_size: Size of the genetic algorithm population.
        generations: Number of generations to iterate.
        toolbox: DEAP toolbox for genetic operators registration.

    """

    def __init__(self, base_config: SimulationConfig, pop_size: int = 50, generations: int = 20):
        """Initialize the optimizer.

        Args:
            base_config: The template simulation configuration schema.
            pop_size: The number of individuals in the population. Defaults to 50.
            generations: Number of evolutionary generations to run. Defaults to 20.
        """
        self.base_config = base_config
        self.pop_size = pop_size
        self.generations = generations
        self.toolbox = base.Toolbox()
        self._setup_deap()

    async def _warm_numba_cache(self) -> None:
        """CRITICAL CONSTRAINT: Pre-warms the Numba JIT cache on the main thread.

        Prevents LLVM compiler lock contention during parallel evaluations.
        """
        logger.info("Pre-warming Numba JIT Cache on 10x10 dummy grid...")
        dummy_config = self.base_config.model_copy(deep=True)
        dummy_config.grid_width = 10
        dummy_config.grid_height = 10
        dummy_config.max_ticks = 5

        loop = SimulationLoop(dummy_config)

        await loop.step()
        for _ in range(5):
            await loop.step()
        logger.info("Numba JIT Cache warmed successfully.")

    def _setup_deap(self) -> None:
        """Register operators, mating, mutation, selection, and evaluation to the toolbox."""
        # In a full implementation, you would register custom MINLP crossover/mutation here
        # that knows how to splice the DSEGenotype properly.
        self.toolbox.register("evaluate", self.evaluate_candidate_sync)
        self.toolbox.register("mate", tools.cxSimulatedBinaryBounded, eta=20.0, low=1e-4, up=1.0)
        self.toolbox.register("mutate", tools.mutPolynomialBounded, eta=20.0, low=1e-4, up=1.0, indpb=1.0 / 10.0)
        self.toolbox.register("select", tools.selNSGA2)

    def evaluate_candidate_sync(self, individual: "creator.Individual") -> tuple[float, float, float]:
        """Synchronous wrapper for DEAP compatibility.

        Args:
            individual: The DEAP individual to evaluate.

        Returns:
            A tuple of float fitnesses: (longevity, stability, dispersion).
        """
        import asyncio

        return asyncio.run(self.evaluate_candidate(individual))

    async def evaluate_candidate(self, individual: "creator.Individual") -> tuple[float, float, float]:
        """Headless evaluation of a single MINLP Genotype.

        Args:
            individual: The DEAP individual holding a candidate genotype.

        Returns:
            A tuple of float fitnesses: (longevity, stability, dispersion).
        """
        genotype: DSEGenotype | None = individual.genotype

        # Stage 1: Analytical Pre-Pruning
        if genotype and not AnalyticalPruner.evaluate_feasibility(genotype):
            return (0.0, 0.0, 0.0)  # Instant rejection

        # Stage 2: Headless Simulation Evaluation
        # Translate genotype back to a runnable SimulationConfig
        candidate_config = self.base_config.model_copy(deep=True)
        # (In production: Map genotype.parametric and genotype.structural to candidate_config here)

        # Must disable Zarr replay during multithreaded DSE to prevent disk exhaustion
        loop = SimulationLoop(candidate_config, disable_replay=True)

        await loop.step()

        ticks_survived = 0
        herbivore_populations = []

        while ticks_survived < candidate_config.max_ticks:
            await loop.step()
            ticks_survived += 1

            # Extract telemetry for fitness calculating
            metrics = loop.telemetry.get_latest_metrics()
            if metrics:
                herbivore_populations.append(metrics.get("total_herbivore_population", 0))

            if loop.terminated:
                break

        # Fitness 1: Longevity
        longevity = float(ticks_survived)

        # Fitness 2: Stability (Inverse of Coefficient of Variation)
        if len(herbivore_populations) > 10 and np.mean(herbivore_populations) > 0:
            cv = np.std(herbivore_populations) / np.mean(herbivore_populations)
            stability = float(1.0 / (cv + 0.01))
        else:
            stability = 0.0

        # Fitness 3: Dispersion (Placeholder for ECS spatial spread calculation)
        dispersion = len(loop.world._spatial_hash.keys()) / (candidate_config.grid_width * candidate_config.grid_height)

        del loop

        return (longevity, stability, dispersion)

    def _evaluate_population(self, population: list["creator.Individual"]) -> None:
        """Evaluate the population.

        Args:
            population: The population to evaluate.
        """
        invalid_ind = [ind for ind in population if not ind.fitness.valid]
        with ThreadPoolExecutor(max_workers=4) as executor:
            fitnesses = list(executor.map(self.toolbox.evaluate, invalid_ind))

        for ind, fit in zip(invalid_ind, fitnesses, strict=False):
            ind.fitness.values = fit

    def _dispatch_sync_callback(
        self,
        pop: list["creator.Individual"],
        gen: int,
        sync_callback: Callable[[dict[str, Any], list[SimulationConfig]], None],
    ) -> None:
        """Dispatch a callback with the Pareto front.

        Args:
            pop: The population.
            gen: The generation number.
            sync_callback: The callback function.
        """
        pareto_front_inds = pop[:10]
        pareto_configs = []
        for _ind in pareto_front_inds:
            cfg = self.base_config.model_copy(deep=True)
            # (Stub mapping of DEAP floats -> Pydantic Config)
            # The float bounding and preservation logic operates here natively
            pareto_configs.append(cfg)

        payload = {
            "generation": gen,
            "pareto_front": [
                {
                    "longevity": ind.fitness.values[0],
                    "stability": ind.fitness.values[1],
                    "dispersion": ind.fitness.values[2],
                }
                for ind in pareto_front_inds
            ],
        }
        try:
            sync_callback(payload, pareto_configs)
        except Exception as e:
            logger.error("Failed to dispatch DSE callback: %s", e)

    def _generate_offspring(self, pop: list["creator.Individual"]) -> list["creator.Individual"]:
        """Generate offspring.

        Args:
            pop: The population.

        Returns:
            The offspring.
        """
        offspring = tools.selTournamentDCD(pop, len(pop))
        offspring = [self.toolbox.clone(ind) for ind in offspring]

        for ind1, ind2 in zip(offspring[::2], offspring[1::2], strict=False):
            if random.random() <= 0.9:
                self.toolbox.mate(ind1, ind2)
            self.toolbox.mutate(ind1)
            self.toolbox.mutate(ind2)
            del ind1.fitness.values, ind2.fitness.values

        return offspring

    def run(
        self,
        sync_callback: Callable[[dict[str, Any], list[SimulationConfig]], None] | None = None,
        cancel_event: Any = None,
    ) -> list["creator.Individual"]:
        """Run the NSGA-II optimization loop.

        Args:
            sync_callback: Optional callable callback dispatched with Pareto front telemetry.
            cancel_event: Optional asyncio/multiprocessing event to trigger early cancellation.

        Returns:
            The final evaluated population list of individuals.

        """
        asyncio.run(self._warm_numba_cache())

        # In production: Initialize population with valid DSEGenotypes mapping
        # For now, we stub the DEAP population generation
        pop = [creator.Individual([random.random() for _ in range(10)]) for _ in range(self.pop_size)]

        # We need a proper stub genotype for evaluate_candidate
        # but model_construct may fail on nested schemas. Let's create a minimal valid base config Genotype instead.
        for ind in pop:
            ind.genotype = None  # The evaluator handles None by evaluating the base_config directly

        # Evaluate the initial population
        self._evaluate_population(pop)
        pop = self.toolbox.select(pop, len(pop))

        # The NSGA-II Loop
        for gen in range(1, self.generations + 1):
            logger.info("--- DSE Generation %d/%d ---", gen, self.generations)

            offspring = self._generate_offspring(pop)

            # In a full implementation, we must re-sync the continuous DEAP float lists
            # back to the explicit MINLP DSEGenotype here before evaluation.

            self._evaluate_population(offspring)

            # Select the next generation population
            pop = self.toolbox.select(pop + offspring, self.pop_size)

            # --- PHASE 4 HOOK PREPARATION ---
            if cancel_event and cancel_event.is_set():
                logger.info("DSE Optimization cancelled by user.")
                break

            if sync_callback:
                self._dispatch_sync_callback(pop, gen, sync_callback)

        return list(pop)

__init__(base_config: SimulationConfig, pop_size: int = 50, generations: int = 20)

Initialize the optimizer.

Parameters:

Name Type Description Default
base_config SimulationConfig

The template simulation configuration schema.

required
pop_size int

The number of individuals in the population. Defaults to 50.

50
generations int

Number of evolutionary generations to run. Defaults to 20.

20
Source code in src/phids/analytics/dse_optimizer.py
def __init__(self, base_config: SimulationConfig, pop_size: int = 50, generations: int = 20):
    """Initialize the optimizer.

    Args:
        base_config: The template simulation configuration schema.
        pop_size: The number of individuals in the population. Defaults to 50.
        generations: Number of evolutionary generations to run. Defaults to 20.
    """
    self.base_config = base_config
    self.pop_size = pop_size
    self.generations = generations
    self.toolbox = base.Toolbox()
    self._setup_deap()

evaluate_candidate(individual: creator.Individual) -> tuple[float, float, float] async

Headless evaluation of a single MINLP Genotype.

Parameters:

Name Type Description Default
individual Individual

The DEAP individual holding a candidate genotype.

required

Returns:

Type Description
tuple[float, float, float]

A tuple of float fitnesses: (longevity, stability, dispersion).

Source code in src/phids/analytics/dse_optimizer.py
async def evaluate_candidate(self, individual: "creator.Individual") -> tuple[float, float, float]:
    """Headless evaluation of a single MINLP Genotype.

    Args:
        individual: The DEAP individual holding a candidate genotype.

    Returns:
        A tuple of float fitnesses: (longevity, stability, dispersion).
    """
    genotype: DSEGenotype | None = individual.genotype

    # Stage 1: Analytical Pre-Pruning
    if genotype and not AnalyticalPruner.evaluate_feasibility(genotype):
        return (0.0, 0.0, 0.0)  # Instant rejection

    # Stage 2: Headless Simulation Evaluation
    # Translate genotype back to a runnable SimulationConfig
    candidate_config = self.base_config.model_copy(deep=True)
    # (In production: Map genotype.parametric and genotype.structural to candidate_config here)

    # Must disable Zarr replay during multithreaded DSE to prevent disk exhaustion
    loop = SimulationLoop(candidate_config, disable_replay=True)

    await loop.step()

    ticks_survived = 0
    herbivore_populations = []

    while ticks_survived < candidate_config.max_ticks:
        await loop.step()
        ticks_survived += 1

        # Extract telemetry for fitness calculating
        metrics = loop.telemetry.get_latest_metrics()
        if metrics:
            herbivore_populations.append(metrics.get("total_herbivore_population", 0))

        if loop.terminated:
            break

    # Fitness 1: Longevity
    longevity = float(ticks_survived)

    # Fitness 2: Stability (Inverse of Coefficient of Variation)
    if len(herbivore_populations) > 10 and np.mean(herbivore_populations) > 0:
        cv = np.std(herbivore_populations) / np.mean(herbivore_populations)
        stability = float(1.0 / (cv + 0.01))
    else:
        stability = 0.0

    # Fitness 3: Dispersion (Placeholder for ECS spatial spread calculation)
    dispersion = len(loop.world._spatial_hash.keys()) / (candidate_config.grid_width * candidate_config.grid_height)

    del loop

    return (longevity, stability, dispersion)

evaluate_candidate_sync(individual: creator.Individual) -> tuple[float, float, float]

Synchronous wrapper for DEAP compatibility.

Parameters:

Name Type Description Default
individual Individual

The DEAP individual to evaluate.

required

Returns:

Type Description
tuple[float, float, float]

A tuple of float fitnesses: (longevity, stability, dispersion).

Source code in src/phids/analytics/dse_optimizer.py
def evaluate_candidate_sync(self, individual: "creator.Individual") -> tuple[float, float, float]:
    """Synchronous wrapper for DEAP compatibility.

    Args:
        individual: The DEAP individual to evaluate.

    Returns:
        A tuple of float fitnesses: (longevity, stability, dispersion).
    """
    import asyncio

    return asyncio.run(self.evaluate_candidate(individual))

run(sync_callback: Callable[[dict[str, Any], list[SimulationConfig]], None] | None = None, cancel_event: Any = None) -> list[creator.Individual]

Run the NSGA-II optimization loop.

Parameters:

Name Type Description Default
sync_callback Callable[[dict[str, Any], list[SimulationConfig]], None] | None

Optional callable callback dispatched with Pareto front telemetry.

None
cancel_event Any

Optional asyncio/multiprocessing event to trigger early cancellation.

None

Returns:

Type Description
list[Individual]

The final evaluated population list of individuals.

Source code in src/phids/analytics/dse_optimizer.py
def run(
    self,
    sync_callback: Callable[[dict[str, Any], list[SimulationConfig]], None] | None = None,
    cancel_event: Any = None,
) -> list["creator.Individual"]:
    """Run the NSGA-II optimization loop.

    Args:
        sync_callback: Optional callable callback dispatched with Pareto front telemetry.
        cancel_event: Optional asyncio/multiprocessing event to trigger early cancellation.

    Returns:
        The final evaluated population list of individuals.

    """
    asyncio.run(self._warm_numba_cache())

    # In production: Initialize population with valid DSEGenotypes mapping
    # For now, we stub the DEAP population generation
    pop = [creator.Individual([random.random() for _ in range(10)]) for _ in range(self.pop_size)]

    # We need a proper stub genotype for evaluate_candidate
    # but model_construct may fail on nested schemas. Let's create a minimal valid base config Genotype instead.
    for ind in pop:
        ind.genotype = None  # The evaluator handles None by evaluating the base_config directly

    # Evaluate the initial population
    self._evaluate_population(pop)
    pop = self.toolbox.select(pop, len(pop))

    # The NSGA-II Loop
    for gen in range(1, self.generations + 1):
        logger.info("--- DSE Generation %d/%d ---", gen, self.generations)

        offspring = self._generate_offspring(pop)

        # In a full implementation, we must re-sync the continuous DEAP float lists
        # back to the explicit MINLP DSEGenotype here before evaluation.

        self._evaluate_population(offspring)

        # Select the next generation population
        pop = self.toolbox.select(pop + offspring, self.pop_size)

        # --- PHASE 4 HOOK PREPARATION ---
        if cancel_event and cancel_event.is_set():
            logger.info("DSE Optimization cancelled by user.")
            break

        if sync_callback:
            self._dispatch_sync_callback(pop, gen, sync_callback)

    return list(pop)

phids.analytics.dse_pruning

Analytical pre-pruning system for Design Space Exploration (DSE).

Contains validators to filter out structurally and thermodynamically unviable genotypes before running computationally expensive simulations.

AnalyticalPruner

Executes Stage 1 of the DSE: Pre-Exploration Pruning via Analytical Bounds.

Eliminates infeasible MINLP configurations instantly to save CPU cycles.

Source code in src/phids/analytics/dse_pruning.py
class AnalyticalPruner:
    """Executes Stage 1 of the DSE: Pre-Exploration Pruning via Analytical Bounds.

    Eliminates infeasible MINLP configurations instantly to save CPU cycles.
    """

    @staticmethod
    def _check_diet_feasibility(genotype: DSEGenotype, herbivore_ids: list[int], flora_ids: list[int]) -> bool:
        """Check if the diet is feasible.

        Args:
            genotype: The DSE genotype.
            herbivore_ids: The list of herbivore indices.
            flora_ids: The list of flora indices.

        Returns:
            True if no herbivore has an empty diet, otherwise False.
        """
        for h_idx in herbivore_ids:
            edible_plants = [f_idx for f_idx in flora_ids if genotype.structural.diet_matrix[h_idx][f_idx]]
            if not edible_plants:
                logger.debug("Pruned: Herbivore %d has no edible plants in diet matrix.", h_idx)
                return False
        return True

    @staticmethod
    def _check_caloric_conservation(genotype: DSEGenotype, herbivore_ids: list[int], flora_ids: list[int]) -> bool:
        """Check if caloric conservation is feasible.

        Args:
            genotype: The DSE genotype.
            herbivore_ids: The list of herbivore indices.
            flora_ids: The list of flora indices.

        Returns:
            True if caloric conservation is feasible, False otherwise.
        """
        for h_idx in herbivore_ids:
            edible_plants = [f_idx for f_idx in flora_ids if genotype.structural.diet_matrix[h_idx][f_idx]]
            h_name = list(genotype.parametric.herbivore_traits.keys())[h_idx]
            herbivore = genotype.parametric.herbivore_traits[h_name]

            max_available_calories = 0.0
            for f_idx in edible_plants:
                f_name = list(genotype.parametric.flora_traits.keys())[f_idx]
                flora = genotype.parametric.flora_traits[f_name]
                available_energy = max(0.0, flora.max_energy - flora.survival_threshold)
                max_bite = min(available_energy, herbivore.consumption_rate)
                if max_bite > max_available_calories:
                    max_available_calories = max_bite

            if max_available_calories < herbivore.metabolism_upkeep:
                logger.debug("Pruned: Caloric deficit for %s. Upkeep exceeds max available intake.", h_name)
                return False
        return True

    @staticmethod
    def _calculate_total_flora_tiles(f_placement: Any, grid_area: float) -> float:
        """Calculate total flora tiles.

        Args:
            f_placement: The flora placement.
            grid_area: The grid area.

        Returns:
            The total flora tiles.
        """
        if f_placement.type == "uniform":
            return float(grid_area * f_placement.density)
        elif f_placement.type == "clustered":
            return float(min(grid_area, f_placement.cluster_count * 9.0))
        else:
            return float(min(grid_area, f_placement.band_count * 40.0))

    @staticmethod
    def _calculate_total_herbivores(h_placement: Any, grid_area: float) -> float:
        """Calculate total herbivores.

        Args:
            h_placement: The herbivore placement.
            grid_area: The grid area.

        Returns:
            The total herbivores.
        """
        if h_placement.type == "uniform":
            return float(grid_area * h_placement.density)
        elif h_placement.type == "clustered":
            return float(min(grid_area, h_placement.cluster_count * 5.0))
        else:
            return float(min(grid_area, h_placement.band_count * 10.0))

    @staticmethod
    def _check_global_thermodynamics(genotype: DSEGenotype, herbivore_ids: list[int], flora_ids: list[int]) -> bool:
        """Check global thermodynamics.

        Args:
            genotype: The DSE genotype.
            herbivore_ids: The list of herbivore indices.
            flora_ids: The list of flora indices.

        Returns:
            True if global thermodynamics is feasible, False otherwise.
        """
        grid_area = 1600.0

        total_flora_tiles = AnalyticalPruner._calculate_total_flora_tiles(
            genotype.structural.flora_placement, grid_area
        )
        num_flora_species = max(1, len(flora_ids))
        n_max_tiles_per_flora = total_flora_tiles / num_flora_species

        total_herbivores = AnalyticalPruner._calculate_total_herbivores(
            genotype.structural.herbivore_placement, grid_area
        )
        num_herbivore_species = max(1, len(herbivore_ids))
        n_initial_per_herbivore = total_herbivores / num_herbivore_species

        total_primary_production = 0.0
        for _f_name, flora in genotype.parametric.flora_traits.items():
            yield_energy = max(0.0, flora.max_energy - flora.survival_threshold)
            total_primary_production += yield_energy * (flora.growth_rate / 100.0) * n_max_tiles_per_flora

        total_metabolism = 0.0
        for _h_name, herbivore in genotype.parametric.herbivore_traits.items():
            total_metabolism += herbivore.metabolism_upkeep * n_initial_per_herbivore

        if total_primary_production <= total_metabolism and total_metabolism > 0:
            logger.debug("Pruned: Global thermodynamic bounds violated.")
            return False
        return True

    @staticmethod
    def _check_flora_biological_validity(genotype: DSEGenotype) -> bool:
        """Check flora biological validity.

        Args:
            genotype: The DSE genotype.

        Returns:
            True if flora biological validity is feasible, False otherwise.
        """
        for f_name, flora in genotype.parametric.flora_traits.items():
            if flora.seed_cost >= (flora.max_energy - flora.survival_threshold):
                logger.debug("Pruned: Flora %s seed cost causes immediate self-termination.", f_name)
                return False
        return True

    @staticmethod
    def evaluate_feasibility(genotype: DSEGenotype) -> bool:
        """Returns True if the genotype is mathematically viable, False if doomed.

        Performs:
        1. Structural diet checks (ensures no herbivore starves by design).
        2. Individual caloric conservation checks.
        3. Global thermodynamic bounds checks.
        4. Flora self-termination (seed cost vs max yield) checks.

        Args:
            genotype: The candidate DSEGenotype to evaluate.

        Returns:
            True if the genotype is mathematically viable, False otherwise.
        """
        herbivore_ids = list(range(len(genotype.parametric.herbivore_traits)))
        flora_ids = list(range(len(genotype.parametric.flora_traits)))

        if not AnalyticalPruner._check_diet_feasibility(genotype, herbivore_ids, flora_ids):
            return False

        if not AnalyticalPruner._check_caloric_conservation(genotype, herbivore_ids, flora_ids):
            return False

        if not AnalyticalPruner._check_global_thermodynamics(genotype, herbivore_ids, flora_ids):
            return False

        if not AnalyticalPruner._check_flora_biological_validity(genotype):
            return False

        return True

evaluate_feasibility(genotype: DSEGenotype) -> bool staticmethod

Returns True if the genotype is mathematically viable, False if doomed.

Performs: 1. Structural diet checks (ensures no herbivore starves by design). 2. Individual caloric conservation checks. 3. Global thermodynamic bounds checks. 4. Flora self-termination (seed cost vs max yield) checks.

Parameters:

Name Type Description Default
genotype DSEGenotype

The candidate DSEGenotype to evaluate.

required

Returns:

Type Description
bool

True if the genotype is mathematically viable, False otherwise.

Source code in src/phids/analytics/dse_pruning.py
@staticmethod
def evaluate_feasibility(genotype: DSEGenotype) -> bool:
    """Returns True if the genotype is mathematically viable, False if doomed.

    Performs:
    1. Structural diet checks (ensures no herbivore starves by design).
    2. Individual caloric conservation checks.
    3. Global thermodynamic bounds checks.
    4. Flora self-termination (seed cost vs max yield) checks.

    Args:
        genotype: The candidate DSEGenotype to evaluate.

    Returns:
        True if the genotype is mathematically viable, False otherwise.
    """
    herbivore_ids = list(range(len(genotype.parametric.herbivore_traits)))
    flora_ids = list(range(len(genotype.parametric.flora_traits)))

    if not AnalyticalPruner._check_diet_feasibility(genotype, herbivore_ids, flora_ids):
        return False

    if not AnalyticalPruner._check_caloric_conservation(genotype, herbivore_ids, flora_ids):
        return False

    if not AnalyticalPruner._check_global_thermodynamics(genotype, herbivore_ids, flora_ids):
        return False

    if not AnalyticalPruner._check_flora_biological_validity(genotype):
        return False

    return True

phids.analytics.tuning

Automated hyperparameter tuning pipeline using Differential Evolution.

This module provides the :class:TrophicOptimizer to autonomously search for stable Lotka-Volterra parameters over complex multi-species scenarios.

TrophicOptimizer

Hyperparameter tuner utilizing Scipy's Differential Evolution.

Mutates a given scenario blueprint to find a parameter regime that maximizes ecosystem survival over a large number of ticks, using population CV as a secondary stability metric.

Attributes:

Name Type Description
blueprint

The baseline scenario configuration.

runs_per_eval

Number of concurrent stochastic runs per evaluation.

max_ticks

Target simulation duration per run.

bounds list[tuple[float, float]]

List of min/max boundary value tuples for each parameter.

param_mapping list[tuple[str, int, str]]

Mapping of float indices to blueprint keys.

Source code in src/phids/analytics/tuning.py
class TrophicOptimizer:
    """Hyperparameter tuner utilizing Scipy's Differential Evolution.

    Mutates a given scenario blueprint to find a parameter regime that maximizes
    ecosystem survival over a large number of ticks, using population CV as a
    secondary stability metric.

    Attributes:
        blueprint: The baseline scenario configuration.
        runs_per_eval: Number of concurrent stochastic runs per evaluation.
        max_ticks: Target simulation duration per run.
        bounds: List of min/max boundary value tuples for each parameter.
        param_mapping: Mapping of float indices to blueprint keys.

    """

    def __init__(
        self,
        blueprint: dict[str, Any],
        runs_per_eval: int = 20,
        max_ticks: int = 2500,
    ) -> None:
        """Initialize the optimizer.

        Args:
            blueprint: The baseline scenario configuration.
            runs_per_eval: Number of concurrent stochastic runs per evaluation.
            max_ticks: Target simulation duration per run.
        """
        self.blueprint = blueprint
        self.runs_per_eval = runs_per_eval
        self.max_ticks = max_ticks

        self.bounds: list[tuple[float, float]] = []
        self.param_mapping: list[tuple[str, int, str]] = []

        self._setup_bounds()

    def _setup_bounds(self) -> None:
        """Extract tunable parameters from the blueprint and set numerical bounds."""
        for i, _flora in enumerate(self.blueprint.get("flora_species", [])):
            # Growth rate (g_j)
            self.bounds.append((1.0, 15.0))
            self.param_mapping.append(("flora_species", i, "growth_rate"))

            # Seed min distance
            self.bounds.append((0.5, 3.0))
            self.param_mapping.append(("flora_species", i, "seed_min_dist"))

            # Seed max distance
            self.bounds.append((3.1, 10.0))
            self.param_mapping.append(("flora_species", i, "seed_max_dist"))

        for i, _herb in enumerate(self.blueprint.get("herbivore_species", [])):
            # Metabolic maintenance (m_i)
            self.bounds.append((0.01, 1.0))
            self.param_mapping.append(("herbivore_species", i, "energy_upkeep_per_individual"))

            # Reproduction cost (c_i) mapped to reproduction_energy_divisor
            self.bounds.append((0.1, 5.0))
            self.param_mapping.append(("herbivore_species", i, "reproduction_energy_divisor"))

    def _apply_params(self, x: np.ndarray) -> dict[str, Any]:
        """Inject an array of values back into a deepcopy of the blueprint.

        Args:
            x: A NumPy array containing the values to apply.

        Returns:
            The modified scenario configuration blueprint dictionary.
        """
        config = cast("dict[str, Any]", json.loads(json.dumps(self.blueprint)))
        for val, (category, idx, key) in zip(x, self.param_mapping, strict=False):
            config[category][idx][key] = float(val)
        return config

    def _calculate_stability_cv(self, run_telemetry: list[dict[str, Any]]) -> float:
        """Calculate stability CV from run telemetry.

        Args:
            run_telemetry: The run telemetry.

        Returns:
            The stability CV.
        """
        flora_pop = [float(str(r.get("flora_population", 0.0))) for r in run_telemetry]
        herb_pop = [float(str(r.get("herbivore_population", 0.0))) for r in run_telemetry]

        f_mean = float(np.mean(flora_pop))
        h_mean = float(np.mean(herb_pop))

        if f_mean > 0 and h_mean > 0:
            f_cv = float(np.std(flora_pop)) / f_mean
            h_cv = float(np.std(herb_pop)) / h_mean
            return (f_cv + h_cv) / 2.0
        return 100.0

    def _compute_fitness_score(self, results: list[list[dict[str, Any]]]) -> float:
        """Compute fitness score from evaluation results.

        Args:
            results: The evaluation results.

        Returns:
            The computed fitness score.
        """
        survived = 0
        cvs: list[float] = []

        for run_telemetry in results:
            if not run_telemetry:
                continue

            last_tick = int(str(run_telemetry[-1].get("tick", 0)))
            if last_tick >= self.max_ticks - 1:
                survived += 1
                cv = self._calculate_stability_cv(run_telemetry)
                cvs.append(cv)

        target_survived = int(self.runs_per_eval * 0.8)
        failure_penalty = max(0, target_survived - survived) * 1000.0

        if survived > 0 and cvs:
            avg_cv = sum(cvs) / len(cvs)
        else:
            avg_cv = 1000.0

        score = failure_penalty + avg_cv
        logger.info(
            "Genome Evaluation: survived=%d/%d, avg_cv=%.3f, fitness_score=%.3f",
            survived,
            self.runs_per_eval,
            avg_cv,
            score,
        )
        return score

    def _evaluate(self, x: np.ndarray) -> float:
        """Evaluate fitness of a parameter vector over N concurrent simulations.

        Args:
            x: A NumPy array containing candidate parameter values.

        Returns:
            The calculated float fitness/stability score (lower is better).
        """
        config = self._apply_params(x)

        # Disable logging overhead during mass evaluation
        # The empty string disables Zarr replay persistence in the worker
        args_list = [(config, self.max_ticks, seed, "tune", i, "") for i, seed in enumerate(range(self.runs_per_eval))]

        results = []
        # Use ProcessPoolExecutor to max out IPC throughput
        with concurrent.futures.ProcessPoolExecutor() as executor:
            for res in executor.map(_run_and_save, args_list):
                results.append(res)

        return self._compute_fitness_score(results)

    def optimize(self) -> dict[str, Any]:
        """Run the Differential Evolution optimization loop.

        Returns:
            The optimized configuration blueprint as a dictionary.
        """
        logger.info(
            "Starting stochastic optimization sweep with %d parameters and %d concurrent runs per eval",
            len(self.bounds),
            self.runs_per_eval,
        )

        # Note: popsize and maxiter can be aggressively tuned based on compute time.
        # Since each eval spawns 20 processes, keep the genetic population small.
        result = differential_evolution(
            self._evaluate,
            self.bounds,
            maxiter=15,
            popsize=3,
            disp=True,
            polish=False,
            workers=1,  # Keep main process single-threaded, concurrency is in the fitness function
        )

        logger.info("Optimization complete. Best fitness: %s", result.fun)
        best_config = self._apply_params(result.x)
        return best_config

__init__(blueprint: dict[str, Any], runs_per_eval: int = 20, max_ticks: int = 2500) -> None

Initialize the optimizer.

Parameters:

Name Type Description Default
blueprint dict[str, Any]

The baseline scenario configuration.

required
runs_per_eval int

Number of concurrent stochastic runs per evaluation.

20
max_ticks int

Target simulation duration per run.

2500
Source code in src/phids/analytics/tuning.py
def __init__(
    self,
    blueprint: dict[str, Any],
    runs_per_eval: int = 20,
    max_ticks: int = 2500,
) -> None:
    """Initialize the optimizer.

    Args:
        blueprint: The baseline scenario configuration.
        runs_per_eval: Number of concurrent stochastic runs per evaluation.
        max_ticks: Target simulation duration per run.
    """
    self.blueprint = blueprint
    self.runs_per_eval = runs_per_eval
    self.max_ticks = max_ticks

    self.bounds: list[tuple[float, float]] = []
    self.param_mapping: list[tuple[str, int, str]] = []

    self._setup_bounds()

optimize() -> dict[str, Any]

Run the Differential Evolution optimization loop.

Returns:

Type Description
dict[str, Any]

The optimized configuration blueprint as a dictionary.

Source code in src/phids/analytics/tuning.py
def optimize(self) -> dict[str, Any]:
    """Run the Differential Evolution optimization loop.

    Returns:
        The optimized configuration blueprint as a dictionary.
    """
    logger.info(
        "Starting stochastic optimization sweep with %d parameters and %d concurrent runs per eval",
        len(self.bounds),
        self.runs_per_eval,
    )

    # Note: popsize and maxiter can be aggressively tuned based on compute time.
    # Since each eval spawns 20 processes, keep the genetic population small.
    result = differential_evolution(
        self._evaluate,
        self.bounds,
        maxiter=15,
        popsize=3,
        disp=True,
        polish=False,
        workers=1,  # Keep main process single-threaded, concurrency is in the fitness function
    )

    logger.info("Optimization complete. Best fitness: %s", result.fun)
    best_config = self._apply_params(result.x)
    return best_config

Telemetry and replay

phids.telemetry.analytics

Telemetry analytics: accumulate per-tick Lotka-Volterra metrics into a Polars DataFrame.

The :class:TelemetryRecorder accumulates per-tick population and energy metrics into an in-memory row buffer and exposes a lazily-constructed :class:polars.DataFrame for downstream export, Chart.js serialisation, and statistical aggregation. Each recorded tick captures both aggregate scalars (total flora energy, total herbivore population) and granular per-species dictionaries (population and aggregate energy keyed by species_id), thereby enabling precise Lotka-Volterra phase-space visualisation and Monte Carlo batch evaluation.

The per-species data is accumulated via defaultdict accumulators inside :meth:TelemetryRecorder.record so that sparse or absent species naturally resolve to zero without requiring sentinel guards. Active defense-maintenance costs are also attributed per flora species_id by querying :class:~phids.engine.components.substances.SubstanceComponent entities whose active flag is set, summing their energy_cost_per_tick contribution. This diagnostic facilitates identification of runaway defense-maintenance scenarios in which an entire connected mycorrhizal network commits metabolic resources to sustained chemical defense under persistent herbivore pressure.

The :attr:TelemetryRecorder.dataframe property materialises a fully rectangular Polars DataFrame that preserves per-species breakdowns as typed scalar columns (plant_{id}_pop, plant_{id}_energy, defense_cost_{id}, swarm_{id}_pop). This columnar representation exposes the per-species data through the primary CSV and NDJSON export routes without requiring callers to reach into the raw _rows buffer or invoke the auxiliary :func:~phids.telemetry.export.core.telemetry_to_dataframe pandas-conversion helper. Species identifiers observed across the accumulated session are unioned and sorted before columns are written, guaranteeing a consistent column order even when individual ticks contain sparse species sets.

TelemetryRecorder

Accumulate per-tick Lotka-Volterra metrics into a Polars DataFrame.

The recorder appends one row per tick and materialises a lazily-built Polars DataFrame containing aggregate scalars together with per-species flat columns. Aggregate fields comprise tick, total_flora_energy, flora_population, herbivore_clusters, herbivore_population, and the five per-tick plant death cause counts (death_reproduction, death_mycorrhiza, death_defense_maintenance, death_herbivore_feeding, death_background_deficit). Per-species breakdowns are exposed as typed Polars scalar columns following the naming convention plant_{id}_pop, plant_{id}_energy, swarm_{id}_pop, and defense_cost_{id}, where {id} denotes the integer species_id. Missing species in a given tick are zero-filled to guarantee a fully rectangular DataFrame suitable for vectorised statistical operations and direct CSV or NDJSON export.

Source code in src/phids/telemetry/analytics.py
class TelemetryRecorder:
    """Accumulate per-tick Lotka-Volterra metrics into a Polars DataFrame.

    The recorder appends one row per tick and materialises a lazily-built Polars
    DataFrame containing aggregate scalars together with per-species flat columns.
    Aggregate fields comprise ``tick``, ``total_flora_energy``, ``flora_population``,
    ``herbivore_clusters``, ``herbivore_population``, and the five per-tick plant death
    cause counts (``death_reproduction``, ``death_mycorrhiza``,
    ``death_defense_maintenance``, ``death_herbivore_feeding``,
    ``death_background_deficit``). Per-species breakdowns are exposed as typed Polars
    scalar columns following the naming convention ``plant_{id}_pop``,
    ``plant_{id}_energy``, ``swarm_{id}_pop``, and ``defense_cost_{id}``, where
    ``{id}`` denotes the integer ``species_id``. Missing species in a given tick are
    zero-filled to guarantee a fully rectangular DataFrame suitable for vectorised
    statistical operations and direct CSV or NDJSON export.
    """

    def __init__(self, max_rows: int = MAX_TELEMETRY_TICKS) -> None:
        """Create a TelemetryRecorder with empty in-memory buffers.

        Args:
            max_rows: Maximum in-memory tick rows retained in the rolling window.
        """
        self._rows: list[TelemetryRow] = []
        self._df: pl.DataFrame | None = None
        self._max_rows = max(1, max_rows)

    def _extract_death_counts(
        self,
        plant_death_causes: dict[str, int] | None,
        metrics: TickMetrics | None,
    ) -> dict[str, int]:
        """Extract death counts from plant death causes and tick metrics.

        Args:
            plant_death_causes: Per-tick plant death diagnostics keyed by cause.
            metrics: Tick metrics containing plant and herbivore death causes.

        Returns:
            A dictionary of death counts keyed by cause.
        """
        death_counts = {
            "death_reproduction": 0,
            "death_mycorrhiza": 0,
            "death_defense_maintenance": 0,
            "death_herbivore_feeding": 0,
            "death_background_deficit": 0,
            "death_starvation": 0,
        }

        def _update_counts(source: dict[str, int]) -> None:
            """Update death counts from a source dictionary.

            Args:
                source: The source dictionary containing death counts.
            """
            for key in source:
                if key in death_counts:
                    death_counts[key] = source[key]

        if plant_death_causes is not None:
            _update_counts(plant_death_causes)
        elif metrics and metrics.plant_death_causes:
            _update_counts(metrics.plant_death_causes)

        if metrics and metrics.herbivore_death_causes:
            _update_counts(metrics.herbivore_death_causes)

        return death_counts

    def record(
        self,
        world: ECSWorld,
        tick: int,
        plant_death_causes: dict[str, int] | None = None,
        tick_metrics: TickMetrics | None = None,
    ) -> None:
        """Snapshot current ECS metrics and append to the internal buffer.

        Iterates over all :class:`~phids.engine.components.plant.PlantComponent`,
        :class:`~phids.engine.components.swarm.SwarmComponent`, and active
        :class:`~phids.engine.components.substances.SubstanceComponent` entities
        to build aggregate and per-species counters. All per-species keys are
        written unconditionally (with zero defaults) so that downstream pandas
        and Polars operations encounter a fully rectangular schema without null
        values.

        Args:
            world: The ECS world to sample entity components from.
            tick: Current simulation tick index.
            plant_death_causes: Per-tick plant death diagnostics keyed by cause.
            tick_metrics: Optional pre-collected tick metrics; if omitted, they are gathered from the world.
        """
        metrics = tick_metrics or collect_tick_metrics(world)
        death_counts = self._extract_death_counts(plant_death_causes, metrics)

        row: TelemetryRow = {
            "tick": tick,
            "total_flora_energy": metrics.total_flora_energy,
            "flora_population": metrics.flora_population,
            "herbivore_clusters": metrics.herbivore_clusters,
            "herbivore_population": metrics.herbivore_population,
            **death_counts,
            # Per-species flat columns
            "plant_pop_by_species": dict(metrics.plant_pop_by_species),
            "plant_energy_by_species": dict(metrics.plant_energy_by_species),
            "swarm_pop_by_species": dict(metrics.swarm_pop_by_species),
            "defense_cost_by_species": dict(metrics.defense_cost_by_species),
        }
        self._rows.append(row)
        if len(self._rows) > self._max_rows:
            # Enforce bounded telemetry memory by dropping oldest ticks first.
            overflow = len(self._rows) - self._max_rows
            del self._rows[:overflow]
        self._df = None  # invalidate cache
        logger.debug(
            "Telemetry row recorded (tick=%d, flora=%d, herbivores=%d, flora_energy=%.2f)",
            tick,
            metrics.flora_population,
            metrics.herbivore_population,
            metrics.total_flora_energy,
        )

    def get_latest_metrics(self) -> TelemetryRow | None:
        """Return the latest recorded telemetry row, if available.

        Returns:
            TelemetryRow | None: Most recent metrics row or ``None``.
        """
        if not self._rows:
            return None
        return self._rows[-1]

    def get_species_ids(self) -> dict[str, list[int]]:
        """Return the union of all flora and herbivore species ids seen so far.

        Scans all accumulated rows to collect every species id that has
        appeared at least once in the simulation history, enabling Chart.js
        dataset generation to create series for species that may have gone
        extinct mid-simulation.

        Returns:
            dict[str, list[int]]: Keys ``"flora_ids"`` and ``"herbivore_ids"``
            each mapping to a sorted list of integer species identifiers.
        """
        flora_ids: set[int] = set()
        herbivore_ids: set[int] = set()
        for row in self._rows:
            flora_ids.update(_as_species_count_map(row.get("plant_pop_by_species", {})).keys())
            herbivore_ids.update(_as_species_count_map(row.get("swarm_pop_by_species", {})).keys())
        return {
            "flora_ids": sorted(flora_ids),
            "herbivore_ids": sorted(herbivore_ids),
        }

    def _materialize_dataframe(self) -> pl.DataFrame:
        """Materialize the telemetry rows into a Polars DataFrame.

        Returns:
            pl.DataFrame: The telemetry DataFrame.
        """
        if not self._rows:
            return pl.DataFrame(
                {
                    "tick": pl.Series([], dtype=pl.Int64),
                    "total_flora_energy": pl.Series([], dtype=pl.Float64),
                    "flora_population": pl.Series([], dtype=pl.Int64),
                    "herbivore_clusters": pl.Series([], dtype=pl.Int64),
                    "herbivore_population": pl.Series([], dtype=pl.Int64),
                    "death_reproduction": pl.Series([], dtype=pl.Int64),
                    "death_mycorrhiza": pl.Series([], dtype=pl.Int64),
                    "death_defense_maintenance": pl.Series([], dtype=pl.Int64),
                    "death_herbivore_feeding": pl.Series([], dtype=pl.Int64),
                    "death_background_deficit": pl.Series([], dtype=pl.Int64),
                }
            )

        all_flora_ids: set[int] = set()
        all_swarm_ids: set[int] = set()
        for r in self._rows:
            all_flora_ids.update(_as_species_count_map(r.get("plant_pop_by_species", {})).keys())
            all_swarm_ids.update(_as_species_count_map(r.get("swarm_pop_by_species", {})).keys())
        sorted_flora = sorted(all_flora_ids)
        sorted_swarm = sorted(all_swarm_ids)

        flat_rows: list[dict[str, object]] = []
        for r in self._rows:
            flat: dict[str, object] = {k: v for k, v in r.items() if not isinstance(v, dict)}
            plant_pop = _as_species_count_map(r.get("plant_pop_by_species", {}))
            plant_energy = _as_species_energy_map(r.get("plant_energy_by_species", {}))
            defense_cost = _as_species_energy_map(r.get("defense_cost_by_species", {}))
            swarm_pop = _as_species_count_map(r.get("swarm_pop_by_species", {}))
            for fid in sorted_flora:
                flat[f"plant_{fid}_pop"] = plant_pop.get(fid, 0)
                flat[f"plant_{fid}_energy"] = plant_energy.get(fid, 0.0)
                flat[f"defense_cost_{fid}"] = defense_cost.get(fid, 0.0)
            for sid in sorted_swarm:
                flat[f"swarm_{sid}_pop"] = swarm_pop.get(sid, 0)
            flat_rows.append(flat)
        return pl.DataFrame(flat_rows)

    @property
    def dataframe(self) -> pl.DataFrame:
        """Return recorded metrics as a Polars DataFrame with per-species flat columns (lazily built).

        Per-species dictionary accumulators stored in each row's
        ``plant_pop_by_species``, ``plant_energy_by_species``,
        ``swarm_pop_by_species``, and ``defense_cost_by_species`` fields are
        flattened into typed Polars scalar columns named ``plant_{id}_pop``
        (``Int64``), ``plant_{id}_energy`` (``Float64``), ``swarm_{id}_pop``
        (``Int64``), and ``defense_cost_{id}`` (``Float64``) respectively.
        Missing species values for a given tick are zero-filled, ensuring the
        resulting DataFrame is fully rectangular and free of null entries.

        All species identifiers observed across the full retention window are
        unioned and sorted prior to column construction, so that the column
        layout is deterministic and consistent even when individual ticks contain
        sparse species sets due to extinction or delayed colonisation events.

        The empty-state DataFrame (no recorded ticks) retains only the stable
        aggregate schema; per-species columns are added dynamically once at
        least one tick has been recorded and at least one species has been
        observed, reflecting the inherently dynamic cardinality of the species
        pool across independent simulation sessions.

        Returns:
            pl.DataFrame: DataFrame containing aggregate and per-species flat
            telemetry columns for all accumulated ticks.

        """
        if self._df is None:
            logger.debug("Materialising telemetry dataframe from %d rows", len(self._rows))
            self._df = self._materialize_dataframe()
        return self._df

    def reset(self) -> None:
        """Clear accumulated telemetry and reset internal cache."""
        logger.info("Resetting telemetry recorder with %d buffered rows", len(self._rows))
        self._rows = []
        self._df = None

dataframe: pl.DataFrame property

Return recorded metrics as a Polars DataFrame with per-species flat columns (lazily built).

Per-species dictionary accumulators stored in each row's plant_pop_by_species, plant_energy_by_species, swarm_pop_by_species, and defense_cost_by_species fields are flattened into typed Polars scalar columns named plant_{id}_pop (Int64), plant_{id}_energy (Float64), swarm_{id}_pop (Int64), and defense_cost_{id} (Float64) respectively. Missing species values for a given tick are zero-filled, ensuring the resulting DataFrame is fully rectangular and free of null entries.

All species identifiers observed across the full retention window are unioned and sorted prior to column construction, so that the column layout is deterministic and consistent even when individual ticks contain sparse species sets due to extinction or delayed colonisation events.

The empty-state DataFrame (no recorded ticks) retains only the stable aggregate schema; per-species columns are added dynamically once at least one tick has been recorded and at least one species has been observed, reflecting the inherently dynamic cardinality of the species pool across independent simulation sessions.

Returns:

Type Description
DataFrame

pl.DataFrame: DataFrame containing aggregate and per-species flat

DataFrame

telemetry columns for all accumulated ticks.

__init__(max_rows: int = MAX_TELEMETRY_TICKS) -> None

Create a TelemetryRecorder with empty in-memory buffers.

Parameters:

Name Type Description Default
max_rows int

Maximum in-memory tick rows retained in the rolling window.

MAX_TELEMETRY_TICKS
Source code in src/phids/telemetry/analytics.py
def __init__(self, max_rows: int = MAX_TELEMETRY_TICKS) -> None:
    """Create a TelemetryRecorder with empty in-memory buffers.

    Args:
        max_rows: Maximum in-memory tick rows retained in the rolling window.
    """
    self._rows: list[TelemetryRow] = []
    self._df: pl.DataFrame | None = None
    self._max_rows = max(1, max_rows)

get_latest_metrics() -> TelemetryRow | None

Return the latest recorded telemetry row, if available.

Returns:

Type Description
TelemetryRow | None

TelemetryRow | None: Most recent metrics row or None.

Source code in src/phids/telemetry/analytics.py
def get_latest_metrics(self) -> TelemetryRow | None:
    """Return the latest recorded telemetry row, if available.

    Returns:
        TelemetryRow | None: Most recent metrics row or ``None``.
    """
    if not self._rows:
        return None
    return self._rows[-1]

get_species_ids() -> dict[str, list[int]]

Return the union of all flora and herbivore species ids seen so far.

Scans all accumulated rows to collect every species id that has appeared at least once in the simulation history, enabling Chart.js dataset generation to create series for species that may have gone extinct mid-simulation.

Returns:

Type Description
dict[str, list[int]]

dict[str, list[int]]: Keys "flora_ids" and "herbivore_ids"

dict[str, list[int]]

each mapping to a sorted list of integer species identifiers.

Source code in src/phids/telemetry/analytics.py
def get_species_ids(self) -> dict[str, list[int]]:
    """Return the union of all flora and herbivore species ids seen so far.

    Scans all accumulated rows to collect every species id that has
    appeared at least once in the simulation history, enabling Chart.js
    dataset generation to create series for species that may have gone
    extinct mid-simulation.

    Returns:
        dict[str, list[int]]: Keys ``"flora_ids"`` and ``"herbivore_ids"``
        each mapping to a sorted list of integer species identifiers.
    """
    flora_ids: set[int] = set()
    herbivore_ids: set[int] = set()
    for row in self._rows:
        flora_ids.update(_as_species_count_map(row.get("plant_pop_by_species", {})).keys())
        herbivore_ids.update(_as_species_count_map(row.get("swarm_pop_by_species", {})).keys())
    return {
        "flora_ids": sorted(flora_ids),
        "herbivore_ids": sorted(herbivore_ids),
    }

record(world: ECSWorld, tick: int, plant_death_causes: dict[str, int] | None = None, tick_metrics: TickMetrics | None = None) -> None

Snapshot current ECS metrics and append to the internal buffer.

Iterates over all :class:~phids.engine.components.plant.PlantComponent, :class:~phids.engine.components.swarm.SwarmComponent, and active :class:~phids.engine.components.substances.SubstanceComponent entities to build aggregate and per-species counters. All per-species keys are written unconditionally (with zero defaults) so that downstream pandas and Polars operations encounter a fully rectangular schema without null values.

Parameters:

Name Type Description Default
world ECSWorld

The ECS world to sample entity components from.

required
tick int

Current simulation tick index.

required
plant_death_causes dict[str, int] | None

Per-tick plant death diagnostics keyed by cause.

None
tick_metrics TickMetrics | None

Optional pre-collected tick metrics; if omitted, they are gathered from the world.

None
Source code in src/phids/telemetry/analytics.py
def record(
    self,
    world: ECSWorld,
    tick: int,
    plant_death_causes: dict[str, int] | None = None,
    tick_metrics: TickMetrics | None = None,
) -> None:
    """Snapshot current ECS metrics and append to the internal buffer.

    Iterates over all :class:`~phids.engine.components.plant.PlantComponent`,
    :class:`~phids.engine.components.swarm.SwarmComponent`, and active
    :class:`~phids.engine.components.substances.SubstanceComponent` entities
    to build aggregate and per-species counters. All per-species keys are
    written unconditionally (with zero defaults) so that downstream pandas
    and Polars operations encounter a fully rectangular schema without null
    values.

    Args:
        world: The ECS world to sample entity components from.
        tick: Current simulation tick index.
        plant_death_causes: Per-tick plant death diagnostics keyed by cause.
        tick_metrics: Optional pre-collected tick metrics; if omitted, they are gathered from the world.
    """
    metrics = tick_metrics or collect_tick_metrics(world)
    death_counts = self._extract_death_counts(plant_death_causes, metrics)

    row: TelemetryRow = {
        "tick": tick,
        "total_flora_energy": metrics.total_flora_energy,
        "flora_population": metrics.flora_population,
        "herbivore_clusters": metrics.herbivore_clusters,
        "herbivore_population": metrics.herbivore_population,
        **death_counts,
        # Per-species flat columns
        "plant_pop_by_species": dict(metrics.plant_pop_by_species),
        "plant_energy_by_species": dict(metrics.plant_energy_by_species),
        "swarm_pop_by_species": dict(metrics.swarm_pop_by_species),
        "defense_cost_by_species": dict(metrics.defense_cost_by_species),
    }
    self._rows.append(row)
    if len(self._rows) > self._max_rows:
        # Enforce bounded telemetry memory by dropping oldest ticks first.
        overflow = len(self._rows) - self._max_rows
        del self._rows[:overflow]
    self._df = None  # invalidate cache
    logger.debug(
        "Telemetry row recorded (tick=%d, flora=%d, herbivores=%d, flora_energy=%.2f)",
        tick,
        metrics.flora_population,
        metrics.herbivore_population,
        metrics.total_flora_energy,
    )

reset() -> None

Clear accumulated telemetry and reset internal cache.

Source code in src/phids/telemetry/analytics.py
def reset(self) -> None:
    """Clear accumulated telemetry and reset internal cache."""
    logger.info("Resetting telemetry recorder with %d buffered rows", len(self._rows))
    self._rows = []
    self._df = None

phids.telemetry.conditions

Termination condition evaluators (Z1-Z7) for deterministic simulation halting.

This module implements the rule-based termination logic that determines when a PHIDS simulation run should end. Seven named termination conditions are supported: Z1 halts when the configured maximum tick count is reached; Z2 halts when a specified flora species goes extinct (zero remaining plant entities with that species identifier); Z3 halts when all flora entities are extinct; Z4 and Z5 apply the analogous extinction conditions to herbivore species; Z6 halts when total aggregate flora energy exceeds a configured upper bound (modelling uncontrolled biomass expansion); and Z7 halts when total aggregate herbivore population exceeds a configured upper bound (modelling herbivore outbreak conditions).

All conditions are evaluated by a single pass over live ECS component queries, keeping the computational cost proportional to the number of living entities rather than to any grid dimension. Conditions with negative threshold values are disabled by convention, enabling selective activation of any subset of the seven rules for a given scenario. The :class:TerminationResult dataclass encodes both the terminated flag and a human-readable reason string, which is logged and surfaced through the REST API /api/simulation/status endpoint when the simulation halts.

TerminationResult dataclass

Result returned by :func:check_termination.

Attributes:

Name Type Description
terminated bool

True when a termination condition has been met.

reason str

Human-readable explanation for termination.

Source code in src/phids/telemetry/conditions.py
@dataclass(slots=True)
class TerminationResult:
    """Result returned by :func:`check_termination`.

    Attributes:
        terminated: True when a termination condition has been met.
        reason: Human-readable explanation for termination.
    """

    terminated: bool
    reason: str

check_termination(world: ECSWorld, tick: int, max_ticks: int, z2_flora_species: int = -1, z3_check_all_flora: bool = True, z4_herbivore_species: int = -1, z5_check_all_herbivores: bool = True, z6_max_flora_energy: float = -1.0, z7_max_total_herbivore_population: int = -1, tick_metrics: TickMetrics | None = None) -> TerminationResult

Evaluate termination conditions and return the first triggered one.

Parameters:

Name Type Description Default
world ECSWorld

The central ECSWorld instance containing all entity component mappings and active systems.

required
tick int

Current simulation tick.

required
max_ticks int

Z1 - maximum allowed ticks (halt when reached).

required
z2_flora_species int

Species id that triggers Z2 on extinction (-1 disables).

-1
z3_check_all_flora bool

If True, halt when all flora are extinct (Z3).

True
z4_herbivore_species int

Species id that triggers Z4 on extinction (-1 disables).

-1
z5_check_all_herbivores bool

If True, halt when all herbivores are extinct (Z5).

True
z6_max_flora_energy float

Aggregate flora energy threshold for Z6 (-1 disables).

-1.0
z7_max_total_herbivore_population int

Aggregate herbivore population threshold for Z7 (-1 disables).

-1
tick_metrics TickMetrics | None

Optional pre-computed tick metrics.

None

Returns:

Name Type Description
TerminationResult TerminationResult

Object indicating whether termination occurred and why.

Source code in src/phids/telemetry/conditions.py
def check_termination(
    world: ECSWorld,
    tick: int,
    max_ticks: int,
    z2_flora_species: int = -1,
    z3_check_all_flora: bool = True,
    z4_herbivore_species: int = -1,
    z5_check_all_herbivores: bool = True,
    z6_max_flora_energy: float = -1.0,
    z7_max_total_herbivore_population: int = -1,
    tick_metrics: TickMetrics | None = None,
) -> TerminationResult:
    """Evaluate termination conditions and return the first triggered one.

    Args:
        world: The central ECSWorld instance containing all entity component mappings and active systems.
        tick: Current simulation tick.
        max_ticks: Z1 - maximum allowed ticks (halt when reached).
        z2_flora_species: Species id that triggers Z2 on extinction (-1 disables).
        z3_check_all_flora: If True, halt when all flora are extinct (Z3).
        z4_herbivore_species: Species id that triggers Z4 on extinction (-1 disables).
        z5_check_all_herbivores: If True, halt when all herbivores are extinct (Z5).
        z6_max_flora_energy: Aggregate flora energy threshold for Z6 (-1 disables).
        z7_max_total_herbivore_population: Aggregate herbivore population threshold for Z7 (-1 disables).
        tick_metrics: Optional pre-computed tick metrics.

    Returns:
        TerminationResult: Object indicating whether termination occurred and why.
    """
    # Z1 - maximum tick count
    if tick >= max_ticks:
        return TerminationResult(terminated=True, reason=f"Z1: reached max_ticks={max_ticks}")

    flora_species_alive, total_flora_energy, flora_alive = _gather_flora_metrics(world, tick_metrics)

    # Z2 - specific flora species extinction
    if z2_flora_species >= 0 and z2_flora_species not in flora_species_alive:
        return TerminationResult(terminated=True, reason=f"Z2: flora species {z2_flora_species} extinct")

    # Z3 - all flora extinct
    if z3_check_all_flora and not flora_alive:
        return TerminationResult(terminated=True, reason="Z3: all flora extinct")

    # Z6 - aggregate flora energy exceeds upper bound
    if 0.0 < z6_max_flora_energy < total_flora_energy:
        return TerminationResult(
            terminated=True,
            reason=f"Z6: total flora energy {total_flora_energy:.1f} > {z6_max_flora_energy}",
        )

    herbivore_species_alive, total_herbivore_population, herbivores_alive = _gather_herbivore_metrics(
        world, tick_metrics
    )

    # Z4 - specific herbivore species extinction
    if z4_herbivore_species >= 0 and z4_herbivore_species not in herbivore_species_alive:
        return TerminationResult(terminated=True, reason=f"Z4: herbivore species {z4_herbivore_species} extinct")

    # Z5 - all herbivores extinct
    if z5_check_all_herbivores and not herbivores_alive:
        return TerminationResult(terminated=True, reason="Z5: all herbivores extinct")

    # Z7 - aggregate herbivore population exceeds upper bound
    if 0 < z7_max_total_herbivore_population < total_herbivore_population:
        return TerminationResult(
            terminated=True,
            reason=(
                f"Z7: total herbivore population {total_herbivore_population} > {z7_max_total_herbivore_population}"
            ),
        )

    return TerminationResult(terminated=False, reason="")

phids.telemetry.tick_metrics

Tick-level ECS aggregation structures for shared telemetry and termination evaluation.

This module defines the :class:TickMetrics dataclass and the corresponding :func:collect_tick_metrics helper, which execute a single deterministic pass over live ECS components to materialize scalar and per-species aggregates used by both telemetry recording and termination-condition evaluation. The explicit shared-aggregation contract eliminates duplicated component scans in the hot simulation loop while preserving strict data-oriented semantics.

The collector accumulates flora and herbivore populations, aggregate energies, species-presence sets, and active defense-maintenance costs attributed to flora species through owner-linked substance components. These metrics encode both the biological observables (population size, energetic state, active chemical maintenance burden) and the computational invariants required by PHIDS phase ordering, thereby allowing telemetry and termination logic to observe an identical post-system world snapshot without divergent sampling artifacts.

TickMetrics dataclass

Shared per-tick aggregate metrics for telemetry and termination consumers.

Attributes:

Name Type Description
flora_population int

Number of live flora entities.

herbivore_clusters int

Number of live herbivore swarm entities.

herbivore_population int

Total herbivore individuals across all swarms.

total_flora_energy float

Sum of flora energy across all live plants.

total_herbivore_population int

Alias for termination readability.

flora_alive bool

Whether any flora entities are alive.

herbivores_alive bool

Whether any herbivore swarms are alive.

flora_species_alive set[int]

Set of live flora species IDs.

herbivore_species_alive set[int]

Set of live herbivore species IDs.

plant_pop_by_species dict[int, int]

Flora population counts keyed by species ID.

plant_energy_by_species dict[int, float]

Flora aggregate energy keyed by species ID.

swarm_pop_by_species dict[int, int]

Herbivore population keyed by species ID.

defense_cost_by_species dict[int, float]

Active defense-maintenance costs keyed by flora species ID.

Source code in src/phids/telemetry/tick_metrics.py
@dataclass(slots=True)
class TickMetrics:
    """Shared per-tick aggregate metrics for telemetry and termination consumers.

    Attributes:
        flora_population: Number of live flora entities.
        herbivore_clusters: Number of live herbivore swarm entities.
        herbivore_population: Total herbivore individuals across all swarms.
        total_flora_energy: Sum of flora energy across all live plants.
        total_herbivore_population: Alias for termination readability.
        flora_alive: Whether any flora entities are alive.
        herbivores_alive: Whether any herbivore swarms are alive.
        flora_species_alive: Set of live flora species IDs.
        herbivore_species_alive: Set of live herbivore species IDs.
        plant_pop_by_species: Flora population counts keyed by species ID.
        plant_energy_by_species: Flora aggregate energy keyed by species ID.
        swarm_pop_by_species: Herbivore population keyed by species ID.
        defense_cost_by_species: Active defense-maintenance costs keyed by flora species ID.

    """

    flora_population: int = 0
    herbivore_clusters: int = 0
    herbivore_population: int = 0
    total_flora_energy: float = 0.0
    total_herbivore_population: int = 0
    flora_alive: bool = False
    herbivores_alive: bool = False
    flora_species_alive: set[int] = field(default_factory=set)
    herbivore_species_alive: set[int] = field(default_factory=set)
    plant_pop_by_species: dict[int, int] = field(default_factory=dict)
    plant_energy_by_species: dict[int, float] = field(default_factory=dict)
    swarm_pop_by_species: dict[int, int] = field(default_factory=dict)
    defense_cost_by_species: dict[int, float] = field(default_factory=dict)
    plant_death_causes: dict[str, int] = field(default_factory=dict)
    herbivore_death_causes: dict[str, int] = field(default_factory=dict)

collect_tick_metrics(world: ECSWorld) -> TickMetrics

Aggregate one shared snapshot of live ECS metrics from the current world.

Parameters:

Name Type Description Default
world ECSWorld

ECS world sampled after ordered system execution for the tick.

required

Returns:

Name Type Description
TickMetrics TickMetrics

Shared aggregate metrics suitable for telemetry and termination.

Source code in src/phids/telemetry/tick_metrics.py
def collect_tick_metrics(world: ECSWorld) -> TickMetrics:
    """Aggregate one shared snapshot of live ECS metrics from the current world.

    Args:
        world: ECS world sampled after ordered system execution for the tick.

    Returns:
        TickMetrics: Shared aggregate metrics suitable for telemetry and termination.

    """
    metrics = TickMetrics()

    for entity in world.query(PlantComponent):
        plant: PlantComponent = entity.get_component(PlantComponent)
        species_id = int(plant.species_id)
        metrics.flora_population += 1
        metrics.flora_alive = True
        metrics.total_flora_energy += float(plant.energy)
        metrics.flora_species_alive.add(species_id)
        metrics.plant_pop_by_species[species_id] = metrics.plant_pop_by_species.get(species_id, 0) + 1
        metrics.plant_energy_by_species[species_id] = metrics.plant_energy_by_species.get(species_id, 0.0) + float(
            plant.energy
        )

    for entity in world.query(SwarmComponent):
        swarm: SwarmComponent = entity.get_component(SwarmComponent)
        species_id = int(swarm.species_id)
        population = int(swarm.population)
        metrics.herbivore_clusters += 1
        metrics.herbivores_alive = True
        metrics.herbivore_population += population
        metrics.total_herbivore_population += population
        metrics.herbivore_species_alive.add(species_id)
        metrics.swarm_pop_by_species[species_id] = metrics.swarm_pop_by_species.get(species_id, 0) + population

    for entity in world.query(SubstanceComponent):
        substance: SubstanceComponent = entity.get_component(SubstanceComponent)
        if not substance.active or substance.energy_cost_per_tick <= 0.0:
            continue
        owner = world.get_entity(substance.owner_plant_id) if world.has_entity(substance.owner_plant_id) else None
        if owner is None or not owner.has_component(PlantComponent):
            continue
        owner_plant: PlantComponent = owner.get_component(PlantComponent)
        owner_species_id = int(owner_plant.species_id)
        metrics.defense_cost_by_species[owner_species_id] = metrics.defense_cost_by_species.get(
            owner_species_id, 0.0
        ) + float(substance.energy_cost_per_tick)

    return metrics

phids.telemetry.export.core

Telemetry export core helpers.

Provides common data transformation utilities, color palettes, and filtering logic for exporting telemetry rows to LaTeX, TikZ, CSV, JSON, and PNG formats.

aggregate_to_dataframe(aggregate: Mapping[str, object], *, flora_names: dict[int, str] | None = None, herbivore_names: dict[int, str] | None = None) -> pd.DataFrame

Convert a batch aggregate summary dict to a wide pandas DataFrame.

Constructs a per-tick DataFrame from the mean and standard deviation arrays stored inside the aggregate summary produced by :func:~phids.engine.batch.aggregate_batch_telemetry.

Parameters:

Name Type Description Default
aggregate Mapping[str, object]

Dict with keys ticks, flora_population_mean, flora_population_std, herbivore_population_mean, herbivore_population_std, and optionally per-species series.

required
flora_names dict[int, str] | None

Optional display name mapping for flora species.

None
herbivore_names dict[int, str] | None

Optional display name mapping for herbivore species.

None

Returns:

Type Description
DataFrame

Wide-format DataFrame ready for export.

Source code in src/phids/telemetry/export/core.py
def aggregate_to_dataframe(
    aggregate: Mapping[str, object],
    *,
    flora_names: dict[int, str] | None = None,
    herbivore_names: dict[int, str] | None = None,
) -> pd.DataFrame:
    """Convert a batch aggregate summary dict to a wide pandas DataFrame.

    Constructs a per-tick DataFrame from the mean and standard deviation arrays
    stored inside the aggregate summary produced by
    :func:`~phids.engine.batch.aggregate_batch_telemetry`.

    Args:
        aggregate: Dict with keys ``ticks``, ``flora_population_mean``,
            ``flora_population_std``, ``herbivore_population_mean``,
            ``herbivore_population_std``, and optionally per-species series.
        flora_names: Optional display name mapping for flora species.
        herbivore_names: Optional display name mapping for herbivore species.

    Returns:
        Wide-format DataFrame ready for export.
    """
    import pandas as pd

    ticks_raw = aggregate.get("ticks", [])
    if not isinstance(ticks_raw, list) or not ticks_raw:
        return pd.DataFrame()
    ticks: list[object] = ticks_raw

    data: dict[str, object] = {"tick": ticks}
    data["flora_population_mean"] = aggregate.get("flora_population_mean", [0.0] * len(ticks))
    data["flora_population_std"] = aggregate.get("flora_population_std", [0.0] * len(ticks))
    data["herbivore_population_mean"] = aggregate.get("herbivore_population_mean", [0.0] * len(ticks))
    data["herbivore_population_std"] = aggregate.get("herbivore_population_std", [0.0] * len(ticks))

    for fid, series_mean in _object_mapping(aggregate.get("per_flora_pop_mean", {})).items():
        fid_int = _to_int(fid, default=-1)
        name = (flora_names or {}).get(fid_int, f"flora_{fid_int}")
        data[f"{name}_pop_mean"] = series_mean
        series_std = _object_mapping(aggregate.get("per_flora_pop_std", {})).get(fid, [0.0] * len(ticks))
        data[f"{name}_pop_std"] = series_std

    for pid, series_mean in _object_mapping(aggregate.get("per_herbivore_pop_mean", {})).items():
        pid_int = _to_int(pid, default=-1)
        name = (herbivore_names or {}).get(pid_int, f"herbivore_{pid_int}")
        data[f"{name}_pop_mean"] = series_mean
        series_std = _object_mapping(aggregate.get("per_herbivore_pop_std", {})).get(pid, [0.0] * len(ticks))
        data[f"{name}_pop_std"] = series_std

    return pd.DataFrame(data)

decimate_dataframe(df: pd.DataFrame, tick_interval: int) -> pd.DataFrame

Return a tick-decimated DataFrame using stride semantics.

Parameters:

Name Type Description Default
df DataFrame

The structured Polars DataFrame constructed from recorded telemetry row objects.

required
tick_interval int

Row stride; values below 1 are treated as 1.

required

Returns:

Type Description
DataFrame

Decimated DataFrame.

Source code in src/phids/telemetry/export/core.py
def decimate_dataframe(df: pd.DataFrame, tick_interval: int) -> pd.DataFrame:
    """Return a tick-decimated DataFrame using stride semantics.

    Args:
        df: The structured Polars DataFrame constructed from recorded telemetry row objects.
        tick_interval: Row stride; values below 1 are treated as 1.

    Returns:
        Decimated DataFrame.
    """
    stride = max(1, tick_interval)
    if stride <= 1 or df.empty:
        return df
    return df.iloc[::stride, :]

filter_dataframe_columns(df: pd.DataFrame, columns: str | None) -> pd.DataFrame

Return a DataFrame restricted to requested columns.

Parameters:

Name Type Description Default
df DataFrame

Input pandas DataFrame.

required
columns str | None

Optional CSV column list.

required

Returns:

Type Description
DataFrame

Filtered DataFrame containing only existing columns.

Source code in src/phids/telemetry/export/core.py
def filter_dataframe_columns(df: pd.DataFrame, columns: str | None) -> pd.DataFrame:
    """Return a DataFrame restricted to requested columns.

    Args:
        df: Input pandas DataFrame.
        columns: Optional CSV column list.

    Returns:
        Filtered DataFrame containing only existing columns.
    """
    if columns is None or columns.strip() == "" or df.empty:
        return df
    wanted = [c.strip() for c in columns.split(",") if c.strip()]
    if "tick" not in wanted and "tick" in df.columns:
        wanted.insert(0, "tick")
    kept = [c for c in wanted if c in df.columns]
    return df.loc[:, kept] if kept else df

filter_telemetry_rows(rows: TelemetryRows, *, flora_ids: str | None = None, herbivore_ids: str | None = None) -> TelemetryRows

Filter per-species nested telemetry dictionaries by id.

Parameters:

Name Type Description Default
rows TelemetryRows

A list of recorded telemetry frame dictionaries sequentially captured during the simulation execution.

required
flora_ids str | None

Optional CSV flora species-id list.

None
herbivore_ids str | None

Optional CSV herbivore species-id list.

None

Returns:

Type Description
TelemetryRows

Row list with filtered species dictionaries.

Source code in src/phids/telemetry/export/core.py
def filter_telemetry_rows(
    rows: TelemetryRows,
    *,
    flora_ids: str | None = None,
    herbivore_ids: str | None = None,
) -> TelemetryRows:
    """Filter per-species nested telemetry dictionaries by id.

    Args:
        rows: A list of recorded telemetry frame dictionaries sequentially captured during the simulation execution.
        flora_ids: Optional CSV flora species-id list.
        herbivore_ids: Optional CSV herbivore species-id list.

    Returns:
        Row list with filtered species dictionaries.
    """
    flora_keep = _parse_species_ids(flora_ids)
    herbivore_keep = _parse_species_ids(herbivore_ids)
    if flora_keep is None and herbivore_keep is None:
        return rows

    filtered: TelemetryRows = []
    for row in rows:
        filtered.append(_filter_single_row(row, flora_keep, herbivore_keep))
    return filtered

telemetry_to_dataframe(rows: TelemetryRows) -> pd.DataFrame

Flatten per-species nested dicts from raw telemetry rows into a pandas DataFrame.

Converts the list of row dicts accumulated by :class:~phids.telemetry.analytics.TelemetryRecorder into a wide-format pandas DataFrame. Each per-species nested dictionary (plant_pop_by_species, plant_energy_by_species, swarm_pop_by_species, defense_cost_by_species) is exploded into individual columns named plant_{id}_pop, plant_{id}_energy, swarm_{id}_pop, and defense_cost_{id} respectively. Missing species in a given tick are filled with zero, ensuring a fully rectangular output suitable for vectorised statistical operations and LaTeX table generation.

Parameters:

Name Type Description Default
rows TelemetryRows

Raw row list from TelemetryRecorder._rows.

required

Returns:

Type Description
DataFrame

Wide-format DataFrame with one row per tick and one column per

DataFrame

scalar metric or per-species measurement.

Source code in src/phids/telemetry/export/core.py
def telemetry_to_dataframe(rows: TelemetryRows) -> pd.DataFrame:
    """Flatten per-species nested dicts from raw telemetry rows into a pandas DataFrame.

    Converts the list of row dicts accumulated by
    :class:`~phids.telemetry.analytics.TelemetryRecorder` into a wide-format
    pandas DataFrame. Each per-species nested dictionary (``plant_pop_by_species``,
    ``plant_energy_by_species``, ``swarm_pop_by_species``, ``defense_cost_by_species``)
    is exploded into individual columns named ``plant_{id}_pop``, ``plant_{id}_energy``,
    ``swarm_{id}_pop``, and ``defense_cost_{id}`` respectively. Missing species in a
    given tick are filled with zero, ensuring a fully rectangular output suitable for
    vectorised statistical operations and LaTeX table generation.

    Args:
        rows: Raw row list from ``TelemetryRecorder._rows``.

    Returns:
        Wide-format DataFrame with one row per tick and one column per
        scalar metric or per-species measurement.
    """
    import pandas as pd  # local import to keep dependency optional at module load

    if not rows:
        return pd.DataFrame()

    # Collect all species ids seen across all rows
    all_flora_ids: set[int] = set()
    all_swarm_ids: set[int] = set()
    for row in rows:
        all_flora_ids.update(_species_map(row, "plant_pop_by_species").keys())
        all_swarm_ids.update(_species_map(row, "swarm_pop_by_species").keys())

    flat_rows = []
    for row in rows:
        flat: dict[str, object] = {k: v for k, v in row.items() if not isinstance(v, dict)}
        pop_by = _species_map(row, "plant_pop_by_species")
        energy_by = _species_map(row, "plant_energy_by_species")
        swarm_by = _species_map(row, "swarm_pop_by_species")
        defense_by = _species_map(row, "defense_cost_by_species")

        for fid in sorted(all_flora_ids):
            flat[f"plant_{fid}_pop"] = pop_by.get(fid, 0)
            flat[f"plant_{fid}_energy"] = energy_by.get(fid, 0.0)
            flat[f"defense_cost_{fid}"] = defense_by.get(fid, 0.0)

        for sid in sorted(all_swarm_ids):
            flat[f"swarm_{sid}_pop"] = swarm_by.get(sid, 0)

        flat_rows.append(flat)

    logger.debug(
        "telemetry_to_dataframe: %d rows, %d flora species, %d herbivore species",
        len(rows),
        len(all_flora_ids),
        len(all_swarm_ids),
    )
    return pd.DataFrame(flat_rows)

phids.telemetry.export.latex

Telemetry export to LaTeX tabular environment.

Formats telemetry dataframes into booktabs LaTeX tabular tables suitable for academic papers.

export_bytes_tex_table(rows: TelemetryRows, *, columns: str | None = None, include_flora_ids: str | None = None, include_herbivore_ids: str | None = None, tick_interval: int = 1) -> bytes

Render the telemetry rows as a booktabs LaTeX tabular environment.

Flattens per-species dicts into a wide pandas DataFrame via :func:telemetry_to_dataframe, then serialises to LaTeX using DataFrame.to_latex(index=False), which emits \\toprule, \\midrule, and \\bottomrule rules consistent with the booktabs LaTeX package conventions expected in peer-reviewed journals.

Parameters:

Name Type Description Default
rows TelemetryRows

Raw telemetry rows from TelemetryRecorder._rows.

required
columns str | None

Optional comma-separated list of columns to include.

None
include_flora_ids str | None

Optional comma-separated list of flora species IDs to filter.

None
include_herbivore_ids str | None

Optional comma-separated list of herbivore species IDs to filter.

None
tick_interval int

Integer tick interval to decimate rows.

1

Returns:

Name Type Description
bytes bytes

UTF-8 encoded LaTeX tabular source.

Source code in src/phids/telemetry/export/latex.py
def export_bytes_tex_table(
    rows: TelemetryRows,
    *,
    columns: str | None = None,
    include_flora_ids: str | None = None,
    include_herbivore_ids: str | None = None,
    tick_interval: int = 1,
) -> bytes:
    r"""Render the telemetry rows as a booktabs LaTeX tabular environment.

    Flattens per-species dicts into a wide pandas DataFrame via
    :func:`telemetry_to_dataframe`, then serialises to LaTeX using
    ``DataFrame.to_latex(index=False)``, which emits
    ``\\toprule``, ``\\midrule``, and ``\\bottomrule`` rules consistent with
    the ``booktabs`` LaTeX package conventions expected in peer-reviewed journals.

    Args:
        rows: Raw telemetry rows from ``TelemetryRecorder._rows``.
        columns: Optional comma-separated list of columns to include.
        include_flora_ids: Optional comma-separated list of flora species IDs to filter.
        include_herbivore_ids: Optional comma-separated list of herbivore species IDs to filter.
        tick_interval: Integer tick interval to decimate rows.

    Returns:
        bytes: UTF-8 encoded LaTeX ``tabular`` source.

    """
    filtered_rows = filter_telemetry_rows(rows, flora_ids=include_flora_ids, herbivore_ids=include_herbivore_ids)
    df = telemetry_to_dataframe(filtered_rows)
    df = filter_dataframe_columns(df, columns)
    df = decimate_dataframe(df, tick_interval)
    if df.empty:
        return b"% No telemetry data\n"
    latex: str = df.to_latex(index=False, float_format="%.2f")
    return latex.encode("utf-8")

phids.telemetry.export.png

Telemetry export to PNG images.

Renders publication-quality charts (time series, phase space, defense economy, biomass stacks, and survival probability) using Matplotlib headless Agg backend to raw PNG bytes.

generate_png_bytes(rows: TelemetryRows, plot_type: str = 'timeseries', *, flora_names: dict[int, str] | None = None, herbivore_names: dict[int, str] | None = None, plant_species_id: int = 0, herbivore_species_id: int = 0, include_flora_ids: str | None = None, include_herbivore_ids: str | None = None, title: str | None = None, x_label: str | None = None, y_label: str | None = None, x_max: float | None = None, y_max: float | None = None, dpi: int = 150) -> bytes

Render a matplotlib chart to PNG bytes using the headless Agg backend.

Supports five plot_type modes:

  • "timeseries" - Overlaid line chart with one series per flora and herbivore species, sharing a common tick x-axis and a left y-axis for population counts.
  • "phasespace" - Lotka-Volterra phase-space scatter with showLine=True semantics.
  • "defense_economy" - Line chart plotting defense cost divided by total energy capacity per flora species.
  • "biomass_stack" - Stacked area chart approximating carrying capacity share.
  • "survival_probability" - Aggregate batch survival probability (requires ensemble rows).

Parameters:

Name Type Description Default
rows TelemetryRows

A list of recorded telemetry frame dictionaries sequentially captured during the simulation execution.

required
plot_type str

Output chart type ("timeseries", "phasespace", "defense_economy", "biomass_stack", or "survival_probability").

'timeseries'
flora_names dict[int, str] | None

Optional dictionary mapping flora species ids to display names.

None
herbivore_names dict[int, str] | None

Optional dictionary mapping herbivore ids to display names.

None
plant_species_id int

Flora species id to use for the x-axis in phasespace mode.

0
herbivore_species_id int

Herbivore species id to use for the y-axis in phasespace.

0
include_flora_ids str | None

Optional CSV list of flora ids to keep (filters out others).

None
include_herbivore_ids str | None

Optional CSV list of herbivore ids to keep.

None
title str | None

Optional override for the matplotlib title.

None
x_label str | None

Optional override for the matplotlib x-axis label.

None
y_label str | None

Optional override for the matplotlib y-axis label.

None
x_max float | None

Optional upper bound for the x-axis (ignored if zero or None).

None
y_max float | None

Optional upper bound for the y-axis (ignored if zero or None).

None
dpi int

Dots-per-inch scaling factor for rasterization.

150

Returns:

Type Description
bytes

Raw PNG-encoded bytes of the rendered figure.

Source code in src/phids/telemetry/export/png.py
def generate_png_bytes(
    rows: TelemetryRows,
    plot_type: str = "timeseries",
    *,
    flora_names: dict[int, str] | None = None,
    herbivore_names: dict[int, str] | None = None,
    plant_species_id: int = 0,
    herbivore_species_id: int = 0,
    include_flora_ids: str | None = None,
    include_herbivore_ids: str | None = None,
    title: str | None = None,
    x_label: str | None = None,
    y_label: str | None = None,
    x_max: float | None = None,
    y_max: float | None = None,
    dpi: int = 150,
) -> bytes:
    """Render a matplotlib chart to PNG bytes using the headless Agg backend.

    Supports five ``plot_type`` modes:

    * ``"timeseries"`` - Overlaid line chart with one series per flora and herbivore
      species, sharing a common tick x-axis and a left y-axis for population counts.
    * ``"phasespace"`` - Lotka-Volterra phase-space scatter with ``showLine=True``
      semantics.
    * ``"defense_economy"`` - Line chart plotting defense cost divided by total energy
      capacity per flora species.
    * ``"biomass_stack"`` - Stacked area chart approximating carrying capacity share.
    * ``"survival_probability"`` - Aggregate batch survival probability (requires ensemble rows).

    Args:
        rows: A list of recorded telemetry frame dictionaries sequentially captured during the simulation execution.
        plot_type: Output chart type (``"timeseries"``, ``"phasespace"``,
            ``"defense_economy"``, ``"biomass_stack"``, or ``"survival_probability"``).
        flora_names: Optional dictionary mapping flora species ids to display names.
        herbivore_names: Optional dictionary mapping herbivore ids to display names.
        plant_species_id: Flora species id to use for the x-axis in phasespace mode.
        herbivore_species_id: Herbivore species id to use for the y-axis in phasespace.
        include_flora_ids: Optional CSV list of flora ids to keep (filters out others).
        include_herbivore_ids: Optional CSV list of herbivore ids to keep.
        title: Optional override for the matplotlib title.
        x_label: Optional override for the matplotlib x-axis label.
        y_label: Optional override for the matplotlib y-axis label.
        x_max: Optional upper bound for the x-axis (ignored if zero or None).
        y_max: Optional upper bound for the y-axis (ignored if zero or None).
        dpi: Dots-per-inch scaling factor for rasterization.

    Returns:
        Raw PNG-encoded bytes of the rendered figure.
    """
    import matplotlib
    import matplotlib.pyplot as plt

    matplotlib.use("Agg")

    rows = filter_telemetry_rows(
        rows,
        flora_ids=include_flora_ids,
        herbivore_ids=include_herbivore_ids,
    )

    if not rows:
        fig, ax = plt.subplots(figsize=(6, 4), dpi=dpi)
        ax.text(
            0.5,
            0.5,
            "No data to display\n(Filter resulted in empty series)",
            horizontalalignment="center",
            verticalalignment="center",
            transform=ax.transAxes,
        )
        buf = io.BytesIO()
        fig.savefig(buf, format="png", bbox_inches="tight")
        plt.close(fig)
        return buf.getvalue()

    ticks = [int(r.get("tick", i)) for i, r in enumerate(rows)]
    fig, ax = plt.subplots(figsize=(8, 5), dpi=dpi)

    try:
        if plot_type == "phasespace":
            _plot_phasespace(
                ax,
                rows,
                plant_species_id=plant_species_id,
                herbivore_species_id=herbivore_species_id,
                flora_names=flora_names,
                herbivore_names=herbivore_names,
                title=title,
                x_label=x_label,
                y_label=y_label,
                x_max=x_max,
                y_max=y_max,
            )
        elif plot_type == "defense_economy":
            _plot_defense_economy(
                ax,
                rows,
                ticks,
                flora_names=flora_names,
                title=title,
                x_label=x_label,
                y_label=y_label,
            )
        elif plot_type == "biomass_stack":
            _plot_biomass_stack(
                ax,
                rows,
                ticks,
                flora_names=flora_names,
                title=title,
                x_label=x_label,
                y_label=y_label,
            )
        elif plot_type == "survival_probability":
            _plot_survival_probability(
                ax,
                rows,
                ticks,
                title=title,
                x_label=x_label,
                y_label=y_label,
            )
        elif plot_type == "timeseries":
            _plot_timeseries(
                ax,
                rows,
                ticks,
                flora_names=flora_names,
                herbivore_names=herbivore_names,
                title=title,
                x_label=x_label,
                y_label=y_label,
            )
        else:
            raise ValueError(f"Unknown plot_type: {plot_type}")
    except ValueError:
        plt.close(fig)
        raise
    except Exception as exc:
        logger.exception("matplotlib render failed")
        ax.clear()
        ax.text(
            0.5,
            0.5,
            f"Render Error: {exc}",
            horizontalalignment="center",
            verticalalignment="center",
            transform=ax.transAxes,
            color="red",
        )

    fig.tight_layout()
    buf = io.BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight")
    plt.close(fig)
    return buf.getvalue()

phids.telemetry.export.structured

Telemetry export to structured formats.

Exports telemetry dataframes into structured formats such as CSV and NDJSON.

export_bytes_csv(df: pl.DataFrame) -> bytes

Return the telemetry DataFrame serialized as CSV bytes.

Parameters:

Name Type Description Default
df DataFrame

Polars DataFrame to serialize.

required

Returns:

Name Type Description
bytes bytes

CSV-encoded bytes.

Source code in src/phids/telemetry/export/structured.py
def export_bytes_csv(df: pl.DataFrame) -> bytes:
    """Return the telemetry DataFrame serialized as CSV bytes.

    Args:
        df: Polars DataFrame to serialize.

    Returns:
        bytes: CSV-encoded bytes.

    """
    return df.write_csv().encode()

export_bytes_json(df: pl.DataFrame) -> bytes

Return the telemetry DataFrame serialized as NDJSON bytes.

Parameters:

Name Type Description Default
df DataFrame

Polars DataFrame to serialize.

required

Returns:

Name Type Description
bytes bytes

NDJSON-encoded bytes.

Source code in src/phids/telemetry/export/structured.py
def export_bytes_json(df: pl.DataFrame) -> bytes:
    """Return the telemetry DataFrame serialized as NDJSON bytes.

    Args:
        df: Polars DataFrame to serialize.

    Returns:
        bytes: NDJSON-encoded bytes.

    """
    return df.write_ndjson().encode()

export_csv(df: pl.DataFrame, path: str | Path) -> None

Write the telemetry DataFrame to a CSV file.

Parameters:

Name Type Description Default
df DataFrame

Polars DataFrame produced by the telemetry recorder.

required
path str | Path

Destination file path.

required
Source code in src/phids/telemetry/export/structured.py
def export_csv(df: pl.DataFrame, path: str | Path) -> None:
    """Write the telemetry DataFrame to a CSV file.

    Args:
        df: Polars DataFrame produced by the telemetry recorder.
        path: Destination file path.

    """
    df.write_csv(str(path))

export_json(df: pl.DataFrame, path: str | Path) -> None

Write the telemetry DataFrame to a newline-delimited JSON file.

Parameters:

Name Type Description Default
df DataFrame

Polars DataFrame produced by the telemetry recorder.

required
path str | Path

Destination file path.

required
Source code in src/phids/telemetry/export/structured.py
def export_json(df: pl.DataFrame, path: str | Path) -> None:
    """Write the telemetry DataFrame to a newline-delimited JSON file.

    Args:
        df: Polars DataFrame produced by the telemetry recorder.
        path: Destination file path.

    """
    df.write_ndjson(str(path))

phids.telemetry.export.tikz

Telemetry export to PGFPlots TikZ code.

Generates self-contained tikzpicture environments containing PGFPlots chart specifications for LaTeX papers (time series, phase spaces, defense economy ratio, biomass stack, survival probability).

generate_tikz_str(rows: TelemetryRows, plot_type: str = 'timeseries', *, flora_names: dict[int, str] | None = None, herbivore_names: dict[int, str] | None = None, plant_species_id: int = 0, herbivore_species_id: int = 0, include_flora_ids: str | None = None, include_herbivore_ids: str | None = None, title: str | None = None, x_label: str | None = None, y_label: str | None = None, x_max: float | None = None, y_max: float | None = None) -> str

Generate a PGFPlots LaTeX source string for publication-quality figures.

Produces a self-contained tikzpicture environment using the pgfplots package. The output does not require the tikzplotlib library; instead, coordinates are injected directly into \\addplot commands via a \\pgfplotstable-compatible inline coordinate format. This approach ensures compatibility with any LaTeX installation providing pgfplots >= 1.16.

The generated code is intended for compilation with pdflatex, xelatex, or lualatex after pasting into a document preamble that includes \\usepackage{pgfplots} and \\pgfplotsset{compat=1.18}.

Parameters:

Name Type Description Default
rows TelemetryRows

Raw telemetry rows from TelemetryRecorder._rows.

required
plot_type str

Chart mode - "timeseries", "phasespace", "defense_economy", "biomass_stack", or "survival_probability".

'timeseries'
flora_names dict[int, str] | None

Optional display names keyed by flora species id.

None
herbivore_names dict[int, str] | None

Optional display names keyed by herbivore species id.

None
plant_species_id int

Flora species id for phase-space x-axis.

0
herbivore_species_id int

Herbivore species id for phase-space y-axis.

0
include_flora_ids str | None

Optional comma-separated list of flora species IDs to filter.

None
include_herbivore_ids str | None

Optional comma-separated list of herbivore species IDs to filter.

None
title str | None

Optional custom chart title.

None
x_label str | None

Optional custom x-axis label.

None
x_max float | None

Optional custom x-axis maximum value.

None
y_label str | None

Optional custom y-axis label.

None
y_max float | None

Optional custom y-axis maximum value.

None

Returns:

Type Description
str

LaTeX source code for a complete tikzpicture environment.

Raises:

Type Description
ValueError

If plot_type is not a supported chart mode.

Source code in src/phids/telemetry/export/tikz.py
def generate_tikz_str(
    rows: TelemetryRows,
    plot_type: str = "timeseries",
    *,
    flora_names: dict[int, str] | None = None,
    herbivore_names: dict[int, str] | None = None,
    plant_species_id: int = 0,
    herbivore_species_id: int = 0,
    include_flora_ids: str | None = None,
    include_herbivore_ids: str | None = None,
    title: str | None = None,
    x_label: str | None = None,
    y_label: str | None = None,
    x_max: float | None = None,
    y_max: float | None = None,
) -> str:
    r"""Generate a PGFPlots LaTeX source string for publication-quality figures.

    Produces a self-contained ``tikzpicture`` environment using the ``pgfplots``
    package. The output does not require the ``tikzplotlib`` library; instead,
    coordinates are injected directly into ``\\addplot`` commands via a
    ``\\pgfplotstable``-compatible inline coordinate format. This approach ensures
    compatibility with any LaTeX installation providing ``pgfplots >= 1.16``.

    The generated code is intended for compilation with ``pdflatex``, ``xelatex``,
    or ``lualatex`` after pasting into a document preamble that includes
    ``\\usepackage{pgfplots}`` and ``\\pgfplotsset{compat=1.18}``.

    Args:
        rows: Raw telemetry rows from ``TelemetryRecorder._rows``.
        plot_type: Chart mode - ``"timeseries"``, ``"phasespace"``,
            ``"defense_economy"``, ``"biomass_stack"``, or
            ``"survival_probability"``.
        flora_names: Optional display names keyed by flora species id.
        herbivore_names: Optional display names keyed by herbivore species id.
        plant_species_id: Flora species id for phase-space x-axis.
        herbivore_species_id: Herbivore species id for phase-space y-axis.
        include_flora_ids: Optional comma-separated list of flora species IDs to filter.
        include_herbivore_ids: Optional comma-separated list of herbivore species IDs to filter.
        title: Optional custom chart title.
        x_label: Optional custom x-axis label.
        x_max: Optional custom x-axis maximum value.
        y_label: Optional custom y-axis label.
        y_max: Optional custom y-axis maximum value.

    Returns:
        LaTeX source code for a complete ``tikzpicture`` environment.

    Raises:
        ValueError: If ``plot_type`` is not a supported chart mode.
    """
    flora_filter = include_flora_ids
    herbivore_filter = include_herbivore_ids
    if plot_type == "phasespace":
        flora_filter = _append_species_id(flora_filter, plant_species_id)
        herbivore_filter = _append_species_id(herbivore_filter, herbivore_species_id)
    plot_rows = filter_telemetry_rows(rows, flora_ids=flora_filter, herbivore_ids=herbivore_filter)
    if plot_type == "timeseries":
        return _tikz_timeseries(
            plot_rows,
            flora_names=flora_names,
            herbivore_names=herbivore_names,
            title=title,
            x_label=x_label,
            y_label=y_label,
        )
    if plot_type == "phasespace":
        return _tikz_phasespace(
            plot_rows,
            plant_species_id=plant_species_id,
            herbivore_species_id=herbivore_species_id,
            flora_names=flora_names,
            herbivore_names=herbivore_names,
            title=title,
            x_label=x_label,
            y_label=y_label,
            x_max=x_max,
            y_max=y_max,
        )
    if plot_type == "defense_economy":
        return _tikz_defense_economy(
            plot_rows,
            flora_names=flora_names,
            title=title,
            x_label=x_label,
            y_label=y_label,
        )
    if plot_type == "biomass_stack":
        return _tikz_biomass_stack(
            plot_rows,
            flora_names=flora_names,
            title=title,
            x_label=x_label,
            y_label=y_label,
        )
    if plot_type == "survival_probability":
        return _tikz_survival_probability(
            plot_rows,
            title=title,
            x_label=x_label,
            y_label=y_label,
        )
    raise ValueError(f"Unknown tikz plot type: {plot_type}")

phids.io.zarr_replay

High-performance chunked replay storage using Zarr for PHIDS simulation snapshots.

This module implements a Zarr-based replay backend that provides identical ReplayBuffer API semantics while achieving superior memory efficiency and I/O performance for large-scale simulations. Zarr's chunked columnar storage model naturally maps to PHIDS field layers (plant energy per species, signal concentrations, toxin fields, flow-field gradients), enabling selective decompression and random access without materializing entire snapshots into Python memory.

The Zarr schema employs fixed-depth chunking across the spatial and temporal dimensions, with metadata groups for tick counters and termination state. Metadata (tick index, termination flags) is stored in a separate consolidated JSON array, eliminating the need to read field chunks for iteration or seeking. Field chunks are compressed using Zstd, offering superior compression ratio and speed compared to default Zarr Blosc profiles when applied to dense floating-point arrays. Subnormal tails (values < 1e-4) in signal layers are omitted during serialization via a custom codec or post-hoc masking, further reducing storage overhead without loss of ecological fidelity.

The design aligns with the project's strict data-oriented paradigm: snapshots are decomposed into structured field arrays during checkpoint and reassembled into the dictionary format for re-simulation compatibility. All serialization and field codec logic is stateless, ensuring deterministic round-trip fidelity.

NoOpReplayBuffer

A no-op replay buffer that does not store or write any frames, preventing disk usage during tuning.

Source code in src/phids/io/zarr_replay.py
class NoOpReplayBuffer:
    """A no-op replay buffer that does not store or write any frames, preventing disk usage during tuning."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the NoOpReplayBuffer (does nothing).

        Args:
            args: Positional arguments (ignored).
            kwargs: Keyword arguments (ignored).
        """
        pass

    def append(self, state: object) -> None:
        """Append a state snapshot to the buffer (no-op).

        Args:
            state: The simulation state object to append.
        """
        pass

    def append_raw_arrays(self, *args: Any, **kwargs: Any) -> None:
        """Append raw environment arrays to the buffer (no-op).

        Args:
            args: Positional arguments (ignored).
            kwargs: Keyword arguments (ignored).
        """
        pass

    def __len__(self) -> int:
        """Return the number of frames in the buffer (always 0)."""
        return 0

__init__(*args: Any, **kwargs: Any) -> None

Initialize the NoOpReplayBuffer (does nothing).

Parameters:

Name Type Description Default
args Any

Positional arguments (ignored).

()
kwargs Any

Keyword arguments (ignored).

{}
Source code in src/phids/io/zarr_replay.py
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the NoOpReplayBuffer (does nothing).

    Args:
        args: Positional arguments (ignored).
        kwargs: Keyword arguments (ignored).
    """
    pass

__len__() -> int

Return the number of frames in the buffer (always 0).

Source code in src/phids/io/zarr_replay.py
def __len__(self) -> int:
    """Return the number of frames in the buffer (always 0)."""
    return 0

append(state: object) -> None

Append a state snapshot to the buffer (no-op).

Parameters:

Name Type Description Default
state object

The simulation state object to append.

required
Source code in src/phids/io/zarr_replay.py
def append(self, state: object) -> None:
    """Append a state snapshot to the buffer (no-op).

    Args:
        state: The simulation state object to append.
    """
    pass

append_raw_arrays(*args: Any, **kwargs: Any) -> None

Append raw environment arrays to the buffer (no-op).

Parameters:

Name Type Description Default
args Any

Positional arguments (ignored).

()
kwargs Any

Keyword arguments (ignored).

{}
Source code in src/phids/io/zarr_replay.py
def append_raw_arrays(self, *args: Any, **kwargs: Any) -> None:
    """Append raw environment arrays to the buffer (no-op).

    Args:
        args: Positional arguments (ignored).
        kwargs: Keyword arguments (ignored).
    """
    pass

ReplayBuffer

Append-only Zarr-backed buffer of chunked tick frames.

Provides backwards-compatible ReplayBuffer API semantics over a Zarr store.

The schema is laid out as:

  • zarr.root['_metadata']: JSON array of tick/termination metadata
  • zarr.root['fields/{field_name}/data']: Chunked field array
Source code in src/phids/io/zarr_replay.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
class ReplayBuffer:
    """Append-only Zarr-backed buffer of chunked tick frames.

    Provides backwards-compatible `ReplayBuffer` API semantics over a Zarr store.

    The schema is laid out as:

    - ``zarr.root['_metadata']``: JSON array of tick/termination metadata
    - ``zarr.root['fields/{field_name}/data']``: Chunked field array
    """

    def __init__(
        self,
        max_frames: int | None = None,
        *,
        spill_to_disk: bool = False,  # noqa: ARG002  # Ignored for Zarr; included for drop-in compatibility
        spill_path: str | Path | None = None,
    ) -> None:
        """Create or open a Zarr replay buffer.

        Args:
            max_frames: Optional upper bound on retained frames. When set and greater
                than zero, only the most recent ``max_frames`` snapshots are retained
                in the Zarr store. Older frames are automatically pruned during append.
                If ``None``, all frames are retained indefinitely.
            spill_to_disk: Accepted for API compatibility but has no effect; Zarr
                storage is always disk-backed.
            spill_path: Optional explicit path for the Zarr store. If omitted,
                a temporary directory is allocated lazily.
        """
        self._store_path: Path | None = Path(spill_path) if spill_path is not None else None
        self._owns_store = spill_path is None
        self._max_frames = max_frames if max_frames is None else max(1, int(max_frames))
        self._metadata: list[_MetadataEntry] = []
        self._root: zarr.Group | None = None
        self._frame_count: int = 0
        self._frame_offset: int = 0  # Index of oldest retained frame in Zarr store

    def _coerce_metadata_entries(self, payload: object) -> list[_MetadataEntry]:
        """Return metadata entries that match the persisted per-frame metadata schema.

        Args:
            payload: The payload to coerce.

        Returns:
            The coerced metadata entries.
        """
        if not isinstance(payload, list):
            return []
        entries: list[_MetadataEntry] = []
        for item in payload:
            if not isinstance(item, dict):
                continue
            tick = item.get("tick")
            terminated = item.get("terminated")
            termination_reason = item.get("termination_reason")
            if not isinstance(tick, int) or not isinstance(terminated, bool):
                continue
            if termination_reason is not None and not isinstance(termination_reason, str):
                continue
            entries.append(
                {
                    "tick": tick,
                    "terminated": terminated,
                    "termination_reason": termination_reason,
                }
            )
        return entries

    def _decode_metadata_bytes(self, payload: object) -> bytes:
        """Return byte payload for persisted metadata arrays.

        The `_metadata` Zarr node is expected to store a uint8 array containing
        UTF-8 JSON bytes. This helper performs explicit runtime narrowing before
        conversion so callers avoid unchecked indexing/type-ignore patterns.

        Args:
            payload: The payload to decode.

        Returns:
            The decoded byte payload.
        """
        if not isinstance(payload, zarr.Array):
            raise TypeError("_metadata node is not a Zarr array")
        array_obj = payload
        return bytes(np.asarray(array_obj[:], dtype=np.uint8).tolist())

    def _ensure_store(self) -> zarr.Group:
        """Lazily create or open the Zarr store group.

        Returns:
            zarr.Group: The Zarr store group.
        """
        if self._root is not None:
            return self._root

        if self._store_path is None:
            temp_dir = Path(tempfile.gettempdir())
            self._store_path = temp_dir / f"phids_replay_zarr_{uuid.uuid4().hex}.zarr"
            if self._owns_store:
                atexit.register(self._cleanup_store)

        self._store_path.mkdir(parents=True, exist_ok=True)
        self._root = zarr.open_group(str(self._store_path), mode="a")
        self._load_metadata()
        return self._root

    def _parse_metadata_obj(self, meta_obj: object) -> None:
        """Parse the metadata object and update the replay buffer's metadata.

        Args:
            meta_obj: The metadata object to parse.
        """
        if isinstance(meta_obj, list):
            self._metadata = self._coerce_metadata_entries(meta_obj)
            self._frame_offset = 0
        elif isinstance(meta_obj, dict) and "_metadata" in meta_obj:
            self._metadata = self._coerce_metadata_entries(meta_obj["_metadata"])
            frame_offset = meta_obj.get("_frame_offset", 0)
            self._frame_offset = frame_offset if isinstance(frame_offset, int) else 0
        self._frame_count = len(self._metadata) + self._frame_offset

    def _load_metadata(self) -> None:
        """Load metadata array from Zarr store if it exists."""
        root = self._ensure_store()
        if "_metadata" in root:
            try:
                meta_bytes = self._decode_metadata_bytes(root["_metadata"])
                meta_str = meta_bytes.decode("utf-8")
                meta_obj = json.loads(meta_str)
                self._parse_metadata_obj(meta_obj)
            except Exception as e:
                logger.warning("Failed to load metadata from Zarr store: %s", e)
                # Fall back to scanning for frame groups
                self._metadata = []
                self._frame_offset = 0
                frame_idx = 0
                while f"frames/{frame_idx:08d}" in root:
                    frame_idx += 1
                self._frame_count = frame_idx
        else:
            # Scan for frame groups if no metadata exists
            self._metadata = []
            self._frame_offset = 0
            frame_idx = 0
            while f"frames/{frame_idx:08d}" in root:
                frame_idx += 1
            self._frame_count = frame_idx

    def _save_metadata(self) -> None:
        """Persist metadata array to Zarr store."""
        root = self._ensure_store()
        # Store both metadata and frame offset
        meta_obj = {
            "_metadata": self._metadata,
            "_frame_offset": self._frame_offset,
        }
        meta_str = json.dumps(meta_obj)
        meta_bytes = np.frombuffer(meta_str.encode("utf-8"), dtype=np.uint8)

        if "_metadata" in root:
            del root["_metadata"]
        root.create_array(
            "_metadata",
            data=meta_bytes,
            chunks=(len(meta_bytes),),
            compressors=(zarr.codecs.ZstdCodec(level=10),),
        )

    def append(self, state: ReplayState) -> None:
        """Serialize and append a tick state to the buffer.

        Decomposes the state dict into field arrays, storing each as a
        chunked Zarr dataset. Metadata (tick, termination) is stored separately.

        Args:
            state: Tick state mapping (e.g., from ``SimulationLoop.get_state_snapshot()``).
        """
        tick_value = state.get("tick", self._frame_count)
        terminated_value = state.get("terminated", False)
        termination_reason_value = state.get("termination_reason", None)
        self._append_fields(
            tick=int(tick_value) if isinstance(tick_value, (int, float, str)) else self._frame_count,
            terminated=bool(terminated_value),
            termination_reason=(termination_reason_value if isinstance(termination_reason_value, str) else None),
            fields={
                field_name: field_data
                for field_name, field_data in state.items()
                if field_name not in ("tick", "terminated", "termination_reason")
            },
        )

    def append_raw_arrays(
        self,
        *,
        tick: int,
        env: _ReplayEnvLike,
        termination_state: tuple[bool, str | None],
    ) -> None:
        """Append replay frame directly from environment NumPy arrays.

        Args:
            tick: Current simulation tick.
            env: Grid environment exposing replay layer arrays.
            termination_state: Tuple ``(terminated, termination_reason)``.
        """
        terminated, termination_reason = termination_state
        self._append_fields(
            tick=tick,
            terminated=terminated,
            termination_reason=termination_reason,
            fields={
                "plant_energy_layer": env.plant_energy_layer,
                "signal_layers": env.signal_layers,
                "toxin_layers": env.toxin_layers,
                "flow_field": env.flow_field,
                "wind_vector_x": env.wind_vector_x,
                "wind_vector_y": env.wind_vector_y,
            },
        )

    def _append_fields(
        self,
        *,
        tick: int,
        terminated: bool,
        termination_reason: str | None,
        fields: dict[str, ReplayValue | np.ndarray],
    ) -> None:
        """Persist one frame's metadata and field payloads into the store.

        Args:
            tick: The current tick.
            terminated: Whether the simulation is terminated.
            termination_reason: The reason for termination.
            fields: The fields to store.
        """
        root = self._ensure_store()

        metadata_entry: _MetadataEntry = {
            "tick": int(tick),
            "terminated": bool(terminated),
            "termination_reason": termination_reason,
        }
        self._metadata.append(metadata_entry)

        frame_key = f"frames/{self._frame_count:08d}"
        if frame_key not in root:
            root.create_group(frame_key)
        frame_group = cast("zarr.Group", root[frame_key])

        for field_name, field_data in fields.items():
            self._store_field(frame_group, field_name, field_data)

        self._frame_count += 1
        self._save_metadata()

        if self._max_frames is not None and len(self._metadata) > self._max_frames:
            frames_to_drop = len(self._metadata) - self._max_frames
            for i in range(frames_to_drop):
                self._metadata.pop(0)
                old_frame_key = f"frames/{self._frame_offset + i:08d}"
                if old_frame_key in root:
                    del root[old_frame_key]
                    logger.debug("Pruned frame %d (retention policy)", self._frame_offset + i)
            self._frame_offset += frames_to_drop
            self._save_metadata()

    def _store_field(
        self,
        frame_group: zarr.Group,
        field_name: str,
        field_data: ReplayValue | np.ndarray,
    ) -> None:
        """Store a single field (array or nested list) into the frame group.

        Args:
            frame_group: The frame group to store the field in.
            field_name: The name of the field.
            field_data: The field data to store.
        """
        if isinstance(field_data, (list, tuple)):
            field_data = np.asarray(field_data, dtype=np.float32)
        elif isinstance(field_data, np.ndarray):
            if field_data.dtype != np.float32:
                field_data = field_data.astype(np.float32)

        if not isinstance(field_data, np.ndarray):
            # Store scalar or string as JSON metadata
            if field_name not in frame_group.attrs:
                frame_group.attrs[field_name] = field_data
            return

        # Determine chunk size (aim for ~1 MB chunks)
        total_elements = int(np.prod(field_data.shape))
        chunk_elements = max(1, min(total_elements, 256_000))  # ~1 MB at float32
        chunk_shape = tuple(
            min(s, max(1, chunk_elements // int(np.prod(field_data.shape[1:])))) if i == 0 else s
            for i, s in enumerate(field_data.shape)
        )

        # Truncate subnormal signal tails
        if "signal" in field_name.lower():
            field_data = np.where(np.abs(field_data) < 1e-4, 0.0, field_data)

        if field_name in frame_group:
            del frame_group[field_name]

        frame_group.create_array(
            field_name,
            data=field_data,
            chunks=chunk_shape,
            compressors=(zarr.codecs.ZstdCodec(level=10),),
        )

    def __len__(self) -> int:
        """Return total number of retained frames.

        Returns:
            Total number of retained frames.
        """
        return len(self._metadata)

    def get_frame(self, tick: int) -> ReplayState:
        """Return the deserialized state for the specified frame index.

        Args:
            tick: Index of the frame to retrieve (0-based).

        Returns:
            ReplayState: Reconstructed state mapping.

        Raises:
            IndexError: If the tick is out of range.
        """
        if tick < 0 or tick >= len(self._metadata):
            raise IndexError(f"Replay frame index out of range: {tick} (total frames={len(self._metadata)})")

        root = self._ensure_store()
        # Calculate actual frame index in Zarr store (accounting for offset)
        actual_frame_idx = self._frame_offset + tick
        frame_key = f"frames/{actual_frame_idx:08d}"
        if frame_key not in root:
            raise IndexError(f"Frame {tick} not found in Zarr store (key: {frame_key})")

        frame_group = cast("zarr.Group", root[frame_key])
        state: ReplayState = {}

        # Restore metadata
        if tick < len(self._metadata):
            metadata = self._metadata[tick]
            state["tick"] = metadata["tick"]
            state["terminated"] = metadata["terminated"]
            state["termination_reason"] = metadata["termination_reason"]

        # Restore field arrays
        try:
            for field_name in frame_group.array_keys():
                field_obj = frame_group[field_name]
                if not isinstance(field_obj, zarr.Array):
                    continue
                array_data = np.asarray(field_obj[:])
                # Convert back to native Python lists for compatibility
                state[field_name] = cast("ReplayValue", array_data.tolist())
        except (AttributeError, TypeError):
            pass

        # Restore scalar/string attributes
        try:
            for attr_name, attr_value in frame_group.attrs.items():
                if attr_name not in state:
                    state[attr_name] = cast("ReplayValue", attr_value)
        except (AttributeError, TypeError):
            pass

        return state

    def save(self, path: str | Path) -> None:
        """Export the Zarr store to a standalone file for external storage.

        This creates a snapshot of the current Zarr store by consolidating
        metadata and copying the store directory.

        Args:
            path: Destination file path (or directory for full export).
        """
        destination = Path(path)
        if destination.suffix == ".zarr":
            import shutil

            if destination.exists():
                shutil.rmtree(destination)
            if self._store_path is not None:
                shutil.copytree(str(self._store_path), str(destination))
            logger.info("Zarr replay exported to %s (frames=%d)", destination, self._frame_count)
        else:
            raise ValueError("Zarr export requires a .zarr destination directory")

    @classmethod
    def load(cls, path: str | Path) -> ReplayBuffer:
        """Load a Zarr replay store.

        If the path is a .zarr directory, opens it directly.

        Args:
            path: Path to the Zarr directory.

        Returns:
            Zarr replay buffer attached to the loaded directory.
        """
        source = Path(path)

        if not source.exists() or not source.is_dir():
            raise ValueError(f"Replay path must be an existing directory: {path}")

        buf = cls(spill_path=source)
        # Ensure metadata is loaded before returning
        buf._ensure_store()
        buf._load_metadata()
        logger.info("Zarr replay loaded from %s (%d frames)", source, len(buf))
        return buf

    def _cleanup_store(self) -> None:
        """Remove owned Zarr store on interpreter shutdown."""
        if self._store_path is None or not self._owns_store:
            return
        try:
            import shutil

            shutil.rmtree(self._store_path, ignore_errors=True)
        except OSError:
            logger.debug("Zarr replay cleanup skipped for %s", self._store_path)

__init__(max_frames: int | None = None, *, spill_to_disk: bool = False, spill_path: str | Path | None = None) -> None

Create or open a Zarr replay buffer.

Parameters:

Name Type Description Default
max_frames int | None

Optional upper bound on retained frames. When set and greater than zero, only the most recent max_frames snapshots are retained in the Zarr store. Older frames are automatically pruned during append. If None, all frames are retained indefinitely.

None
spill_to_disk bool

Accepted for API compatibility but has no effect; Zarr storage is always disk-backed.

False
spill_path str | Path | None

Optional explicit path for the Zarr store. If omitted, a temporary directory is allocated lazily.

None
Source code in src/phids/io/zarr_replay.py
def __init__(
    self,
    max_frames: int | None = None,
    *,
    spill_to_disk: bool = False,  # noqa: ARG002  # Ignored for Zarr; included for drop-in compatibility
    spill_path: str | Path | None = None,
) -> None:
    """Create or open a Zarr replay buffer.

    Args:
        max_frames: Optional upper bound on retained frames. When set and greater
            than zero, only the most recent ``max_frames`` snapshots are retained
            in the Zarr store. Older frames are automatically pruned during append.
            If ``None``, all frames are retained indefinitely.
        spill_to_disk: Accepted for API compatibility but has no effect; Zarr
            storage is always disk-backed.
        spill_path: Optional explicit path for the Zarr store. If omitted,
            a temporary directory is allocated lazily.
    """
    self._store_path: Path | None = Path(spill_path) if spill_path is not None else None
    self._owns_store = spill_path is None
    self._max_frames = max_frames if max_frames is None else max(1, int(max_frames))
    self._metadata: list[_MetadataEntry] = []
    self._root: zarr.Group | None = None
    self._frame_count: int = 0
    self._frame_offset: int = 0  # Index of oldest retained frame in Zarr store

__len__() -> int

Return total number of retained frames.

Returns:

Type Description
int

Total number of retained frames.

Source code in src/phids/io/zarr_replay.py
def __len__(self) -> int:
    """Return total number of retained frames.

    Returns:
        Total number of retained frames.
    """
    return len(self._metadata)

append(state: ReplayState) -> None

Serialize and append a tick state to the buffer.

Decomposes the state dict into field arrays, storing each as a chunked Zarr dataset. Metadata (tick, termination) is stored separately.

Parameters:

Name Type Description Default
state ReplayState

Tick state mapping (e.g., from SimulationLoop.get_state_snapshot()).

required
Source code in src/phids/io/zarr_replay.py
def append(self, state: ReplayState) -> None:
    """Serialize and append a tick state to the buffer.

    Decomposes the state dict into field arrays, storing each as a
    chunked Zarr dataset. Metadata (tick, termination) is stored separately.

    Args:
        state: Tick state mapping (e.g., from ``SimulationLoop.get_state_snapshot()``).
    """
    tick_value = state.get("tick", self._frame_count)
    terminated_value = state.get("terminated", False)
    termination_reason_value = state.get("termination_reason", None)
    self._append_fields(
        tick=int(tick_value) if isinstance(tick_value, (int, float, str)) else self._frame_count,
        terminated=bool(terminated_value),
        termination_reason=(termination_reason_value if isinstance(termination_reason_value, str) else None),
        fields={
            field_name: field_data
            for field_name, field_data in state.items()
            if field_name not in ("tick", "terminated", "termination_reason")
        },
    )

append_raw_arrays(*, tick: int, env: _ReplayEnvLike, termination_state: tuple[bool, str | None]) -> None

Append replay frame directly from environment NumPy arrays.

Parameters:

Name Type Description Default
tick int

Current simulation tick.

required
env _ReplayEnvLike

Grid environment exposing replay layer arrays.

required
termination_state tuple[bool, str | None]

Tuple (terminated, termination_reason).

required
Source code in src/phids/io/zarr_replay.py
def append_raw_arrays(
    self,
    *,
    tick: int,
    env: _ReplayEnvLike,
    termination_state: tuple[bool, str | None],
) -> None:
    """Append replay frame directly from environment NumPy arrays.

    Args:
        tick: Current simulation tick.
        env: Grid environment exposing replay layer arrays.
        termination_state: Tuple ``(terminated, termination_reason)``.
    """
    terminated, termination_reason = termination_state
    self._append_fields(
        tick=tick,
        terminated=terminated,
        termination_reason=termination_reason,
        fields={
            "plant_energy_layer": env.plant_energy_layer,
            "signal_layers": env.signal_layers,
            "toxin_layers": env.toxin_layers,
            "flow_field": env.flow_field,
            "wind_vector_x": env.wind_vector_x,
            "wind_vector_y": env.wind_vector_y,
        },
    )

get_frame(tick: int) -> ReplayState

Return the deserialized state for the specified frame index.

Parameters:

Name Type Description Default
tick int

Index of the frame to retrieve (0-based).

required

Returns:

Name Type Description
ReplayState ReplayState

Reconstructed state mapping.

Raises:

Type Description
IndexError

If the tick is out of range.

Source code in src/phids/io/zarr_replay.py
def get_frame(self, tick: int) -> ReplayState:
    """Return the deserialized state for the specified frame index.

    Args:
        tick: Index of the frame to retrieve (0-based).

    Returns:
        ReplayState: Reconstructed state mapping.

    Raises:
        IndexError: If the tick is out of range.
    """
    if tick < 0 or tick >= len(self._metadata):
        raise IndexError(f"Replay frame index out of range: {tick} (total frames={len(self._metadata)})")

    root = self._ensure_store()
    # Calculate actual frame index in Zarr store (accounting for offset)
    actual_frame_idx = self._frame_offset + tick
    frame_key = f"frames/{actual_frame_idx:08d}"
    if frame_key not in root:
        raise IndexError(f"Frame {tick} not found in Zarr store (key: {frame_key})")

    frame_group = cast("zarr.Group", root[frame_key])
    state: ReplayState = {}

    # Restore metadata
    if tick < len(self._metadata):
        metadata = self._metadata[tick]
        state["tick"] = metadata["tick"]
        state["terminated"] = metadata["terminated"]
        state["termination_reason"] = metadata["termination_reason"]

    # Restore field arrays
    try:
        for field_name in frame_group.array_keys():
            field_obj = frame_group[field_name]
            if not isinstance(field_obj, zarr.Array):
                continue
            array_data = np.asarray(field_obj[:])
            # Convert back to native Python lists for compatibility
            state[field_name] = cast("ReplayValue", array_data.tolist())
    except (AttributeError, TypeError):
        pass

    # Restore scalar/string attributes
    try:
        for attr_name, attr_value in frame_group.attrs.items():
            if attr_name not in state:
                state[attr_name] = cast("ReplayValue", attr_value)
    except (AttributeError, TypeError):
        pass

    return state

load(path: str | Path) -> ReplayBuffer classmethod

Load a Zarr replay store.

If the path is a .zarr directory, opens it directly.

Parameters:

Name Type Description Default
path str | Path

Path to the Zarr directory.

required

Returns:

Type Description
ReplayBuffer

Zarr replay buffer attached to the loaded directory.

Source code in src/phids/io/zarr_replay.py
@classmethod
def load(cls, path: str | Path) -> ReplayBuffer:
    """Load a Zarr replay store.

    If the path is a .zarr directory, opens it directly.

    Args:
        path: Path to the Zarr directory.

    Returns:
        Zarr replay buffer attached to the loaded directory.
    """
    source = Path(path)

    if not source.exists() or not source.is_dir():
        raise ValueError(f"Replay path must be an existing directory: {path}")

    buf = cls(spill_path=source)
    # Ensure metadata is loaded before returning
    buf._ensure_store()
    buf._load_metadata()
    logger.info("Zarr replay loaded from %s (%d frames)", source, len(buf))
    return buf

save(path: str | Path) -> None

Export the Zarr store to a standalone file for external storage.

This creates a snapshot of the current Zarr store by consolidating metadata and copying the store directory.

Parameters:

Name Type Description Default
path str | Path

Destination file path (or directory for full export).

required
Source code in src/phids/io/zarr_replay.py
def save(self, path: str | Path) -> None:
    """Export the Zarr store to a standalone file for external storage.

    This creates a snapshot of the current Zarr store by consolidating
    metadata and copying the store directory.

    Args:
        path: Destination file path (or directory for full export).
    """
    destination = Path(path)
    if destination.suffix == ".zarr":
        import shutil

        if destination.exists():
            shutil.rmtree(destination)
        if self._store_path is not None:
            shutil.copytree(str(self._store_path), str(destination))
        logger.info("Zarr replay exported to %s (frames=%d)", destination, self._frame_count)
    else:
        raise ValueError("Zarr export requires a .zarr destination directory")

Integration

phids.mcp_server

Model Context Protocol surface for autonomous PHIDS orchestration.

Exposes read-only simulation states as structural resources and provides agentic tools for system validation and telemetry inspection without violating the engine's single-writer architecture.

Architecture overview

  • Resources - Declarative, passively-read context feeds that consuming agents can cache and reference without spending a tool-call budget.
  • Tools - Targeted execution primitives for read-only inspection, validation, and diagnostics.
  • Prompts - Pre-baked guidance fragments that wire the above surfaces into coherent agentic workflows.

The MCP server runs as a headless stdio process completely decoupled from the FastAPI HTTP layer. It may be launched independently via just mcp or programmatically via :func:run_mcp_server. No write paths into the engine state are exposed.

active_draft_resource() -> str

Provide the full, untruncated JSON layout of the active configuration draft.

Agents can read this resource directly to digest species mappings, substance definitions, trigger-rule trees, diet matrices, and termination thresholds without spending a tool-call budget on runtime_snapshot.

Returns:

Type Description
str

Indented JSON string of the current :class:~phids.api.ui_state.state.DraftState.

Source code in src/phids/mcp_server.py
@mcp.resource("phids://config/draft.json")
def active_draft_resource() -> str:
    """Provide the full, untruncated JSON layout of the active configuration draft.

    Agents can read this resource directly to digest species mappings, substance
    definitions, trigger-rule trees, diet matrices, and termination thresholds
    without spending a tool-call budget on ``runtime_snapshot``.

    Returns:
        Indented JSON string of the current :class:`~phids.api.ui_state.state.DraftState`.

    """
    return _draft_to_json(get_draft())

analyze_simulation_drift() -> str

Pre-configured prompt mapping to guide debugging agents through drift triage.

Returns:

Name Type Description
str str

Structured step-by-step investigation guide for stochastic drift

str

anomalies inside the PHIDS engine.

Source code in src/phids/mcp_server.py
@mcp.prompt()
def analyze_simulation_drift() -> str:
    """Pre-configured prompt mapping to guide debugging agents through drift triage.

    Returns:
        str: Structured step-by-step investigation guide for stochastic drift
        anomalies inside the PHIDS engine.

    """
    return (
        "You are tasked with evaluating a stochastic drift anomaly inside the PHIDS engine.\n\n"
        "Follow this triage protocol in order:\n"
        "1. Read `phids://config/draft.json` to establish full scenario context "
        "(species, substances, termination thresholds).\n"
        "2. Call `runtime_snapshot` to confirm active entity counts and Z-code thresholds "
        "match your expectations.\n"
        "3. Call `query_diagnostic_logs` (limit=120) and scan for WARNING/ERROR entries "
        "from `phids.engine.loop`, `phids.engine.systems.*`, or Numba compilation traces.\n"
        "4. Call `validate_okf_compliance` to verify no documentation invariants were "
        "silently broken by a recent schema mutation.\n"
        "5. If a Zarr replay buffer path is available, call `inspect_telemetry_schema` "
        "to confirm frame counts and field arrays are structurally intact.\n"
        "6. Cross-reference all findings. Propose concrete parameter remediation steps "
        "targeting the most probable root cause (seed entropy, flow-field boundary, "
        "or trigger-rule population threshold)."
    )

inspect_telemetry_schema(zarr_store_path: str) -> dict[str, Any]

Expose Zarr replay store structure to the agent without loading field arrays.

Allows autonomous MLOps operators to inspect frame counts, top-level tree keys, and store-level metadata before initiating a heavy Polars lazy-frame extraction. The store is opened read-only; no data is mutated.

Parameters:

Name Type Description Default
zarr_store_path str

Filesystem path to a PHIDS .zarr replay store directory.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: On success - status, store_path, frame_count,

dict[str, Any]

tree_keys, and store_attrs. On failure - status and

dict[str, Any]

message.

Source code in src/phids/mcp_server.py
@mcp.tool()
def inspect_telemetry_schema(zarr_store_path: str) -> dict[str, Any]:
    """Expose Zarr replay store structure to the agent without loading field arrays.

    Allows autonomous MLOps operators to inspect frame counts, top-level tree
    keys, and store-level metadata before initiating a heavy Polars lazy-frame
    extraction.  The store is opened read-only; no data is mutated.

    Args:
        zarr_store_path: Filesystem path to a PHIDS ``.zarr`` replay store
            directory.

    Returns:
        dict[str, Any]: On success - ``status``, ``store_path``, ``frame_count``,
        ``tree_keys``, and ``store_attrs``.  On failure - ``status`` and
        ``message``.
    """
    try:
        import numpy as np
        import zarr
    except ImportError as exc:  # pragma: no cover
        return {"status": "error", "message": f"Required package not available: {exc}"}

    store = Path(zarr_store_path)
    if not store.exists():
        return {
            "status": "error",
            "message": f"Store path does not exist: {zarr_store_path}",
        }

    try:
        root: zarr.Group = zarr.open_group(str(store), mode="r")
        tree_keys: list[str] = list(root.keys())

        # Derive frame count from the consolidated _metadata JSON array.
        frame_count: int = 0
        if "_metadata" in root:
            try:
                meta_node = cast("zarr.Array[Any]", root["_metadata"])
                meta_bytes = bytes(np.asarray(meta_node[:], dtype=np.uint8).tolist())
                meta_obj = json.loads(meta_bytes.decode("utf-8"))
                if isinstance(meta_obj, list):
                    frame_count = len(meta_obj)
                elif isinstance(meta_obj, dict) and "_metadata" in meta_obj:
                    inner = meta_obj["_metadata"]
                    frame_count = len(inner) if isinstance(inner, list) else 0
            except Exception:  # pragma: no cover - corrupt metadata
                frame_count = -1  # Corrupt metadata - indicate uncertainty

        store_attrs: dict[str, Any] = dict(root.attrs) if root.attrs else {}

        return {
            "status": "success",
            "store_path": str(store.resolve()),
            "frame_count": frame_count,
            "tree_keys": tree_keys,
            "store_attrs": store_attrs,
        }
    except Exception as exc:
        return {"status": "error", "message": f"Failed to read Zarr store: {exc}"}

query_batch_jobs() -> dict[str, Any]

Return a summary of active and completed batch jobs from the draft state.

Provides visibility into long-running exploration tasks or evolutionary exploration results.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Dictionary mapping job IDs to their state representations.

Source code in src/phids/mcp_server.py
@mcp.tool()
def query_batch_jobs() -> dict[str, Any]:
    """Return a summary of active and completed batch jobs from the draft state.

    Provides visibility into long-running exploration tasks or evolutionary
    exploration results.

    Returns:
        dict[str, Any]: Dictionary mapping job IDs to their state representations.
    """
    draft = get_draft()
    return {
        job_id: {
            "status": state.status,
            "completed_runs": state.completed,
            "total_runs": state.total,
            "finished_at": state.finished_at,
        }
        for job_id, state in draft.active_batch_jobs.items()
    }

query_diagnostic_logs(limit: int = 80) -> list[dict[str, str]]

Return the newest structured diagnostic entries recorded by PHIDS.

Entries are emitted by all engine, API, and telemetry loggers via the :class:~phids.shared.logging_config.InMemoryLogHandler ring buffer. Ordered most-recent-first.

Parameters:

Name Type Description Default
limit int

Maximum number of log rows to return (clamped to >= 1 internally).

80

Returns:

Type Description
list[dict[str, str]]

list[dict[str, str]]: Structured entries with timestamp, level,

list[dict[str, str]]

logger, module, and message keys.

Source code in src/phids/mcp_server.py
@mcp.tool()
def query_diagnostic_logs(limit: int = 80) -> list[dict[str, str]]:
    """Return the newest structured diagnostic entries recorded by PHIDS.

    Entries are emitted by all engine, API, and telemetry loggers via the
    :class:`~phids.shared.logging_config.InMemoryLogHandler` ring buffer.
    Ordered most-recent-first.

    Args:
        limit: Maximum number of log rows to return (clamped to >= 1 internally).

    Returns:
        list[dict[str, str]]: Structured entries with ``timestamp``, ``level``,
        ``logger``, ``module``, and ``message`` keys.
    """
    return get_recent_logs(limit=limit)

read_batch_summary(job_id: str) -> dict[str, Any]

Read the aggregated metrics inside a batch job's summary JSON file.

Allows agents to digest batch job metric summaries without manually loading JSON artifacts.

Parameters:

Name Type Description Default
job_id str

The ID of the batch job to read.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Dictionary containing the aggregated metrics on success,

dict[str, Any]

or an error message on failure.

Source code in src/phids/mcp_server.py
@mcp.tool()
def read_batch_summary(job_id: str) -> dict[str, Any]:
    """Read the aggregated metrics inside a batch job's summary JSON file.

    Allows agents to digest batch job metric summaries without manually
    loading JSON artifacts.

    Args:
        job_id: The ID of the batch job to read.

    Returns:
        dict[str, Any]: Dictionary containing the aggregated metrics on success,
        or an error message on failure.
    """
    summary_path = _PROJECT_ROOT / "data" / "batches" / f"{job_id}_summary.json"
    if not summary_path.exists():
        return {"status": "error", "message": f"Summary file not found: {summary_path}"}

    try:
        with open(summary_path, encoding="utf-8") as f:
            return {"status": "success", "data": json.load(f)}
    except Exception as exc:
        return {"status": "error", "message": f"Failed to read summary file: {exc}"}

run_mcp_server() -> None

Spawn the headless stdio MCP communications loop.

Source code in src/phids/mcp_server.py
def run_mcp_server() -> None:
    """Spawn the headless stdio MCP communications loop."""
    mcp.run()

runtime_snapshot() -> dict[str, Any]

Return a compact performance-and-counts summary of the active draft state.

Useful as a lightweight sanity check before heavier resource reads or batch operations. All counts reflect the in-memory singleton draft; no simulation loop is touched.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Compact read-only summary including scenario metadata,

dict[str, Any]

grid dimensions, entity counts, and active termination thresholds

dict[str, Any]

(Z-codes).

Source code in src/phids/mcp_server.py
@mcp.tool()
def runtime_snapshot() -> dict[str, Any]:
    """Return a compact performance-and-counts summary of the active draft state.

    Useful as a lightweight sanity check before heavier resource reads or batch
    operations.  All counts reflect the in-memory singleton draft; no simulation
    loop is touched.

    Returns:
        dict[str, Any]: Compact read-only summary including scenario metadata,
        grid dimensions, entity counts, and active termination thresholds
        (Z-codes).
    """
    draft = get_draft()
    return {
        "scenario_name": draft.scenario_name,
        "dimensions": f"{draft.grid_width}x{draft.grid_height}",
        "grid_width": draft.grid_width,
        "grid_height": draft.grid_height,
        "max_ticks": draft.max_ticks,
        "tick_rate_hz": draft.tick_rate_hz,
        "placement_mode": draft.placement_mode,
        "mycorrhizal_inter_species": draft.mycorrhizal_inter_species,
        "flora_species_count": len(draft.flora_species),
        "herbivore_species_count": len(draft.herbivore_species),
        "substance_definitions_count": len(draft.substance_definitions),
        "trigger_rules_count": len(draft.trigger_rules),
        "initial_plants_count": len(draft.initial_plants),
        "initial_swarms_count": len(draft.initial_swarms),
        "active_batch_jobs_count": len(draft.active_batch_jobs),
        "termination_thresholds": {
            "z2_flora_species_extinction": draft.z2_flora_species_extinction,
            "z4_herbivore_species_extinction": draft.z4_herbivore_species_extinction,
            "z6_max_total_flora_energy": draft.z6_max_total_flora_energy,
            "z7_max_total_herbivore_population": draft.z7_max_total_herbivore_population,
        },
    }

validate_okf_compliance() -> dict[str, Any]

Run the OKF knowledge-graph validation suite against the docs/ and .agents/ trees.

Invokes scripts/validate_okf.py via uv run from the project root, mirroring the pre-commit hook execution environment exactly. Essential for self-evolving agent loops to verify that documentation mutations remain structurally valid before opening a PR.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: compliant (bool), violations (list of extracted

dict[str, Any]

error lines), and output (full captured stdout+stderr).

Source code in src/phids/mcp_server.py
@mcp.tool()
def validate_okf_compliance() -> dict[str, Any]:
    """Run the OKF knowledge-graph validation suite against the docs/ and .agents/ trees.

    Invokes ``scripts/validate_okf.py`` via ``uv run`` from the project root,
    mirroring the pre-commit hook execution environment exactly.  Essential for
    self-evolving agent loops to verify that documentation mutations remain
    structurally valid before opening a PR.

    Returns:
        dict[str, Any]: ``compliant`` (bool), ``violations`` (list of extracted
        error lines), and ``output`` (full captured stdout+stderr).
    """
    uv_bin = shutil.which("uv") or "uv"

    try:
        result = subprocess.run(
            [uv_bin, "run", "python", "scripts/validate_okf.py"],
            cwd=_PROJECT_ROOT,
            capture_output=True,
            text=True,
            timeout=30,
        )
    except FileNotFoundError:
        return {
            "compliant": False,
            "violations": [f"Executable not found: {uv_bin}"],
            "output": "",
        }
    except subprocess.TimeoutExpired:
        return {
            "compliant": False,
            "violations": ["Validation process timed out after 30 s"],
            "output": "",
        }

    compliant: bool = result.returncode == 0
    combined: str = (result.stdout + result.stderr).strip()
    # Extract individual violation lines (lines containing the bullet marker).
    violations: list[str] = [
        line.strip().lstrip("\u2022").strip() for line in combined.splitlines() if "\u2022" in line or "\u274c" in line
    ]
    return {
        "compliant": compliant,
        "violations": violations,
        "output": combined,
    }

Shared utilities

phids.shared.constants

Shared compile-time constants for the PHIDS simulation engine.

This module centralises all numeric sentinels, hard upper limits, and physical simulation parameters that must remain consistent across the engine core, API schemas, and telemetry sub-packages. The Rule-of-16 caps (MAX_FLORA_SPECIES, MAX_HERBIVORE_SPECIES, MAX_SUBSTANCE_TYPES) govern the maximum cardinality of pre-allocated NumPy matrices in the GridEnvironment and ECS world; exceeding these limits during scenario construction is intercepted by Pydantic validation at the API ingress boundary and is never permitted to reach the engine simulation loop. Grid dimension bounds (GRID_W_MAX, GRID_H_MAX) define the maximum spatial extent of the biotope, constraining convolution and Jacobi propagation cost.

The diffusion constant SIGNAL_EPSILON is a performance invariant: after each Gaussian diffusion step, values below SIGNAL_EPSILON are zeroed to maintain matrix sparsity and avoid accumulation of subnormal floating-point values that would degrade Numba JIT-compiled kernel throughput.

Note: SIGNAL_DECAY_FACTOR and SUBSTANCE_EMIT_RATE were intentionally moved to SimulationConfig to allow dynamic parameter tuning during Design Space Exploration.

phids.shared.logging_config

Central logging configuration for PHIDS.

The package uses a single idempotent logging bootstrap so API, UI, engine, telemetry, and I/O modules share consistent formatting and levels. Configuration is environment-driven to keep detailed debugging available without paying for verbose logs by default.

InMemoryLogHandler

Bases: Handler

Capture recent structured log entries for the diagnostics UI.

Source code in src/phids/shared/logging_config.py
class InMemoryLogHandler(logging.Handler):
    """Capture recent structured log entries for the diagnostics UI."""

    def emit(self, record: logging.LogRecord) -> None:
        """Append one formatted record to the in-memory diagnostics buffer.

        Args:
        record: The log record object to emit.
        """
        try:
            message = record.getMessage()
            if record.exc_info:
                formatter = self.formatter or logging.Formatter()
                exc_text = formatter.formatException(record.exc_info)
                if exc_text:
                    message = f"{message}\n{exc_text}"
            entry = {
                "timestamp": datetime.fromtimestamp(record.created).strftime("%H:%M:%S"),
                "level": record.levelname,
                "logger": record.name,
                "module": record.module,
                "message": message,
            }
            with _RECENT_LOGS_LOCK:
                _RECENT_LOGS.append(entry)
        except Exception:  # pragma: no cover - logging must never fail app code
            self.handleError(record)

emit(record: logging.LogRecord) -> None

Append one formatted record to the in-memory diagnostics buffer.

Args: record: The log record object to emit.

Source code in src/phids/shared/logging_config.py
def emit(self, record: logging.LogRecord) -> None:
    """Append one formatted record to the in-memory diagnostics buffer.

    Args:
    record: The log record object to emit.
    """
    try:
        message = record.getMessage()
        if record.exc_info:
            formatter = self.formatter or logging.Formatter()
            exc_text = formatter.formatException(record.exc_info)
            if exc_text:
                message = f"{message}\n{exc_text}"
        entry = {
            "timestamp": datetime.fromtimestamp(record.created).strftime("%H:%M:%S"),
            "level": record.levelname,
            "logger": record.name,
            "module": record.module,
            "message": message,
        }
        with _RECENT_LOGS_LOCK:
            _RECENT_LOGS.append(entry)
    except Exception:  # pragma: no cover - logging must never fail app code
        self.handleError(record)

configure_logging(*, force: bool = False) -> None

Configure PHIDS package logging.

Environment variables

PHIDS_LOG_LEVEL: Package log level (default INFO). PHIDS_LOG_FILE: Optional file path for a rotating debug log. PHIDS_LOG_FILE_LEVEL: File handler level (default DEBUG). PHIDS_LOG_SIM_DEBUG_INTERVAL: Tick interval for engine summaries.

Parameters:

Name Type Description Default
force bool

Reconfigure logging even if already configured.

False
Source code in src/phids/shared/logging_config.py
def configure_logging(*, force: bool = False) -> None:
    """Configure PHIDS package logging.

    Environment variables:
        ``PHIDS_LOG_LEVEL``: Package log level (default ``INFO``).
        ``PHIDS_LOG_FILE``: Optional file path for a rotating debug log.
        ``PHIDS_LOG_FILE_LEVEL``: File handler level (default ``DEBUG``).
        ``PHIDS_LOG_SIM_DEBUG_INTERVAL``: Tick interval for engine summaries.

    Args:
        force: Reconfigure logging even if already configured.

    """
    global _CONFIGURED
    if _CONFIGURED and not force:
        return

    package_level = _coerce_log_level(os.getenv("PHIDS_LOG_LEVEL"))
    file_level = _coerce_log_level(os.getenv("PHIDS_LOG_FILE_LEVEL"), default="DEBUG")
    log_file = os.getenv("PHIDS_LOG_FILE")

    handlers: dict[str, dict[str, object]] = {
        "console": {
            "class": "logging.StreamHandler",
            "level": package_level,
            "formatter": "standard",
        },
        "memory": {
            "class": "phids.shared.logging_config.InMemoryLogHandler",
            "level": package_level,
        },
    }
    root_handlers = ["console", "memory"]

    if force:
        with _RECENT_LOGS_LOCK:
            _RECENT_LOGS.clear()

    if log_file:
        log_path = Path(log_file)
        log_path.parent.mkdir(parents=True, exist_ok=True)
        handlers["file"] = {
            "class": "logging.handlers.RotatingFileHandler",
            "level": file_level,
            "formatter": "standard",
            "filename": str(log_path),
            "maxBytes": 2_000_000,
            "backupCount": 3,
            "encoding": "utf-8",
        }
        root_handlers.append("file")

    logging.config.dictConfig(
        {
            "version": 1,
            "disable_existing_loggers": False,
            "formatters": {"standard": {"format": ("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")}},
            "handlers": handlers,
            "root": {
                "level": "WARNING",
                "handlers": root_handlers,
            },
            "loggers": {
                logger_name: {
                    "level": package_level,
                    "handlers": [],
                    "propagate": True,
                }
                for logger_name in _LOGGER_NAMES
            },
        }
    )

    _CONFIGURED = True
    logging.getLogger(__name__).debug(
        "Logging configured (package_level=%s, file_logging=%s, sim_debug_interval=%d)",
        package_level,
        bool(log_file),
        get_simulation_debug_interval(),
    )

get_recent_logs(*, limit: int = 80) -> list[dict[str, str]]

Return the newest structured PHIDS log entries first.

Parameters:

Name Type Description Default
limit int

Maximum number of entries to return.

80

Returns:

Type Description
list[dict[str, str]]

list[dict[str, str]]: Structured log entries for diagnostics panels.

Source code in src/phids/shared/logging_config.py
def get_recent_logs(*, limit: int = 80) -> list[dict[str, str]]:
    """Return the newest structured PHIDS log entries first.

    Args:
        limit: Maximum number of entries to return.

    Returns:
        list[dict[str, str]]: Structured log entries for diagnostics panels.

    """
    clamped_limit = max(1, limit)
    with _RECENT_LOGS_LOCK:
        return list(reversed(list(_RECENT_LOGS)[-clamped_limit:]))

get_simulation_debug_interval() -> int

Return the interval used for periodic simulation debug summaries.

Returns:

Name Type Description
int int

Tick interval for DEBUG summaries.

Source code in src/phids/shared/logging_config.py
def get_simulation_debug_interval() -> int:
    """Return the interval used for periodic simulation debug summaries.

    Returns:
        int: Tick interval for DEBUG summaries.

    """
    return _coerce_positive_int(
        os.getenv("PHIDS_LOG_SIM_DEBUG_INTERVAL"),
        default=_DEFAULT_SIM_DEBUG_INTERVAL,
    )