Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Importing Measured Data - Fed-Batch Bioreactor

The other gallery examples generate synthetic training data with generate_data() directly in the script. This notebook walks through training a hybrid model from real measurements (for example, a CSV exported from a lab notebook or process historian), before checking that the trained model makes physically feasible predictions under new operating conditions.

The system is the fed-batch bioreactor from Kantor (2021), with four states (biomass XX, product PP, substrate SS, and reactor volume VV):

dXdt=μX−FVXdPdt=Ypx μX−FXPdSdt=FV(Sf−S)−μXYxsdVdt=F\begin{align} \frac{dX}{dt} &= \mu X - \frac{F}{V}X \tag{1}\\ \frac{dP}{dt} &= Y_{px}\,\mu X - \frac{F}{X}P \tag{2}\\ \frac{dS}{dt} &= \frac{F}{V}(S_f - S) - \frac{\mu X}{Y_{xs}} \tag{3}\\ \frac{dV}{dt} &= F \tag{4} \end{align}

The unknown term is the specific growth rate μ\mu, which we replace with a neural network of the measured states:

μ(t)=fNN(X,P,S,V; θ)(5)\mu(t) = f_{NN}\big(X, P, S, V;\ \theta\big) \tag{5}

For this bioreactor the true kinetics happen to be the Monod law, μ=μmax S/(Ks+S)\mu = \mu_{max}\, S / (K_s + S). We deliberately treat that law as unknown and learn μ\mu from data instead, mirroring the realistic case where the growth kinetics of a strain have not been characterised. The Monod expression is kept only so we can sanity-check the learned rate at the end.

The notebook proceeds in two parts:

  1. Load measured data and train the hybrid model.

  2. Predict new batches at unseen initial conditions and confirm the trajectories stay physically feasible.

Let’s start with the required imports. Note pandas, which we use to read the measurement CSV.

Defining the problem

The system is a ProblemDefinition subclass implementing three methods: build_trajectory() writes the ODEs and fixes the initial charge, get_input_vars() returns the states that feed the network, and get_output_vars() returns the growth rate the network produces. Because we are treating the kinetics as unknown, the problem contains no expression for μ\mu since the network will predict it.

The mechanistic parameters (yields and feed rate). We also keep the true Monod constants Ks and mu_max. Note that the training workflow never touches them, but we draw the true Monod curve at the end to check the learned rate.

Step 1 - Load the measured data

Assume fedbatch_measurements.csv contains raw experimental data.

A ProblemDefinition expects two parallel lists, one entry per trajectory (batch):

attributetypeshape per batch
obs_timeslist of arrays(n_samples,)
obs_valueslist of arrays(n_samples, n_measured_states)

So we group the table by batch, sort by time, and pull the time column and the state columns out as NumPy arrays.

Loaded 3 batches
  batch 0: 31 samples, values shape (31, 4)
  batch 1: 31 samples, values shape (31, 4)
  batch 2: 31 samples, values shape (31, 4)

Then attach the measurements to a problem instance. The initial conditions of each batch is a known quantity set when starting the reactor. Thus, they are supplied directly through BATCH_ICS. Next, we set a training discretization domain as in the other example notebooks, again noting that the discretisation used for fitting need not match however the data was sampled.

A quick look at the raw measurements for each batch:

<Figure size 4800x900 with 4 Axes>

Step 2 - Define the Network and Train the Hybrid Model

The network maps the four states (X,P,S,VX, P, S, V) to the scalar growth rate μ\mu, via z(t)=μ(t)=fNN(X,P,S,V; θ)z(t) = \mu(t) = f_{NN}\big(X, P, S, V;\ \theta\big). As in the other examples we first solve the smoother (a data-fitting NLP with μ\mu free) to obtain smooth state and μ\mu trajectories, then pre-train the network on those pairs so the main solve starts from a sensible point.

For this example, we’ll train with the simultaneous approach. The bioreactor ODEs contain ratio terms (PX\frac{P}{X}, XV\frac{X}{V}) whose exact Hessian is awkward, so we use the grey-box variant with a limited-memory (L-BFGS) Hessian. See the Simultaneous Solver page for the trade-offs.

=== Building smoother for 3 trajectories (smooth_coef=10.0) ===
  Smoother: warning / maxIterations
/var/folders/bd/5y876_fs4td3chn71x256drh0000gn/T/ipykernel_41171/3841332699.py:60: UserWarning: HybridDAE smoother solve terminated with 'maxIterations' (not optimal)
  model.fit(problem, metrics=["rmse", "mse", "mae"])
=== Building simultaneous GBM model for 3 trajectories ===
=== Solving simultaneous GBM model (pounce, L-BFGS) ===
  pounce: ok / optimal
=== Simultaneous solve complete ===
=== Per Trajectory Metrics ===
RMSE: 
╒════════╤═══════════╤═══════════╤══════════╤══════════╤═══════════╕
│        │       x_0 │       x_1 │      x_2 │      x_3 │     NRMSE │
╞════════╪═══════════╪═══════════╪══════════╪══════════╪═══════════╡
│ traj_0 │ 0.0779451 │ 0.0446272 │ 0.476643 │ 0.103781 │ 0.0374063 │
├────────┼───────────┼───────────┼──────────┼──────────┼───────────┤
│ traj_1 │ 0.0554483 │ 0.044553  │ 0.515739 │ 0.125678 │ 0.0627455 │
├────────┼───────────┼───────────┼──────────┼──────────┼───────────┤
│ traj_2 │ 0.0702658 │ 0.0421831 │ 0.48064  │ 0.114207 │ 0.0439893 │
├────────┼───────────┼───────────┼──────────┼──────────┼───────────┤
│ mean   │ 0.0678864 │ 0.0437878 │ 0.491007 │ 0.114555 │ 0.0480471 │
╘════════╧═══════════╧═══════════╧══════════╧══════════╧═══════════╛
MSE: 
╒════════╤════════════╤════════════╤══════════╤═══════════╤════════════╕
│        │        x_0 │        x_1 │      x_2 │       x_3 │       NMSE │
╞════════╪════════════╪════════════╪══════════╪═══════════╪════════════╡
│ traj_0 │ 0.00607544 │ 0.00199158 │ 0.227188 │ 0.0107704 │ 0.00728384 │
├────────┼────────────┼────────────┼──────────┼───────────┼────────────┤
│ traj_1 │ 0.00307451 │ 0.00198497 │ 0.265987 │ 0.0157949 │ 0.0135653  │
├────────┼────────────┼────────────┼──────────┼───────────┼────────────┤
│ traj_2 │ 0.00493728 │ 0.00177942 │ 0.231014 │ 0.0130433 │ 0.00935167 │
├────────┼────────────┼────────────┼──────────┼───────────┼────────────┤
│ mean   │ 0.00469574 │ 0.00191866 │ 0.241397 │ 0.0132029 │ 0.0100669  │
╘════════╧════════════╧════════════╧══════════╧═══════════╧════════════╛
MAE: 
╒════════╤═══════════╤═══════════╤══════════╤═══════════╤═══════════╕
│        │       x_0 │       x_1 │      x_2 │       x_3 │      NMAE │
╞════════╪═══════════╪═══════════╪══════════╪═══════════╪═══════════╡
│ traj_0 │ 0.0629278 │ 0.0358349 │ 0.396103 │ 0.0898245 │ 0.0311567 │
├────────┼───────────┼───────────┼──────────┼───────────┼───────────┤
│ traj_1 │ 0.0450873 │ 0.0369768 │ 0.401074 │ 0.0972701 │ 0.0501327 │
├────────┼───────────┼───────────┼──────────┼───────────┼───────────┤
│ traj_2 │ 0.0506275 │ 0.0357364 │ 0.390106 │ 0.081136  │ 0.0341312 │
├────────┼───────────┼───────────┼──────────┼───────────┼───────────┤
│ mean   │ 0.0528808 │ 0.0361827 │ 0.395761 │ 0.0894102 │ 0.0384735 │
╘════════╧═══════════╧═══════════╧══════════╧═══════════╧═══════════╛

The fitted state trajectories should pass through the measurement data, and the learned μ\mu should track the Monod curve we set aside earlier:

<Figure size 4800x2700 with 12 Axes>
<Figure size 1500x1200 with 1 Axes>

Step 4 - Predict new batches under new operating conditions

The learned μ(⋅)\mu(\cdot) is now a fixed component embedded inside the mechanistic ODEs. To predict a new batch we build the same problem with a different initial charge and call solve_inference(), which holds the network fixed and solves the DAE forward.

Because the conservation equations are enforced as hard constraints and the states are declared non-negative, the predictions stay physically feasible (concentrations ≥0\geq 0, volume rising at the feed rate) even for initial conditions never seen in training.

As in previous examples, the discretization domain can be altered from that which was used for training.

=== Building inference model for 1 trajectories (slack_coef=1e-05) ===
=== Solving inference model ===
  Inference: ok / optimal
=== Building inference model for 1 trajectories (slack_coef=1e-05) ===
=== Solving inference model ===
  Inference: ok / optimal
IC [0.2, 0.0, 7.0, 0.9]:  min state = 0.000 (>= 0),  mu in [0.016, 0.190]
IC [0.4, 0.0, 6.5, 0.9]:  min state = 0.000 (>= 0),  mu in [0.017, 0.189]

The predicted trajectories for the two new batches, with non-negative concentrations and a steadily rising volume, and no measured data to lean on:

<Figure size 4800x900 with 4 Axes>

(Bonus) Step 5 - Compare to Ground Truth

To demonstrate the accuracy of the trained HybridDAE, we will compare the new operating condition prediction to the ground truth since we know the underlying dynamics for this example.

generate_data: ok / optimal
=== Building inference model for 1 trajectories (slack_coef=1e-05) ===
=== Solving inference model ===
  Inference: ok / optimal
<Figure size 5400x960 with 5 Axes>
generate_data: ok / optimal
=== Building inference model for 1 trajectories (slack_coef=1e-05) ===
=== Solving inference model ===
  Inference: ok / optimal
<Figure size 5400x960 with 5 Axes>

Summary

This example walked through the bring-your-own-data workflow end to end:

  • measurements live in a plain CSV and become obs_times / obs_values with a few lines of pandas, with no synthetic data generation and no ground-truth kinetics in the problem.

  • the known initial charges are supplied directly, and the time series drives the fit.

  • once trained, the hybrid model predicts unseen batches with solve_inference() while respecting the mechanistic conservation laws, keeping the predictions physically feasible.

Lueg et al. (2025)

References
  1. Kantor, J. (2021). Fed-batch Bioreactor (cbe30338 chemical process control notebook). https://github.com/jckantor/CBE30338/blob/master/notebooks/02.07-Fed-Batch-Bioreactor.ipynb
  2. Lueg, L. R., Alves, V., Schicksnus, D., Kitchin, J. R., Laird, C. D., & Biegler, L. T. (2025). A simultaneous approach for training neural differential-algebraic systems of equations. arXiv Preprint arXiv:2504.04665. https://arxiv.org/abs/2504.04665