8000 Plan the recipe and its pH reagent together; add pH-up reagents (#74-#77) by rafatahmed · Pull Request #81 · 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

- 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).
- Record the 0.5.0 publication. The published 0.5.0 manual and changelog still carry the release-candidate wording ('not yet published', 'Unreleased'); they are corrected here, and the publishing procedure now has an explicit finalize step before tagging (#71).

## 0.5.0 — 2026-10-20
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ plan; they never operate pumps, valves or other hardware.
| Layer | Capability | Boundary |
|---|---|---|
| Formulation | Chooses salts and grams from the full shipped library to meet a fixed crop formula after subtracting the source water | Non-negative least squares; the formula is not altered |
| Equilibrium chemistry | One mixed aqueous model for all 30 catalogue products: 197 aqueous species, 28 solid phases, ion pairs, chelates and saturation indices | 25 °C, Davies activities up to ionic strength 0.1 mol/kg, fixed oxidation states, closed inorganic carbon |
| Equilibrium chemistry | One mixed aqueous model for all 32 catalogue products: 197 aqueous species, 28 solid phases, ion pairs, chelates and saturation indices | 25 °C, Davies activities up to ionic strength 0.1 mol/kg, fixed oxidation states, closed inorganic carbon |
| pH and acid | Calculated initial pH; nitric-acid dose to a target pH; standard workflow with nitric and phosphoric acid, using supplied assay and density | An initial dose estimate; measure after mixing |
| Delivery planning | Stock-tank grouping with compatibility and solubility checks, reagent volumes, pump and injector flow sizing, composed delivery plans | Uses caller-supplied product, compatibility and equipment records; no equipment model selection |
| Conductivity | Approximate EC screening for supported products; EC from a measured calibration profile | Dilute, pre-acid screening with unknown accuracy; no validated concentrated-stock model yet |
Expand Down
2 changes: 1 addition & 1 deletion docs 8000 /site/api/package-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ To retain only artifacts that passed clean-install checks, use `python tools/ver

## Installed distributions

Run `python -m pip install build` in a development virtual environment, then `python tools/verify_distribution.py`. The tool builds wheel and sdist (wheel from sdist), installs each non-editably in a separate temporary virtual environment, runs from outside the checkout with `-I` and without `PYTHONPATH`, and asserts the imported package is inside that environment. It verifies zero runtime dependencies, 30 catalogue products, the exact chemistry resource hash, catalogue solve, nitric target, equilibrium delivery adapter, and both `python -m flahax` and `flahax` CLI entry points. A JSON report containing distribution hashes and observed numerical values is written to `build/distribution-verification.json`. Nothing is published.
Run `python -m pip install build` in a development virtual environment, then `python tools/verify_distribution.py`. The tool builds wheel and sdist (wheel from sdist), installs each non-editably in a separate temporary virtual environment, runs from outside the checkout with `-I` and without `PYTHONPATH`, and asserts the imported package is inside that environment. It verifies zero runtime dependencies, 32 catalogue products, the exact chemistry resource hash, catalogue solve, nitric target, equilibrium delivery adapter, and both `python -m flahax` and `flahax` CLI entry points. A JSON report containing distribution hashes and observed numerical values is written to `build/distribution-verification.json`. Nothing is published.

The source distribution now includes offline fixture inputs/outputs, extensions and reproduction tools as well as tests; the wheel includes runtime Python and JSON resources only. Neither contains PHREEQC binaries. Python 3.11–3.13 CI runs source regression and clean distribution smoke checks. Local Python 3.14 testing is additional evidence, not a substitute for that matrix.

Expand Down
35 changes: 35 additions & 0 deletions docs/site/guide/water-to-delivery.md
8000
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ configurations are mappings:
|---|---|---|
| `equilibrium` | `water_totals`, `water_mass_kg`, `water_analysis_id`, `complete_analysis`, `carbon_boundary`, `temperature_c`, `phases` | Complete fixed-valence analytical component totals in mol/kg-water; explicit final solvent inventory, 25 C, closed carbon, explicit phase list ; optional `measured_ph` (with `alkalinity_eq_per_kg`, `balance_ion`, `maximum_imbalance_percent`) speciates the water at its measured pH and closes the charge on one ion instead of solving pH from charge balance |
| `acids` | `products`, `maximum_nutrient_error_percent`, `maximum_reagent_volume_litres` | Evaluate single-acid candidates; optional `preferred_product_id` forbids automatic fallback |
| `ph_adjustment` | `products`, `maximum_reagent_volume_litres` | Plan the recipe and its pH reagent together, acid or base, with the reagent's nutrients counted as supply (see below); optional `preferred_product_id` |
| `nitric` | `reagent`, `reagent_channel_id`, `maximum_reagent_volume`, `maximum_nutrient_error_percent` | Existing assay-qualified HNO3 delivery adapter; volume is `VolumeLitres` |
| `titration` | `water_analysis`, `curve`, `reagent`, `maximum_reagent_volume` | Existing matched-record measured-curve API; optional positive-target tolerance defaults to 1% |
| `stocks` | `injection_ratio`, `tanks`, `compatibility_rules`, `solubility_limits`, `temperature` | Existing typed stock planner, with sourced records and temperature-qualified limits |
Expand Down Expand Up @@ -147,6 +148,40 @@ nutrient loss, with input order breaking ties; this is not a recommendation
that the lowest-loss acid is operationally or agronomically best. No acid
blending, cost optimization, handling protocol or autonomous dosing is added.

## Recipe and pH reagent together

The `acids` stage evaluates an acid against a recipe that already meets every
target, so the acid's own nitrate, phosphorus or sulfur pushes the recipe past
its targets and the plan is rejected. `ph_adjustment` plans them together:

1. The recipe is fitted without pH reagents. Acids (nitric, phosphoric,
sulfuric, citric) are not offered as nutrient salts here: phosphoric acid
chosen as a P source drove five shipped formulas to pH 3.0-3.4 in RO water.
2. The mixed feed is speciated at its calculated pH. Below the target it needs
a base; above it, an acid. Products of the other direction are reported as
`not_applicable`.
3. For each applicable product, the dose that brings the mixed feed to the
target pH is solved on the same equilibrium model. Its nutrients (N from
nitric acid, P from phosphoric acid, K from the bases) are counted as supply,
like the water's, and the recipe is fitted again. This repeats until the
recipe and the dose agree; if the recipe switches products between nearby
doses, the nearer side is taken and its nutrients are rescored at the dose
that recipe needs.
4. Every candidate reports its dose (mol/kg water, product grams and, with a
density, litres), nutrient contribution, recipe and target-pH state, even
when it is rejected. A plan is selected only from accepted candidates, by
lowest final nutrient loss.

A recipe that misses a target still gets a pH plan: the miss stays in the
recipe's `unmetTargets`. Bases are added as K+ with implicit hydroxide (KOH),
or with inorganic carbon (KHCO3, K2CO3) under the closed carbon boundary.
`catalogue_ph_reagents()` returns sourced records for the catalogue's three
pH-up products (Potassium Hydroxide (45% solution), Potassium Bicarbonate,
Potassium Carbonate); acids are supplied by the user with the assay and density
from the supplier's certificate. The precipitation gate is unchanged: a plan
whose target state has a positive saturation index is rejected, and the
message names the phases.

## Reproducible evidence

`tests/test_water_workflow.py` covers water subtraction, incidental review and
Expand Down
2 changes: 1 addition & 1 deletion docs/site/models/full-fertilizer-chemistry.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Full Fertilizer Chemistry Coverage

The current stock-chemistry and pH implementation is one source-versioned aqueous-equilibrium system. The authoritative [coverage matrix](chemistry-coverage-matrix.md) lists 197 aqueous entries and 28 phases. All 30 catalogue products convert to conserved analytical components before one simultaneous solve. See [the current model contract](fertilizer-equilibrium-model.md) and [release evidence](../evidence/release-evidence.md).
The current stock-chemistry and pH implementation is one source-versioned aqueous-equilibrium system. The authoritative [coverage matrix](chemistry-coverage-matrix.md) lists 197 aqueous entries and 28 phases. All 32 catalogue products convert to conserved analytical components before one simultaneous solve. See [the current model contract](fertilizer-equilibrium-model.md) and [release evidence](../evidence/release-evidence.md).

## Current end-to-end coverage

Expand Down
2 changes: 1 addition & 1 deletion docs/site/models/reference-fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The merger removes only the terminal END from a temporary copy, appends `databas
- Nitric dose: 3% + 2e-6 mol/kgw against both independent target methods. Expected titration is 0.0006712882238301735 and Fix_H+ is 0.0006712943408644 mol/kgw.
- Internal component conservation: 1e-9 relative + 1e-15 mol/kgw; phase complementarity SI <= 1e-7; present-phase SI = 0 within 1e-7; finite iteration bounds and explicit domain/nonconvergence errors.

`tests/test_product_phreeqc.py` verifies hashes, every product conversion path, full species/activity/SI comparisons, acid equivalence, phase allocation, conservation and live replay. `tests/test_chemistry_acceptance.py` independently checks all 30 products, ligand mass action/order independence, frozen product boundaries, zero/dilute cases, Davies/temperature limits and nitrate conservation. `tests/test_preserved_chemistry_evidence.py` protects successes and diagnostic reproducers.
`tests/test_product_phreeqc.py` verifies hashes, every product conversion path, full species/activity/SI comparisons, acid equivalence, phase allocation, conservation and live replay. `tests/test_chemistry_acceptance.py` independently checks all 32 products, ligand mass action/order independence, frozen product boundaries, zero/dilute cases, Davies/temperature limits and nitrate conservation. `tests/test_preserved_chemistry_evidence.py` protects successes and diagnostic reproducers.

## Historical reduced fixture contract

Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Always run from the checkout root, using `-m`, not by executing module files dir
| Module / run command | Demonstrates | Important boundary |
|---|---|---|
| `python -m examples.pepper_formulation` | User's twelve fertilizers, crop targets, water nutrient subtraction, recommendation versus raw least squares, incidental NH4/Na review, 1000 L batch masses and missing-input diagnostics | Water Ca 20 is subtracted from crop Ca 104. Actual water acid/base data are not invented; pH remains uncalculated. Commercial phosphoric-acid grade needs confirmation. |
| `python -m examples.mixed_chemistry` | All 30 catalogue dose conversions; one macro/trace equilibrium solve including Fe-DTPA, Fe-o,o-EDDHA, CH-micro and urea; species, complexes, activities, SI, conservation residuals, iterations; separate precipitation allocation and Davies rejection | Fixed pH is imposed, not predicted. g/kg-water is not g/L. Fe-DTPA/EDDHA remain Fe-only catalogue forms. |
| `python -m examples.mixed_chemistry` | All 32 catalogue dose conversions; one macro/trace equilibrium solve including Fe-DTPA, Fe-o,o-EDDHA, CH-micro and urea; species, complexes, activities, SI, conservation residuals, iterations; separate precipitation allocation and Davies rejection | Fixed pH is imposed, not predicted. g/kg-water is not g/L. Fe-DTPA/EDDHA remain Fe-only catalogue forms. |
| `python -m examples.equilibrium_delivery` | Complete synthetic water-to-recipe workflow, calculated initial pH, nitric/phosphoric comparison, HNO3 demand, commercial mass/volume and nutrient rescoring, stock concentration, pump runtime/uncertainty, composed validated plan, audit and human report | Fictional water/assays/limits/calibration. No operator approval, dosing execution or real equipment contact. |
| `python -m examples.titration_and_stocks` | Matched water/reagent/curve interpolation, alkalinity conversion and added nitrate; two-tank assignment, concentration/capacity checks and rejection when evidence cannot support the request | Curve points and stock records are **synthetic**, not laboratory/manufacturer evidence. A water-only curve does not prove final fertilizer pH. |
| `python -m examples.cli_usage` | JSON request/response through `python -m flahax` | CLI covers formulation, not automatic delivery. |
Expand Down
2 changes: 2 additions & 0 deletions src/flahax/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@
__all__ += ["calculate_equilibrium_ph", "FertilizerWorkflow", "WorkflowStage", "plan_fertilizer_workflow"]
from .acid_selection import AcidCandidate, AcidSelection, plan_acid_options
__all__ += ["AcidCandidate", "AcidSelection", "plan_acid_options"]
from .ph_adjustment import PhAdjustmentPlan, PhCandidate, catalogue_ph_reagents, plan_coupled_ph, reagent_dose
__all__ += ["PhAdjustmentPlan", "PhCandidate", "catalogue_ph_reagents", "plan_coupled_ph", "reagent_dose"]
from .conductivity import ECPrediction, ECDeliveryPlan, water_ec25, ec_recipe_key, plan_ec_delivery
__all__ += ["ECPrediction", "ECDeliveryPlan", "water_ec25", "ec_recipe_key", "plan_ec_delivery"]
from .calculated_ec import estimate_manufacturer_ec
Expand Down
35 changes: 4 additions & 31 deletions src/flahax/acid_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from .delivery_contracts import DeliveryError
from .delivery_quantities import rescore_with_reagent, MilligramsPerLitre
from .nutrient_acceptance import assess_incidental
from .root_finding import shrink_bracket
from .ph_adjustment import reagent_dose

# Pure, anhydrous acid identities; commercial aqueous products supply their own
# mass fraction/density. Hydrated citric solids require a separately defined stock.
Expand Down Expand Up @@ -107,36 +107,9 @@ def plan_acid_options(totals, initial_ph, target_ph, *, products, water_mass_kg,
raise DeliveryError('inconsistent_assay', 'massFraction cannot exceed one')
if target_ph > initial_ph:
raise DeliveryError('wrong_reagent_direction', 'acid selection cannot satisfy a base requirement')
basis, molar_mass, nutrients = ACIDS[formula]

last = [initial]

def at(dose, last=last):
amended = dict(totals)
amended[basis] = amended.get(basis, 0) + dose
last[0] = solve(amended, target_ph, phases=phases, start=last[0]) # warm start, #61
return last[0]

dose = 0.
target = initial if target_ph == initial_ph else at(0.)
if target_ph != initial_ph:
if target.charge_balance < initial.charge_balance - 1e-12:
raise DeliveryError('wrong_reagent_direction', 'target requires base under this phase policy')
low, high = 0., 1e-6
g_low = target.charge_balance - initial.charge_balance
for _ in range(32):
g_high = at(high).charge_balance - initial.charge_balance
if g_high <= 0:
break
low, g_low = high, g_high
high *= 2
else:
raise DeliveryError('nonconvergent', 'acid demand could not be bracketed')
# Same final width as 40 bisection steps from [0, high].
low, high = shrink_bracket(lambda d: at(d).charge_balance - initial.charge_balance,
low, g_low, high, g_high, width=high * 2. ** -40)
dose = (low + high) / 2
target = at(dose)
_, molar_mass, nutrients = ACIDS[formula]
# The shared acid/base dose solve (#75); for acids it is the former bracketed search.
dose, target = reagent_dose(totals, initial_ph, target_ph, formula, phases=phases, initial=initial)
grams = dose * mass * molar_mass / fraction
litres = grams / (1000 * density)
if litres > cap or litres > volume or grams * (1 - fraction) > mass * 1000:
Expand Down
2 changes: 2 additions & 0 deletions src/flahax/catalogue_chemistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ def _ligand_profiles():
"Mn EDTA": ProductChemistry("Mn EDTA", "edta_chelate", ("Na+", "Mn-EDTA"), PHREEQC_MINTEQ_V4),
"Phosphoric Acid (75%)": ProductChemistry("Phosphoric Acid (75%)", "macro_acid", ("H3PO4",), PHREEQC_MINTEQ_V4),
"Potassium Carbonate": ProductChemistry("Potassium Carbonate", "macro_salt", ("K+", "CO3-2"), PHREEQC_MINTEQ_V4),
"Potassium Bicarbonate": ProductChemistry("Potassium Bicarbonate", "macro_salt", ("K+", "HCO3-"), PHREEQC_MINTEQ_V4),
"Potassium Hydroxide (45% solution)": ProductChemistry("Potassium Hydroxide (45% solution)", "strong_base", ("K+", "OH-"), PHREEQC_MINTEQ_V4),
"Potassium Chloride": ProductChemistry("Potassium Chloride", "macro_salt", ("K+", "Cl-"), PHREEQC_MINTEQ_V4),
"Potassium Citrate": ProductChemistry("Potassium Citrate", "citrate_salt", ("K+", "citrate"), PHREEQC_MINTEQ_V4),
"Potassium Dibasic Phosphate": ProductChemistry("Potassium Dibasic Phosphate", "macro_salt", ("K+", "HPO4-2"), PHREEQC_MINTEQ_V4),
Expand Down
Loading
Loading
0