8000 Explain every recipe miss; ammonium limit, formula balance, faster ceilings by rafatahmed · Pull Request #57 · rafatahmed/Flahax · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- The NNLS solver raises `InputError` when it is still not optimal at its iteration cap, instead of silently returning a non-optimal fit (#55). No benchmark fit reaches the cap.
- `recommend()` reports `targetIonBalance`, the cation and anion meq/L implied by the formula (P as H2PO4-, S as SO4 2-), and warns when the sums differ by more than 10%. Eight shipped formulas exceed it, which explains most recipe misses (#39); for example Pepper (Howard Resh) is -39.5% and the U of Florida tomato clusters are +19% to +30%.
- `recommend()` reports `unmetTargets`: each positive target missed by more than 1% with a reason (`no_source`, `limited_by_ceiling`, `incidental_tradeoff`, `conflicting_targets` with the conflicting targets, and whether chloride products would reach it) (#39). The 1% tolerance is documented as a FlahaX assumption.
- Incidental-ion ceilings reach their limit in one refit instead of up to 13 (#56): mean `recommend()` time on the benchmark falls from 482 to 211 ms with the ammonium default.
- `plan_fertilizer_workflow` notes a target pH outside the 5.3-5.8 feed range of Dutch soilless practice, or outside 5.0-7.0. The equilibrium acid plan reports the residual bicarbonate at the target pH and warns below the 0.5 mmol/L buffer Dutch practice leaves.
- `recommend()` now allows incidental NH4-N up to the smaller of 10% of the formula's total target N and 1.5 mmol/L (21.01 mg/L N) when the targets omit N_NH4 (#54). Calcium nitrate (ag grade) always carries ammonium, so the penalty-only fit under-dosed calcium even where a recipe meeting every target existed (7 benchmark cases; 5 now pass, and Generic Grow needs more ammonium than the limit allows). Values are owner decisions based on Dutch soilless practice: NH4 at 5-15% of total N, at most 1-1.5 mmol/L (De Kreij, Voogt et al.; Voogt 'max 90:10'). `result["ammoniumLimit"]` reports the limit; `max_ammonium_fraction` raises it and `None` restores the previous fit; an explicit N_NH4 target, including 0, is unchanged. Whole-recipe passes rise from 29 to 34 of 52 benchmark cases (targets 563 -> 571 of 636), with no limit violations.
- Speed up the equilibrium solver and its searches (#37) with numerically equivalent results (largest change across all PHREEQC fixtures: 6e-12 relative in species, 4e-13 in the nitric dose). The Jacobian and component sums now use each species' non-zero stoichiometry instead of every component (94% of runtime). The nitric-target, acid-option and alkalinity-to-carbon searches share one bracketed Illinois root finder with the same stopping rules as before, in place of fixed bisections. The calculated-pH search keeps its bisection, so the shipped phosphate conductivity fit (#37) reproduces exactly. The mixed-product nitric target fell from 44 solves and about 70 s to 12 solves and under 2 s (median 1.6 s) on the recording laptop. Benchmark v1 adds `track_search_solves`, which reports the solves per call.
- Fix the equilibrium solver raising 'line search stalled' at some valid states: after a cold start a near-singular Jacobian (seen with Fe-EDDHA in the mixed-product solution at pH 7.396) gave an unusable Newton step. A damped least-squares (Levenberg-Marquardt) step now recovers. It runs only where the solver previously failed, so converging solves are unchanged.
- Add Potassium Chloride (KCl) and Calcium Chloride (Dihydrate) (CaCl2.2H2O) to the catalogue (30 products), with identity-theoretical assays (K 52.4469 / Cl 47.5531 %; Ca 27.2626 / Cl 48.2289 %) sourced to Sigma-Aldrich P3911 and 223506. Calcium chloride is the first calcium source without nitrate (#39). Chloride needs consent: neither product is used unless the caller targets Cl, allows it (`allow_ions`) or sets a Cl maximum, so no default recipe changes.
Expand Down
90 changes: 86 additions & 4 deletions docs/site/guide/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ from flahax import gap_for, load_library, recommend, solve_weights

load_library() -> dict
gap_for(targets, water) -> dict
recommend(salts, targets, water=None, *, allow_ions=None, allow_carbonates=False, ridge=0.02) -> dict
recommend(salts, targets, water=None, *, allow_ions=None, allow_carbonates=False, ridge=0.02,
maximum_concentrations=None, max_ammonium_fraction=0.10) -> dict
solve_weights(salts, targets, water=None, ridge=0.0) -> dict
```

Expand All @@ -73,7 +74,7 @@ Targets and water are maps of element symbol to ppm. Use the database symbols:

`N_NO3`, `N_NH4`, `P`, `K`, `Ca`, `Mg`, `S`, `Fe`, `Mn`, `Zn`, `B`, `Cu`, `Mo`, `Na`, `Cl`, `Si`.

A missing target means the formula does not ask for that element. That is different from a target of zero. Any ppm the salts still produce for an unrequested element is penalized.
A missing target means the formula does not ask for that element. That is different from a target of zero. Any ppm the salts still produce for an unrequested element is penalized, unless that element has a ceiling (see [Incidental ions and ceilings](#incidental-ions-and-ceilings)).

A salt is a dictionary:

Expand Down Expand Up @@ -114,8 +115,89 @@ fit; it is not an assertion that every incidental addition is acceptable.
`incidentalContributions` and `requiresReview` report zero/omitted-target
nutrients separately. Explicit `maximum_concentrations` in the workflow or
acid adapters enforce absolute nutrient limits, including a true zero limit.
See [water-to-delivery](water-to-delivery.md). This does not change the
optimizer's existing penalties or invent different fertilizer materials.
See [water-to-delivery](water-to-delivery.md).

## Tolerance, formula balance and unmet targets

`feasible` means every positive target is within 1% of the formula value (an
absolute 0.01 mg/L floor applies in the benchmark evaluator). The 1% is a FlahaX
assumption: no published standard gives a per-ion tolerance for preparing a feed
solution. Dutch practice corrects only when root-zone analyses deviate by 25% or
more (De Kreij et al. 2003).

A real nutrient solution is electrically neutral (Steiner 1961). `recommend`
reports `targetIonBalance`: the cation and anion meq/L implied by the formula
itself, counting P as H2PO4- and S as SO4 2- and excluding urea, Si and
micronutrients. When the two sums differ by more than 10% of the larger, a
warning explains that no salt combination can meet every target exactly. The
10% follows the window used by Steiner-type software (Martinez-Corral et al.
2009). Several shipped formulas exceed it, including Pepper (Howard Resh) at
-39.5% and the U of Florida tomato clusters at +19% to +30%.

Every positive target missed by more than 1% appears in `unmetTargets` with a
reason:

| Reason | Meaning |
|---|---|
| `no_source` | No catalogue product supplies the element (for example Si). |
| `limited_by_ceiling` | The targets are reachable, but an incidental ceiling binds; `bindingLimits` names it. |
| `incidental_tradeoff` | The targets are reachable, but the incidental-ion penalty chose otherwise. |
| `conflicting_targets` | Dropping one of the `conflictsWith` targets makes the rest reachable; `reachableWithChloride` says whether chloride products (which need a Cl ceiling) would reach it. |

The diagnosis is a least-squares check on the target rows alone. It names a
likely cause; it does not prove infeasibility.

## Incidental ions and ceilings

An incidental ion is one the products add but the formula does not ask for.
Without a ceiling, each mg/L of it is penalized in the fit. With a ceiling in
`maximum_concentrations` (mg/L), the ion is free up to that value, is pulled
back if the fit exceeds it, and any remaining excess is reported in
`limitViolations` with `feasible` false. A ceiling on `Cl` or `Na` also admits
the products that carry it; chloride products are never used without one.

**Ammonium default.** When the targets omit `N_NH4` and no `N_NH4` ceiling is
given, incidental NH4-N may reach the smaller of 10% of the formula's total
target N (`N_NO3 + N_NH4 + N_UREA`) and 1.5 mmol/L NH4 (21.01 mg/L N). Calcium
nitrate (ag grade) always carries some ammonium, so without this allowance the
fit under-doses calcium to avoid it. `result["ammoniumLimit"]` reports the limit
applied. Raise the share with `max_ammonium_fraction` (for example `0.15`), or
pass `None` for the previous penalty-only fit. An explicit `N_NH4` target,
including `0` ("none wanted"), keeps its own fit and is never relaxed.

The values are the project owner's decisions (2026-10-20), grounded in Dutch
soilless practice. Recommended NH4 is 5-15% of total N, with at most about
1-1.5 mmol/L (Van der Lugt et al., following De Kreij, Voogt et al.). Voogt's
WUR lecture gives "heavy fruit bearing - max 90:10" NO3:NH4. The Dutch standard
solutions carry 5.9-8.8% NH4-N for most crops (gerbera 12.8%). The often-quoted
"5-10% in hydroponics, 10-15% in substrate" is Dickson and Fisher's (2019)
attribution to Sonneveld and Voogt (2009) without a page reference, and it
describes the pH-neutral share rather than a limit. That primary text has not
been checked.

References:

- De Kreij, C., Voogt, W. and Baas, R. (2003). *Nutrient solutions and water
quality for soilless cultures*. Brochure 191, PPO Glasshouse Horticulture,
Naaldwijk. <https://edepot.wur.nl/456342>
- Voogt, W. Lecture slides on nutrient solutions, Wageningen UR.
<https://edepot.wur.nl/13081>
- Sonneveld, C. and Voogt, W. (2009). *Plant Nutrition of Greenhouse Crops*.
Springer, Dordrecht. <https://doi.org/10.1007/978-90-481-2532-6>
(chapters 12-13, pp. 257-312; not consulted directly).
- Dickson, R. W. and Fisher, P. R. (2019). Quantifying the acidic and basic
effects of vegetable and herb species in peat-based substrate and
hydroponics. *HortScience* 54(6): 1093-1100.
<https://doi.org/10.21273/HORTSCI13959-19>

## Feed pH and residual bicarbonate

`plan_fertilizer_workflow` adds a note when the target pH lies outside 5.3-5.8,
the feed range of Dutch soilless practice (root zone about 5.5-6.0; Van der Lugt
et al.; Savvas). A stronger note appears outside 5.0-7.0. FlahaX doses acid to the
target pH, so the bicarbonate left afterwards is an outcome. The equilibrium acid
plan reports it in `audit["residualBicarbonateMmolPerKg"]` and warns when it is
below the 0.5 mmol/L that Dutch practice leaves as a pH buffer (0.5-0.75 mmol/L).

## Additional planning commands (since 0.3.1)

Expand Down
36 changes: 36 additions & 0 deletions src/flahax/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,42 @@

_TOKEN = re.compile(r"[A-Z][a-z]?|\(|\)|\d+")

# Ion form and charge of each target symbol for a formula's charge balance (Dutch
# convention: P as H2PO4-, S as SO4 2-). Urea and Si are neutral; micronutrients are
# excluded as negligible at mg/L levels.
BALANCE_IONS = {
"N_NO3": ("N", -1), "N_NH4": ("N", 1), "P": ("P", -1), "S": ("S", -2), "Cl": ("Cl", -1),
"K": ("K", 1), "Ca": ("Ca", 2), "Mg": ("Mg", 2), "Na": ("Na", 1),
}
# Warn when the formula's cation and anion sums differ by more than this share of the
# larger sum (about the +/-10% window used by Steiner-type software, Martinez-Corral 2009).
ION_BALANCE_WARNING_PCT = 10.0


def target_ion_balance(targets: dict) -> dict:
"""Cation and anion meq/L implied by a formula's targets, and their imbalance.

A real solution is electrically neutral (Steiner 1961). When a formula is not,
no combination of salts meets every target exactly: counter-ions the formula does
not list must carry the difference.
"""
cations = anions = 0.0
for symbol, ppm in targets.items():
form = BALANCE_IONS.get(symbol)
if form is None or not ppm or ppm <= 0:
continue
element, charge = form
meq = float(ppm) / ATOMIC[element] * abs(charge)
if charge > 0:
cations += meq
else:
anions += meq
larger = max(cations, anions)
imbalance = 100.0 * (cations - anions) / larger if larger > 0 else 0.0
return {"cationsMeqPerL": cations, "anionsMeqPerL": anions, "imbalancePercent": imbalance,
"exceedsWarning": abs(imbalance) > ION_BALANCE_WARNING_PCT,
"assumptions": "P as H2PO4-, S as SO4 2-; urea, Si and micronutrients excluded"}


class InputError(ValueError):
"""The caller passed a profile or a salt the balance cannot use."""
Expand Down
7 changes: 6 additions & 1 deletion src/flahax/data/API_REFERENCE.md
< 8BD0 details class="js-file-header-dropdown dropdown details-overlay details-reset pr-2 pl-2"> View file Open in desktop

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/flahax/data/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@ A zero NH4 target has no meaningful percentage deviation. Real products may
add NH4: report the absolute addition separately; never relabel it as zero.
Use `maximum_concentrations` to enforce explicit hard caps. Do not silently
relax those caps or replace the product assay to obtain a feasible fit.
When targets omit NH4 entirely, `recommend` allows incidental NH4-N up to the
smaller of 10% of the total target N and 1.5 mmol/L (21.01 mg/L N) by default,
an owner decision based on Dutch soilless practice (5-15% of total N, at most
1-1.5 mmol/L; De Kreij, Voogt et al.). `ammoniumLimit` reports it;
`max_ammonium_fraction` raises it and `None` turns it off.

### Real water analyses: start from the measured pH

Expand Down
Loading
Loading
0