Zum Inhalt

Konfigurator-API

pyadm1.configurator.plant_builder.BiogasPlant

Complete biogas plant model with multiple components.

Manages component lifecycle, connections, and simulation. Supports JSON-based configuration.

Attributes:

Name Type Description
plant_name str

Name of the biogas plant.

components Dict[str, Component]

Dictionary of all plant components.

connections List[Connection]

List of connections between components.

simulation_time float

Current simulation time in days.

Example

from pyadm1 import Feedstock, BiogasPlant from pyadm1.components.biological import Digester

feedstock = Feedstock(["maize_silage_milk_ripeness", "swine_manure"], feeding_freq=24) plant = BiogasPlant("My Plant") digester = Digester("dig1", feedstock, V_liq=1200, V_gas=216, T_ad=315.15) plant.add_component(digester) plant.initialize()

Source code in pyadm1/configurator/plant_builder.py
 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
 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
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
class BiogasPlant:
    """
    Complete biogas plant model with multiple components.

    Manages component lifecycle, connections, and simulation.
    Supports JSON-based configuration.

    Attributes:
        plant_name (str): Name of the biogas plant.
        components (Dict[str, Component]): Dictionary of all plant components.
        connections (List[Connection]): List of connections between components.
        simulation_time (float): Current simulation time in days.

    Example:
        >>> from pyadm1 import Feedstock, BiogasPlant
        >>> from pyadm1.components.biological import Digester
        >>>
        >>> feedstock = Feedstock(["maize_silage_milk_ripeness", "swine_manure"], feeding_freq=24)
        >>> plant = BiogasPlant("My Plant")
        >>> digester = Digester("dig1", feedstock, V_liq=1200, V_gas=216, T_ad=315.15)
        >>> plant.add_component(digester)
        >>> plant.initialize()
    """

    def __init__(self, plant_name: str = "Biogas Plant"):
        """
        Initialize biogas plant.

        Args:
            plant_name (str): Name of the plant. Defaults to "Biogas Plant".
        """
        self.plant_name = plant_name
        self.components: Dict[str, Component] = {}
        self.connections: List[Connection] = []
        self.simulation_time = 0.0

    def add_component(self, component: Component) -> None:
        """
        Add a component to the plant.

        Args:
            component (Component): Component to add to the plant.

        Raises:
            ValueError: If component with same ID already exists.
        """
        if component.component_id in self.components:
            raise ValueError(f"Component with ID '{component.component_id}' already exists")
        self.components[component.component_id] = component

    def add_connection(self, connection: Connection) -> None:
        """
        Add a connection between components.

        Args:
            connection (Connection): Connection to add.

        Raises:
            ValueError: If source or target component not found.
        """
        # Verify components exist
        if connection.from_component not in self.components:
            raise ValueError(f"Source component '{connection.from_component}' not found")
        if connection.to_component not in self.components:
            raise ValueError(f"Target component '{connection.to_component}' not found")

        # Update component connections
        from_comp = self.components[connection.from_component]
        to_comp = self.components[connection.to_component]

        from_comp.add_output(connection.to_component)
        to_comp.add_input(connection.from_component)

        self.connections.append(connection)

    def initialize(self) -> None:
        """
        Initialize all components.

        Note: Most components auto-initialize in their constructor.
        This method is kept for compatibility and to ensure any
        components that need explicit initialization are handled.
        """
        for component in self.components.values():
            if not component._initialized:
                component.initialize()

    def step(self, dt: float) -> Dict[str, Dict[str, Any]]:
        """
        Perform one simulation time step for all components.

        This uses a multi-pass execution model:
        1. Execute digesters to produce gas → storages
        2. Execute CHPs to determine gas demand → storages
        3. Execute storages to supply gas → CHPs (re-execute with actual supply)
        4. Execute BiogasUpgrading units: re-execute storages with BGAA demand,
           then re-execute BGAA with actual supply, then re-execute its flare.

        Args:
            dt (float): Time step in days.

        Returns:
            Dict[str, Dict[str, Any]]: Results from all components.
        """
        results = {}

        # Build dependency graph
        execution_order = self._get_execution_order()

        # ========================================================================
        # PASS 1: Execute all non-storage, non-heating components
        # ========================================================================
        # Heaters are also deferred (until after Pass 3) because their CHP
        # input ``P_th_available`` is only meaningful once the CHP has run
        # in Pass 3 with the actual gas supply. Stepping a heater here would
        # see a stale/zero ``P_th`` and (a) charge the auxiliary boiler for
        # heat the CHP would otherwise have supplied and (b) double-count the
        # heater's ``energy_consumed`` if we then re-stepped it in Pass 3.
        for component_id in execution_order:
            component = self.components[component_id]

            # Skip storages in first pass
            if component.component_type.value == "storage":
                continue
            # Skip heaters in first pass (see comment above)
            if component.component_type.value == "heating":
                continue

            # Gather inputs from connected components. For "liquid"
            # cascade connections (e.g. primary digester -> post-digester),
            # the upstream component writes its effluent as Q_out /
            # state_out but the downstream digester reads its inflow as
            # Q_in / state_in -- remap on the fly so the cascade actually
            # carries flow. Other connection types (gas, heat, ...) keep
            # their keys verbatim.
            inputs = {}
            for input_id in component.inputs:
                if input_id not in self.components:
                    continue
                input_comp = self.components[input_id]
                out = input_comp.outputs_data
                conn_type = None
                for conn in self.connections:
                    if conn.from_component == input_id and conn.to_component == component_id:
                        conn_type = conn.connection_type
                        break
                if conn_type == "liquid":
                    if "Q_out" in out:
                        inputs["Q_in"] = out["Q_out"]
                    if "state_out" in out:
                        inputs["state_in"] = out["state_out"]
                else:
                    inputs.update(out)

            # Execute component
            output = component.step(self.simulation_time, dt, inputs)
            results[component_id] = output

        # ========================================================================
        # PASS 2: Execute gas storages with gas production from digesters
        # ========================================================================
        for component_id in execution_order:
            component = self.components[component_id]

            if component.component_type.value != "storage":
                continue

            # Get gas input from connected digesters
            gas_input = 0.0
            for conn in self.connections:
                if conn.to_component == component_id and conn.connection_type == "gas":
                    source_comp = self.components.get(conn.from_component)
                    if source_comp and source_comp.component_type.value == "digester":
                        gas_input += source_comp.outputs_data.get("Q_gas", 0.0)

            # Execute storage with production only (no demand yet)
            storage_inputs = {
                "Q_gas_in_m3_per_day": gas_input,
                "Q_gas_out_m3_per_day": 0.0,  # No demand yet
                "vent_to_flare": True,
            }

            output = component.step(self.simulation_time, dt, storage_inputs)
            results[component_id] = output

        # ========================================================================
        # PASS 3: Handle gas demand from CHPs
        # ========================================================================
        for component_id, component in self.components.items():
            if component.component_type.value != "chp":
                continue

            # Calculate CHP gas demand based on current operating point
            P_el_nom = component.P_el_nom
            eta_el = component.eta_el
            load_setpoint = 1.0  # Full load by default

            # Calculate required gas (m³/d biogas at 60% CH4)
            E_ch4 = 10.0  # kWh/m³ CH4
            CH4_content = 0.60
            P_required = load_setpoint * P_el_nom  # kW
            Q_ch4_required = (P_required / eta_el) * 24.0 / E_ch4  # m³/d CH4
            Q_gas_required = Q_ch4_required / CH4_content  # m³/d biogas

            # Find all gas storages connected to this CHP
            connected_storages = []
            for conn in self.connections:
                if conn.to_component == component_id and conn.connection_type == "gas":
                    storage_id = conn.from_component
                    if storage_id in self.components:
                        storage_comp = self.components[storage_id]
                        if storage_comp.component_type.value == "storage":
                            connected_storages.append(storage_id)

            if not connected_storages:
                continue

            # Distribute demand equally among storages
            demand_per_storage = Q_gas_required / len(connected_storages)

            total_supplied = 0.0

            # Re-execute storages with gas demand.
            #
            # IMPORTANT: do not pass ``Q_gas_in_m3_per_day`` again here.
            # The digester's production for this step was already routed
            # into the storage in Pass 2 and is now sitting in
            # ``storage.stored_volume_m3``. The storage's ``step()`` adds
            # ``Q_in * dt`` to ``stored_volume_m3`` on every call, so
            # re-passing the production would double-count it -- inflating
            # both inventory growth and (when the storage is saturated)
            # vented gas, producing the "vented > produced" symptom in
            # whole-run mass balances.
            #
            # We also need to preserve any vent that happened in Pass 2,
            # because the storage resets ``vented_volume_m3`` to 0 at the
            # start of every ``step()`` call. After this Pass-3 call, the
            # output dict's ``vented_volume_m3`` would otherwise only
            # contain Pass 3's vent (typically 0; Pass 2 carries the
            # overflow vents). Carry the Pass-2 value through manually.
            for storage_id in connected_storages:
                storage = self.components[storage_id]

                pass2_vented = float(results.get(storage_id, {}).get("vented_volume_m3", 0.0))

                storage_inputs = {
                    "Q_gas_in_m3_per_day": 0.0,
                    "Q_gas_out_m3_per_day": demand_per_storage,
                    "vent_to_flare": True,
                }

                storage_output = storage.step(self.simulation_time, dt, storage_inputs)

                # Merge Pass-2 vent volume into the final per-step report.
                # ``cumulative_vented_m3`` already tracks all vents across
                # both passes inside the storage component itself, so we
                # only need to repair the per-step field here.
                storage_output["vented_volume_m3"] = float(storage_output.get("vented_volume_m3", 0.0)) + pass2_vented
                results[storage_id] = storage_output

                # Accumulate supplied gas
                supplied = storage_output.get("Q_gas_supplied_m3_per_day", 0.0)
                total_supplied += supplied

            # Re-execute CHP with actual gas supply
            chp_inputs = {
                "Q_gas_supplied_m3_per_day": total_supplied,
                "load_setpoint": load_setpoint,
            }

            chp_output = component.step(self.simulation_time, dt, chp_inputs)
            results[component_id] = chp_output

            # ============================================================
            # PASS 3b: Re-execute heaters connected to this CHP with the
            # CHP's now-current thermal output. Without this, heaters
            # always see ``P_th_available = 0`` from the CHP's
            # initialised/stale outputs and charge all heat demand to the
            # auxiliary boiler -- even when the CHP has plenty of free
            # thermal power.
            #
            # When multiple heaters share one CHP, split the available
            # thermal power equally. This is a defensible-but-simple
            # allocation that preserves energy conservation
            # (``sum(P_th_used) <= chp.P_th``). A demand-proportional split
            # would require a separate iteration.
            # ============================================================
            connected_heaters = [
                conn.to_component
                for conn in self.connections
                if conn.from_component == component_id
                and conn.connection_type == "heat"
                and conn.to_component in self.components
                and self.components[conn.to_component].component_type.value == "heating"
            ]

            if connected_heaters:
                P_th_per_heater = chp_output.get("P_th", 0.0) / len(connected_heaters)
                for h_id in connected_heaters:
                    heater = self.components[h_id]
                    heater_inputs: Dict[str, Any] = {"P_th_available": P_th_per_heater}
                    # Forward any non-heat upstream inputs the heater may
                    # need (e.g. T_digester from a connected digester).
                    for conn in self.connections:
                        if conn.to_component == h_id and conn.connection_type != "heat":
                            source = self.components.get(conn.from_component)
                            if source is not None:
                                heater_inputs.update(source.outputs_data)
                    results[h_id] = heater.step(self.simulation_time, dt, heater_inputs)

        # ============================================================
        # Catch any heaters NOT connected to a CHP. They were skipped in
        # Pass 1 to avoid the stale-CHP-output problem, so they need to
        # run here with their full upstream inputs but no CHP heat.
        # ============================================================
        stepped_heaters = {cid for cid in results if cid in self.components}
        for component_id in execution_order:
            component = self.components[component_id]
            if component.component_type.value != "heating":
                continue
            if component_id in stepped_heaters:
                continue
            heater_inputs = {"P_th_available": 0.0}
            for conn in self.connections:
                if conn.to_component == component_id:
                    source = self.components.get(conn.from_component)
                    if source is not None:
                        heater_inputs.update(source.outputs_data)
            results[component_id] = component.step(self.simulation_time, dt, heater_inputs)

        # ========================================================================
        # PASS 4: Handle gas demand from BiogasUpgrading units
        #
        # Mirror of the CHP pass: re-execute connected storages with BGAA's
        # capacity as demand, then re-execute the BGAA with the actually supplied
        # volume, then update the downstream flare with the capacity overflow.
        # Without this pass, storages never supply gas to BGAA and fill until the
        # storage's internal overflow vent fires — causing the flare to activate
        # spuriously at every timestep once the storage is full.
        # ========================================================================
        for component_id, component in self.components.items():
            if component.component_type.value != "upgrading":
                continue

            # BGAA requests its full nominal capacity; supply is capped by storage level
            Q_gas_demand = component.capacity_m3_per_day

            connected_storages = [
                conn.from_component
                for conn in self.connections
                if conn.to_component == component_id
                and conn.connection_type == "gas"
                and conn.from_component in self.components
                and self.components[conn.from_component].component_type.value == "storage"
            ]

            if not connected_storages:
                continue

            demand_per_storage = Q_gas_demand / len(connected_storages)
            total_supplied = 0.0

            for storage_id in connected_storages:
                storage = self.components[storage_id]

                # Preserve the per-step vent logged in Pass 2 (same reason as CHP pass)
                pass2_vented = float(results.get(storage_id, {}).get("vented_volume_m3", 0.0))

                storage_inputs = {
                    "Q_gas_in_m3_per_day": 0.0,  # already added in Pass 2; avoid double-count
                    "Q_gas_out_m3_per_day": demand_per_storage,
                    "vent_to_flare": True,
                }
                storage_output = storage.step(self.simulation_time, dt, storage_inputs)
                storage_output["vented_volume_m3"] = float(storage_output.get("vented_volume_m3", 0.0)) + pass2_vented
                results[storage_id] = storage_output
                total_supplied += storage_output.get("Q_gas_supplied_m3_per_day", 0.0)

            # Re-execute BGAA with actual gas supply
            bgaa_output = component.step(self.simulation_time, dt, {"Q_gas_in_m3_per_day": total_supplied})
            results[component_id] = bgaa_output

            # Re-execute downstream flare with BGAA's capacity overflow
            for conn in self.connections:
                if conn.from_component != component_id or conn.connection_type != "gas":
                    continue
                flare_id = conn.to_component
                if flare_id in self.components and self.components[flare_id].component_type.value == "flare":
                    flare_inputs = {"Q_gas_in_m3_per_day": bgaa_output.get("Q_gas_out_m3_per_day", 0.0)}
                    results[flare_id] = self.components[flare_id].step(self.simulation_time, dt, flare_inputs)

        self.simulation_time += dt

        return results

    def simulate(
        self,
        duration: float,
        dt: float = 1.0 / 24.0,
        save_interval: Optional[float] = None,
    ) -> List[Dict[str, Any]]:
        """
        Run simulation for specified duration.

        Args:
            duration (float): Simulation duration in days.
            dt (float): Time step in days. Defaults to 1 hour (1/24 day).
            save_interval (Optional[float]): Interval for saving results in days.
                If None, saves every step.

        Returns:
            List[Dict[str, Any]]: Simulation results at each saved time point.
                Each entry contains 'time' and 'components' with component results.

        Example:
            >>> results = plant.simulate(duration=30, dt=1/24, save_interval=1.0)
            >>> print(f"Simulated {len(results)} time points")
        """
        if save_interval is None:
            save_interval = dt

        results = []
        next_save_time = self.simulation_time

        n_steps = int(duration / dt)

        for i in range(n_steps):
            step_result = self.step(dt)

            # Save results at specified interval
            if self.simulation_time >= next_save_time:
                results.append(
                    {
                        "time": self.simulation_time,
                        "components": step_result,
                    }
                )
                next_save_time += save_interval

            if (i + 1) % 100 == 0:
                print(f"Simulated {i + 1}/{n_steps} steps")

        return results

    def _get_execution_order(self) -> List[str]:
        """
        Determine execution order based on component dependencies.

        Returns:
            List[str]: Topologically sorted list of component IDs.
        """
        # Simple topological sort
        visited = set()
        order = []

        def visit(comp_id: str) -> None:
            """Recursive DFS helper: visit dependencies first, then append this component."""
            if comp_id in visited:
                return
            visited.add(comp_id)

            component = self.components[comp_id]
            for input_id in component.inputs:
                if input_id in self.components:
                    visit(input_id)

            order.append(comp_id)

        for comp_id in self.components:
            visit(comp_id)

        return order

    def to_graph(self) -> Graph:
        """
        Build the typed component/connection graph for this plant.

        Serializes the plant exactly like the LLM benchmark does (the
        ``components`` / ``connections`` dict produced by ``to_dict``) and
        normalizes it into the benchmark ``Graph`` data model. The returned
        graph has one ``Node`` per component (keyed by component id, carrying
        its ``component_type`` and serialized scalar parameters) and one
        ``Edge`` per connection (``liquid`` / ``gas`` / ``heat``).

        Returns:
            Graph: Graph with ``nodes`` (Dict[str, Node]) and ``edges``
                (List[Edge]), identical to what ``normalize_candidate`` builds
                in the benchmark.
        """
        candidate = {
            "components": [comp.to_dict() for comp in self.components.values()],
            "connections": [conn.to_dict() for conn in self.connections],
        }
        return normalize_candidate(candidate)

    def visualize_graph(
        self,
        output_path: Optional[str] = None,
        dpi: int = 150,
        title: Optional[str] = None,
    ) -> str:
        """
        Render the plant graph (see :meth:`to_graph`) to a PNG file.

        Mirrors the Mermaid ``flowchart LR`` style used for the benchmark
        dataset: components are drawn as rounded boxes laid out left-to-right by
        process depth, liquid connections as solid labelled arrows and gas
        connections as dotted labelled arrows (heat as dashed). Unlike the
        dataset diagrams, no dataset-specific annotations (obligations,
        confidences, reference ids) are shown -- only the plant's own
        components and connections.

        Args:
            output_path (Optional[str]): Target PNG path. Defaults to
                ``output/<plant_name>_graph.png`` relative to the repo root.
            dpi (int): Resolution of the saved figure.
            title (Optional[str]): Figure title. ``None`` (default) shows no
                title.

        Returns:
            str: Path to the written PNG file.
        """
        import matplotlib

        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        from matplotlib.lines import Line2D
        from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
        import networkx as nx

        graph = self.to_graph()

        # Serialized component_type -> proper PyADM1ODE class name for display.
        class_names = {
            "digester": "Digester",
            "storage": "GasStorage",
            "chp": "CHP",
            "flare": "Flare",
            "heating": "HeatingSystem",
            "boiler": "Boiler",
            "separator": "Separator",
            "upgrading": "BiogasUpgrading",
            "mixer": "Mixer",
        }

        # Edge style per transport medium, matching the Mermaid dataset look:
        # liquid = solid, gas = dotted, heat = dashed.
        edge_styles = {
            "liquid": {"linestyle": "solid", "color": "#333333", "label": "liquid"},
            "gas": {"linestyle": "dotted", "color": "#C0703C", "label": "gas"},
            "heat": {"linestyle": "dashed", "color": "#C44E52", "label": "heat"},
        }

        # --- Layered left-to-right layout (process depth = longest path) ------
        dg = nx.DiGraph()
        dg.add_nodes_from(graph.nodes)
        for edge in graph.edges:
            dg.add_edge(edge.src, edge.dst)

        # Longest-path depth from any source; robust against cycles.
        depth: Dict[str, int] = {n: 0 for n in dg.nodes}
        try:
            for n in nx.topological_sort(dg):
                for succ in dg.successors(n):
                    depth[succ] = max(depth[succ], depth[n] + 1)
        except nx.NetworkXUnfeasible:
            depth = {n: 0 for n in dg.nodes}  # cyclic: fall back to single column

        # Group nodes by depth (column) and assign vertical slots.
        columns: Dict[int, List[str]] = {}
        for nid in graph.nodes:
            columns.setdefault(depth[nid], []).append(nid)

        def _key_info(ctype: str, p: Dict[str, Any]) -> List[str]:
            """Most relevant sizing/rating values per component type."""

            def num(key, fmt="{:g}"):
                v = p.get(key)
                return fmt.format(v) if isinstance(v, (int, float)) else None

            lines: List[str] = []
            if ctype == "digester":
                if num("V_liq"):
                    lines.append(f"V_liq {num('V_liq')} m³")
                if num("V_gas"):
                    lines.append(f"V_gas {num('V_gas')} m³")
                if isinstance(p.get("T_ad"), (int, float)):
                    lines.append(f"T {p['T_ad'] - 273.15:.0f} °C")
            elif ctype == "storage":
                if num("capacity_m3"):
                    lines.append(f"V_gas {num('capacity_m3')} m³")
            elif ctype == "chp":
                if num("P_el_nom"):
                    lines.append(f"P_el {num('P_el_nom')} kW")
                if num("eta_el"):
                    lines.append(f"η_el {float(p['eta_el']) * 100:.0f} %")
            elif ctype == "upgrading":
                if num("capacity_m3h"):
                    lines.append(f"{num('capacity_m3h')} m³/h")
            elif ctype == "separator":
                if num("separation_efficiency"):
                    lines.append(f{float(p['separation_efficiency']) * 100:.0f} %")
            elif ctype == "flare":
                if num("destruction_efficiency"):
                    lines.append(f{float(p['destruction_efficiency']) * 100:.0f} %")
            elif ctype == "heating":
                if isinstance(p.get("target_temperature"), (int, float)):
                    t = p["target_temperature"]
                    t = t - 273.15 if t > 200 else t
                    lines.append(f"T {t:.0f} °C")
            return lines

        # Per-node info line; box width adapts to the longest text shown.
        infos: Dict[str, str] = {
            nid: "  ·  ".join(_key_info(graph.nodes[nid].ctype, graph.nodes[nid].params)) for nid in graph.nodes
        }
        longest = max([len(infos[n]) for n in infos] + [len(n) for n in graph.nodes] + [10])
        box_h = 1.3
        box_w = max(3.2, 0.095 * longest + 1.0)

        # --- Layered left-to-right layout (columns by process depth) ----------
        x_gap, y_gap = box_w + 1.8, 1.9
        pos: Dict[str, tuple] = {}
        max_rows = max((len(c) for c in columns.values()), default=1)
        for col, nids in sorted(columns.items()):
            n_rows = len(nids)
            offset = (max_rows - n_rows) / 2.0
            for row, nid in enumerate(sorted(nids)):
                pos[nid] = (col * x_gap, -(row + offset) * y_gap)

        n_cols = max(columns) + 1 if columns else 1
        fig, ax = plt.subplots(figsize=(max(7, n_cols * (box_w + 1.8)), max(4, max_rows * 1.9)))

        for nid, (x, y) in pos.items():
            node = graph.nodes[nid]
            ctype = node.ctype
            box = FancyBboxPatch(
                (x - box_w / 2, y - box_h / 2),
                box_w,
                box_h,
                boxstyle="round,pad=0.03,rounding_size=0.15",
                linewidth=1.4,
                edgecolor="#34495E",
                facecolor="#ECF3FB",
                zorder=2,
            )
            ax.add_patch(box)
            ax.text(x, y + box_h * 0.22, nid, ha="center", va="center", fontsize=9, fontweight="bold", zorder=3)
            ax.text(
                x,
                y + box_h * 0.02,
                class_names.get(ctype, ctype),
                ha="center",
                va="center",
                fontsize=7.5,
                color="#555555",
                zorder=3,
            )
            info = infos[nid]
            if info:
                ax.text(
                    x,
                    y - box_h * 0.28,
                    info,
                    ha="center",
                    va="center",
                    fontsize=6.5,
                    color="#1A5276",
                    zorder=3,
                )

        def _border(cx, cy, tx, ty):
            """Point on the box border of (cx, cy) facing (tx, ty)."""
            dx, dy = tx - cx, ty - cy
            if dx == 0 and dy == 0:
                return cx, cy
            sx = (box_w / 2 + 0.06) / abs(dx) if dx else float("inf")
            sy = (box_h / 2 + 0.06) / abs(dy) if dy else float("inf")
            t = min(sx, sy)
            return cx + dx * t, cy + dy * t

        used_styles: Dict[str, dict] = {}
        # Bow gas edges up and heat edges down so parallel liquid/gas/heat
        # connections separate; edges spanning intermediate columns bow more
        # (like Mermaid) to clear the boxes in between.
        bow_dir = {"liquid": 0.0, "gas": 1.0, "heat": -1.0}
        for edge in graph.edges:
            if edge.src not in pos or edge.dst not in pos:
                continue
            style = edge_styles.get(edge.etype, edge_styles["liquid"])
            used_styles[edge.etype] = style
            cx0, cy0 = pos[edge.src]
            cx1, cy1 = pos[edge.dst]
            # Start/end on the box borders so the arrow head stays visible.
            x0, y0 = _border(cx0, cy0, cx1, cy1)
            x1, y1 = _border(cx1, cy1, cx0, cy0)
            span = abs(depth[edge.dst] - depth[edge.src])
            direction = bow_dir.get(edge.etype, 0.0)
            chord_x = abs(cx1 - cx0)
            if chord_x < 0.1:  # same column (e.g. cyclic) -> sidestep
                rad = 0.3
            elif direction == 0.0 and span <= 1:  # adjacent liquid -> straight
                rad = 0.0
            else:
                # Fixed apex offset (data units) regardless of span, so long
                # multi-column edges bow gently instead of dipping far down.
                apex = 0.8 if span <= 1 else 1.2
                rad = direction * 2 * apex / chord_x
            conn = f"arc3,rad={rad}"
            # Body line in the medium's linestyle, but without a head, so a
            # dotted gas line does not produce a dotted (broken) arrow head.
            ax.add_patch(
                FancyArrowPatch(
                    (x0, y0),
                    (x1, y1),
                    connectionstyle=conn,
                    arrowstyle="-",
                    linewidth=1.6,
                    linestyle=style["linestyle"],
                    color=style["color"],
                    shrinkA=1,
                    shrinkB=2,
                    zorder=1,
                )
            )
            # Solid filled arrow head only (linewidth 0 -> no body line drawn).
            ax.add_patch(
                FancyArrowPatch(
                    (x0, y0),
                    (x1, y1),
                    connectionstyle=conn,
                    arrowstyle="-|>,head_length=5,head_width=3",
                    mutation_scale=2.4,
                    linewidth=0,
                    color=style["color"],
                    shrinkA=1,
                    shrinkB=2,
                    zorder=1,
                )
            )

        legend = [
            Line2D([0], [0], color=s["color"], linestyle=s["linestyle"], lw=1.6, label=s["label"])
            for et, s in edge_styles.items()
            if et in used_styles
        ]
        if legend:
            ax.legend(handles=legend, loc="lower right", fontsize=8, title="connection", framealpha=0.9)
        if title:
            ax.set_title(title)
        ax.axis("off")

        # Tight content bounding box: boxes plus room for the edge bows
        # (gas bows below, heat above). Sizing the figure to this box keeps the
        # image height matched to the graph instead of leaving large gaps.
        xs = [x for x, _ in pos.values()]
        ys = [y for _, y in pos.values()]
        pad = 0.5
        x_lo, x_hi = min(xs) - box_w / 2 - pad, max(xs) + box_w / 2 + pad
        y_lo = min(ys) - box_h / 2 - (1.4 if "gas" in used_styles else 0.0) - pad
        y_hi = max(ys) + box_h / 2 + (1.4 if "heat" in used_styles else 0.0) + pad
        ax.set_xlim(x_lo, x_hi)
        ax.set_ylim(y_lo, y_hi)
        ax.set_aspect("equal", adjustable="box")

        scale = 0.55
        fig.set_size_inches((x_hi - x_lo) * scale, (y_hi - y_lo) * scale)
        fig.tight_layout()

        if output_path is None:
            from pathlib import Path

            repo_root = Path(__file__).resolve().parents[2]
            out_dir = repo_root / "output"
            out_dir.mkdir(exist_ok=True)
            safe_name = self.plant_name.replace(" ", "_")
            output_path = str(out_dir / f"{safe_name}_graph.png")

        fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
        plt.close(fig)
        return output_path

    def to_json(self, filepath: str) -> None:
        """
        Save plant configuration to JSON file.

        Args:
            filepath (str): Path to JSON file.
        """
        config = {
            "plant_name": self.plant_name,
            "simulation_time": self.simulation_time,
            "components": [comp.to_dict() for comp in self.components.values()],
            "connections": [conn.to_dict() for conn in self.connections],
        }

        with open(filepath, "w") as f:
            json.dump(config, f, indent=2)

        print(f"Plant configuration saved to {filepath}")

    @classmethod
    def from_json(cls, filepath: str, feedstock: Optional[Feedstock] = None) -> "BiogasPlant":
        """
        Load plant configuration from JSON file.

        Args:
            filepath (str): Path to JSON file.
            feedstock (Optional[Feedstock]): Feedstock object for digesters.
                Required if plant has digesters.

        Returns:
            BiogasPlant: Loaded plant model.

        Raises:
            ValueError: If feedstock is None but plant contains digesters.
        """
        with open(filepath, "r") as f:
            config = json.load(f)

        plant = cls(config.get("plant_name", "Biogas Plant"))
        plant.simulation_time = config.get("simulation_time", 0.0)

        # Create components.  Digesters need the supplied feedstock; for every
        # other component type we delegate to the class's ``from_dict`` via the
        # global component registry so all auto-attached children (gas storage,
        # flares, mixers, sensors, …) round-trip without bespoke branches.
        from pyadm1.components.registry import get_registry

        registry = get_registry()
        type_to_registry_key = {
            ComponentType.DIGESTER: "Digester",
            ComponentType.CHP: "CHP",
            ComponentType.HEATING: "HeatingSystem",
            ComponentType.STORAGE: "GasStorage",
            ComponentType.FLARE: "Flare",
            ComponentType.BOILER: "Boiler",
            ComponentType.SEPARATOR: "Separator",
            ComponentType.UPGRADING: "BiogasUpgrading",
        }

        for comp_config in config.get("components", []):
            comp_type = ComponentType(comp_config["component_type"])

            if comp_type == ComponentType.DIGESTER:
                if feedstock is None:
                    raise ValueError("Feedstock required for loading plant with digesters")
                component = Digester.from_dict(comp_config, feedstock)
            elif comp_type in type_to_registry_key:
                cls_obj = registry.get_registered_components().get(type_to_registry_key[comp_type])
                if cls_obj is None:
                    raise ValueError(f"No registered class for component type: {comp_type}")
                component = cls_obj.from_dict(comp_config)
            else:
                raise ValueError(f"Unknown component type: {comp_type}")

            plant.add_component(component)

        # Create connections
        for conn_config in config.get("connections", []):
            connection = Connection.from_dict(conn_config)
            plant.add_connection(connection)

        print(f"Plant configuration loaded from {filepath}")

        return plant

    def get_summary(self) -> str:
        """
        Get human-readable summary of plant configuration.

        Returns:
            str: Summary text with components and connections.
        """
        lines = [
            f"=== {self.plant_name} ===",
            f"Simulation time: {self.simulation_time:.2f} days",
            f"\nComponents ({len(self.components)}):",
        ]

        for comp in self.components.values():
            lines.append(f"  - {comp.name} ({comp.component_type.value})")

        lines.append(f"\nConnections ({len(self.connections)}):")
        for conn in self.connections:
            from_name = self.components[conn.from_component].name
            to_name = self.components[conn.to_component].name
            lines.append(f"  - {from_name} -> {to_name} ({conn.connection_type})")

        return "\n".join(lines)

Methods:

__init__(plant_name='Biogas Plant')

Initialize biogas plant.

Parameters:

Name Type Description Default
plant_name str

Name of the plant. Defaults to "Biogas Plant".

'Biogas Plant'
Source code in pyadm1/configurator/plant_builder.py
def __init__(self, plant_name: str = "Biogas Plant"):
    """
    Initialize biogas plant.

    Args:
        plant_name (str): Name of the plant. Defaults to "Biogas Plant".
    """
    self.plant_name = plant_name
    self.components: Dict[str, Component] = {}
    self.connections: List[Connection] = []
    self.simulation_time = 0.0

add_component(component)

Add a component to the plant.

Parameters:

Name Type Description Default
component Component

Component to add to the plant.

required

Raises:

Type Description
ValueError

If component with same ID already exists.

Source code in pyadm1/configurator/plant_builder.py
def add_component(self, component: Component) -> None:
    """
    Add a component to the plant.

    Args:
        component (Component): Component to add to the plant.

    Raises:
        ValueError: If component with same ID already exists.
    """
    if component.component_id in self.components:
        raise ValueError(f"Component with ID '{component.component_id}' already exists")
    self.components[component.component_id] = component

add_connection(connection)

Add a connection between components.

Parameters:

Name Type Description Default
connection Connection

Connection to add.

required

Raises:

Type Description
ValueError

If source or target component not found.

Source code in pyadm1/configurator/plant_builder.py
def add_connection(self, connection: Connection) -> None:
    """
    Add a connection between components.

    Args:
        connection (Connection): Connection to add.

    Raises:
        ValueError: If source or target component not found.
    """
    # Verify components exist
    if connection.from_component not in self.components:
        raise ValueError(f"Source component '{connection.from_component}' not found")
    if connection.to_component not in self.components:
        raise ValueError(f"Target component '{connection.to_component}' not found")

    # Update component connections
    from_comp = self.components[connection.from_component]
    to_comp = self.components[connection.to_component]

    from_comp.add_output(connection.to_component)
    to_comp.add_input(connection.from_component)

    self.connections.append(connection)

from_json(filepath, feedstock=None) classmethod

Load plant configuration from JSON file.

Parameters:

Name Type Description Default
filepath str

Path to JSON file.

required
feedstock Optional[Feedstock]

Feedstock object for digesters. Required if plant has digesters.

None

Returns:

Name Type Description
BiogasPlant BiogasPlant

Loaded plant model.

Raises:

Type Description
ValueError

If feedstock is None but plant contains digesters.

Source code in pyadm1/configurator/plant_builder.py
@classmethod
def from_json(cls, filepath: str, feedstock: Optional[Feedstock] = None) -> "BiogasPlant":
    """
    Load plant configuration from JSON file.

    Args:
        filepath (str): Path to JSON file.
        feedstock (Optional[Feedstock]): Feedstock object for digesters.
            Required if plant has digesters.

    Returns:
        BiogasPlant: Loaded plant model.

    Raises:
        ValueError: If feedstock is None but plant contains digesters.
    """
    with open(filepath, "r") as f:
        config = json.load(f)

    plant = cls(config.get("plant_name", "Biogas Plant"))
    plant.simulation_time = config.get("simulation_time", 0.0)

    # Create components.  Digesters need the supplied feedstock; for every
    # other component type we delegate to the class's ``from_dict`` via the
    # global component registry so all auto-attached children (gas storage,
    # flares, mixers, sensors, …) round-trip without bespoke branches.
    from pyadm1.components.registry import get_registry

    registry = get_registry()
    type_to_registry_key = {
        ComponentType.DIGESTER: "Digester",
        ComponentType.CHP: "CHP",
        ComponentType.HEATING: "HeatingSystem",
        ComponentType.STORAGE: "GasStorage",
        ComponentType.FLARE: "Flare",
        ComponentType.BOILER: "Boiler",
        ComponentType.SEPARATOR: "Separator",
        ComponentType.UPGRADING: "BiogasUpgrading",
    }

    for comp_config in config.get("components", []):
        comp_type = ComponentType(comp_config["component_type"])

        if comp_type == ComponentType.DIGESTER:
            if feedstock is None:
                raise ValueError("Feedstock required for loading plant with digesters")
            component = Digester.from_dict(comp_config, feedstock)
        elif comp_type in type_to_registry_key:
            cls_obj = registry.get_registered_components().get(type_to_registry_key[comp_type])
            if cls_obj is None:
                raise ValueError(f"No registered class for component type: {comp_type}")
            component = cls_obj.from_dict(comp_config)
        else:
            raise ValueError(f"Unknown component type: {comp_type}")

        plant.add_component(component)

    # Create connections
    for conn_config in config.get("connections", []):
        connection = Connection.from_dict(conn_config)
        plant.add_connection(connection)

    print(f"Plant configuration loaded from {filepath}")

    return plant

get_summary()

Get human-readable summary of plant configuration.

Returns:

Name Type Description
str str

Summary text with components and connections.

Source code in pyadm1/configurator/plant_builder.py
def get_summary(self) -> str:
    """
    Get human-readable summary of plant configuration.

    Returns:
        str: Summary text with components and connections.
    """
    lines = [
        f"=== {self.plant_name} ===",
        f"Simulation time: {self.simulation_time:.2f} days",
        f"\nComponents ({len(self.components)}):",
    ]

    for comp in self.components.values():
        lines.append(f"  - {comp.name} ({comp.component_type.value})")

    lines.append(f"\nConnections ({len(self.connections)}):")
    for conn in self.connections:
        from_name = self.components[conn.from_component].name
        to_name = self.components[conn.to_component].name
        lines.append(f"  - {from_name} -> {to_name} ({conn.connection_type})")

    return "\n".join(lines)

initialize()

Initialize all components.

Note: Most components auto-initialize in their constructor. This method is kept for compatibility and to ensure any components that need explicit initialization are handled.

Source code in pyadm1/configurator/plant_builder.py
def initialize(self) -> None:
    """
    Initialize all components.

    Note: Most components auto-initialize in their constructor.
    This method is kept for compatibility and to ensure any
    components that need explicit initialization are handled.
    """
    for component in self.components.values():
        if not component._initialized:
            component.initialize()

simulate(duration, dt=1.0 / 24.0, save_interval=None)

Run simulation for specified duration.

Parameters:

Name Type Description Default
duration float

Simulation duration in days.

required
dt float

Time step in days. Defaults to 1 hour (1/24 day).

1.0 / 24.0
save_interval Optional[float]

Interval for saving results in days. If None, saves every step.

None

Returns:

Type Description
List[Dict[str, Any]]

List[Dict[str, Any]]: Simulation results at each saved time point. Each entry contains 'time' and 'components' with component results.

Example

results = plant.simulate(duration=30, dt=1/24, save_interval=1.0) print(f"Simulated {len(results)} time points")

Source code in pyadm1/configurator/plant_builder.py
def simulate(
    self,
    duration: float,
    dt: float = 1.0 / 24.0,
    save_interval: Optional[float] = None,
) -> List[Dict[str, Any]]:
    """
    Run simulation for specified duration.

    Args:
        duration (float): Simulation duration in days.
        dt (float): Time step in days. Defaults to 1 hour (1/24 day).
        save_interval (Optional[float]): Interval for saving results in days.
            If None, saves every step.

    Returns:
        List[Dict[str, Any]]: Simulation results at each saved time point.
            Each entry contains 'time' and 'components' with component results.

    Example:
        >>> results = plant.simulate(duration=30, dt=1/24, save_interval=1.0)
        >>> print(f"Simulated {len(results)} time points")
    """
    if save_interval is None:
        save_interval = dt

    results = []
    next_save_time = self.simulation_time

    n_steps = int(duration / dt)

    for i in range(n_steps):
        step_result = self.step(dt)

        # Save results at specified interval
        if self.simulation_time >= next_save_time:
            results.append(
                {
                    "time": self.simulation_time,
                    "components": step_result,
                }
            )
            next_save_time += save_interval

        if (i + 1) % 100 == 0:
            print(f"Simulated {i + 1}/{n_steps} steps")

    return results

step(dt)

Perform one simulation time step for all components.

This uses a multi-pass execution model: 1. Execute digesters to produce gas → storages 2. Execute CHPs to determine gas demand → storages 3. Execute storages to supply gas → CHPs (re-execute with actual supply) 4. Execute BiogasUpgrading units: re-execute storages with BGAA demand, then re-execute BGAA with actual supply, then re-execute its flare.

Parameters:

Name Type Description Default
dt float

Time step in days.

required

Returns:

Type Description
Dict[str, Dict[str, Any]]

Dict[str, Dict[str, Any]]: Results from all components.

Source code in pyadm1/configurator/plant_builder.py
def step(self, dt: float) -> Dict[str, Dict[str, Any]]:
    """
    Perform one simulation time step for all components.

    This uses a multi-pass execution model:
    1. Execute digesters to produce gas → storages
    2. Execute CHPs to determine gas demand → storages
    3. Execute storages to supply gas → CHPs (re-execute with actual supply)
    4. Execute BiogasUpgrading units: re-execute storages with BGAA demand,
       then re-execute BGAA with actual supply, then re-execute its flare.

    Args:
        dt (float): Time step in days.

    Returns:
        Dict[str, Dict[str, Any]]: Results from all components.
    """
    results = {}

    # Build dependency graph
    execution_order = self._get_execution_order()

    # ========================================================================
    # PASS 1: Execute all non-storage, non-heating components
    # ========================================================================
    # Heaters are also deferred (until after Pass 3) because their CHP
    # input ``P_th_available`` is only meaningful once the CHP has run
    # in Pass 3 with the actual gas supply. Stepping a heater here would
    # see a stale/zero ``P_th`` and (a) charge the auxiliary boiler for
    # heat the CHP would otherwise have supplied and (b) double-count the
    # heater's ``energy_consumed`` if we then re-stepped it in Pass 3.
    for component_id in execution_order:
        component = self.components[component_id]

        # Skip storages in first pass
        if component.component_type.value == "storage":
            continue
        # Skip heaters in first pass (see comment above)
        if component.component_type.value == "heating":
            continue

        # Gather inputs from connected components. For "liquid"
        # cascade connections (e.g. primary digester -> post-digester),
        # the upstream component writes its effluent as Q_out /
        # state_out but the downstream digester reads its inflow as
        # Q_in / state_in -- remap on the fly so the cascade actually
        # carries flow. Other connection types (gas, heat, ...) keep
        # their keys verbatim.
        inputs = {}
        for input_id in component.inputs:
            if input_id not in self.components:
                continue
            input_comp = self.components[input_id]
            out = input_comp.outputs_data
            conn_type = None
            for conn in self.connections:
                if conn.from_component == input_id and conn.to_component == component_id:
                    conn_type = conn.connection_type
                    break
            if conn_type == "liquid":
                if "Q_out" in out:
                    inputs["Q_in"] = out["Q_out"]
                if "state_out" in out:
                    inputs["state_in"] = out["state_out"]
            else:
                inputs.update(out)

        # Execute component
        output = component.step(self.simulation_time, dt, inputs)
        results[component_id] = output

    # ========================================================================
    # PASS 2: Execute gas storages with gas production from digesters
    # ========================================================================
    for component_id in execution_order:
        component = self.components[component_id]

        if component.component_type.value != "storage":
            continue

        # Get gas input from connected digesters
        gas_input = 0.0
        for conn in self.connections:
            if conn.to_component == component_id and conn.connection_type == "gas":
                source_comp = self.components.get(conn.from_component)
                if source_comp and source_comp.component_type.value == "digester":
                    gas_input += source_comp.outputs_data.get("Q_gas", 0.0)

        # Execute storage with production only (no demand yet)
        storage_inputs = {
            "Q_gas_in_m3_per_day": gas_input,
            "Q_gas_out_m3_per_day": 0.0,  # No demand yet
            "vent_to_flare": True,
        }

        output = component.step(self.simulation_time, dt, storage_inputs)
        results[component_id] = output

    # ========================================================================
    # PASS 3: Handle gas demand from CHPs
    # ========================================================================
    for component_id, component in self.components.items():
        if component.component_type.value != "chp":
            continue

        # Calculate CHP gas demand based on current operating point
        P_el_nom = component.P_el_nom
        eta_el = component.eta_el
        load_setpoint = 1.0  # Full load by default

        # Calculate required gas (m³/d biogas at 60% CH4)
        E_ch4 = 10.0  # kWh/m³ CH4
        CH4_content = 0.60
        P_required = load_setpoint * P_el_nom  # kW
        Q_ch4_required = (P_required / eta_el) * 24.0 / E_ch4  # m³/d CH4
        Q_gas_required = Q_ch4_required / CH4_content  # m³/d biogas

        # Find all gas storages connected to this CHP
        connected_storages = []
        for conn in self.connections:
            if conn.to_component == component_id and conn.connection_type == "gas":
                storage_id = conn.from_component
                if storage_id in self.components:
                    storage_comp = self.components[storage_id]
                    if storage_comp.component_type.value == "storage":
                        connected_storages.append(storage_id)

        if not connected_storages:
            continue

        # Distribute demand equally among storages
        demand_per_storage = Q_gas_required / len(connected_storages)

        total_supplied = 0.0

        # Re-execute storages with gas demand.
        #
        # IMPORTANT: do not pass ``Q_gas_in_m3_per_day`` again here.
        # The digester's production for this step was already routed
        # into the storage in Pass 2 and is now sitting in
        # ``storage.stored_volume_m3``. The storage's ``step()`` adds
        # ``Q_in * dt`` to ``stored_volume_m3`` on every call, so
        # re-passing the production would double-count it -- inflating
        # both inventory growth and (when the storage is saturated)
        # vented gas, producing the "vented > produced" symptom in
        # whole-run mass balances.
        #
        # We also need to preserve any vent that happened in Pass 2,
        # because the storage resets ``vented_volume_m3`` to 0 at the
        # start of every ``step()`` call. After this Pass-3 call, the
        # output dict's ``vented_volume_m3`` would otherwise only
        # contain Pass 3's vent (typically 0; Pass 2 carries the
        # overflow vents). Carry the Pass-2 value through manually.
        for storage_id in connected_storages:
            storage = self.components[storage_id]

            pass2_vented = float(results.get(storage_id, {}).get("vented_volume_m3", 0.0))

            storage_inputs = {
                "Q_gas_in_m3_per_day": 0.0,
                "Q_gas_out_m3_per_day": demand_per_storage,
                "vent_to_flare": True,
            }

            storage_output = storage.step(self.simulation_time, dt, storage_inputs)

            # Merge Pass-2 vent volume into the final per-step report.
            # ``cumulative_vented_m3`` already tracks all vents across
            # both passes inside the storage component itself, so we
            # only need to repair the per-step field here.
            storage_output["vented_volume_m3"] = float(storage_output.get("vented_volume_m3", 0.0)) + pass2_vented
            results[storage_id] = storage_output

            # Accumulate supplied gas
            supplied = storage_output.get("Q_gas_supplied_m3_per_day", 0.0)
            total_supplied += supplied

        # Re-execute CHP with actual gas supply
        chp_inputs = {
            "Q_gas_supplied_m3_per_day": total_supplied,
            "load_setpoint": load_setpoint,
        }

        chp_output = component.step(self.simulation_time, dt, chp_inputs)
        results[component_id] = chp_output

        # ============================================================
        # PASS 3b: Re-execute heaters connected to this CHP with the
        # CHP's now-current thermal output. Without this, heaters
        # always see ``P_th_available = 0`` from the CHP's
        # initialised/stale outputs and charge all heat demand to the
        # auxiliary boiler -- even when the CHP has plenty of free
        # thermal power.
        #
        # When multiple heaters share one CHP, split the available
        # thermal power equally. This is a defensible-but-simple
        # allocation that preserves energy conservation
        # (``sum(P_th_used) <= chp.P_th``). A demand-proportional split
        # would require a separate iteration.
        # ============================================================
        connected_heaters = [
            conn.to_component
            for conn in self.connections
            if conn.from_component == component_id
            and conn.connection_type == "heat"
            and conn.to_component in self.components
            and self.components[conn.to_component].component_type.value == "heating"
        ]

        if connected_heaters:
            P_th_per_heater = chp_output.get("P_th", 0.0) / len(connected_heaters)
            for h_id in connected_heaters:
                heater = self.components[h_id]
                heater_inputs: Dict[str, Any] = {"P_th_available": P_th_per_heater}
                # Forward any non-heat upstream inputs the heater may
                # need (e.g. T_digester from a connected digester).
                for conn in self.connections:
                    if conn.to_component == h_id and conn.connection_type != "heat":
                        source = self.components.get(conn.from_component)
                        if source is not None:
                            heater_inputs.update(source.outputs_data)
                results[h_id] = heater.step(self.simulation_time, dt, heater_inputs)

    # ============================================================
    # Catch any heaters NOT connected to a CHP. They were skipped in
    # Pass 1 to avoid the stale-CHP-output problem, so they need to
    # run here with their full upstream inputs but no CHP heat.
    # ============================================================
    stepped_heaters = {cid for cid in results if cid in self.components}
    for component_id in execution_order:
        component = self.components[component_id]
        if component.component_type.value != "heating":
            continue
        if component_id in stepped_heaters:
            continue
        heater_inputs = {"P_th_available": 0.0}
        for conn in self.connections:
            if conn.to_component == component_id:
                source = self.components.get(conn.from_component)
                if source is not None:
                    heater_inputs.update(source.outputs_data)
        results[component_id] = component.step(self.simulation_time, dt, heater_inputs)

    # ========================================================================
    # PASS 4: Handle gas demand from BiogasUpgrading units
    #
    # Mirror of the CHP pass: re-execute connected storages with BGAA's
    # capacity as demand, then re-execute the BGAA with the actually supplied
    # volume, then update the downstream flare with the capacity overflow.
    # Without this pass, storages never supply gas to BGAA and fill until the
    # storage's internal overflow vent fires — causing the flare to activate
    # spuriously at every timestep once the storage is full.
    # ========================================================================
    for component_id, component in self.components.items():
        if component.component_type.value != "upgrading":
            continue

        # BGAA requests its full nominal capacity; supply is capped by storage level
        Q_gas_demand = component.capacity_m3_per_day

        connected_storages = [
            conn.from_component
            for conn in self.connections
            if conn.to_component == component_id
            and conn.connection_type == "gas"
            and conn.from_component in self.components
            and self.components[conn.from_component].component_type.value == "storage"
        ]

        if not connected_storages:
            continue

        demand_per_storage = Q_gas_demand / len(connected_storages)
        total_supplied = 0.0

        for storage_id in connected_storages:
            storage = self.components[storage_id]

            # Preserve the per-step vent logged in Pass 2 (same reason as CHP pass)
            pass2_vented = float(results.get(storage_id, {}).get("vented_volume_m3", 0.0))

            storage_inputs = {
                "Q_gas_in_m3_per_day": 0.0,  # already added in Pass 2; avoid double-count
                "Q_gas_out_m3_per_day": demand_per_storage,
                "vent_to_flare": True,
            }
            storage_output = storage.step(self.simulation_time, dt, storage_inputs)
            storage_output["vented_volume_m3"] = float(storage_output.get("vented_volume_m3", 0.0)) + pass2_vented
            results[storage_id] = storage_output
            total_supplied += storage_output.get("Q_gas_supplied_m3_per_day", 0.0)

        # Re-execute BGAA with actual gas supply
        bgaa_output = component.step(self.simulation_time, dt, {"Q_gas_in_m3_per_day": total_supplied})
        results[component_id] = bgaa_output

        # Re-execute downstream flare with BGAA's capacity overflow
        for conn in self.connections:
            if conn.from_component != component_id or conn.connection_type != "gas":
                continue
            flare_id = conn.to_component
            if flare_id in self.components and self.components[flare_id].component_type.value == "flare":
                flare_inputs = {"Q_gas_in_m3_per_day": bgaa_output.get("Q_gas_out_m3_per_day", 0.0)}
                results[flare_id] = self.components[flare_id].step(self.simulation_time, dt, flare_inputs)

    self.simulation_time += dt

    return results

to_graph()

Build the typed component/connection graph for this plant.

Serializes the plant exactly like the LLM benchmark does (the components / connections dict produced by to_dict) and normalizes it into the benchmark Graph data model. The returned graph has one Node per component (keyed by component id, carrying its component_type and serialized scalar parameters) and one Edge per connection (liquid / gas / heat).

Returns:

Name Type Description
Graph Graph

Graph with nodes (Dict[str, Node]) and edges (List[Edge]), identical to what normalize_candidate builds in the benchmark.

Source code in pyadm1/configurator/plant_builder.py
def to_graph(self) -> Graph:
    """
    Build the typed component/connection graph for this plant.

    Serializes the plant exactly like the LLM benchmark does (the
    ``components`` / ``connections`` dict produced by ``to_dict``) and
    normalizes it into the benchmark ``Graph`` data model. The returned
    graph has one ``Node`` per component (keyed by component id, carrying
    its ``component_type`` and serialized scalar parameters) and one
    ``Edge`` per connection (``liquid`` / ``gas`` / ``heat``).

    Returns:
        Graph: Graph with ``nodes`` (Dict[str, Node]) and ``edges``
            (List[Edge]), identical to what ``normalize_candidate`` builds
            in the benchmark.
    """
    candidate = {
        "components": [comp.to_dict() for comp in self.components.values()],
        "connections": [conn.to_dict() for conn in self.connections],
    }
    return normalize_candidate(candidate)

to_json(filepath)

Save plant configuration to JSON file.

Parameters:

Name Type Description Default
filepath str

Path to JSON file.

required
Source code in pyadm1/configurator/plant_builder.py
def to_json(self, filepath: str) -> None:
    """
    Save plant configuration to JSON file.

    Args:
        filepath (str): Path to JSON file.
    """
    config = {
        "plant_name": self.plant_name,
        "simulation_time": self.simulation_time,
        "components": [comp.to_dict() for comp in self.components.values()],
        "connections": [conn.to_dict() for conn in self.connections],
    }

    with open(filepath, "w") as f:
        json.dump(config, f, indent=2)

    print(f"Plant configuration saved to {filepath}")

visualize_graph(output_path=None, dpi=150, title=None)

Render the plant graph (see :meth:to_graph) to a PNG file.

Mirrors the Mermaid flowchart LR style used for the benchmark dataset: components are drawn as rounded boxes laid out left-to-right by process depth, liquid connections as solid labelled arrows and gas connections as dotted labelled arrows (heat as dashed). Unlike the dataset diagrams, no dataset-specific annotations (obligations, confidences, reference ids) are shown -- only the plant's own components and connections.

Parameters:

Name Type Description Default
output_path Optional[str]

Target PNG path. Defaults to output/<plant_name>_graph.png relative to the repo root.

None
dpi int

Resolution of the saved figure.

150
title Optional[str]

Figure title. None (default) shows no title.

None

Returns:

Name Type Description
str str

Path to the written PNG file.

Source code in pyadm1/configurator/plant_builder.py
def visualize_graph(
    self,
    output_path: Optional[str] = None,
    dpi: int = 150,
    title: Optional[str] = None,
) -> str:
    """
    Render the plant graph (see :meth:`to_graph`) to a PNG file.

    Mirrors the Mermaid ``flowchart LR`` style used for the benchmark
    dataset: components are drawn as rounded boxes laid out left-to-right by
    process depth, liquid connections as solid labelled arrows and gas
    connections as dotted labelled arrows (heat as dashed). Unlike the
    dataset diagrams, no dataset-specific annotations (obligations,
    confidences, reference ids) are shown -- only the plant's own
    components and connections.

    Args:
        output_path (Optional[str]): Target PNG path. Defaults to
            ``output/<plant_name>_graph.png`` relative to the repo root.
        dpi (int): Resolution of the saved figure.
        title (Optional[str]): Figure title. ``None`` (default) shows no
            title.

    Returns:
        str: Path to the written PNG file.
    """
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from matplotlib.lines import Line2D
    from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
    import networkx as nx

    graph = self.to_graph()

    # Serialized component_type -> proper PyADM1ODE class name for display.
    class_names = {
        "digester": "Digester",
        "storage": "GasStorage",
        "chp": "CHP",
        "flare": "Flare",
        "heating": "HeatingSystem",
        "boiler": "Boiler",
        "separator": "Separator",
        "upgrading": "BiogasUpgrading",
        "mixer": "Mixer",
    }

    # Edge style per transport medium, matching the Mermaid dataset look:
    # liquid = solid, gas = dotted, heat = dashed.
    edge_styles = {
        "liquid": {"linestyle": "solid", "color": "#333333", "label": "liquid"},
        "gas": {"linestyle": "dotted", "color": "#C0703C", "label": "gas"},
        "heat": {"linestyle": "dashed", "color": "#C44E52", "label": "heat"},
    }

    # --- Layered left-to-right layout (process depth = longest path) ------
    dg = nx.DiGraph()
    dg.add_nodes_from(graph.nodes)
    for edge in graph.edges:
        dg.add_edge(edge.src, edge.dst)

    # Longest-path depth from any source; robust against cycles.
    depth: Dict[str, int] = {n: 0 for n in dg.nodes}
    try:
        for n in nx.topological_sort(dg):
            for succ in dg.successors(n):
                depth[succ] = max(depth[succ], depth[n] + 1)
    except nx.NetworkXUnfeasible:
        depth = {n: 0 for n in dg.nodes}  # cyclic: fall back to single column

    # Group nodes by depth (column) and assign vertical slots.
    columns: Dict[int, List[str]] = {}
    for nid in graph.nodes:
        columns.setdefault(depth[nid], []).append(nid)

    def _key_info(ctype: str, p: Dict[str, Any]) -> List[str]:
        """Most relevant sizing/rating values per component type."""

        def num(key, fmt="{:g}"):
            v = p.get(key)
            return fmt.format(v) if isinstance(v, (int, float)) else None

        lines: List[str] = []
        if ctype == "digester":
            if num("V_liq"):
                lines.append(f"V_liq {num('V_liq')} m³")
            if num("V_gas"):
                lines.append(f"V_gas {num('V_gas')} m³")
            if isinstance(p.get("T_ad"), (int, float)):
                lines.append(f"T {p['T_ad'] - 273.15:.0f} °C")
        elif ctype == "storage":
            if num("capacity_m3"):
                lines.append(f"V_gas {num('capacity_m3')} m³")
        elif ctype == "chp":
            if num("P_el_nom"):
                lines.append(f"P_el {num('P_el_nom')} kW")
            if num("eta_el"):
                lines.append(f"η_el {float(p['eta_el']) * 100:.0f} %")
        elif ctype == "upgrading":
            if num("capacity_m3h"):
                lines.append(f"{num('capacity_m3h')} m³/h")
        elif ctype == "separator":
            if num("separation_efficiency"):
                lines.append(f{float(p['separation_efficiency']) * 100:.0f} %")
        elif ctype == "flare":
            if num("destruction_efficiency"):
                lines.append(f{float(p['destruction_efficiency']) * 100:.0f} %")
        elif ctype == "heating":
            if isinstance(p.get("target_temperature"), (int, float)):
                t = p["target_temperature"]
                t = t - 273.15 if t > 200 else t
                lines.append(f"T {t:.0f} °C")
        return lines

    # Per-node info line; box width adapts to the longest text shown.
    infos: Dict[str, str] = {
        nid: "  ·  ".join(_key_info(graph.nodes[nid].ctype, graph.nodes[nid].params)) for nid in graph.nodes
    }
    longest = max([len(infos[n]) for n in infos] + [len(n) for n in graph.nodes] + [10])
    box_h = 1.3
    box_w = max(3.2, 0.095 * longest + 1.0)

    # --- Layered left-to-right layout (columns by process depth) ----------
    x_gap, y_gap = box_w + 1.8, 1.9
    pos: Dict[str, tuple] = {}
    max_rows = max((len(c) for c in columns.values()), default=1)
    for col, nids in sorted(columns.items()):
        n_rows = len(nids)
        offset = (max_rows - n_rows) / 2.0
        for row, nid in enumerate(sorted(nids)):
            pos[nid] = (col * x_gap, -(row + offset) * y_gap)

    n_cols = max(columns) + 1 if columns else 1
    fig, ax = plt.subplots(figsize=(max(7, n_cols * (box_w + 1.8)), max(4, max_rows * 1.9)))

    for nid, (x, y) in pos.items():
        node = graph.nodes[nid]
        ctype = node.ctype
        box = FancyBboxPatch(
            (x - box_w / 2, y - box_h / 2),
            box_w,
            box_h,
            boxstyle="round,pad=0.03,rounding_size=0.15",
            linewidth=1.4,
            edgecolor="#34495E",
            facecolor="#ECF3FB",
            zorder=2,
        )
        ax.add_patch(box)
        ax.text(x, y + box_h * 0.22, nid, ha="center", va="center", fontsize=9, fontweight="bold", zorder=3)
        ax.text(
            x,
            y + box_h * 0.02,
            class_names.get(ctype, ctype),
            ha="center",
            va="center",
            fontsize=7.5,
            color="#555555",
            zorder=3,
        )
        info = infos[nid]
        if info:
            ax.text(
                x,
                y - box_h * 0.28,
                info,
                ha="center",
                va="center",
                fontsize=6.5,
                color="#1A5276",
                zorder=3,
            )

    def _border(cx, cy, tx, ty):
        """Point on the box border of (cx, cy) facing (tx, ty)."""
        dx, dy = tx - cx, ty - cy
        if dx == 0 and dy == 0:
            return cx, cy
        sx = (box_w / 2 + 0.06) / abs(dx) if dx else float("inf")
        sy = (box_h / 2 + 0.06) / abs(dy) if dy else float("inf")
        t = min(sx, sy)
        return cx + dx * t, cy + dy * t

    used_styles: Dict[str, dict] = {}
    # Bow gas edges up and heat edges down so parallel liquid/gas/heat
    # connections separate; edges spanning intermediate columns bow more
    # (like Mermaid) to clear the boxes in between.
    bow_dir = {"liquid": 0.0, "gas": 1.0, "heat": -1.0}
    for edge in graph.edges:
        if edge.src not in pos or edge.dst not in pos:
            continue
        style = edge_styles.get(edge.etype, edge_styles["liquid"])
        used_styles[edge.etype] = style
        cx0, cy0 = pos[edge.src]
        cx1, cy1 = pos[edge.dst]
        # Start/end on the box borders so the arrow head stays visible.
        x0, y0 = _border(cx0, cy0, cx1, cy1)
        x1, y1 = _border(cx1, cy1, cx0, cy0)
        span = abs(depth[edge.dst] - depth[edge.src])
        direction = bow_dir.get(edge.etype, 0.0)
        chord_x = abs(cx1 - cx0)
        if chord_x < 0.1:  # same column (e.g. cyclic) -> sidestep
            rad = 0.3
        elif direction == 0.0 and span <= 1:  # adjacent liquid -> straight
            rad = 0.0
        else:
            # Fixed apex offset (data units) regardless of span, so long
            # multi-column edges bow gently instead of dipping far down.
            apex = 0.8 if span <= 1 else 1.2
            rad = direction * 2 * apex / chord_x
        conn = f"arc3,rad={rad}"
        # Body line in the medium's linestyle, but without a head, so a
        # dotted gas line does not produce a dotted (broken) arrow head.
        ax.add_patch(
            FancyArrowPatch(
                (x0, y0),
                (x1, y1),
                connectionstyle=conn,
                arrowstyle="-",
                linewidth=1.6,
                linestyle=style["linestyle"],
                color=style["color"],
                shrinkA=1,
                shrinkB=2,
                zorder=1,
            )
        )
        # Solid filled arrow head only (linewidth 0 -> no body line drawn).
        ax.add_patch(
            FancyArrowPatch(
                (x0, y0),
                (x1, y1),
                connectionstyle=conn,
                arrowstyle="-|>,head_length=5,head_width=3",
                mutation_scale=2.4,
                linewidth=0,
                color=style["color"],
                shrinkA=1,
                shrinkB=2,
                zorder=1,
            )
        )

    legend = [
        Line2D([0], [0], color=s["color"], linestyle=s["linestyle"], lw=1.6, label=s["label"])
        for et, s in edge_styles.items()
        if et in used_styles
    ]
    if legend:
        ax.legend(handles=legend, loc="lower right", fontsize=8, title="connection", framealpha=0.9)
    if title:
        ax.set_title(title)
    ax.axis("off")

    # Tight content bounding box: boxes plus room for the edge bows
    # (gas bows below, heat above). Sizing the figure to this box keeps the
    # image height matched to the graph instead of leaving large gaps.
    xs = [x for x, _ in pos.values()]
    ys = [y for _, y in pos.values()]
    pad = 0.5
    x_lo, x_hi = min(xs) - box_w / 2 - pad, max(xs) + box_w / 2 + pad
    y_lo = min(ys) - box_h / 2 - (1.4 if "gas" in used_styles else 0.0) - pad
    y_hi = max(ys) + box_h / 2 + (1.4 if "heat" in used_styles else 0.0) + pad
    ax.set_xlim(x_lo, x_hi)
    ax.set_ylim(y_lo, y_hi)
    ax.set_aspect("equal", adjustable="box")

    scale = 0.55
    fig.set_size_inches((x_hi - x_lo) * scale, (y_hi - y_lo) * scale)
    fig.tight_layout()

    if output_path is None:
        from pathlib import Path

        repo_root = Path(__file__).resolve().parents[2]
        out_dir = repo_root / "output"
        out_dir.mkdir(exist_ok=True)
        safe_name = self.plant_name.replace(" ", "_")
        output_path = str(out_dir / f"{safe_name}_graph.png")

    fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
    plt.close(fig)
    return output_path

pyadm1.configurator.plant_configurator.PlantConfigurator

High-level configurator for building biogas plants.

Provides convenient methods for adding components with sensible defaults and automatic setup of common configurations (gas storage attached to digesters, flare attached to CHP, etc.).

Source code in pyadm1/configurator/plant_configurator.py
class PlantConfigurator:
    """
    High-level configurator for building biogas plants.

    Provides convenient methods for adding components with sensible defaults
    and automatic setup of common configurations (gas storage attached to
    digesters, flare attached to CHP, etc.).
    """

    def __init__(self, plant: BiogasPlant, feedstock: Feedstock):
        """
        Parameters
        ----------
        plant : BiogasPlant
            Plant instance to configure.
        feedstock : Feedstock
            Feedstock used by all digesters added through this configurator.
        """
        self.plant = plant
        self.feedstock = feedstock

    def add_digester(
        self,
        digester_id: str,
        V_liq: float = 1050.0,
        V_gas: float = 150.0,
        T_ad: float = 315.15,
        name: Optional[str] = None,
        Q_substrates: Optional[list] = None,
        k_L_a: Optional[float] = None,
        adm1_state: Optional[list] = None,
        dynamic_volume: bool = False,
        initial_fill_fraction: float = 1.0,
        outflow_time_constant: float = 1.0,
        backend: Optional[str] = None,
    ) -> "tuple[Digester, str]":
        """
        Add an ADM1da digester to the plant.

        The digester's influent DataFrame, density, and steady-state initial
        state are wired automatically from the attached :class:`Feedstock`.
        A gas storage is auto-created and connected.

        Parameters
        ----------
        digester_id : str
            Unique identifier for this digester.
        V_liq : float
            Liquid volume [m³] (default 1050).
        V_gas : float
            Gas headspace volume [m³] (default 150).
        T_ad : float
            Operating temperature [K] (default 315.15 = 42 °C).
        name : str, optional
        Q_substrates : list of float, optional
            Substrate feed rates [m³/d], one entry per substrate slot
            (up to 10 slots).
        k_L_a : float, optional
            Override of the gas–liquid mass-transfer coefficient [1/d].
        adm1_state : list of float, optional
            41-element initial state vector.  When supplied, replaces the
            auto-built steady-state vector.
        dynamic_volume : bool, default False
            Enable a dynamic sludge-volume balance
            ``dV/dt = Q_in − Q_out − q_S,loss``, with ``Q_out`` from an
            overflow weir at ``V_liq``. When False, sludge volume stays
            constant.
        initial_fill_fraction : float, default 1.0
            Starting sludge fill as a fraction of ``V_liq``. Only used when
            ``dynamic_volume=True``. Set below 1.0 to simulate a partially-
            filled startup transient.
        outflow_time_constant : float, default 1.0
            Overflow-weir time constant ``τ_out`` [d]. Only used when
            ``dynamic_volume=True``.
        backend : str, optional
            ADM1 right-hand-side backend, ``"numpy"`` (default) or ``"torch"``
            (differentiable, same values). ``None`` uses the process-wide
            default (see :func:`pyadm1.set_default_adm1_backend`).

        Returns
        -------
        (Digester, str)
            The created digester and a one-line description of how the
            initial state was determined.
        """
        digester = Digester(
            component_id=digester_id,
            feedstock=self.feedstock,
            V_liq=V_liq,
            V_gas=V_gas,
            T_ad=T_ad,
            name=name or digester_id,
            dynamic_volume=dynamic_volume,
            initial_fill_fraction=initial_fill_fraction,
            outflow_time_constant=outflow_time_constant,
            backend=backend,
        )

        if k_L_a is not None:
            digester.adm1.set_calibration_parameters({"k_L_a": float(k_L_a)})

        if Q_substrates is None:
            Q_substrates = [0.0] * 10

        init_kwargs: Dict[str, Any] = {"Q_substrates": Q_substrates}
        if adm1_state is not None:
            init_kwargs["adm1_state"] = list(adm1_state)
            state_info = "  - Initial state: User-supplied 41-element ADM1 vector\n"
        else:
            state_info = "  - Initial state: Auto-built steady-state from feedstock\n"
        digester.initialize(init_kwargs)

        self.plant.add_component(digester)

        # Automatic gas storage + connection
        storage_id = f"{digester_id}_storage"
        storage = GasStorage(
            component_id=storage_id,
            storage_type="membrane",
            capacity_m3=max(50.0, V_gas),
            name=f"{name or digester_id} Gas Storage",
        )
        self.plant.add_component(storage)
        self.connect(digester_id, storage_id, "gas")

        return digester, state_info

    def add_chp(
        self,
        chp_id: str,
        P_el_nom: float = 500.0,
        eta_el: float = 0.40,
        eta_th: float = 0.45,
        name: Optional[str] = None,
    ) -> CHP:
        """
        Add a CHP unit to the plant.

        Automatically creates and connects a safety flare downstream of the CHP.
        """
        chp = CHP(
            component_id=chp_id,
            P_el_nom=P_el_nom,
            eta_el=eta_el,
            eta_th=eta_th,
            name=name or chp_id,
        )

        self.plant.add_component(chp)

        flare_id = f"{chp_id}_flare"
        flare = Flare(component_id=flare_id, name=f"{chp_id}_flare")
        self.plant.add_component(flare)
        self.connect(chp_id, flare_id, "gas")

        return chp

    def add_heating(
        self,
        heating_id: str,
        target_temperature: float = 308.15,
        heat_loss_coefficient: float = 0.5,
        name: Optional[str] = None,
    ) -> HeatingSystem:
        """Add a heating system to the plant."""
        heating = HeatingSystem(
            component_id=heating_id,
            target_temperature=target_temperature,
            heat_loss_coefficient=heat_loss_coefficient,
            name=name or heating_id,
            feedstock=self.feedstock,
        )

        self.plant.add_component(heating)

        return heating

    def connect(self, from_component: str, to_component: str, connection_type: str = "default") -> Connection:
        """Connect two components."""
        connection = Connection(from_component, to_component, connection_type)
        self.plant.add_connection(connection)
        return connection

    def auto_connect_digester_to_chp(self, digester_id: str, chp_id: str) -> None:
        """Connect digester → gas_storage → chp."""
        storage_id = f"{digester_id}_storage"

        if storage_id not in self.plant.components:
            raise ValueError(
                f"Gas storage '{storage_id}' not found for digester '{digester_id}'. "
                f"Ensure the digester was added via PlantConfigurator.add_digester()."
            )

        self.connect(storage_id, chp_id, "gas")

    def auto_connect_chp_to_heating(self, chp_id: str, heating_id: str) -> None:
        """Connect CHP → heating with heat flow."""
        self.connect(chp_id, heating_id, "heat")

    def add_bgaa(
        self,
        bgaa_id: str,
        capacity_m3h: float = 500.0,
        ch4_recovery: float = 0.98,
        ch4_content_in: float = 0.55,
        ch4_content_out: float = 0.97,
        name: Optional[str] = None,
    ) -> "BiogasUpgrading":
        """
        Add a biogas upgrading unit (Biogasaufbereitungsanlage) to the plant.

        Automatically creates and connects an emergency flare downstream of
        the BGAA for capacity overflow (``{bgaa_id}_flare``).
        """
        from pyadm1.components.energy.biogas_upgrading import BiogasUpgrading

        bgaa = BiogasUpgrading(
            component_id=bgaa_id,
            capacity_m3h=capacity_m3h,
            ch4_recovery=ch4_recovery,
            ch4_content_in=ch4_content_in,
            ch4_content_out=ch4_content_out,
            name=name or bgaa_id,
        )
        self.plant.add_component(bgaa)

        flare_id = f"{bgaa_id}_flare"
        flare = Flare(component_id=flare_id, name=f"{bgaa_id}_flare")
        self.plant.add_component(flare)
        self.connect(bgaa_id, flare_id, "gas")

        return bgaa

    def auto_connect_digester_to_bgaa(self, digester_id: str, bgaa_id: str) -> None:
        """Connect digester gas storage → BGAA."""
        storage_id = f"{digester_id}_storage"
        if storage_id not in self.plant.components:
            raise ValueError(
                f"Gas storage '{storage_id}' not found for digester '{digester_id}'. "
                f"Ensure the digester was added via PlantConfigurator.add_digester()."
            )
        self.connect(storage_id, bgaa_id, "gas")

    def create_single_stage_plant(
        self,
        digester_config: Optional[Dict[str, Any]] = None,
        chp_config: Optional[Dict[str, Any]] = None,
        heating_config: Optional[Dict[str, Any]] = None,
        auto_connect: bool = True,
    ) -> Dict[str, Any]:
        """Create a complete single-stage plant configuration."""
        digester_config = digester_config or {}
        chp_config = chp_config or {}
        heating_config = heating_config or {}

        digester_config.setdefault("digester_id", "main_digester")
        chp_config.setdefault("chp_id", "chp_main")
        heating_config.setdefault("heating_id", "heating_main")

        digester, _ = self.add_digester(**digester_config)

        components = {
            "digester": digester.component_id,
            "storage": f"{digester.component_id}_storage",
        }

        if chp_config:
            chp = self.add_chp(**chp_config)
            components["chp"] = chp.component_id
            components["flare"] = f"{chp.component_id}_flare"

            if auto_connect:
                self.auto_connect_digester_to_chp(digester.component_id, chp.component_id)

        if heating_config:
            heating = self.add_heating(**heating_config)
            components["heating"] = heating.component_id

            if auto_connect and "chp" in components:
                self.auto_connect_chp_to_heating(chp.component_id, heating.component_id)

        return components

    def create_two_stage_plant(
        self,
        hydrolysis_config: Optional[Dict[str, Any]] = None,
        digester_config: Optional[Dict[str, Any]] = None,
        chp_config: Optional[Dict[str, Any]] = None,
        heating_configs: Optional[list] = None,
        auto_connect: bool = True,
    ) -> Dict[str, Any]:
        """
        Create a two-stage plant: hydrolysis pre-tank → main fermenter.

        The hydrolysis stage is just another :class:`Digester` instance with
        a higher temperature and shorter HRT — there is no separate
        ``Hydrolysis`` class.
        """
        hydrolysis_config = hydrolysis_config or {}
        digester_config = digester_config or {}
        chp_config = chp_config or {}

        hydrolysis_config.setdefault("digester_id", "hydrolysis_tank")
        hydrolysis_config.setdefault("name", "Hydrolysis Tank")
        hydrolysis_config.setdefault("T_ad", 318.15)  # 45 °C
        hydrolysis_config.setdefault("V_liq", 500.0)
        hydrolysis_config.setdefault("V_gas", 75.0)

        digester_config.setdefault("digester_id", "main_digester")
        digester_config.setdefault("name", "Main Digester")

        chp_config.setdefault("chp_id", "chp_main")

        hydrolysis, _ = self.add_digester(**hydrolysis_config)
        digester, _ = self.add_digester(**digester_config)

        components = {
            "hydrolysis": hydrolysis.component_id,
            "hydrolysis_storage": f"{hydrolysis.component_id}_storage",
            "digester": digester.component_id,
            "digester_storage": f"{digester.component_id}_storage",
        }

        if auto_connect:
            self.connect(hydrolysis.component_id, digester.component_id, "liquid")

        if chp_config:
            chp = self.add_chp(**chp_config)
            components["chp"] = chp.component_id
            components["flare"] = f"{chp.component_id}_flare"

            if auto_connect:
                self.auto_connect_digester_to_chp(hydrolysis.component_id, chp.component_id)
                self.auto_connect_digester_to_chp(digester.component_id, chp.component_id)

        if heating_configs:
            components["heating"] = []
            for i, heating_cfg in enumerate(heating_configs):
                heating_cfg.setdefault("heating_id", f"heating_{i + 1}")
                heating = self.add_heating(**heating_cfg)
                components["heating"].append(heating.component_id)

                if auto_connect and "chp" in components:
                    self.auto_connect_chp_to_heating(chp.component_id, heating.component_id)

        return components

Methods:

__init__(plant, feedstock)

Parameters

plant : BiogasPlant Plant instance to configure. feedstock : Feedstock Feedstock used by all digesters added through this configurator.

Source code in pyadm1/configurator/plant_configurator.py
def __init__(self, plant: BiogasPlant, feedstock: Feedstock):
    """
    Parameters
    ----------
    plant : BiogasPlant
        Plant instance to configure.
    feedstock : Feedstock
        Feedstock used by all digesters added through this configurator.
    """
    self.plant = plant
    self.feedstock = feedstock

add_bgaa(bgaa_id, capacity_m3h=500.0, ch4_recovery=0.98, ch4_content_in=0.55, ch4_content_out=0.97, name=None)

Add a biogas upgrading unit (Biogasaufbereitungsanlage) to the plant.

Automatically creates and connects an emergency flare downstream of the BGAA for capacity overflow ({bgaa_id}_flare).

Source code in pyadm1/configurator/plant_configurator.py
def add_bgaa(
    self,
    bgaa_id: str,
    capacity_m3h: float = 500.0,
    ch4_recovery: float = 0.98,
    ch4_content_in: float = 0.55,
    ch4_content_out: float = 0.97,
    name: Optional[str] = None,
) -> "BiogasUpgrading":
    """
    Add a biogas upgrading unit (Biogasaufbereitungsanlage) to the plant.

    Automatically creates and connects an emergency flare downstream of
    the BGAA for capacity overflow (``{bgaa_id}_flare``).
    """
    from pyadm1.components.energy.biogas_upgrading import BiogasUpgrading

    bgaa = BiogasUpgrading(
        component_id=bgaa_id,
        capacity_m3h=capacity_m3h,
        ch4_recovery=ch4_recovery,
        ch4_content_in=ch4_content_in,
        ch4_content_out=ch4_content_out,
        name=name or bgaa_id,
    )
    self.plant.add_component(bgaa)

    flare_id = f"{bgaa_id}_flare"
    flare = Flare(component_id=flare_id, name=f"{bgaa_id}_flare")
    self.plant.add_component(flare)
    self.connect(bgaa_id, flare_id, "gas")

    return bgaa

add_chp(chp_id, P_el_nom=500.0, eta_el=0.4, eta_th=0.45, name=None)

Add a CHP unit to the plant.

Automatically creates and connects a safety flare downstream of the CHP.

Source code in pyadm1/configurator/plant_configurator.py
def add_chp(
    self,
    chp_id: str,
    P_el_nom: float = 500.0,
    eta_el: float = 0.40,
    eta_th: float = 0.45,
    name: Optional[str] = None,
) -> CHP:
    """
    Add a CHP unit to the plant.

    Automatically creates and connects a safety flare downstream of the CHP.
    """
    chp = CHP(
        component_id=chp_id,
        P_el_nom=P_el_nom,
        eta_el=eta_el,
        eta_th=eta_th,
        name=name or chp_id,
    )

    self.plant.add_component(chp)

    flare_id = f"{chp_id}_flare"
    flare = Flare(component_id=flare_id, name=f"{chp_id}_flare")
    self.plant.add_component(flare)
    self.connect(chp_id, flare_id, "gas")

    return chp

add_digester(digester_id, V_liq=1050.0, V_gas=150.0, T_ad=315.15, name=None, Q_substrates=None, k_L_a=None, adm1_state=None, dynamic_volume=False, initial_fill_fraction=1.0, outflow_time_constant=1.0, backend=None)

Add an ADM1da digester to the plant.

The digester's influent DataFrame, density, and steady-state initial state are wired automatically from the attached :class:Feedstock. A gas storage is auto-created and connected.

Parameters

digester_id : str Unique identifier for this digester. V_liq : float Liquid volume [m³] (default 1050). V_gas : float Gas headspace volume [m³] (default 150). T_ad : float Operating temperature [K] (default 315.15 = 42 °C). name : str, optional Q_substrates : list of float, optional Substrate feed rates [m³/d], one entry per substrate slot (up to 10 slots). k_L_a : float, optional Override of the gas–liquid mass-transfer coefficient [1/d]. adm1_state : list of float, optional 41-element initial state vector. When supplied, replaces the auto-built steady-state vector. dynamic_volume : bool, default False Enable a dynamic sludge-volume balance dV/dt = Q_in − Q_out − q_S,loss, with Q_out from an overflow weir at V_liq. When False, sludge volume stays constant. initial_fill_fraction : float, default 1.0 Starting sludge fill as a fraction of V_liq. Only used when dynamic_volume=True. Set below 1.0 to simulate a partially- filled startup transient. outflow_time_constant : float, default 1.0 Overflow-weir time constant τ_out [d]. Only used when dynamic_volume=True. backend : str, optional ADM1 right-hand-side backend, "numpy" (default) or "torch" (differentiable, same values). None uses the process-wide default (see :func:pyadm1.set_default_adm1_backend).

Returns

(Digester, str) The created digester and a one-line description of how the initial state was determined.

Source code in pyadm1/configurator/plant_configurator.py
def add_digester(
    self,
    digester_id: str,
    V_liq: float = 1050.0,
    V_gas: float = 150.0,
    T_ad: float = 315.15,
    name: Optional[str] = None,
    Q_substrates: Optional[list] = None,
    k_L_a: Optional[float] = None,
    adm1_state: Optional[list] = None,
    dynamic_volume: bool = False,
    initial_fill_fraction: float = 1.0,
    outflow_time_constant: float = 1.0,
    backend: Optional[str] = None,
) -> "tuple[Digester, str]":
    """
    Add an ADM1da digester to the plant.

    The digester's influent DataFrame, density, and steady-state initial
    state are wired automatically from the attached :class:`Feedstock`.
    A gas storage is auto-created and connected.

    Parameters
    ----------
    digester_id : str
        Unique identifier for this digester.
    V_liq : float
        Liquid volume [m³] (default 1050).
    V_gas : float
        Gas headspace volume [m³] (default 150).
    T_ad : float
        Operating temperature [K] (default 315.15 = 42 °C).
    name : str, optional
    Q_substrates : list of float, optional
        Substrate feed rates [m³/d], one entry per substrate slot
        (up to 10 slots).
    k_L_a : float, optional
        Override of the gas–liquid mass-transfer coefficient [1/d].
    adm1_state : list of float, optional
        41-element initial state vector.  When supplied, replaces the
        auto-built steady-state vector.
    dynamic_volume : bool, default False
        Enable a dynamic sludge-volume balance
        ``dV/dt = Q_in − Q_out − q_S,loss``, with ``Q_out`` from an
        overflow weir at ``V_liq``. When False, sludge volume stays
        constant.
    initial_fill_fraction : float, default 1.0
        Starting sludge fill as a fraction of ``V_liq``. Only used when
        ``dynamic_volume=True``. Set below 1.0 to simulate a partially-
        filled startup transient.
    outflow_time_constant : float, default 1.0
        Overflow-weir time constant ``τ_out`` [d]. Only used when
        ``dynamic_volume=True``.
    backend : str, optional
        ADM1 right-hand-side backend, ``"numpy"`` (default) or ``"torch"``
        (differentiable, same values). ``None`` uses the process-wide
        default (see :func:`pyadm1.set_default_adm1_backend`).

    Returns
    -------
    (Digester, str)
        The created digester and a one-line description of how the
        initial state was determined.
    """
    digester = Digester(
        component_id=digester_id,
        feedstock=self.feedstock,
        V_liq=V_liq,
        V_gas=V_gas,
        T_ad=T_ad,
        name=name or digester_id,
        dynamic_volume=dynamic_volume,
        initial_fill_fraction=initial_fill_fraction,
        outflow_time_constant=outflow_time_constant,
        backend=backend,
    )

    if k_L_a is not None:
        digester.adm1.set_calibration_parameters({"k_L_a": float(k_L_a)})

    if Q_substrates is None:
        Q_substrates = [0.0] * 10

    init_kwargs: Dict[str, Any] = {"Q_substrates": Q_substrates}
    if adm1_state is not None:
        init_kwargs["adm1_state"] = list(adm1_state)
        state_info = "  - Initial state: User-supplied 41-element ADM1 vector\n"
    else:
        state_info = "  - Initial state: Auto-built steady-state from feedstock\n"
    digester.initialize(init_kwargs)

    self.plant.add_component(digester)

    # Automatic gas storage + connection
    storage_id = f"{digester_id}_storage"
    storage = GasStorage(
        component_id=storage_id,
        storage_type="membrane",
        capacity_m3=max(50.0, V_gas),
        name=f"{name or digester_id} Gas Storage",
    )
    self.plant.add_component(storage)
    self.connect(digester_id, storage_id, "gas")

    return digester, state_info

add_heating(heating_id, target_temperature=308.15, heat_loss_coefficient=0.5, name=None)

Add a heating system to the plant.

Source code in pyadm1/configurator/plant_configurator.py
def add_heating(
    self,
    heating_id: str,
    target_temperature: float = 308.15,
    heat_loss_coefficient: float = 0.5,
    name: Optional[str] = None,
) -> HeatingSystem:
    """Add a heating system to the plant."""
    heating = HeatingSystem(
        component_id=heating_id,
        target_temperature=target_temperature,
        heat_loss_coefficient=heat_loss_coefficient,
        name=name or heating_id,
        feedstock=self.feedstock,
    )

    self.plant.add_component(heating)

    return heating

auto_connect_chp_to_heating(chp_id, heating_id)

Connect CHP → heating with heat flow.

Source code in pyadm1/configurator/plant_configurator.py
def auto_connect_chp_to_heating(self, chp_id: str, heating_id: str) -> None:
    """Connect CHP → heating with heat flow."""
    self.connect(chp_id, heating_id, "heat")

auto_connect_digester_to_bgaa(digester_id, bgaa_id)

Connect digester gas storage → BGAA.

Source code in pyadm1/configurator/plant_configurator.py
def auto_connect_digester_to_bgaa(self, digester_id: str, bgaa_id: str) -> None:
    """Connect digester gas storage → BGAA."""
    storage_id = f"{digester_id}_storage"
    if storage_id not in self.plant.components:
        raise ValueError(
            f"Gas storage '{storage_id}' not found for digester '{digester_id}'. "
            f"Ensure the digester was added via PlantConfigurator.add_digester()."
        )
    self.connect(storage_id, bgaa_id, "gas")

auto_connect_digester_to_chp(digester_id, chp_id)

Connect digester → gas_storage → chp.

Source code in pyadm1/configurator/plant_configurator.py
def auto_connect_digester_to_chp(self, digester_id: str, chp_id: str) -> None:
    """Connect digester → gas_storage → chp."""
    storage_id = f"{digester_id}_storage"

    if storage_id not in self.plant.components:
        raise ValueError(
            f"Gas storage '{storage_id}' not found for digester '{digester_id}'. "
            f"Ensure the digester was added via PlantConfigurator.add_digester()."
        )

    self.connect(storage_id, chp_id, "gas")

connect(from_component, to_component, connection_type='default')

Connect two components.

Source code in pyadm1/configurator/plant_configurator.py
def connect(self, from_component: str, to_component: str, connection_type: str = "default") -> Connection:
    """Connect two components."""
    connection = Connection(from_component, to_component, connection_type)
    self.plant.add_connection(connection)
    return connection

create_single_stage_plant(digester_config=None, chp_config=None, heating_config=None, auto_connect=True)

Create a complete single-stage plant configuration.

Source code in pyadm1/configurator/plant_configurator.py
def create_single_stage_plant(
    self,
    digester_config: Optional[Dict[str, Any]] = None,
    chp_config: Optional[Dict[str, Any]] = None,
    heating_config: Optional[Dict[str, Any]] = None,
    auto_connect: bool = True,
) -> Dict[str, Any]:
    """Create a complete single-stage plant configuration."""
    digester_config = digester_config or {}
    chp_config = chp_config or {}
    heating_config = heating_config or {}

    digester_config.setdefault("digester_id", "main_digester")
    chp_config.setdefault("chp_id", "chp_main")
    heating_config.setdefault("heating_id", "heating_main")

    digester, _ = self.add_digester(**digester_config)

    components = {
        "digester": digester.component_id,
        "storage": f"{digester.component_id}_storage",
    }

    if chp_config:
        chp = self.add_chp(**chp_config)
        components["chp"] = chp.component_id
        components["flare"] = f"{chp.component_id}_flare"

        if auto_connect:
            self.auto_connect_digester_to_chp(digester.component_id, chp.component_id)

    if heating_config:
        heating = self.add_heating(**heating_config)
        components["heating"] = heating.component_id

        if auto_connect and "chp" in components:
            self.auto_connect_chp_to_heating(chp.component_id, heating.component_id)

    return components

create_two_stage_plant(hydrolysis_config=None, digester_config=None, chp_config=None, heating_configs=None, auto_connect=True)

Create a two-stage plant: hydrolysis pre-tank → main fermenter.

The hydrolysis stage is just another :class:Digester instance with a higher temperature and shorter HRT — there is no separate Hydrolysis class.

Source code in pyadm1/configurator/plant_configurator.py
def create_two_stage_plant(
    self,
    hydrolysis_config: Optional[Dict[str, Any]] = None,
    digester_config: Optional[Dict[str, Any]] = None,
    chp_config: Optional[Dict[str, Any]] = None,
    heating_configs: Optional[list] = None,
    auto_connect: bool = True,
) -> Dict[str, Any]:
    """
    Create a two-stage plant: hydrolysis pre-tank → main fermenter.

    The hydrolysis stage is just another :class:`Digester` instance with
    a higher temperature and shorter HRT — there is no separate
    ``Hydrolysis`` class.
    """
    hydrolysis_config = hydrolysis_config or {}
    digester_config = digester_config or {}
    chp_config = chp_config or {}

    hydrolysis_config.setdefault("digester_id", "hydrolysis_tank")
    hydrolysis_config.setdefault("name", "Hydrolysis Tank")
    hydrolysis_config.setdefault("T_ad", 318.15)  # 45 °C
    hydrolysis_config.setdefault("V_liq", 500.0)
    hydrolysis_config.setdefault("V_gas", 75.0)

    digester_config.setdefault("digester_id", "main_digester")
    digester_config.setdefault("name", "Main Digester")

    chp_config.setdefault("chp_id", "chp_main")

    hydrolysis, _ = self.add_digester(**hydrolysis_config)
    digester, _ = self.add_digester(**digester_config)

    components = {
        "hydrolysis": hydrolysis.component_id,
        "hydrolysis_storage": f"{hydrolysis.component_id}_storage",
        "digester": digester.component_id,
        "digester_storage": f"{digester.component_id}_storage",
    }

    if auto_connect:
        self.connect(hydrolysis.component_id, digester.component_id, "liquid")

    if chp_config:
        chp = self.add_chp(**chp_config)
        components["chp"] = chp.component_id
        components["flare"] = f"{chp.component_id}_flare"

        if auto_connect:
            self.auto_connect_digester_to_chp(hydrolysis.component_id, chp.component_id)
            self.auto_connect_digester_to_chp(digester.component_id, chp.component_id)

    if heating_configs:
        components["heating"] = []
        for i, heating_cfg in enumerate(heating_configs):
            heating_cfg.setdefault("heating_id", f"heating_{i + 1}")
            heating = self.add_heating(**heating_cfg)
            components["heating"].append(heating.component_id)

            if auto_connect and "chp" in components:
                self.auto_connect_chp_to_heating(chp.component_id, heating.component_id)

    return components

pyadm1.configurator.connection_manager.ConnectionManager

Manages connections between components in a biogas plant.

The ConnectionManager handles connection validation, dependency resolution, and provides utilities for analyzing component relationships.

Attributes:

Name Type Description
connections List[Connection]

List of all connections.

Example

manager = ConnectionManager() manager.add_connection(Connection("dig1", "chp1", "gas")) deps = manager.get_dependencies("chp1") print(deps) # ['dig1']

Source code in pyadm1/configurator/connection_manager.py
class ConnectionManager:
    """
    Manages connections between components in a biogas plant.

    The ConnectionManager handles connection validation, dependency resolution,
    and provides utilities for analyzing component relationships.

    Attributes:
        connections (List[Connection]): List of all connections.

    Example:
        >>> manager = ConnectionManager()
        >>> manager.add_connection(Connection("dig1", "chp1", "gas"))
        >>> deps = manager.get_dependencies("chp1")
        >>> print(deps)  # ['dig1']
    """

    def __init__(self):
        """Initialize an empty connection manager."""
        self.connections: List[Connection] = []

    def add_connection(self, connection: Connection) -> None:
        """
        Add a connection to the manager.

        Args:
            connection (Connection): Connection to add.

        Raises:
            ValueError: If connection already exists.
        """
        # Check for duplicate
        for conn in self.connections:
            if (
                conn.from_component == connection.from_component
                and conn.to_component == connection.to_component
                and conn.connection_type == connection.connection_type
            ):
                raise ValueError(
                    f"Connection already exists: {connection.from_component} -> "
                    f"{connection.to_component} ({connection.connection_type})"
                )

        self.connections.append(connection)

    def remove_connection(
        self,
        from_component: str,
        to_component: str,
        connection_type: Optional[str] = None,
    ) -> bool:
        """
        Remove a connection.

        Args:
            from_component (str): Source component ID.
            to_component (str): Target component ID.
            connection_type (Optional[str]): Connection type. If None, removes
                all connections between the components.

        Returns:
            bool: True if at least one connection was removed, False otherwise.
        """
        removed = False
        self.connections = [
            conn
            for conn in self.connections
            if not (
                conn.from_component == from_component
                and conn.to_component == to_component
                and (connection_type is None or conn.connection_type == connection_type)
            )
            or not (removed := True)
        ]
        return removed

    def get_connections_from(self, component_id: str) -> List[Connection]:
        """
        Get all connections originating from a component.

        Args:
            component_id (str): Component ID.

        Returns:
            List[Connection]: List of outgoing connections.
        """
        return [conn for conn in self.connections if conn.from_component == component_id]

    def get_connections_to(self, component_id: str) -> List[Connection]:
        """
        Get all connections terminating at a component.

        Args:
            component_id (str): Component ID.

        Returns:
            List[Connection]: List of incoming connections.
        """
        return [conn for conn in self.connections if conn.to_component == component_id]

    def get_dependencies(self, component_id: str) -> List[str]:
        """
        Get all components that the given component depends on.

        Args:
            component_id (str): Component ID.

        Returns:
            List[str]: List of component IDs that this component depends on.
        """
        return [conn.from_component for conn in self.get_connections_to(component_id)]

    def get_dependents(self, component_id: str) -> List[str]:
        """
        Get all components that depend on the given component.

        Args:
            component_id (str): Component ID.

        Returns:
            List[str]: List of component IDs that depend on this component.
        """
        return [conn.to_component for conn in self.get_connections_from(component_id)]

    def get_execution_order(self, component_ids: List[str]) -> List[str]:
        """
        Determine execution order based on dependencies (topological sort).

        Args:
            component_ids (List[str]): List of all component IDs.

        Returns:
            List[str]: Component IDs in execution order.

        Raises:
            ValueError: If circular dependencies are detected.
        """
        # Build adjacency list
        in_degree = {comp_id: 0 for comp_id in component_ids}
        adjacency = {comp_id: [] for comp_id in component_ids}

        for conn in self.connections:
            if conn.from_component in component_ids and conn.to_component in component_ids:
                adjacency[conn.from_component].append(conn.to_component)
                in_degree[conn.to_component] += 1

        # Kahn's algorithm for topological sort
        queue = [comp_id for comp_id in component_ids if in_degree[comp_id] == 0]
        result = []

        while queue:
            current = queue.pop(0)
            result.append(current)

            for neighbor in adjacency[current]:
                in_degree[neighbor] -= 1
                if in_degree[neighbor] == 0:
                    queue.append(neighbor)

        # Check for cycles
        if len(result) != len(component_ids):
            raise ValueError("Circular dependency detected in component connections")

        return result

    def has_circular_dependency(self, component_ids: List[str]) -> bool:
        """
        Check if there are circular dependencies.

        Args:
            component_ids (List[str]): List of component IDs to check.

        Returns:
            bool: True if circular dependencies exist, False otherwise.
        """
        try:
            self.get_execution_order(component_ids)
            return False
        except ValueError:
            return True

    def get_connected_components(self, component_id: str) -> Set[str]:
        """
        Get all components connected to the given component (directly or indirectly).

        Args:
            component_id (str): Starting component ID.

        Returns:
            Set[str]: Set of connected component IDs.
        """
        visited = set()
        queue = [component_id]

        while queue:
            current = queue.pop(0)
            if current in visited:
                continue

            visited.add(current)

            # Add all connected components
            for conn in self.connections:
                if conn.from_component == current and conn.to_component not in visited:
                    queue.append(conn.to_component)
                elif conn.to_component == current and conn.from_component not in visited:
                    queue.append(conn.from_component)

        visited.discard(component_id)  # Remove the starting component
        return visited

    def validate_connections(self, component_ids: List[str]) -> List[str]:
        """
        Validate all connections and return list of issues.

        Args:
            component_ids (List[str]): List of valid component IDs.

        Returns:
            List[str]: List of validation error messages. Empty if all valid.
        """
        errors = []

        # Check for invalid component references
        for conn in self.connections:
            if conn.from_component not in component_ids:
                errors.append(f"Connection references non-existent source component: " f"{conn.from_component}")
            if conn.to_component not in component_ids:
                errors.append(f"Connection references non-existent target component: " f"{conn.to_component}")

        # Check for circular dependencies
        if self.has_circular_dependency(component_ids):
            errors.append("Circular dependency detected in connections")

        return errors

    def get_all_connections(self) -> List[Connection]:
        """
        Get all connections.

        Returns:
            List[Connection]: List of all connections.
        """
        return self.connections.copy()

    def clear(self) -> None:
        """Remove all connections."""
        self.connections.clear()

    def to_dict(self) -> Dict[str, Any]:
        """
        Serialize all connections to dictionary.

        Returns:
            Dict[str, Any]: Dictionary with connection data.
        """
        return {"connections": [conn.to_dict() for conn in self.connections]}

    @classmethod
    def from_dict(cls, config: Dict[str, Any]) -> "ConnectionManager":
        """
        Create ConnectionManager from dictionary.

        Args:
            config (Dict[str, Any]): Dictionary with 'connections' key.

        Returns:
            ConnectionManager: New manager with loaded connections.
        """
        manager = cls()
        for conn_config in config.get("connections", []):
            manager.add_connection(Connection.from_dict(conn_config))
        return manager

    def __len__(self) -> int:
        """Return number of connections."""
        return len(self.connections)

    def __repr__(self) -> str:
        """String representation of manager."""
        return f"ConnectionManager(connections={len(self.connections)})"

Methods:

__init__()

Initialize an empty connection manager.

Source code in pyadm1/configurator/connection_manager.py
def __init__(self):
    """Initialize an empty connection manager."""
    self.connections: List[Connection] = []

__len__()

Return number of connections.

Source code in pyadm1/configurator/connection_manager.py
def __len__(self) -> int:
    """Return number of connections."""
    return len(self.connections)

__repr__()

String representation of manager.

Source code in pyadm1/configurator/connection_manager.py
def __repr__(self) -> str:
    """String representation of manager."""
    return f"ConnectionManager(connections={len(self.connections)})"

add_connection(connection)

Add a connection to the manager.

Parameters:

Name Type Description Default
connection Connection

Connection to add.

required

Raises:

Type Description
ValueError

If connection already exists.

Source code in pyadm1/configurator/connection_manager.py
def add_connection(self, connection: Connection) -> None:
    """
    Add a connection to the manager.

    Args:
        connection (Connection): Connection to add.

    Raises:
        ValueError: If connection already exists.
    """
    # Check for duplicate
    for conn in self.connections:
        if (
            conn.from_component == connection.from_component
            and conn.to_component == connection.to_component
            and conn.connection_type == connection.connection_type
        ):
            raise ValueError(
                f"Connection already exists: {connection.from_component} -> "
                f"{connection.to_component} ({connection.connection_type})"
            )

    self.connections.append(connection)

clear()

Remove all connections.

Source code in pyadm1/configurator/connection_manager.py
def clear(self) -> None:
    """Remove all connections."""
    self.connections.clear()

from_dict(config) classmethod

Create ConnectionManager from dictionary.

Parameters:

Name Type Description Default
config Dict[str, Any]

Dictionary with 'connections' key.

required

Returns:

Name Type Description
ConnectionManager ConnectionManager

New manager with loaded connections.

Source code in pyadm1/configurator/connection_manager.py
@classmethod
def from_dict(cls, config: Dict[str, Any]) -> "ConnectionManager":
    """
    Create ConnectionManager from dictionary.

    Args:
        config (Dict[str, Any]): Dictionary with 'connections' key.

    Returns:
        ConnectionManager: New manager with loaded connections.
    """
    manager = cls()
    for conn_config in config.get("connections", []):
        manager.add_connection(Connection.from_dict(conn_config))
    return manager

get_all_connections()

Get all connections.

Returns:

Type Description
List[Connection]

List[Connection]: List of all connections.

Source code in pyadm1/configurator/connection_manager.py
def get_all_connections(self) -> List[Connection]:
    """
    Get all connections.

    Returns:
        List[Connection]: List of all connections.
    """
    return self.connections.copy()

get_connected_components(component_id)

Get all components connected to the given component (directly or indirectly).

Parameters:

Name Type Description Default
component_id str

Starting component ID.

required

Returns:

Type Description
Set[str]

Set[str]: Set of connected component IDs.

Source code in pyadm1/configurator/connection_manager.py
def get_connected_components(self, component_id: str) -> Set[str]:
    """
    Get all components connected to the given component (directly or indirectly).

    Args:
        component_id (str): Starting component ID.

    Returns:
        Set[str]: Set of connected component IDs.
    """
    visited = set()
    queue = [component_id]

    while queue:
        current = queue.pop(0)
        if current in visited:
            continue

        visited.add(current)

        # Add all connected components
        for conn in self.connections:
            if conn.from_component == current and conn.to_component not in visited:
                queue.append(conn.to_component)
            elif conn.to_component == current and conn.from_component not in visited:
                queue.append(conn.from_component)

    visited.discard(component_id)  # Remove the starting component
    return visited

get_connections_from(component_id)

Get all connections originating from a component.

Parameters:

Name Type Description Default
component_id str

Component ID.

required

Returns:

Type Description
List[Connection]

List[Connection]: List of outgoing connections.

Source code in pyadm1/configurator/connection_manager.py
def get_connections_from(self, component_id: str) -> List[Connection]:
    """
    Get all connections originating from a component.

    Args:
        component_id (str): Component ID.

    Returns:
        List[Connection]: List of outgoing connections.
    """
    return [conn for conn in self.connections if conn.from_component == component_id]

get_connections_to(component_id)

Get all connections terminating at a component.

Parameters:

Name Type Description Default
component_id str

Component ID.

required

Returns:

Type Description
List[Connection]

List[Connection]: List of incoming connections.

Source code in pyadm1/configurator/connection_manager.py
def get_connections_to(self, component_id: str) -> List[Connection]:
    """
    Get all connections terminating at a component.

    Args:
        component_id (str): Component ID.

    Returns:
        List[Connection]: List of incoming connections.
    """
    return [conn for conn in self.connections if conn.to_component == component_id]

get_dependencies(component_id)

Get all components that the given component depends on.

Parameters:

Name Type Description Default
component_id str

Component ID.

required

Returns:

Type Description
List[str]

List[str]: List of component IDs that this component depends on.

Source code in pyadm1/configurator/connection_manager.py
def get_dependencies(self, component_id: str) -> List[str]:
    """
    Get all components that the given component depends on.

    Args:
        component_id (str): Component ID.

    Returns:
        List[str]: List of component IDs that this component depends on.
    """
    return [conn.from_component for conn in self.get_connections_to(component_id)]

get_dependents(component_id)

Get all components that depend on the given component.

Parameters:

Name Type Description Default
component_id str

Component ID.

required

Returns:

Type Description
List[str]

List[str]: List of component IDs that depend on this component.

Source code in pyadm1/configurator/connection_manager.py
def get_dependents(self, component_id: str) -> List[str]:
    """
    Get all components that depend on the given component.

    Args:
        component_id (str): Component ID.

    Returns:
        List[str]: List of component IDs that depend on this component.
    """
    return [conn.to_component for conn in self.get_connections_from(component_id)]

get_execution_order(component_ids)

Determine execution order based on dependencies (topological sort).

Parameters:

Name Type Description Default
component_ids List[str]

List of all component IDs.

required

Returns:

Type Description
List[str]

List[str]: Component IDs in execution order.

Raises:

Type Description
ValueError

If circular dependencies are detected.

Source code in pyadm1/configurator/connection_manager.py
def get_execution_order(self, component_ids: List[str]) -> List[str]:
    """
    Determine execution order based on dependencies (topological sort).

    Args:
        component_ids (List[str]): List of all component IDs.

    Returns:
        List[str]: Component IDs in execution order.

    Raises:
        ValueError: If circular dependencies are detected.
    """
    # Build adjacency list
    in_degree = {comp_id: 0 for comp_id in component_ids}
    adjacency = {comp_id: [] for comp_id in component_ids}

    for conn in self.connections:
        if conn.from_component in component_ids and conn.to_component in component_ids:
            adjacency[conn.from_component].append(conn.to_component)
            in_degree[conn.to_component] += 1

    # Kahn's algorithm for topological sort
    queue = [comp_id for comp_id in component_ids if in_degree[comp_id] == 0]
    result = []

    while queue:
        current = queue.pop(0)
        result.append(current)

        for neighbor in adjacency[current]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)

    # Check for cycles
    if len(result) != len(component_ids):
        raise ValueError("Circular dependency detected in component connections")

    return result

has_circular_dependency(component_ids)

Check if there are circular dependencies.

Parameters:

Name Type Description Default
component_ids List[str]

List of component IDs to check.

required

Returns:

Name Type Description
bool bool

True if circular dependencies exist, False otherwise.

Source code in pyadm1/configurator/connection_manager.py
def has_circular_dependency(self, component_ids: List[str]) -> bool:
    """
    Check if there are circular dependencies.

    Args:
        component_ids (List[str]): List of component IDs to check.

    Returns:
        bool: True if circular dependencies exist, False otherwise.
    """
    try:
        self.get_execution_order(component_ids)
        return False
    except ValueError:
        return True

remove_connection(from_component, to_component, connection_type=None)

Remove a connection.

Parameters:

Name Type Description Default
from_component str

Source component ID.

required
to_component str

Target component ID.

required
connection_type Optional[str]

Connection type. If None, removes all connections between the components.

None

Returns:

Name Type Description
bool bool

True if at least one connection was removed, False otherwise.

Source code in pyadm1/configurator/connection_manager.py
def remove_connection(
    self,
    from_component: str,
    to_component: str,
    connection_type: Optional[str] = None,
) -> bool:
    """
    Remove a connection.

    Args:
        from_component (str): Source component ID.
        to_component (str): Target component ID.
        connection_type (Optional[str]): Connection type. If None, removes
            all connections between the components.

    Returns:
        bool: True if at least one connection was removed, False otherwise.
    """
    removed = False
    self.connections = [
        conn
        for conn in self.connections
        if not (
            conn.from_component == from_component
            and conn.to_component == to_component
            and (connection_type is None or conn.connection_type == connection_type)
        )
        or not (removed := True)
    ]
    return removed

to_dict()

Serialize all connections to dictionary.

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: Dictionary with connection data.

Source code in pyadm1/configurator/connection_manager.py
def to_dict(self) -> Dict[str, Any]:
    """
    Serialize all connections to dictionary.

    Returns:
        Dict[str, Any]: Dictionary with connection data.
    """
    return {"connections": [conn.to_dict() for conn in self.connections]}

validate_connections(component_ids)

Validate all connections and return list of issues.

Parameters:

Name Type Description Default
component_ids List[str]

List of valid component IDs.

required

Returns:

Type Description
List[str]

List[str]: List of validation error messages. Empty if all valid.

Source code in pyadm1/configurator/connection_manager.py
def validate_connections(self, component_ids: List[str]) -> List[str]:
    """
    Validate all connections and return list of issues.

    Args:
        component_ids (List[str]): List of valid component IDs.

    Returns:
        List[str]: List of validation error messages. Empty if all valid.
    """
    errors = []

    # Check for invalid component references
    for conn in self.connections:
        if conn.from_component not in component_ids:
            errors.append(f"Connection references non-existent source component: " f"{conn.from_component}")
        if conn.to_component not in component_ids:
            errors.append(f"Connection references non-existent target component: " f"{conn.to_component}")

    # Check for circular dependencies
    if self.has_circular_dependency(component_ids):
        errors.append("Circular dependency detected in connections")

    return errors