I’ve been playing around with the relatively new Dust LLM and decided to have it work through bicarbonate pH buffering in an aquarium. At equilibrium, the pH in an aquarium is determined by only two factors: the concentration of CO2 in the air over the water’s surface Note 1, and the alkalinity (KH) of the water. Note that the pH of the water has no effect on how much CO2 is dissolved in the water. It took a lot of iterations to get a final result, so let’s get right to the good stuff:
Aquarium pH as a function of CO2 and KH

The chart shows the equilibrium pH of the water for specified values of alkalinity (KH) measured in ppm CaCO3 equivalents and either the atmospheric CO2 concentration in ppm, or the directly related concentration of CO2 dissolved in the water. To convert from dKH to ppm, you would multiply the dKH value by 17.86. For those injecting CO2 gas in a high-tech setup, you can use the listed dissolved CO2 concentration as you might determine for example by examining the colour of a drop checker.
As you might expect, increasing the concentration of dissolved CO2 decreases the pH, and increasing the concentration of KH increases the pH. You can see that there is a relatively big difference between not having any KH (0 ppm) and having even just a little bit (1 ppm). It doesn’t take much alkalinity to be a really good pH buffer in water. This is also how your blood pH is kept mostly stable.
Real world application
Cambridgeshire tap water has KH of a little over 280 ppm so in a low-tech setup with inert substrate the pH is going be around 8.5. This is pretty much what Shimphaus used to run at. This pH is on the high side for neocaridina shrimp, and much too high for caridina shrimp. The way I sort this situation now is to decrease the alkalinity of the tap water by adding a measured amount of moderately concentrated hydrochloric acid (HCl). I am current running the Shrimphaus at a KH of 15 ppm which will keep the pH below 7.4. Both neocaridina and caridina shrimp seem happy (or at least happier) under these conditions, with successful breeding of both species.
First principles derivation of pH as a function of alkalinity and CO2 concentration
Here is a first principles derivation of the equation for pH in bicarbonate buffered water. The assumption is fresh water at 20C (there is a small temperature dependence). We use composite carbonic acid H2CO3* to represent dissolved CO2. This is important to get the equilibrium constant for K₁ correct, but you can just think of it as ‘dissolved CO2‘.
EQUILIBRIUM CONSTANTS
- H₂O ⇌ H⁺ + OH⁻
- Kw = [H⁺][OH⁻] = 6.81 × 10⁻¹⁵
- H₂CO₃* ⇌ H⁺ + HCO₃⁻
- K₁ = [H⁺][HCO₃⁻]/[H₂CO₃*] = 4.16 × 10⁻⁷
- HCO₃⁻ ⇌ H⁺ + CO₃²⁻
- K₂ = [H⁺][CO₃²⁻]/[HCO₃⁻] = 4.69 × 10⁻¹¹
SPECIES EXPRESSIONS
- [OH⁻] = Kw/[H⁺]
- [HCO₃⁻] = K₁[H₂CO₃*]/[H⁺]
- [CO₃²⁻] = K₂[HCO₃⁻]/[H⁺] = K₁K₂[H₂CO₃*]/[H⁺]²
CHARGE BALANCE
- [H⁺] + [KH] = [OH⁻] + [HCO₃⁻] + 2[CO₃²⁻]
SUBSTITUTE EXPRESSIONS
- [H⁺] + [KH] = Kw/[H⁺] + K₁[H₂CO₃*]/[H⁺] + 2K₁K₂[H₂CO₃*]/[H⁺]²
MULTIPLY ALL TERMS BY [H⁺]²
- [H⁺]³ + [KH][H⁺]² = Kw[H⁺] + K₁[H₂CO₃*][H⁺] + 2K₁K₂[H₂CO₃*]
REARRANGE TO STANDARD FORM
- [H⁺]³ + [KH][H⁺]² – (K₁[H₂CO₃*] + Kw)[H⁺] – 2K₁K₂[H₂CO₃*] = 0
ADD UNIT CONVERSIONS
- [H⁺]³ + (KH/100090)[H⁺]² – (K₁H₂CO₃*/44010 + Kw)[H⁺] – 2K₁K₂(H₂CO₃*/44010) = 0
The end result is a cubic equation for [H+] which you can solve by a variety of methods. There is provably always only one real root solution to this cubic. pH can be calculated from [H+] by pH = -log10([H+])
Python code to solve for pH given input KH and CO2 values
Here is some python code you can run to generate the table shown above. You can put in whichever KH and H2CO3* values you like.
import numpy as np import pandas as pd def calculate_ph(KH, H2CO3): """ Calculate pH given KH (ppm as CaCO3) and H2CO3* (ppm as CO2) at 20°C """ # Constants at 20°C K1 = 4.16e-7 # First dissociation constant K2 = 4.69e-11 # Second dissociation constant Kw = 6.81e-15 # Water dissociation constant # Convert ppm to mol/L KH_mol = KH/100090 # CaCO3: 100.09 g/mol H2CO3_mol = H2CO3/44010 # CO2: 44.01 g/mol # Cubic equation coefficients a = 1.0 # [H⁺]³ term b = KH_mol # [H⁺]² term c = -(K1*H2CO3_mol + Kw) # [H⁺] term d = -(2*K1*K2*H2CO3_mol) # constant term # Solve cubic equation solutions = np.roots([a, b, c, d]) # Find the real, positive root h_conc = np.real(solutions[(np.imag(solutions) == 0) & (np.real(solutions) > 0)])[0] # Calculate pH pH = -np.log10(h_conc) return pH # Define test values KH_values = [0, 1, 2, 5, 10, 15, 50, 100, 150, 200, 250, 300, 350] # ppm as CaCO3 H2CO3_values = [0.6, 1, 3, 5, 10, 30] # ppm as CO2 # Create results table results = [] for kh in KH_values: row = [] for h2co3 in H2CO3_values: pH = calculate_ph(kh, h2co3) row.append(f"{pH:.2f}") results.append(row) # Create and display DataFrame df = pd.DataFrame(results, columns=[f'H2CO3*={h2co3}' for h2co3 in H2CO3_values], index=[f'KH={kh}' for kh in KH_values]) print("\npH values at 20°C:") print(df)
For reasons I don’t really understand, the LLM could either generate the cubic equation from first principles, or it could solve the cubic equation, but it could not reliably do both at the same time. Turns out it was much more robust to have the LLM output the python code to solve the cubic, then run the python code elsewhere and import the python-generated solution table back into the LLM to make the pretty heatmap.
Note 1. CO2 injection is a non-equilibrium method, but is functionally equivalent to an equilibration between water and a CO2 enriched atmosphere over the water surface. You can inject CO2 with a diffuser to generate a lot of bubbles to get the steady-state concentration of CO2 in the water up to 30 ppm, or you could equivalently have enriched the headspace over the water to 20000 ppm CO2 without using any CO2 bubbled through the water which would also get you to 30 ppm dissolved CO2.