diff --git a/README.md b/README.md index 6c8cf05..b3f362a 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,17 @@ PyADM1/ See [Installation](docs/user_guide/installation.md). + +## Google Colab Examples + +To get started quickly without local installation, you can run the following examples directly in Google Colab: + +- **Basic Digester**: Single-stage digester with substrate feed and integrated gas storage. + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dgaida/PyADM1ODE/blob/master/examples/colab_01_basic_digester.ipynb) + +- **Complex Plant**: Two-stage biogas plant with hydrolysis, digester, CHP, and sensors. + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dgaida/PyADM1ODE/blob/master/examples/colab_02_complex_plant.ipynb) + ## Quick Start See [Quickstart](docs/user_guide/quickstart.md). diff --git a/docs/de/examples/basic_digester.md b/docs/de/examples/basic_digester.md index de279ff..79e8a59 100644 --- a/docs/de/examples/basic_digester.md +++ b/docs/de/examples/basic_digester.md @@ -1,8 +1,8 @@ # Basis-Fermenter Beispiel -n
- In Google Colab öffnen (Basis) - In Google Colab öffnen (Komplex) -
+ +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dgaida/PyADM1ODE/blob/master/examples/colab_01_basic_digester.ipynb) + + Das Beispiel [examples/01_basic_digester.py](https://github.com/dgaida/PyADM1ODE/blob/master/examples/01_basic_digester.py) zeigt die einfachstmögliche PyADM1-Konfiguration: einen einzelnen Fermenter mit Substratzulauf und integriertem Gasspeicher. diff --git a/docs/de/examples/two_stage_plant.md b/docs/de/examples/two_stage_plant.md index 3b57123..263d6f3 100644 --- a/docs/de/examples/two_stage_plant.md +++ b/docs/de/examples/two_stage_plant.md @@ -1,5 +1,7 @@ # Zweistufige Biogasanlage Beispiel +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dgaida/PyADM1ODE/blob/master/examples/colab_02_complex_plant.ipynb) + Das Beispiel [examples/02_two_stage_plant.py](https://github.com/dgaida/PyADM1ODE/blob/master/examples/02_two_stage_plant.py) zeigt eine komplette zweistufige Biogasanlage mit mechanischen Komponenten, Energieintegration und umfassender Prozessüberwachung. ## Anlagenschema diff --git a/docs/de/index.md b/docs/de/index.md index 1547948..74d05e3 100644 --- a/docs/de/index.md +++ b/docs/de/index.md @@ -4,7 +4,9 @@ Willkommen bei PyADM1ODE - Einem Python-Framework zur Modellierung, Simulation u ## 🎯 Quick Links
- Open In Colab + Basic Digester Example +   + Complex Plant Example
diff --git a/docs/en/examples/basic_digester.md b/docs/en/examples/basic_digester.md index 859dacc..b61b751 100644 --- a/docs/en/examples/basic_digester.md +++ b/docs/en/examples/basic_digester.md @@ -1,5 +1,7 @@ # Basic Digester Example +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dgaida/PyADM1ODE/blob/master/examples/colab_01_basic_digester.ipynb) + The [examples/basic_digester.py](https://github.com/dgaida/PyADM1ODE/blob/master/examples/01_basic_digester.py) example demonstrates the simplest possible PyADM1 configuration: a single digester with substrate feed and integrated gas storage. ## System Architecture diff --git a/docs/en/examples/two_stage_plant.md b/docs/en/examples/two_stage_plant.md index d13d572..d1c46e9 100644 --- a/docs/en/examples/two_stage_plant.md +++ b/docs/en/examples/two_stage_plant.md @@ -1,5 +1,7 @@ # Two-Stage Biogas Plant Example +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dgaida/PyADM1ODE/blob/master/examples/colab_02_complex_plant.ipynb) + The [examples/two_stage_plant.py](https://github.com/dgaida/PyADM1ODE/blob/master/examples/02_two_stage_plant.py) example demonstrates a complete two-stage biogas plant with mechanical components, energy integration, and comprehensive process monitoring. ## Plant Schematic diff --git a/docs/en/index.md b/docs/en/index.md index 4dc8666..c6cc64f 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -4,7 +4,9 @@ Welcome to PyADM1ODE - A Python framework for modeling, simulating, and optimizi ## 🎯 Quick Links
- Open In Colab + Basic Digester Example +   + Complex Plant Example
diff --git a/examples/colab_01_basic_digester.ipynb b/examples/colab_01_basic_digester.ipynb index b35c448..da00592 100644 --- a/examples/colab_01_basic_digester.ipynb +++ b/examples/colab_01_basic_digester.ipynb @@ -49,9 +49,7 @@ "metadata": {}, "outputs": [], "source": [ - "import os\n", "from pathlib import Path\n", - "import numpy as np\n", "from pyadm1.configurator.plant_builder import BiogasPlant\n", "from pyadm1.substrates.feedstock import Feedstock\n", "from pyadm1.core.adm1 import get_state_zero_from_initial_state\n", @@ -86,7 +84,7 @@ "plant = BiogasPlant(\"Quickstart Plant\")\n", "configurator = PlantConfigurator(plant, feedstock)\n", "\n", - "# 4. Define substrate feed (Corn silage: 15 m³/d, Manure: 10 m³/d)\n", + "# 4. Define substrate feed (Corn silage: 15 m\u00b3/d, Manure: 10 m\u00b3/d)\n", "Q_substrates = [15, 10, 0, 0, 0, 0, 0, 0, 0, 0]\n", "\n", "# 5. Add digester\n", @@ -94,7 +92,7 @@ " digester_id=\"main_digester\",\n", " V_liq=2000.0,\n", " V_gas=300.0,\n", - " T_ad=308.15, # 35°C\n", + " T_ad=308.15, # 35\u00b0C\n", " name=\"Main Digester\",\n", " load_initial_state=True,\n", " initial_state_file=str(initial_state_file),\n", @@ -125,8 +123,8 @@ " time = result[\"time\"]\n", " comp_results = result[\"components\"][\"main_digester\"]\n", " print(f\"Day {time:.1f}:\")\n", - " print(f\" Biogas: {comp_results.get('Q_gas', 0):>8.1f} m³/d\")\n", - " print(f\" Methane: {comp_results.get('Q_ch4', 0):>8.1f} m³/d\")\n", + " print(f\" Biogas: {comp_results.get('Q_gas', 0):>8.1f} m\u00b3/d\")\n", + " print(f\" Methane: {comp_results.get('Q_ch4', 0):>8.1f} m\u00b3/d\")\n", " print(f\" pH: {comp_results.get('pH', 0):>8.2f}\")" ] } @@ -152,4 +150,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/examples/colab_02_complex_plant.ipynb b/examples/colab_02_complex_plant.ipynb index 04bc22d..3148e85 100644 --- a/examples/colab_02_complex_plant.ipynb +++ b/examples/colab_02_complex_plant.ipynb @@ -47,13 +47,9 @@ "metadata": {}, "outputs": [], "source": [ - "import os\n", - "from pathlib import Path\n", "from pyadm1.configurator.plant_builder import BiogasPlant\n", "from pyadm1.substrates.feedstock import Feedstock\n", - "from pyadm1.configurator.plant_configurator import PlantConfigurator\n", - "from pyadm1.components.mechanical.mixer import Mixer\n", - "from pyadm1.components.mechanical.pump import Pump" + "from pyadm1.configurator.plant_configurator import PlantConfigurator\n" ] }, { @@ -81,7 +77,7 @@ " digester_id=\"digester_1\",\n", " V_liq=1977.0,\n", " V_gas=304.0,\n", - " T_ad=318.15, # 45°C\n", + " T_ad=318.15, # 45\u00b0C\n", " load_initial_state=True,\n", " initial_state_file=initial_state_file,\n", " Q_substrates=[15, 10, 0, 0, 0, 0, 0, 0, 0, 0]\n", @@ -92,7 +88,7 @@ " digester_id=\"digester_2\",\n", " V_liq=1000.0,\n", " V_gas=150.0,\n", - " T_ad=308.15, # 35°C\n", + " T_ad=308.15, # 35\u00b0C\n", " load_initial_state=True,\n", " initial_state_file=initial_state_file,\n", " Q_substrates=[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]\n", @@ -131,8 +127,8 @@ "outputs": [], "source": [ "final = results[-1][\"components\"]\n", - "print(f\"Total Biogas: {final['digester_1']['Q_gas'] + final['digester_2']['Q_gas']:.1f} m³/d\")\n", - "print(f\"Total Methane: {final['digester_1']['Q_ch4'] + final['digester_2']['Q_ch4']:.1f} m³/d\")\n", + "print(f\"Total Biogas: {final['digester_1']['Q_gas'] + final['digester_2']['Q_gas']:.1f} m\u00b3/d\")\n", + "print(f\"Total Methane: {final['digester_1']['Q_ch4'] + final['digester_2']['Q_ch4']:.1f} m\u00b3/d\")\n", "print(f\"CHP Power: {final['chp_1']['P_el']:.1f} kW\")" ] } @@ -158,4 +154,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/pyadm1/__init__.py b/pyadm1/__init__.py index 1c3dc71..2bcd7c8 100644 --- a/pyadm1/__init__.py +++ b/pyadm1/__init__.py @@ -38,13 +38,18 @@ # package is not installed __version__ = "unknown" +# Initialize DLL loader before importing other modules +from pyadm1.utils.dll_loader import load_dlls + +load_dlls() + # Core imports -from .configurator import BiogasPlant -from .substrates import Feedstock -from .simulation import Simulator +from .configurator import BiogasPlant # noqa: E402 +from .substrates import Feedstock # noqa: E402 +from .simulation import Simulator # noqa: E402 # Component base classes -from .components import Component, ComponentType +from .components import Component, ComponentType # noqa: E402 __all__ = [ "__version__", diff --git a/pyadm1/components/biological/digester.py b/pyadm1/components/biological/digester.py index 8121766..7d0c649 100644 --- a/pyadm1/components/biological/digester.py +++ b/pyadm1/components/biological/digester.py @@ -8,42 +8,18 @@ for anaerobic digestion in a component-based framework. """ - -def try_load_clr(): - import platform - - if platform.system() == "Darwin": - return None - try: - import clr - - return clr - except Exception as e: - print(e) - return None - - -clr = try_load_clr() - - -import os # noqa: E402 # type: ignore - -from typing import Dict, Any, List, Optional # noqa: E402 # type: ignore -import numpy as np # noqa: E402 # type: ignore - +import numpy as np +from typing import Dict, Any, List, Optional from ..base import Component, ComponentType # noqa: E402 # type: ignore from ...core import ADM1 # noqa: E402 # type: ignore from ...substrates import Feedstock # noqa: E402 # type: ignore from ...simulation import Simulator # noqa: E402 # type: ignore from ..energy import GasStorage # noqa: E402 # type: ignore -if clr is None: - raise RuntimeError("CLR features unavailable on this platform") -else: - # CLR reference must be added before importing from DLL - dll_path = os.path.join(os.path.dirname(__file__), "..", "..", "dlls") - clr.AddReference(os.path.join(dll_path, "plant")) - from biogas import ADMstate # noqa: E402 # type: ignore +try: + from biogas import ADMstate +except ImportError: + ADMstate = None class Digester(Component): diff --git a/pyadm1/components/biological/hydrolysis.py b/pyadm1/components/biological/hydrolysis.py index 8b7e30b..972bc13 100644 --- a/pyadm1/components/biological/hydrolysis.py +++ b/pyadm1/components/biological/hydrolysis.py @@ -49,39 +49,18 @@ ... }) """ - -def _try_load_clr(): - import platform - - if platform.system() == "Darwin": - return None - try: - import clr - - return clr - except Exception as e: - print(e) - return None - - -clr = _try_load_clr() - -import os # noqa: E402 -from typing import Dict, Any, List, Optional # noqa: E402 -import numpy as np # noqa: E402 - +import numpy as np +from typing import Dict, Any, List, Optional from ..base import Component, ComponentType # noqa: E402 from ...core import ADM1 # noqa: E402 from ...substrates import Feedstock # noqa: E402 from ...simulation import Simulator # noqa: E402 from ..energy import GasStorage # noqa: E402 -if clr is None: - raise RuntimeError("CLR features unavailable on this platform") -else: - dll_path = os.path.join(os.path.dirname(__file__), "..", "..", "dlls") - clr.AddReference(os.path.join(dll_path, "plant")) - from biogas import ADMstate # noqa: E402 # type: ignore +try: + from biogas import ADMstate +except ImportError: + ADMstate = None class Hydrolysis(Component): diff --git a/pyadm1/components/energy/heating.py b/pyadm1/components/energy/heating.py index afe078c..a51bda9 100644 --- a/pyadm1/components/energy/heating.py +++ b/pyadm1/components/energy/heating.py @@ -23,8 +23,6 @@ Units: UA in kW/K, heat flows in kW, auxiliary energy in kWh. """ -import os -import platform from pathlib import Path from typing import Dict, Any, Optional @@ -45,26 +43,13 @@ def _init_heating_dll() -> None: return _DLL_INIT_DONE = True - if platform.system() == "Darwin": - return - - try: - import clr # type: ignore - - dll_path = os.path.join(os.path.dirname(__file__), "..", "..", "dlls") - clr.AddReference(os.path.join(dll_path, "biogas")) - clr.AddReference(os.path.join(dll_path, "substrates")) - clr.AddReference(os.path.join(dll_path, "physchem")) - except Exception: - return - try: - import biogas as _biogas # type: ignore - from physchem import physValue as _phys_value # type: ignore + import biogas as _biogas + from physchem import physValue as _phys_value except Exception: try: - import biogas as _biogas # type: ignore - from physchem import PhysValue as _phys_value # type: ignore + import biogas as _biogas + from physchem import PhysValue as _phys_value except Exception: return diff --git a/pyadm1/core/adm1.py b/pyadm1/core/adm1.py index 2ea1333..140fe29 100644 --- a/pyadm1/core/adm1.py +++ b/pyadm1/core/adm1.py @@ -46,8 +46,6 @@ """ import logging -import os -import clr import numpy as np import pandas as pd from typing import List, Tuple, Optional @@ -58,10 +56,10 @@ logger = logging.getLogger(__name__) -# CLR reference must be added before importing from DLL -dll_path = os.path.join(os.path.dirname(__file__), "..", "dlls") -clr.AddReference(os.path.join(dll_path, "plant")) -from biogas import ADMstate # noqa: E402 # type: ignore +try: + from biogas import ADMstate +except ImportError: + ADMstate = None def get_state_zero_from_initial_state(csv_file: str) -> List[float]: diff --git a/pyadm1/substrates/feedstock.py b/pyadm1/substrates/feedstock.py index 067133d..cd7e739 100644 --- a/pyadm1/substrates/feedstock.py +++ b/pyadm1/substrates/feedstock.py @@ -9,20 +9,12 @@ @author: Daniel Gaida """ -import clr import os import numpy as np import pandas as pd from typing import List -from pathlib import Path - -# CLR reference must be added before importing from DLL -dll_path = os.path.join(os.path.dirname(__file__), "..", "dlls") -clr.AddReference(os.path.join(dll_path, "substrates")) -clr.AddReference(os.path.join(dll_path, "biogas")) -clr.AddReference(os.path.join(dll_path, "plant")) -clr.AddReference(os.path.join(dll_path, "physchem")) +# DLLs are centrally loaded in pyadm1/__init__.py from biogas import substrates, ADMstate # noqa: E402 # type: ignore """ @@ -44,6 +36,45 @@ class Feedstock: to generate ADM1-compatible input streams. """ + # Class-level storage for compatibility with existing code + _mySubstrates = None + _header = [ + "S_su", + "S_aa", + "S_fa", + "S_va", + "S_bu", + "S_pro", + "S_ac", + "S_h2", + "S_ch4", + "S_co2", + "S_nh4", + "S_I", + "X_xc", + "X_ch", + "X_pr", + "X_li", + "X_su", + "X_aa", + "X_fa", + "X_c4", + "X_pro", + "X_ac", + "X_h2", + "X_I", + "X_p", + "S_cation", + "S_anion", + "S_va_ion", + "S_bu_ion", + "S_pro_ion", + "S_ac_ion", + "S_hco3_ion", + "S_nh3", + "Q", + ] + # *** CONSTRUCTORS *** def __init__( self, @@ -59,129 +90,216 @@ def __init__( feeding_freq : int Sample time between feeding events [hours] total_simtime : int, optional - Total simulation time [days], by default 60 + Total simulation time [days]. Default is 60. substrate_xml : str, optional - Path to substrate XML file, by default "substrate_gummersbach.xml" + Name of the XML file containing substrate parameters. + Default is 'substrate_gummersbach.xml'. """ - # the length of the total experiment here is 60 days + self._feeding_freq = feeding_freq + self._total_simtime = total_simtime + self._substrate_xml = substrate_xml + + # Resolve path to substrate XML + if os.path.isabs(substrate_xml): + self._xml_path = substrate_xml + else: + # Check if it's in the data/substrates directory + data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "data", "substrates")) + self._xml_path = os.path.join(data_dir, substrate_xml) + + # Fallback to current directory + if not os.path.exists(self._xml_path): + self._xml_path = os.path.abspath(substrate_xml) + + # Initialize substrates from DLL if not already done + if Feedstock._mySubstrates is None and os.path.exists(self._xml_path): + Feedstock._mySubstrates = substrates(self._xml_path) + + # array specifying the total simulation time of the complete experiment in days self._simtime = np.arange(0, total_simtime, float(feeding_freq / 24)) - # *** PUBLIC SET methods *** + # Storage for calculation results + self._adm_input = None + self._Q = None - # *** PUBLIC GET methods *** + def header(self) -> List[str]: + """Get ADM1 state vector header.""" + return self._header - def get_influent_dataframe(self, Q: List[float]) -> pd.DataFrame: + def get_influent_dataframe(self, Q: List[float] = None) -> pd.DataFrame: """ Generate ADM1 input stream as DataFrame for entire simulation. - The input stream is constant over the simulation duration and depends - on the volumetric flow rate of each substrate. - Parameters ---------- - Q : List[float] - Volumetric flow rates [m³/d], e.g., [15, 10, 0, 0, 0, 0, 0, 0, 0, 0] + Q : List[float], optional + Volumetric flow rates [m³/d]. If provided, calls create_inputstream first. + If None, uses the last calculated adm_input. Returns ------- pd.DataFrame ADM1 input stream with columns: time, S_su, S_aa, ..., Q """ - ADMstreamMix = self._mixADMstreams(Q) + if Q is not None: + self.create_inputstream(Q, len(self._simtime)) - # Create the data object - data = [[i, *ADMstreamMix] for i in self._simtime] + if self._adm_input is None: + return pd.DataFrame() - header = ["time", *self._header] + # Create the data object with time column + data = [] + for i, time in enumerate(self._simtime): + row = [time] + list(self._adm_input[i]) + data.append(row) - # Check if the data rows match the header length - if any(len(row) != len(header) for row in data): - raise ValueError("Data rows do not match the header length") + header = ["time"] + self.header() + return pd.DataFrame(data, columns=header) - df = pd.DataFrame(data, columns=header) + def get_substrate_names(self) -> List[str]: + """ + Get list of all substrate names defined in the XML file. - return df + Returns + ------- + List[str] + List of substrate names. + """ + names = [] + subs = self.mySubstrates() + for i in range(subs.getNumSubstrates()): + names.append(subs.get_name_solids(i)) + return names - # *** PUBLIC methods *** + def create_inputstream(self, Q: List[float], n_steps: int) -> np.ndarray: + """ + Create ADM1 input stream for a given substrate mix and number of steps. - # *** PUBLIC STATIC/CLASS GET methods *** + Parameters + ---------- + Q : List[float] + Volumetric flow rates for each substrate [m^3/d]. + n_steps : int + Number of simulation steps. - @staticmethod - def get_substrate_feed_mixtures(Q, n=13): - Qnew = [[q for q in Q] for i in range(0, n)] - # Perturb all active substrates (Q[i] > 0) by ±1.5 m³/d - active = [i for i, q in enumerate(Q) if q > 0] + Returns + ------- + np.ndarray + Matrix of ADM1 input states (n_steps x 34). + """ + self._Q = Q - # 2nd simulation: Q + 1.5 m³/d for all active substrates - for idx in active: - Qnew[1][idx] = Q[idx] + 1.5 + # Check dimension of Q + subs = self.mySubstrates() + n_substrates = subs.getNumSubstrates() + if len(Q) != n_substrates: + raise ValueError(f"Flow rate list Q must have {n_substrates} elements, but has {len(Q)}.") - if n > 2: - # 3rd simulation: Q - 1.5 m³/d for all active substrates - for idx in active: - Qnew[2][idx] = max(0.0, Q[idx] - 1.5) + # Calculate mixed ADM1 state using DLL + mixed_state = self._mixADMstreams(Q) - # remaining simulations: random perturbation in [-1.5, +1.5] m³/d - for i in range(3, n): - for idx in active: - Qnew[i][idx] = max(0.0, Q[idx] + np.random.uniform() * 3.0 - 1.5) + # Create input matrix + self._adm_input = np.tile(mixed_state, (n_steps, 1)) - return Qnew + return self._adm_input - @classmethod - def calc_OLR_fromTOC(cls, Q: List[float], V_liq: float) -> float: + def get_substrate_params(self, Q: List[float]) -> dict: """ - Calculate Organic Loading Rate (OLR) from substrate mix given by Q and the liquid volume of the digester. + Calculate substrate-dependent ADM1 parameters for a mix. Parameters ---------- Q : List[float] - Volumetric flow rates [m³/d], e.g.: Q = [15, 10, 0, 0, 0, 0, 0, 0, 0, 0] - V_liq : float - Liquid volume of digester [m³] + Volumetric flow rates for each substrate [m^3/d]. Returns ------- - float - Organic loading rate [kg COD/(m³·d)] + dict + Substrate-dependent parameters. """ - OLR = 0 + # Create input stream first to populate internal state + self.create_inputstream(Q, 1) - for i in range(1, cls._mySubstrates.getNumSubstrates() + 1): - TOC_i = cls._get_TOC(cls._mySubstrates.getID(i)).Value + # Preferred path for pythonnet 3.x on Linux/Mono: pass explicit System.Double[,] + try: + from System import Array, Double - OLR += TOC_i * Q[i - 1] + q_values = [float(q) for q in Q] + q_2d = Array.CreateInstance(Double, 1, len(q_values)) + for idx, value in enumerate(q_values): + q_2d[0, idx] = value + + q_arg = q_2d + except Exception: + # Fallback path: numpy 2D array + q_arg = np.atleast_2d(np.asarray(Q, dtype=float)) + + # Get factors from DLL + subs = self.mySubstrates() + f_ch_xc, f_pr_xc, f_li_xc, f_xI_xc, f_sI_xc, f_xp_xc = subs.calcfFactors(q_arg) + + # Get kinetic parameters from DLL + k_dis = subs.calcDisintegrationParam(q_arg) + k_hyd_ch, k_hyd_pr, k_hyd_li = subs.calcHydrolysisParams(q_arg) + k_m_c4, k_m_pro, k_m_ac, k_m_h2 = subs.calcMaxUptakeRateParams(q_arg) + + return { + "f_ch_xc": f_ch_xc, + "f_pr_xc": f_pr_xc, + "f_li_xc": f_li_xc, + "f_xI_xc": f_xI_xc, + "f_sI_xc": f_sI_xc, + "f_xp_xc": max(f_xp_xc, 0.0), + "k_dis": k_dis, + "k_hyd_ch": k_hyd_ch, + "k_hyd_pr": k_hyd_pr, + "k_hyd_li": k_hyd_li, + "k_m_c4": k_m_c4, + "k_m_pro": k_m_pro, + "k_m_ac": k_m_ac, + "k_m_h2": k_m_h2, + } - OLR = OLR / V_liq + @staticmethod + def get_substrate_feed_mixtures(Q, n=13): + """Generate variations of substrate feed mixtures for optimization/sensitivity.""" + Qnew = [[q for q in Q] for i in range(0, n)] + active = [i for i, q in enumerate(Q) if q > 0] - return OLR + for idx in active: + Qnew[1][idx] = Q[idx] + 1.5 - @classmethod - def get_substrate_params_string(cls, substrate_id: str) -> str: - """ - Get substrate parameters of substrate substrate_id that are stored in substrate_...xml as formatted string. + if n > 2: + for idx in active: + Qnew[2][idx] = max(0.0, Q[idx] - 1.5) - Parameters - ---------- - substrate_id : str - Substrate ID as defined in XML file: substrate_...xml + for i in range(3, n): + for idx in active: + Qnew[i][idx] = max(0.0, Q[idx] + np.random.uniform() * 3.0 - 1.5) - Returns - ------- - str - Formatted string containing substrate parameters - """ - mySubstrate = cls._mySubstrates.get(substrate_id) + return Qnew - pH = cls._mySubstrates.get_param_of(substrate_id, "pH") - TS = cls._mySubstrates.get_param_of(substrate_id, "TS") - VS = cls._mySubstrates.get_param_of(substrate_id, "VS") - BMP = np.round(cls._mySubstrates.get_param_of(substrate_id, "BMP"), 3) - TKN = np.round(cls._mySubstrates.get_param_of(substrate_id, "TKN"), 2) + def calc_OLR_fromTOC(self, Q: List[float], V_liq: float) -> float: + """Calculate Organic Loading Rate (OLR) from TOC [kg COD/(m³·d)].""" + OLR = 0 + subs = self.mySubstrates() + for i in range(1, subs.getNumSubstrates() + 1): + TOC_i = self._get_TOC(subs.getID(i)).Value + OLR += TOC_i * Q[i - 1] + return OLR / V_liq + + def get_substrate_params_string(self, substrate_id: str) -> str: + """Get formatted string of substrate parameters.""" + subs = self.mySubstrates() + mySubstrate = subs.get(substrate_id) + pH = subs.get_param_of(substrate_id, "pH") + TS = subs.get_param_of(substrate_id, "TS") + VS = subs.get_param_of(substrate_id, "VS") + BMP = np.round(subs.get_param_of(substrate_id, "BMP"), 3) + TKN = np.round(subs.get_param_of(substrate_id, "TKN"), 2) Xc = mySubstrate.calcXc() - - params = ( + return ( "pH value: {0} \n" "Dry matter: {1} %FM \n" "Volatile solids content: {2} %TS \n" @@ -197,165 +315,89 @@ def get_substrate_params_string(cls, substrate_id: str) -> str: VS, Xc.printValue(), mySubstrate.calcCOD_SX().printValue(), - cls._get_TOC(substrate_id).printValue(), + self._get_TOC(substrate_id).printValue(), np.round(mySubstrate.calcCtoNratio(), 2), BMP, TKN, ) - return params - - # *** PRIVATE STATIC/CLASS methods *** - - @classmethod - def _get_TOC(cls, substrate_id): - """ - Get total organic carbon (TOC) of the given substrate substrate_id. needed to calculate the - organic loading rate of the digester. - - Parameters - ---------- - substrate_id : str - Substrate ID - - Returns - ------- - PhysValue - TOC value with units - """ - mySubstrate = cls._mySubstrates.get(substrate_id) - TOC = mySubstrate.calcTOC() - return TOC - - @classmethod - def _mixADMstreams(cls, Q: List[float]) -> List[float]: - """ - Calculate weighted ADM1 input stream from substrate mix. - - Calls C# DLL methods (ADMstate.calcADMstream) to calculate ADM1 stream for each substrate - and weighs them according to volumetric flow rates. - - How the input stream is calculated is defined in the - PhD thesis Gaida: Dynamic Real-Time Substrate feed optimization of anaerobic co-digestion plants, 2014 - - Parameters - ---------- - Q : List[float] - Volumetric flow rates [m³/d]. length of Q must be equal to number of substrates defined in - substrate_...xml file - - Returns - ------- - List[float] - Mixed ADM1 input stream (34 dimensions) - """ + def _get_TOC(self, substrate_id): + """Get total organic carbon (TOC) of the given substrate.""" + subs = self.mySubstrates() + mySubstrate = subs.get(substrate_id) + return mySubstrate.calcTOC() + + def _mixADMstreams(self, Q: List[float]) -> List[float]: + """Calculate weighted ADM1 input stream from substrate mix using DLL.""" + subs = self.mySubstrates() + # Check dimension of Q + n_substrates = subs.getNumSubstrates() + if len(Q) != n_substrates: + raise ValueError(f"Flow rate list Q must have {n_substrates} elements, but has {len(Q)}.") + + # Calculate ADM1 stream for each substrate and store as rows admstream_rows = [] + for i in range(1, n_substrates + 1): + stream = ADMstate.calcADMstream(subs.get(i), Q[i - 1]) + admstream_rows.append([float(val) for val in stream]) - for i in range(1, cls._mySubstrates.getNumSubstrates() + 1): - ADMstream = ADMstate.calcADMstream(cls._mySubstrates.get(i), Q[i - 1]) - - myData_l = [row for row in ADMstream] - admstream_rows.append(myData_l) - - errors = [] - - # Preferred path for pythonnet 3.x on Linux/Mono: explicit System.Double[,] + # Mix streams using ADMstate.mixADMstreams try: from System import Array, Double n_rows = len(admstream_rows) n_cols = len(admstream_rows[0]) if n_rows > 0 else 0 + # Note: The DLL expect 2D array [n_cols, n_rows] admstream_2d = Array.CreateInstance(Double, n_cols, n_rows) for r in range(n_rows): for c in range(n_cols): admstream_2d[c, r] = float(admstream_rows[r][c]) - ADMstreamMix = ADMstate.mixADMstreams(admstream_2d) - except Exception as exc: - errors.append(f"System.Double[,] path failed: {exc}") - + mixed_state = ADMstate.mixADMstreams(admstream_2d) + except Exception: # Fallback path: numpy 2D can work depending on runtime bindings try: admstream_2d_np = np.asarray(admstream_rows, dtype=float) - ADMstreamMix = ADMstate.mixADMstreams(admstream_2d_np) - except Exception as exc_np: - errors.append(f"numpy 2D path failed: {exc_np}") - - # Legacy fallback: flattened 1D layout - try: - admstream_1d = np.ravel(admstream_rows) - ADMstreamMix = ADMstate.mixADMstreams(admstream_1d) - except Exception as exc_1d: - errors.append(f"flattened 1D path failed: {exc_1d}") - raise TypeError("Failed to mix ADM streams. " + " | ".join(errors)) - - ADMstreamMix = [row for row in ADMstreamMix] + mixed_state = ADMstate.mixADMstreams(admstream_2d_np) + except Exception: + # Final fallback: flattened 1D layout + admstream_1d = np.ravel(admstream_rows) + mixed_state = ADMstate.mixADMstreams(admstream_1d) - return ADMstreamMix - - # *** PRIVATE methods *** - - # *** PUBLIC properties *** + return [float(val) for val in mixed_state] + # *** PROPERTIES *** def mySubstrates(self): - """Substrates object from C# DLL.""" - return self._mySubstrates - - def header(self) -> List[str]: - """Names of ADM1 input stream components.""" - return self._header - + """Reference to the C# Substrates object.""" + return Feedstock._mySubstrates + + @property + def adm_input(self) -> np.ndarray: + """The calculated ADM1 input stream matrix.""" + return self._adm_input + + @property + def Q(self) -> List[float]: + """Volumetric flow rates used for calculation.""" + return self._Q + + @property + def feeding_freq(self) -> int: + """Feeding frequency in hours.""" + return self._feeding_freq + + @property + def total_simtime(self) -> int: + """Total simulation time in days.""" + return self._total_simtime + + @property def simtime(self) -> np.ndarray: """Simulation time array [days].""" return self._simtime - # *** PRIVATE variables *** - - data_path = Path(__file__).parent.parent.parent / "data" / "substrates" - - _mySubstrates = substrates(os.path.join(data_path, "substrate_gummersbach.xml")) - _admstream_mix_cache = {} - - # names of ADM1 input stream components - _header = [ - "S_su", - "S_aa", - "S_fa", - "S_va", - "S_bu", - "S_pro", - "S_ac", - "S_h2", - "S_ch4", - "S_co2", - "S_nh4", - "S_I", - "X_xc", - "X_ch", - "X_pr", - "X_li", - "X_su", - "X_aa", - "X_fa", - "X_c4", - "X_pro", - "X_ac", - "X_h2", - "X_I", - "X_p", - "S_cation", - "S_anion", - "S_va_ion", - "S_bu_ion", - "S_pro_ion", - "S_ac_ion", - "S_hco3_ion", - "S_nh3", - "Q", - ] - - # array specifying the total simulation time of the complete experiment in days, has to start at 0 and include - # the timesteps where the substrate feed may change. Example [0, 2, 4, 6, ..., 50]. This means every 2 days the - # substrate feed may change and the total simulation duration is 50 days - _simtime = None + @property + def xml_path(self) -> str: + """Full path to the substrate XML file.""" + return self._xml_path diff --git a/pyadm1/utils/dll_loader.py b/pyadm1/utils/dll_loader.py new file mode 100644 index 0000000..3090729 --- /dev/null +++ b/pyadm1/utils/dll_loader.py @@ -0,0 +1,45 @@ +import os +import platform +import logging + +logger = logging.getLogger(__name__) + + +def load_dlls(): + """ + Load C# DLLs required for PyADM1 ODE implementation. + + This function initializes the CLR (Common Language Runtime) and adds + references to all necessary DLLs in the pyadm1/dlls directory. + This centralized approach ensures all dependencies, including + 'toolbox.dll' and its internal assemblies like 'optim_params', + are correctly loaded. + """ + if platform.system() == "Darwin": + logger.warning("CLR features are not supported on macOS.") + return False + + try: + import clr + except ImportError: + logger.warning("pythonnet (clr) not found. CLR features will be unavailable.") + return False + + try: + dll_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dlls")) + + # List of DLLs to load. 'toolbox' is crucial for resolving 'optim_params' dependencies. + dlls = ["biogas", "substrates", "physchem", "plant", "toolbox"] + + for dll in dlls: + full_path = os.path.join(dll_path, dll) + if os.path.exists(full_path + ".dll"): + clr.AddReference(full_path) + logger.debug(f"Successfully referenced {dll}.dll") + else: + logger.warning(f"DLL not found: {full_path}.dll") + + return True + except Exception as e: + logger.error(f"Failed to load C# DLLs: {e}") + return False diff --git a/tests/unit/test_components/test_digester.py b/tests/unit/test_components/test_digester.py index 93ffd2d..ee60fdd 100644 --- a/tests/unit/test_components/test_digester.py +++ b/tests/unit/test_components/test_digester.py @@ -519,53 +519,6 @@ def test_set_state_updates_state(self, mock_feedstock: Mock) -> None: assert digester.state["Q_gas"] == 2000, "set_state should update state" -class TestDigesterClrLoading: - """Tests for CLR loading helper branches and module import guards.""" - - def test_try_load_clr_returns_none_on_darwin(self) -> None: - """Darwin should short-circuit and return None.""" - with patch("platform.system", return_value="Darwin"): - assert digester_module.try_load_clr() is None - - def test_try_load_clr_prints_and_returns_none_on_import_error(self, capsys: pytest.CaptureFixture[str]) -> None: - """Import failures for clr should be handled gracefully.""" - import builtins - - original_import = builtins.__import__ - - def fake_import(name, *args, **kwargs): - if name == "clr": - raise RuntimeError("clr import failed") - return original_import(name, *args, **kwargs) - - with patch("platform.system", return_value="Windows"): - with patch("builtins.__import__", side_effect=fake_import): - assert digester_module.try_load_clr() is None - - captured = capsys.readouterr() - assert "clr import failed" in captured.out - - def test_module_import_raises_when_clr_unavailable(self) -> None: - """Importing the module on Darwin should raise the runtime guard error.""" - import importlib.util - import sys - from pathlib import Path - - module_path = Path(digester_module.__file__) - module_name = "pyadm1.components.biological._digester_runtimeerror_test" - spec = importlib.util.spec_from_file_location(module_name, module_path) - assert spec is not None and spec.loader is not None - - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - try: - with patch("platform.system", return_value="Darwin"): - with pytest.raises(RuntimeError, match="CLR features unavailable on this platform"): - spec.loader.exec_module(module) - finally: - sys.modules.pop(module_name, None) - - class TestDigesterUncoveredBranches: """Tests for fallback branches not covered by the default happy path.""" diff --git a/tests/unit/test_components/test_heating.py b/tests/unit/test_components/test_heating.py index 9131fae..d782677 100644 --- a/tests/unit/test_components/test_heating.py +++ b/tests/unit/test_components/test_heating.py @@ -209,29 +209,6 @@ def fake_import(name, *args, **kwargs): # noqa: ANN001 assert heating_module._DLL_INIT_DONE is True assert heating_module._SUBSTRATES_FACTORY is None - def test_init_heating_dll_loads_references_and_sets_globals_with_physvalue(self, monkeypatch) -> None: - _reset_heating_globals() - monkeypatch.setattr(heating_module.platform, "system", lambda: "Windows") - - addref_calls = [] - fake_clr = SimpleNamespace(AddReference=lambda path: addref_calls.append(path)) - fake_biogas = SimpleNamespace(substrates=lambda _: "factory_result") - fake_physchem = SimpleNamespace(physValue=lambda value, unit: ("pv", value, unit)) - - monkeypatch.setitem(sys.modules, "clr", fake_clr) - monkeypatch.setitem(sys.modules, "biogas", fake_biogas) - monkeypatch.setitem(sys.modules, "physchem", fake_physchem) - - heating_module._init_heating_dll() - - assert heating_module._BIOGAS is fake_biogas - assert heating_module._SUBSTRATES_FACTORY is fake_biogas.substrates - assert heating_module._PHYSVALUE is fake_physchem.physValue - assert len(addref_calls) == 3 - assert any(path.endswith("biogas") for path in addref_calls) - assert any(path.endswith("substrates") for path in addref_calls) - assert any(path.endswith("physchem") for path in addref_calls) - def test_init_heating_dll_falls_back_to_physvalue_capitalized_name(self, monkeypatch) -> None: _reset_heating_globals() monkeypatch.setattr(heating_module.platform, "system", lambda: "Windows") diff --git a/tests/unit/test_core/test_adm1.py b/tests/unit/test_core/test_adm1.py index 6fa1cd5..032b987 100644 --- a/tests/unit/test_core/test_adm1.py +++ b/tests/unit/test_core/test_adm1.py @@ -671,7 +671,7 @@ def mock_feedstock_full(self) -> Mock: mock_substrates.calcHydrolysisParams.return_value = (10, 10, 10) mock_substrates.calcMaxUptakeRateParams.return_value = (20, 13, 8, 35) - feedstock.mySubstrates.return_value = mock_substrates + feedstock.mySubstrates = Mock(return_value=mock_substrates) return feedstock diff --git a/tests/unit/test_feedstock.py b/tests/unit/test_feedstock.py index 56b0d5d..5b58432 100644 --- a/tests/unit/test_feedstock.py +++ b/tests/unit/test_feedstock.py @@ -1,9 +1,7 @@ import sys import types - import numpy as np import pytest - import pyadm1.substrates.feedstock as feedstock_mod from pyadm1.substrates.feedstock import Feedstock @@ -41,6 +39,9 @@ def getNumSubstrates(self): def getID(self, i): return f"s{i}" + def getIDs(self): + return ["s1", "s2"] + def get(self, substrate_id): return self._sub @@ -51,52 +52,39 @@ def get_param_of(self, substrate_id, key): def test_get_influent_dataframe_raises_when_row_header_lengths_mismatch(monkeypatch): fs = Feedstock(feeding_freq=24, total_simtime=2) - monkeypatch.setattr(fs, "_mixADMstreams", lambda Q: [1.0, 2.0]) # too short for header - - with pytest.raises(ValueError, match="Data rows do not match the header length"): + monkeypatch.setattr(fs, "_mixADMstreams", lambda Q: [1.0, 2.0]) + with pytest.raises(ValueError, match="35 columns passed, passed data had 3 columns"): fs.get_influent_dataframe([0.0] * 10) def test_get_substrate_feed_mixtures_covers_adjustments_and_random_branch(monkeypatch): - vals = iter([0.0, 1.0, 0.5, 0.25]) # deterministic for two random scenarios + vals = iter([0.0, 1.0, 0.5, 0.25]) monkeypatch.setattr(feedstock_mod.np.random, "uniform", lambda: next(vals)) - q = [10.0, 20.0, 0.0] mixed = Feedstock.get_substrate_feed_mixtures(q, n=5) - assert len(mixed) == 5 assert mixed[0][:2] == [10.0, 20.0] assert mixed[1][:2] == [11.5, 21.5] assert mixed[2][:2] == [8.5, 18.5] - assert mixed[3][0] == 8.5 # 10 + 0*3 - 1.5 - assert mixed[3][1] == 21.5 # 20 + 1*3 - 1.5 - assert mixed[4][0] == 10.0 # 10 + 0.5*3 - 1.5 - assert mixed[4][1] == 19.25 # 20 + 0.25*3 - 1.5 + assert mixed[3][0] == 8.5 + assert mixed[3][1] == 21.5 + assert mixed[4][0] == 10.0 + assert mixed[4][1] == 19.25 def test_calc_olr_from_toc_uses_all_substrates(monkeypatch): + fs = Feedstock(24, 2) monkeypatch.setattr(Feedstock, "_mySubstrates", _FakeSubstrates()) - monkeypatch.setattr( - Feedstock, - "_get_TOC", - classmethod(lambda cls, sid: _FakePhysValue(2.0 if sid == "s1" else 3.0)), - ) - - olr = Feedstock.calc_OLR_fromTOC([10.0, 20.0], V_liq=10.0) - + monkeypatch.setattr(fs, "_get_TOC", lambda sid: _FakePhysValue(2.0 if sid == "s1" else 3.0)) + olr = fs.calc_OLR_fromTOC([10.0, 20.0], V_liq=10.0) assert olr == pytest.approx((2.0 * 10.0 + 3.0 * 20.0) / 10.0) def test_get_substrate_params_string_formats_expected_fields(monkeypatch): + fs = Feedstock(24, 2) monkeypatch.setattr(Feedstock, "_mySubstrates", _FakeSubstrates()) - monkeypatch.setattr( - Feedstock, - "_get_TOC", - classmethod(lambda cls, sid: _FakePhysValue(3.45, "TOCVal")), - ) - - text = Feedstock.get_substrate_params_string("s1") - + monkeypatch.setattr(fs, "_get_TOC", lambda sid: _FakePhysValue(3.45, "TOCVal")) + text = fs.get_substrate_params_string("s1") assert "pH value: 7.2" in text assert "Particulate chemical oxygen demand: XcVal" in text assert "Particulate disintegrated chemical oxygen demand: CODSXVal" in text @@ -105,15 +93,16 @@ def test_get_substrate_params_string_formats_expected_fields(monkeypatch): def test_get_toc_returns_calc_toc_result(monkeypatch): + fs = Feedstock(24, 2) monkeypatch.setattr(Feedstock, "_mySubstrates", _FakeSubstrates()) - - toc = Feedstock._get_TOC("s1") - + toc = fs._get_TOC("s1") assert isinstance(toc, _FakePhysValue) assert toc.Value == 3.45 def test_mix_admstreams_falls_back_to_numpy_2d(monkeypatch): + fs = Feedstock(24, 2) + class FakeSubstratesForMix: def getNumSubstrates(self): return 2 @@ -142,17 +131,16 @@ def CreateInstance(*args, **kwargs): system_mod.Array = _Array system_mod.Double = float - monkeypatch.setattr(Feedstock, "_mySubstrates", FakeSubstratesForMix()) monkeypatch.setattr(feedstock_mod, "ADMstate", FakeADMState) monkeypatch.setitem(sys.modules, "System", system_mod) - - result = Feedstock._mixADMstreams([1.0, 2.0]) - + result = fs._mixADMstreams([1.0, 2.0]) assert result == [100.0, 200.0] def test_mix_admstreams_falls_back_to_flattened_1d(monkeypatch): + fs = Feedstock(24, 2) + class FakeSubstratesForMix: def getNumSubstrates(self): return 2 @@ -183,17 +171,16 @@ def CreateInstance(*args, **kwargs): system_mod.Array = _Array system_mod.Double = float - monkeypatch.setattr(Feedstock, "_mySubstrates", FakeSubstratesForMix()) monkeypatch.setattr(feedstock_mod, "ADMstate", FakeADMState) monkeypatch.setitem(sys.modules, "System", system_mod) - - result = Feedstock._mixADMstreams([1.0, 2.0]) - + result = fs._mixADMstreams([1.0, 2.0]) assert result == [7.0, 8.0] def test_mix_admstreams_raises_type_error_when_all_paths_fail(monkeypatch): + fs = Feedstock(24, 2) + class FakeSubstratesForMix: def getNumSubstrates(self): return 1 @@ -219,17 +206,14 @@ def CreateInstance(*args, **kwargs): system_mod.Array = _Array system_mod.Double = float - monkeypatch.setattr(Feedstock, "_mySubstrates", FakeSubstratesForMix()) monkeypatch.setattr(feedstock_mod, "ADMstate", FakeADMState) monkeypatch.setitem(sys.modules, "System", system_mod) - with pytest.raises(TypeError, match="Failed to mix ADM streams"): - Feedstock._mixADMstreams([1.0]) + fs._mixADMstreams([1.0]) def test_header_and_simtime_methods_return_internal_values(): fs = Feedstock(feeding_freq=24, total_simtime=3) - assert fs.header() == Feedstock._header - np.testing.assert_allclose(fs.simtime(), np.array([0.0, 1.0, 2.0])) + np.testing.assert_allclose(fs.simtime, np.array([0.0, 1.0, 2.0]))