Skip to content

API reference

Auto-generated from the source docstrings.

Indices

cooling_tower_chem.indices

Water-stability and corrosion indices for cooling-tower and process water.

All indices use the classical water-chemistry conventions:

  • Calcium hardness and total alkalinity are expressed in mg/L as CaCO3.
  • Total dissolved solids (TDS) is in mg/L.
  • Temperature is in degrees Celsius.
  • Chloride and sulfate are in mg/L (as the ion).

The saturation pH (ph_of_saturation) uses the standard analytical form of Langelier's equation::

pHs = (9.3 + A + B) - (C + D)
A = (log10(TDS) - 1) / 10
B = -13.12 * log10(T_kelvin) + 34.55
C = log10(calcium_hardness_as_CaCO3) - 0.4
D = log10(total_alkalinity_as_CaCO3)
References
  • Langelier, W. F. (1936). "The Analytical Control of Anti-Corrosion Water Treatment." J. AWWA 28(10), 1500-1521.
  • Ryznar, J. W. (1944). "A New Index for Determining Amount of Calcium Carbonate Scale Formed by a Water." J. AWWA 36(4), 472-483.
  • Puckorius, P. R. & Brooke, J. M. (1991). "A New Practical Index for Calcium Carbonate Scale Prediction in Cooling Tower Systems." Corrosion 47(4).
  • Larson, T. E. & Skold, R. V. (1958). "Laboratory Studies Relating Mineral Quality of Water to Corrosion of Steel and Cast Iron." Corrosion 14(6).

ph_of_saturation

ph_of_saturation(temperature_c: float, tds: float, calcium_hardness: float, total_alkalinity: float) -> float

Return the pH of calcium-carbonate saturation (pHs).

Parameters:

Name Type Description Default
temperature_c float

Water temperature in degrees Celsius.

required
tds float

Total dissolved solids in mg/L. If you only have conductivity, convert it first with :func:cooling_tower_chem.balance.tds_from_conductivity.

required
calcium_hardness float

Calcium hardness in mg/L as CaCO3.

required
total_alkalinity float

Total alkalinity in mg/L as CaCO3.

required

Returns:

Type Description
float

The saturation pH. LSI = pH - pHs and RSI = 2*pHs - pH.

Source code in src/cooling_tower_chem/indices.py
def ph_of_saturation(
    temperature_c: float,
    tds: float,
    calcium_hardness: float,
    total_alkalinity: float,
) -> float:
    """Return the pH of calcium-carbonate saturation (``pHs``).

    Parameters
    ----------
    temperature_c:
        Water temperature in degrees Celsius.
    tds:
        Total dissolved solids in mg/L. If you only have conductivity, convert
        it first with :func:`cooling_tower_chem.balance.tds_from_conductivity`.
    calcium_hardness:
        Calcium hardness in mg/L **as CaCO3**.
    total_alkalinity:
        Total alkalinity in mg/L **as CaCO3**.

    Returns
    -------
    float
        The saturation pH. ``LSI = pH - pHs`` and ``RSI = 2*pHs - pH``.
    """
    temperature_c = _require_temperature(temperature_c)
    tds = _require_positive("tds", tds)
    calcium_hardness = _require_positive("calcium_hardness", calcium_hardness)
    total_alkalinity = _require_positive("total_alkalinity", total_alkalinity)

    a = (math.log10(tds) - 1.0) / 10.0
    b = -13.12 * math.log10(temperature_c + 273.15) + 34.55
    c = math.log10(calcium_hardness) - 0.4
    d = math.log10(total_alkalinity)
    return (9.3 + a + b) - (c + d)

langelier_saturation_index

langelier_saturation_index(ph: float, temperature_c: float, tds: float, calcium_hardness: float, total_alkalinity: float) -> float

Langelier Saturation Index (LSI): pH - pHs.

  • LSI > 0 — water is supersaturated; calcium carbonate tends to precipitate (scaling).
  • LSI = 0 — water is at equilibrium.
  • LSI < 0 — water is undersaturated; it tends to dissolve calcium carbonate (corrosive / aggressive).

For most cooling-tower programs a small positive LSI (roughly 0 to +1) is targeted to lay a thin protective scale without fouling.

Source code in src/cooling_tower_chem/indices.py
def langelier_saturation_index(
    ph: float,
    temperature_c: float,
    tds: float,
    calcium_hardness: float,
    total_alkalinity: float,
) -> float:
    """Langelier Saturation Index (LSI): ``pH - pHs``.

    * ``LSI > 0`` — water is supersaturated; calcium carbonate tends to
      precipitate (scaling).
    * ``LSI = 0`` — water is at equilibrium.
    * ``LSI < 0`` — water is undersaturated; it tends to dissolve calcium
      carbonate (corrosive / aggressive).

    For most cooling-tower programs a small positive LSI (roughly ``0`` to
    ``+1``) is targeted to lay a thin protective scale without fouling.
    """
    phs = ph_of_saturation(temperature_c, tds, calcium_hardness, total_alkalinity)
    return float(ph) - phs

ryznar_stability_index

ryznar_stability_index(ph: float, temperature_c: float, tds: float, calcium_hardness: float, total_alkalinity: float) -> float

Ryznar Stability Index (RSI): 2*pHs - pH.

The RSI is always positive and is read on an empirical scale:

  • RSI < 6 — scale-forming;
  • 6 <= RSI <= 7 — approximately balanced;
  • RSI > 7 — corrosive (increasingly so above ~8).
Source code in src/cooling_tower_chem/indices.py
def ryznar_stability_index(
    ph: float,
    temperature_c: float,
    tds: float,
    calcium_hardness: float,
    total_alkalinity: float,
) -> float:
    """Ryznar Stability Index (RSI): ``2*pHs - pH``.

    The RSI is always positive and is read on an empirical scale:

    * ``RSI < 6``   — scale-forming;
    * ``6 <= RSI <= 7`` — approximately balanced;
    * ``RSI > 7``   — corrosive (increasingly so above ~8).
    """
    phs = ph_of_saturation(temperature_c, tds, calcium_hardness, total_alkalinity)
    return 2.0 * phs - float(ph)

puckorius_scaling_index

puckorius_scaling_index(temperature_c: float, tds: float, calcium_hardness: float, total_alkalinity: float) -> float

Puckorius (Practical) Scaling Index (PSI): 2*pHs - pH_eq.

The PSI replaces the measured pH of the RSI with an equilibrium pH driven only by alkalinity, pH_eq = 1.465 * log10(total_alkalinity) + 4.54. This makes it a better predictor than LSI/RSI for the highly buffered, recirculating water typical of cooling towers, where measured pH can swing without changing scaling potential.

Interpreted on the same empirical scale as the RSI (< 6 scaling, 6-7 balanced, > 7 corrosive). Note the PSI does not take a measured pH argument by design.

Source code in src/cooling_tower_chem/indices.py
def puckorius_scaling_index(
    temperature_c: float,
    tds: float,
    calcium_hardness: float,
    total_alkalinity: float,
) -> float:
    """Puckorius (Practical) Scaling Index (PSI): ``2*pHs - pH_eq``.

    The PSI replaces the measured pH of the RSI with an *equilibrium* pH driven
    only by alkalinity, ``pH_eq = 1.465 * log10(total_alkalinity) + 4.54``. This
    makes it a better predictor than LSI/RSI for the highly buffered,
    recirculating water typical of cooling towers, where measured pH can swing
    without changing scaling potential.

    Interpreted on the same empirical scale as the RSI (``< 6`` scaling,
    ``6-7`` balanced, ``> 7`` corrosive). Note the PSI does not take a measured
    pH argument by design.
    """
    total_alkalinity = _require_positive("total_alkalinity", total_alkalinity)
    phs = ph_of_saturation(temperature_c, tds, calcium_hardness, total_alkalinity)
    ph_eq = 1.465 * math.log10(total_alkalinity) + 4.54
    return 2.0 * phs - ph_eq

larson_skold_index

larson_skold_index(chloride: float, sulfate: float, total_alkalinity: float) -> float

Larson-Skold index: corrosivity of water toward mild steel.

Defined as the equivalents ratio (Cl- + SO4(2-)) / (HCO3- + CO3(2-)). Alkalinity is supplied here as mg/L as CaCO3 and converted to equivalents internally.

  • < 0.8 — chlorides and sulfates are unlikely to interfere with a protective film;
  • 0.8 - 1.2 — chlorides and sulfates may increase corrosion rates;
  • > 1.2 — high corrosion rates on mild steel are expected.

Parameters:

Name Type Description Default
chloride float

Concentrations in mg/L (as the ion).

required
sulfate float

Concentrations in mg/L (as the ion).

required
total_alkalinity float

Total alkalinity in mg/L as CaCO3 (must be > 0).

required
Source code in src/cooling_tower_chem/indices.py
def larson_skold_index(
    chloride: float,
    sulfate: float,
    total_alkalinity: float,
) -> float:
    """Larson-Skold index: corrosivity of water toward mild steel.

    Defined as the equivalents ratio
    ``(Cl- + SO4(2-)) / (HCO3- + CO3(2-))``. Alkalinity is supplied here as
    mg/L **as CaCO3** and converted to equivalents internally.

    * ``< 0.8`` — chlorides and sulfates are unlikely to interfere with a
      protective film;
    * ``0.8 - 1.2`` — chlorides and sulfates may increase corrosion rates;
    * ``> 1.2`` — high corrosion rates on mild steel are expected.

    Parameters
    ----------
    chloride, sulfate:
        Concentrations in mg/L (as the ion).
    total_alkalinity:
        Total alkalinity in mg/L as CaCO3 (must be > 0).
    """
    if chloride is None or not math.isfinite(chloride) or chloride < 0:
        raise ValueError(f"chloride must be a non-negative number, got {chloride!r}")
    if sulfate is None or not math.isfinite(sulfate) or sulfate < 0:
        raise ValueError(f"sulfate must be a non-negative number, got {sulfate!r}")
    total_alkalinity = _require_positive("total_alkalinity", total_alkalinity)

    epm_chloride = chloride / _EQ_WEIGHT_CHLORIDE
    epm_sulfate = sulfate / _EQ_WEIGHT_SULFATE
    epm_alkalinity = total_alkalinity / _EQ_WEIGHT_CACO3
    return (epm_chloride + epm_sulfate) / epm_alkalinity

stiff_davis_ph_of_saturation

stiff_davis_ph_of_saturation(temperature_c: float, calcium_hardness: float, total_alkalinity: float, ionic_strength: float) -> float

Stiff-Davis pH of saturation: pHs = pCa + pAlk + K.

pCa and pAlk are molar negative logs, derived from the CaCO3-basis inputs ([Ca] = hardness / 100086.9 mol/L, [Alk] = alkalinity / 50043.45 eq/L). K comes from :func:_stiff_davis_k.

Source code in src/cooling_tower_chem/indices.py
def stiff_davis_ph_of_saturation(
    temperature_c: float,
    calcium_hardness: float,
    total_alkalinity: float,
    ionic_strength: float,
) -> float:
    """Stiff-Davis pH of saturation: ``pHs = pCa + pAlk + K``.

    ``pCa`` and ``pAlk`` are molar negative logs, derived from the CaCO3-basis
    inputs (``[Ca] = hardness / 100086.9`` mol/L, ``[Alk] = alkalinity / 50043.45``
    eq/L). ``K`` comes from :func:`_stiff_davis_k`.
    """
    calcium_hardness = _require_positive("calcium_hardness", calcium_hardness)
    total_alkalinity = _require_positive("total_alkalinity", total_alkalinity)
    p_ca = -math.log10(calcium_hardness / _CACO3_MOLAR_MASS_MG)
    p_alk = -math.log10(total_alkalinity / _CACO3_EQUIV_MG)
    return p_ca + p_alk + _stiff_davis_k(ionic_strength, temperature_c)

stiff_davis_index

stiff_davis_index(ph: float, temperature_c: float, calcium_hardness: float, total_alkalinity: float, tds: float | None = None, ionic_strength: float | None = None) -> float

Stiff-Davis Stability Index (S&DSI): pH - pHs, for high-salinity water.

The Langelier/Ryznar indices lose accuracy at high ionic strength (brines, seawater, concentrated blowdown). The Stiff-Davis index corrects for this by replacing the LSI temperature/TDS terms with an ionic-strength-dependent constant K. Read like the LSI: > 0 scale-forming, < 0 corrosive.

Provide the ionic strength directly (mol/L), or tds (mg/L) to estimate it via I = 2.5e-5 * TDS. Hardness and alkalinity are mg/L as CaCO3.

.. note:: S&DSI is intended for the high-salinity regime (roughly TDS > 10,000 mg/L, ionic strength >= ~0.05 mol/L). The K curve fit is unreliable at very low ionic strength; use :func:langelier_saturation_index for low-salinity water instead.

References: Stiff & Davis (1952); ASTM D4582; USBR (2013).

Source code in src/cooling_tower_chem/indices.py
def stiff_davis_index(
    ph: float,
    temperature_c: float,
    calcium_hardness: float,
    total_alkalinity: float,
    tds: float | None = None,
    ionic_strength: float | None = None,
) -> float:
    """Stiff-Davis Stability Index (S&DSI): ``pH - pHs``, for high-salinity water.

    The Langelier/Ryznar indices lose accuracy at high ionic strength (brines,
    seawater, concentrated blowdown). The Stiff-Davis index corrects for this by
    replacing the LSI temperature/TDS terms with an ionic-strength-dependent
    constant ``K``. Read like the LSI: ``> 0`` scale-forming, ``< 0`` corrosive.

    Provide the ionic strength directly (mol/L), or ``tds`` (mg/L) to estimate it
    via ``I = 2.5e-5 * TDS``. Hardness and alkalinity are mg/L as CaCO3.

    .. note::
        S&DSI is intended for the high-salinity regime (roughly TDS > 10,000 mg/L,
        ionic strength >= ~0.05 mol/L). The ``K`` curve fit is unreliable at very
        low ionic strength; use :func:`langelier_saturation_index` for
        low-salinity water instead.

    References: Stiff & Davis (1952); ASTM D4582; USBR (2013).
    """
    if ionic_strength is None:
        if tds is None:
            raise ValueError("provide either tds or ionic_strength")
        ionic_strength = ionic_strength_from_tds(tds)
    phs = stiff_davis_ph_of_saturation(
        temperature_c, calcium_hardness, total_alkalinity, ionic_strength
    )
    return float(ph) - phs

aggressiveness_index

aggressiveness_index(ph: float, calcium_hardness: float, total_alkalinity: float) -> float

AWWA Aggressiveness Index (AI): pH + log10(calcium_hardness * alkalinity).

Originally standardized (AWWA C400) for asbestos-cement pipe, the AI is a simplified, temperature-independent corrosivity screen:

  • AI >= 12 — non-aggressive;
  • 10 <= AI < 12 — moderately aggressive;
  • AI < 10 — highly aggressive.

Hardness and alkalinity are in mg/L as CaCO3.

Source code in src/cooling_tower_chem/indices.py
def aggressiveness_index(
    ph: float,
    calcium_hardness: float,
    total_alkalinity: float,
) -> float:
    """AWWA Aggressiveness Index (AI): ``pH + log10(calcium_hardness * alkalinity)``.

    Originally standardized (AWWA C400) for asbestos-cement pipe, the AI is a
    simplified, temperature-independent corrosivity screen:

    * ``AI >= 12`` — non-aggressive;
    * ``10 <= AI < 12`` — moderately aggressive;
    * ``AI < 10``  — highly aggressive.

    Hardness and alkalinity are in mg/L as CaCO3.
    """
    calcium_hardness = _require_positive("calcium_hardness", calcium_hardness)
    total_alkalinity = _require_positive("total_alkalinity", total_alkalinity)
    return float(ph) + math.log10(calcium_hardness * total_alkalinity)

CCPP (advanced)

cooling_tower_chem.advanced

Quantitative closed-system carbonate chemistry: the CaCO3 precipitation potential.

The saturation indices in :mod:cooling_tower_chem.indices (LSI, RSI, S&DSI, ...) report a direction — whether water tends to scale or corrode — but not how much calcium carbonate is involved. The Calcium Carbonate Precipitation Potential (CCPP) closes that gap: it is the mass of CaCO3, in mg/L as CaCO3, that must precipitate (positive) or dissolve (negative) to bring the water to exact calcite saturation (saturation index SI = 0).

Method

There is no closed form in the general case, so the equilibrium is solved iteratively. The water is treated as a closed system: as CaCO3 is deposited or dissolved the total (carbonate) alkalinity and the CO2-acidity are held conservative. Removing one mole of CaCO3 removes one mole of calcium, one mole of total carbonate, and two equivalents of alkalinity; the CO2-acidity 2*CT - Alk is therefore unchanged. The equilibrium calcium, carbonate and pH that satisfy [Ca][CO3] = Ksp are found by a bracketed root search, and

CCPP (mg/L as CaCO3) = x * 100086.9      (x = mol/L of CaCO3 exchanged)

Thermodynamic equilibrium constants are the Plummer & Busenberg (1982) fits (T in kelvin); at 25 C they give pK1 = 6.352, pK2 = 10.329, pK_sp(calcite) = 8.480 and log Kw = -13.995. They are converted to conditional (concentration) constants with single-ion activity coefficients from the Davies equation, log gamma = -A z^2 (sqrt(I)/(1 + sqrt(I)) - 0.3 I) with A = 0.509 (its 25 C value), valid to an ionic strength of about 0.5 mol/L.

References
  • Plummer, L. N. & Busenberg, E. (1982). "The solubilities of calcite, aragonite and vaterite in CO2-H2O solutions between 0 and 90 C." Geochim. Cosmochim. Acta 46(6), 1011-1040.
  • Wojtowicz, J. A. (2001). "The Calcium Carbonate Precipitation Potential (CCPP) and its Use in Pool Water Balance." J. Swimming Pool & Spa Industry 2(2), 23-29.
  • Rossum, J. R. & Merrill, D. T. (1983). "An Evaluation of the Calcium Carbonate Saturation Indexes." J. AWWA 75(1), 95-100.
  • Standard Methods for the Examination of Water and Wastewater, Method 2330 (Calcium Carbonate Saturation).
  • Tang, C. et al. (2021). "Prediction of Calcium Carbonate Precipitation Potential." Water 13(1), 42.

calcium_carbonate_precipitation_potential

calcium_carbonate_precipitation_potential(ph: float, temperature_c: float, calcium_hardness: float, total_alkalinity: float, tds: float | None = None, ionic_strength: float | None = None) -> float

Calcium Carbonate Precipitation Potential (CCPP), in mg/L as CaCO3.

The signed mass of calcium carbonate that must precipitate (positive) or dissolve (negative) to bring the water to exact calcite saturation (SI = 0), computed for a closed system in which the total alkalinity and CO2-acidity are conserved as CaCO3 is exchanged (see the module docstring for the method and the Plummer & Busenberg / Wojtowicz references).

Provide the ionic strength directly (ionic_strength, mol/L) or a tds (mg/L) from which it is estimated as I = 2.5e-5 * TDS via :func:cooling_tower_chem.balance.ionic_strength_from_tds.

Parameters:

Name Type Description Default
ph float

Measured pH of the water (treated as -log10 of the H+ activity).

required
temperature_c float

Water temperature in degrees Celsius.

required
calcium_hardness float

Calcium hardness in mg/L as CaCO3 (must be > 0).

required
total_alkalinity float

Total (carbonate) alkalinity in mg/L as CaCO3 (must be > 0).

required
tds float | None

Total dissolved solids in mg/L, used to estimate ionic strength when ionic_strength is not given.

None
ionic_strength float | None

Ionic strength in mol/L. Takes precedence over tds when both are given; the Davies model it feeds is valid to about 0.5 mol/L.

None

Returns:

Type Description
float

CCPP in mg/L as CaCO3: > 0 scale-forming (calcite will precipitate), < 0 aggressive (calcite will dissolve), ~ 0 at saturation.

Notes

This is a screening estimate, not a substitute for a full speciation model. Its magnitude is dominated by the activity correction, so it is sensitive to the ionic strength: the I = 2.5e-5 * TDS estimate is rough, and the dependency-free Davies model carries no ion pairing (e.g. CaHCO3+, CaCO3-aq), so it tends to over-predict precipitation for hard, high-TDS water relative to an ion-pairing model such as PHREEQC. Supply a measured or model-derived ionic_strength when accuracy matters. The Davies A is held at its 25 C value (0.509); the closed-system assumption (no CO2 exchange with the atmosphere) suits a snapshot of a recirculating loop rather than an aerated basin.

Raises:

Type Description
ValueError

If an input is non-finite or non-physical, or if neither tds nor ionic_strength is provided.

Source code in src/cooling_tower_chem/advanced.py
def calcium_carbonate_precipitation_potential(
    ph: float,
    temperature_c: float,
    calcium_hardness: float,
    total_alkalinity: float,
    tds: float | None = None,
    ionic_strength: float | None = None,
) -> float:
    """Calcium Carbonate Precipitation Potential (CCPP), in mg/L as CaCO3.

    The signed mass of calcium carbonate that must **precipitate** (positive) or
    **dissolve** (negative) to bring the water to exact calcite saturation
    (``SI = 0``), computed for a closed system in which the total alkalinity and
    CO2-acidity are conserved as CaCO3 is exchanged (see the module docstring for
    the method and the Plummer & Busenberg / Wojtowicz references).

    Provide the ionic strength directly (``ionic_strength``, mol/L) or a ``tds``
    (mg/L) from which it is estimated as ``I = 2.5e-5 * TDS`` via
    :func:`cooling_tower_chem.balance.ionic_strength_from_tds`.

    Parameters
    ----------
    ph:
        Measured pH of the water (treated as ``-log10`` of the H+ *activity*).
    temperature_c:
        Water temperature in degrees Celsius.
    calcium_hardness:
        Calcium hardness in mg/L **as CaCO3** (must be > 0).
    total_alkalinity:
        Total (carbonate) alkalinity in mg/L **as CaCO3** (must be > 0).
    tds:
        Total dissolved solids in mg/L, used to estimate ionic strength when
        ``ionic_strength`` is not given.
    ionic_strength:
        Ionic strength in mol/L. Takes precedence over ``tds`` when both are
        given; the Davies model it feeds is valid to about 0.5 mol/L.

    Returns
    -------
    float
        CCPP in mg/L as CaCO3: ``> 0`` scale-forming (calcite will precipitate),
        ``< 0`` aggressive (calcite will dissolve), ``~ 0`` at saturation.

    Notes
    -----
    This is a screening estimate, not a substitute for a full speciation model.
    Its magnitude is dominated by the activity correction, so it is sensitive to
    the ionic strength: the ``I = 2.5e-5 * TDS`` estimate is rough, and the
    dependency-free Davies model carries no ion pairing (e.g. CaHCO3+, CaCO3-aq),
    so it tends to over-predict precipitation for hard, high-TDS water relative to
    an ion-pairing model such as PHREEQC. Supply a measured or model-derived
    ``ionic_strength`` when accuracy matters. The Davies ``A`` is held at its
    25 C value (0.509); the closed-system assumption (no CO2 exchange with the
    atmosphere) suits a snapshot of a recirculating loop rather than an aerated
    basin.

    Raises
    ------
    ValueError
        If an input is non-finite or non-physical, or if neither ``tds`` nor
        ``ionic_strength`` is provided.
    """
    ph = _require_finite("ph", ph)
    temperature_c = _require_temperature(temperature_c)
    calcium_hardness = _require_positive("calcium_hardness", calcium_hardness)
    total_alkalinity = _require_positive("total_alkalinity", total_alkalinity)
    if ionic_strength is None:
        if tds is None:
            raise ValueError("provide either tds or ionic_strength")
        ionic_strength = ionic_strength_from_tds(tds)
    else:
        ionic_strength = _require_positive("ionic_strength", ionic_strength)

    k1, k2, ksp, kw = _plummer_busenberg_constants(temperature_c)
    gamma_1, gamma_2 = _davies_activity_coefficients(ionic_strength)
    # Conditional (concentration-based) constants; H+ is carried as an activity,
    # so pH = -log10(a_H+) and the carbonate/hydroxide species are concentrations.
    ck1 = k1 / gamma_1
    ck2 = k2 * gamma_1 / gamma_2
    cksp = ksp / (gamma_2 * gamma_2)
    ckw = kw / gamma_1

    def _alpha1_alpha2(h: float) -> tuple[float, float]:
        denom = h * h + h * ck1 + ck1 * ck2
        return h * ck1 / denom, ck1 * ck2 / denom

    def _alkalinity(h: float, ct: float) -> float:
        alpha_1, alpha_2 = _alpha1_alpha2(h)
        return ct * (alpha_1 + 2.0 * alpha_2) + ckw / h - h / gamma_1

    def _equilibrium_h(ct: float, alk_target: float) -> float:
        # Alkalinity strictly decreases with [H+]; bracket pH 0..14 and bisect
        # geometrically (i.e. linearly in pH).
        low, high = 1e-14, 1.0
        for _ in range(200):
            mid = math.sqrt(low * high)
            if _alkalinity(mid, ct) > alk_target:
                low = mid
            else:
                high = mid
            if high - low < 1e-18:
                break
        return math.sqrt(low * high)

    h_initial = 10.0 ** (-ph)
    alpha1_i, alpha2_i = _alpha1_alpha2(h_initial)
    alk_total = total_alkalinity / _CACO3_EQUIV_MG        # eq/L
    ca_total = calcium_hardness / _CACO3_MOLAR_MASS_MG      # mol/L
    ct_initial = (alk_total - ckw / h_initial + h_initial / gamma_1) / (
        alpha1_i + 2.0 * alpha2_i
    )

    def _saturation_gap(x: float) -> float:
        # x = mol/L of CaCO3 precipitated (>0) or dissolved (<0). Removing x
        # removes x from Ca and from total carbonate and 2x from alkalinity.
        ct_eq = ct_initial - x
        h_eq = _equilibrium_h(ct_eq, alk_total - 2.0 * x)
        _, alpha2_eq = _alpha1_alpha2(h_eq)
        return (ca_total - x) * ct_eq * alpha2_eq - cksp

    # The saturation gap decreases monotonically in x. Bracket the root.
    if _saturation_gap(0.0) >= 0.0:
        # Supersaturated: precipitate, 0 < x < min(Ca, CT).
        low, high = 0.0, min(ca_total, ct_initial) * (1.0 - 1e-12)
    else:
        # Undersaturated: dissolve, x < 0. Grow the bracket until the gap turns.
        low, high = -1e-9, 0.0
        for _ in range(200):
            if _saturation_gap(low) > 0.0:
                break
            low *= 2.0
        else:  # pragma: no cover - unreachable for physical inputs
            raise ValueError("could not bracket the calcite equilibrium state")

    for _ in range(200):
        mid = 0.5 * (low + high)
        if _saturation_gap(mid) > 0.0:
            low = mid
        else:
            high = mid
        if high - low < 1e-18:
            break
    x = 0.5 * (low + high)
    return x * _CACO3_MOLAR_MASS_MG

Water balance

cooling_tower_chem.balance

Cooling-tower water balance: cycles of concentration and evaporation/blowdown.

These helpers relate the four water streams of an evaporative cooling tower:

  • Evaporation (E) — pure water lost to the air; it concentrates dissolved solids in the remaining water.
  • Drift / windage (D) — liquid droplets carried out in the air stream.
  • Blowdown / bleed (B) — water deliberately discharged to cap the dissolved solids concentration.
  • Makeup (M) — fresh water added to replace all losses: M = E + B + D.

Cycles of concentration (CoC) is the ratio of a conservative species' concentration in the recirculating water to its concentration in the makeup water, and equals M / (B + D).

All flow rates share whatever volumetric unit you pass in (e.g. m3/h or gpm); the functions only take ratios and sums, so the result carries the same unit.

tds_from_conductivity

tds_from_conductivity(conductivity_us_cm: float, factor: float = DEFAULT_TDS_FACTOR) -> float

Estimate TDS (mg/L) from electrical conductivity (uS/cm).

TDS ~= factor * conductivity. The factor depends on the ionic makeup of the water and typically ranges 0.55-0.70 for cooling-tower water; 0.65 is a common default. Calibrate against a lab TDS when accuracy matters.

Source code in src/cooling_tower_chem/balance.py
def tds_from_conductivity(
    conductivity_us_cm: float, factor: float = DEFAULT_TDS_FACTOR
) -> float:
    """Estimate TDS (mg/L) from electrical conductivity (uS/cm).

    ``TDS ~= factor * conductivity``. The factor depends on the ionic makeup of
    the water and typically ranges 0.55-0.70 for cooling-tower water; 0.65 is a
    common default. Calibrate against a lab TDS when accuracy matters.
    """
    conductivity_us_cm = _require_positive("conductivity_us_cm", conductivity_us_cm)
    factor = _require_positive("factor", factor)
    return conductivity_us_cm * factor

conductivity_from_tds

conductivity_from_tds(tds: float, factor: float = DEFAULT_TDS_FACTOR) -> float

Inverse of :func:tds_from_conductivity: estimate uS/cm from mg/L TDS.

Source code in src/cooling_tower_chem/balance.py
def conductivity_from_tds(
    tds: float, factor: float = DEFAULT_TDS_FACTOR
) -> float:
    """Inverse of :func:`tds_from_conductivity`: estimate uS/cm from mg/L TDS."""
    tds = _require_positive("tds", tds)
    factor = _require_positive("factor", factor)
    return tds / factor

ionic_strength_from_tds

ionic_strength_from_tds(tds: float, factor: float = 2.5e-05) -> float

Estimate ionic strength (mol/L) from TDS (mg/L).

Uses the standard approximation I = 2.5e-5 * TDS (Langelier, 1936; reproduced in Snoeyink & Jenkins and APHA Standard Methods). If you have a full ion analysis, computing I = 0.5 * sum(c_i * z_i^2) directly is more accurate. Ionic strength feeds the Stiff-Davis index (:func:cooling_tower_chem.indices.stiff_davis_index).

Source code in src/cooling_tower_chem/balance.py
def ionic_strength_from_tds(tds: float, factor: float = 2.5e-5) -> float:
    """Estimate ionic strength (mol/L) from TDS (mg/L).

    Uses the standard approximation ``I = 2.5e-5 * TDS`` (Langelier, 1936;
    reproduced in Snoeyink & Jenkins and APHA Standard Methods). If you have a
    full ion analysis, computing ``I = 0.5 * sum(c_i * z_i^2)`` directly is more
    accurate. Ionic strength feeds the Stiff-Davis index
    (:func:`cooling_tower_chem.indices.stiff_davis_index`).
    """
    tds = _require_positive("tds", tds)
    factor = _require_positive("factor", factor)
    return tds * factor

cycles_of_concentration

cycles_of_concentration(circulating: float, makeup: float) -> float

Cycles of concentration from a conservative-species ratio.

Pass the concentration of a species that leaves only via blowdown/drift (conductivity, chloride, or silica are common choices) in the recirculating water and in the makeup water. CoC = circulating / makeup.

Source code in src/cooling_tower_chem/balance.py
def cycles_of_concentration(circulating: float, makeup: float) -> float:
    """Cycles of concentration from a conservative-species ratio.

    Pass the concentration of a species that leaves only via blowdown/drift
    (conductivity, chloride, or silica are common choices) in the recirculating
    water and in the makeup water. ``CoC = circulating / makeup``.
    """
    circulating = _require_positive("circulating", circulating)
    makeup = _require_positive("makeup", makeup)
    return circulating / makeup

evaporation_loss

evaporation_loss(circulation_rate: float, delta_t_c: float, latent_heat_kj_per_kg: float | None = None, specific_heat_kj_per_kg_c: float = 4.186) -> float

Evaporation rate from a first-principles energy balance.

E = circulation_rate * cp * delta_t / latent_heat. Roughly 1% of the circulating flow evaporates per ~5.5 C (10 F) of cooling range, which this energy balance reproduces.

Note this assumes all rejected heat leaves as latent heat of vaporization, so it returns the theoretical maximum evaporation. Real towers also reject some sensible/convective heat, so field evaporation is typically ~75-90% of this value; apply a heat-rejection factor if you need the practical estimate, or pass a temperature-appropriate latent_heat.

Parameters:

Name Type Description Default
circulation_rate float

Recirculating water flow (any volumetric unit; the result is returned in the same unit).

required
delta_t_c float

Cooling range: the temperature drop across the tower, in C.

required
latent_heat_kj_per_kg float | None

Latent heat of vaporization. If omitted, a temperature-independent 2450 kJ/kg (typical near 30-35 C) is used.

None
specific_heat_kj_per_kg_c float

Specific heat of water, default 4.186 kJ/(kg.C).

4.186
Source code in src/cooling_tower_chem/balance.py
def evaporation_loss(
    circulation_rate: float,
    delta_t_c: float,
    latent_heat_kj_per_kg: float | None = None,
    specific_heat_kj_per_kg_c: float = 4.186,
) -> float:
    """Evaporation rate from a first-principles energy balance.

    ``E = circulation_rate * cp * delta_t / latent_heat``. Roughly 1% of the
    circulating flow evaporates per ~5.5 C (10 F) of cooling range, which this
    energy balance reproduces.

    Note this assumes **all** rejected heat leaves as latent heat of
    vaporization, so it returns the theoretical *maximum* evaporation. Real
    towers also reject some sensible/convective heat, so field evaporation is
    typically ~75-90% of this value; apply a heat-rejection factor if you need
    the practical estimate, or pass a temperature-appropriate ``latent_heat``.

    Parameters
    ----------
    circulation_rate:
        Recirculating water flow (any volumetric unit; the result is returned in
        the same unit).
    delta_t_c:
        Cooling range: the temperature drop across the tower, in C.
    latent_heat_kj_per_kg:
        Latent heat of vaporization. If omitted, a temperature-independent
        2450 kJ/kg (typical near 30-35 C) is used.
    specific_heat_kj_per_kg_c:
        Specific heat of water, default 4.186 kJ/(kg.C).
    """
    circulation_rate = _require_positive("circulation_rate", circulation_rate)
    if delta_t_c is None or not math.isfinite(delta_t_c) or delta_t_c < 0:
        raise ValueError(f"delta_t_c must be >= 0, got {delta_t_c!r}")
    latent = 2450.0 if latent_heat_kj_per_kg is None else latent_heat_kj_per_kg
    latent = _require_positive("latent_heat_kj_per_kg", latent)
    return circulation_rate * specific_heat_kj_per_kg_c * delta_t_c / latent

drift_loss

drift_loss(circulation_rate: float, drift_fraction: float = 0.0002) -> float

Drift (windage) loss as a fraction of circulation.

Modern drift eliminators achieve 0.001%-0.02% of circulating flow; the default 0.0002 (0.02%) is a conservative upper estimate for a tower with good eliminators.

Source code in src/cooling_tower_chem/balance.py
def drift_loss(circulation_rate: float, drift_fraction: float = 0.0002) -> float:
    """Drift (windage) loss as a fraction of circulation.

    Modern drift eliminators achieve 0.001%-0.02% of circulating flow; the
    default 0.0002 (0.02%) is a conservative upper estimate for a tower with
    good eliminators.
    """
    circulation_rate = _require_positive("circulation_rate", circulation_rate)
    if drift_fraction < 0 or not math.isfinite(drift_fraction):
        raise ValueError(f"drift_fraction must be >= 0, got {drift_fraction!r}")
    return circulation_rate * drift_fraction

blowdown_loss

blowdown_loss(evaporation: float, cycles: float, drift: float = 0.0) -> float

Required blowdown to hold a target cycles of concentration.

From the dissolved-solids mass balance, B = E / (CoC - 1) - D. The result is clamped at zero (if drift alone already exceeds the solids budget, no blowdown is needed).

Source code in src/cooling_tower_chem/balance.py
def blowdown_loss(
    evaporation: float, cycles: float, drift: float = 0.0
) -> float:
    """Required blowdown to hold a target cycles of concentration.

    From the dissolved-solids mass balance, ``B = E / (CoC - 1) - D``. The
    result is clamped at zero (if drift alone already exceeds the solids budget,
    no blowdown is needed).
    """
    evaporation = _require_positive("evaporation", evaporation)
    if cycles <= 1:
        raise ValueError(f"cycles must be > 1, got {cycles!r}")
    if drift < 0 or not math.isfinite(drift):
        raise ValueError(f"drift must be >= 0, got {drift!r}")
    return max(0.0, evaporation / (cycles - 1.0) - drift)

makeup_water

makeup_water(evaporation: float, blowdown: float, drift: float = 0.0) -> float

Makeup water required: M = E + B + D.

Source code in src/cooling_tower_chem/balance.py
def makeup_water(evaporation: float, blowdown: float, drift: float = 0.0) -> float:
    """Makeup water required: ``M = E + B + D``."""
    evaporation = _require_positive("evaporation", evaporation)
    if blowdown < 0 or not math.isfinite(blowdown):
        raise ValueError(f"blowdown must be >= 0, got {blowdown!r}")
    if drift < 0 or not math.isfinite(drift):
        raise ValueError(f"drift must be >= 0, got {drift!r}")
    return evaporation + blowdown + drift

cycles_from_flows

cycles_from_flows(makeup: float, blowdown: float, drift: float = 0.0) -> float

Cycles of concentration from the water streams: CoC = M / (B + D).

Source code in src/cooling_tower_chem/balance.py
def cycles_from_flows(makeup: float, blowdown: float, drift: float = 0.0) -> float:
    """Cycles of concentration from the water streams: ``CoC = M / (B + D)``."""
    makeup = _require_positive("makeup", makeup)
    denominator = blowdown + drift
    if denominator <= 0:
        raise ValueError("blowdown + drift must be > 0 to define cycles")
    return makeup / denominator

water_saved_by_increasing_cycles

water_saved_by_increasing_cycles(evaporation: float, cycles_low: float, cycles_high: float, drift: float = 0.0) -> float

Makeup water saved per unit time by raising cycles of concentration.

Increasing CoC reduces blowdown (and therefore makeup) for the same evaporative duty. Returns makeup(cycles_low) - makeup(cycles_high) in the same flow unit as evaporation. A positive result is water saved; raising cycles from, say, 3 to 6 typically cuts makeup noticeably.

Source code in src/cooling_tower_chem/balance.py
def water_saved_by_increasing_cycles(
    evaporation: float, cycles_low: float, cycles_high: float, drift: float = 0.0
) -> float:
    """Makeup water saved per unit time by raising cycles of concentration.

    Increasing CoC reduces blowdown (and therefore makeup) for the same
    evaporative duty. Returns ``makeup(cycles_low) - makeup(cycles_high)`` in the
    same flow unit as *evaporation*. A positive result is water saved; raising
    cycles from, say, 3 to 6 typically cuts makeup noticeably.
    """
    if cycles_high <= cycles_low:
        raise ValueError("cycles_high must be greater than cycles_low")
    b_low = blowdown_loss(evaporation, cycles_low, drift)
    b_high = blowdown_loss(evaporation, cycles_high, drift)
    return makeup_water(evaporation, b_low, drift) - makeup_water(
        evaporation, b_high, drift
    )

Interpretation

cooling_tower_chem.interpret

Human-readable interpretation of the water-chemistry indices.

Each interpret_* function maps a numeric index to a :class:Tendency (a coarse scaling/corrosion classification) and a short sentence. Thresholds follow the conventional bands cited in the index references; they are screening guides, not a substitute for site-specific engineering judgment.

Band edges vary between published sources (for example, some Ryznar tables put the balanced/corrosive boundary at 6.8 rather than 7.0). This module uses the common "RSI 6-7 balanced" convention; treat values near a boundary as borderline rather than decisive.

Tendency

Bases: str, Enum

Coarse classification shared by the interpretation helpers.

Source code in src/cooling_tower_chem/interpret.py
class Tendency(str, Enum):
    """Coarse classification shared by the interpretation helpers."""

    SEVERELY_CORROSIVE = "severely_corrosive"
    CORROSIVE = "corrosive"
    BALANCED = "balanced"
    SCALE_FORMING = "scale_forming"
    SEVERELY_SCALE_FORMING = "severely_scale_forming"

    def __str__(self) -> str:  # pragma: no cover - trivial
        return self.value

interpret_lsi

interpret_lsi(lsi: float) -> tuple[Tendency, str]

Interpret a Langelier Saturation Index value.

Source code in src/cooling_tower_chem/interpret.py
def interpret_lsi(lsi: float) -> tuple[Tendency, str]:
    """Interpret a Langelier Saturation Index value."""
    if lsi <= -2.0:
        return Tendency.SEVERELY_CORROSIVE, (
            f"LSI {lsi:+.2f}: severely undersaturated and aggressive; expect "
            "rapid dissolution of protective carbonate films."
        )
    if lsi < -0.5:
        return Tendency.CORROSIVE, (
            f"LSI {lsi:+.2f}: corrosive tendency; water will tend to dissolve "
            "calcium carbonate."
        )
    if lsi <= 0.5:
        return Tendency.BALANCED, (
            f"LSI {lsi:+.2f}: near saturation (balanced to slightly "
            "scale-forming) - the usual target band."
        )
    if lsi <= 1.5:
        return Tendency.SCALE_FORMING, (
            f"LSI {lsi:+.2f}: scale-forming; calcium carbonate will tend to "
            "precipitate."
        )
    return Tendency.SEVERELY_SCALE_FORMING, (
        f"LSI {lsi:+.2f}: heavily supersaturated; expect rapid scaling without "
        "inhibitor or blowdown control."
    )

interpret_rsi

interpret_rsi(rsi: float) -> tuple[Tendency, str]

Interpret a Ryznar Stability Index value.

Source code in src/cooling_tower_chem/interpret.py
def interpret_rsi(rsi: float) -> tuple[Tendency, str]:
    """Interpret a Ryznar Stability Index value."""
    if rsi < 5.5:
        return Tendency.SEVERELY_SCALE_FORMING, (
            f"RSI {rsi:.2f}: heavy scale formation expected."
        )
    if rsi < 6.2:
        return Tendency.SCALE_FORMING, (
            f"RSI {rsi:.2f}: scale-forming tendency."
        )
    if rsi <= 7.0:
        return Tendency.BALANCED, (
            f"RSI {rsi:.2f}: approximately balanced - the target band."
        )
    if rsi < 8.5:
        return Tendency.CORROSIVE, (
            f"RSI {rsi:.2f}: corrosive tendency."
        )
    return Tendency.SEVERELY_CORROSIVE, (
        f"RSI {rsi:.2f}: severely corrosive water."
    )

interpret_psi

interpret_psi(psi: float) -> tuple[Tendency, str]

Interpret a Puckorius Scaling Index value (same bands as the RSI).

Source code in src/cooling_tower_chem/interpret.py
def interpret_psi(psi: float) -> tuple[Tendency, str]:
    """Interpret a Puckorius Scaling Index value (same bands as the RSI)."""
    tendency, text = interpret_rsi(psi)
    return tendency, text.replace("RSI", "PSI", 1)

interpret_stiff_davis

interpret_stiff_davis(index: float) -> tuple[Tendency, str]

Interpret a Stiff-Davis Stability Index (same directional bands as the LSI).

Source code in src/cooling_tower_chem/interpret.py
def interpret_stiff_davis(index: float) -> tuple[Tendency, str]:
    """Interpret a Stiff-Davis Stability Index (same directional bands as the LSI)."""
    tendency, text = interpret_lsi(index)
    return tendency, text.replace("LSI", "S&DSI", 1)

interpret_larson_skold

interpret_larson_skold(index: float) -> tuple[Tendency, str]

Interpret a Larson-Skold index (corrosivity toward mild steel).

Source code in src/cooling_tower_chem/interpret.py
def interpret_larson_skold(index: float) -> tuple[Tendency, str]:
    """Interpret a Larson-Skold index (corrosivity toward mild steel)."""
    if index < 0.8:
        return Tendency.BALANCED, (
            f"Larson-Skold {index:.2f}: chlorides and sulfates are unlikely to "
            "disrupt a protective film."
        )
    if index <= 1.2:
        return Tendency.CORROSIVE, (
            f"Larson-Skold {index:.2f}: chlorides and sulfates may raise "
            "corrosion rates on mild steel."
        )
    return Tendency.SEVERELY_CORROSIVE, (
        f"Larson-Skold {index:.2f}: high corrosion rates on mild steel expected."
    )

interpret_aggressiveness

interpret_aggressiveness(index: float) -> tuple[Tendency, str]

Interpret an AWWA Aggressiveness Index value.

Source code in src/cooling_tower_chem/interpret.py
def interpret_aggressiveness(index: float) -> tuple[Tendency, str]:
    """Interpret an AWWA Aggressiveness Index value."""
    if index < 10.0:
        return Tendency.SEVERELY_CORROSIVE, (
            f"AI {index:.2f}: highly aggressive water."
        )
    if index < 12.0:
        return Tendency.CORROSIVE, (
            f"AI {index:.2f}: moderately aggressive water."
        )
    return Tendency.BALANCED, (
        f"AI {index:.2f}: non-aggressive water."
    )

WaterSample

cooling_tower_chem.sample

A convenience WaterSample that computes every index from one input.

Example:

from cooling_tower_chem import WaterSample

s = WaterSample(ph=8.2, temperature_c=32, calcium_hardness=450,
                total_alkalinity=250, conductivity_us_cm=2400,
                chloride=180, sulfate=120)
s.lsi()                        # 1.38
s.report()["lsi"]["tendency"]  # 'scale_forming'

WaterSample dataclass

A single water analysis, with lazy accessors for every index.

Required fields: ph, temperature_c, calcium_hardness and total_alkalinity (hardness and alkalinity in mg/L as CaCO3).

tds may be given directly (mg/L) or derived from conductivity_us_cm. chloride and sulfate (mg/L) are only needed for the Larson-Skold index.

Source code in src/cooling_tower_chem/sample.py
@dataclass
class WaterSample:
    """A single water analysis, with lazy accessors for every index.

    Required fields: ``ph``, ``temperature_c``, ``calcium_hardness`` and
    ``total_alkalinity`` (hardness and alkalinity in mg/L as CaCO3).

    ``tds`` may be given directly (mg/L) or derived from ``conductivity_us_cm``.
    ``chloride`` and ``sulfate`` (mg/L) are only needed for the Larson-Skold
    index.
    """

    ph: float
    temperature_c: float
    calcium_hardness: float
    total_alkalinity: float
    tds: float | None = None
    conductivity_us_cm: float | None = None
    chloride: float | None = None
    sulfate: float | None = None
    tds_factor: float = balance.DEFAULT_TDS_FACTOR

    @classmethod
    def from_us_units(
        cls,
        ph: float,
        temperature_f: float,
        calcium_hardness_gpg: float,
        total_alkalinity_gpg: float,
        tds: float | None = None,
        conductivity_us_cm: float | None = None,
        chloride: float | None = None,
        sulfate: float | None = None,
        tds_factor: float = balance.DEFAULT_TDS_FACTOR,
    ) -> WaterSample:
        """Build a :class:`WaterSample` from customary US units.

        Many US practitioners report temperature in degrees Fahrenheit and
        calcium hardness / total alkalinity in grains per US gallon (as CaCO3).
        This constructor accepts those units and converts them to the SI
        conventions the indices expect, via
        :func:`cooling_tower_chem.convert.fahrenheit_to_celsius` and
        :func:`cooling_tower_chem.convert.grains_per_gallon_to_mg_l`
        (1 grain/gal = 17.118 mg/L as CaCO3). The signature is otherwise
        parallel to :class:`WaterSample`.

        Parameters
        ----------
        ph:
            Water pH (dimensionless), unchanged.
        temperature_f:
            Water temperature in degrees **Fahrenheit**.
        calcium_hardness_gpg:
            Calcium hardness in **grains per US gallon** as CaCO3.
        total_alkalinity_gpg:
            Total alkalinity in **grains per US gallon** as CaCO3.
        tds:
            Total dissolved solids in **mg/L** (its usual unit), or ``None`` to
            derive it from ``conductivity_us_cm``.
        conductivity_us_cm:
            Conductivity in **microsiemens/cm** (its usual unit); used to derive
            TDS when ``tds`` is not given.
        chloride, sulfate:
            Concentrations in **mg/L** as the ion (their usual units), for the
            Larson-Skold index.
        tds_factor:
            TDS-from-conductivity factor (mg/L per uS/cm), same as
            :class:`WaterSample`.

        Returns
        -------
        WaterSample
            An instance whose fields are all in SI units, so every index method
            behaves identically to one built with the primary constructor.

        Example
        -------
        ```python
        # 90 F, 26.3 gpg calcium hardness, 14.6 gpg alkalinity as CaCO3.
        s = WaterSample.from_us_units(
            ph=8.2, temperature_f=90,
            calcium_hardness_gpg=26.3, total_alkalinity_gpg=14.6,
            conductivity_us_cm=2400,
        )
        ```
        """
        return cls(
            ph=ph,
            temperature_c=convert.fahrenheit_to_celsius(temperature_f),
            calcium_hardness=convert.grains_per_gallon_to_mg_l(calcium_hardness_gpg),
            total_alkalinity=convert.grains_per_gallon_to_mg_l(total_alkalinity_gpg),
            tds=tds,
            conductivity_us_cm=conductivity_us_cm,
            chloride=chloride,
            sulfate=sulfate,
            tds_factor=tds_factor,
        )

    def effective_tds(self) -> float:
        """TDS in mg/L, using the measured value or deriving it from conductivity."""
        if self.tds is not None:
            return self.tds
        if self.conductivity_us_cm is not None:
            return balance.tds_from_conductivity(
                self.conductivity_us_cm, self.tds_factor
            )
        raise ValueError(
            "Provide either tds or conductivity_us_cm to compute saturation indices"
        )

    def ph_of_saturation(self) -> float:
        return indices.ph_of_saturation(
            self.temperature_c,
            self.effective_tds(),
            self.calcium_hardness,
            self.total_alkalinity,
        )

    def lsi(self) -> float:
        return indices.langelier_saturation_index(
            self.ph,
            self.temperature_c,
            self.effective_tds(),
            self.calcium_hardness,
            self.total_alkalinity,
        )

    def rsi(self) -> float:
        return indices.ryznar_stability_index(
            self.ph,
            self.temperature_c,
            self.effective_tds(),
            self.calcium_hardness,
            self.total_alkalinity,
        )

    def psi(self) -> float:
        return indices.puckorius_scaling_index(
            self.temperature_c,
            self.effective_tds(),
            self.calcium_hardness,
            self.total_alkalinity,
        )

    def aggressiveness_index(self) -> float:
        return indices.aggressiveness_index(
            self.ph, self.calcium_hardness, self.total_alkalinity
        )

    def stiff_davis_index(self) -> float:
        """Stiff-Davis Stability Index, with ionic strength estimated from TDS.

        For high-salinity water only (see
        :func:`cooling_tower_chem.indices.stiff_davis_index`). Not included in
        :meth:`report`, which targets ordinary cooling-tower water.
        """
        return indices.stiff_davis_index(
            self.ph,
            self.temperature_c,
            self.calcium_hardness,
            self.total_alkalinity,
            tds=self.effective_tds(),
        )

    def ccpp(self, ionic_strength: float | None = None) -> float:
        """Calcium Carbonate Precipitation Potential, mg/L as CaCO3 (signed).

        Positive means calcite tends to precipitate (scaling); negative means it
        tends to dissolve (aggressive). By default the ionic strength is estimated
        from TDS; pass ``ionic_strength`` (mol/L) to supply a measured or
        model-derived value, which the result is sensitive to (see
        :func:`cooling_tower_chem.advanced.calcium_carbonate_precipitation_potential`).
        Not included in :meth:`report`.
        """
        return advanced.calcium_carbonate_precipitation_potential(
            self.ph,
            self.temperature_c,
            self.calcium_hardness,
            self.total_alkalinity,
            tds=None if ionic_strength is not None else self.effective_tds(),
            ionic_strength=ionic_strength,
        )

    def larson_skold_index(self) -> float | None:
        """Larson-Skold index, or ``None`` if chloride/sulfate were not provided."""
        if self.chloride is None or self.sulfate is None:
            return None
        return indices.larson_skold_index(
            self.chloride, self.sulfate, self.total_alkalinity
        )

    def report(self) -> dict:
        """Return every available index with its interpretation as a plain dict.

        Keys are index names; each value has ``value``, ``tendency`` and
        ``description``. Larson-Skold is included only when chloride and sulfate
        are present. The result is JSON-serializable.
        """
        lsi = self.lsi()
        rsi = self.rsi()
        psi = self.psi()
        ai = self.aggressiveness_index()

        lsi_t, lsi_desc = interpret.interpret_lsi(lsi)
        rsi_t, rsi_desc = interpret.interpret_rsi(rsi)
        psi_t, psi_desc = interpret.interpret_psi(psi)
        ai_t, ai_desc = interpret.interpret_aggressiveness(ai)

        report: dict = {
            "ph_of_saturation": round(self.ph_of_saturation(), 3),
            "lsi": {
                "value": round(lsi, 3),
                "tendency": str(lsi_t),
                "description": lsi_desc,
            },
            "rsi": {
                "value": round(rsi, 3),
                "tendency": str(rsi_t),
                "description": rsi_desc,
            },
            "psi": {
                "value": round(psi, 3),
                "tendency": str(psi_t),
                "description": psi_desc,
            },
            "aggressiveness_index": {
                "value": round(ai, 3),
                "tendency": str(ai_t),
                "description": ai_desc,
            },
        }

        ls = self.larson_skold_index()
        if ls is not None:
            ls_t, ls_desc = interpret.interpret_larson_skold(ls)
            report["larson_skold_index"] = {
                "value": round(ls, 3),
                "tendency": str(ls_t),
                "description": ls_desc,
            }
        return report

from_us_units classmethod

from_us_units(ph: float, temperature_f: float, calcium_hardness_gpg: float, total_alkalinity_gpg: float, tds: float | None = None, conductivity_us_cm: float | None = None, chloride: float | None = None, sulfate: float | None = None, tds_factor: float = balance.DEFAULT_TDS_FACTOR) -> WaterSample

Build a :class:WaterSample from customary US units.

Many US practitioners report temperature in degrees Fahrenheit and calcium hardness / total alkalinity in grains per US gallon (as CaCO3). This constructor accepts those units and converts them to the SI conventions the indices expect, via :func:cooling_tower_chem.convert.fahrenheit_to_celsius and :func:cooling_tower_chem.convert.grains_per_gallon_to_mg_l (1 grain/gal = 17.118 mg/L as CaCO3). The signature is otherwise parallel to :class:WaterSample.

Parameters:

Name Type Description Default
ph float

Water pH (dimensionless), unchanged.

required
temperature_f float

Water temperature in degrees Fahrenheit.

required
calcium_hardness_gpg float

Calcium hardness in grains per US gallon as CaCO3.

required
total_alkalinity_gpg float

Total alkalinity in grains per US gallon as CaCO3.

required
tds float | None

Total dissolved solids in mg/L (its usual unit), or None to derive it from conductivity_us_cm.

None
conductivity_us_cm float | None

Conductivity in microsiemens/cm (its usual unit); used to derive TDS when tds is not given.

None
chloride float | None

Concentrations in mg/L as the ion (their usual units), for the Larson-Skold index.

None
sulfate float | None

Concentrations in mg/L as the ion (their usual units), for the Larson-Skold index.

None
tds_factor float

TDS-from-conductivity factor (mg/L per uS/cm), same as :class:WaterSample.

DEFAULT_TDS_FACTOR

Returns:

Type Description
WaterSample

An instance whose fields are all in SI units, so every index method behaves identically to one built with the primary constructor.

Example
# 90 F, 26.3 gpg calcium hardness, 14.6 gpg alkalinity as CaCO3.
s = WaterSample.from_us_units(
    ph=8.2, temperature_f=90,
    calcium_hardness_gpg=26.3, total_alkalinity_gpg=14.6,
    conductivity_us_cm=2400,
)
Source code in src/cooling_tower_chem/sample.py
@classmethod
def from_us_units(
    cls,
    ph: float,
    temperature_f: float,
    calcium_hardness_gpg: float,
    total_alkalinity_gpg: float,
    tds: float | None = None,
    conductivity_us_cm: float | None = None,
    chloride: float | None = None,
    sulfate: float | None = None,
    tds_factor: float = balance.DEFAULT_TDS_FACTOR,
) -> WaterSample:
    """Build a :class:`WaterSample` from customary US units.

    Many US practitioners report temperature in degrees Fahrenheit and
    calcium hardness / total alkalinity in grains per US gallon (as CaCO3).
    This constructor accepts those units and converts them to the SI
    conventions the indices expect, via
    :func:`cooling_tower_chem.convert.fahrenheit_to_celsius` and
    :func:`cooling_tower_chem.convert.grains_per_gallon_to_mg_l`
    (1 grain/gal = 17.118 mg/L as CaCO3). The signature is otherwise
    parallel to :class:`WaterSample`.

    Parameters
    ----------
    ph:
        Water pH (dimensionless), unchanged.
    temperature_f:
        Water temperature in degrees **Fahrenheit**.
    calcium_hardness_gpg:
        Calcium hardness in **grains per US gallon** as CaCO3.
    total_alkalinity_gpg:
        Total alkalinity in **grains per US gallon** as CaCO3.
    tds:
        Total dissolved solids in **mg/L** (its usual unit), or ``None`` to
        derive it from ``conductivity_us_cm``.
    conductivity_us_cm:
        Conductivity in **microsiemens/cm** (its usual unit); used to derive
        TDS when ``tds`` is not given.
    chloride, sulfate:
        Concentrations in **mg/L** as the ion (their usual units), for the
        Larson-Skold index.
    tds_factor:
        TDS-from-conductivity factor (mg/L per uS/cm), same as
        :class:`WaterSample`.

    Returns
    -------
    WaterSample
        An instance whose fields are all in SI units, so every index method
        behaves identically to one built with the primary constructor.

    Example
    -------
    ```python
    # 90 F, 26.3 gpg calcium hardness, 14.6 gpg alkalinity as CaCO3.
    s = WaterSample.from_us_units(
        ph=8.2, temperature_f=90,
        calcium_hardness_gpg=26.3, total_alkalinity_gpg=14.6,
        conductivity_us_cm=2400,
    )
    ```
    """
    return cls(
        ph=ph,
        temperature_c=convert.fahrenheit_to_celsius(temperature_f),
        calcium_hardness=convert.grains_per_gallon_to_mg_l(calcium_hardness_gpg),
        total_alkalinity=convert.grains_per_gallon_to_mg_l(total_alkalinity_gpg),
        tds=tds,
        conductivity_us_cm=conductivity_us_cm,
        chloride=chloride,
        sulfate=sulfate,
        tds_factor=tds_factor,
    )

effective_tds

effective_tds() -> float

TDS in mg/L, using the measured value or deriving it from conductivity.

Source code in src/cooling_tower_chem/sample.py
def effective_tds(self) -> float:
    """TDS in mg/L, using the measured value or deriving it from conductivity."""
    if self.tds is not None:
        return self.tds
    if self.conductivity_us_cm is not None:
        return balance.tds_from_conductivity(
            self.conductivity_us_cm, self.tds_factor
        )
    raise ValueError(
        "Provide either tds or conductivity_us_cm to compute saturation indices"
    )

stiff_davis_index

stiff_davis_index() -> float

Stiff-Davis Stability Index, with ionic strength estimated from TDS.

For high-salinity water only (see :func:cooling_tower_chem.indices.stiff_davis_index). Not included in :meth:report, which targets ordinary cooling-tower water.

Source code in src/cooling_tower_chem/sample.py
def stiff_davis_index(self) -> float:
    """Stiff-Davis Stability Index, with ionic strength estimated from TDS.

    For high-salinity water only (see
    :func:`cooling_tower_chem.indices.stiff_davis_index`). Not included in
    :meth:`report`, which targets ordinary cooling-tower water.
    """
    return indices.stiff_davis_index(
        self.ph,
        self.temperature_c,
        self.calcium_hardness,
        self.total_alkalinity,
        tds=self.effective_tds(),
    )

ccpp

ccpp(ionic_strength: float | None = None) -> float

Calcium Carbonate Precipitation Potential, mg/L as CaCO3 (signed).

Positive means calcite tends to precipitate (scaling); negative means it tends to dissolve (aggressive). By default the ionic strength is estimated from TDS; pass ionic_strength (mol/L) to supply a measured or model-derived value, which the result is sensitive to (see :func:cooling_tower_chem.advanced.calcium_carbonate_precipitation_potential). Not included in :meth:report.

Source code in src/cooling_tower_chem/sample.py
def ccpp(self, ionic_strength: float | None = None) -> float:
    """Calcium Carbonate Precipitation Potential, mg/L as CaCO3 (signed).

    Positive means calcite tends to precipitate (scaling); negative means it
    tends to dissolve (aggressive). By default the ionic strength is estimated
    from TDS; pass ``ionic_strength`` (mol/L) to supply a measured or
    model-derived value, which the result is sensitive to (see
    :func:`cooling_tower_chem.advanced.calcium_carbonate_precipitation_potential`).
    Not included in :meth:`report`.
    """
    return advanced.calcium_carbonate_precipitation_potential(
        self.ph,
        self.temperature_c,
        self.calcium_hardness,
        self.total_alkalinity,
        tds=None if ionic_strength is not None else self.effective_tds(),
        ionic_strength=ionic_strength,
    )

larson_skold_index

larson_skold_index() -> float | None

Larson-Skold index, or None if chloride/sulfate were not provided.

Source code in src/cooling_tower_chem/sample.py
def larson_skold_index(self) -> float | None:
    """Larson-Skold index, or ``None`` if chloride/sulfate were not provided."""
    if self.chloride is None or self.sulfate is None:
        return None
    return indices.larson_skold_index(
        self.chloride, self.sulfate, self.total_alkalinity
    )

report

report() -> dict

Return every available index with its interpretation as a plain dict.

Keys are index names; each value has value, tendency and description. Larson-Skold is included only when chloride and sulfate are present. The result is JSON-serializable.

Source code in src/cooling_tower_chem/sample.py
def report(self) -> dict:
    """Return every available index with its interpretation as a plain dict.

    Keys are index names; each value has ``value``, ``tendency`` and
    ``description``. Larson-Skold is included only when chloride and sulfate
    are present. The result is JSON-serializable.
    """
    lsi = self.lsi()
    rsi = self.rsi()
    psi = self.psi()
    ai = self.aggressiveness_index()

    lsi_t, lsi_desc = interpret.interpret_lsi(lsi)
    rsi_t, rsi_desc = interpret.interpret_rsi(rsi)
    psi_t, psi_desc = interpret.interpret_psi(psi)
    ai_t, ai_desc = interpret.interpret_aggressiveness(ai)

    report: dict = {
        "ph_of_saturation": round(self.ph_of_saturation(), 3),
        "lsi": {
            "value": round(lsi, 3),
            "tendency": str(lsi_t),
            "description": lsi_desc,
        },
        "rsi": {
            "value": round(rsi, 3),
            "tendency": str(rsi_t),
            "description": rsi_desc,
        },
        "psi": {
            "value": round(psi, 3),
            "tendency": str(psi_t),
            "description": psi_desc,
        },
        "aggressiveness_index": {
            "value": round(ai, 3),
            "tendency": str(ai_t),
            "description": ai_desc,
        },
    }

    ls = self.larson_skold_index()
    if ls is not None:
        ls_t, ls_desc = interpret.interpret_larson_skold(ls)
        report["larson_skold_index"] = {
            "value": round(ls, 3),
            "tendency": str(ls_t),
            "description": ls_desc,
        }
    return report

CoolingTower

cooling_tower_chem.tower

A :class:CoolingTower convenience object over the water-balance functions.

Give it a circulating flow, a cooling range, and a target cycles of concentration; read back the evaporation, drift, blowdown and makeup streams, and project how the recirculating water concentrates relative to the makeup.

CoolingTower dataclass

Steady-state water balance for an evaporative cooling tower.

Parameters:

Name Type Description Default
circulation_rate float

Recirculating water flow in any consistent volumetric unit (e.g. m3/h). Every returned stream is in the same unit.

required
delta_t_c float

Cooling range (temperature drop across the tower) in degrees C.

required
cycles float | None

Target cycles of concentration. Required to compute blowdown/makeup.

None
drift_fraction float

Drift (windage) as a fraction of circulation. Default 0.0002 (0.02%).

0.0002
latent_heat_kj_per_kg float | None

Optional override for the latent heat used by the evaporation estimate.

None
Source code in src/cooling_tower_chem/tower.py
@dataclass
class CoolingTower:
    """Steady-state water balance for an evaporative cooling tower.

    Parameters
    ----------
    circulation_rate:
        Recirculating water flow in any consistent volumetric unit (e.g. m3/h).
        Every returned stream is in the same unit.
    delta_t_c:
        Cooling range (temperature drop across the tower) in degrees C.
    cycles:
        Target cycles of concentration. Required to compute blowdown/makeup.
    drift_fraction:
        Drift (windage) as a fraction of circulation. Default 0.0002 (0.02%).
    latent_heat_kj_per_kg:
        Optional override for the latent heat used by the evaporation estimate.
    """

    circulation_rate: float
    delta_t_c: float
    cycles: float | None = None
    drift_fraction: float = 0.0002
    latent_heat_kj_per_kg: float | None = None

    @property
    def evaporation(self) -> float:
        """Evaporation loss (energy balance)."""
        return balance.evaporation_loss(
            self.circulation_rate,
            self.delta_t_c,
            latent_heat_kj_per_kg=self.latent_heat_kj_per_kg,
        )

    @property
    def drift(self) -> float:
        """Drift / windage loss."""
        return balance.drift_loss(self.circulation_rate, self.drift_fraction)

    def _require_cycles(self) -> float:
        if self.cycles is None:
            raise ValueError("cycles must be set to compute blowdown/makeup")
        return self.cycles

    @property
    def blowdown(self) -> float:
        """Blowdown required to hold ``cycles`` (needs ``cycles``)."""
        return balance.blowdown_loss(self.evaporation, self._require_cycles(), self.drift)

    @property
    def makeup(self) -> float:
        """Makeup water required (needs ``cycles``)."""
        return balance.makeup_water(self.evaporation, self.blowdown, self.drift)

    def water_balance(self) -> dict:
        """All four streams (and cycles) as a JSON-serializable dict."""
        result = {
            "evaporation": round(self.evaporation, 4),
            "drift": round(self.drift, 4),
        }
        if self.cycles is not None:
            result["blowdown"] = round(self.blowdown, 4)
            result["makeup"] = round(self.makeup, 4)
            result["cycles"] = self.cycles
        return result

    def concentrated(self, makeup_water_sample: WaterSample) -> WaterSample:
        """Project recirculating-water chemistry from the makeup analysis.

        Conservative species (hardness, alkalinity, TDS, chloride, sulfate,
        conductivity) concentrate by the cycles of concentration; pH and
        temperature are *not* scaled (they are governed by equilibria and the
        process, not by a mass balance) and are carried through unchanged. Use
        the result to estimate the LSI/RSI of the water actually in the basin.
        """
        n = self._require_cycles()

        def scaled(value: float | None) -> float | None:
            return None if value is None else value * n

        return WaterSample(
            ph=makeup_water_sample.ph,
            temperature_c=makeup_water_sample.temperature_c,
            calcium_hardness=makeup_water_sample.calcium_hardness * n,
            total_alkalinity=makeup_water_sample.total_alkalinity * n,
            tds=scaled(makeup_water_sample.tds),
            conductivity_us_cm=scaled(makeup_water_sample.conductivity_us_cm),
            chloride=scaled(makeup_water_sample.chloride),
            sulfate=scaled(makeup_water_sample.sulfate),
            tds_factor=makeup_water_sample.tds_factor,
        )

evaporation property

evaporation: float

Evaporation loss (energy balance).

drift property

drift: float

Drift / windage loss.

blowdown property

blowdown: float

Blowdown required to hold cycles (needs cycles).

makeup property

makeup: float

Makeup water required (needs cycles).

water_balance

water_balance() -> dict

All four streams (and cycles) as a JSON-serializable dict.

Source code in src/cooling_tower_chem/tower.py
def water_balance(self) -> dict:
    """All four streams (and cycles) as a JSON-serializable dict."""
    result = {
        "evaporation": round(self.evaporation, 4),
        "drift": round(self.drift, 4),
    }
    if self.cycles is not None:
        result["blowdown"] = round(self.blowdown, 4)
        result["makeup"] = round(self.makeup, 4)
        result["cycles"] = self.cycles
    return result

concentrated

concentrated(makeup_water_sample: WaterSample) -> WaterSample

Project recirculating-water chemistry from the makeup analysis.

Conservative species (hardness, alkalinity, TDS, chloride, sulfate, conductivity) concentrate by the cycles of concentration; pH and temperature are not scaled (they are governed by equilibria and the process, not by a mass balance) and are carried through unchanged. Use the result to estimate the LSI/RSI of the water actually in the basin.

Source code in src/cooling_tower_chem/tower.py
def concentrated(self, makeup_water_sample: WaterSample) -> WaterSample:
    """Project recirculating-water chemistry from the makeup analysis.

    Conservative species (hardness, alkalinity, TDS, chloride, sulfate,
    conductivity) concentrate by the cycles of concentration; pH and
    temperature are *not* scaled (they are governed by equilibria and the
    process, not by a mass balance) and are carried through unchanged. Use
    the result to estimate the LSI/RSI of the water actually in the basin.
    """
    n = self._require_cycles()

    def scaled(value: float | None) -> float | None:
        return None if value is None else value * n

    return WaterSample(
        ph=makeup_water_sample.ph,
        temperature_c=makeup_water_sample.temperature_c,
        calcium_hardness=makeup_water_sample.calcium_hardness * n,
        total_alkalinity=makeup_water_sample.total_alkalinity * n,
        tds=scaled(makeup_water_sample.tds),
        conductivity_us_cm=scaled(makeup_water_sample.conductivity_us_cm),
        chloride=scaled(makeup_water_sample.chloride),
        sulfate=scaled(makeup_water_sample.sulfate),
        tds_factor=makeup_water_sample.tds_factor,
    )

Unit conversions

cooling_tower_chem.convert

Unit conversions for water-chemistry work.

Water-treatment data arrives in inconsistent units: an ion may be reported as mg/L of the ion itself or "as CaCO3", hardness may be in grains per gallon, and US sources use Fahrenheit. These helpers convert between the conventions the indices in :mod:cooling_tower_chem.indices expect (mg/L as CaCO3, degrees C).

"As CaCO3" expresses a concentration in terms of an equivalent amount of calcium carbonate: mg/L as CaCO3 = mg/L of ion x (50.04 / equivalent_weight_of_ion), where 50.04 mg/meq is the equivalent weight of CaCO3.

as_caco3

as_caco3(concentration_mg_l: float, equivalent_weight: float) -> float

Convert a concentration in mg/L of an ion to mg/L as CaCO3.

Pass the ion's equivalent weight (mg/meq); several are in :data:EQUIVALENT_WEIGHTS.

Source code in src/cooling_tower_chem/convert.py
def as_caco3(concentration_mg_l: float, equivalent_weight: float) -> float:
    """Convert a concentration in mg/L of an ion to mg/L as CaCO3.

    Pass the ion's equivalent weight (mg/meq); several are in
    :data:`EQUIVALENT_WEIGHTS`.
    """
    concentration_mg_l = _check("concentration_mg_l", concentration_mg_l)
    if equivalent_weight <= 0 or not math.isfinite(equivalent_weight):
        raise ValueError(f"equivalent_weight must be > 0, got {equivalent_weight!r}")
    return concentration_mg_l * (_CACO3_EQ / equivalent_weight)

caco3_to_ion

caco3_to_ion(caco3_mg_l: float, equivalent_weight: float) -> float

Inverse of :func:as_caco3: mg/L as CaCO3 back to mg/L of the ion.

Source code in src/cooling_tower_chem/convert.py
def caco3_to_ion(caco3_mg_l: float, equivalent_weight: float) -> float:
    """Inverse of :func:`as_caco3`: mg/L as CaCO3 back to mg/L of the ion."""
    caco3_mg_l = _check("caco3_mg_l", caco3_mg_l)
    if equivalent_weight <= 0 or not math.isfinite(equivalent_weight):
        raise ValueError(f"equivalent_weight must be > 0, got {equivalent_weight!r}")
    return caco3_mg_l * (equivalent_weight / _CACO3_EQ)

calcium_as_caco3

calcium_as_caco3(calcium_mg_l: float) -> float

Convert mg/L of Ca(2+) to mg/L as CaCO3 (x ~2.497).

Source code in src/cooling_tower_chem/convert.py
def calcium_as_caco3(calcium_mg_l: float) -> float:
    """Convert mg/L of Ca(2+) to mg/L as CaCO3 (x ~2.497)."""
    return as_caco3(calcium_mg_l, EQUIVALENT_WEIGHTS["Ca"])

magnesium_as_caco3

magnesium_as_caco3(magnesium_mg_l: float) -> float

Convert mg/L of Mg(2+) to mg/L as CaCO3 (x ~4.118).

Source code in src/cooling_tower_chem/convert.py
def magnesium_as_caco3(magnesium_mg_l: float) -> float:
    """Convert mg/L of Mg(2+) to mg/L as CaCO3 (x ~4.118)."""
    return as_caco3(magnesium_mg_l, EQUIVALENT_WEIGHTS["Mg"])

bicarbonate_as_caco3

bicarbonate_as_caco3(bicarbonate_mg_l: float) -> float

Convert mg/L of HCO3(-) alkalinity to mg/L as CaCO3 (x ~0.820).

Source code in src/cooling_tower_chem/convert.py
def bicarbonate_as_caco3(bicarbonate_mg_l: float) -> float:
    """Convert mg/L of HCO3(-) alkalinity to mg/L as CaCO3 (x ~0.820)."""
    return as_caco3(bicarbonate_mg_l, EQUIVALENT_WEIGHTS["HCO3"])

mg_l_to_grains_per_gallon

mg_l_to_grains_per_gallon(mg_l: float) -> float

Convert mg/L (as CaCO3) to grains per US gallon.

Source code in src/cooling_tower_chem/convert.py
def mg_l_to_grains_per_gallon(mg_l: float) -> float:
    """Convert mg/L (as CaCO3) to grains per US gallon."""
    return _check("mg_l", mg_l) / GRAINS_PER_GALLON_TO_MG_L

grains_per_gallon_to_mg_l

grains_per_gallon_to_mg_l(grains_per_gallon: float) -> float

Convert grains per US gallon to mg/L (as CaCO3).

Source code in src/cooling_tower_chem/convert.py
def grains_per_gallon_to_mg_l(grains_per_gallon: float) -> float:
    """Convert grains per US gallon to mg/L (as CaCO3)."""
    return _check("grains_per_gallon", grains_per_gallon) * GRAINS_PER_GALLON_TO_MG_L

celsius_to_fahrenheit

celsius_to_fahrenheit(celsius: float) -> float

Convert degrees Celsius to Fahrenheit.

Source code in src/cooling_tower_chem/convert.py
def celsius_to_fahrenheit(celsius: float) -> float:
    """Convert degrees Celsius to Fahrenheit."""
    if celsius is None or not math.isfinite(celsius):
        raise ValueError(f"celsius must be finite, got {celsius!r}")
    return celsius * 9.0 / 5.0 + 32.0

fahrenheit_to_celsius

fahrenheit_to_celsius(fahrenheit: float) -> float

Convert degrees Fahrenheit to Celsius.

Source code in src/cooling_tower_chem/convert.py
def fahrenheit_to_celsius(fahrenheit: float) -> float:
    """Convert degrees Fahrenheit to Celsius."""
    if fahrenheit is None or not math.isfinite(fahrenheit):
        raise ValueError(f"fahrenheit must be finite, got {fahrenheit!r}")
    return (fahrenheit - 32.0) * 5.0 / 9.0