8000 Reject pH plans only for phases that form at feed conditions (#78) by rafatahmed · Pull Request #82 · 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

- 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 10000 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).
- 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).

Expand Down
34 changes: 29 additions & 5 deletions docs/site/guide/water-to-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,9 @@ match before the chemistry stage converts fertilizer doses.

The 25 C reaction set, Davies ionic-strength bound, phase allocation and
convergence errors remain enforced. `phases=[]` means aqueous-only modelling,
not proof that precipitation is impossible. Target supersaturation or solids
prevent acid-plan acceptance. Calculated pH is restricted to [0,14], with a
not proof that precipitation is impossible. Solids, or supersaturation of a
phase that forms readily at feed conditions, prevent acid-plan acceptance; see
[saturation indices and acceptance](#saturation-indices-and-acceptance). Calculated pH is restricted to [0,14], with a
charge residual of at most 1e-12 mol-charge/kg-water. The present complete
mixed reference is checked within 0.01 pH units at initial and acidified states.

Expand Down Expand Up @@ -178,9 +179,32 @@ 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.
from the supplier's certificate. A plan is rejected when its target state has
solids or a supersaturated phase that forms readily; phases that only need
review make it `requires_review` (next section).

## Saturation indices and acceptance

A positive saturation index says a solid is thermodynamically possible, not
that it forms. FlahaX and live PHREEQC 3.8.6 agree on every sign at the coupled
target states (#78), so the question is which phases form at feed conditions.
Four phases, supersaturated in most phosphate feeds, make a plan
`requires_review` instead of rejecting it, each with its reason and sources:

| Phase | Why it needs review, not rejection | Sources |
|---|---|---|
| Hydroxylapatite | Forms through amorphous, octacalcium-phosphate and brushite precursors; magnesium slows it. Brushite stays a hard gate | Nancollas, Tomazic & Tomson (1976) Croat. Chem. Acta 48(4), 431-438 |
| Goethite | Forms slowly from ferrihydrite (months to years at 24 C); ferrihydrite stays a hard gate | Schwertmann & Murad (1983) Clays Clay Miner. 31(4), 277-284 |
| MnHPO4 | The anhydrous phase's constant (log K -25.4) traces only to 1976 program data; not reported to form at 25 C or in dilute feeds | Ball & Nordstrom (1991) USGS OFR 91-183; Pedas et al. (2011) Front. Plant Sci. 2:37 |
| Vivianite | An Fe(II) phosphate of reducing environments; the model holds Fe(II) fixed, but it oxidizes in an aerated feed | Metz et al. (2024) Environ. Sci. Technol. 58, 15321; Rothe et al. (2016) Earth-Sci. Rev. 158, 51 |

When the feed contains iron(II) (for example from iron(II) sulfate), plans also
carry an advisory: the iron oxidizes within hours (pH 6) to days (pH 5.5) and
precipitates as amorphous Fe(III) phosphate; prefer a chelated iron source.
Every other positive index, for example brushite, monetite, gypsum, struvite,
calcite or ferrihydrite, still rejects the plan, and allocated solids always
do. All indices are reported. The classification is in
`flahax/saturation_policy.py`.

## Reproducible evidence

Expand Down
10 changes: 7 additions & 3 deletions src/flahax/acid_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .delivery_quantities import rescore_with_reagent, MilligramsPerLitre
from .nutrient_acceptance import assess_incidental
from .ph_adjustment import reagent_dose
from .saturation_policy import assess_saturation, describe

# 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 @@ -123,9 +124,12 @@ def plan_acid_options(totals, initial_ph, target_ph, *, products, water_mass_kg,
assessment = assess_incidental(rows, maximum_concentrations)
if assessment['limitViolations']:
raise DeliveryError('nutrient_limit_exceeded', 'acid plan exceeds an explicit nutrient maximum')
if any(v > 1e-12 for v in target.precipitated.values()) or any(si > 1e-7 for si in target.saturation_indices.values()):
raise DeliveryError('precipitation_risk', 'target solids or positive SI prohibit acid-plan acceptance')
candidates.append(AcidCandidate(identity, formula, 'requires_review' if assessment['requiresReview'] else 'accepted',
saturation = assess_saturation(target)
if saturation['blocks']:
raise DeliveryError('precipitation_risk', 'target solids or positive SI prohibit acid-plan acceptance: '
+ '; '.join(describe(saturation)))
review = assessment['requiresReview'] or bool(saturation['review'] or saturation['advisories'])
candidates.append(AcidCandidate(identity, formula, 'requires_review' if review else 'accepted',
dose, grams, litres, contribution, tuple(rows), loss, target,
product_record=dict(product), incidental_assessment=assessment))
except DeliveryError as exc:
Expand Down
4 changes: 2 additions & 2 deletions src/flahax/data/API_REFERENCE.md

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

4 changes: 3 additions & 1 deletion src/flahax/delivery_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from dataclasses import dataclass, replace
import math
from typing import Iterable, Mapping, Any
from .saturation_policy import assess_saturation
from .delivery_contracts import DeliveryError
from .delivery_quantities import FinalSolutionRecipe, VolumeLitres
from .ph_planning import PhPlan
Expand Down Expand Up @@ -55,7 +56,8 @@ def transition_plan(plan: DeliveryPlan, target: str) -> DeliveryPlan:
raise DeliveryError("incomplete_plan", "validated plan needs pH plan and pump commands")
if target == 'validated' and isinstance(plan.ph_plan,EquilibriumPhPlan):
result=plan.ph_plan.equilibrium.target
if any(v>1e-12 for v in result.precipitated.values()) or any(si>1e-7 for si in result.saturation_indices.values()):
# Review-class phases (#78) travel as plan warnings; operator approval is the review.
if assess_saturation(result)['blocks']:
raise DeliveryError('precipitation_risk','equilibrium target cannot be approved with solids or positive SI')
if plan.ph_plan.incidental_assessment and plan.ph_plan.incidental_assessment['requiresReview']:
raise DeliveryError('incidental_review_required','incidental nutrient contributions need explicit limits before validation')
Expand Down
6 changes: 5 additions & 1 deletion src/flahax/equilibrium_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import math
from typing import Mapping

from .saturation_policy import assess_saturation
from .aqueous_model import AcidResult, chemistry
from .delivery_contracts import DeliveryError, validate_product_assay
from .delivery_quantities import FinalSolutionRecipe, MassGrams, VolumeLitres, elemental_contribution, rescore_with_reagent
Expand Down Expand Up @@ -151,8 +152,11 @@ def plan_equilibrium_delivery(recipe, *, water_mass_kg, water_totals,
warnings = ['Initial dose only; measure pH after mixing before any correction.']
if assessment['requiresReview']:
warnings.append('Incidental nutrient additions require review or explicit maximum-concentration limits.')
if any(v>1e-12 for v in result.target.precipitated.values()) or any(si>1e-7 for si in result.target.saturation_indices.values()):
saturation = assess_saturation(result.target)
if saturation['blocks']:
warnings.append('Precipitation risk: target has formed solids or positive saturation indices; not valid for delivery approval.')
warnings += [f'Review: {p} SI {si:.2f}. {reason} Sources: {sources}' for p, si, reason, sources in saturation['review']]
warnings += saturation['advisories']
# Dutch practice leaves 0.5-0.75 mmol/L HCO3 as a pH buffer (Van der Lugt et al.);
# FlahaX doses to the target pH, so the residual is reported, not imposed.
residual_bicarbonate = 1000*result.target.species.get('HCO3-',0.)
Expand Down
24 changes: 14 additions & 10 deletions src/flahax/ph_adjustment.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .engine import PH_REAGENT, TOLERANCE_PCT, load_library, recommend, score
from .nutrient_acceptance import assess_incidental
from .root_finding import shrink_bracket
from .saturation_policy import assess_saturation, describe

_K, _C, _O, _H = ATOMIC['K'], ATOMIC['C'], ATOMIC['O'], ATOMIC['H']

Expand Down Expand Up @@ -109,6 +110,7 @@ class PhCandidate:
error_code: str | None = None
message: str | None = None
product_record: dict | None = None
saturation: dict | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -210,9 +212,11 @@ def plan_coupled_ph(salts, targets, water, target_ph, *, products, mix: Callable
candidates.append(PhCandidate(identity, formula, REAGENTS.get(formula, ('unknown',))[0], 'rejected',
error_code=exc.code, message=str(exc),
product_record=dict(product) if isinstance(product, Mapping) else None))
usable = [c for c in candidates if c.status in ('accepted', 'not_needed')
# Accepted plans first; a plan with review-only findings (#78) is usable after review.
usable = [c for c in candidates if c.status in ('accepted', 'not_needed', 'requires_review')
and (preferred_product_id is None or c.product_id == preferred_product_id)]
selected = min(usable, key=lambda c: (c.loss, identifiers.index(c.product_id))).product_id if usable else None
selected = min(usable, key=lambda c: (c.status == 'requires_review', c.loss,
identifiers.index(c.product_id))).product_id if usable else None
policy = ('explicit user choice; no fallback' if preferred_product_id is not None
else 'lowest final nutrient loss; input order breaks ties')
return PhAdjustmentPlan(float(target_ph), base_state.ph, direction, tuple(candidates), selected, policy,
Expand Down Expand Up @@ -294,17 +298,17 @@ def settled(assumed):
status, code, message = 'rejected', 'dose_limit_exceeded', 'dose exceeds the reagent volume limit or solvent inventory'
elif incidental['limitViolations']:
status, code, message = 'rejected', 'nutrient_limit_exceeded', 'the plan exceeds an explicit nutrient maximum'
elif any(v > 1e-12 for v in target.precipitated.values()) or any(
si > 1e-7 for si in target.saturation_indices.values()):
risky = sorted((p for p, si in target.saturation_indices.items() if si > 1e-7),
key=lambda p: -target.saturation_indices[p])
saturation = assess_saturation(target)
if status == 'accepted' and saturation['blocks']:
status, code = 'rejected', 'precipitation_risk'
message = 'positive saturation index at the target pH: ' + ', '.join(
f'{p} {target.saturation_indices[p]:.2f}' for p in risky)
elif incidental['requiresReview']:
message = 'precipitation risk at the target pH: ' + '; '.join(describe(saturation))
elif status == 'accepted' and (incidental['requiresReview'] or saturation['review'] or saturation['advisories']):
status = 'requires_review'
findings = describe(saturation) + saturation['advisories']
message = '; '.join(findings) if findings else 'incidental nutrient contributions need review'
return PhCandidate(identity, formula, REAGENTS[formula][0], status, dose, grams, litres, contribution(dose),
recommendation, mixed, target, len(cache), _loss(recommendation), code, message, dict(product))
recommendation, mixed, target, len(cache), _loss(recommendation), code, message, dict(product),
saturation)


def catalogue_ph_reagents():
Expand Down
Loading
Loading
0