Skip to content

Python API Reference

This page exposes the current PHIDS Python API using mkdocstrings.

Schemas and ingress

Pydantic schemas for ECS components, configuration payloads, and REST API models.

This module defines payload and response models used by the REST API as well as schemata representing ECS components and species parameters.

AllOfConditionSchema

Bases: BaseModel

Boolean AND over nested activation predicates.

Source code in src/phids/api/schemas.py
157
158
159
160
161
162
163
164
165
class AllOfConditionSchema(BaseModel):
    """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: BaseModel

Boolean OR over nested activation predicates.

Source code in src/phids/api/schemas.py
168
169
170
171
172
173
174
175
176
class AnyOfConditionSchema(BaseModel):
    """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.",
    )

BatchJobState

Bases: BaseModel

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.py
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
class BatchJobState(BaseModel):
    """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: BaseModel

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.py
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
class BatchStartPayload(BaseModel):
    """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.")

DietCompatibilityMatrix

Bases: BaseModel

Boolean matrix [predator_species, flora_species] indicating edibility.

Source code in src/phids/api/schemas.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
class DietCompatibilityMatrix(BaseModel):
    """Boolean matrix [predator_species, flora_species] indicating edibility."""

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

    @model_validator(mode="after")
    def _validate_shape(self) -> DietCompatibilityMatrix:
        n_pred = len(self.rows)
        if n_pred > MAX_PREDATOR_SPECIES:
            raise ValueError(
                f"DietCompatibilityMatrix has {n_pred} rows, max is {MAX_PREDATOR_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

EnemyPresenceConditionSchema

Bases: BaseModel

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

Source code in src/phids/api/schemas.py
122
123
124
125
126
127
128
129
130
131
class EnemyPresenceConditionSchema(BaseModel):
    """Leaf predicate requiring a predator species at the owner's cell."""

    kind: Literal["enemy_presence"] = "enemy_presence"
    predator_species_id: PredatorId
    min_predator_population: int = Field(
        default=1,
        gt=0,
        description="Minimum co-located predator population required for this predicate.",
    )

EnvironmentalSignalConditionSchema

Bases: BaseModel

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

Source code in src/phids/api/schemas.py
143
144
145
146
147
148
149
150
151
152
153
154
class EnvironmentalSignalConditionSchema(BaseModel):
    """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.",
    )

FloraSpeciesParams

Bases: BaseModel

Per-species parameters for flora.

Source code in src/phids/api/schemas.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
class FloraSpeciesParams(BaseModel):
    """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)
    camouflage: bool = False
    camouflage_factor: float = Field(default=1.0, ge=0.0, le=1.0)
    # Trigger matrix: list of trigger conditions associated with this species
    triggers: list[TriggerConditionSchema] = Field(default_factory=list)

InitialPlantPlacement

Bases: BaseModel

Single plant to place at simulation start.

Source code in src/phids/api/schemas.py
361
362
363
364
365
366
367
class InitialPlantPlacement(BaseModel):
    """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: BaseModel

Single swarm to place at simulation start.

Source code in src/phids/api/schemas.py
370
371
372
373
374
375
376
377
class InitialSwarmPlacement(BaseModel):
    """Single swarm to place at simulation start."""

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

PlantComponentSchema

Bases: BaseModel

Pydantic schema for the Plant ECS component.

Source code in src/phids/api/schemas.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class PlantComponentSchema(BaseModel):
    """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="Current energy reserve (E_i,j).")
    max_energy: float = Field(..., gt=0.0, description="Species-specific energy capacity (E_max).")
    base_energy: float = Field(..., gt=0.0, description="Initial energy E_i,j(0).")
    growth_rate: float = Field(..., ge=0.0, description="Per-tick growth rate r_i,j (%).")
    survival_threshold: float = Field(..., ge=0.0, description="Minimum energy B_i,j before death.")
    reproduction_interval: int = Field(
        ..., gt=0, description="Ticks between reproduction attempts (T_i)."
    )
    seed_min_dist: float = Field(..., ge=0.0, description="Minimum seed dispersal distance d_min.")
    seed_max_dist: float = Field(..., gt=0.0, description="Maximum seed dispersal distance d_max.")
    seed_energy_cost: float = Field(..., ge=0.0, description="Energy cost per reproduction event.")
    camouflage: bool = Field(default=False, description="Constitutive gradient attenuation flag.")
    camouflage_factor: float = Field(
        default=1.0, ge=0.0, le=1.0, description="Gradient multiplier when camouflaged."
    )
    last_reproduction_tick: int = Field(default=0, description="Last tick of reproduction.")

PredatorSpeciesParams

Bases: BaseModel

Per-species parameters for predators (herbivore swarms).

Source code in src/phids/api/schemas.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
class PredatorSpeciesParams(BaseModel):
    """Per-species parameters for predators (herbivore swarms)."""

    species_id: PredatorId
    name: str
    energy_min: float = Field(..., gt=0.0)
    velocity: int = Field(..., gt=0)
    consumption_rate: float = Field(..., gt=0.0)
    reproduction_energy_divisor: float = Field(
        default=1.0,
        gt=0.0,
        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.",
    )
    split_population_threshold: int = Field(
        default=0,
        ge=0,
        description="Explicit population threshold for mitosis; 0 keeps legacy thresholding.",
    )

SimulationConfig

Bases: BaseModel

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

Source code in src/phids/api/schemas.py
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
class SimulationConfig(BaseModel):
    """Complete simulation configuration payload (REST /api/scenario/load body)."""

    grid_width: int = Field(default=40, ge=1, le=80)
    grid_height: int = Field(default=40, ge=1, le=80)
    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)
    predator_species: list[PredatorSpeciesParams] = Field(
        ..., min_length=1, max_length=MAX_PREDATOR_SPECIES
    )
    diet_matrix: DietCompatibilityMatrix

    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_predator_species_extinction: int = Field(
        default=-1,
        description="Halt when this predator 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_predator_population: int = Field(
        default=-1,
        description="Halt when total predator population exceeds this value (-1 = disabled).",
    )

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

SimulationStatusResponse

Bases: BaseModel

Response model for simulation state queries.

Source code in src/phids/api/schemas.py
468
469
470
471
472
473
474
475
class SimulationStatusResponse(BaseModel):
    """Response model for simulation state queries."""

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

SubstanceActiveConditionSchema

Bases: BaseModel

Leaf predicate requiring another substance to already be active.

Source code in src/phids/api/schemas.py
134
135
136
137
138
139
140
class SubstanceActiveConditionSchema(BaseModel):
    """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."
    )

SubstanceComponentSchema

Bases: BaseModel

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

Source code in src/phids/api/schemas.py
 88
 89
 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
class SubstanceComponentSchema(BaseModel):
    """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="ECS entity id of the producing plant.")
    is_toxin: bool = Field(default=False, description="True for toxins, False for signals.")
    synthesis_remaining: int = Field(
        default=0, ge=0, description="Ticks before substance becomes active."
    )
    active: bool = Field(default=False, description="Whether the substance is currently active.")
    aftereffect_ticks: int = Field(
        default=0, ge=0, description="Remaining aftereffect duration T_k."
    )
    lethal: bool = Field(default=False, description="Lethal toxin flag.")
    lethality_rate: float = Field(
        default=0.0, ge=0.0, description="Individuals eliminated per tick β(s_x, C_i)."
    )
    repellent: bool = Field(default=False, description="Repellent toxin flag.")
    repellent_walk_ticks: int = Field(
        default=0, ge=0, description="Random-walk duration k on repel trigger."
    )
    precursor_signal_id: int = Field(
        default=-1, description="Signal substance_id required before toxin activation (-1 = none)."
    )
    energy_cost_per_tick: float = Field(
        default=0.0, ge=0.0, description="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: BaseModel

Pydantic schema for the Herbivore Swarm ECS component.

Source code in src/phids/api/schemas.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class SwarmComponentSchema(BaseModel):
    """Pydantic schema for the Herbivore Swarm ECS component."""

    entity_id: int = Field(..., description="Unique ECS entity identifier.")
    species_id: PredatorId = Field(
        ..., description="Predator species index [0, MAX_PREDATOR_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="Current swarm head-count n(t).")
    initial_population: int = Field(..., gt=0, description="Initial population n(0) for mitosis.")
    energy: float = Field(..., ge=0.0, description="Current energy reserve.")
    energy_min: float = Field(..., gt=0.0, description="Minimum energy per individual E_min(e_h).")
    velocity: int = Field(..., gt=0, description="Movement period v_h (ticks between moves).")
    consumption_rate: float = Field(..., gt=0.0, description="Per-tick consumption scalar η(C_i).")
    energy_upkeep_per_individual: float = Field(
        default=0.05,
        ge=0.0,
        description="Per-individual metabolic upkeep scalar applied each tick.",
    )
    split_population_threshold: int = Field(
        default=0,
        ge=0,
        description="Explicit mitosis threshold; 0 keeps legacy initial-population based split rule.",
    )
    repelled: bool = Field(default=False, description="Currently repelled by toxin.")
    repelled_ticks_remaining: int = Field(
        default=0, description="Ticks remaining in repelled random-walk."
    )

TriggerConditionSchema

Bases: BaseModel

Trigger condition for substance synthesis (Interaction Matrix entry).

Maps a (plant species, predator species) pair to the substance that should be synthesised when the trigger conditions are met, together with all behavioural properties of the resulting substance.

Source code in src/phids/api/schemas.py
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
class TriggerConditionSchema(BaseModel):
    """Trigger condition for substance synthesis (Interaction Matrix entry).

    Maps a (plant species, predator species) pair to the substance that should
    be synthesised when the trigger conditions are met, together with all
    behavioural properties of the resulting substance.
    """

    predator_species_id: PredatorId
    min_predator_population: int = Field(
        ..., gt=0, description="Minimum swarm size n_i,min to trigger synthesis."
    )
    substance_id: SubstanceId = Field(..., description="Substance to synthesise.")
    synthesis_duration: int = Field(..., gt=0, description="Ticks to synthesise T(s_x).")
    is_toxin: bool = Field(default=False, description="True for toxins, False for signals.")
    lethal: bool = Field(default=False, description="Lethal toxin flag.")
    lethality_rate: float = Field(
        default=0.0, ge=0.0, description="Individuals eliminated per tick β(s_x, C_i)."
    )
    repellent: bool = Field(default=False, description="Repellent toxin flag.")
    repellent_walk_ticks: int = Field(
        default=0, ge=0, description="Random-walk duration k on repel trigger."
    )
    aftereffect_ticks: int = Field(
        default=0,
        ge=0,
        description="Aftereffect duration T_k (signals linger after emission ceases).",
    )
    precursor_signal_id: int = Field(
        default=-1,
        description="Single signal substance_id required before toxin activation (-1 = none). Legacy field.",
    )
    precursor_signal_ids: list[int] = Field(
        default_factory=list,
        description=(
            "All signal substance_ids that must ALL be active before this substance activates "
            "(AND logic). Empty list = no precursor required. Takes precedence over precursor_signal_id."
        ),
    )
    activation_condition: ConditionNode | None = Field(
        default=None,
        description=(
            "Optional nested predicate tree controlling whether the configured substance may activate. "
            "Supports explicit all_of/any_of composition over enemy_presence and substance_active leaves."
        ),
    )
    energy_cost_per_tick: float = Field(
        default=0.0, ge=0.0, description="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."
        ),
    )

    @model_validator(mode="after")
    def _populate_activation_condition_from_legacy_precursors(self) -> TriggerConditionSchema:
        """Translate deprecated precursor fields into the richer condition tree."""
        if self.activation_condition is not None:
            return self

        signal_ids = self.precursor_signal_ids
        if not signal_ids and self.precursor_signal_id >= 0:
            signal_ids = [self.precursor_signal_id]

        if not signal_ids:
            return self

        leaves = [
            SubstanceActiveConditionSchema(substance_id=signal_id) for signal_id in signal_ids
        ]
        self.activation_condition = (
            leaves[0] if len(leaves) == 1 else AllOfConditionSchema(conditions=leaves)
        )
        return self

WindUpdatePayload

Bases: BaseModel

REST payload for dynamically updating wind vectors.

Source code in src/phids/api/schemas.py
478
479
480
481
482
class WindUpdatePayload(BaseModel):
    """REST payload for dynamically updating wind vectors."""

    wind_x: float
    wind_y: float

Scenario I/O helpers for loading and serialising SimulationConfig.

This module provides convenience functions to parse and validate simulation configurations from Python mappings or JSON files.

load_scenario_from_dict(data)

Parse and validate a simulation configuration from a mapping.

Parameters:

Name Type Description Default
data dict[str, Any]

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
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def load_scenario_from_dict(data: dict[str, Any]) -> 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(data)
    logger.debug(
        "Scenario validated from mapping (grid=%dx%d, flora=%d, predators=%d)",
        config.grid_width,
        config.grid_height,
        len(config.flora_species),
        len(config.predator_species),
    )
    return config

load_scenario_from_json(path)

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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")
    data: dict[str, Any] = json.loads(raw)
    config = load_scenario_from_dict(data)
    logger.info("Scenario loaded from %s", source)
    return config

scenario_to_json(config, path=None)

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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

FastAPI application exposing REST endpoints and WebSocket streaming for PHIDS ecosystem simulation.

This module implements the FastAPI application for PHIDS, providing REST endpoints for scenario loading, simulation lifecycle control, environmental parameter updates, and telemetry export. A WebSocket endpoint streams per-tick grid snapshots, supporting real-time visualization and analysis. The application also serves a browser-oriented surface via Jinja2-rendered HTML under / and /ui/*, together with HTMX-driven config endpoints that mutate the server-side DraftState before committing scenarios to the engine. The architectural design ensures deterministic simulation, reproducibility, and scientific integrity, supporting rigorous validation, double-buffered state management, and compliance with the Rule of 16 and O(1) spatial hash invariants. The module is central to the API and UI’s ability to model complex ecological dynamics and emergent behaviors with maximal biological fidelity.

This module-level docstring is written in accordance with Google-style documentation standards, providing a comprehensive scholarly abstract of the application's architectural role, algorithmic mechanics, and biological rationale.

batch_export(job_id, format='csv', tick_interval=1, columns=None, title=None, x_label=None, y_label=None, chart_type='timeseries') async

Export a batch aggregate summary as CSV, LaTeX table, or PGFPlots TikZ source.

Parameters:

Name Type Description Default
job_id str

Unique batch job identifier.

required
format str

Output format — csv, tex_table, or tex_tikz.

'csv'

Returns:

Name Type Description
Response Response

File download with appropriate Content-Type headers.

Raises:

Type Description
HTTPException

404 if the summary file for job_id does not exist.

Source code in src/phids/api/main.py
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
@app.get(
    "/api/batch/export/{job_id}",
    summary="Export batch aggregate in academic formats",
)
async def batch_export(
    job_id: str,
    format: str = "csv",  # noqa: A002
    tick_interval: int = 1,
    columns: str | None = None,
    title: str | None = None,
    x_label: str | None = None,
    y_label: str | None = None,
    chart_type: str = "timeseries",
) -> Response:
    """Export a batch aggregate summary as CSV, LaTeX table, or PGFPlots TikZ source.

    Args:
        job_id: Unique batch job identifier.
        format: Output format — ``csv``, ``tex_table``, or ``tex_tikz``.

    Returns:
        Response: File download with appropriate Content-Type headers.

    Raises:
        HTTPException: 404 if the summary file for ``job_id`` does not exist.
    """
    import json as _json

    summary_path = _BATCH_DIR / f"{job_id}_summary.json"
    if not summary_path.exists():
        raise HTTPException(status_code=404, detail=f"No summary found for job '{job_id}'.")

    with summary_path.open(encoding="utf-8") as fp:
        aggregate: dict[str, Any] = _json.load(fp)

    from phids.telemetry.export import aggregate_to_dataframe

    df = aggregate_to_dataframe(aggregate)
    if tick_interval < 1:
        raise HTTPException(status_code=400, detail="tick_interval must be >= 1")
    df = filter_dataframe_columns(df, columns)
    df = decimate_dataframe(df, tick_interval)

    if format == "csv":
        data = df.to_csv(index=False).encode("utf-8")
        filename = f"phids_batch_{job_id}.csv"
        media_type = "text/csv"
    elif format == "tex_table":
        latex: str = df.to_latex(index=False, float_format="%.2f")
        data = latex.encode("utf-8")
        filename = f"phids_batch_{job_id}_table.tex"
        media_type = "text/plain"
    elif format == "tex_tikz":
        # Build simplified export rows from aggregate mean and survival series.
        rows_agg: list[dict[str, Any]] = []
        ticks = aggregate.get("ticks", [])
        flora_mean = aggregate.get("flora_population_mean", [])
        pred_mean = aggregate.get("predator_population_mean", [])
        survival = aggregate.get("survival_probability_curve", [])
        for i, t in enumerate(ticks):
            rows_agg.append(
                {
                    "tick": t,
                    "plant_pop_by_species": {0: flora_mean[i] if i < len(flora_mean) else 0},
                    "swarm_pop_by_species": {0: pred_mean[i] if i < len(pred_mean) else 0},
                    "survival_probability": float(survival[i]) if i < len(survival) else 0.0,
                }
            )
        normalized_chart_type = "survival_probability" if chart_type == "survival" else chart_type
        tikz = generate_tikz_str(
            rows_agg,
            normalized_chart_type,
            title=title,
            x_label=x_label,
            y_label=y_label,
        )
        data = tikz.encode("utf-8")
        filename = f"phids_batch_{job_id}.tex"
        media_type = "text/plain"
    else:
        raise HTTPException(
            status_code=400,
            detail=f"Unknown format '{format}'. Use csv, tex_table, or tex_tikz.",
        )

    logger.info("Batch export job=%s format=%s size=%d", job_id, format, len(data))
    return Response(
        content=data,
        media_type=media_type,
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )

batch_ledger(request) async

Return an HTMX HTML fragment listing all batch jobs.

Parameters:

Name Type Description Default
request Request

FastAPI request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/batch_ledger.html fragment.

Source code in src/phids/api/main.py
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
@app.get("/api/batch/ledger", response_class=HTMLResponse, summary="Batch job ledger")
async def batch_ledger(request: Request) -> Any:
    """Return an HTMX HTML fragment listing all batch jobs.

    Args:
        request: FastAPI request object.

    Returns:
        TemplateResponse: Rendered ``partials/batch_ledger.html`` fragment.
    """
    draft = get_draft()
    jobs = list(draft.active_batch_jobs.values())
    return templates.TemplateResponse(
        request,
        "partials/batch_ledger.html",
        {"jobs": jobs},
    )

batch_load_persisted() async

Load persisted batch summaries from disk into draft active_batch_jobs.

Returns:

Name Type Description
JSONResponse JSONResponse

Number of jobs loaded into memory.

Source code in src/phids/api/main.py
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
@app.post("/api/batch/load-persisted", summary="Load persisted batch summaries into the UI ledger")
async def batch_load_persisted() -> JSONResponse:
    """Load persisted batch summaries from disk into draft ``active_batch_jobs``.

    Returns:
        JSONResponse: Number of jobs loaded into memory.
    """
    draft = get_draft()
    loaded = 0
    for job in _discover_persisted_batches():
        if job.job_id not in draft.active_batch_jobs:
            draft.active_batch_jobs[job.job_id] = job
            loaded += 1
    logger.info("Loaded %d persisted batch jobs into UI ledger", loaded)
    return JSONResponse({"loaded": loaded, "total": len(draft.active_batch_jobs)})

batch_start(payload) async

Enqueue a Monte Carlo batch simulation job for asynchronous execution.

Validates the current server-side draft scenario, creates a :class:~phids.api.schemas.BatchJobState record, inserts it into the draft's active_batch_jobs registry, and dispatches a background :class:asyncio.Task that drives the :class:~phids.engine.batch.BatchRunner in a :class:concurrent.futures.ProcessPoolExecutor. The HTTP response returns immediately with the job_id so the client can begin polling the status endpoint.

Parameters:

Name Type Description Default
payload BatchStartPayload

Batch execution parameters (runs, max_ticks, scenario_name).

required

Returns:

Name Type Description
JSONResponse JSONResponse

{"job_id": str} upon successful enqueue.

Raises:

Type Description
HTTPException

400 if the current draft cannot produce a valid :class:~phids.api.schemas.SimulationConfig.

Source code in src/phids/api/main.py
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
@app.post("/api/batch/start", summary="Start a Monte Carlo batch simulation job")
async def batch_start(
    payload: BatchStartPayload,
) -> JSONResponse:
    """Enqueue a Monte Carlo batch simulation job for asynchronous execution.

    Validates the current server-side draft scenario, creates a
    :class:`~phids.api.schemas.BatchJobState` record, inserts it into the
    draft's ``active_batch_jobs`` registry, and dispatches a background
    :class:`asyncio.Task` that drives the
    :class:`~phids.engine.batch.BatchRunner` in a
    :class:`concurrent.futures.ProcessPoolExecutor`. The HTTP response returns
    immediately with the ``job_id`` so the client can begin polling the status
    endpoint.

    Args:
        payload: Batch execution parameters (runs, max_ticks, scenario_name).

    Returns:
        JSONResponse: ``{"job_id": str}`` upon successful enqueue.

    Raises:
        HTTPException: 400 if the current draft cannot produce a valid
            :class:`~phids.api.schemas.SimulationConfig`.
    """
    import datetime
    import uuid

    draft = get_draft()
    try:
        config = draft.build_sim_config()
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Invalid draft: {exc}") from exc

    job_id = str(uuid.uuid4())[:8]
    scenario_name = payload.scenario_name or draft.scenario_name
    job = BatchJobState(
        job_id=job_id,
        status="queued",
        completed=0,
        total=payload.runs,
        scenario_name=scenario_name,
        started_at=datetime.datetime.now(tz=datetime.timezone.utc).isoformat(),
        max_ticks=payload.max_ticks,
    )
    draft.active_batch_jobs[job_id] = job
    logger.info(
        "Batch job %s enqueued (runs=%d, max_ticks=%d)", job_id, payload.runs, payload.max_ticks
    )

    scenario_dict = config.model_dump()

    async def _run_batch() -> None:
        from phids.engine.batch import BatchRunner

        job.status = "running"
        try:
            _BATCH_DIR.mkdir(parents=True, exist_ok=True)
            runner = BatchRunner()

            loop = asyncio.get_event_loop()

            def _progress(completed: int) -> None:
                job.completed = completed
                logger.debug("Batch job %s progress: %d/%d", job_id, completed, payload.runs)

            await loop.run_in_executor(
                None,
                lambda: runner.execute_batch(
                    scenario_dict,
                    payload.runs,
                    payload.max_ticks,
                    job_id,
                    _BATCH_DIR,
                    _progress,
                ),
            )
            job.status = "done"
            job.completed = payload.runs
        except Exception:
            logger.exception("Batch job %s failed", job_id)
            job.status = "failed"
        finally:
            import datetime as dt

            job.finished_at = dt.datetime.now(tz=dt.timezone.utc).isoformat()

    asyncio.create_task(_run_batch())
    return JSONResponse({"job_id": job_id})

batch_status(request, job_id) async

Return an HTMX HTML fragment for a single batch job progress row.

Parameters:

Name Type Description Default
request Request

FastAPI request object.

required
job_id str

Unique batch job identifier.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/batch_job_row.html fragment.

Raises:

Type Description
HTTPException

404 if job_id is not found.

Source code in src/phids/api/main.py
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
@app.get("/api/batch/status/{job_id}", response_class=HTMLResponse, summary="Batch job status row")
async def batch_status(request: Request, job_id: str) -> Any:
    """Return an HTMX HTML fragment for a single batch job progress row.

    Args:
        request: FastAPI request object.
        job_id: Unique batch job identifier.

    Returns:
        TemplateResponse: Rendered ``partials/batch_job_row.html`` fragment.

    Raises:
        HTTPException: 404 if ``job_id`` is not found.
    """
    draft = get_draft()
    job = draft.active_batch_jobs.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found.")
    return templates.TemplateResponse(
        request,
        "partials/batch_job_row.html",
        {"job": job},
    )

batch_view(request, job_id) async

Return an HTMX fragment showing aggregate statistics for a completed batch job.

Reads the persisted {job_id}_summary.json from disk and renders the aggregate mean±σ chart data. If the file does not exist (e.g., job still running), a placeholder message is returned.

Parameters:

Name Type Description Default
request Request

FastAPI request object.

required
job_id str

Unique batch job identifier.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/batch_view.html fragment.

Source code in src/phids/api/main.py
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
@app.get("/api/batch/view/{job_id}", response_class=HTMLResponse, summary="Batch aggregate view")
async def batch_view(request: Request, job_id: str) -> Any:
    """Return an HTMX fragment showing aggregate statistics for a completed batch job.

    Reads the persisted ``{job_id}_summary.json`` from disk and renders the
    aggregate mean±σ chart data. If the file does not exist (e.g., job still
    running), a placeholder message is returned.

    Args:
        request: FastAPI request object.
        job_id: Unique batch job identifier.

    Returns:
        TemplateResponse: Rendered ``partials/batch_view.html`` fragment.
    """
    import json as _json

    draft = get_draft()
    job = draft.active_batch_jobs.get(job_id)
    summary_path = _BATCH_DIR / f"{job_id}_summary.json"
    aggregate: dict[str, Any] = {}
    if summary_path.exists():
        with summary_path.open(encoding="utf-8") as fp:
            aggregate = _json.load(fp)

    return templates.TemplateResponse(
        request,
        "partials/batch_view.html",
        {"job": job, "aggregate": aggregate, "job_id": job_id},
    )

config_biotope(request, grid_width=40, grid_height=40, max_ticks=1000, tick_rate_hz=10.0, wind_x=0.0, wind_y=0.0, num_signals=4, num_toxins=4, mycorrhizal_inter_species='off', mycorrhizal_connection_cost=1.0, mycorrhizal_growth_interval_ticks=8, mycorrhizal_signal_velocity=1) async

Persist biotope parameters to the draft and return the updated partial.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
grid_width Annotated[int, Form()]

Grid width (10–80).

40
grid_height Annotated[int, Form()]

Grid height (10–80).

40
max_ticks Annotated[int, Form()]

Tick budget.

1000
tick_rate_hz Annotated[float, Form()]

WebSocket stream rate.

10.0
wind_x Annotated[float, Form()]

Uniform wind x-component.

0.0
wind_y Annotated[float, Form()]

Uniform wind y-component.

0.0
num_signals Annotated[int, Form()]

Signal layer count.

4
num_toxins Annotated[int, Form()]

Toxin layer count.

4
mycorrhizal_inter_species Annotated[str, Form()]

Checkbox value ("on" or "off").

'off'
mycorrhizal_connection_cost Annotated[float, Form()]

Root link energy cost.

1.0
mycorrhizal_growth_interval_ticks Annotated[int, Form()]

Ticks between new root-link attempts.

8
mycorrhizal_signal_velocity Annotated[int, Form()]

Signal hops per tick.

1

Returns:

Name Type Description
TemplateResponse Any

Updated biotope config form partial.

Source code in src/phids/api/main.py
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
@app.post("/api/config/biotope", response_class=HTMLResponse, summary="Update biotope draft config")
async def config_biotope(
    request: Request,
    grid_width: Annotated[int, Form()] = 40,
    grid_height: Annotated[int, Form()] = 40,
    max_ticks: Annotated[int, Form()] = 1000,
    tick_rate_hz: Annotated[float, Form()] = 10.0,
    wind_x: Annotated[float, Form()] = 0.0,
    wind_y: Annotated[float, Form()] = 0.0,
    num_signals: Annotated[int, Form()] = 4,
    num_toxins: Annotated[int, Form()] = 4,
    mycorrhizal_inter_species: Annotated[str, Form()] = "off",
    mycorrhizal_connection_cost: Annotated[float, Form()] = 1.0,
    mycorrhizal_growth_interval_ticks: Annotated[int, Form()] = 8,
    mycorrhizal_signal_velocity: Annotated[int, Form()] = 1,
) -> Any:
    """Persist biotope parameters to the draft and return the updated partial.

    Args:
        request: Starlette request.
        grid_width: Grid width (10–80).
        grid_height: Grid height (10–80).
        max_ticks: Tick budget.
        tick_rate_hz: WebSocket stream rate.
        wind_x: Uniform wind x-component.
        wind_y: Uniform wind y-component.
        num_signals: Signal layer count.
        num_toxins: Toxin layer count.
        mycorrhizal_inter_species: Checkbox value ("on" or "off").
        mycorrhizal_connection_cost: Root link energy cost.
        mycorrhizal_growth_interval_ticks: Ticks between new root-link attempts.
        mycorrhizal_signal_velocity: Signal hops per tick.

    Returns:
        TemplateResponse: Updated biotope config form partial.
    """
    draft = get_draft()
    clamped_grid_width = max(10, min(80, grid_width))
    clamped_grid_height = max(10, min(80, 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_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)
    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.mycorrhizal_inter_species = mycorrhizal_inter_species == "on"
    draft.mycorrhizal_connection_cost = clamped_connection_cost
    draft.mycorrhizal_growth_interval_ticks = clamped_growth_interval
    draft.mycorrhizal_signal_velocity = clamped_signal_velocity
    logger.debug(
        "Draft biotope updated (grid=%dx%d, max_ticks=%d, tick_rate_hz=%.2f, wind=(%.3f, %.3f), signals=%d, toxins=%d, mycorrhiza_interval=%d)",
        draft.grid_width,
        draft.grid_height,
        draft.max_ticks,
        draft.tick_rate_hz,
        draft.wind_x,
        draft.wind_y,
        draft.num_signals,
        draft.num_toxins,
        draft.mycorrhizal_growth_interval_ticks,
    )
    if (
        clamped_grid_width != grid_width
        or clamped_grid_height != grid_height
        or clamped_max_ticks != max_ticks
        or clamped_tick_rate_hz != tick_rate_hz
        or clamped_num_signals != num_signals
        or clamped_num_toxins != num_toxins
        or clamped_connection_cost != mycorrhizal_connection_cost
        or clamped_growth_interval != mycorrhizal_growth_interval_ticks
        or clamped_signal_velocity != mycorrhizal_signal_velocity
    ):
        logger.warning(
            "Draft biotope values were clamped to valid ranges (requested_grid=%dx%d, applied_grid=%dx%d)",
            grid_width,
            grid_height,
            clamped_grid_width,
            clamped_grid_height,
        )
    return templates.TemplateResponse(
        request,
        "partials/biotope_config.html",
        {"draft": draft},
    )

config_flora_add(request, name='NewFlora', base_energy=10.0, max_energy=100.0, growth_rate=5.0, survival_threshold=1.0, reproduction_interval=10, seed_min_dist=1.0, seed_max_dist=3.0, seed_energy_cost=5.0, camouflage='off', camouflage_factor=1.0) async

Add a new flora species to the draft and return the updated table.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
name Annotated[str, Form()]

Species name.

'NewFlora'
base_energy Annotated[float, Form()]

Starting energy.

10.0
max_energy Annotated[float, Form()]

Energy capacity.

100.0
growth_rate Annotated[float, Form()]

Per-tick growth percentage.

5.0
survival_threshold Annotated[float, Form()]

Minimum energy before death.

1.0
reproduction_interval Annotated[int, Form()]

Ticks between seeds.

10
seed_min_dist Annotated[float, Form()]

Minimum dispersal distance.

1.0
seed_max_dist Annotated[float, Form()]

Maximum dispersal distance.

3.0
seed_energy_cost Annotated[float, Form()]

Energy cost per seed event.

5.0
camouflage Annotated[str, Form()]

Checkbox value ("on" or "off").

'off'
camouflage_factor Annotated[float, Form()]

Camouflage factor (0–1).

1.0

Returns:

Name Type Description
TemplateResponse Any

Updated flora config table partial.

Raises:

Type Description
HTTPException

400 if Rule of 16 limit is already reached.

Source code in src/phids/api/main.py
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
@app.post("/api/config/flora", response_class=HTMLResponse, summary="Add flora species to draft")
async def config_flora_add(
    request: Request,
    name: Annotated[str, Form()] = "NewFlora",
    base_energy: Annotated[float, Form()] = 10.0,
    max_energy: Annotated[float, Form()] = 100.0,
    growth_rate: Annotated[float, Form()] = 5.0,
    survival_threshold: Annotated[float, Form()] = 1.0,
    reproduction_interval: Annotated[int, Form()] = 10,
    seed_min_dist: Annotated[float, Form()] = 1.0,
    seed_max_dist: Annotated[float, Form()] = 3.0,
    seed_energy_cost: Annotated[float, Form()] = 5.0,
    camouflage: Annotated[str, Form()] = "off",
    camouflage_factor: Annotated[float, Form()] = 1.0,
) -> Any:
    """Add a new flora species to the draft and return the updated table.

    Args:
        request: Starlette request.
        name: Species name.
        base_energy: Starting energy.
        max_energy: Energy capacity.
        growth_rate: Per-tick growth percentage.
        survival_threshold: Minimum energy before death.
        reproduction_interval: Ticks between seeds.
        seed_min_dist: Minimum dispersal distance.
        seed_max_dist: Maximum dispersal distance.
        seed_energy_cost: Energy cost per seed event.
        camouflage: Checkbox value ("on" or "off").
        camouflage_factor: Camouflage factor (0–1).

    Returns:
        TemplateResponse: Updated flora config table partial.

    Raises:
        HTTPException: 400 if Rule of 16 limit is already reached.
    """
    draft = get_draft()
    if len(draft.flora_species) >= 16:
        logger.warning("Rule-of-16 rejected flora creation")
        raise HTTPException(status_code=400, detail="Rule of 16: maximum flora species reached.")
    new_id = len(draft.flora_species)
    params = FloraSpeciesParams(
        species_id=new_id,
        name=name,
        base_energy=base_energy,
        max_energy=max_energy,
        growth_rate=growth_rate,
        survival_threshold=survival_threshold,
        reproduction_interval=reproduction_interval,
        seed_min_dist=seed_min_dist,
        seed_max_dist=seed_max_dist,
        seed_energy_cost=seed_energy_cost,
        camouflage=camouflage == "on",
        camouflage_factor=max(0.0, min(1.0, camouflage_factor)),
        triggers=[],
    )
    draft.add_flora(params)
    logger.info("Flora species added via API (species_id=%d, name=%s)", new_id, name)
    return templates.TemplateResponse(
        request,
        "partials/flora_config.html",
        {"flora_species": draft.flora_species},
    )

config_flora_delete(species_id) async

Remove a flora species from the draft.

Parameters:

Name Type Description Default
species_id int

The species_id to remove.

required

Returns:

Name Type Description
HTMLResponse HTMLResponse

Empty string (HTMX removes the row with outerHTML swap).

Raises:

Type Description
HTTPException

404 if species_id not found.

Source code in src/phids/api/main.py
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
@app.delete(
    "/api/config/flora/{species_id}", response_class=HTMLResponse, summary="Delete flora species"
)
async def config_flora_delete(species_id: int) -> HTMLResponse:
    """Remove a flora species from the draft.

    Args:
        species_id: The ``species_id`` to remove.

    Returns:
        HTMLResponse: Empty string (HTMX removes the row with ``outerHTML`` swap).

    Raises:
        HTTPException: 404 if species_id not found.
    """
    draft = get_draft()
    try:
        draft.remove_flora(species_id)
    except ValueError as exc:
        logger.warning("Flora delete requested for unknown species_id=%d", species_id)
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return HTMLResponse(content="")

config_flora_update(request, species_id, name=None, base_energy=None, max_energy=None, growth_rate=None, survival_threshold=None, reproduction_interval=None, seed_min_dist=None, seed_max_dist=None, seed_energy_cost=None, camouflage=None, camouflage_factor=None) async

Patch a single flora species in the draft and return the updated row.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
species_id int

Target species id.

required
name Annotated[str | None, Form()]

Updated name (optional).

None
base_energy Annotated[float | None, Form()]

Updated base energy (optional).

None
max_energy Annotated[float | None, Form()]

Updated max energy (optional).

None
growth_rate Annotated[float | None, Form()]

Updated growth rate (optional).

None
survival_threshold Annotated[float | None, Form()]

Updated survival threshold (optional).

None
reproduction_interval Annotated[int | None, Form()]

Updated reproduction interval (optional).

None
seed_min_dist Annotated[float | None, Form()]

Updated minimum seed distance (optional).

None
seed_max_dist Annotated[float | None, Form()]

Updated maximum seed distance (optional).

None
seed_energy_cost Annotated[float | None, Form()]

Updated seed energy cost (optional).

None
camouflage Annotated[str | None, Form()]

Checkbox value "on" or absent (optional).

None
camouflage_factor Annotated[float | None, Form()]

Updated camouflage factor 0–1 (optional).

None

Returns:

Name Type Description
TemplateResponse Any

Updated <tr> row partial (outerHTML swap).

Raises:

Type Description
HTTPException

404 if species_id not found.

Source code in src/phids/api/main.py
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
@app.put(
    "/api/config/flora/{species_id}",
    response_class=HTMLResponse,
    summary="Update flora species row",
)
async def config_flora_update(
    request: Request,
    species_id: int,
    name: Annotated[str | None, Form()] = None,
    base_energy: Annotated[float | None, Form()] = None,
    max_energy: Annotated[float | None, Form()] = None,
    growth_rate: Annotated[float | None, Form()] = None,
    survival_threshold: Annotated[float | None, Form()] = None,
    reproduction_interval: Annotated[int | None, Form()] = None,
    seed_min_dist: Annotated[float | None, Form()] = None,
    seed_max_dist: Annotated[float | None, Form()] = None,
    seed_energy_cost: Annotated[float | None, Form()] = None,
    camouflage: Annotated[str | None, Form()] = None,
    camouflage_factor: Annotated[float | None, Form()] = None,
) -> Any:
    """Patch a single flora species in the draft and return the updated row.

    Args:
        request: Starlette request.
        species_id: Target species id.
        name: Updated name (optional).
        base_energy: Updated base energy (optional).
        max_energy: Updated max energy (optional).
        growth_rate: Updated growth rate (optional).
        survival_threshold: Updated survival threshold (optional).
        reproduction_interval: Updated reproduction interval (optional).
        seed_min_dist: Updated minimum seed distance (optional).
        seed_max_dist: Updated maximum seed distance (optional).
        seed_energy_cost: Updated seed energy cost (optional).
        camouflage: Checkbox value "on" or absent (optional).
        camouflage_factor: Updated camouflage factor 0–1 (optional).

    Returns:
        TemplateResponse: Updated ``<tr>`` row partial (outerHTML swap).

    Raises:
        HTTPException: 404 if species_id not found.
    """
    draft = get_draft()
    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:
        logger.warning("Flora update requested for unknown species_id=%d", species_id)
        raise HTTPException(status_code=404, detail=f"Flora species {species_id} not found.")

    fp = draft.flora_species[idx]
    if not isinstance(fp, FloraSpeciesParams):
        raise HTTPException(status_code=400, detail="Invalid flora species entry in draft state.")
    updates: dict[str, Any] = {}
    if name is not None:
        updates["name"] = name
    if base_energy is not None:
        updates["base_energy"] = base_energy
    if max_energy is not None:
        updates["max_energy"] = max_energy
    if growth_rate is not None:
        updates["growth_rate"] = growth_rate
    if survival_threshold is not None:
        updates["survival_threshold"] = survival_threshold
    if reproduction_interval is not None:
        updates["reproduction_interval"] = reproduction_interval
    if seed_min_dist is not None:
        updates["seed_min_dist"] = seed_min_dist
    if seed_max_dist is not None:
        updates["seed_max_dist"] = seed_max_dist
    if seed_energy_cost is not None:
        updates["seed_energy_cost"] = seed_energy_cost
    if camouflage is not None:
        updates["camouflage"] = camouflage == "on"
    if camouflage_factor is not None:
        updates["camouflage_factor"] = max(0.0, min(1.0, camouflage_factor))

    draft.flora_species[idx] = fp.model_copy(update=updates)
    logger.debug(
        "Flora species updated via API (species_id=%d, fields=%s)", species_id, sorted(updates)
    )
    return templates.TemplateResponse(
        request,
        "partials/flora_config.html",
        {"flora_species": draft.flora_species},
    )

config_placement_plant_add(request, species_id, x, y, energy=10.0) async

Place a plant entity at the given grid position.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
species_id Annotated[int, Form()]

Flora species id.

required
x Annotated[int, Form()]

Grid x-coordinate.

required
y Annotated[int, Form()]

Grid y-coordinate.

required
energy Annotated[float, Form()]

Starting energy.

10.0

Returns:

Name Type Description
TemplateResponse Any

Updated placement list partial.

Source code in src/phids/api/main.py
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
@app.post(
    "/api/config/placements/plant", response_class=HTMLResponse, summary="Place a plant on the grid"
)
async def config_placement_plant_add(
    request: Request,
    species_id: Annotated[int, Form()],
    x: Annotated[int, Form()],
    y: Annotated[int, Form()],
    energy: Annotated[float, Form()] = 10.0,
) -> Any:
    """Place a plant entity at the given grid position.

    Args:
        request: Starlette request.
        species_id: Flora species id.
        x: Grid x-coordinate.
        y: Grid y-coordinate.
        energy: Starting energy.

    Returns:
        TemplateResponse: Updated placement list partial.
    """
    draft = get_draft()
    x = max(0, min(draft.grid_width - 1, x))
    y = max(0, min(draft.grid_height - 1, y))
    draft.add_plant_placement(species_id, x, y, max(0.1, energy))
    logger.info("Plant placement added via API (species_id=%d, x=%d, y=%d)", species_id, x, y)
    return templates.TemplateResponse(
        request,
        "partials/placement_list.html",
        {
            "flora_species": draft.flora_species,
            "predator_species": draft.predator_species,
            "initial_plants": draft.initial_plants,
            "initial_swarms": draft.initial_swarms,
        },
    )

config_placement_plant_delete(request, index) async

Remove a plant placement by list index.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
index int

Position in the initial_plants list.

required

Returns:

Name Type Description
TemplateResponse Any

Updated placement list partial.

Source code in src/phids/api/main.py
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
@app.delete(
    "/api/config/placements/plant/{index}",
    response_class=HTMLResponse,
    summary="Remove a placed plant",
)
async def config_placement_plant_delete(request: Request, index: int) -> Any:
    """Remove a plant placement by list index.

    Args:
        request: Starlette request.
        index: Position in the initial_plants list.

    Returns:
        TemplateResponse: Updated placement list partial.
    """
    draft = get_draft()
    try:
        draft.remove_plant_placement(index)
    except IndexError as exc:
        logger.warning("Plant placement delete requested for unknown index=%d", index)
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return templates.TemplateResponse(
        request,
        "partials/placement_list.html",
        {
            "flora_species": draft.flora_species,
            "predator_species": draft.predator_species,
            "initial_plants": draft.initial_plants,
            "initial_swarms": draft.initial_swarms,
        },
    )

config_placement_swarm_add(request, species_id, x, y, population=10, energy=50.0) async

Place a herbivore swarm at the given grid position.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
species_id Annotated[int, Form()]

Predator species id.

required
x Annotated[int, Form()]

Grid x-coordinate.

required
y Annotated[int, Form()]

Grid y-coordinate.

required
population Annotated[int, Form()]

Initial swarm size.

10
energy Annotated[float, Form()]

Starting energy.

50.0

Returns:

Name Type Description
TemplateResponse Any

Updated placement list partial.

Source code in src/phids/api/main.py
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
@app.post(
    "/api/config/placements/swarm", response_class=HTMLResponse, summary="Place a swarm on the grid"
)
async def config_placement_swarm_add(
    request: Request,
    species_id: Annotated[int, Form()],
    x: Annotated[int, Form()],
    y: Annotated[int, Form()],
    population: Annotated[int, Form()] = 10,
    energy: Annotated[float, Form()] = 50.0,
) -> Any:
    """Place a herbivore swarm at the given grid position.

    Args:
        request: Starlette request.
        species_id: Predator species id.
        x: Grid x-coordinate.
        y: Grid y-coordinate.
        population: Initial swarm size.
        energy: Starting energy.

    Returns:
        TemplateResponse: Updated placement list partial.
    """
    draft = get_draft()
    x = max(0, min(draft.grid_width - 1, x))
    y = max(0, min(draft.grid_height - 1, y))
    draft.add_swarm_placement(species_id, x, y, max(1, population), max(0.1, energy))
    logger.info(
        "Swarm placement added via API (species_id=%d, x=%d, y=%d, population=%d)",
        species_id,
        x,
        y,
        max(1, population),
    )
    return templates.TemplateResponse(
        request,
        "partials/placement_list.html",
        {
            "flora_species": draft.flora_species,
            "predator_species": draft.predator_species,
            "initial_plants": draft.initial_plants,
            "initial_swarms": draft.initial_swarms,
        },
    )

config_placement_swarm_delete(request, index) async

Remove a swarm placement by list index.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
index int

Position in the initial_swarms list.

required

Returns:

Name Type Description
TemplateResponse Any

Updated placement list partial.

Source code in src/phids/api/main.py
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
@app.delete(
    "/api/config/placements/swarm/{index}",
    response_class=HTMLResponse,
    summary="Remove a placed swarm",
)
async def config_placement_swarm_delete(request: Request, index: int) -> Any:
    """Remove a swarm placement by list index.

    Args:
        request: Starlette request.
        index: Position in the initial_swarms list.

    Returns:
        TemplateResponse: Updated placement list partial.
    """
    draft = get_draft()
    try:
        draft.remove_swarm_placement(index)
    except IndexError as exc:
        logger.warning("Swarm placement delete requested for unknown index=%d", index)
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return templates.TemplateResponse(
        request,
        "partials/placement_list.html",
        {
            "flora_species": draft.flora_species,
            "predator_species": draft.predator_species,
            "initial_plants": draft.initial_plants,
            "initial_swarms": draft.initial_swarms,
        },
    )

config_placements_clear(request) async

Remove all plant and swarm placements from the draft.

Parameters:

Name Type Description Default
request Request

Starlette request.

required

Returns:

Name Type Description
TemplateResponse Any

Updated placement list partial.

Source code in src/phids/api/main.py
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
@app.post(
    "/api/config/placements/clear", response_class=HTMLResponse, summary="Clear all placements"
)
async def config_placements_clear(request: Request) -> Any:
    """Remove all plant and swarm placements from the draft.

    Args:
        request: Starlette request.

    Returns:
        TemplateResponse: Updated placement list partial.
    """
    draft = get_draft()
    draft.clear_placements()
    logger.info("All draft placements cleared via API")
    return templates.TemplateResponse(
        request,
        "partials/placement_list.html",
        {
            "flora_species": draft.flora_species,
            "predator_species": draft.predator_species,
            "initial_plants": draft.initial_plants,
            "initial_swarms": draft.initial_swarms,
        },
    )

config_predator_add(request, name='NewPredator', energy_min=5.0, velocity=2, consumption_rate=10.0, reproduction_energy_divisor=1.0, energy_upkeep_per_individual=0.05, split_population_threshold=0) async

Add a new predator species to the draft.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
name Annotated[str, Form()]

Species name.

'NewPredator'
energy_min Annotated[float, Form()]

Minimum per-individual energy.

5.0
velocity Annotated[int, Form()]

Movement period in ticks.

2
consumption_rate Annotated[float, Form()]

Per-tick consumption scalar.

10.0
reproduction_energy_divisor Annotated[float, Form()]

Divisor for reproduction formula.

1.0
energy_upkeep_per_individual Annotated[float, Form()]

Per-individual metabolic upkeep scalar.

0.05
split_population_threshold Annotated[int, Form()]

Explicit mitosis threshold (0 keeps legacy behavior).

0

Returns:

Name Type Description
TemplateResponse Any

Updated predator config table partial.

Raises:

Type Description
HTTPException

400 if Rule of 16 limit is reached.

Source code in src/phids/api/main.py
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
@app.post(
    "/api/config/predators", response_class=HTMLResponse, summary="Add predator species to draft"
)
async def config_predator_add(
    request: Request,
    name: Annotated[str, Form()] = "NewPredator",
    energy_min: Annotated[float, Form()] = 5.0,
    velocity: Annotated[int, Form()] = 2,
    consumption_rate: Annotated[float, Form()] = 10.0,
    reproduction_energy_divisor: Annotated[float, Form()] = 1.0,
    energy_upkeep_per_individual: Annotated[float, Form()] = 0.05,
    split_population_threshold: Annotated[int, Form()] = 0,
) -> Any:
    """Add a new predator species to the draft.

    Args:
        request: Starlette request.
        name: Species name.
        energy_min: Minimum per-individual energy.
        velocity: Movement period in ticks.
        consumption_rate: Per-tick consumption scalar.
        reproduction_energy_divisor: Divisor for reproduction formula.
        energy_upkeep_per_individual: Per-individual metabolic upkeep scalar.
        split_population_threshold: Explicit mitosis threshold (0 keeps legacy behavior).

    Returns:
        TemplateResponse: Updated predator config table partial.

    Raises:
        HTTPException: 400 if Rule of 16 limit is reached.
    """
    draft = get_draft()
    if len(draft.predator_species) >= 16:
        logger.warning("Rule-of-16 rejected predator creation")
        raise HTTPException(status_code=400, detail="Rule of 16: maximum predator species reached.")
    new_id = len(draft.predator_species)
    params = PredatorSpeciesParams(
        species_id=new_id,
        name=name,
        energy_min=energy_min,
        velocity=velocity,
        consumption_rate=consumption_rate,
        reproduction_energy_divisor=max(1.0, reproduction_energy_divisor),
        energy_upkeep_per_individual=energy_upkeep_per_individual,
        split_population_threshold=split_population_threshold,
    )
    draft.add_predator(params)
    logger.info("Predator species added via API (species_id=%d, name=%s)", new_id, name)
    return templates.TemplateResponse(
        request,
        "partials/predator_config.html",
        {"predator_species": draft.predator_species},
    )

config_predator_delete(species_id) async

Remove a predator species from the draft.

Parameters:

Name Type Description Default
species_id int

The species_id to remove.

required

Returns:

Name Type Description
HTMLResponse HTMLResponse

Empty string (HTMX removes the row).

Raises:

Type Description
HTTPException

404 if species_id not found.

Source code in src/phids/api/main.py
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
@app.delete(
    "/api/config/predators/{species_id}",
    response_class=HTMLResponse,
    summary="Delete predator species",
)
async def config_predator_delete(species_id: int) -> HTMLResponse:
    """Remove a predator species from the draft.

    Args:
        species_id: The ``species_id`` to remove.

    Returns:
        HTMLResponse: Empty string (HTMX removes the row).

    Raises:
        HTTPException: 404 if species_id not found.
    """
    draft = get_draft()
    try:
        draft.remove_predator(species_id)
    except ValueError as exc:
        logger.warning("Predator delete requested for unknown species_id=%d", species_id)
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return HTMLResponse(content="")

config_predator_update(request, species_id, name=None, energy_min=None, velocity=None, consumption_rate=None, reproduction_energy_divisor=None, energy_upkeep_per_individual=None, split_population_threshold=None) async

Patch a single predator species in the draft.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
species_id int

Target species id.

required
name Annotated[str | None, Form()]

Updated name (optional).

None
energy_min Annotated[float | None, Form()]

Updated energy minimum (optional).

None
velocity Annotated[int | None, Form()]

Updated velocity (optional).

None
consumption_rate Annotated[float | None, Form()]

Updated consumption rate (optional).

None
reproduction_energy_divisor Annotated[float | None, Form()]

Updated reproduction divisor (optional).

None
energy_upkeep_per_individual Annotated[float | None, Form()]

Updated metabolic upkeep scalar (optional).

None
split_population_threshold Annotated[int | None, Form()]

Updated mitosis threshold (optional).

None

Returns:

Name Type Description
TemplateResponse Any

Updated <tr> row partial.

Raises:

Type Description
HTTPException

404 if species_id not found.

Source code in src/phids/api/main.py
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
@app.put(
    "/api/config/predators/{species_id}",
    response_class=HTMLResponse,
    summary="Update predator species row",
)
async def config_predator_update(
    request: Request,
    species_id: int,
    name: Annotated[str | None, Form()] = None,
    energy_min: Annotated[float | None, Form()] = None,
    velocity: Annotated[int | None, Form()] = None,
    consumption_rate: Annotated[float | None, Form()] = None,
    reproduction_energy_divisor: Annotated[float | None, Form()] = None,
    energy_upkeep_per_individual: Annotated[float | None, Form()] = None,
    split_population_threshold: Annotated[int | None, Form()] = None,
) -> Any:
    """Patch a single predator species in the draft.

    Args:
        request: Starlette request.
        species_id: Target species id.
        name: Updated name (optional).
        energy_min: Updated energy minimum (optional).
        velocity: Updated velocity (optional).
        consumption_rate: Updated consumption rate (optional).
        reproduction_energy_divisor: Updated reproduction divisor (optional).
        energy_upkeep_per_individual: Updated metabolic upkeep scalar (optional).
        split_population_threshold: Updated mitosis threshold (optional).

    Returns:
        TemplateResponse: Updated ``<tr>`` row partial.

    Raises:
        HTTPException: 404 if species_id not found.
    """
    draft = get_draft()
    idx = next(
        (
            i
            for i, pp in enumerate(draft.predator_species)
            if isinstance(pp, PredatorSpeciesParams) and pp.species_id == species_id
        ),
        None,
    )
    if idx is None:
        logger.warning("Predator update requested for unknown species_id=%d", species_id)
        raise HTTPException(status_code=404, detail=f"Predator species {species_id} not found.")

    pp = draft.predator_species[idx]
    if not isinstance(pp, PredatorSpeciesParams):
        raise HTTPException(
            status_code=400, detail="Invalid predator species entry in draft state."
        )
    updates: dict[str, Any] = {}
    if name is not None:
        updates["name"] = name
    if energy_min is not None:
        updates["energy_min"] = energy_min
    if velocity is not None:
        updates["velocity"] = velocity
    if consumption_rate is not None:
        updates["consumption_rate"] = consumption_rate
    if reproduction_energy_divisor is not None:
        updates["reproduction_energy_divisor"] = max(1.0, reproduction_energy_divisor)
    if energy_upkeep_per_individual is not None:
        updates["energy_upkeep_per_individual"] = energy_upkeep_per_individual
    if split_population_threshold is not None:
        updates["split_population_threshold"] = split_population_threshold

    draft.predator_species[idx] = pp.model_copy(update=updates)
    logger.debug(
        "Predator species updated via API (species_id=%d, fields=%s)", species_id, sorted(updates)
    )
    return templates.TemplateResponse(
        request,
        "partials/predator_config.html",
        {"predator_species": draft.predator_species},
    )

config_substance_add(request, name='Signal', is_toxin='false', lethal='false', repellent='false', synthesis_duration=3, aftereffect_ticks=0, lethality_rate=0.0, repellent_walk_ticks=3, energy_cost_per_tick=1.0, irreversible='false') async

Add a substance definition to the draft.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
name Annotated[str, Form()]

Substance name.

'Signal'
is_toxin Annotated[str, Form()]

"true" for toxins.

'false'
lethal Annotated[str, Form()]

"true" for lethal toxins.

'false'
repellent Annotated[str, Form()]

"true" for repellent toxins.

'false'
synthesis_duration Annotated[int, Form()]

Production ticks.

3
aftereffect_ticks Annotated[int, Form()]

Linger duration.

0
lethality_rate Annotated[float, Form()]

Population eliminated per tick.

0.0
repellent_walk_ticks Annotated[int, Form()]

Random-walk duration.

3
energy_cost_per_tick Annotated[float, Form()]

Energy drain per active tick.

1.0
irreversible Annotated[str, Form()]

"true" to keep the substance active after first activation.

'false'

Returns:

Name Type Description
Any Any

Response object.

Source code in src/phids/api/main.py
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
@app.post(
    "/api/config/substances",
    response_class=HTMLResponse,
    summary="Add substance definition to draft",
)
async def config_substance_add(
    request: Request,
    name: Annotated[str, Form()] = "Signal",
    is_toxin: Annotated[str, Form()] = "false",
    lethal: Annotated[str, Form()] = "false",
    repellent: Annotated[str, Form()] = "false",
    synthesis_duration: Annotated[int, Form()] = 3,
    aftereffect_ticks: Annotated[int, Form()] = 0,
    lethality_rate: Annotated[float, Form()] = 0.0,
    repellent_walk_ticks: Annotated[int, Form()] = 3,
    energy_cost_per_tick: Annotated[float, Form()] = 1.0,
    irreversible: Annotated[str, Form()] = "false",
) -> Any:
    """Add a substance definition to the draft.

    Args:
        request: Starlette request.
        name: Substance name.
        is_toxin: ``"true"`` for toxins.
        lethal: ``"true"`` for lethal toxins.
        repellent: ``"true"`` for repellent toxins.
        synthesis_duration: Production ticks.
        aftereffect_ticks: Linger duration.
        lethality_rate: Population eliminated per tick.
        repellent_walk_ticks: Random-walk duration.
        energy_cost_per_tick: Energy drain per active tick.
        irreversible: ``"true"`` to keep the substance active after first activation.

    Returns:
        Any: Response object.
    """
    draft = get_draft()
    if len(draft.substance_definitions) >= 16:
        logger.warning("Rule-of-16 rejected substance creation")
        raise HTTPException(status_code=400, detail="Rule of 16: maximum substances reached.")
    new_id = len(draft.substance_definitions)
    toxin_flag = is_toxin.lower() in ("true", "1", "yes", "on")
    lethal_flag = lethal.lower() in ("true", "1", "yes", "on")
    repellent_flag = repellent.lower() in ("true", "1", "yes", "on")
    irreversible_flag = irreversible.lower() in ("true", "1", "yes", "on")
    draft.substance_definitions.append(
        SubstanceDefinition(
            substance_id=new_id,
            name=name,
            is_toxin=toxin_flag,
            lethal=lethal_flag,
            repellent=repellent_flag,
            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=irreversible_flag,
        )
    )
    logger.info(
        "Substance added via API (substance_id=%d, name=%s, is_toxin=%s)", new_id, name, toxin_flag
    )
    return templates.TemplateResponse(
        request,
        "partials/substance_config.html",
        {"substances": draft.substance_definitions},
    )

config_substance_delete(substance_id) async

Remove a substance definition from the draft.

Parameters:

Name Type Description Default
substance_id int

The substance_id to remove.

required

Returns:

Name Type Description
HTMLResponse HTMLResponse

Empty string (HTMX removes the row).

Raises:

Type Description
HTTPException

404 if substance_id not found.

Source code in src/phids/api/main.py
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
@app.delete(
    "/api/config/substances/{substance_id}",
    response_class=HTMLResponse,
    summary="Delete substance definition",
)
async def config_substance_delete(substance_id: int) -> HTMLResponse:
    """Remove a substance definition from the draft.

    Args:
        substance_id: The ``substance_id`` to remove.

    Returns:
        HTMLResponse: Empty string (HTMX removes the row).

    Raises:
        HTTPException: 404 if substance_id not found.
    """
    draft = get_draft()
    idx = next(
        (i for i, s in enumerate(draft.substance_definitions) if s.substance_id == substance_id),
        None,
    )
    if idx is None:
        logger.warning("Substance delete requested for unknown substance_id=%d", substance_id)
        raise HTTPException(status_code=404, detail=f"Substance {substance_id} not found.")
    del draft.substance_definitions[idx]
    logger.info("Substance deleted via API (substance_id=%d)", substance_id)
    return HTMLResponse(content="")

config_substance_update(request, substance_id, name=None, type_label=None, synthesis_duration=None, aftereffect_ticks=None, lethality_rate=None, repellent_walk_ticks=None, energy_cost_per_tick=None, irreversible=None) async

Patch a substance definition in the draft.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
substance_id int

Target substance id.

required
name Annotated[str | None, Form()]

Updated name (optional).

None
type_label Annotated[str | None, Form()]

One of "Signal", "Lethal Toxin", "Repellent Toxin", "Toxin" (optional).

None
synthesis_duration Annotated[int | None, Form()]

Updated synthesis duration (optional).

None
aftereffect_ticks Annotated[int | None, Form()]

Updated aftereffect ticks (optional).

None
lethality_rate Annotated[float | None, Form()]

Updated lethality rate (optional).

None
repellent_walk_ticks Annotated[int | None, Form()]

Updated random-walk duration (optional).

None
energy_cost_per_tick Annotated[float | None, Form()]

Updated energy drain per tick (optional).

None
irreversible Annotated[str | None, Form()]

Updated irreversible activation flag (optional).

None

Returns:

Name Type Description
TemplateResponse Any

Updated substance config table partial.

Raises:

Type Description
HTTPException

404 if substance_id not found.

Source code in src/phids/api/main.py
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
@app.put(
    "/api/config/substances/{substance_id}",
    response_class=HTMLResponse,
    summary="Update substance definition row",
)
async def config_substance_update(
    request: Request,
    substance_id: int,
    name: Annotated[str | None, Form()] = None,
    type_label: Annotated[str | None, Form()] = None,
    synthesis_duration: Annotated[int | None, Form()] = None,
    aftereffect_ticks: Annotated[int | None, Form()] = None,
    lethality_rate: Annotated[float | None, Form()] = None,
    repellent_walk_ticks: Annotated[int | None, Form()] = None,
    energy_cost_per_tick: Annotated[float | None, Form()] = None,
    irreversible: Annotated[str | None, Form()] = None,
) -> Any:
    """Patch a substance definition in the draft.

    Args:
        request: Starlette request.
        substance_id: Target substance id.
        name: Updated name (optional).
        type_label: One of ``"Signal"``, ``"Lethal Toxin"``,
            ``"Repellent Toxin"``, ``"Toxin"`` (optional).
        synthesis_duration: Updated synthesis duration (optional).
        aftereffect_ticks: Updated aftereffect ticks (optional).
        lethality_rate: Updated lethality rate (optional).
        repellent_walk_ticks: Updated random-walk duration (optional).
        energy_cost_per_tick: Updated energy drain per tick (optional).
        irreversible: Updated irreversible activation flag (optional).

    Returns:
        TemplateResponse: Updated substance config table partial.

    Raises:
        HTTPException: 404 if substance_id not found.
    """
    draft = get_draft()
    idx = next(
        (i for i, s in enumerate(draft.substance_definitions) if s.substance_id == substance_id),
        None,
    )
    if idx is None:
        logger.warning("Substance update requested for unknown substance_id=%d", substance_id)
        raise HTTPException(status_code=404, detail=f"Substance {substance_id} not found.")

    sd = draft.substance_definitions[idx]
    if name is not None:
        sd.name = name
    if type_label is not None:
        sd.is_toxin = type_label in ("Lethal Toxin", "Repellent Toxin", "Toxin")
        sd.lethal = type_label == "Lethal Toxin"
        sd.repellent = type_label == "Repellent Toxin"
    if synthesis_duration is not None:
        sd.synthesis_duration = max(1, synthesis_duration)
    if aftereffect_ticks is not None:
        sd.aftereffect_ticks = max(0, aftereffect_ticks)
    if lethality_rate is not None:
        sd.lethality_rate = max(0.0, lethality_rate)
    if repellent_walk_ticks is not None:
        sd.repellent_walk_ticks = max(0, repellent_walk_ticks)
    if energy_cost_per_tick is not None:
        sd.energy_cost_per_tick = max(0.0, energy_cost_per_tick)
    if irreversible is not None:
        sd.irreversible = irreversible.lower() in ("true", "1", "yes", "on")
    logger.debug("Substance updated via API (substance_id=%d, name=%s)", substance_id, sd.name)

    return templates.TemplateResponse(
        request,
        "partials/substance_config.html",
        {"substances": draft.substance_definitions},
    )

config_trigger_rule_add(request, flora_species_id, predator_species_id, substance_id, min_predator_population=5, activation_condition_json='') async

Add a new trigger rule to the draft.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
flora_species_id Annotated[int, Form()]

Flora species index.

required
predator_species_id Annotated[int, Form()]

Predator species index.

required
substance_id Annotated[int, Form()]

Substance to synthesise.

required
min_predator_population Annotated[int, Form()]

Minimum swarm size to trigger.

5
activation_condition_json Annotated[str, Form()]

Optional JSON condition tree controlling activation after synthesis completes.

''

Returns:

Name Type Description
TemplateResponse Any

Updated trigger rules partial.

Source code in src/phids/api/main.py
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
@app.post("/api/config/trigger-rules", response_class=HTMLResponse, summary="Add a trigger rule")
async def config_trigger_rule_add(
    request: Request,
    flora_species_id: Annotated[int, Form()],
    predator_species_id: Annotated[int, Form()],
    substance_id: Annotated[int, Form()],
    min_predator_population: Annotated[int, Form()] = 5,
    activation_condition_json: Annotated[str, Form()] = "",
) -> Any:
    """Add a new trigger rule to the draft.

    Args:
        request: Starlette request.
        flora_species_id: Flora species index.
        predator_species_id: Predator species index.
        substance_id: Substance to synthesise.
        min_predator_population: Minimum swarm size to trigger.
        activation_condition_json: Optional JSON condition tree controlling
            activation after synthesis completes.

    Returns:
        TemplateResponse: Updated trigger rules partial.
    """
    draft = get_draft()
    draft.add_trigger_rule(
        flora_species_id=flora_species_id,
        predator_species_id=predator_species_id,
        substance_id=substance_id,
        min_predator_population=max(1, min_predator_population),
        activation_condition=_parse_activation_condition_json(activation_condition_json),
    )
    logger.info(
        "Trigger rule added via API (flora_species_id=%d, predator_species_id=%d, substance_id=%d)",
        flora_species_id,
        predator_species_id,
        substance_id,
    )
    return templates.TemplateResponse(
        request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
    )

config_trigger_rule_condition_child_add(request, index, node_kind, parent_path='') async

Append a new child node to an existing group condition.

Source code in src/phids/api/main.py
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
@app.post(
    "/api/config/trigger-rules/{index}/condition/child",
    response_class=HTMLResponse,
    summary="Append a child node to a trigger-rule condition group",
)
async def config_trigger_rule_condition_child_add(
    request: Request,
    index: int,
    node_kind: Annotated[str, Form()],
    parent_path: Annotated[str, Form()] = "",
) -> Any:
    """Append a new child node to an existing group condition."""
    draft = get_draft()
    rule = _trigger_rule_by_index(draft, index)
    try:
        draft.append_trigger_rule_condition_child(
            index,
            parent_path,
            _default_activation_condition_for_rule(draft, rule, node_kind),
        )
    except IndexError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return templates.TemplateResponse(
        request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
    )

config_trigger_rule_condition_delete(request, index, path='') async

Delete one activation-condition node or clear the whole tree.

Source code in src/phids/api/main.py
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
@app.post(
    "/api/config/trigger-rules/{index}/condition/delete",
    response_class=HTMLResponse,
    summary="Delete a node from a trigger-rule condition tree",
)
async def config_trigger_rule_condition_delete(
    request: Request,
    index: int,
    path: Annotated[str, Form()] = "",
) -> Any:
    """Delete one activation-condition node or clear the whole tree."""
    draft = get_draft()
    _trigger_rule_by_index(draft, index)
    try:
        draft.delete_trigger_rule_condition_node(index, path)
    except IndexError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return templates.TemplateResponse(
        request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
    )

config_trigger_rule_condition_node_update(request, index, path, kind=None, predator_species_id=None, min_predator_population=None, substance_id=None, signal_id=None, min_concentration=None) async

Update or replace a node in a trigger-rule activation-condition tree.

Source code in src/phids/api/main.py
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
@app.put(
    "/api/config/trigger-rules/{index}/condition/node",
    response_class=HTMLResponse,
    summary="Update one node in a trigger-rule condition tree",
)
async def config_trigger_rule_condition_node_update(
    request: Request,
    index: int,
    path: Annotated[str, Form()],
    kind: Annotated[str | None, Form()] = None,
    predator_species_id: Annotated[int | None, Form()] = None,
    min_predator_population: Annotated[int | None, Form()] = None,
    substance_id: Annotated[int | None, Form()] = None,
    signal_id: Annotated[int | None, Form()] = None,
    min_concentration: Annotated[float | None, Form()] = None,
) -> Any:
    """Update or replace a node in a trigger-rule activation-condition tree."""
    draft = get_draft()
    rule = _trigger_rule_by_index(draft, index)

    if rule.activation_condition is None:
        if kind is None or path:
            raise HTTPException(
                status_code=400, detail="Trigger rule has no activation condition to update."
            )
        draft.set_trigger_rule_activation_condition(
            index,
            _default_activation_condition_for_rule(draft, rule, kind),
        )
        return templates.TemplateResponse(
            request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
        )

    try:
        current_node: dict[str, Any] = rule.activation_condition
        if path:
            path_indices = [int(part) for part in path.split(".") if part != ""]
            for child_index in path_indices:
                children = current_node.get("conditions")
                if current_node.get("kind") not in {"all_of", "any_of"} or not isinstance(
                    children, list
                ):
                    raise IndexError("Condition path traversed into a non-group node.")
                next_node = children[child_index]
                if not isinstance(next_node, dict):
                    raise IndexError("Condition path resolved to an invalid child node.")
                current_node = next_node

        if kind is not None and current_node.get("kind") != kind:
            replacement = _default_activation_condition_for_rule(draft, rule, kind)
            draft.replace_trigger_rule_condition_node(index, path, replacement)
        else:
            updates: dict[str, object] = {}
            if current_node.get("kind") == "enemy_presence":
                if predator_species_id is not None:
                    updates["predator_species_id"] = predator_species_id
                if min_predator_population is not None:
                    updates["min_predator_population"] = max(1, min_predator_population)
            elif current_node.get("kind") == "substance_active":
                if substance_id is not None:
                    updates["substance_id"] = substance_id
            elif current_node.get("kind") == "environmental_signal":
                if signal_id is not None:
                    updates["signal_id"] = signal_id
                if min_concentration is not None:
                    updates["min_concentration"] = max(0.0, min_concentration)
            elif current_node.get("kind") in {"all_of", "any_of"} and kind is not None:
                updates["kind"] = kind

            if updates:
                draft.update_trigger_rule_condition_node(index, path, **updates)
    except IndexError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    return templates.TemplateResponse(
        request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
    )

config_trigger_rule_condition_root(request, index, node_kind) async

Create the root activation condition for one trigger rule.

Source code in src/phids/api/main.py
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
@app.post(
    "/api/config/trigger-rules/{index}/condition/root",
    response_class=HTMLResponse,
    summary="Create or replace a trigger rule root condition",
)
async def config_trigger_rule_condition_root(
    request: Request,
    index: int,
    node_kind: Annotated[str, Form()],
) -> Any:
    """Create the root activation condition for one trigger rule."""
    draft = get_draft()
    rule = _trigger_rule_by_index(draft, index)
    draft.set_trigger_rule_activation_condition(
        index,
        _default_activation_condition_for_rule(draft, rule, node_kind),
    )
    return templates.TemplateResponse(
        request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
    )

config_trigger_rule_delete(request, index) async

Remove a trigger rule from the draft.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
index int

Rule position in the list.

required

Returns:

Name Type Description
TemplateResponse Any

Updated trigger rules partial.

Raises:

Type Description
HTTPException

404 if index out of range.

Source code in src/phids/api/main.py
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
@app.delete(
    "/api/config/trigger-rules/{index}",
    response_class=HTMLResponse,
    summary="Delete a trigger rule",
)
async def config_trigger_rule_delete(request: Request, index: int) -> Any:
    """Remove a trigger rule from the draft.

    Args:
        request: Starlette request.
        index: Rule position in the list.

    Returns:
        TemplateResponse: Updated trigger rules partial.

    Raises:
        HTTPException: 404 if index out of range.
    """
    draft = get_draft()
    try:
        draft.remove_trigger_rule(index)
    except IndexError as exc:
        logger.warning("Trigger rule delete requested for unknown index=%d", index)
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return templates.TemplateResponse(
        request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
    )

config_trigger_rule_update(request, index, flora_species_id=None, predator_species_id=None, substance_id=None, min_predator_population=None, activation_condition_json=None) async

Update an existing trigger rule in the draft.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
index int

Rule position in the list.

required
flora_species_id Annotated[int | None, Form()]

New flora species id (optional).

None
predator_species_id Annotated[int | None, Form()]

New predator species id (optional).

None
substance_id Annotated[int | None, Form()]

New substance id (optional).

None
min_predator_population Annotated[int | None, Form()]

New minimum population (optional).

None
activation_condition_json Annotated[str | None, Form()]

Optional JSON condition tree.

None

Returns:

Name Type Description
TemplateResponse Any

Updated trigger rules partial.

Raises:

Type Description
HTTPException

404 if index out of range.

Source code in src/phids/api/main.py
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
@app.put(
    "/api/config/trigger-rules/{index}",
    response_class=HTMLResponse,
    summary="Update a trigger rule",
)
async def config_trigger_rule_update(
    request: Request,
    index: int,
    flora_species_id: Annotated[int | None, Form()] = None,
    predator_species_id: Annotated[int | None, Form()] = None,
    substance_id: Annotated[int | None, Form()] = None,
    min_predator_population: Annotated[int | None, Form()] = None,
    activation_condition_json: Annotated[str | None, Form()] = None,
) -> Any:
    """Update an existing trigger rule in the draft.

    Args:
        request: Starlette request.
        index: Rule position in the list.
        flora_species_id: New flora species id (optional).
        predator_species_id: New predator species id (optional).
        substance_id: New substance id (optional).
        min_predator_population: New minimum population (optional).
        activation_condition_json: Optional JSON condition tree.

    Returns:
        TemplateResponse: Updated trigger rules partial.

    Raises:
        HTTPException: 404 if index out of range.
    """
    draft = get_draft()
    if index < 0 or index >= len(draft.trigger_rules):
        logger.warning("Trigger rule update requested for unknown index=%d", index)
        raise HTTPException(status_code=404, detail=f"Trigger rule {index} not found.")

    draft.update_trigger_rule(
        index,
        flora_species_id=flora_species_id,
        predator_species_id=predator_species_id,
        substance_id=substance_id,
        min_predator_population=min_predator_population,
        activation_condition=(
            _parse_activation_condition_json(activation_condition_json)
            if activation_condition_json is not None
            else None
        ),
    )
    logger.debug("Trigger rule updated via API (index=%d)", index)
    return templates.TemplateResponse(
        request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
    )

export_telemetry_csv() async

Stream Lotka-Volterra analytics as a downloadable CSV file.

Returns:

Name Type Description
Response Response

FastAPI response containing CSV bytes and headers.

Source code in src/phids/api/main.py
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
@app.get("/api/telemetry/export/csv", summary="Export telemetry as CSV")
async def export_telemetry_csv() -> Response:
    """Stream Lotka-Volterra analytics as a downloadable CSV file.

    Returns:
        Response: FastAPI response containing CSV bytes and headers.
    """
    loop = _get_loop()

    def _build_csv_payload() -> tuple[bytes, int]:
        df = loop.telemetry.dataframe
        return export_bytes_csv(df), int(df.height)

    data, rows = await run_in_threadpool(_build_csv_payload)
    logger.info("Telemetry exported as CSV (%d rows)", rows)
    return Response(
        content=data,
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=telemetry.csv"},
    )

export_telemetry_format(data_type, format='csv', prey_species_id=0, predator_species_id=0, columns=None, flora_ids=None, predator_ids=None, title=None, x_label=None, y_label=None, x_max=None, y_max=None, tick_interval=1) async

Export telemetry data as CSV, LaTeX table, PGFPlots TikZ source, or PNG.

Supported data_type values are timeseries (per-species population time series), phasespace (Lotka-Volterra phase-space trajectory), defense_economy (defense-cost to energy ratio), and biomass_stack (stacked flora biomass proxy). Supported format values are csv, tex_table, tex_tikz, and png. All export formats are generated server-side using the headless matplotlib Agg backend and the PGFPlots template generator; no LaTeX installation is required on the server for TikZ export.

Parameters:

Name Type Description Default
data_type str

Chart type — timeseries or phasespace.

required
format str

Output format — csv, tex_table, tex_tikz, or png.

'csv'
prey_species_id int

Flora species id for phase-space x-axis.

0
predator_species_id int

Predator species id for phase-space y-axis.

0

Returns:

Name Type Description
Response Response

File download response with appropriate Content-Type and

Response

Content-Disposition headers.

Raises:

Type Description
HTTPException

404 if no simulation is loaded; 400 if format is unknown.

Source code in src/phids/api/main.py
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
@app.get(
    "/api/export/{data_type}",
    summary="Export telemetry data in academic formats",
)
async def export_telemetry_format(
    data_type: str,
    format: str = "csv",  # noqa: A002
    prey_species_id: int = 0,
    predator_species_id: int = 0,
    columns: str | None = None,
    flora_ids: str | None = None,
    predator_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,
    tick_interval: int = 1,
) -> Response:
    """Export telemetry data as CSV, LaTeX table, PGFPlots TikZ source, or PNG.

    Supported ``data_type`` values are ``timeseries`` (per-species population
    time series), ``phasespace`` (Lotka-Volterra phase-space trajectory),
    ``defense_economy`` (defense-cost to energy ratio), and ``biomass_stack``
    (stacked flora biomass proxy).
    Supported ``format`` values are ``csv``, ``tex_table``, ``tex_tikz``, and
    ``png``. All export formats are generated server-side using the headless
    matplotlib Agg backend and the PGFPlots template generator; no LaTeX
    installation is required on the server for TikZ export.

    Args:
        data_type: Chart type — ``timeseries`` or ``phasespace``.
        format: Output format — ``csv``, ``tex_table``, ``tex_tikz``, or ``png``.
        prey_species_id: Flora species id for phase-space x-axis.
        predator_species_id: Predator species id for phase-space y-axis.

    Returns:
        Response: File download response with appropriate Content-Type and
        Content-Disposition headers.

    Raises:
        HTTPException: 404 if no simulation is loaded; 400 if format is unknown.
    """
    if _sim_loop is None:
        raise HTTPException(status_code=404, detail="No simulation loaded.")

    normalized_data_type = "defense_economy" if data_type == "metabolic" else data_type
    if normalized_data_type not in {"timeseries", "phasespace", "defense_economy", "biomass_stack"}:
        raise HTTPException(
            status_code=400,
            detail=(
                f"Unknown data_type '{data_type}'. Use timeseries, phasespace, defense_economy, biomass_stack, or metabolic."
            ),
        )

    if tick_interval < 1:
        raise HTTPException(status_code=400, detail="tick_interval must be >= 1")

    rows = _sim_loop.telemetry._rows
    flora_names: dict[int, str] = {sp.species_id: sp.name for sp in _sim_loop.config.flora_species}
    predator_names: dict[int, str] = {
        sp.species_id: sp.name for sp in _sim_loop.config.predator_species
    }
    filtered_rows = filter_telemetry_rows(rows, flora_ids=flora_ids, predator_ids=predator_ids)

    if format == "csv":

        def _build_export_csv() -> bytes:
            df = telemetry_to_dataframe(filtered_rows)
            df = filter_dataframe_columns(df, columns)
            df = decimate_dataframe(df, tick_interval)
            csv_text = cast(str, df.to_csv(index=False))
            return csv_text.encode("utf-8")

        data = await run_in_threadpool(_build_export_csv)
        filename = f"phids_{normalized_data_type}.csv"
        media_type = "text/csv"
    elif format == "tex_table":

        def _build_export_tex_table() -> bytes:
            return export_bytes_tex_table(
                rows,
                columns=columns,
                include_flora_ids=flora_ids,
                include_predator_ids=predator_ids,
                tick_interval=tick_interval,
            )

        data = await run_in_threadpool(_build_export_tex_table)
        filename = f"phids_{normalized_data_type}_table.tex"
        media_type = "text/plain"
    elif format == "tex_tikz":
        try:

            def _build_export_tikz() -> str:
                return generate_tikz_str(
                    filtered_rows,
                    normalized_data_type,
                    flora_names=flora_names,
                    predator_names=predator_names,
                    prey_species_id=prey_species_id,
                    predator_species_id=predator_species_id,
                    include_flora_ids=flora_ids,
                    include_predator_ids=predator_ids,
                    title=title,
                    x_label=x_label,
                    y_label=y_label,
                    x_max=x_max,
                    y_max=y_max,
                )

            tikz = await run_in_threadpool(_build_export_tikz)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        data = tikz.encode("utf-8")
        filename = f"phids_{normalized_data_type}.tex"
        media_type = "text/plain"
    elif format == "png":
        try:

            def _build_export_png() -> bytes:
                return generate_png_bytes(
                    filtered_rows,
                    normalized_data_type,
                    flora_names=flora_names,
                    predator_names=predator_names,
                    prey_species_id=prey_species_id,
                    predator_species_id=predator_species_id,
                    include_flora_ids=flora_ids,
                    include_predator_ids=predator_ids,
                    title=title,
                    x_label=x_label,
                    y_label=y_label,
                    x_max=x_max,
                    y_max=y_max,
                )

            data = await run_in_threadpool(_build_export_png)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        filename = f"phids_{normalized_data_type}.png"
        media_type = "image/png"
    else:
        raise HTTPException(
            status_code=400,
            detail=f"Unknown format '{format}'. Use csv, tex_table, tex_tikz, or png.",
        )

    logger.info("Export (%s/%s): %d bytes", normalized_data_type, format, len(data))
    return Response(
        content=data,
        media_type=media_type,
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )

export_telemetry_json() async

Stream Lotka-Volterra analytics as a downloadable NDJSON file.

Returns:

Name Type Description
Response Response

FastAPI response containing NDJSON bytes and headers.

Source code in src/phids/api/main.py
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
@app.get("/api/telemetry/export/json", summary="Export telemetry as JSON")
async def export_telemetry_json() -> Response:
    """Stream Lotka-Volterra analytics as a downloadable NDJSON file.

    Returns:
        Response: FastAPI response containing NDJSON bytes and headers.
    """
    loop = _get_loop()

    def _build_json_payload() -> tuple[bytes, int]:
        df = loop.telemetry.dataframe
        return export_bytes_json(df), int(df.height)

    data, rows = await run_in_threadpool(_build_json_payload)
    logger.info("Telemetry exported as NDJSON (%d rows)", rows)
    return Response(
        content=data,
        media_type="application/x-ndjson",
        headers={"Content-Disposition": "attachment; filename=telemetry.ndjson"},
    )

load_scenario(config) async

Initialise the simulation loop with the provided configuration.

Parameters:

Name Type Description Default
config SimulationConfig

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

required

Returns:

Name Type Description
dict dict[str, Any]

Confirmation message including grid dimensions.

Source code in src/phids/api/main.py
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
@app.post("/api/scenario/load", summary="Load simulation scenario")
async def load_scenario(config: SimulationConfig) -> dict[str, Any]:
    """Initialise the simulation loop with the provided configuration.

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

    Returns:
        dict: Confirmation message including grid dimensions.
    """
    global _sim_loop, _sim_task  # noqa: PLW0603

    # Cancel any running simulation
    if _sim_task is not None and not _sim_task.done():
        logger.info("Cancelling existing background simulation task before loading a new scenario")
        _sim_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await _sim_task

    _sim_loop = SimulationLoop(config)
    _set_simulation_substance_names(config)
    logger.info(
        "Scenario loaded: %dx%d grid, %d flora species, %d predator species",
        config.grid_width,
        config.grid_height,
        len(config.flora_species),
        len(config.predator_species),
    )
    return {
        "message": "Scenario loaded.",
        "grid_width": config.grid_width,
        "grid_height": config.grid_height,
    }

log_http_requests(request, call_next) async

Log API/UI request timing with low overhead.

DEBUG logging is emitted for successful API, HTMX, and UI requests. WARNING logging is emitted for client/server error responses.

Source code in src/phids/api/main.py
 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
@app.middleware("http")
async def log_http_requests(
    request: Request,
    call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
    """Log API/UI request timing with low overhead.

    DEBUG logging is emitted for successful API, HTMX, and UI requests.
    WARNING logging is emitted for client/server error responses.
    """
    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

matrix_diet(request, predator_idx, flora_idx, compatible='toggle') async

Toggle or set a diet compatibility matrix cell.

Parameters:

Name Type Description Default
request Request

Starlette request.

required
predator_idx Annotated[int, Form()]

Row index (predator species list position).

required
flora_idx Annotated[int, Form()]

Column index (flora species list position).

required
compatible Annotated[str, Form()]

"toggle" to flip or "true"/"false" to set.

'toggle'

Returns:

Name Type Description
TemplateResponse Any

Updated diet matrix partial.

Source code in src/phids/api/main.py
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
@app.post("/api/matrices/diet", response_class=HTMLResponse, summary="Toggle diet matrix cell")
async def matrix_diet(
    request: Request,
    predator_idx: Annotated[int, Form()],
    flora_idx: Annotated[int, Form()],
    compatible: Annotated[str, Form()] = "toggle",
) -> Any:
    """Toggle or set a diet compatibility matrix cell.

    Args:
        request: Starlette request.
        predator_idx: Row index (predator species list position).
        flora_idx: Column index (flora species list position).
        compatible: ``"toggle"`` to flip or ``"true"``/``"false"`` to set.

    Returns:
        TemplateResponse: Updated diet matrix partial.
    """
    draft = get_draft()
    if predator_idx < len(draft.diet_matrix) and flora_idx < len(draft.diet_matrix[predator_idx]):
        if compatible == "toggle":
            draft.diet_matrix[predator_idx][flora_idx] = not draft.diet_matrix[predator_idx][
                flora_idx
            ]
        else:
            draft.diet_matrix[predator_idx][flora_idx] = compatible.lower() in ("true", "1", "on")
        logger.debug(
            "Diet matrix updated (predator_idx=%d, flora_idx=%d, compatible=%s)",
            predator_idx,
            flora_idx,
            draft.diet_matrix[predator_idx][flora_idx],
        )
    else:
        logger.warning(
            "Diet matrix update ignored for out-of-range indices (predator_idx=%d, flora_idx=%d)",
            predator_idx,
            flora_idx,
        )
    return templates.TemplateResponse(
        request,
        "partials/diet_matrix.html",
        {
            "flora_species": draft.flora_species,
            "predator_species": draft.predator_species,
            "diet_matrix": draft.diet_matrix,
        },
    )

pause_simulation(request) async

Toggle pause state of the running simulation.

Returns:

Name Type Description
dict Any

Message indicating current paused/resumed state.

Source code in src/phids/api/main.py
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
@app.post("/api/simulation/pause", summary="Pause or resume simulation")
async def pause_simulation(request: Request) -> Any:
    """Toggle pause state of the running simulation.

    Returns:
        dict: Message indicating current paused/resumed state.
    """
    loop = _get_loop()
    loop.pause()
    state = "paused" if loop.paused else "resumed"
    logger.info("Simulation %s via API", state)
    if _is_htmx_request(request):
        return HTMLResponse(content=_render_status_badge_html())
    return {"message": f"Simulation {state}."}

placement_data() async

Return current plant and swarm placements as JSON for canvas rendering.

Returns:

Name Type Description
JSONResponse JSONResponse

JSON with plants, swarms, grid_width, grid_height, flora_species, predator_species.

Source code in src/phids/api/main.py
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
@app.get("/api/config/placements/data", summary="Get placement data as JSON")
async def placement_data() -> JSONResponse:
    """Return current plant and swarm placements as JSON for canvas rendering.

    Returns:
        JSONResponse: JSON with ``plants``, ``swarms``, ``grid_width``,
            ``grid_height``, ``flora_species``, ``predator_species``.
    """
    draft = get_draft()
    plants = [
        {"idx": i, "species_id": p.species_id, "x": p.x, "y": p.y, "energy": p.energy}
        for i, p in enumerate(draft.initial_plants)
    ]
    swarms = [
        {
            "idx": i,
            "species_id": s.species_id,
            "x": s.x,
            "y": s.y,
            "population": s.population,
            "energy": s.energy,
        }
        for i, s in enumerate(draft.initial_swarms)
    ]
    flora = [
        {"species_id": getattr(fp, "species_id", i), "name": getattr(fp, "name", f"Flora {i}")}
        for i, fp in enumerate(draft.flora_species)
    ]
    predators = [
        {"species_id": getattr(pp, "species_id", i), "name": getattr(pp, "name", f"Pred {i}")}
        for i, pp in enumerate(draft.predator_species)
    ]
    mycorrhizal_links = _build_draft_mycorrhizal_links(draft)
    return JSONResponse(
        content={
            "plants": plants,
            "swarms": swarms,
            "grid_width": draft.grid_width,
            "grid_height": draft.grid_height,
            "flora_species": flora,
            "predator_species": predators,
            "mycorrhizal_links": mycorrhizal_links,
        }
    )

reset_simulation(request) async

Recreate the live simulation loop from the currently loaded config.

Returns:

Type Description
Any

dict[str, Any]: Confirmation and reset tick.

Source code in src/phids/api/main.py
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
@app.post("/api/simulation/reset", summary="Reset simulation to the loaded scenario")
async def reset_simulation(request: Request) -> Any:
    """Recreate the live simulation loop from the currently loaded config.

    Returns:
        dict[str, Any]: Confirmation and reset tick.
    """
    global _sim_loop, _sim_task  # noqa: PLW0603

    loop = _get_loop()

    if _sim_task is not None and not _sim_task.done():
        logger.info("Cancelling existing background simulation task before reset")
        _sim_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await _sim_task

    _sim_loop = SimulationLoop(loop.config)
    _sim_task = None
    _set_simulation_substance_names(loop.config)
    logger.info("Simulation reset to the loaded scenario")
    if _is_htmx_request(request):
        return HTMLResponse(content=_render_status_badge_html())
    return {"message": "Simulation reset.", "tick": 0}

root(request) async

Render the PHIDS Control Centre root page.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered index.html with dashboard content.

Source code in src/phids/api/main.py
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
@app.get("/", response_class=HTMLResponse, summary="Main UI")
async def root(request: Request) -> Any:
    """Render the PHIDS Control Centre root page.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``index.html`` with dashboard content.
    """
    draft = get_draft()
    return templates.TemplateResponse(
        request,
        "index.html",
        {"scenario_name": draft.scenario_name},
    )

scenario_export() async

Serialise the draft state as a downloadable JSON file.

Returns:

Name Type Description
Response Response

JSON file download response.

Source code in src/phids/api/main.py
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
@app.get("/api/scenario/export", summary="Export draft as JSON")
async def scenario_export() -> Response:
    """Serialise the draft state as a downloadable JSON file.

    Returns:
        Response: JSON file download response.
    """
    draft = get_draft()
    try:
        config = draft.build_sim_config()
        data = json.dumps(config.model_dump(), indent=2)
    except (ValueError, AttributeError) as exc:
        logger.warning("Scenario export failed: %s", exc)
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    logger.info("Draft scenario exported (scenario_name=%s)", draft.scenario_name)
    return Response(
        content=data,
        media_type="application/json",
        headers={
            "Content-Disposition": (
                f'attachment; filename="{draft.scenario_name.replace(" ", "_")}.json"'
            )
        },
    )

scenario_import(file=File(...)) async

Parse an uploaded JSON scenario and replace the draft.

Parameters:

Name Type Description Default
file UploadFile

Uploaded .json file containing a SimulationConfig serialisation.

File(...)

Returns:

Name Type Description
JSONResponse JSONResponse

Confirmation with imported scenario grid dimensions.

Raises:

Type Description
HTTPException

422 if the JSON does not validate against :class:~phids.api.schemas.SimulationConfig.

Source code in src/phids/api/main.py
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
@app.post("/api/scenario/import", summary="Import scenario from JSON file")
async def scenario_import(file: UploadFile = File(...)) -> JSONResponse:
    """Parse an uploaded JSON scenario and replace the draft.

    Args:
        file: Uploaded ``.json`` file containing a ``SimulationConfig``
            serialisation.

    Returns:
        JSONResponse: Confirmation with imported scenario grid dimensions.

    Raises:
        HTTPException: 422 if the JSON does not validate against
            :class:`~phids.api.schemas.SimulationConfig`.
    """
    raw = await file.read()
    try:
        payload = json.loads(raw)
        config = SimulationConfig.model_validate(payload)
    except Exception as exc:
        logger.warning("Scenario import failed for file %s: %s", file.filename, exc)
        raise HTTPException(status_code=422, detail=f"Invalid scenario JSON: {exc}") from exc

    # Reconstruct trigger rules from the imported flora triggers
    imported_trigger_rules: list[TriggerRule] = []
    imported_substances: list[SubstanceDefinition] = []
    seen_substance_ids: set[int] = set()
    for flora_spec in config.flora_species:
        for trig in flora_spec.triggers:
            imported_trigger_rules.append(
                TriggerRule(
                    flora_species_id=flora_spec.species_id,
                    predator_species_id=trig.predator_species_id,
                    substance_id=trig.substance_id,
                    min_predator_population=trig.min_predator_population,
                    activation_condition=(
                        trig.activation_condition.model_dump(mode="json")
                        if trig.activation_condition is not None
                        else None
                    ),
                )
            )
            if trig.substance_id not in seen_substance_ids:
                seen_substance_ids.add(trig.substance_id)
                imported_substances.append(
                    SubstanceDefinition(
                        substance_id=trig.substance_id,
                        name=f"Substance {trig.substance_id}",
                        is_toxin=trig.is_toxin,
                        lethal=trig.lethal,
                        repellent=trig.repellent,
                        synthesis_duration=trig.synthesis_duration,
                        aftereffect_ticks=trig.aftereffect_ticks,
                        lethality_rate=trig.lethality_rate,
                        repellent_walk_ticks=trig.repellent_walk_ticks,
                        energy_cost_per_tick=trig.energy_cost_per_tick,
                        irreversible=trig.irreversible,
                        precursor_signal_id=(
                            trig.precursor_signal_ids[0]
                            if len(trig.precursor_signal_ids) == 1
                            else trig.precursor_signal_id
                        ),
                        min_predator_population=trig.min_predator_population,
                    )
                )

    new_draft = DraftState(
        scenario_name=(file.filename or "imported").replace(".json", ""),
        grid_width=config.grid_width,
        grid_height=config.grid_height,
        max_ticks=config.max_ticks,
        tick_rate_hz=config.tick_rate_hz,
        wind_x=config.wind_x,
        wind_y=config.wind_y,
        num_signals=config.num_signals,
        num_toxins=config.num_toxins,
        mycorrhizal_inter_species=config.mycorrhizal_inter_species,
        mycorrhizal_connection_cost=config.mycorrhizal_connection_cost,
        mycorrhizal_growth_interval_ticks=config.mycorrhizal_growth_interval_ticks,
        mycorrhizal_signal_velocity=config.mycorrhizal_signal_velocity,
        flora_species=list(config.flora_species),
        predator_species=list(config.predator_species),
        diet_matrix=[list(row) for row in config.diet_matrix.rows],
        trigger_rules=imported_trigger_rules,
        substance_definitions=imported_substances,
        initial_plants=[
            PlacedPlant(species_id=p.species_id, x=p.x, y=p.y, energy=p.energy)
            for p in config.initial_plants
        ],
        initial_swarms=[
            PlacedSwarm(
                species_id=s.species_id,
                x=s.x,
                y=s.y,
                population=s.population,
                energy=s.energy,
            )
            for s in config.initial_swarms
        ],
    )
    set_draft(new_draft)
    logger.info(
        "Scenario imported into draft (file=%s, grid=%dx%d, flora=%d, predators=%d)",
        file.filename,
        config.grid_width,
        config.grid_height,
        len(config.flora_species),
        len(config.predator_species),
    )
    return JSONResponse(
        content={
            "message": "Scenario imported.",
            "grid_width": config.grid_width,
            "grid_height": config.grid_height,
        }
    )

scenario_load_draft(request) async

Commit the current draft to the simulation engine.

Equivalent to POST /api/scenario/load but uses the server-side draft rather than a request body.

Returns:

Name Type Description
HTMLResponse Any

Updated status badge HTML fragment.

Raises:

Type Description
HTTPException

400 if draft is invalid or missing required species.

Source code in src/phids/api/main.py
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
@app.post(
    "/api/scenario/load-draft",
    response_class=HTMLResponse,
    summary="Load draft config into simulation engine",
)
async def scenario_load_draft(request: Request) -> Any:
    """Commit the current draft to the simulation engine.

    Equivalent to ``POST /api/scenario/load`` but uses the server-side
    draft rather than a request body.

    Returns:
        HTMLResponse: Updated status badge HTML fragment.

    Raises:
        HTTPException: 400 if draft is invalid or missing required species.
    """
    global _sim_loop, _sim_task  # noqa: PLW0603

    draft = get_draft()
    try:
        config = draft.build_sim_config()
    except (ValueError, Exception) as exc:
        logger.warning("Draft load into simulation failed: %s", exc)
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    if _sim_task is not None and not _sim_task.done():
        logger.info("Cancelling existing background simulation task before loading draft")
        _sim_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await _sim_task

    _sim_loop = SimulationLoop(config)
    _sim_task = None
    _set_simulation_substance_names(config, draft=draft)
    logger.info(
        "Draft loaded: %dx%d grid, %d flora, %d predators",
        config.grid_width,
        config.grid_height,
        len(config.flora_species),
        len(config.predator_species),
    )
    return HTMLResponse(content=_render_status_badge_html())

simulation_status() async

Return the current tick and simulation state flags.

Returns:

Name Type Description
SimulationStatusResponse SimulationStatusResponse

Pydantic response model with status.

Source code in src/phids/api/main.py
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
@app.get(
    "/api/simulation/status",
    response_model=SimulationStatusResponse,
    summary="Get simulation status",
)
async def simulation_status() -> SimulationStatusResponse:
    """Return the current tick and simulation state flags.

    Returns:
        SimulationStatusResponse: Pydantic response model with status.
    """
    loop = _get_loop()
    return SimulationStatusResponse(
        tick=loop.tick,
        running=loop.running,
        paused=loop.paused,
        terminated=loop.terminated,
        termination_reason=loop.termination_reason,
    )

simulation_stream(websocket) async

Stream grid state snapshots over WebSocket at each simulation tick.

The state is serialised with :mod:msgpack and compressed with :mod:zlib for compact binary transport. If no scenario is loaded the connection is closed with code 1008 (Policy Violation).

Source code in src/phids/api/main.py
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
@app.websocket("/ws/simulation/stream")
async def simulation_stream(websocket: WebSocket) -> None:
    """Stream grid state snapshots over WebSocket at each simulation tick.

    The state is serialised with :mod:`msgpack` and compressed with
    :mod:`zlib` for compact binary transport. If no scenario is loaded the
    connection is closed with code 1008 (Policy Violation).
    """
    await websocket.accept()
    logger.debug("WebSocket connected: /ws/simulation/stream")

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

    loop = _sim_loop
    last_tick = -1
    global _stream_cache_loop_id, _stream_cache_tick, _stream_cache_payload

    def _encoded_snapshot_bytes() -> bytes:
        """Return cached compressed bytes for the current loop tick."""
        global _stream_cache_loop_id, _stream_cache_tick, _stream_cache_payload
        loop_id = id(loop)
        if loop_id != _stream_cache_loop_id or loop.tick != _stream_cache_tick:
            snapshot = loop.get_state_snapshot()
            packed = msgpack.packb(snapshot, use_bin_type=True)
            _stream_cache_payload = zlib.compress(packed, level=1)
            _stream_cache_loop_id = loop_id
            _stream_cache_tick = loop.tick
        return _stream_cache_payload

    try:
        while True:
            if loop.terminated:
                # Send final state and close
                if loop.tick != last_tick:
                    await websocket.send_bytes(_encoded_snapshot_bytes())
                break

            if loop.tick != last_tick:
                await websocket.send_bytes(_encoded_snapshot_bytes())
                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 websocket.close()

start_simulation(request) async

Begin background execution of the simulation loop.

Returns:

Name Type Description
dict Any

Message confirming the simulation was started.

Source code in src/phids/api/main.py
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
@app.post("/api/simulation/start", summary="Start or resume simulation")
async def start_simulation(request: Request) -> Any:
    """Begin background execution of the simulation loop.

    Returns:
        dict: Message confirming the simulation was started.
    """
    global _sim_task  # noqa: PLW0603
    loop = _get_loop()

    if loop.running and not loop.paused:
        logger.info("Start requested while simulation was already running")
        if _is_htmx_request(request):
            return HTMLResponse(content=_render_status_badge_html())
        return {"message": "Simulation already running."}

    if loop.terminated:
        logger.warning(
            "Start requested for a terminated simulation (reason=%s)", loop.termination_reason
        )
        raise HTTPException(status_code=400, detail="Simulation has terminated.")

    loop.start()

    async def _bg() -> None:
        await loop.run()

    _sim_task = asyncio.create_task(_bg())
    logger.info("Background simulation task created")
    if _is_htmx_request(request):
        return HTMLResponse(content=_render_status_badge_html())
    return {"message": "Simulation started."}

step_simulation(request) async

Execute a single deterministic simulation tick.

Returns:

Type Description
Any

dict[str, Any]: Updated simulation status after the step.

Raises:

Type Description
HTTPException

If the simulation is running in the background or has terminated.

Source code in src/phids/api/main.py
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
@app.post("/api/simulation/step", summary="Advance simulation by one tick")
async def step_simulation(request: Request) -> Any:
    """Execute a single deterministic simulation tick.

    Returns:
        dict[str, Any]: Updated simulation status after the step.

    Raises:
        HTTPException: If the simulation is running in the background or has terminated.
    """
    loop = _get_loop()

    if _sim_task is not None and not _sim_task.done() and loop.running and not loop.paused:
        logger.warning("Single-step requested while simulation is already running")
        raise HTTPException(status_code=400, detail="Pause the simulation before stepping.")

    if loop.terminated:
        logger.warning(
            "Single-step requested for a terminated simulation (reason=%s)", loop.termination_reason
        )
        raise HTTPException(status_code=400, detail="Simulation has terminated.")

    result = await loop.step()
    logger.info(
        "Simulation advanced by one tick via API (tick=%d, terminated=%s)",
        loop.tick,
        result.terminated,
    )
    if _is_htmx_request(request):
        return HTMLResponse(content=_render_status_badge_html())
    return {
        "message": "Simulation advanced by one tick.",
        "tick": loop.tick,
        "terminated": loop.terminated,
        "termination_reason": loop.termination_reason,
    }

telemetry_chart(request) async

Return an SVG chart fragment for the HTMX polling target.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/telemetry_chart.html.

Source code in src/phids/api/main.py
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
@app.get("/api/telemetry", summary="Telemetry SVG chart partial")
async def telemetry_chart(request: Request) -> Any:
    """Return an SVG chart fragment for the HTMX polling target.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/telemetry_chart.html``.
    """
    if _sim_loop is None:
        svg = _build_telemetry_svg(None)
        legend = False
        latest_metrics = None
        live_summary = None
    else:
        svg = _build_telemetry_svg(_sim_loop.telemetry.dataframe)
        legend = True
        latest_metrics = _sim_loop.telemetry.get_latest_metrics()
        live_summary = _build_live_summary()

    return templates.TemplateResponse(
        request,
        "partials/telemetry_chart.html",
        {
            "svg_content": svg,
            "legend": legend,
            "latest_metrics": latest_metrics,
            "live_summary": live_summary,
        },
    )

telemetry_chartjs_data() async

Return per-species population and energy series as a Chart.js-compatible JSON payload.

Iterates over raw telemetry rows to extract per-species population and energy time series keyed by species_id. The response is consumed by the telemetry_tabs.html partial to populate line datasets and the Lotka-Volterra phase-space scatter canvas without additional server round-trips.

Returns:

Name Type Description
JSONResponse JSONResponse

Dict with keys labels, flora_ids, predator_ids,

JSONResponse

flora_names, predator_names, and series.

Source code in src/phids/api/main.py
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
@app.get("/api/telemetry/chartjs-data", summary="Per-species time-series data for Chart.js")
async def telemetry_chartjs_data() -> JSONResponse:
    """Return per-species population and energy series as a Chart.js-compatible JSON payload.

    Iterates over raw telemetry rows to extract per-species population and energy
    time series keyed by ``species_id``. The response is consumed by the
    ``telemetry_tabs.html`` partial to populate line datasets and the Lotka-Volterra
    phase-space scatter canvas without additional server round-trips.

    Returns:
        JSONResponse: Dict with keys ``labels``, ``flora_ids``, ``predator_ids``,
        ``flora_names``, ``predator_names``, and ``series``.
    """
    if _sim_loop is None:
        return JSONResponse({"labels": [], "flora_ids": [], "predator_ids": [], "series": {}})

    rows = _sim_loop.telemetry._rows
    species = _sim_loop.telemetry.get_species_ids()
    flora_ids = species["flora_ids"]
    predator_ids = species["predator_ids"]

    flora_names = {sp.species_id: sp.name for sp in _sim_loop.config.flora_species}
    predator_names = {sp.species_id: sp.name for sp in _sim_loop.config.predator_species}

    labels = [r["tick"] for r in rows]
    series: dict[str, list[float]] = {
        "flora_population": [float(r.get("flora_population", 0)) for r in rows],
        "predator_population": [float(r.get("predator_population", 0)) for r in rows],
        "total_flora_energy": [float(r.get("total_flora_energy", 0.0)) for r in rows],
    }
    for fid in flora_ids:
        series[f"plant_{fid}_pop"] = [
            float(r.get("plant_pop_by_species", {}).get(fid, 0)) for r in rows
        ]
        series[f"plant_{fid}_energy"] = [
            float(r.get("plant_energy_by_species", {}).get(fid, 0.0)) for r in rows
        ]
        series[f"defense_cost_{fid}"] = [
            float(r.get("defense_cost_by_species", {}).get(fid, 0.0)) for r in rows
        ]
    for pid in predator_ids:
        series[f"swarm_{pid}_pop"] = [
            float(r.get("swarm_pop_by_species", {}).get(pid, 0)) for r in rows
        ]

    return JSONResponse(
        {
            "labels": labels,
            "flora_ids": flora_ids,
            "predator_ids": predator_ids,
            "flora_names": {str(k): v for k, v in flora_names.items()},
            "predator_names": {str(k): v for k, v in predator_names.items()},
            "series": series,
        }
    )

telemetry_table_preview(request, columns=None, flora_ids=None, predator_ids=None, tick_interval=1, limit=200) async

Render an HTMX table preview aligned with current telemetry visibility filters.

Parameters:

Name Type Description Default
request Request

FastAPI request object.

required
columns str | None

Optional comma-delimited DataFrame columns to include.

None
flora_ids str | None

Optional comma-delimited flora species ids to include.

None
predator_ids str | None

Optional comma-delimited predator species ids to include.

None
limit int

Maximum preview rows for UI responsiveness.

200

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/telemetry_table_preview.html fragment.

Source code in src/phids/api/main.py
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
@app.get(
    "/api/telemetry/table_preview", response_class=HTMLResponse, summary="Telemetry table preview"
)
async def telemetry_table_preview(
    request: Request,
    columns: str | None = None,
    flora_ids: str | None = None,
    predator_ids: str | None = None,
    tick_interval: int = 1,
    limit: int = 200,
) -> Any:
    """Render an HTMX table preview aligned with current telemetry visibility filters.

    Args:
        request: FastAPI request object.
        columns: Optional comma-delimited DataFrame columns to include.
        flora_ids: Optional comma-delimited flora species ids to include.
        predator_ids: Optional comma-delimited predator species ids to include.
        limit: Maximum preview rows for UI responsiveness.

    Returns:
        TemplateResponse: Rendered ``partials/telemetry_table_preview.html`` fragment.
    """
    if _sim_loop is None:
        return templates.TemplateResponse(
            request,
            "partials/telemetry_table_preview.html",
            {"table_html": "", "empty_message": "No telemetry data available."},
        )

    rows = filter_telemetry_rows(
        _sim_loop.telemetry._rows, flora_ids=flora_ids, predator_ids=predator_ids
    )
    df = telemetry_to_dataframe(rows)
    df = filter_dataframe_columns(df, columns)
    df = decimate_dataframe(df, tick_interval)

    limit = max(1, min(limit, 1000))
    df = df.tail(limit)

    if df.empty:
        context = {"table_html": "", "empty_message": "No rows match current table filters."}
    else:
        table_html = df.to_html(
            index=False,
            classes="min-w-full text-[11px]",
            border=0,
            justify="left",
            float_format=lambda value: f"{value:.2f}",
        )
        context = {"table_html": table_html, "empty_message": ""}
    return templates.TemplateResponse(request, "partials/telemetry_table_preview.html", context)

ui_batch_dashboard(request) async

Render the Monte Carlo batch runner dashboard partial.

Parameters:

Name Type Description Default
request Request

FastAPI request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered batch_dashboard.html partial.

Source code in src/phids/api/main.py
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
@app.get("/ui/batch", response_class=HTMLResponse, summary="Batch runner dashboard")
async def ui_batch_dashboard(request: Request) -> Any:
    """Render the Monte Carlo batch runner dashboard partial.

    Args:
        request: FastAPI request object.

    Returns:
        TemplateResponse: Rendered ``batch_dashboard.html`` partial.
    """
    return templates.TemplateResponse(request, "batch_dashboard.html")

ui_biotope(request) async

Return the biotope configuration form partial.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/biotope_config.html.

Source code in src/phids/api/main.py
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
@app.get("/ui/biotope", response_class=HTMLResponse, summary="Biotope config partial")
async def ui_biotope(request: Request) -> Any:
    """Return the biotope configuration form partial.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/biotope_config.html``.
    """
    return templates.TemplateResponse(
        request,
        "partials/biotope_config.html",
        {"draft": get_draft()},
    )

ui_cell_details(x, y, expected_tick=None) 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 draft placement preview is returned.

Source code in src/phids/api/main.py
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
@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 draft placement preview is returned.
    """
    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)
        if _sim_loop is not None
        else _build_preview_cell_details(x, y)
    )
    return JSONResponse(content=payload)

ui_dashboard(request) async

Return the dashboard HTMX partial.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/dashboard.html.

Source code in src/phids/api/main.py
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
@app.get("/ui/dashboard", response_class=HTMLResponse, summary="Dashboard partial")
async def ui_dashboard(request: Request) -> Any:
    """Return the dashboard HTMX partial.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/dashboard.html``.
    """
    return templates.TemplateResponse(request, "partials/dashboard.html")

ui_diagnostics_backend(request) async

Render structured recent backend logs for the diagnostics rail.

Source code in src/phids/api/main.py
2161
2162
2163
2164
2165
2166
2167
2168
@app.get("/ui/diagnostics/backend", response_class=HTMLResponse, summary="Diagnostics backend tab")
async def ui_diagnostics_backend(request: Request) -> Any:
    """Render structured recent backend logs for the diagnostics rail."""
    return templates.TemplateResponse(
        request,
        "partials/diagnostics_backend.html",
        {"recent_logs": get_recent_logs(limit=120)},
    )

ui_diagnostics_frontend(request) async

Render the client-side diagnostics tab shell.

Source code in src/phids/api/main.py
2153
2154
2155
2156
2157
2158
@app.get(
    "/ui/diagnostics/frontend", response_class=HTMLResponse, summary="Diagnostics frontend tab"
)
async def ui_diagnostics_frontend(request: Request) -> Any:
    """Render the client-side diagnostics tab shell."""
    return templates.TemplateResponse(request, "partials/diagnostics_frontend.html")

ui_diagnostics_model(request) async

Render live model counters, telemetry, and energy-deficit watch.

Source code in src/phids/api/main.py
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
@app.get("/ui/diagnostics/model", response_class=HTMLResponse, summary="Diagnostics model tab")
async def ui_diagnostics_model(request: Request) -> Any:
    """Render live model counters, telemetry, and energy-deficit watch."""
    return templates.TemplateResponse(
        request,
        "partials/diagnostics_model.html",
        {
            "draft": get_draft(),
            "live_summary": _build_live_summary(),
            "latest_metrics": _sim_loop.telemetry.get_latest_metrics()
            if _sim_loop is not None
            else None,
            "energy_deficit_swarms": _build_energy_deficit_swarms(),
        },
    )

ui_diet_matrix(request) async

Return the diet compatibility matrix partial.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/diet_matrix.html.

Source code in src/phids/api/main.py
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
@app.get("/ui/diet-matrix", response_class=HTMLResponse, summary="Diet matrix partial")
async def ui_diet_matrix(request: Request) -> Any:
    """Return the diet compatibility matrix partial.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/diet_matrix.html``.
    """
    draft = get_draft()
    return templates.TemplateResponse(
        request,
        "partials/diet_matrix.html",
        {
            "flora_species": draft.flora_species,
            "predator_species": draft.predator_species,
            "diet_matrix": draft.diet_matrix,
        },
    )

ui_flora(request) async

Return the flora species table partial.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/flora_config.html.

Source code in src/phids/api/main.py
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
@app.get("/ui/flora", response_class=HTMLResponse, summary="Flora config partial")
async def ui_flora(request: Request) -> Any:
    """Return the flora species table partial.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/flora_config.html``.
    """
    draft = get_draft()
    return templates.TemplateResponse(
        request,
        "partials/flora_config.html",
        {"flora_species": draft.flora_species},
    )

ui_placements(request) async

Return the placement editor partial with interactive grid canvas.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/placement_editor.html.

Source code in src/phids/api/main.py
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
@app.get("/ui/placements", response_class=HTMLResponse, summary="Placement editor partial")
async def ui_placements(request: Request) -> Any:
    """Return the placement editor partial with interactive grid canvas.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/placement_editor.html``.
    """
    draft = get_draft()
    return templates.TemplateResponse(
        request,
        "partials/placement_editor.html",
        {
            "draft": draft,
            "flora_species": draft.flora_species,
            "predator_species": draft.predator_species,
            "initial_plants": draft.initial_plants,
            "initial_swarms": draft.initial_swarms,
        },
    )

ui_predators(request) async

Return the predator species table partial.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/predator_config.html.

Source code in src/phids/api/main.py
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
@app.get("/ui/predators", response_class=HTMLResponse, summary="Predator config partial")
async def ui_predators(request: Request) -> Any:
    """Return the predator species table partial.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/predator_config.html``.
    """
    draft = get_draft()
    return templates.TemplateResponse(
        request,
        "partials/predator_config.html",
        {"predator_species": draft.predator_species},
    )

ui_status_badge() async

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

Returns:

Name Type Description
HTMLResponse HTMLResponse

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

Source code in src/phids/api/main.py
2101
2102
2103
2104
2105
2106
2107
2108
@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:
        HTMLResponse: Styled ``<span id="sim-status">`` fragment.
    """
    return HTMLResponse(content=_render_status_badge_html())

ui_stream(websocket) async

Stream lightweight JSON grid snapshots for the browser canvas.

Each message contains plant_energy (2-D list), swarms (list of {x, y, population}), tick, and max_energy. Reconnects are handled client-side with an exponential back-off.

Source code in src/phids/api/main.py
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
@app.websocket("/ws/ui/stream")
async def ui_stream(websocket: WebSocket) -> None:
    """Stream lightweight JSON grid snapshots for the browser canvas.

    Each message contains ``plant_energy`` (2-D list), ``swarms``
    (list of ``{x, y, population}``), ``tick``, and ``max_energy``.
    Reconnects are handled client-side with an exponential back-off.
    """
    await websocket.accept()
    logger.debug("WebSocket connected: /ws/ui/stream")
    last_state_signature: tuple[int, int, bool, bool, bool] | None = None
    try:
        while True:
            loop = _sim_loop
            if loop is None:
                await asyncio.sleep(0.5)
                continue

            loop_id = id(loop)
            state_signature = (loop_id, loop.tick, loop.running, loop.paused, loop.terminated)
            # Send whenever the rendered state changes, including pause/resume toggles.
            if state_signature != last_state_signature:
                payload = _build_live_dashboard_payload(loop)
                await websocket.send_text(json.dumps(payload))
                last_state_signature = 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 websocket.close()

ui_substances(request) async

Return the substance definitions table partial.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/substance_config.html.

Source code in src/phids/api/main.py
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
@app.get("/ui/substances", response_class=HTMLResponse, summary="Substance config partial")
async def ui_substances(request: Request) -> Any:
    """Return the substance definitions table partial.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/substance_config.html``.
    """
    draft = get_draft()
    return templates.TemplateResponse(
        request,
        "partials/substance_config.html",
        {"substances": draft.substance_definitions},
    )

ui_tick() async

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

Returns:

Name Type Description
Response Response

Tick count as plain-text body.

Source code in src/phids/api/main.py
2090
2091
2092
2093
2094
2095
2096
2097
2098
@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:
        Response: Tick count as plain-text body.
    """
    tick = _sim_loop.tick if _sim_loop is not None else 0
    return Response(content=str(tick), media_type="text/plain")

ui_trigger_matrix_legacy(request) async

Redirect-compatible alias for the trigger rules page.

Source code in src/phids/api/main.py
2317
2318
2319
2320
@app.get("/ui/trigger-matrix", response_class=HTMLResponse, summary="Trigger rules (legacy URL)")
async def ui_trigger_matrix_legacy(request: Request) -> Any:
    """Redirect-compatible alias for the trigger rules page."""
    return await ui_trigger_rules(request)

ui_trigger_rules(request) async

Return the trigger rules editor partial.

Parameters:

Name Type Description Default
request Request

FastAPI/Starlette request object.

required

Returns:

Name Type Description
TemplateResponse Any

Rendered partials/trigger_rules.html.

Source code in src/phids/api/main.py
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
@app.get("/ui/trigger-rules", response_class=HTMLResponse, summary="Trigger rules partial")
async def ui_trigger_rules(request: Request) -> Any:
    """Return the trigger rules editor partial.

    Args:
        request: FastAPI/Starlette request object.

    Returns:
        TemplateResponse: Rendered ``partials/trigger_rules.html``.
    """
    draft = get_draft()
    return templates.TemplateResponse(
        request, "partials/trigger_rules.html", _trigger_rules_template_context(draft)
    )

update_wind(payload) async

Dynamically update the simulation wind vector.

Parameters:

Name Type Description Default
payload WindUpdatePayload

:class:~phids.api.schemas.WindUpdatePayload.

required

Returns:

Name Type Description
dict dict[str, Any]

Confirmation and the applied wind vector.

Source code in src/phids/api/main.py
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
@app.put("/api/simulation/wind", summary="Update wind vector")
async def update_wind(payload: WindUpdatePayload) -> dict[str, Any]:
    """Dynamically update the simulation wind vector.

    Args:
        payload: :class:`~phids.api.schemas.WindUpdatePayload`.

    Returns:
        dict: Confirmation and the applied wind vector.
    """
    loop = _get_loop()
    loop.update_wind(payload.wind_x, payload.wind_y)
    logger.info("Wind updated via API to (vx=%.3f, vy=%.3f)", payload.wind_x, payload.wind_y)
    return {"message": "Wind updated.", "wind_x": payload.wind_x, "wind_y": payload.wind_y}

Server-side mutable draft state for the HTMX scenario-builder UI in PHIDS.

This module implements the DraftState, a mutable server-side configuration accumulator for the PHIDS scenario-builder UI. The DraftState collects all operator choices made through the web interface, including species, substances, trigger rules, and placements, before committing them to the simulation engine via POST /api/scenario/load-draft. The module exposes a single global DraftState instance, accessed through get_draft and reset_draft, which is mutated directly by route handlers. No concurrency-safe locking is applied, as the server is designed for single-operator workbench usage. The architectural design ensures deterministic scenario construction, reproducibility, and scientific integrity, supporting rigorous validation and compliance with the Rule of 16, O(1) spatial hash invariants, and double-buffered simulation logic. The module is central to the UI’s ability to model complex ecological dynamics and emergent behaviors with maximal biological fidelity.

This module-level docstring is written in accordance with Google-style documentation standards, providing a comprehensive scholarly abstract of the DraftState's architectural role, algorithmic mechanics, and biological rationale.

DraftState dataclass

Mutable server-side configuration accumulator for the builder UI.

Attributes:

Name Type Description
scenario_name str

Human-readable label used in the UI header.

grid_width int

Biotope width in cells (1–80).

grid_height int

Biotope height in cells (1–80).

max_ticks int

Simulation tick budget.

tick_rate_hz float

WebSocket streaming rate in ticks per second.

wind_x float

Initial uniform wind x-component.

wind_y float

Initial uniform wind y-component.

num_signals int

Number of airborne signal layers.

num_toxins int

Number of toxin layers.

mycorrhizal_inter_species bool

Allow root connections across species.

mycorrhizal_connection_cost float

Energy to establish a root link.

mycorrhizal_growth_interval_ticks int

Ticks between root-growth attempts.

mycorrhizal_signal_velocity int

Signal hops per tick through roots.

flora_species list[object]

Flora species parameter list (species_id == index).

predator_species list[object]

Predator species parameter list (species_id == index).

diet_matrix list[list[bool]]

Boolean matrix [pred_idx][flora_idx] for edibility.

trigger_rules list[TriggerRule]

List of explicit chemical-defense trigger rules. Multiple rules per (flora, predator) pair are allowed.

substance_definitions list[SubstanceDefinition]

Named substance registry indexed by substance_id.

initial_plants list[PlacedPlant]

Plants placed on the grid before simulation start.

initial_swarms list[PlacedSwarm]

Swarms placed on the grid before simulation start.

active_batch_jobs dict[str, object]

Registry of batch simulation jobs keyed by job_id.

Source code in src/phids/api/ui_state.py
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
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
@dataclasses.dataclass
class DraftState:
    """Mutable server-side configuration accumulator for the builder UI.

    Attributes:
        scenario_name: Human-readable label used in the UI header.
        grid_width: Biotope width in cells (1–80).
        grid_height: Biotope height in cells (1–80).
        max_ticks: Simulation tick budget.
        tick_rate_hz: WebSocket streaming rate in ticks per second.
        wind_x: Initial uniform wind x-component.
        wind_y: Initial uniform wind y-component.
        num_signals: Number of airborne signal layers.
        num_toxins: Number of toxin layers.
        mycorrhizal_inter_species: Allow root connections across species.
        mycorrhizal_connection_cost: Energy to establish a root link.
        mycorrhizal_growth_interval_ticks: Ticks between root-growth attempts.
        mycorrhizal_signal_velocity: Signal hops per tick through roots.
        flora_species: Flora species parameter list (species_id == index).
        predator_species: Predator species parameter list (species_id == index).
        diet_matrix: Boolean matrix ``[pred_idx][flora_idx]`` for edibility.
        trigger_rules: List of explicit chemical-defense trigger rules.
            Multiple rules per (flora, predator) pair are allowed.
        substance_definitions: Named substance registry indexed by substance_id.
        initial_plants: Plants placed on the grid before simulation start.
        initial_swarms: Swarms placed on the grid before simulation start.
        active_batch_jobs: Registry of batch simulation jobs keyed by job_id.
    """

    scenario_name: str = _DEFAULT_SCENARIO_NAME
    grid_width: int = 40
    grid_height: int = 40
    max_ticks: int = 1000
    tick_rate_hz: float = 10.0
    wind_x: float = 0.0
    wind_y: float = 0.0
    num_signals: int = 4
    num_toxins: int = 4
    mycorrhizal_inter_species: bool = False
    mycorrhizal_connection_cost: float = 1.0
    mycorrhizal_growth_interval_ticks: int = 8
    mycorrhizal_signal_velocity: int = 1
    flora_species: list[object] = dataclasses.field(default_factory=list)
    predator_species: list[object] = dataclasses.field(default_factory=list)
    diet_matrix: list[list[bool]] = dataclasses.field(default_factory=list)
    trigger_rules: list[TriggerRule] = dataclasses.field(default_factory=list)
    substance_definitions: list[SubstanceDefinition] = dataclasses.field(default_factory=list)
    initial_plants: list[PlacedPlant] = dataclasses.field(default_factory=list)
    initial_swarms: list[PlacedSwarm] = dataclasses.field(default_factory=list)
    active_batch_jobs: dict[str, object] = dataclasses.field(default_factory=dict)

    # ------------------------------------------------------------------
    # Matrix resize helpers (diet_matrix only now)
    # ------------------------------------------------------------------

    def _resize_diet_matrix(self) -> None:
        """Resize diet matrix to match species list lengths."""
        n_pred = len(self.predator_species)
        n_flora = len(self.flora_species)

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

    def rebuild_species_ids(self) -> None:
        """Re-assign sequential ``species_id`` values after list mutation."""
        from phids.api.schemas import FloraSpeciesParams, PredatorSpeciesParams

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

    # ------------------------------------------------------------------
    # Species mutation helpers
    # ------------------------------------------------------------------

    def add_flora(self, params: object) -> None:
        """Append a flora species and extend the diet matrix.

        Args:
            params: :class:`~phids.api.schemas.FloraSpeciesParams`.
        """
        self.flora_species.append(params)
        self.rebuild_species_ids()
        self._resize_diet_matrix()
        logger.debug(
            "Draft flora added (species_id=%s, total_flora=%d)",
            getattr(params, "species_id", "?"),
            len(self.flora_species),
        )

    def remove_flora(self, species_id: int) -> None:
        """Remove a flora species by id and clean up dependent data.

        Args:
            species_id: The ``species_id`` of the species to remove.

        Raises:
            ValueError: If no species with the given id exists.
        """
        from phids.api.schemas import FloraSpeciesParams

        idx = next(
            (
                i
                for i, fp in enumerate(self.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 self.flora_species[idx]
        # Remove diet_matrix column for this flora species
        for row in self.diet_matrix:
            if idx < len(row):
                del row[idx]

        # Remove trigger rules referencing this flora species; compact higher IDs
        new_rules: list[TriggerRule] = []
        for rule in self.trigger_rules:
            if rule.flora_species_id == species_id:
                continue  # orphaned rule
            new_rule = dataclasses.replace(rule)
            if new_rule.flora_species_id > species_id:
                new_rule.flora_species_id -= 1  # compact after removal
            new_rules.append(new_rule)
        self.trigger_rules = new_rules

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

    def add_predator(self, params: object) -> None:
        """Append a predator species and extend the diet matrix.

        Args:
            params: :class:`~phids.api.schemas.PredatorSpeciesParams`.
        """
        self.predator_species.append(params)
        self.rebuild_species_ids()
        self._resize_diet_matrix()
        logger.debug(
            "Draft predator added (species_id=%s, total_predators=%d)",
            getattr(params, "species_id", "?"),
            len(self.predator_species),
        )

    def remove_predator(self, species_id: int) -> None:
        """Remove a predator species by id and clean up dependent data.

        Args:
            species_id: The ``species_id`` of the species to remove.

        Raises:
            ValueError: If no species with the given id exists.
        """
        from phids.api.schemas import PredatorSpeciesParams

        idx = next(
            (
                i
                for i, pp in enumerate(self.predator_species)
                if isinstance(pp, PredatorSpeciesParams) and pp.species_id == species_id
            ),
            None,
        )
        if idx is None:
            raise ValueError(f"Predator species_id {species_id} not found.")

        del self.predator_species[idx]
        # Remove diet_matrix row for this predator
        if idx < len(self.diet_matrix):
            del self.diet_matrix[idx]

        # Remove trigger rules referencing this predator; compact higher IDs
        new_rules: list[TriggerRule] = []
        for rule in self.trigger_rules:
            if rule.predator_species_id == species_id:
                continue  # orphaned rule
            new_rule = dataclasses.replace(rule)
            if new_rule.predator_species_id > species_id:
                new_rule.predator_species_id -= 1
            new_rule.activation_condition = _remap_condition_references(
                deepcopy(new_rule.activation_condition),
                removed_predator_id=species_id,
            )
            new_rules.append(new_rule)
        self.trigger_rules = new_rules

        # Remove placements
        self.initial_swarms = [s for s in self.initial_swarms if s.species_id != species_id]
        self.rebuild_species_ids()
        self._resize_diet_matrix()
        logger.debug(
            "Draft predator removed (species_id=%d, total_predators=%d, remaining_trigger_rules=%d)",
            species_id,
            len(self.predator_species),
            len(self.trigger_rules),
        )

    # ------------------------------------------------------------------
    # Trigger rule helpers
    # ------------------------------------------------------------------

    def add_trigger_rule(
        self,
        flora_species_id: int,
        predator_species_id: int,
        substance_id: int,
        min_predator_population: int = 5,
        activation_condition: dict[str, object] | None = None,
        required_signal_ids: list[int] | None = None,
    ) -> None:
        """Append a new trigger rule.

        Args:
            flora_species_id: Flora species index.
            predator_species_id: Predator species index.
            substance_id: Substance to synthesise.
            min_predator_population: Minimum swarm size threshold.
            activation_condition: Optional nested predicate tree.
            required_signal_ids: Deprecated compatibility shorthand for
                signal-only AND gates.
        """
        self.trigger_rules.append(
            TriggerRule(
                flora_species_id=flora_species_id,
                predator_species_id=predator_species_id,
                substance_id=substance_id,
                min_predator_population=min_predator_population,
                activation_condition=deepcopy(
                    activation_condition
                    if activation_condition is not None
                    else _legacy_signal_ids_to_activation_condition(required_signal_ids)
                ),
            )
        )
        logger.debug(
            "Draft trigger rule added (flora_species_id=%d, predator_species_id=%d, substance_id=%d, total_rules=%d)",
            flora_species_id,
            predator_species_id,
            substance_id,
            len(self.trigger_rules),
        )

    def remove_trigger_rule(self, index: int) -> None:
        """Remove a trigger rule by list index.

        Args:
            index: Position in the trigger_rules list.

        Raises:
            IndexError: If index is out of range.
        """
        removed = self.trigger_rules[index]
        del self.trigger_rules[index]
        logger.debug(
            "Draft trigger rule removed (index=%d, flora_species_id=%d, predator_species_id=%d, substance_id=%d, total_rules=%d)",
            index,
            removed.flora_species_id,
            removed.predator_species_id,
            removed.substance_id,
            len(self.trigger_rules),
        )

    def update_trigger_rule(
        self,
        index: int,
        *,
        flora_species_id: int | None = None,
        predator_species_id: int | None = None,
        substance_id: int | None = None,
        min_predator_population: int | None = None,
        activation_condition: dict[str, object] | None = None,
        required_signal_ids: list[int] | None = None,
    ) -> None:
        """Update fields of a trigger rule in-place.

        Args:
            index: Position in the trigger_rules list.
            flora_species_id: New flora species id (optional).
            predator_species_id: New predator species id (optional).
            substance_id: New substance id (optional).
            min_predator_population: New minimum population (optional).
            activation_condition: New condition tree (optional).
            required_signal_ids: Deprecated compatibility shorthand for
                signal-only AND gates.

        Raises:
            IndexError: If index is out of range.
        """
        rule = self.trigger_rules[index]
        if flora_species_id is not None:
            rule.flora_species_id = flora_species_id
        if predator_species_id is not None:
            rule.predator_species_id = predator_species_id
        if substance_id is not None:
            rule.substance_id = substance_id
        if min_predator_population is not None:
            rule.min_predator_population = min_predator_population
        if activation_condition is not None:
            rule.activation_condition = deepcopy(activation_condition)
        elif required_signal_ids is not None:
            rule.activation_condition = _legacy_signal_ids_to_activation_condition(
                required_signal_ids
            )
        logger.debug(
            "Draft trigger rule updated (index=%d, flora_species_id=%d, predator_species_id=%d, substance_id=%d)",
            index,
            rule.flora_species_id,
            rule.predator_species_id,
            rule.substance_id,
        )

    def set_trigger_rule_activation_condition(
        self,
        index: int,
        condition: dict[str, object] | None,
    ) -> None:
        """Replace the full activation-condition tree for one trigger rule."""
        self.trigger_rules[index].activation_condition = deepcopy(condition)

    def replace_trigger_rule_condition_node(
        self,
        index: int,
        path: str,
        condition: dict[str, object],
    ) -> None:
        """Replace the node at ``path`` inside one trigger rule's condition tree."""
        rule = self.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

    def append_trigger_rule_condition_child(
        self,
        index: int,
        parent_path: str,
        condition: dict[str, object],
    ) -> None:
        """Append a child node to a group within one trigger rule's condition tree."""
        rule = self.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

    def delete_trigger_rule_condition_node(self, index: int, path: str) -> None:
        """Delete the node at ``path`` from one trigger rule's condition tree."""
        rule = self.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)

    def update_trigger_rule_condition_node(
        self,
        index: int,
        path: str,
        **fields: object,
    ) -> None:
        """Patch selected fields on the condition node at ``path``."""
        rule = self.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

    # ------------------------------------------------------------------
    # Placement helpers
    # ------------------------------------------------------------------

    def add_plant_placement(self, species_id: int, x: int, y: int, energy: float) -> None:
        """Add a plant placement to the draft."""
        self.initial_plants.append(PlacedPlant(species_id=species_id, x=x, y=y, energy=energy))
        logger.debug(
            "Draft plant placement added (species_id=%d, x=%d, y=%d, total_plants=%d)",
            species_id,
            x,
            y,
            len(self.initial_plants),
        )

    def add_swarm_placement(
        self, species_id: int, x: int, y: int, population: int, energy: float
    ) -> None:
        """Add a swarm placement to the draft."""
        self.initial_swarms.append(
            PlacedSwarm(
                species_id=species_id,
                x=x,
                y=y,
                population=population,
                energy=energy,
            )
        )
        logger.debug(
            "Draft swarm placement added (species_id=%d, x=%d, y=%d, population=%d, total_swarms=%d)",
            species_id,
            x,
            y,
            population,
            len(self.initial_swarms),
        )

    def remove_plant_placement(self, index: int) -> None:
        """Remove a plant placement by list index."""
        removed = self.initial_plants[index]
        del self.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(self.initial_plants),
        )

    def remove_swarm_placement(self, index: int) -> None:
        """Remove a swarm placement by list index."""
        removed = self.initial_swarms[index]
        del self.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(self.initial_swarms),
        )

    def clear_placements(self) -> None:
        """Remove all plant and swarm placements."""
        cleared_plants = len(self.initial_plants)
        cleared_swarms = len(self.initial_swarms)
        self.initial_plants.clear()
        self.initial_swarms.clear()
        logger.debug(
            "Draft placements cleared (plants=%d, swarms=%d)",
            cleared_plants,
            cleared_swarms,
        )

    # ------------------------------------------------------------------
    # Config export
    # ------------------------------------------------------------------

    def build_sim_config(self) -> SimulationConfig:
        """Assemble a :class:`~phids.api.schemas.SimulationConfig`.

        Returns:
            SimulationConfig: Validated simulation configuration.

        Raises:
            ValueError: If no flora or predator species defined.
        """
        from phids.api.schemas import (
            DietCompatibilityMatrix,
            FloraSpeciesParams,
            InitialPlantPlacement,
            InitialSwarmPlacement,
            PredatorSpeciesParams,
            SimulationConfig,
            TriggerConditionSchema,
        )

        if not self.flora_species or not self.predator_species:
            logger.warning(
                "Draft build rejected because required species are missing (flora=%d, predators=%d)",
                len(self.flora_species),
                len(self.predator_species),
            )
            raise ValueError("At least one flora and one predator species are required.")

        subs_by_id: dict[int, SubstanceDefinition] = {
            sd.substance_id: sd for sd in self.substance_definitions
        }

        # Group trigger rules by flora_species_id
        triggers_by_flora: dict[int, list[TriggerConditionSchema]] = {}
        for rule in self.trigger_rules:
            sd = subs_by_id.get(rule.substance_id)
            if sd is None:
                logger.warning(
                    "Skipping trigger rule with missing substance definition (flora_species_id=%d, predator_species_id=%d, substance_id=%d)",
                    rule.flora_species_id,
                    rule.predator_species_id,
                    rule.substance_id,
                )
                continue
            triggers_by_flora.setdefault(rule.flora_species_id, []).append(
                TriggerConditionSchema(
                    predator_species_id=rule.predator_species_id,
                    min_predator_population=rule.min_predator_population,
                    substance_id=rule.substance_id,
                    synthesis_duration=sd.synthesis_duration,
                    is_toxin=sd.is_toxin,
                    lethal=sd.lethal,
                    lethality_rate=sd.lethality_rate,
                    repellent=sd.repellent,
                    repellent_walk_ticks=sd.repellent_walk_ticks,
                    aftereffect_ticks=sd.aftereffect_ticks,
                    activation_condition=deepcopy(rule.activation_condition),
                    energy_cost_per_tick=sd.energy_cost_per_tick,
                    irreversible=sd.irreversible,
                )
            )

        flora_with_triggers: list[FloraSpeciesParams] = []
        for fp in self.flora_species:
            if not isinstance(fp, FloraSpeciesParams):
                continue
            triggers = triggers_by_flora.get(fp.species_id, [])
            flora_with_triggers.append(fp.model_copy(update={"triggers": triggers}))

        n_pred = len(self.predator_species)
        n_flora = len(flora_with_triggers)
        diet_rows = [
            (self.diet_matrix[pi][:n_flora] if pi < len(self.diet_matrix) else [False] * n_flora)
            for pi in range(n_pred)
        ]

        plant_placements = [
            InitialPlantPlacement(species_id=p.species_id, x=p.x, y=p.y, energy=p.energy)
            for p in self.initial_plants
        ]
        swarm_placements = [
            InitialSwarmPlacement(
                species_id=s.species_id,
                x=s.x,
                y=s.y,
                population=s.population,
                energy=s.energy,
            )
            for s in self.initial_swarms
        ]

        config = SimulationConfig(
            grid_width=self.grid_width,
            grid_height=self.grid_height,
            max_ticks=self.max_ticks,
            tick_rate_hz=self.tick_rate_hz,
            num_signals=self.num_signals,
            num_toxins=self.num_toxins,
            wind_x=self.wind_x,
            wind_y=self.wind_y,
            flora_species=flora_with_triggers,
            predator_species=[
                pp for pp in self.predator_species if isinstance(pp, PredatorSpeciesParams)
            ],
            diet_matrix=DietCompatibilityMatrix(rows=diet_rows),
            initial_plants=plant_placements,
            initial_swarms=swarm_placements,
            mycorrhizal_inter_species=self.mycorrhizal_inter_species,
            mycorrhizal_connection_cost=self.mycorrhizal_connection_cost,
            mycorrhizal_growth_interval_ticks=self.mycorrhizal_growth_interval_ticks,
            mycorrhizal_signal_velocity=self.mycorrhizal_signal_velocity,
        )
        logger.info(
            "Draft converted to SimulationConfig (grid=%dx%d, flora=%d, predators=%d, trigger_rules=%d, plants=%d, swarms=%d)",
            self.grid_width,
            self.grid_height,
            len(flora_with_triggers),
            len(self.predator_species),
            len(self.trigger_rules),
            len(self.initial_plants),
            len(self.initial_swarms),
        )
        return config

    @classmethod
    def default(cls) -> DraftState:
        """Create the built-in default draft state."""
        from phids.api.schemas import FloraSpeciesParams, PredatorSpeciesParams

        state = cls(
            flora_species=[
                FloraSpeciesParams(
                    species_id=0,
                    name="Grass",
                    base_energy=10.0,
                    max_energy=100.0,
                    growth_rate=5.0,
                    survival_threshold=1.0,
                    reproduction_interval=10,
                    seed_min_dist=1.0,
                    seed_max_dist=3.0,
                    seed_energy_cost=5.0,
                    triggers=[],
                )
            ],
            predator_species=[
                PredatorSpeciesParams(
                    species_id=0,
                    name="Herbivore",
                    energy_min=5.0,
                    velocity=2,
                    consumption_rate=10.0,
                )
            ],
            diet_matrix=[[True]],
            trigger_rules=[],
            substance_definitions=[],
            initial_plants=[],
            initial_swarms=[],
        )
        return state

add_flora(params)

Append a flora species and extend the diet matrix.

Parameters:

Name Type Description Default
params object

:class:~phids.api.schemas.FloraSpeciesParams.

required
Source code in src/phids/api/ui_state.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
def add_flora(self, params: object) -> None:
    """Append a flora species and extend the diet matrix.

    Args:
        params: :class:`~phids.api.schemas.FloraSpeciesParams`.
    """
    self.flora_species.append(params)
    self.rebuild_species_ids()
    self._resize_diet_matrix()
    logger.debug(
        "Draft flora added (species_id=%s, total_flora=%d)",
        getattr(params, "species_id", "?"),
        len(self.flora_species),
    )

add_plant_placement(species_id, x, y, energy)

Add a plant placement to the draft.

Source code in src/phids/api/ui_state.py
745
746
747
748
749
750
751
752
753
754
def add_plant_placement(self, species_id: int, x: int, y: int, energy: float) -> None:
    """Add a plant placement to the draft."""
    self.initial_plants.append(PlacedPlant(species_id=species_id, x=x, y=y, energy=energy))
    logger.debug(
        "Draft plant placement added (species_id=%d, x=%d, y=%d, total_plants=%d)",
        species_id,
        x,
        y,
        len(self.initial_plants),
    )

add_predator(params)

Append a predator species and extend the diet matrix.

Parameters:

Name Type Description Default
params object

:class:~phids.api.schemas.PredatorSpeciesParams.

required
Source code in src/phids/api/ui_state.py
467
468
469
470
471
472
473
474
475
476
477
478
479
480
def add_predator(self, params: object) -> None:
    """Append a predator species and extend the diet matrix.

    Args:
        params: :class:`~phids.api.schemas.PredatorSpeciesParams`.
    """
    self.predator_species.append(params)
    self.rebuild_species_ids()
    self._resize_diet_matrix()
    logger.debug(
        "Draft predator added (species_id=%s, total_predators=%d)",
        getattr(params, "species_id", "?"),
        len(self.predator_species),
    )

add_swarm_placement(species_id, x, y, population, energy)

Add a swarm placement to the draft.

Source code in src/phids/api/ui_state.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
def add_swarm_placement(
    self, species_id: int, x: int, y: int, population: int, energy: float
) -> None:
    """Add a swarm placement to the draft."""
    self.initial_swarms.append(
        PlacedSwarm(
            species_id=species_id,
            x=x,
            y=y,
            population=population,
            energy=energy,
        )
    )
    logger.debug(
        "Draft swarm placement added (species_id=%d, x=%d, y=%d, population=%d, total_swarms=%d)",
        species_id,
        x,
        y,
        population,
        len(self.initial_swarms),
    )

add_trigger_rule(flora_species_id, predator_species_id, substance_id, min_predator_population=5, activation_condition=None, required_signal_ids=None)

Append a new trigger rule.

Parameters:

Name Type Description Default
flora_species_id int

Flora species index.

required
predator_species_id int

Predator species index.

required
substance_id int

Substance to synthesise.

required
min_predator_population int

Minimum swarm size threshold.

5
activation_condition dict[str, object] | None

Optional nested predicate tree.

None
required_signal_ids list[int] | None

Deprecated compatibility shorthand for signal-only AND gates.

None
Source code in src/phids/api/ui_state.py
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
def add_trigger_rule(
    self,
    flora_species_id: int,
    predator_species_id: int,
    substance_id: int,
    min_predator_population: int = 5,
    activation_condition: dict[str, object] | None = None,
    required_signal_ids: list[int] | None = None,
) -> None:
    """Append a new trigger rule.

    Args:
        flora_species_id: Flora species index.
        predator_species_id: Predator species index.
        substance_id: Substance to synthesise.
        min_predator_population: Minimum swarm size threshold.
        activation_condition: Optional nested predicate tree.
        required_signal_ids: Deprecated compatibility shorthand for
            signal-only AND gates.
    """
    self.trigger_rules.append(
        TriggerRule(
            flora_species_id=flora_species_id,
            predator_species_id=predator_species_id,
            substance_id=substance_id,
            min_predator_population=min_predator_population,
            activation_condition=deepcopy(
                activation_condition
                if activation_condition is not None
                else _legacy_signal_ids_to_activation_condition(required_signal_ids)
            ),
        )
    )
    logger.debug(
        "Draft trigger rule added (flora_species_id=%d, predator_species_id=%d, substance_id=%d, total_rules=%d)",
        flora_species_id,
        predator_species_id,
        substance_id,
        len(self.trigger_rules),
    )

append_trigger_rule_condition_child(index, parent_path, condition)

Append a child node to a group within one trigger rule's condition tree.

Source code in src/phids/api/ui_state.py
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
def append_trigger_rule_condition_child(
    self,
    index: int,
    parent_path: str,
    condition: dict[str, object],
) -> None:
    """Append a child node to a group within one trigger rule's condition tree."""
    rule = self.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

build_sim_config()

Assemble a :class:~phids.api.schemas.SimulationConfig.

Returns:

Name Type Description
SimulationConfig SimulationConfig

Validated simulation configuration.

Raises:

Type Description
ValueError

If no flora or predator species defined.

Source code in src/phids/api/ui_state.py
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
def build_sim_config(self) -> SimulationConfig:
    """Assemble a :class:`~phids.api.schemas.SimulationConfig`.

    Returns:
        SimulationConfig: Validated simulation configuration.

    Raises:
        ValueError: If no flora or predator species defined.
    """
    from phids.api.schemas import (
        DietCompatibilityMatrix,
        FloraSpeciesParams,
        InitialPlantPlacement,
        InitialSwarmPlacement,
        PredatorSpeciesParams,
        SimulationConfig,
        TriggerConditionSchema,
    )

    if not self.flora_species or not self.predator_species:
        logger.warning(
            "Draft build rejected because required species are missing (flora=%d, predators=%d)",
            len(self.flora_species),
            len(self.predator_species),
        )
        raise ValueError("At least one flora and one predator species are required.")

    subs_by_id: dict[int, SubstanceDefinition] = {
        sd.substance_id: sd for sd in self.substance_definitions
    }

    # Group trigger rules by flora_species_id
    triggers_by_flora: dict[int, list[TriggerConditionSchema]] = {}
    for rule in self.trigger_rules:
        sd = subs_by_id.get(rule.substance_id)
        if sd is None:
            logger.warning(
                "Skipping trigger rule with missing substance definition (flora_species_id=%d, predator_species_id=%d, substance_id=%d)",
                rule.flora_species_id,
                rule.predator_species_id,
                rule.substance_id,
            )
            continue
        triggers_by_flora.setdefault(rule.flora_species_id, []).append(
            TriggerConditionSchema(
                predator_species_id=rule.predator_species_id,
                min_predator_population=rule.min_predator_population,
                substance_id=rule.substance_id,
                synthesis_duration=sd.synthesis_duration,
                is_toxin=sd.is_toxin,
                lethal=sd.lethal,
                lethality_rate=sd.lethality_rate,
                repellent=sd.repellent,
                repellent_walk_ticks=sd.repellent_walk_ticks,
                aftereffect_ticks=sd.aftereffect_ticks,
                activation_condition=deepcopy(rule.activation_condition),
                energy_cost_per_tick=sd.energy_cost_per_tick,
                irreversible=sd.irreversible,
            )
        )

    flora_with_triggers: list[FloraSpeciesParams] = []
    for fp in self.flora_species:
        if not isinstance(fp, FloraSpeciesParams):
            continue
        triggers = triggers_by_flora.get(fp.species_id, [])
        flora_with_triggers.append(fp.model_copy(update={"triggers": triggers}))

    n_pred = len(self.predator_species)
    n_flora = len(flora_with_triggers)
    diet_rows = [
        (self.diet_matrix[pi][:n_flora] if pi < len(self.diet_matrix) else [False] * n_flora)
        for pi in range(n_pred)
    ]

    plant_placements = [
        InitialPlantPlacement(species_id=p.species_id, x=p.x, y=p.y, energy=p.energy)
        for p in self.initial_plants
    ]
    swarm_placements = [
        InitialSwarmPlacement(
            species_id=s.species_id,
            x=s.x,
            y=s.y,
            population=s.population,
            energy=s.energy,
        )
        for s in self.initial_swarms
    ]

    config = SimulationConfig(
        grid_width=self.grid_width,
        grid_height=self.grid_height,
        max_ticks=self.max_ticks,
        tick_rate_hz=self.tick_rate_hz,
        num_signals=self.num_signals,
        num_toxins=self.num_toxins,
        wind_x=self.wind_x,
        wind_y=self.wind_y,
        flora_species=flora_with_triggers,
        predator_species=[
            pp for pp in self.predator_species if isinstance(pp, PredatorSpeciesParams)
        ],
        diet_matrix=DietCompatibilityMatrix(rows=diet_rows),
        initial_plants=plant_placements,
        initial_swarms=swarm_placements,
        mycorrhizal_inter_species=self.mycorrhizal_inter_species,
        mycorrhizal_connection_cost=self.mycorrhizal_connection_cost,
        mycorrhizal_growth_interval_ticks=self.mycorrhizal_growth_interval_ticks,
        mycorrhizal_signal_velocity=self.mycorrhizal_signal_velocity,
    )
    logger.info(
        "Draft converted to SimulationConfig (grid=%dx%d, flora=%d, predators=%d, trigger_rules=%d, plants=%d, swarms=%d)",
        self.grid_width,
        self.grid_height,
        len(flora_with_triggers),
        len(self.predator_species),
        len(self.trigger_rules),
        len(self.initial_plants),
        len(self.initial_swarms),
    )
    return config

clear_placements()

Remove all plant and swarm placements.

Source code in src/phids/api/ui_state.py
804
805
806
807
808
809
810
811
812
813
814
def clear_placements(self) -> None:
    """Remove all plant and swarm placements."""
    cleared_plants = len(self.initial_plants)
    cleared_swarms = len(self.initial_swarms)
    self.initial_plants.clear()
    self.initial_swarms.clear()
    logger.debug(
        "Draft placements cleared (plants=%d, swarms=%d)",
        cleared_plants,
        cleared_swarms,
    )

default() classmethod

Create the built-in default draft state.

Source code in src/phids/api/ui_state.py
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
@classmethod
def default(cls) -> DraftState:
    """Create the built-in default draft state."""
    from phids.api.schemas import FloraSpeciesParams, PredatorSpeciesParams

    state = cls(
        flora_species=[
            FloraSpeciesParams(
                species_id=0,
                name="Grass",
                base_energy=10.0,
                max_energy=100.0,
                growth_rate=5.0,
                survival_threshold=1.0,
                reproduction_interval=10,
                seed_min_dist=1.0,
                seed_max_dist=3.0,
                seed_energy_cost=5.0,
                triggers=[],
            )
        ],
        predator_species=[
            PredatorSpeciesParams(
                species_id=0,
                name="Herbivore",
                energy_min=5.0,
                velocity=2,
                consumption_rate=10.0,
            )
        ],
        diet_matrix=[[True]],
        trigger_rules=[],
        substance_definitions=[],
        initial_plants=[],
        initial_swarms=[],
    )
    return state

delete_trigger_rule_condition_node(index, path)

Delete the node at path from one trigger rule's condition tree.

Source code in src/phids/api/ui_state.py
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
def delete_trigger_rule_condition_node(self, index: int, path: str) -> None:
    """Delete the node at ``path`` from one trigger rule's condition tree."""
    rule = self.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)

rebuild_species_ids()

Re-assign sequential species_id values after list mutation.

Source code in src/phids/api/ui_state.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
def rebuild_species_ids(self) -> None:
    """Re-assign sequential ``species_id`` values after list mutation."""
    from phids.api.schemas import FloraSpeciesParams, PredatorSpeciesParams

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

remove_flora(species_id)

Remove a flora species by id and clean up dependent data.

Parameters:

Name Type Description Default
species_id int

The species_id of the species to remove.

required

Raises:

Type Description
ValueError

If no species with the given id exists.

Source code in src/phids/api/ui_state.py
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
def remove_flora(self, species_id: int) -> None:
    """Remove a flora species by id and clean up dependent data.

    Args:
        species_id: The ``species_id`` of the species to remove.

    Raises:
        ValueError: If no species with the given id exists.
    """
    from phids.api.schemas import FloraSpeciesParams

    idx = next(
        (
            i
            for i, fp in enumerate(self.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 self.flora_species[idx]
    # Remove diet_matrix column for this flora species
    for row in self.diet_matrix:
        if idx < len(row):
            del row[idx]

    # Remove trigger rules referencing this flora species; compact higher IDs
    new_rules: list[TriggerRule] = []
    for rule in self.trigger_rules:
        if rule.flora_species_id == species_id:
            continue  # orphaned rule
        new_rule = dataclasses.replace(rule)
        if new_rule.flora_species_id > species_id:
            new_rule.flora_species_id -= 1  # compact after removal
        new_rules.append(new_rule)
    self.trigger_rules = new_rules

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

remove_plant_placement(index)

Remove a plant placement by list index.

Source code in src/phids/api/ui_state.py
778
779
780
781
782
783
784
785
786
787
788
789
def remove_plant_placement(self, index: int) -> None:
    """Remove a plant placement by list index."""
    removed = self.initial_plants[index]
    del self.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(self.initial_plants),
    )

remove_predator(species_id)

Remove a predator species by id and clean up dependent data.

Parameters:

Name Type Description Default
species_id int

The species_id of the species to remove.

required

Raises:

Type Description
ValueError

If no species with the given id exists.

Source code in src/phids/api/ui_state.py
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
def remove_predator(self, species_id: int) -> None:
    """Remove a predator species by id and clean up dependent data.

    Args:
        species_id: The ``species_id`` of the species to remove.

    Raises:
        ValueError: If no species with the given id exists.
    """
    from phids.api.schemas import PredatorSpeciesParams

    idx = next(
        (
            i
            for i, pp in enumerate(self.predator_species)
            if isinstance(pp, PredatorSpeciesParams) and pp.species_id == species_id
        ),
        None,
    )
    if idx is None:
        raise ValueError(f"Predator species_id {species_id} not found.")

    del self.predator_species[idx]
    # Remove diet_matrix row for this predator
    if idx < len(self.diet_matrix):
        del self.diet_matrix[idx]

    # Remove trigger rules referencing this predator; compact higher IDs
    new_rules: list[TriggerRule] = []
    for rule in self.trigger_rules:
        if rule.predator_species_id == species_id:
            continue  # orphaned rule
        new_rule = dataclasses.replace(rule)
        if new_rule.predator_species_id > species_id:
            new_rule.predator_species_id -= 1
        new_rule.activation_condition = _remap_condition_references(
            deepcopy(new_rule.activation_condition),
            removed_predator_id=species_id,
        )
        new_rules.append(new_rule)
    self.trigger_rules = new_rules

    # Remove placements
    self.initial_swarms = [s for s in self.initial_swarms if s.species_id != species_id]
    self.rebuild_species_ids()
    self._resize_diet_matrix()
    logger.debug(
        "Draft predator removed (species_id=%d, total_predators=%d, remaining_trigger_rules=%d)",
        species_id,
        len(self.predator_species),
        len(self.trigger_rules),
    )

remove_swarm_placement(index)

Remove a swarm placement by list index.

Source code in src/phids/api/ui_state.py
791
792
793
794
795
796
797
798
799
800
801
802
def remove_swarm_placement(self, index: int) -> None:
    """Remove a swarm placement by list index."""
    removed = self.initial_swarms[index]
    del self.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(self.initial_swarms),
    )

remove_trigger_rule(index)

Remove a trigger rule by list index.

Parameters:

Name Type Description Default
index int

Position in the trigger_rules list.

required

Raises:

Type Description
IndexError

If index is out of range.

Source code in src/phids/api/ui_state.py
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
def remove_trigger_rule(self, index: int) -> None:
    """Remove a trigger rule by list index.

    Args:
        index: Position in the trigger_rules list.

    Raises:
        IndexError: If index is out of range.
    """
    removed = self.trigger_rules[index]
    del self.trigger_rules[index]
    logger.debug(
        "Draft trigger rule removed (index=%d, flora_species_id=%d, predator_species_id=%d, substance_id=%d, total_rules=%d)",
        index,
        removed.flora_species_id,
        removed.predator_species_id,
        removed.substance_id,
        len(self.trigger_rules),
    )

replace_trigger_rule_condition_node(index, path, condition)

Replace the node at path inside one trigger rule's condition tree.

Source code in src/phids/api/ui_state.py
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
def replace_trigger_rule_condition_node(
    self,
    index: int,
    path: str,
    condition: dict[str, object],
) -> None:
    """Replace the node at ``path`` inside one trigger rule's condition tree."""
    rule = self.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(index, condition)

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

Source code in src/phids/api/ui_state.py
649
650
651
652
653
654
655
def set_trigger_rule_activation_condition(
    self,
    index: int,
    condition: dict[str, object] | None,
) -> None:
    """Replace the full activation-condition tree for one trigger rule."""
    self.trigger_rules[index].activation_condition = deepcopy(condition)

update_trigger_rule(index, *, flora_species_id=None, predator_species_id=None, substance_id=None, min_predator_population=None, activation_condition=None, required_signal_ids=None)

Update fields of a trigger rule in-place.

Parameters:

Name Type Description Default
index int

Position in the trigger_rules list.

required
flora_species_id int | None

New flora species id (optional).

None
predator_species_id int | None

New predator species id (optional).

None
substance_id int | None

New substance id (optional).

None
min_predator_population int | None

New minimum population (optional).

None
activation_condition dict[str, object] | None

New condition tree (optional).

None
required_signal_ids list[int] | None

Deprecated compatibility shorthand for signal-only AND gates.

None

Raises:

Type Description
IndexError

If index is out of range.

Source code in src/phids/api/ui_state.py
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
def update_trigger_rule(
    self,
    index: int,
    *,
    flora_species_id: int | None = None,
    predator_species_id: int | None = None,
    substance_id: int | None = None,
    min_predator_population: int | None = None,
    activation_condition: dict[str, object] | None = None,
    required_signal_ids: list[int] | None = None,
) -> None:
    """Update fields of a trigger rule in-place.

    Args:
        index: Position in the trigger_rules list.
        flora_species_id: New flora species id (optional).
        predator_species_id: New predator species id (optional).
        substance_id: New substance id (optional).
        min_predator_population: New minimum population (optional).
        activation_condition: New condition tree (optional).
        required_signal_ids: Deprecated compatibility shorthand for
            signal-only AND gates.

    Raises:
        IndexError: If index is out of range.
    """
    rule = self.trigger_rules[index]
    if flora_species_id is not None:
        rule.flora_species_id = flora_species_id
    if predator_species_id is not None:
        rule.predator_species_id = predator_species_id
    if substance_id is not None:
        rule.substance_id = substance_id
    if min_predator_population is not None:
        rule.min_predator_population = min_predator_population
    if activation_condition is not None:
        rule.activation_condition = deepcopy(activation_condition)
    elif required_signal_ids is not None:
        rule.activation_condition = _legacy_signal_ids_to_activation_condition(
            required_signal_ids
        )
    logger.debug(
        "Draft trigger rule updated (index=%d, flora_species_id=%d, predator_species_id=%d, substance_id=%d)",
        index,
        rule.flora_species_id,
        rule.predator_species_id,
        rule.substance_id,
    )

update_trigger_rule_condition_node(index, path, **fields)

Patch selected fields on the condition node at path.

Source code in src/phids/api/ui_state.py
726
727
728
729
730
731
732
733
734
735
736
737
738
739
def update_trigger_rule_condition_node(
    self,
    index: int,
    path: str,
    **fields: object,
) -> None:
    """Patch selected fields on the condition node at ``path``."""
    rule = self.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

PlacedPlant dataclass

A plant placed on the grid before the simulation starts.

Parameters:

Name Type Description Default
species_id int

Flora species index.

required
x int

Grid x-coordinate.

required
y int

Grid y-coordinate.

required
energy float

Initial energy reserve.

required
Source code in src/phids/api/ui_state.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
@dataclasses.dataclass
class PlacedPlant:
    """A plant placed on the grid before the simulation starts.

    Args:
        species_id: Flora species index.
        x: Grid x-coordinate.
        y: Grid y-coordinate.
        energy: Initial energy reserve.
    """

    species_id: int
    x: int
    y: int
    energy: float

PlacedSwarm dataclass

A herbivore swarm placed on the grid before the simulation starts.

Parameters:

Name Type Description Default
species_id int

Predator species index.

required
x int

Grid x-coordinate.

required
y int

Grid y-coordinate.

required
population int

Initial swarm population.

required
energy float

Initial energy reserve.

required
Source code in src/phids/api/ui_state.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@dataclasses.dataclass
class PlacedSwarm:
    """A herbivore swarm placed on the grid before the simulation starts.

    Args:
        species_id: Predator species index.
        x: Grid x-coordinate.
        y: Grid y-coordinate.
        population: Initial swarm population.
        energy: Initial energy reserve.
    """

    species_id: int
    x: int
    y: int
    population: int
    energy: float

SubstanceDefinition dataclass

Named substance with physical/biological properties.

A substance definition captures how a chemical behaves once produced. The trigger matrix separately records which (flora, predator) pair activates synthesis.

Parameters:

Name Type Description Default
substance_id int

Layer index in GridEnvironment.signal_layers or toxin_layers (0 ≤ id < MAX_SUBSTANCE_TYPES).

required
name str

Human-readable label shown in the UI.

'Signal'
is_toxin bool

True for toxins; False for airborne signals.

False
lethal bool

Lethal-toxin flag (ignored if is_toxin is False).

False
repellent bool

Repellent-toxin flag.

False
synthesis_duration int

Ticks to complete synthesis (production time).

3
aftereffect_ticks int

Ticks the substance lingers after emission ceases.

0
lethality_rate float

Population units eliminated per tick (β).

0.0
repellent_walk_ticks int

Random-walk duration on repel trigger.

3
energy_cost_per_tick float

Energy drained from the plant per active tick.

1.0
irreversible bool

Keep the substance active permanently once activated.

False
precursor_signal_id int

Signal id required before activation (−1 = none).

-1
min_predator_population int

Minimum swarm size to trigger synthesis.

5
Source code in src/phids/api/ui_state.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@dataclasses.dataclass
class SubstanceDefinition:
    """Named substance with physical/biological properties.

    A substance definition captures how a chemical behaves once produced.
    The trigger matrix separately records *which* (flora, predator) pair
    activates synthesis.

    Args:
        substance_id: Layer index in ``GridEnvironment.signal_layers`` or
            ``toxin_layers`` (0 ≤ id < MAX_SUBSTANCE_TYPES).
        name: Human-readable label shown in the UI.
        is_toxin: ``True`` for toxins; ``False`` for airborne signals.
        lethal: Lethal-toxin flag (ignored if ``is_toxin`` is ``False``).
        repellent: Repellent-toxin flag.
        synthesis_duration: Ticks to complete synthesis (production time).
        aftereffect_ticks: Ticks the substance lingers after emission ceases.
        lethality_rate: Population units eliminated per tick (β).
        repellent_walk_ticks: Random-walk duration on repel trigger.
        energy_cost_per_tick: Energy drained from the plant per active tick.
        irreversible: Keep the substance active permanently once activated.
        precursor_signal_id: Signal id required before activation (−1 = none).
        min_predator_population: Minimum swarm size to trigger synthesis.
    """

    substance_id: int
    name: str = "Signal"
    is_toxin: bool = False
    lethal: bool = False
    repellent: 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: bool = False
    precursor_signal_id: int = -1
    min_predator_population: int = 5

    @property
    def type_label(self) -> str:
        """Human-readable substance type.

        Returns:
            str: One of ``"Signal"``, ``"Lethal Toxin"``,
                ``"Repellent Toxin"``, or ``"Toxin"``.
        """
        if not self.is_toxin:
            return "Signal"
        if self.lethal:
            return "Lethal Toxin"
        if self.repellent:
            return "Repellent Toxin"
        return "Toxin"

type_label property

Human-readable substance type.

Returns:

Name Type Description
str str

One of "Signal", "Lethal Toxin", "Repellent Toxin", or "Toxin".

TriggerRule dataclass

One explicit chemical-defense trigger rule.

A rule says: "when flora species flora_species_id is attacked by predator species predator_species_id with at least min_predator_population individuals, synthesise substance substance_id. Optional nested activation conditions can additionally require active substances and/or other enemy presences via explicit all_of / any_of predicate trees. None = unconditional."

Multiple rules may share the same (flora, predator) pair to express production of different substances simultaneously.

Parameters:

Name Type Description Default
flora_species_id int

Flora species index (0-based).

required
predator_species_id int

Predator species index (0-based).

required
substance_id int

Substance layer index to synthesise.

required
min_predator_population int

Minimum swarm size to trigger this rule.

5
activation_condition dict[str, object] | None

Optional JSON-serialisable predicate tree.

None
Source code in src/phids/api/ui_state.py
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
@dataclasses.dataclass
class TriggerRule:
    """One explicit chemical-defense trigger rule.

    A rule says: "when flora species *flora_species_id* is attacked by
    predator species *predator_species_id* with at least
    *min_predator_population* individuals, synthesise substance
    *substance_id*. Optional nested activation conditions can additionally
    require active substances and/or other enemy presences via explicit
    ``all_of`` / ``any_of`` predicate trees. ``None`` = unconditional."

    Multiple rules may share the same (flora, predator) pair to express
    production of different substances simultaneously.

    Args:
        flora_species_id: Flora species index (0-based).
        predator_species_id: Predator species index (0-based).
        substance_id: Substance layer index to synthesise.
        min_predator_population: Minimum swarm size to trigger this rule.
        activation_condition: Optional JSON-serialisable predicate tree.
    """

    flora_species_id: int
    predator_species_id: int
    substance_id: int
    min_predator_population: int = 5
    activation_condition: dict[str, object] | None = None

get_draft()

Return the current draft state, initialising a default if needed.

Returns:

Name Type Description
DraftState DraftState

The active draft configuration.

Source code in src/phids/api/ui_state.py
989
990
991
992
993
994
995
996
997
998
999
def get_draft() -> DraftState:
    """Return the current draft state, initialising a default if needed.

    Returns:
        DraftState: The active draft configuration.
    """
    global _draft  # noqa: PLW0603
    if _draft is None:
        _draft = DraftState.default()
        logger.info("Draft state initialised with built-in default scenario")
    return _draft

reset_draft()

Reset the draft state to the built-in default.

Source code in src/phids/api/ui_state.py
1019
1020
1021
1022
1023
def reset_draft() -> None:
    """Reset the draft state to the built-in default."""
    global _draft  # noqa: PLW0603
    _draft = DraftState.default()
    logger.info("Draft state reset to built-in default scenario")

set_draft(state)

Replace the active draft state.

Parameters:

Name Type Description Default
state DraftState

New :class:DraftState to activate.

required
Source code in src/phids/api/ui_state.py
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
def set_draft(state: DraftState) -> None:
    """Replace the active draft state.

    Args:
        state: New :class:`DraftState` to activate.
    """
    global _draft  # noqa: PLW0603
    _draft = state
    logger.info(
        "Draft state replaced (scenario_name=%s, flora=%d, predators=%d, substances=%d)",
        state.scenario_name,
        len(state.flora_species),
        len(state.predator_species),
        len(state.substance_definitions),
    )

Engine orchestration

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 behaviors and ecological dynamics. The architectural design reflects the project's commitment to data-oriented modeling, 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.

This module-level docstring is written in accordance with Google-style documentation standards, providing a detailed scholarly abstract of the simulation loop's algorithmic mechanics and biological rationale.

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
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 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
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) -> None:
        """Initialise the SimulationLoop with the provided configuration.

        Args:
            config: Validated SimulationConfig instance from the API payload.
        """
        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._cached_snapshot_tick: int = -1
        self._cached_snapshot: dict[str, Any] | None = None

        # 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 (msgpack serialisation per tick)
        self.replay = ReplayBuffer()

        # Pre-compute species parameter lookups
        self._flora_params: dict[int, Any] = {sp.species_id: sp for sp in config.flora_species}
        self._trigger_conditions: dict[int, list[Any]] = {
            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, predator_species=%d, signals=%d, toxins=%d, tick_rate_hz=%.2f)",
            config.grid_width,
            config.grid_height,
            len(config.flora_species),
            len(config.predator_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,
                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_predator_energy_min(swarm_placement.species_id),
                velocity=self._get_predator_velocity(swarm_placement.species_id),
                consumption_rate=self._get_predator_consumption_rate(swarm_placement.species_id),
                reproduction_energy_divisor=self._get_predator_reproduction_divisor(
                    swarm_placement.species_id
                ),
                energy_upkeep_per_individual=self._get_predator_energy_upkeep(
                    swarm_placement.species_id
                ),
                split_population_threshold=self._get_predator_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_predator_energy_min(self, species_id: int) -> float:
        """Return the configured minimum energy for a predator species.

        Args:
            species_id: Predator species identifier to look up.

        Returns:
            float: Configured minimum energy if found, otherwise a sensible
            default of 1.0.
        """
        for sp in self.config.predator_species:
            if sp.species_id == species_id:
                return sp.energy_min
        return 1.0

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

        Args:
            species_id: Predator species identifier to look up.

        Returns:
            int: Movement period in ticks; defaults to 1 when not found.
        """
        for sp in self.config.predator_species:
            if sp.species_id == species_id:
                return sp.velocity
        return 1

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

        Args:
            species_id: Predator species identifier to look up.

        Returns:
            float: Consumption rate if present, otherwise 1.0 by default.
        """
        for sp in self.config.predator_species:
            if sp.species_id == species_id:
                return sp.consumption_rate
        return 1.0

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

        Args:
            species_id: Predator species identifier to look up.

        Returns:
            float: Reproduction divisor if present, otherwise 1.0.
        """
        for sp in self.config.predator_species:
            if sp.species_id == species_id:
                return sp.reproduction_energy_divisor
        return 1.0

    def _get_predator_energy_upkeep(self, species_id: int) -> float:
        """Return the configured per-individual metabolic upkeep scalar."""
        for sp in self.config.predator_species:
            if sp.species_id == species_id:
                return sp.energy_upkeep_per_individual
        return 0.05

    def _get_predator_split_threshold(self, species_id: int) -> int:
        """Return the configured explicit mitosis population threshold."""
        for sp in self.config.predator_species:
            if sp.species_id == species_id:
                return sp.split_population_threshold
        return 0

    # ------------------------------------------------------------------
    # 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 _log_debug_tick_summary(
        self,
        *,
        latest_metrics: dict[str, Any] | None,
        phase_timings_ms: dict[str, float],
    ) -> None:
        """Emit a coarse DEBUG snapshot for the current tick."""
        swarm_population = 0
        for entity in self.world.query(SwarmComponent):
            swarm_population += entity.get_component(SwarmComponent).population

        logger.debug(
            (
                "Tick summary (tick=%d, flora_energy=%.3f, flora_population=%d, "
                "predator_clusters=%d, predator_population=%d, replay_frames=%d, "
                "phase_timings_ms=%s)"
            ),
            self.tick,
            float(latest_metrics.get("total_flora_energy", 0.0)) if latest_metrics else 0.0,
            int(latest_metrics.get("flora_population", 0)) if latest_metrics else 0,
            int(latest_metrics.get("predator_clusters", 0)) if latest_metrics else 0,
            swarm_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,
            }
            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.toxin_layers,
                self.env.width,
                self.env.height,
            )
            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,
                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,
                self.tick,
                plant_death_causes=plant_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,
            )
            if debug_summary:
                phase_timings_ms["signaling"] = (time.perf_counter() - phase_started) * 1000.0
                phase_started = time.perf_counter()

            # FIX: Commit all energy depletion from feeding and defense upkeep
            # before telemetry reads it and the next tick's flow field evaluates it.
            self.env.rebuild_energy_layer()

            # --------------------------------------------------------
            # Phase 5: Telemetry
            # --------------------------------------------------------
            self.telemetry.record(self.world, self.tick, plant_death_causes=plant_death_causes)
            self.replay.append(self.get_state_snapshot())
            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_predator_species=self.config.z4_predator_species_extinction,
                z6_max_flora_energy=self.config.z6_max_total_flora_energy,
                z7_max_predator_population=self.config.z7_max_total_predator_population,
            )
            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,
                    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``.
        """
        tick_interval = 1.0 / self.config.tick_rate_hz
        self.start()
        logger.info("Simulation run loop entering background execution")

        while self.running and not self.terminated:
            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,
        )

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

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

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

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

    def get_state_snapshot(self) -> dict[str, Any]:
        """Return a serialisable snapshot of the current grid state.

        Returns:
            dict[str, Any]: 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 = {
            "tick": self.tick,
            "terminated": self.terminated,
            "termination_reason": self.termination_reason,
            **self.env.to_dict(),
        }
        self._cached_snapshot_tick = self.tick
        self._cached_snapshot = snapshot
        return snapshot

__init__(config)

Initialise the SimulationLoop with the provided configuration.

Parameters:

Name Type Description Default
config SimulationConfig

Validated SimulationConfig instance from the API payload.

required
Source code in src/phids/engine/loop.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def __init__(self, config: SimulationConfig) -> None:
    """Initialise the SimulationLoop with the provided configuration.

    Args:
        config: Validated SimulationConfig instance from the API payload.
    """
    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._cached_snapshot_tick: int = -1
    self._cached_snapshot: dict[str, Any] | None = None

    # 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 (msgpack serialisation per tick)
    self.replay = ReplayBuffer()

    # Pre-compute species parameter lookups
    self._flora_params: dict[int, Any] = {sp.species_id: sp for sp in config.flora_species}
    self._trigger_conditions: dict[int, list[Any]] = {
        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, predator_species=%d, signals=%d, toxins=%d, tick_rate_hz=%.2f)",
        config.grid_width,
        config.grid_height,
        len(config.flora_species),
        len(config.predator_species),
        config.num_signals,
        config.num_toxins,
        config.tick_rate_hz,
    )

get_state_snapshot()

Return a serialisable snapshot of the current grid state.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Snapshot containing tick, termination state and

dict[str, Any]

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

Source code in src/phids/engine/loop.py
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
def get_state_snapshot(self) -> dict[str, Any]:
    """Return a serialisable snapshot of the current grid state.

    Returns:
        dict[str, Any]: 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 = {
        "tick": self.tick,
        "terminated": self.terminated,
        "termination_reason": self.termination_reason,
        **self.env.to_dict(),
    }
    self._cached_snapshot_tick = self.tick
    self._cached_snapshot = snapshot
    return snapshot

pause()

Toggle the paused state.

Flips the paused boolean.

Source code in src/phids/engine/loop.py
269
270
271
272
273
274
275
276
277
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() 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
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
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``.
    """
    tick_interval = 1.0 / self.config.tick_rate_hz
    self.start()
    logger.info("Simulation run loop entering background execution")

    while self.running and not self.terminated:
        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()

Mark the simulation as running.

Sets running state to True and clears the paused flag.

Source code in src/phids/engine/loop.py
260
261
262
263
264
265
266
267
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() 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
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
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,
        }
        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.toxin_layers,
            self.env.width,
            self.env.height,
        )
        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,
            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,
            self.tick,
            plant_death_causes=plant_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,
        )
        if debug_summary:
            phase_timings_ms["signaling"] = (time.perf_counter() - phase_started) * 1000.0
            phase_started = time.perf_counter()

        # FIX: Commit all energy depletion from feeding and defense upkeep
        # before telemetry reads it and the next tick's flow field evaluates it.
        self.env.rebuild_energy_layer()

        # --------------------------------------------------------
        # Phase 5: Telemetry
        # --------------------------------------------------------
        self.telemetry.record(self.world, self.tick, plant_death_causes=plant_death_causes)
        self.replay.append(self.get_state_snapshot())
        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_predator_species=self.config.z4_predator_species_extinction,
            z6_max_flora_energy=self.config.z6_max_total_flora_energy,
            z7_max_predator_population=self.config.z7_max_total_predator_population,
        )
        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,
                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()

Halt the simulation by clearing the running flag.

Source code in src/phids/engine/loop.py
279
280
281
282
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_wind(vx, vy)

Update the environment uniform wind vector.

Parameters:

Name Type Description Default
vx float

Wind X component.

required
vy float

Wind Y component.

required
Source code in src/phids/engine/loop.py
498
499
500
501
502
503
504
505
506
507
508
509
def update_wind(self, vx: float, vy: float) -> None:
    """Update the environment uniform wind vector.

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

ECS components

Plant ECS component dataclass.

This module defines the :class:PlantComponent dataclass which stores the runtime state for a plant entity used by lifecycle and signaling systems.

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.

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.

Source code in src/phids/engine/components/plant.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@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.
        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.
    """

    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
    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)

Herbivore Swarm ECS component dataclass.

Defines :class:SwarmComponent storing runtime state for a herbivore swarm entity used by interaction and telemetry subsystems.

SwarmComponent dataclass

Holds runtime state for a single herbivore swarm entity.

Attributes:

Name Type Description
entity_id int

ECS entity identifier.

species_id int

Predator 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 (<=0 keeps legacy rule).

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
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@dataclass(slots=True)
class SwarmComponent:
    """Holds runtime state for a single herbivore swarm entity.

    Attributes:
        entity_id: ECS entity identifier.
        species_id: Predator 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 (<=0 keeps legacy rule).
        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

Substance ECS component dataclass.

Defines :class:SubstanceComponent for volatile signals and toxins emitted by plants in response to herbivore presence.

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.

precursor_signal_id int

Single required precursor signal id (-1 = none). Legacy.

precursor_signal_ids tuple[int, ...]

All signal ids that must ALL be active before this substance activates (AND logic). Empty tuple = no precursor required.

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
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@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.
        precursor_signal_id: Single required precursor signal id (-1 = none). Legacy.
        precursor_signal_ids: All signal ids that must ALL be active before this
            substance activates (AND logic).  Empty tuple = no precursor required.
        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
    precursor_signal_id: int = -1
    precursor_signal_ids: tuple[int, ...] = ()
    activation_condition: dict[str, object] | None = None
    energy_cost_per_tick: float = 0.0
    irreversible: bool = False
    trigger_predator_species_id: int = -1
    trigger_min_predator_population: int = 0
    triggered_this_tick: bool = False

Core runtime structures

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

This module implements the GridEnvironment, a cellular automata biotope for PHIDS, using NumPy arrays to represent all state layers. 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 truncated to eliminate subnormal floats, maintaining computational efficiency and scientific accuracy. The architectural design is tightly coupled to the ECSWorld and flow-field systems, supporting O(1) spatial hash lookups and reproducible ecological dynamics.

This module-level docstring is written in accordance with Google-style documentation standards, providing a comprehensive scholarly abstract of the biotope's algorithmic mechanics and biological rationale.

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
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 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
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)
        self._plant_energy_layer_write: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)

        # 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)
        self.wind_vector_y: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)

        # ------------------------------------------------------------------
        # Signal layers  [num_signals, W, H] – read buffer
        # ------------------------------------------------------------------
        self.signal_layers: npt.NDArray[np.float64] = np.zeros(
            (num_signals, width, height), dtype=np.float64
        )
        # 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
        )
        self._toxin_layers_write: npt.NDArray[np.float64] = np.zeros_like(self.toxin_layers)

        # ------------------------------------------------------------------
        # Flow-field gradient (scalar attraction field, W×H)
        # ------------------------------------------------------------------
        self.flow_field: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)

    # ------------------------------------------------------------------
    # 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: X coordinate.
            y: Y 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) -> None:
        """Compute one diffusion tick for all signal layers.

        This applies a 2-D convolution with a pre-computed Gaussian kernel,
        advects the result by a bounded integer cell shift (zero-filled at
        boundaries), and applies a sparsity threshold to zero small values.
        """
        # Compute mean wind shift in integer grid cells.
        mean_vx: int = int(round(float(self.wind_vector_x.mean())))
        mean_vy: int = int(round(float(self.wind_vector_y.mean())))

        for s in range(self.num_signals):
            layer: npt.NDArray[np.float64] = self.signal_layers[s]
            if not np.any(layer >= SIGNAL_EPSILON):
                self._signal_layers_write[s].fill(0.0)
                continue
            convolved: npt.NDArray[np.float64] = convolve2d(
                layer, DIFFUSION_KERNEL, mode="same", boundary="fill", fillvalue=0.0
            )

            shifted: npt.NDArray[np.float64] = np.zeros_like(convolved)
            x_shift = mean_vx
            y_shift = mean_vy
            src_x_start = max(0, -x_shift)
            src_x_end = self.width - max(0, x_shift)
            dst_x_start = max(0, x_shift)
            dst_x_end = self.width - max(0, -x_shift)
            src_y_start = max(0, -y_shift)
            src_y_end = self.height - max(0, y_shift)
            dst_y_start = max(0, y_shift)
            dst_y_end = self.height - max(0, -y_shift)

            if src_x_start < src_x_end and src_y_start < src_y_end:
                shifted[dst_x_start:dst_x_end, dst_y_start:dst_y_end] = convolved[
                    src_x_start:src_x_end,
                    src_y_start:src_y_end,
                ]

            shifted *= SIGNAL_DECAY_FACTOR
            # Zero sub-threshold values to preserve matrix sparsity
            shifted[shifted < SIGNAL_EPSILON] = 0.0
            self._signal_layers_write[s] = shifted

        # 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.
        """
        self._plant_energy_layer_write[:] = self._plant_energy_by_species_write.sum(axis=0)
        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

    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: X coordinate.
            y: Y coordinate.
            species_id: Species index.
            value: Energy contribution (clamped to >= 0).
        """
        self._plant_energy_by_species_write[species_id, x, y] = max(0.0, 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: X coordinate.
            y: Y coordinate.
            species_id: Species index.
        """
        self._plant_energy_by_species_write[species_id, x, y] = 0.0

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

    def to_dict(self) -> dict[str, object]:
        """Return a lightweight snapshot dict suitable for msgpack serialisation.
        Returns:
            dict: 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=40, height=40, num_signals=4, num_toxins=4)

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
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 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
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)
    self._plant_energy_layer_write: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)

    # 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)
    self.wind_vector_y: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)

    # ------------------------------------------------------------------
    # Signal layers  [num_signals, W, H] – read buffer
    # ------------------------------------------------------------------
    self.signal_layers: npt.NDArray[np.float64] = np.zeros(
        (num_signals, width, height), dtype=np.float64
    )
    # 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
    )
    self._toxin_layers_write: npt.NDArray[np.float64] = np.zeros_like(self.toxin_layers)

    # ------------------------------------------------------------------
    # Flow-field gradient (scalar attraction field, W×H)
    # ------------------------------------------------------------------
    self.flow_field: npt.NDArray[np.float64] = np.zeros(shape, dtype=np.float64)

clear_plant_energy(x, y, species_id)

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

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
species_id int

Species index.

required
Source code in src/phids/engine/core/biotope.py
256
257
258
259
260
261
262
263
264
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: X coordinate.
        y: Y coordinate.
        species_id: Species index.
    """
    self._plant_energy_by_species_write[species_id, x, y] = 0.0

diffuse_signals()

Compute one diffusion tick for all signal layers.

This applies a 2-D convolution with a pre-computed Gaussian kernel, advects the result by a bounded integer cell shift (zero-filled at boundaries), and applies a sparsity threshold to zero small values.

Source code in src/phids/engine/core/biotope.py
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
def diffuse_signals(self) -> None:
    """Compute one diffusion tick for all signal layers.

    This applies a 2-D convolution with a pre-computed Gaussian kernel,
    advects the result by a bounded integer cell shift (zero-filled at
    boundaries), and applies a sparsity threshold to zero small values.
    """
    # Compute mean wind shift in integer grid cells.
    mean_vx: int = int(round(float(self.wind_vector_x.mean())))
    mean_vy: int = int(round(float(self.wind_vector_y.mean())))

    for s in range(self.num_signals):
        layer: npt.NDArray[np.float64] = self.signal_layers[s]
        if not np.any(layer >= SIGNAL_EPSILON):
            self._signal_layers_write[s].fill(0.0)
            continue
        convolved: npt.NDArray[np.float64] = convolve2d(
            layer, DIFFUSION_KERNEL, mode="same", boundary="fill", fillvalue=0.0
        )

        shifted: npt.NDArray[np.float64] = np.zeros_like(convolved)
        x_shift = mean_vx
        y_shift = mean_vy
        src_x_start = max(0, -x_shift)
        src_x_end = self.width - max(0, x_shift)
        dst_x_start = max(0, x_shift)
        dst_x_end = self.width - max(0, -x_shift)
        src_y_start = max(0, -y_shift)
        src_y_end = self.height - max(0, y_shift)
        dst_y_start = max(0, y_shift)
        dst_y_end = self.height - max(0, -y_shift)

        if src_x_start < src_x_end and src_y_start < src_y_end:
            shifted[dst_x_start:dst_x_end, dst_y_start:dst_y_end] = convolved[
                src_x_start:src_x_end,
                src_y_start:src_y_end,
            ]

        shifted *= SIGNAL_DECAY_FACTOR
        # Zero sub-threshold values to preserve matrix sparsity
        shifted[shifted < SIGNAL_EPSILON] = 0.0
        self._signal_layers_write[s] = shifted

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

rebuild_energy_layer()

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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
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.
    """
    self._plant_energy_layer_write[:] = self._plant_energy_by_species_write.sum(axis=0)
    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

set_plant_energy(x, y, species_id, value)

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

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
species_id int

Species index.

required
value float

Energy contribution (clamped to >= 0).

required
Source code in src/phids/engine/core/biotope.py
245
246
247
248
249
250
251
252
253
254
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: X coordinate.
        y: Y coordinate.
        species_id: Species index.
        value: Energy contribution (clamped to >= 0).
    """
    self._plant_energy_by_species_write[species_id, x, y] = max(0.0, value)

set_uniform_wind(vx, vy)

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
147
148
149
150
151
152
153
154
155
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()

Return a lightweight snapshot dict suitable for msgpack serialisation. Returns: dict: Mapping containing numpy arrays converted to nested lists.

Source code in src/phids/engine/core/biotope.py
270
271
272
273
274
275
276
277
278
279
280
281
282
def to_dict(self) -> dict[str, object]:
    """Return a lightweight snapshot dict suitable for msgpack serialisation.
    Returns:
        dict: 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, y, vx, vy)

Update the wind vector at a single grid cell.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y 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
157
158
159
160
161
162
163
164
165
166
167
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: X coordinate.
        y: Y 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

Entity-Component-System (ECS) registry with O(1) spatial hash support for deterministic ecosystem simulation.

This module implements the ECSWorld registry, a flat entity-component system designed to maximize computational efficiency and biological fidelity in the PHIDS simulation engine. The ECS maintains a flat entity registry, per-component indices for rapid queries, and a spatial hash grid enabling O(1) membership lookups for entities occupying a cell. This architecture is essential for simulating plant-herbivore interactions, metabolic attrition, and systemic acquired resistance without incurring O(N^2) locality costs. The design strictly adheres to data-oriented principles, avoiding Python object graphs in favor of NumPy-backed state matrices 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 rigor.

This module-level docstring is written in accordance with Google-style documentation standards, providing a comprehensive scholarly abstract of the ECS registry's algorithmic mechanics and biological rationale.

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
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 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
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.
        """
        self._next_id: int = 0
        self._entities: dict[int, Entity] = {}
        # component_type -> set of entity ids
        self._component_index: dict[type[Any], 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 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
        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
        # Clean component index
        for ctype in list(entity._components.keys()):
            self._component_index[ctype].discard(entity_id)
        # Clean spatial hash (search all cells – acceptable for sparse grids)
        for cell_set in self._spatial_hash.values():
            cell_set.discard(entity_id)

    def has_entity(self, entity_id: int) -> bool:
        """Return True if the entity exists.

        Args:
            entity_id: Entity identifier.

        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: Entity identifier.

        Returns:
            Entity: Matching entity.
        """
        return self._entities[entity_id]

    # ------------------------------------------------------------------
    # Component helpers
    # ------------------------------------------------------------------

    def add_component(self, entity_id: int, component: Any) -> None:
        """Attach a component to an entity and update the component index.

        Args:
            entity_id: Target entity id.
            component: Component instance to attach.
        """
        entity = self._entities[entity_id]
        entity.add_component(component)
        self._component_index[type(component)].add(entity_id)

    def remove_component(self, entity_id: int, component_type: type[Any]) -> None:
        """Detach a component of the specified type from an entity.

        Args:
            entity_id: Target entity id.
            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)

    def query(self, *component_types: type[Any]) -> Iterator[Entity]:
        """Yield all entities that possess all listed component types.

        Args:
            *component_types: Component classes/types to require.

        Yields:
            Entity: Entities matching the component set.
        """
        if not component_types:
            yield from self._entities.values()
            return
        # Start from the smallest set for efficiency
        sets = [self._component_index.get(ct, set()) for ct in component_types]
        smallest = min(sets, key=len)
        for eid in list(smallest):
            entity = self._entities.get(eid)
            if entity is None:
                continue
            if all(entity.has_component(ct) for ct in component_types):
                yield entity

    # ------------------------------------------------------------------
    # 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: Entity identifier.
            x: X coordinate of the cell.
            y: Y coordinate of the cell.
        """
        self._spatial_hash[(x, y)].add(entity_id)

    def unregister_position(self, entity_id: int, x: int, y: int) -> None:
        """Remove an entity from a grid cell.

        Args:
            entity_id: Entity identifier.
            x: X coordinate of the cell.
            y: Y coordinate of the cell.
        """
        cell = self._spatial_hash.get((x, y))
        if cell is not None:
            cell.discard(entity_id)

    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: Entity identifier.
            old_x: Previous X coordinate.
            old_y: Previous Y coordinate.
            new_x: New X coordinate.
            new_y: New Y coordinate.
        """
        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: X coordinate.
            y: Y coordinate.

        Returns:
            set[int]: Entity ids occupying the cell.
        """
        return self._spatial_hash.get((x, y), set())

    # ------------------------------------------------------------------
    # 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__()

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.

Source code in src/phids/engine/core/ecs.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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.
    """
    self._next_id: int = 0
    self._entities: dict[int, Entity] = {}
    # component_type -> set of entity ids
    self._component_index: dict[type[Any], 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)

add_component(entity_id, component)

Attach a component to an entity and update the component index.

Parameters:

Name Type Description Default
entity_id int

Target entity id.

required
component Any

Component instance to attach.

required
Source code in src/phids/engine/core/ecs.py
156
157
158
159
160
161
162
163
164
165
def add_component(self, entity_id: int, component: Any) -> None:
    """Attach a component to an entity and update the component index.

    Args:
        entity_id: Target entity id.
        component: Component instance to attach.
    """
    entity = self._entities[entity_id]
    entity.add_component(component)
    self._component_index[type(component)].add(entity_id)

collect_garbage(dead_entity_ids)

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
255
256
257
258
259
260
261
262
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()

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
102
103
104
105
106
107
108
109
110
111
112
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
    return entity

destroy_entity(entity_id)

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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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
    # Clean component index
    for ctype in list(entity._components.keys()):
        self._component_index[ctype].discard(entity_id)
    # Clean spatial hash (search all cells – acceptable for sparse grids)
    for cell_set in self._spatial_hash.values():
        cell_set.discard(entity_id)

entities_at(x, y)

Return the set of entity ids occupying a cell.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required

Returns:

Type Description
set[int]

set[int]: Entity ids occupying the cell.

Source code in src/phids/engine/core/ecs.py
239
240
241
242
243
244
245
246
247
248
249
def entities_at(self, x: int, y: int) -> set[int]:
    """Return the set of entity ids occupying a cell.

    Args:
        x: X coordinate.
        y: Y coordinate.

    Returns:
        set[int]: Entity ids occupying the cell.
    """
    return self._spatial_hash.get((x, y), set())

get_entity(entity_id)

Return the entity instance for the given id.

Parameters:

Name Type Description Default
entity_id int

Entity identifier.

required

Returns:

Name Type Description
Entity Entity

Matching entity.

Source code in src/phids/engine/core/ecs.py
141
142
143
144
145
146
147
148
149
150
def get_entity(self, entity_id: int) -> Entity:
    """Return the entity instance for the given id.

    Args:
        entity_id: Entity identifier.

    Returns:
        Entity: Matching entity.
    """
    return self._entities[entity_id]

has_entity(entity_id)

Return True if the entity exists.

Parameters:

Name Type Description Default
entity_id int

Entity identifier.

required

Returns:

Name Type Description
bool bool

True if present.

Source code in src/phids/engine/core/ecs.py
130
131
132
133
134
135
136
137
138
139
def has_entity(self, entity_id: int) -> bool:
    """Return True if the entity exists.

    Args:
        entity_id: Entity identifier.

    Returns:
        bool: True if present.
    """
    return entity_id in self._entities

move_entity(entity_id, old_x, old_y, new_x, new_y)

Atomically update spatial hash when an entity moves.

Parameters:

Name Type Description Default
entity_id int

Entity identifier.

required
old_x int

Previous X coordinate.

required
old_y int

Previous Y coordinate.

required
new_x int

New X coordinate.

required
new_y int

New Y coordinate.

required
Source code in src/phids/engine/core/ecs.py
226
227
228
229
230
231
232
233
234
235
236
237
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: Entity identifier.
        old_x: Previous X coordinate.
        old_y: Previous Y coordinate.
        new_x: New X coordinate.
        new_y: New Y coordinate.
    """
    self.unregister_position(entity_id, old_x, old_y)
    self.register_position(entity_id, new_x, new_y)

query(*component_types)

Yield all entities that possess all listed component types.

Parameters:

Name Type Description Default
*component_types type[Any]

Component classes/types to require.

()

Yields:

Name Type Description
Entity Entity

Entities matching the component set.

Source code in src/phids/engine/core/ecs.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def query(self, *component_types: type[Any]) -> Iterator[Entity]:
    """Yield all entities that possess all listed component types.

    Args:
        *component_types: Component classes/types to require.

    Yields:
        Entity: Entities matching the component set.
    """
    if not component_types:
        yield from self._entities.values()
        return
    # Start from the smallest set for efficiency
    sets = [self._component_index.get(ct, set()) for ct in component_types]
    smallest = min(sets, key=len)
    for eid in list(smallest):
        entity = self._entities.get(eid)
        if entity is None:
            continue
        if all(entity.has_component(ct) for ct in component_types):
            yield entity

register_position(entity_id, x, y)

Register an entity at grid cell (x, y).

Parameters:

Name Type Description Default
entity_id int

Entity identifier.

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
204
205
206
207
208
209
210
211
212
def register_position(self, entity_id: int, x: int, y: int) -> None:
    """Register an entity at grid cell (x, y).

    Args:
        entity_id: Entity identifier.
        x: X coordinate of the cell.
        y: Y coordinate of the cell.
    """
    self._spatial_hash[(x, y)].add(entity_id)

remove_component(entity_id, component_type)

Detach a component of the specified type from an entity.

Parameters:

Name Type Description Default
entity_id int

Target entity id.

required
component_type type[Any]

Component class/type to remove.

required
Source code in src/phids/engine/core/ecs.py
167
168
169
170
171
172
173
174
175
176
def remove_component(self, entity_id: int, component_type: type[Any]) -> None:
    """Detach a component of the specified type from an entity.

    Args:
        entity_id: Target entity id.
        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)

unregister_position(entity_id, x, y)

Remove an entity from a grid cell.

Parameters:

Name Type Description Default
entity_id int

Entity identifier.

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
214
215
216
217
218
219
220
221
222
223
224
def unregister_position(self, entity_id: int, x: int, y: int) -> None:
    """Remove an entity from a grid cell.

    Args:
        entity_id: Entity identifier.
        x: X coordinate of the cell.
        y: Y coordinate of the cell.
    """
    cell = self._spatial_hash.get((x, y))
    if cell is not None:
        cell.discard(entity_id)

Entity dataclass

Lightweight wrapper holding an entity id and attached components.

Source code in src/phids/engine/core/ecs.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@dataclass(slots=True)
class Entity:
    """Lightweight wrapper holding an entity id and attached components."""

    entity_id: int
    _components: dict[type[Any], Any] = field(default_factory=dict, repr=False)

    def add_component(self, component: Any) -> 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[Any]) -> 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[Any]) -> 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)

Attach a component instance keyed by its type.

Parameters:

Name Type Description Default
component Any

Component instance to attach.

required
Source code in src/phids/engine/core/ecs.py
31
32
33
34
35
36
37
def add_component(self, component: Any) -> None:
    """Attach a component instance keyed by its type.

    Args:
        component: Component instance to attach.
    """
    self._components[type(component)] = component

get_component(component_type)

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
39
40
41
42
43
44
45
46
47
48
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)

Return True if the entity has a component of the given type.

Parameters:

Name Type Description Default
component_type type[Any]

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
50
51
52
53
54
55
56
57
58
59
def has_component(self, component_type: type[Any]) -> 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)

Detach a component of the given type (no-op if absent).

Parameters:

Name Type Description Default
component_type type[Any]

Component class/type to remove.

required
Source code in src/phids/engine/core/ecs.py
61
62
63
64
65
66
67
def remove_component(self, component_type: type[Any]) -> 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)

Flow-field gradient generation accelerated with Numba @njit for deterministic ecological simulation.

This module implements the flow-field gradient computation for PHIDS, leveraging Numba JIT compilation to accelerate iterative Jacobi propagation. The global attraction gradient is computed by combining plant attraction and toxin repulsion, then propagating values to neighbors. The scalar field is intended to populate GridEnvironment.flow_field, supporting O(1) spatial hash lookups and deterministic simulation of emergent ecological phenomena such as predator movement, systemic acquired resistance, and metabolic attrition. The design strictly adheres to data-oriented principles, using NumPy arrays and pre-allocated buffers, and truncates subnormal floats to maintain computational efficiency and scientific rigor. The module is central to the simulation's ability to model complex plant-herbivore interactions with maximal biological fidelity.

This module-level docstring is written in accordance with Google-style documentation standards, providing a comprehensive scholarly abstract of the flow-field's algorithmic mechanics and biological rationale.

apply_camouflage(flow_field, x, y, factor)

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

X coordinate.

required
y int

Y coordinate.

required
factor float

Multiplier in [0, 1]; 0 = invisible, 1 = no attenuation.

required
Source code in src/phids/engine/core/flow_field.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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: X coordinate.
        y: Y coordinate.
        factor: Multiplier in [0, 1]; 0 = invisible, 1 = no attenuation.
    """
    flow_field[x, y] *= factor

compute_flow_field(plant_energy, toxin_layers, width, height)

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
toxin_layers NDArray[float64]

Shape (num_toxins, W, H) toxin concentration layers.

required
width int

Grid width.

required
height int

Grid height.

required

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
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def compute_flow_field(
    plant_energy: npt.NDArray[np.float64],
    toxin_layers: npt.NDArray[np.float64],
    width: int,
    height: int,
) -> npt.NDArray[np.float64]:
    """Public wrapper: sum toxin layers and delegate to the Numba kernel.

    Args:
        plant_energy: Shape ``(W, H)`` aggregate plant energy.
        toxin_layers: Shape ``(num_toxins, W, H)`` toxin concentration layers.
        width: Grid width.
        height: Grid height.

    Returns:
        npt.NDArray[np.float64]: Flow-field gradient of shape ``(W, H)``.
    """
    toxin_sum: npt.NDArray[np.float64] = toxin_layers.sum(axis=0)
    result = np.asarray(
        _compute_flow_field(plant_energy, toxin_sum, width, height), dtype=np.float64
    )
    return result

Engine systems

Lifecycle system: plant growth, reproduction, mycorrhizal networking and death.

This module implements per-tick plant updates including growth according to species parameters, reproduction attempts, establishment of symbiotic root connections, and culling of dead plants. It should run before interaction and signaling phases.

run_lifecycle(world, env, tick, flora_species_params, mycorrhizal_connection_cost=1.0, mycorrhizal_growth_interval_ticks=8, mycorrhizal_inter_species=False, plant_death_causes=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
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
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 list(world.query(PlantComponent)):
        plant: PlantComponent = entity.get_component(PlantComponent)
        plant.last_energy_loss_cause = None

        # Growth
        _grow(plant, tick)

        # 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)

        # 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):
        _establish_mycorrhizal_connections(
            world,
            env,
            mycorrhizal_connection_cost,
            mycorrhizal_inter_species,
            excluded_entity_ids=set(dead),
            plant_death_causes=plant_death_causes,
        )

    world.collect_garbage(dead)

Interaction system: swarm movement, feeding and continuous energy economy.

This module implements swarm behaviour including movement (gradient navigation or random walk), feeding using the diet compatibility matrix, metabolic attrition and mitosis. Spatial hash lookups provide O(1) co-occupancy checks.

run_interaction(world, env, diet_matrix, tick, plant_death_causes=None)

Execute one interaction tick for all swarm entities.

The routine performs movement, feeding using the diet matrix, toxin damage, metabolic attrition, and mitosis, then collects dead swarms and rebuilds the plant energy layer.

Parameters:

Name Type Description Default
world ECSWorld

ECSWorld registry.

required
env GridEnvironment

GridEnvironment instance (provides flow_field and toxin layers).

required
diet_matrix list[list[bool]]

Compatibility matrix indexed by predator_id then flora_id.

required
tick int

Current simulation tick (reserved for future use).

required
plant_death_causes dict[str, int] | None

Mapping of death causes to their respective counts.

None
Source code in src/phids/engine/systems/interaction.py
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
def run_interaction(
    world: ECSWorld,
    env: GridEnvironment,
    diet_matrix: list[list[bool]],
    tick: int,
    plant_death_causes: dict[str, int] | None = None,
) -> None:
    """Execute one interaction tick for all swarm entities.

    The routine performs movement, feeding using the diet matrix, toxin
    damage, metabolic attrition, and mitosis, then collects dead swarms
    and rebuilds the plant energy layer.

    Args:
        world: ECSWorld registry.
        env: GridEnvironment instance (provides flow_field and toxin layers).
        diet_matrix: Compatibility matrix indexed by predator_id then flora_id.
        tick: Current simulation tick (reserved for future use).
        plant_death_causes: Mapping of death causes to their respective counts.
    """
    dead_swarms: list[int] = []
    tile_populations: dict[tuple[int, int], int] = {}
    for entity in world.query(SwarmComponent):
        indexed_swarm = entity.get_component(SwarmComponent)
        _accumulate_tile_population(
            tile_populations,
            indexed_swarm.x,
            indexed_swarm.y,
            indexed_swarm.population,
        )

    for entity in list(world.query(SwarmComponent)):
        swarm: SwarmComponent = entity.get_component(SwarmComponent)
        has_moved = False

        # ----------------------------------------------------------------
        # 1. Movement cooldown
        # ----------------------------------------------------------------
        if swarm.move_cooldown > 0:
            swarm.move_cooldown -= 1
        else:
            # ----------------------------------------------------------------
            # 2. Navigate with local crowding pressure
            # ----------------------------------------------------------------
            old_x, old_y = swarm.x, swarm.y

            if (
                not swarm.repelled
                and tile_populations.get((swarm.x, swarm.y), 0) > TILE_CARRYING_CAPACITY
            ):
                swarm.repelled = True
                swarm.repelled_ticks_remaining = 1

            if swarm.repelled and swarm.repelled_ticks_remaining > 0:
                nx, ny = _random_walk_step(swarm.x, swarm.y, env.width, env.height)
                swarm.repelled_ticks_remaining -= 1
                if swarm.repelled_ticks_remaining <= 0:
                    swarm.repelled = False
            else:
                nx, ny = _choose_neighbour_by_flow_probability(
                    swarm,
                    env.flow_field,
                    env.width,
                    env.height,
                )

            if (nx, ny) != (old_x, old_y):
                world.move_entity(entity.entity_id, old_x, old_y, nx, ny)
                _accumulate_tile_population(tile_populations, old_x, old_y, -swarm.population)
                _accumulate_tile_population(tile_populations, nx, ny, swarm.population)
                swarm.x, swarm.y = nx, ny
                swarm.last_dx = nx - old_x
                swarm.last_dy = ny - old_y
                has_moved = True

            swarm.move_cooldown = swarm.velocity - 1

        # ----------------------------------------------------------------
        # 3. Feeding – check co-located plants via spatial hash
        # ----------------------------------------------------------------
        if not has_moved:
            for co_eid in list(world.entities_at(swarm.x, swarm.y)):
                if not world.has_entity(co_eid):
                    continue
                co_entity = world.get_entity(co_eid)
                if not co_entity.has_component(PlantComponent):
                    continue
                plant: PlantComponent = co_entity.get_component(PlantComponent)

                # Diet compatibility check
                pred_row = (
                    diet_matrix[swarm.species_id] if swarm.species_id < len(diet_matrix) else []
                )
                if not (plant.species_id < len(pred_row) and pred_row[plant.species_id]):
                    continue

                effective_velocity = max(1, swarm.velocity)
                consumed = min(
                    (swarm.consumption_rate / effective_velocity) * swarm.population,
                    plant.energy,
                )
                plant.energy -= consumed
                env.set_plant_energy(plant.x, plant.y, plant.species_id, plant.energy)
                swarm.energy += consumed

                # Kill plant if energy below threshold
                if plant.energy < plant.survival_threshold:
                    if plant_death_causes is not None:
                        plant_death_causes["death_herbivore_feeding"] = (
                            plant_death_causes.get("death_herbivore_feeding", 0) + 1
                        )
                    env.clear_plant_energy(plant.x, plant.y, plant.species_id)
                    world.unregister_position(co_eid, plant.x, plant.y)
                    world.collect_garbage([co_eid])

        # ----------------------------------------------------------------
        # 4. Metabolic upkeep and deficit attrition
        # ----------------------------------------------------------------
        metabolic_cost = swarm.population * swarm.energy_min * swarm.energy_upkeep_per_individual
        swarm.energy -= metabolic_cost

        if swarm.energy < 0.0 and swarm.population > 0:
            previous_population = swarm.population
            deficit = -swarm.energy
            casualties = int(deficit // swarm.energy_min)
            if casualties * swarm.energy_min < deficit:
                casualties += 1
            swarm.population = max(0, swarm.population - casualties)
            _accumulate_tile_population(
                tile_populations,
                swarm.x,
                swarm.y,
                swarm.population - previous_population,
            )
            total_casualty_energy = casualties * swarm.energy_min
            leftover_energy = total_casualty_energy - deficit
            swarm.energy = max(0.0, leftover_energy)

        # ----------------------------------------------------------------
        # 5. Death check
        # ----------------------------------------------------------------
        if swarm.population <= 0:
            world.unregister_position(entity.entity_id, swarm.x, swarm.y)
            dead_swarms.append(entity.entity_id)
            continue

        # ----------------------------------------------------------------
        # 6. Reproduction: convert only swarm-scale surplus energy into growth
        # ----------------------------------------------------------------
        baseline_energy = swarm.population * swarm.energy_min
        if swarm.energy > baseline_energy:
            surplus = swarm.energy - baseline_energy
            cost_per_offspring = max(
                swarm.energy_min,
                swarm.energy_min * swarm.reproduction_energy_divisor,
            )

            new_individuals = int(surplus // cost_per_offspring)
            if new_individuals > 0:
                previous_population = swarm.population
                swarm.population += new_individuals
                _accumulate_tile_population(
                    tile_populations,
                    swarm.x,
                    swarm.y,
                    swarm.population - previous_population,
                )
                swarm.energy -= new_individuals * cost_per_offspring

        # ----------------------------------------------------------------
        # 7. Mitosis
        # ----------------------------------------------------------------
        split_threshold = (
            swarm.split_population_threshold
            if swarm.split_population_threshold > 0
            else 2 * swarm.initial_population
        )
        if swarm.population >= split_threshold:
            pre_split_population = swarm.population
            offspring = _perform_mitosis(swarm, world, env)
            _accumulate_tile_population(
                tile_populations,
                swarm.x,
                swarm.y,
                swarm.population - pre_split_population,
            )
            _accumulate_tile_population(
                tile_populations,
                offspring.x,
                offspring.y,
                offspring.population,
            )

    world.collect_garbage(dead_swarms)

Signaling system: substance synthesis, activation and local toxin effects.

This module manages the lifecycle of VOC signals and defensive toxins, including trigger evaluation via the spatial hash, synthesis countdowns, delegation of airborne signal diffusion to :class:GridEnvironment, mycorrhizal signal relays and application of toxin effects to swarms.

run_signaling(world, env, trigger_conditions, mycorrhizal_inter_species, signal_velocity, tick, plant_death_causes=None)

Execute one signaling tick, handling synthesis, emission and diffusion.

Parameters:

Name Type Description Default
world ECSWorld

ECS world registry.

required
env GridEnvironment

Grid environment holding signal/toxin layers.

required
trigger_conditions dict[int, list[object]]

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
Source code in src/phids/engine/systems/signaling.py
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
def run_signaling(
    world: ECSWorld,
    env: GridEnvironment,
    trigger_conditions: dict[int, list[object]],
    mycorrhizal_inter_species: bool,
    signal_velocity: int,
    tick: int,
    plant_death_causes: dict[str, int] | None = None,
) -> None:
    """Execute one signaling tick, handling synthesis, emission and diffusion.

    Args:
        world: ECS world registry.
        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.
    """
    from phids.api.schemas import TriggerConditionSchema  # avoid circular at module level

    dead_substances: list[int] = []
    dead_plants: list[int] = []
    dead_plant_ids: set[int] = set()

    # ------------------------------------------------------------------
    # 0. Garbage-collect orphaned substances before any trigger checks
    # ------------------------------------------------------------------
    substance_entities = list(world.query(SubstanceComponent))
    for entity in substance_entities:
        sub = entity.get_component(SubstanceComponent)
        if not world.has_entity(sub.owner_plant_id):
            dead_substances.append(entity.entity_id)

    world.collect_garbage(dead_substances)
    dead_substances.clear()
    substance_entities = list(world.query(SubstanceComponent))

    owner_substance_by_key: dict[tuple[int, int], SubstanceComponent] = {}
    active_substance_ids_by_owner: dict[int, set[int]] = {}
    for entity in substance_entities:
        sub = entity.get_component(SubstanceComponent)
        owner_substance_by_key[(sub.owner_plant_id, sub.substance_id)] = sub
        if sub.active:
            active_substance_ids_by_owner.setdefault(sub.owner_plant_id, set()).add(
                sub.substance_id
            )

    swarm_population_by_cell_species = _build_swarm_population_index(world)

    # Toxins are rebuilt from currently active emitters each signaling pass
    # and remain local to living plant tissue. Non-triggered toxins remain
    # active only through configured aftereffects (or indefinitely when
    # irreversible=True).
    env.toxin_layers[:] = 0.0
    env._toxin_layers_write[:] = 0.0

    for entity in substance_entities:
        sub = entity.get_component(SubstanceComponent)
        sub.triggered_this_tick = False

    # ------------------------------------------------------------------
    # 1. Evaluate trigger conditions for all plants
    # ------------------------------------------------------------------
    for entity in list(world.query(PlantComponent)):
        plant: PlantComponent = entity.get_component(PlantComponent)
        triggers = trigger_conditions.get(plant.species_id, [])

        for trig_raw in triggers:
            if not isinstance(trig_raw, TriggerConditionSchema):
                continue
            trig: TriggerConditionSchema = trig_raw

            # Check for predator presence at this cell via spatial hash.
            predator_present = (
                swarm_population_by_cell_species.get(
                    (plant.x, plant.y, trig.predator_species_id), 0
                )
                >= trig.min_predator_population
            )

            # Evaluate optional condition trees as an alternative trigger path.
            # This enables alarm-chain behavior where a plant reacts to an
            # already-active internal condition without requiring direct
            # predator co-location on the same cell.
            condition_met = False
            if trig.activation_condition is not None:
                condition_met = _check_activation_condition(
                    plant,
                    plant.entity_id,
                    trig.activation_condition.model_dump(mode="json"),
                    env,
                    swarm_population_by_cell_species,
                    active_substance_ids_by_owner,
                )

            triggered = predator_present or condition_met

            if not triggered:
                continue

            # Ensure a substance entity exists for this (plant, substance_id) pair
            existing_sub = owner_substance_by_key.get((plant.entity_id, trig.substance_id))

            if existing_sub is None:
                # Spawn new substance entity with full properties from trigger
                new_entity = world.create_entity()
                existing_sub = SubstanceComponent(
                    entity_id=new_entity.entity_id,
                    substance_id=trig.substance_id,
                    owner_plant_id=plant.entity_id,
                    is_toxin=trig.is_toxin,
                    synthesis_duration=trig.synthesis_duration,
                    synthesis_remaining=trig.synthesis_duration,
                    lethal=trig.lethal,
                    lethality_rate=trig.lethality_rate,
                    repellent=trig.repellent,
                    repellent_walk_ticks=trig.repellent_walk_ticks,
                    aftereffect_ticks=trig.aftereffect_ticks,
                    aftereffect_remaining_ticks=trig.aftereffect_ticks,
                    precursor_signal_id=trig.precursor_signal_id,
                    precursor_signal_ids=tuple(trig.precursor_signal_ids),
                    activation_condition=(
                        trig.activation_condition.model_dump(mode="json")
                        if trig.activation_condition is not None
                        else None
                    ),
                    energy_cost_per_tick=trig.energy_cost_per_tick,
                    irreversible=trig.irreversible,
                    trigger_predator_species_id=trig.predator_species_id,
                    trigger_min_predator_population=trig.min_predator_population,
                )
                world.add_component(new_entity.entity_id, existing_sub)
                owner_substance_by_key[(plant.entity_id, trig.substance_id)] = existing_sub
            else:
                if (
                    not existing_sub.active
                    and existing_sub.synthesis_remaining <= 0
                    and existing_sub.aftereffect_remaining_ticks <= 0
                ):
                    existing_sub.synthesis_remaining = existing_sub.synthesis_duration

            existing_sub.triggered_this_tick = True

    # ------------------------------------------------------------------
    # 2. Advance synthesis timers & activate substances
    # ------------------------------------------------------------------
    for entity in list(world.query(SubstanceComponent)):
        sub = entity.get_component(SubstanceComponent)
        if sub.active:
            continue
        if not sub.triggered_this_tick:
            continue
        owner_entity = (
            world.get_entity(sub.owner_plant_id) if world.has_entity(sub.owner_plant_id) else None
        )
        if owner_entity is None:
            dead_substances.append(entity.entity_id)
            continue
        plant = owner_entity.get_component(PlantComponent)
        if sub.synthesis_remaining > 0:
            sub.synthesis_remaining -= 1
        if sub.synthesis_remaining <= 0:
            if not _check_activation_condition(
                plant,
                sub.owner_plant_id,
                sub.activation_condition,
                env,
                swarm_population_by_cell_species,
                active_substance_ids_by_owner,
            ):
                continue
            sub.active = True
            sub.aftereffect_remaining_ticks = sub.aftereffect_ticks
            active_substance_ids_by_owner.setdefault(sub.owner_plant_id, set()).add(
                sub.substance_id
            )

    # ------------------------------------------------------------------
    # 2b. Irreversible induced defense lock
    # ------------------------------------------------------------------
    for entity in list(world.query(SubstanceComponent)):
        sub = entity.get_component(SubstanceComponent)
        if sub.active and sub.irreversible:
            sub.triggered_this_tick = True

    # ------------------------------------------------------------------
    # 3. Emit active signals / toxins into environment layers
    # ------------------------------------------------------------------
    active_toxin_props: dict[int, dict[str, Any]] = {}

    for entity in list(world.query(SubstanceComponent)):
        sub = entity.get_component(SubstanceComponent)
        if not sub.active:
            continue

        if not sub.triggered_this_tick:
            if not sub.irreversible and sub.aftereffect_remaining_ticks <= 0:
                sub.active = False
                active_substance_ids_by_owner.get(sub.owner_plant_id, set()).discard(
                    sub.substance_id
                )
                continue

        owner_entity = (
            world.get_entity(sub.owner_plant_id) if world.has_entity(sub.owner_plant_id) else None
        )
        if owner_entity is None:
            dead_substances.append(entity.entity_id)
            continue

        plant = owner_entity.get_component(PlantComponent)
        if plant.entity_id in dead_plant_ids:
            sub.active = False
            active_substance_ids_by_owner.get(sub.owner_plant_id, set()).discard(sub.substance_id)
            dead_substances.append(entity.entity_id)
            continue

        # --- Energy maintenance cost (Section 4: continuous depletion) ---
        if (
            sub.energy_cost_per_tick > 0.0
            and not sub.triggered_this_tick
            and not sub.irreversible
            and (plant.energy - sub.energy_cost_per_tick) < plant.survival_threshold
        ):
            sub.active = False
            sub.aftereffect_remaining_ticks = 0
            active_substance_ids_by_owner.get(sub.owner_plant_id, set()).discard(sub.substance_id)
            continue

        if sub.energy_cost_per_tick > 0.0:
            plant.energy -= sub.energy_cost_per_tick
            env.set_plant_energy(plant.x, plant.y, plant.species_id, plant.energy)
            plant.last_energy_loss_cause = "death_defense_maintenance"
            if plant.energy < plant.survival_threshold:
                if plant_death_causes is not None:
                    plant_death_causes["death_defense_maintenance"] = (
                        plant_death_causes.get("death_defense_maintenance", 0) + 1
                    )
                env.clear_plant_energy(plant.x, plant.y, plant.species_id)
                world.unregister_position(plant.entity_id, plant.x, plant.y)
                dead_plants.append(plant.entity_id)
                dead_plant_ids.add(plant.entity_id)
                sub.active = False
                active_substance_ids_by_owner.get(sub.owner_plant_id, set()).discard(
                    sub.substance_id
                )
                dead_substances.append(entity.entity_id)
                continue

        if sub.is_toxin:
            if sub.substance_id < env.num_toxins:
                env.toxin_layers[sub.substance_id, plant.x, plant.y] = min(
                    1.0,
                    float(env.toxin_layers[sub.substance_id, plant.x, plant.y])
                    + SUBSTANCE_EMIT_RATE,
                )
                if sub.substance_id not in active_toxin_props:
                    active_toxin_props[sub.substance_id] = {
                        "lethal": sub.lethal,
                        "lethality_rate": sub.lethality_rate,
                        "repellent": sub.repellent,
                        "repellent_walk_ticks": sub.repellent_walk_ticks,
                    }
                else:
                    props = active_toxin_props[sub.substance_id]
                    props["lethal"] = bool(props["lethal"] or sub.lethal)
                    props["lethality_rate"] = max(
                        float(props["lethality_rate"]), sub.lethality_rate
                    )
                    props["repellent"] = bool(props["repellent"] or sub.repellent)
                    props["repellent_walk_ticks"] = max(
                        int(props["repellent_walk_ticks"]),
                        sub.repellent_walk_ticks,
                    )
        else:
            if sub.substance_id < env.num_signals:
                env.signal_layers[sub.substance_id, plant.x, plant.y] = min(
                    1.0,
                    float(env.signal_layers[sub.substance_id, plant.x, plant.y])
                    + SUBSTANCE_EMIT_RATE,
                )
            # Relay via mycorrhizal network
            _relay_signal_via_mycorrhizal(
                plant,
                sub.substance_id,
                SUBSTANCE_EMIT_RATE,
                env,
                world,
                mycorrhizal_inter_species,
                signal_velocity,
                tick,
            )

    for sub_id, props in active_toxin_props.items():
        _apply_toxin_to_swarms(
            sub_id,
            bool(props["lethal"]),
            float(props["lethality_rate"]),
            bool(props["repellent"]),
            int(props["repellent_walk_ticks"]),
            env,
            world,
        )

    # ------------------------------------------------------------------
    # 4. Check aftereffects and deactivate expired substances
    # ------------------------------------------------------------------
    for entity in list(world.query(SubstanceComponent)):
        sub = entity.get_component(SubstanceComponent)
        if not sub.active:
            continue

        # Verify owner still exists
        if not world.has_entity(sub.owner_plant_id):
            dead_substances.append(entity.entity_id)
            continue

        owner_entity = world.get_entity(sub.owner_plant_id)
        plant = owner_entity.get_component(PlantComponent)
        if plant.entity_id in dead_plant_ids:
            sub.active = False
            active_substance_ids_by_owner.get(sub.owner_plant_id, set()).discard(sub.substance_id)
            dead_substances.append(entity.entity_id)
            continue

        if sub.triggered_this_tick:
            sub.aftereffect_remaining_ticks = sub.aftereffect_ticks
            continue

        if sub.irreversible:
            continue

        if sub.aftereffect_remaining_ticks > 0:
            sub.aftereffect_remaining_ticks -= 1
            if sub.aftereffect_remaining_ticks <= 0:
                sub.active = False
                active_substance_ids_by_owner.get(sub.owner_plant_id, set()).discard(
                    sub.substance_id
                )
        else:
            sub.active = False
            active_substance_ids_by_owner.get(sub.owner_plant_id, set()).discard(sub.substance_id)

    # ------------------------------------------------------------------
    # 5. Diffusion (delegated to GridEnvironment)
    # ------------------------------------------------------------------
    env.diffuse_signals()

    # ------------------------------------------------------------------
    # 6. Garbage collect expired substance entities
    # ------------------------------------------------------------------
    world.collect_garbage(dead_plants)
    world.collect_garbage(dead_substances)

Telemetry and replay

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 predator 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.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, predator_clusters, predator_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
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 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
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``,
    ``predator_clusters``, ``predator_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[dict[str, Any]] = []
        self._df: pl.DataFrame | None = None
        self._max_rows = max(1, int(max_rows))

    def record(
        self,
        world: ECSWorld,
        tick: int,
        plant_death_causes: dict[str, int] | 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.
        """
        total_flora_energy = 0.0
        flora_population = 0
        plant_pop_by_species: dict[int, int] = defaultdict(int)
        plant_energy_by_species: dict[int, float] = defaultdict(float)

        for entity in world.query(PlantComponent):
            plant: PlantComponent = entity.get_component(PlantComponent)
            total_flora_energy += plant.energy
            flora_population += 1
            plant_pop_by_species[plant.species_id] += 1
            plant_energy_by_species[plant.species_id] += plant.energy

        predator_clusters = 0
        predator_population = 0
        swarm_pop_by_species: dict[int, int] = defaultdict(int)

        for entity in world.query(SwarmComponent):
            swarm: SwarmComponent = entity.get_component(SwarmComponent)
            predator_clusters += 1
            predator_population += swarm.population
            swarm_pop_by_species[swarm.species_id] += swarm.population

        defense_cost_by_species: dict[int, float] = defaultdict(float)
        for entity in world.query(SubstanceComponent):
            sub: SubstanceComponent = entity.get_component(SubstanceComponent)
            if not sub.active or sub.energy_cost_per_tick <= 0.0:
                continue
            owner = (
                world.get_entity(sub.owner_plant_id)
                if world.has_entity(sub.owner_plant_id)
                else None
            )
            if owner is None:
                continue
            if owner.has_component(PlantComponent):
                plant_owner: PlantComponent = owner.get_component(PlantComponent)
                defense_cost_by_species[plant_owner.species_id] += sub.energy_cost_per_tick

        death_counts = {
            "death_reproduction": 0,
            "death_mycorrhiza": 0,
            "death_defense_maintenance": 0,
            "death_herbivore_feeding": 0,
            "death_background_deficit": 0,
        }
        if plant_death_causes is not None:
            for key in death_counts:
                death_counts[key] = int(plant_death_causes.get(key, 0))

        row: dict[str, Any] = {
            "tick": tick,
            "total_flora_energy": total_flora_energy,
            "flora_population": flora_population,
            "predator_clusters": predator_clusters,
            "predator_population": predator_population,
            **death_counts,
            # Per-species flat columns
            "plant_pop_by_species": dict(plant_pop_by_species),
            "plant_energy_by_species": dict(plant_energy_by_species),
            "swarm_pop_by_species": dict(swarm_pop_by_species),
            "defense_cost_by_species": dict(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, predators=%d, flora_energy=%.2f)",
            tick,
            flora_population,
            predator_population,
            total_flora_energy,
        )

    def get_latest_metrics(self) -> dict[str, Any] | None:
        """Return the latest recorded telemetry row, if available.

        Returns:
            dict[str, Any] | 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 predator 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 ``"predator_ids"``
            each mapping to a sorted list of integer species identifiers.
        """
        flora_ids: set[int] = set()
        predator_ids: set[int] = set()
        for row in self._rows:
            flora_ids.update(row.get("plant_pop_by_species", {}).keys())
            predator_ids.update(row.get("swarm_pop_by_species", {}).keys())
        return {
            "flora_ids": sorted(flora_ids),
            "predator_ids": sorted(predator_ids),
        }

    @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))
            if self._rows:
                # Union of all species IDs observed across the full retention window
                all_flora_ids: set[int] = set()
                all_swarm_ids: set[int] = set()
                for r in self._rows:
                    all_flora_ids.update(r.get("plant_pop_by_species", {}).keys())
                    all_swarm_ids.update(r.get("swarm_pop_by_species", {}).keys())
                sorted_flora = sorted(all_flora_ids)
                sorted_swarm = sorted(all_swarm_ids)

                # Build flat rows: aggregate scalars + per-species flat columns
                flat_rows: list[dict[str, Any]] = []
                for r in self._rows:
                    flat: dict[str, Any] = {k: v for k, v in r.items() if not isinstance(v, dict)}
                    for fid in sorted_flora:
                        flat[f"plant_{fid}_pop"] = int(
                            r.get("plant_pop_by_species", {}).get(fid, 0)
                        )
                        flat[f"plant_{fid}_energy"] = float(
                            r.get("plant_energy_by_species", {}).get(fid, 0.0)
                        )
                        flat[f"defense_cost_{fid}"] = float(
                            r.get("defense_cost_by_species", {}).get(fid, 0.0)
                        )
                    for sid in sorted_swarm:
                        flat[f"swarm_{sid}_pop"] = int(
                            r.get("swarm_pop_by_species", {}).get(sid, 0)
                        )
                    flat_rows.append(flat)
                self._df = pl.DataFrame(flat_rows)
            else:
                # Stable aggregate-only schema when no ticks have been recorded.
                # Per-species columns are absent because no species IDs are yet known;
                # they will be added dynamically on the first post-tick materialisation.
                self._df = pl.DataFrame(
                    {
                        "tick": pl.Series([], dtype=pl.Int64),
                        "total_flora_energy": pl.Series([], dtype=pl.Float64),
                        "flora_population": pl.Series([], dtype=pl.Int64),
                        "predator_clusters": pl.Series([], dtype=pl.Int64),
                        "predator_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),
                    }
                )
        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 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=MAX_TELEMETRY_TICKS)

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
67
68
69
70
71
72
73
74
75
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[dict[str, Any]] = []
    self._df: pl.DataFrame | None = None
    self._max_rows = max(1, int(max_rows))

get_latest_metrics()

Return the latest recorded telemetry row, if available.

Returns:

Type Description
dict[str, Any] | None

dict[str, Any] | None: Most recent metrics row or None.

Source code in src/phids/telemetry/analytics.py
174
175
176
177
178
179
180
181
182
def get_latest_metrics(self) -> dict[str, Any] | None:
    """Return the latest recorded telemetry row, if available.

    Returns:
        dict[str, Any] | None: Most recent metrics row or ``None``.
    """
    if not self._rows:
        return None
    return self._rows[-1]

get_species_ids()

Return the union of all flora and predator 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 "predator_ids"

dict[str, list[int]]

each mapping to a sorted list of integer species identifiers.

Source code in src/phids/telemetry/analytics.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def get_species_ids(self) -> dict[str, list[int]]:
    """Return the union of all flora and predator 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 ``"predator_ids"``
        each mapping to a sorted list of integer species identifiers.
    """
    flora_ids: set[int] = set()
    predator_ids: set[int] = set()
    for row in self._rows:
        flora_ids.update(row.get("plant_pop_by_species", {}).keys())
        predator_ids.update(row.get("swarm_pop_by_species", {}).keys())
    return {
        "flora_ids": sorted(flora_ids),
        "predator_ids": sorted(predator_ids),
    }

record(world, tick, plant_death_causes=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
Source code in src/phids/telemetry/analytics.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 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
def record(
    self,
    world: ECSWorld,
    tick: int,
    plant_death_causes: dict[str, int] | 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.
    """
    total_flora_energy = 0.0
    flora_population = 0
    plant_pop_by_species: dict[int, int] = defaultdict(int)
    plant_energy_by_species: dict[int, float] = defaultdict(float)

    for entity in world.query(PlantComponent):
        plant: PlantComponent = entity.get_component(PlantComponent)
        total_flora_energy += plant.energy
        flora_population += 1
        plant_pop_by_species[plant.species_id] += 1
        plant_energy_by_species[plant.species_id] += plant.energy

    predator_clusters = 0
    predator_population = 0
    swarm_pop_by_species: dict[int, int] = defaultdict(int)

    for entity in world.query(SwarmComponent):
        swarm: SwarmComponent = entity.get_component(SwarmComponent)
        predator_clusters += 1
        predator_population += swarm.population
        swarm_pop_by_species[swarm.species_id] += swarm.population

    defense_cost_by_species: dict[int, float] = defaultdict(float)
    for entity in world.query(SubstanceComponent):
        sub: SubstanceComponent = entity.get_component(SubstanceComponent)
        if not sub.active or sub.energy_cost_per_tick <= 0.0:
            continue
        owner = (
            world.get_entity(sub.owner_plant_id)
            if world.has_entity(sub.owner_plant_id)
            else None
        )
        if owner is None:
            continue
        if owner.has_component(PlantComponent):
            plant_owner: PlantComponent = owner.get_component(PlantComponent)
            defense_cost_by_species[plant_owner.species_id] += sub.energy_cost_per_tick

    death_counts = {
        "death_reproduction": 0,
        "death_mycorrhiza": 0,
        "death_defense_maintenance": 0,
        "death_herbivore_feeding": 0,
        "death_background_deficit": 0,
    }
    if plant_death_causes is not None:
        for key in death_counts:
            death_counts[key] = int(plant_death_causes.get(key, 0))

    row: dict[str, Any] = {
        "tick": tick,
        "total_flora_energy": total_flora_energy,
        "flora_population": flora_population,
        "predator_clusters": predator_clusters,
        "predator_population": predator_population,
        **death_counts,
        # Per-species flat columns
        "plant_pop_by_species": dict(plant_pop_by_species),
        "plant_energy_by_species": dict(plant_energy_by_species),
        "swarm_pop_by_species": dict(swarm_pop_by_species),
        "defense_cost_by_species": dict(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, predators=%d, flora_energy=%.2f)",
        tick,
        flora_population,
        predator_population,
        total_flora_energy,
    )

reset()

Clear accumulated telemetry and reset internal cache.

Source code in src/phids/telemetry/analytics.py
286
287
288
289
290
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

Termination condition evaluators (Z1–Z7).

Provide checks for simulation termination conditions such as maximum ticks, species extinction and aggregate population/energy thresholds.

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
16
17
18
19
20
21
22
23
24
25
26
@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, tick, max_ticks, z2_flora_species=-1, z3_check_all_flora=True, z4_predator_species=-1, z5_check_all_predators=True, z6_max_flora_energy=-1.0, z7_max_predator_population=-1)

Evaluate termination conditions and return the first triggered one.

Parameters:

Name Type Description Default
world ECSWorld

ECS world registry.

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_predator_species int

Species id that triggers Z4 on extinction (-1 disables).

-1
z5_check_all_predators bool

If True, halt when all predators are extinct (Z5).

True
z6_max_flora_energy float

Aggregate flora energy threshold for Z6 (-1 disables).

-1.0
z7_max_predator_population int

Aggregate predator population threshold for Z7 (-1 disables).

-1

Returns:

Name Type Description
TerminationResult TerminationResult

Object indicating whether termination occurred and why.

Source code in src/phids/telemetry/conditions.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 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
def check_termination(
    world: ECSWorld,
    tick: int,
    max_ticks: int,
    z2_flora_species: int = -1,
    z3_check_all_flora: bool = True,
    z4_predator_species: int = -1,
    z5_check_all_predators: bool = True,
    z6_max_flora_energy: float = -1.0,
    z7_max_predator_population: int = -1,
) -> TerminationResult:
    """Evaluate termination conditions and return the first triggered one.

    Args:
        world: ECS world registry.
        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_predator_species: Species id that triggers Z4 on extinction (-1 disables).
        z5_check_all_predators: If True, halt when all predators are extinct (Z5).
        z6_max_flora_energy: Aggregate flora energy threshold for Z6 (-1 disables).
        z7_max_predator_population: Aggregate predator population threshold for Z7 (-1 disables).

    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}")

    # Gather live flora
    flora_species_alive: set[int] = set()
    total_flora_energy = 0.0
    flora_alive = False
    for entity in world.query(PlantComponent):
        plant: PlantComponent = entity.get_component(PlantComponent)
        flora_species_alive.add(plant.species_id)
        total_flora_energy += plant.energy
        flora_alive = True

    # 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 z6_max_flora_energy > 0.0 and total_flora_energy > z6_max_flora_energy:
        return TerminationResult(
            terminated=True,
            reason=f"Z6: total flora energy {total_flora_energy:.1f} > {z6_max_flora_energy}",
        )

    # Gather live predators
    predator_species_alive: set[int] = set()
    total_predator_population = 0
    predators_alive = False
    for entity in world.query(SwarmComponent):
        swarm: SwarmComponent = entity.get_component(SwarmComponent)
        predator_species_alive.add(swarm.species_id)
        total_predator_population += swarm.population
        predators_alive = True

    # Z4 – specific predator species extinction
    if z4_predator_species >= 0 and z4_predator_species not in predator_species_alive:
        return TerminationResult(
            terminated=True, reason=f"Z4: predator species {z4_predator_species} extinct"
        )

    # Z5 – all predators extinct
    if z5_check_all_predators and not predators_alive:
        return TerminationResult(terminated=True, reason="Z5: all predators extinct")

    # Z7 – aggregate predator population exceeds upper bound
    if z7_max_predator_population > 0 and total_predator_population > z7_max_predator_population:
        return TerminationResult(
            terminated=True,
            reason=(
                f"Z7: total predator population {total_predator_population} "
                f"> {z7_max_predator_population}"
            ),
        )

    return TerminationResult(terminated=False, reason="")

Academic export pipeline for PHIDS telemetry data.

This module implements the export layer that transforms Polars DataFrames produced by :class:~phids.telemetry.analytics.TelemetryRecorder into publication-ready artifacts suitable for peer-reviewed manuscript submission. Four output formats are supported:

  1. CSV — Plain-text comma-separated values compatible with spreadsheet tools and statistical computing environments.
  2. NDJSON — Newline-delimited JSON for programmatic ingestion.
  3. PNG — Rasterized chart rendered via matplotlib using the Agg (headless) backend, supporting both time-series and Lotka-Volterra phase-space views.
  4. PGFPlots TikZ — LaTeX pgfplots source code generated from matplotlib figures via the pgf backend or an internal template generator, enabling vector-quality figures with full typography control for publication workflows. Note that TikZ generation does not require a local LaTeX installation at the Python level; the returned string is intended to be compiled by the end user's LaTeX toolchain.
  5. LaTeX Table — A \begin{tabular} environment generated via pandas.DataFrame.to_latex with booktabs formatting (\toprule, \midrule, \bottomrule), suitable for direct inclusion in manuscripts.

Per-species flattening is performed by :func:telemetry_to_dataframe, which converts the nested per-species dicts stored in :attr:TelemetryRecorder._rows into a wide-format pandas DataFrame with columns named plant_{id}_pop, plant_{id}_energy, swarm_{id}_pop, and defense_cost_{id}. This columnar layout is compatible with both the matplotlib plotting functions and the LaTeX table generator.

The matplotlib.use("Agg") call is scoped to the function body (not the module level) to avoid conflicting with interactive display backends in notebook or GUI contexts.

aggregate_to_dataframe(aggregate, *, flora_names=None, predator_names=None)

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 dict[str, Any]

Dict with keys ticks, flora_population_mean, flora_population_std, predator_population_mean, predator_population_std, and optionally per-species series.

required
flora_names dict[int, str] | None

Optional display name mapping for flora species.

None
predator_names dict[int, str] | None

Optional display name mapping for predator species.

None

Returns:

Type Description
'pd.DataFrame'

pd.DataFrame: Wide-format DataFrame ready for export.

Source code in src/phids/telemetry/export.py
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
def aggregate_to_dataframe(
    aggregate: dict[str, Any],
    *,
    flora_names: dict[int, str] | None = None,
    predator_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``, ``predator_population_mean``,
            ``predator_population_std``, and optionally per-species series.
        flora_names: Optional display name mapping for flora species.
        predator_names: Optional display name mapping for predator species.

    Returns:
        pd.DataFrame: Wide-format DataFrame ready for export.
    """
    import pandas as pd

    ticks = aggregate.get("ticks", [])
    if not ticks:
        return pd.DataFrame()

    data: dict[str, Any] = {"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["predator_population_mean"] = aggregate.get("predator_population_mean", [0.0] * len(ticks))
    data["predator_population_std"] = aggregate.get("predator_population_std", [0.0] * len(ticks))

    for fid, series_mean in aggregate.get("per_flora_pop_mean", {}).items():
        name = (flora_names or {}).get(int(fid), f"flora_{fid}")
        data[f"{name}_pop_mean"] = series_mean
        series_std = aggregate.get("per_flora_pop_std", {}).get(fid, [0.0] * len(ticks))
        data[f"{name}_pop_std"] = series_std

    for pid, series_mean in aggregate.get("per_predator_pop_mean", {}).items():
        name = (predator_names or {}).get(int(pid), f"predator_{pid}")
        data[f"{name}_pop_mean"] = series_mean
        series_std = aggregate.get("per_predator_pop_std", {}).get(pid, [0.0] * len(ticks))
        data[f"{name}_pop_std"] = series_std

    return pd.DataFrame(data)

decimate_dataframe(df, tick_interval)

Return a tick-decimated DataFrame using stride semantics.

Parameters:

Name Type Description Default
df 'pd.DataFrame'

Input DataFrame.

required
tick_interval int

Row stride; values below 1 are treated as 1.

required

Returns:

Type Description
'pd.DataFrame'

pd.DataFrame: Decimated DataFrame.

Source code in src/phids/telemetry/export.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def decimate_dataframe(df: "pd.DataFrame", tick_interval: int) -> "pd.DataFrame":
    """Return a tick-decimated DataFrame using stride semantics.

    Args:
        df: Input DataFrame.
        tick_interval: Row stride; values below 1 are treated as 1.

    Returns:
        pd.DataFrame: Decimated DataFrame.
    """
    stride = max(1, int(tick_interval))
    if stride <= 1 or df.empty:
        return df
    return df.iloc[::stride, :]

export_bytes_csv(df)

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.py
200
201
202
203
204
205
206
207
208
209
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)

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.py
212
213
214
215
216
217
218
219
220
221
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_bytes_tex_table(rows, *, columns=None, include_flora_ids=None, include_predator_ids=None, tick_interval=1)

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 list[dict[str, Any]]

Raw telemetry rows from TelemetryRecorder._rows.

required

Returns:

Name Type Description
bytes bytes

UTF-8 encoded LaTeX tabular source.

Source code in src/phids/telemetry/export.py
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
def export_bytes_tex_table(
    rows: list[dict[str, Any]],
    *,
    columns: str | None = None,
    include_flora_ids: str | None = None,
    include_predator_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.

    Args:
        rows: Raw telemetry rows from ``TelemetryRecorder._rows``.

    Returns:
        bytes: UTF-8 encoded LaTeX ``tabular`` source.
    """
    filtered_rows = filter_telemetry_rows(
        rows, flora_ids=include_flora_ids, predator_ids=include_predator_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")

export_csv(df, path)

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.py
180
181
182
183
184
185
186
187
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, path)

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.py
190
191
192
193
194
195
196
197
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))

filter_dataframe_columns(df, columns)

Return a DataFrame restricted to requested columns.

Parameters:

Name Type Description Default
df 'pd.DataFrame'

Input pandas DataFrame.

required
columns str | None

Optional CSV column list.

required

Returns:

Type Description
'pd.DataFrame'

pd.DataFrame: Filtered DataFrame containing only existing columns.

Source code in src/phids/telemetry/export.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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:
        pd.DataFrame: 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, *, flora_ids=None, predator_ids=None)

Filter per-species nested telemetry dictionaries by id.

Parameters:

Name Type Description Default
rows list[dict[str, Any]]

Raw telemetry rows.

required
flora_ids str | None

Optional CSV flora species-id list.

None
predator_ids str | None

Optional CSV predator species-id list.

None

Returns:

Type Description
list[dict[str, Any]]

list[dict[str, Any]]: Row list with filtered species dictionaries.

Source code in src/phids/telemetry/export.py
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
def filter_telemetry_rows(
    rows: list[dict[str, Any]],
    *,
    flora_ids: str | None = None,
    predator_ids: str | None = None,
) -> list[dict[str, Any]]:
    """Filter per-species nested telemetry dictionaries by id.

    Args:
        rows: Raw telemetry rows.
        flora_ids: Optional CSV flora species-id list.
        predator_ids: Optional CSV predator species-id list.

    Returns:
        list[dict[str, Any]]: Row list with filtered species dictionaries.
    """
    flora_keep = _parse_species_ids(flora_ids)
    predator_keep = _parse_species_ids(predator_ids)
    if flora_keep is None and predator_keep is None:
        return rows

    filtered: list[dict[str, Any]] = []
    for row in rows:
        clone = dict(row)
        if flora_keep is not None:
            clone["plant_pop_by_species"] = {
                sid: val
                for sid, val in row.get("plant_pop_by_species", {}).items()
                if int(sid) in flora_keep
            }
            clone["plant_energy_by_species"] = {
                sid: val
                for sid, val in row.get("plant_energy_by_species", {}).items()
                if int(sid) in flora_keep
            }
            clone["defense_cost_by_species"] = {
                sid: val
                for sid, val in row.get("defense_cost_by_species", {}).items()
                if int(sid) in flora_keep
            }
        if predator_keep is not None:
            clone["swarm_pop_by_species"] = {
                sid: val
                for sid, val in row.get("swarm_pop_by_species", {}).items()
                if int(sid) in predator_keep
            }
        filtered.append(clone)
    return filtered

generate_png_bytes(rows, plot_type='timeseries', *, flora_names=None, predator_names=None, prey_species_id=0, predator_species_id=0, include_flora_ids=None, include_predator_ids=None, title=None, x_label=None, y_label=None, x_max=None, y_max=None, dpi=150)

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 predator species, sharing a common tick x-axis and a left y-axis for population counts. Total flora energy is plotted on a secondary y-axis.
  • "phasespace" — Lotka-Volterra phase-space scatter with showLine=True semantics, plotting the aggregate population of prey_species_id flora on the x-axis and the aggregate population of predator_species_id herbivores on the y-axis as a connected trajectory through time, revealing orbital cycles.
  • "defense_economy" — Per-species ratio of defense maintenance cost to per-species stored plant energy.
  • "biomass_stack" — Stacked area chart of per-species flora population, used as a biomass proxy under fixed-cell carrying-capacity constraints.
  • "survival_probability" — Per-tick percentage of runs remaining alive.

The matplotlib.use("Agg") backend directive is applied locally before any pyplot call to prevent interference with interactive display backends.

Parameters:

Name Type Description Default
rows list[dict[str, Any]]

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
predator_names dict[int, str] | None

Optional display names keyed by predator species id.

None
prey_species_id int

Flora species id for phase-space x-axis.

0
predator_species_id int

Predator species id for phase-space y-axis.

0
dpi int

Output resolution in dots per inch.

150

Returns:

Name Type Description
bytes bytes

PNG-encoded figure bytes.

Raises:

Type Description
ValueError

If plot_type is not a supported chart mode.

Source code in src/phids/telemetry/export.py
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
def generate_png_bytes(
    rows: list[dict[str, Any]],
    plot_type: str = "timeseries",
    *,
    flora_names: dict[int, str] | None = None,
    predator_names: dict[int, str] | None = None,
    prey_species_id: int = 0,
    predator_species_id: int = 0,
    include_flora_ids: str | None = None,
    include_predator_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 predator
      species, sharing a common tick x-axis and a left y-axis for population counts.
      Total flora energy is plotted on a secondary y-axis.
    * ``"phasespace"`` — Lotka-Volterra phase-space scatter with ``showLine=True``
      semantics, plotting the aggregate population of ``prey_species_id`` flora on
      the x-axis and the aggregate population of ``predator_species_id`` herbivores
      on the y-axis as a connected trajectory through time, revealing orbital cycles.
    * ``"defense_economy"`` — Per-species ratio of defense maintenance cost to
      per-species stored plant energy.
    * ``"biomass_stack"`` — Stacked area chart of per-species flora population,
      used as a biomass proxy under fixed-cell carrying-capacity constraints.
    * ``"survival_probability"`` — Per-tick percentage of runs remaining alive.

    The ``matplotlib.use("Agg")`` backend directive is applied locally before any
    pyplot call to prevent interference with interactive display backends.

    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.
        predator_names: Optional display names keyed by predator species id.
        prey_species_id: Flora species id for phase-space x-axis.
        predator_species_id: Predator species id for phase-space y-axis.
        dpi: Output resolution in dots per inch.

    Returns:
        bytes: PNG-encoded figure bytes.

    Raises:
        ValueError: If ``plot_type`` is not a supported chart mode.
    """
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    flora_filter = include_flora_ids
    predator_filter = include_predator_ids
    if plot_type == "phasespace":
        flora_filter = _append_species_id(flora_filter, prey_species_id)
        predator_filter = _append_species_id(predator_filter, predator_species_id)
    plot_rows = filter_telemetry_rows(rows, flora_ids=flora_filter, predator_ids=predator_filter)
    fig, ax = plt.subplots(figsize=(10, 5), dpi=dpi)

    if not plot_rows:
        ax.text(0.5, 0.5, "No telemetry data", ha="center", va="center", transform=ax.transAxes)
        buf = io.BytesIO()
        fig.savefig(buf, format="png", bbox_inches="tight", facecolor="white")
        plt.close(fig)
        return buf.getvalue()

    ticks = [r["tick"] for r in plot_rows]

    if plot_type == "timeseries":
        _plot_timeseries(
            ax,
            plot_rows,
            ticks,
            flora_names=flora_names,
            predator_names=predator_names,
            title=title,
            x_label=x_label,
            y_label=y_label,
        )
    elif plot_type == "phasespace":
        _plot_phasespace(
            ax,
            plot_rows,
            prey_species_id=prey_species_id,
            predator_species_id=predator_species_id,
            flora_names=flora_names,
            predator_names=predator_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,
            plot_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,
            plot_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,
            plot_rows,
            ticks,
            title=title,
            x_label=x_label,
            y_label=y_label,
        )
    else:
        plt.close(fig)
        raise ValueError(
            (
                f"Unknown plot_type '{plot_type}'; expected timeseries, phasespace, "
                "defense_economy, biomass_stack, or survival_probability"
            )
        )

    fig.tight_layout()
    buf = io.BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight", facecolor="white")
    plt.close(fig)
    logger.debug("PNG export complete (plot_type=%s, dpi=%d, bytes=%d)", plot_type, dpi, buf.tell())
    return buf.getvalue()

generate_tikz_str(rows, plot_type='timeseries', *, flora_names=None, predator_names=None, prey_species_id=0, predator_species_id=0, include_flora_ids=None, include_predator_ids=None, title=None, x_label=None, y_label=None, x_max=None, y_max=None)

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 list[dict[str, Any]]

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
predator_names dict[int, str] | None

Optional display names keyed by predator species id.

None
prey_species_id int

Flora species id for phase-space x-axis.

0
predator_species_id int

Predator species id for phase-space y-axis.

0

Returns:

Name Type Description
str 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.py
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
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
def generate_tikz_str(
    rows: list[dict[str, Any]],
    plot_type: str = "timeseries",
    *,
    flora_names: dict[int, str] | None = None,
    predator_names: dict[int, str] | None = None,
    prey_species_id: int = 0,
    predator_species_id: int = 0,
    include_flora_ids: str | None = None,
    include_predator_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}``.

    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.
        predator_names: Optional display names keyed by predator species id.
        prey_species_id: Flora species id for phase-space x-axis.
        predator_species_id: Predator species id for phase-space y-axis.

    Returns:
        str: LaTeX source code for a complete ``tikzpicture`` environment.

    Raises:
        ValueError: If ``plot_type`` is not a supported chart mode.
    """
    flora_filter = include_flora_ids
    predator_filter = include_predator_ids
    if plot_type == "phasespace":
        flora_filter = _append_species_id(flora_filter, prey_species_id)
        predator_filter = _append_species_id(predator_filter, predator_species_id)
    plot_rows = filter_telemetry_rows(rows, flora_ids=flora_filter, predator_ids=predator_filter)
    if plot_type == "timeseries":
        return _tikz_timeseries(
            plot_rows,
            flora_names=flora_names,
            predator_names=predator_names,
            title=title,
            x_label=x_label,
            y_label=y_label,
        )
    if plot_type == "phasespace":
        return _tikz_phasespace(
            plot_rows,
            prey_species_id=prey_species_id,
            predator_species_id=predator_species_id,
            flora_names=flora_names,
            predator_names=predator_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 plot_type '{plot_type}'; expected timeseries, phasespace, defense_economy, "
            "biomass_stack, or survival_probability"
        )
    )

telemetry_to_dataframe(rows)

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 list[dict[str, Any]]

Raw row list from TelemetryRecorder._rows.

required

Returns:

Type Description
'pd.DataFrame'

pd.DataFrame: Wide-format DataFrame with one row per tick and one column per

'pd.DataFrame'

scalar metric or per-species measurement.

Source code in src/phids/telemetry/export.py
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
def telemetry_to_dataframe(rows: list[dict[str, Any]]) -> "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:
        pd.DataFrame: 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(row.get("plant_pop_by_species", {}).keys())
        all_swarm_ids.update(row.get("swarm_pop_by_species", {}).keys())

    flat_rows = []
    for row in rows:
        flat: dict[str, Any] = {k: v for k, v in row.items() if not isinstance(v, dict)}
        pop_by = row.get("plant_pop_by_species", {})
        energy_by = row.get("plant_energy_by_species", {})
        swarm_by = row.get("swarm_pop_by_species", {})
        defense_by = row.get("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 predator species",
        len(rows),
        len(all_flora_ids),
        len(all_swarm_ids),
    )
    return pd.DataFrame(flat_rows)

Replay I/O for deterministic simulations.

This module provides compact binary serialisation of per-tick state snapshots using :mod:msgpack. Each tick is stored as a discrete frame to support deterministic re-simulation and on-disk replay files.

ReplayBuffer

Append-only buffer of binary-serialised tick frames.

Supports writing to and reading from a binary replay file for deterministic re-simulation.

Source code in src/phids/io/replay.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 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
class ReplayBuffer:
    """Append-only buffer of binary-serialised tick frames.

    Supports writing to and reading from a binary replay file for
    deterministic re-simulation.
    """

    def __init__(self) -> None:
        """Create an empty replay buffer.

        The buffer stores msgpack-serialised frames in an append-only list.
        """
        self._frames: list[bytes] = []

    def append(self, state: dict[str, Any]) -> None:
        """Serialize and append a tick state to the buffer.

        Args:
            state: Tick state mapping to serialize and store.
        """
        self._frames.append(serialise_state(state))

    def __len__(self) -> int:
        """Return number of stored frames."""
        return len(self._frames)

    def get_frame(self, tick: int) -> dict[str, Any]:
        """Return the deserialised state for the specified tick index.

        Args:
            tick: Index of the frame to retrieve (0-based).

        Returns:
            dict[str, Any]: Decoded state mapping for the requested tick.
        """
        return deserialise_state(self._frames[tick])

    def save(self, path: str | Path) -> None:
        """Write all frames to a binary replay file.

        The file format is an append of records where each record begins with a
        4-byte little-endian unsigned integer describing the following frame
        length, immediately followed by the frame bytes.

        Args:
            path: Destination file path.
        """
        destination = Path(path)
        with destination.open("wb") as fp:
            for frame in self._frames:
                length = len(frame).to_bytes(4, "little")
                fp.write(length)
                fp.write(frame)
        logger.info("Replay saved to %s (%d frames)", destination, len(self._frames))

    @classmethod
    def load(cls, path: str | Path) -> ReplayBuffer:
        """Load a replay file produced by :meth:`save`.

        Args:
            path: Path to the binary replay file.

        Returns:
            ReplayBuffer: Populated buffer ready for re-simulation.
        """
        source = Path(path)
        buf = cls()
        with source.open("rb") as fp:
            while True:
                length_bytes = fp.read(4)
                if len(length_bytes) < 4:
                    break
                length = int.from_bytes(length_bytes, "little")
                frame = fp.read(length)
                if len(frame) < length:
                    logger.warning(
                        "Replay file %s ended mid-frame (expected=%d bytes, got=%d)",
                        source,
                        length,
                        len(frame),
                    )
                    break
                buf._frames.append(frame)
        logger.info("Replay loaded from %s (%d frames)", source, len(buf._frames))
        return buf

__init__()

Create an empty replay buffer.

The buffer stores msgpack-serialised frames in an append-only list.

Source code in src/phids/io/replay.py
52
53
54
55
56
57
def __init__(self) -> None:
    """Create an empty replay buffer.

    The buffer stores msgpack-serialised frames in an append-only list.
    """
    self._frames: list[bytes] = []

__len__()

Return number of stored frames.

Source code in src/phids/io/replay.py
67
68
69
def __len__(self) -> int:
    """Return number of stored frames."""
    return len(self._frames)

append(state)

Serialize and append a tick state to the buffer.

Parameters:

Name Type Description Default
state dict[str, Any]

Tick state mapping to serialize and store.

required
Source code in src/phids/io/replay.py
59
60
61
62
63
64
65
def append(self, state: dict[str, Any]) -> None:
    """Serialize and append a tick state to the buffer.

    Args:
        state: Tick state mapping to serialize and store.
    """
    self._frames.append(serialise_state(state))

get_frame(tick)

Return the deserialised state for the specified tick index.

Parameters:

Name Type Description Default
tick int

Index of the frame to retrieve (0-based).

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Decoded state mapping for the requested tick.

Source code in src/phids/io/replay.py
71
72
73
74
75
76
77
78
79
80
def get_frame(self, tick: int) -> dict[str, Any]:
    """Return the deserialised state for the specified tick index.

    Args:
        tick: Index of the frame to retrieve (0-based).

    Returns:
        dict[str, Any]: Decoded state mapping for the requested tick.
    """
    return deserialise_state(self._frames[tick])

load(path) classmethod

Load a replay file produced by :meth:save.

Parameters:

Name Type Description Default
path str | Path

Path to the binary replay file.

required

Returns:

Name Type Description
ReplayBuffer ReplayBuffer

Populated buffer ready for re-simulation.

Source code in src/phids/io/replay.py
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
@classmethod
def load(cls, path: str | Path) -> ReplayBuffer:
    """Load a replay file produced by :meth:`save`.

    Args:
        path: Path to the binary replay file.

    Returns:
        ReplayBuffer: Populated buffer ready for re-simulation.
    """
    source = Path(path)
    buf = cls()
    with source.open("rb") as fp:
        while True:
            length_bytes = fp.read(4)
            if len(length_bytes) < 4:
                break
            length = int.from_bytes(length_bytes, "little")
            frame = fp.read(length)
            if len(frame) < length:
                logger.warning(
                    "Replay file %s ended mid-frame (expected=%d bytes, got=%d)",
                    source,
                    length,
                    len(frame),
                )
                break
            buf._frames.append(frame)
    logger.info("Replay loaded from %s (%d frames)", source, len(buf._frames))
    return buf

save(path)

Write all frames to a binary replay file.

The file format is an append of records where each record begins with a 4-byte little-endian unsigned integer describing the following frame length, immediately followed by the frame bytes.

Parameters:

Name Type Description Default
path str | Path

Destination file path.

required
Source code in src/phids/io/replay.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def save(self, path: str | Path) -> None:
    """Write all frames to a binary replay file.

    The file format is an append of records where each record begins with a
    4-byte little-endian unsigned integer describing the following frame
    length, immediately followed by the frame bytes.

    Args:
        path: Destination file path.
    """
    destination = Path(path)
    with destination.open("wb") as fp:
        for frame in self._frames:
            length = len(frame).to_bytes(4, "little")
            fp.write(length)
            fp.write(frame)
    logger.info("Replay saved to %s (%d frames)", destination, len(self._frames))

deserialise_state(data)

Deserialize a msgpack frame into a state mapping.

Parameters:

Name Type Description Default
data bytes

msgpack-encoded bytes produced by :func:serialise_state.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Decoded state mapping.

Source code in src/phids/io/replay.py
32
33
34
35
36
37
38
39
40
41
42
def deserialise_state(data: bytes) -> dict[str, Any]:
    """Deserialize a msgpack frame into a state mapping.

    Args:
        data: msgpack-encoded bytes produced by :func:`serialise_state`.

    Returns:
        dict[str, Any]: Decoded state mapping.
    """
    result: dict[str, Any] = msgpack.unpackb(data, raw=False)
    return result

serialise_state(state)

Serialise a state snapshot to a msgpack frame.

Parameters:

Name Type Description Default
state dict[str, Any]

Tick state mapping (for example, the output of SimulationLoop.get_state_snapshot()).

required

Returns:

Name Type Description
bytes bytes

msgpack-encoded frame.

Source code in src/phids/io/replay.py
19
20
21
22
23
24
25
26
27
28
29
def serialise_state(state: dict[str, Any]) -> bytes:
    """Serialise a state snapshot to a msgpack frame.

    Args:
        state: Tick state mapping (for example, the output of
            ``SimulationLoop.get_state_snapshot()``).

    Returns:
        bytes: msgpack-encoded frame.
    """
    return msgpack.packb(state, use_bin_type=True)  # type: ignore[no-any-return]

Shared utilities

Shared constants for PHIDS.

This module defines compile-time limits (Rule of 16), grid bounds and other numeric sentinel values used across the codebase.

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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."""
        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)

Append one formatted record to the in-memory diagnostics buffer.

Source code in src/phids/shared/logging_config.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def emit(self, record: logging.LogRecord) -> None:
    """Append one formatted record to the in-memory diagnostics buffer."""
    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=False)

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
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
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  # noqa: PLW0603
    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=80)

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
54
55
56
57
58
59
60
61
62
63
64
65
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()

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
103
104
105
106
107
108
109
110
111
112
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,
    )