8000 Decide concentration limits on the laboratory analysis, not the charge-balanced water (#84) by rafatahmed · Pull Request #85 · 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Concentration limits and nutrient targets now always use the laboratory water analysis; the charge-balanced water is used for chemistry only (#84). `balance_water_analysis` closes an analysis' charge on chloride or sodium, a modelling step, but its totals were passed on as the water: the nitric delivery stage rebuilt its nutrient rows and limits from them, `flahax batch` (#79) formulated on them, and the tomato example chose its blend on them. On Shomar et al. (2024) station 104 the model has 1158.5 mg/L chloride against 1280.4 measured, so the example reported 9% well water as within the 3 mmol/L chloride limit; on the laboratory value 9% is 115.2 mg/L and fails, and 8% (102.4 mg/L) is the largest passing share (boundary about 8.3%). Balancing on sodium raises sodium from 680 to 759 mg/L, so the error could also reject a passing water. Now `BalancedWaterAnalysis` keeps `analytical_totals` (the analysis as entered, defensively copied) beside the model, with its provenance, and `diluted(fraction)` blends it with ion-free water, scaling both and recomputing the blend's chemistry. The workflow accepts such a record as `equilibrium['water_balance']`, rejects contradictory water inputs, passes `analytical_water_totals` to `plan_equilibrium_delivery` (rows and limits on the analysis, dose on the chemistry), and reports laboratory and modelled water in `water_basis`. When the balancing alone would change a limit decision the plan is `requires_review` with the reason; a laboratory-basis exceedance always blocks, and a review never downgrades a block. The coupled pH planner's no-reagent-needed candidate now gets the same limit and saturation checks as dosed candidates (it skipped both), and the workflow rechecks the selected plan's limits. Balancing itself is now explicit: an analysis that already closes at its measured pH (within 0.0001% of its total charge) is used unchanged and needs no balance ion, so chloride-free waters no longer fail on the default chloride; `CO3-2` = 0 marks measured carbon-free water (omitted carbon is still `missing_carbon`); measured zeros and `below_detection` components are held fixed; the closure is `reconciled` only within a stated `balance_ion_uncertainty_percent` (else `reconciliation_out_of_bounds`), and without one it is `assumed` and the chemistry, and every plan built on it, asks for review. No other ion is tried automatically (`fixed_balance_ion`). `flahax batch` takes nutrients straight from the analysis file, accepts measured zeros, detection limits, a balance ion and its uncertainty, and gains `--limit SYMBOL=MG_PER_L`; undeclared limits are never assumed. The report shows laboratory, modelled and adjusted water, each limit with its decision basis, and the batch sheet the limits on the laboratory basis. The tomato example now blends 8% well water and every dependent number is recomputed; the publication example keeps its 1:3 blend, and only its printed blend chloride changes (290 -> 320 mg/L, the laboratory value). The grower-outputs page now gives the 5% imbalance screen (it said 10%).
- Ship the grower outputs in the package (#79). They were example code only (#73), so an installed FlahaX gave growers JSON. New `flahax batch` command: one formula (packaged, or your targets) and one water (`ro` or a measured analysis, closed at its pH) to `report.html`, `batch-sheet.html` and `summary.json`, with the coupled pH plan (your acids from certificate values, plus the packaged bases). In Python, `summarize_batch`, `render_report` and `render_batch_sheet` do the same from any `plan_fertilizer_workflow` result; when a reagent is selected the sheet weighs the refitted recipe that counts its nutrients. `group_tanks` is the sourced default concentrate grouping: the Yara page 65 compatibility table (read with page 63) as three composition rules (calcium with phosphate, calcium with sulfate, magnesium with phosphate), which reproduce its twelve incompatible pairs exactly; micronutrients and chelates go to tank A with calcium and stay out of phosphate concentrates, and tank A is never acidified (owner rule). The tomato examples use it, so micronutrients no longer show as 'Not in table'; their acid text now reflects #78 (nitric needs review, phosphoric misses P). `default_compatibility_rules` gives `plan_stocks` the same rules as records. Stock solubility is still not checked: the outputs say so.
- Positive saturation indices of phases that do not form at feed conditions no longer reject pH and delivery plans (#78). FlahaX and live PHREEQC 3.8.6 agree on every sign at the coupled target states, so the indices are right; the question was which phases form. Hydroxylapatite (precursor pathway, magnesium-inhibited), goethite (forms slowly from ferrihydrite), anhydrous MnHPO4 (unmeasured constant, log K -25.4 from 1976 program data; not reported to form at 25 C or in dilute feeds) and vivianite (Fe(II) phosphate of reducing environments; the model holds Fe(II) fixed) now make a plan `requires_review`, each with its reason and sources; feeds containing iron(II) carry an advisory that the iron oxidizes and precipitates as Fe(III) phosphate. Brushite, monetite, gypsum, struvite, calcite, ferrihydrite and every other phase, and any allocated solid, still reject. One policy (`saturation_policy.py`) now serves the coupled pH plan, acid selection, equilibrium delivery and plan validation. In the 26-formula RO-water audit at pH 5.8, 21 formulas now get a usable pH plan (12 also meet every target), against none before; the 5 still rejected have monetite or brushite supersaturated. Owner decision and cited research summary on #78.
- Plan the recipe and its pH reagent together (#74, #76, #77) and add pH-up reagents (#75). The grower-path audit found that none of the 26 shipped formulas in RO water got a usable pH plan: the recipe met N and P exactly and the acid then broke those targets (#74); a feed below the target pH had no correction at all (#75); phosphoric acid chosen as a P source drove five formulas to pH 3.0-3.4 (#76); and a 1% target miss blocked acid planning (#77). The new `ph_adjustment` workflow stage (`plan_coupled_ph`) fits the recipe without acids as nutrient salts, solves the acid or base dose to the target pH on the same equilibrium model, counts the reagent's nutrients as supply and refits until recipe and dose agree (bracketed, so recipes that switch products between nearby doses still settle). `reagent_dose` is the shared acid/base solve; `plan_acid_options` now uses it with bit-identical results. In the same audit every applicable candidate (62) now gets a computed dose at exactly the target pH (re-adding it and recalculating from scratch reproduces pH 5.8), and the five pH-3 feeds now mix at 4.5-4.9 and receive a base. All 62 are still rejected by the unchanged precipitation gate (hydroxylapatite and MnHPO4 supersaturated in every case), which #78 addresses next; each rejection names the phases and keeps the dose. The catalogue gains Potassium Hydroxide (45% solution) and Potassium Bicarbonate, and Potassium Carbonate is linked to its sourced theoretical analysis (K 56.581 -> 56.580316 %, owner-approved), all three `role: ph_reagent` and never selected as nutrient salts; sources are Sigma-Aldrich 417661, 237205 and 209619 (#75). `catalogue_ph_reagents()` returns ready reagent records for them. The catalogue now has 32 products; the mixed_target_ph_nitric PHREEQC fixture was recaptured with the pinned PHREEQC 3.8.6 because it contains potassium carbonate (nitric dose -7.8e-6 relative).
Expand Down
17 changes: 10 additions & 7 deletions docs/publication/example/qatar_tomato.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,27 @@ def ppm(totals):


def workflow(water, salts, label):
"""Formulate on the laboratory analysis; speciate the charge-balanced record (#84)."""
return plan_fertilizer_workflow(
salts, targets(), ppm(water), TARGET_PH, final_volume=VolumeLitres(1000),
equilibrium=dict(water_totals=water, water_mass_kg=1000, water_analysis_id=label,
salts, targets(), ppm(water.analytical_totals), TARGET_PH, final_volume=VolumeLitres(1000),
equilibrium=dict(water_balance=water, water_mass_kg=1000, water_analysis_id=label,
complete_analysis=True, carbon_boundary='closed', temperature_c=25, phases=[]))


def main():
well = {ion: mg / GRAMS_PER_MOL[ion] / 1000 for ion, mg in WELL_MG_L.items()}
balanced = balance_water_analysis(well, measured_ph=WELL_PH)
print(f'Well: imbalance {balanced.imbalance_percent:.1f}% at pH {WELL_PH}, closed by '
f'{100 * balanced.balance_adjustment_molal / balanced.analysed_balance_ion_molal:+.1f}% Cl')
print(f'Well: imbalance {balanced.imbalance_percent:.1f}% at pH {WELL_PH}; for the chemistry only it is closed by '
f'{100 * balanced.balance_adjustment_molal / balanced.analysed_balance_ion_molal:+.1f}% Cl. '
'Nutrients and limits use the laboratory values.')

library = load_library()['salts']
raw = workflow(balanced.totals, library, 'station-104')
raw = workflow(balanced, library, 'station-104')
lab = ppm(balanced.analytical_totals)
print(f"1. Well water alone: best tomato recipe misses by {raw.recommendation['maxAbsDeltaPct']:.0f}% "
f"(water Ca {ppm(balanced.totals)['Ca']:.0f}, S {ppm(balanced.totals)['S']:.0f} mg/L)")
f"(water Ca {lab['Ca']:.0f}, S {lab['S']:.0f} mg/L)")

blend = {k: v * BLEND for k, v in balanced.totals.items()}
blend = balanced.diluted(BLEND)
acid_p = workflow(blend, library, 'station-104-blend')
chem = acid_p.stages['chemistry'].value
print(f"2. Blend, best mass balance: within {acid_p.recommendation['maxAbsDeltaPct']:.2f}% of every target, "
Expand Down
22 changes: 18 additions & 4 deletions docs/site/guide/grower-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ that matches exactly one formula name and lists the matches otherwise. Use
| `--ph` | target feed pH | 5.8 |
| `--ratio` | final litres per litre of concentrate | 100 |
| `--acid` | `FORMULA:MASS_FRACTION:DENSITY[:NAME]` from your certificate, for example `HNO3:0.60:1.37`; repeat for more | none: acids are never assumed |
| `--limit` | a maximum in the final solution, `SYMBOL=MG_PER_L`, for example `Cl=106.35`; repeat for more | none: limits are never assumed, and undeclared ones are not checked |
| `--max-reagent-litres` | largest pH reagent volume per batch | volume / 200 |
| `--products` | JSON array of your own product records | the packaged catalogue |
| `--rules` | JSON array of extra `compatibilityRule` records | none |
Expand All @@ -43,9 +44,22 @@ for an input error.

Elements are mg/L of the element (S as S, P as P, N_NO3 and N_NH4 as N);
`HCO3` is bicarbonate in mg/L as HCO3. Iron is taken as Fe(III), the form in
aerated water. The analysis is closed at its measured pH on chloride
(`balance_water_analysis`), and an analysis more than 10 % out of charge balance
is refused. One litre of water is taken as one kilogram.
aerated water. One litre of water is taken as one kilogram.

A value of 0 is a measured zero and stays zero (`"Cl": 0` for chloride-free
water, `"HCO3": 0` for carbon-free water); an omitted ion is not measured.
Optional keys: `"below_detection": {"Mn": 0.01}` (detection limits in mg/L;
taken as zero, never adjusted), `"balance_ion": "Cl"` or `"Na"`, and
`"balance_ion_uncertainty_percent": 10`, the stated uncertainty of that ion.

Targets and `--limit` maximums use these laboratory values. For the chemistry
only (pH, saturation, EC, reagent dose) the analysis is speciated at its
measured pH: used unchanged if it already balances; otherwise closed on the
balance ion within its stated uncertainty, or, without one, as an assumption
that puts the plan in review. An analysis more than 5 % out of charge balance
is refused, and no other ion is adjusted automatically. The report shows
laboratory and modelled water side by side (see
[measured-pH water balancing](water-to-delivery.md#measured-ph-water-balancing)).

## What the files say

Expand All @@ -55,7 +69,7 @@ The plan status is one of:
|---|---|
| `ready` | Every target is met and the pH plan is accepted. |
| `requires_review` | Usable after review: a target is missed with its reason, or a review-class phase is supersaturated (see [saturation indices](water-to-delivery.md#saturation-indices-and-acceptance)). |
| `blocked` | Not usable as planned: a phase that forms at feed conditions is supersaturated, no reagent reaches the pH, or the chemistry could not be calculated. The reason is given. |
| `blocked` | Not usable as planned: a declared maximum is exceeded on the laboratory analysis, a phase that forms at feed conditions is supersaturated, no reagent reaches the pH, or the chemistry could not be calculated. The reason is given. A review never downgrades a block. |

When a pH reagent is selected, the recipe to weigh is the refitted one that
counts the reagent's nutrients. The batch sheet lists what to weigh per tank,
Expand Down
66 changes: 56 additions & 10 deletions docs/site/guide/water-to-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,63 @@ cannot pass `validated_equilibrium` delivery composition.

## Measured-pH water balancing

**Two inventories, two uses (#84).** Nutrient targets and maximum
concentrations are always decided on the laboratory analysis after blending,
plus everything added (products and pH reagent), at the final volume. The
charge-balanced composition is an input to the chemistry only: pH, speciation,
saturation, EC and the reagent dose. Closing the charge on chloride or sodium is
a modelling step, not a corrected measurement, so it can never pass or fail a
limit. When it would change a limit decision, the plan asks for review and says
so; a laboratory-basis exceedance always blocks.

`balance_water_analysis(totals, measured_ph=..., alkalinity_eq_per_kg=None,
balance_ion='Cl-', maximum_imbalance_percent=5, phases=(), temperature_c=25)`
speciates a laboratory analysis at its measured pH. Inorganic carbon comes
from `totals['CO3-2']` or from titrated alkalinity (carbonate alkalinity:
each carbon species counts 2 x C - H+ relative to H2CO3, plus OH- - H+), never
both. The charge left at the measured pH is closed by changing `Cl-` or `Na+`,
as PHREEQC's `charge` option does. The result records the analysed and
adjusted balance-ion totals and the imbalance in percent of total
equivalents. An imbalance above the limit is rejected
(`charge_imbalance_exceeded`); the balance ion is never driven to zero
(`balance_ion_exhausted`).
balance_ion='Cl-', maximum_imbalance_percent=5, phases=(), temperature_c=25,
analysis_id=None, balance_ion_uncertainty_percent=None, fixed=(),
below_detection=None)` speciates a laboratory analysis at its measured pH.

- **Zero, not measured, below detection.** A component given as 0 is a measured
zero and is held fixed; an omitted component is not measured.
`below_detection` records detection limits (mol/kg); those components enter
the chemistry as 0 and are never adjusted. Inorganic carbon comes from
`totals['CO3-2']` (0 for water measured carbon-free) or from titrated
alkalinity (carbonate alkalinity: each carbon species counts 2 x C - H+
relative to H2CO3, plus OH- - H+), never both; carbon not reported at all is
`missing_carbon`.
- **Balance first.** If the composition's charge closes at the measured pH within
`CLOSURE_TOLERANCE` (0.0001 % of its total ionic charge, a numerical
tolerance), it is used unchanged (`reconciliation='unchanged'`) and no balance
ion is needed: a chloride-free water does not fail because the default
balance ion is chloride. Passing the 5 % screen does not make an analysis
balanced.
- **Otherwise, explicit and bounded.** An imbalance above
`maximum_imbalance_percent` is rejected (`charge_imbalance_exceeded`). The
rest is closed on `balance_ion` (`Cl-` or `Na+`), as PHREEQC's `charge`
option does, only if that ion is measured, positive and not fixed
(`fixed_balance_ion`, `missing_balance_ion`); no other ion is tried. With
`balance_ion_uncertainty_percent` the adjustment must lie within that stated
measurement uncertainty (`reconciled`, else `reconciliation_out_of_bounds`).
Without it the closure is an assumption (`assumed`): the workflow's chemistry
stage, and every plan built on it, is `requires_review` with the reason.
- **The record.** `analytical_totals` keeps the analysis as entered,
`totals` the model; `balance_ion`, `balance_adjustment_molal`,
`imbalance_percent`, `reconciliation`, `fixed`, `below_detection` and
`analysis_id` record what was done. `diluted(fraction)` blends it with
ion-free water: both inventories scale and the blend's chemistry is
recomputed; the source's measured pH stays as metadata. Blending two real
waters needs both analyses and is not supported.

In `plan_fertilizer_workflow`, either pass the laboratory totals with
`measured_ph` (and optional `alkalinity_eq_per_kg`, `balance_ion`,
`balance_ion_uncertainty_percent`, `fixed`, `below_detection`) in the
`equilibrium` record, or pass a prepared `water_balance` record (for example a
diluted blend) instead of `water_totals`. The `water` mg/L must be the
laboratory analysis; the balanced model's mg/L, or `water_balance` together with
`measured_ph` or with different `water_totals`, is rejected. The result's
`water_basis` lists laboratory and modelled water side by side and every limit
the balancing alone would flip. `plan_equilibrium_delivery` takes
`analytical_water_totals` (or a `BalancedWaterAnalysis` as `water_totals`): its
nutrient rows and limits use the analysis, its dose the chemistry. A plain
mapping without it keeps its previous meaning.

Why: calculated pH from charge balance alone is extremely sensitive to
analytical error in brackish water. For Shomar et al. (2024) station 104
Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ python -m examples.tomato_report --output build/tomato-report
| Command | What it shows |
|---|---|
| `tomato_simple` | The shipped Howard Resh tomato formula in RO water: products and doses, a 1000 L batch, nutrient fit, the formula's ion balance, product identities and an EC estimate. The unacidified mix is pH 6.9, outside the 5.3-5.8 feed range, and the example says so. |
| `tomato_advanced` | A published brackish well (Shomar et al. 2024, station 104) with Dutch open-system tomato targets (De Kreij, Voogt and Baas). Steps: balance the laboratory analysis at its measured pH; choose the largest blend with desalinated water that keeps Na and Cl within 3 mmol/L (9% well water); formulate on it, showing why phosphoric acid is withdrawn as a P source (it drives the feed to pH 3.8); speciate the mix; evaluate nitric and phosphoric acid (nitric needs review of two review-class phases, phosphoric is refused for its P, with FlahaX's reasons) and compute the nitric dose to pH 5.5, which also closes the nitrate gap; estimate EC; group concentrates into tanks; size injection; and run three growth-stage formulas, each as its own batch. |
| `tomato_advanced` | A published brackish well (Shomar et al. 2024, station 104) with Dutch open-system tomato targets (De Kreij, Voogt and Baas). Steps: balance the laboratory analysis at its measured pH; choose the largest blend with desalinated water that keeps Na and Cl within 3 mmol/L on the laboratory values (8% well water; 9% fails on its measured chloride although the charge-balanced model would pass it); formulate on it, showing why phosphoric acid is withdrawn as a P source (it drives the feed to pH 3.8); speciate the mix; evaluate nitric and phosphoric acid (nitric needs review of two review-class phases, phosphoric is refused for its P, with FlahaX's reasons) and compute the nitric dose to pH 5.5, which also closes the nitrate gap; estimate EC; group concentrates into tanks; size injection; and run three growth-stage formulas, each as its own batch. |
| `tomato_report` | `tomato-report.html`: both examples with charts and tables. `batch-sheet-simple.html` and `batch-sheet-advanced.html`: printable one-page grower sheets (water, what to weigh per tank, concentrate g/L, acid step, and measure-after-mixing lines). Offline HTML; print to PDF from the browser. |

Concentrate tanks are grouped by the packaged default, `flahax.group_tanks`: the
Expand Down
Loading
Loading
0