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.
+ [](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.
+ [](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
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
+[](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
+[](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
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]))