8000 Leave out products for a ceiling the water already exceeds (#86) by rafatahmed · Pull Request #87 · 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

- Fix `recommend()` raising 'NNLS did not reach optimality' when the source water alone exceeds one declared maximum while another is binding (#86), for example 9% of the station 104 well with Na and Cl maxima of 3 mmol/L. A ceiling the water already reaches cannot be met, so its Lagrange search grew the shift without bound (#68) until NNLS failed. Products carrying that ion are now left out of the fit directly, the infinite-multiplier limit the search was approaching, and the excess is reported in `limitViolations` as before. Over 312 formula, water-share and ceiling cases the recipes are identical wherever the old search succeeded, and the failing cases now equal the fit without chloride products.
- 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.
Expand Down
23 changes: 21 additions & 2 deletions src/flahax/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,9 +421,28 @@ def _fit_within(salts: list[dict], targets: dict, water: dict, ridge: float, max
the scaled Lagrange multiplier. The ion's excess over its aim falls as the shift
grows, so each shift is found by a bracketed (Illinois) search; a ceiling whose
shift reaches zero with the ion below its aim is released. Each refit starts from
the last doses. A remainder after CEILING_STEPS (for example water alone above the
limit) is reported by _report as a limit violation.
the last doses. A remainder after CEILING_STEPS is reported by _report as a limit
violation.

A ceiling the water alone already reaches cannot be met: any product carrying that
ion only adds to the violation, which is the infinite-multiplier limit of the
search. Those products are left out of the fit directly (#86); searching instead
grew the shift without bound, and with a second binding ceiling NNLS failed.
"""
unreachable = {symbol for symbol, limit in maxima.items()
if limit > 0 and symbol not in targets
and float(water.get(symbol) or 0.0) >= limit * (1 - CEILING_MARGIN)}
if unreachable:
keep = [i for i, salt in enumerate(salts) if not any(salt["elements"].get(s, 0.0) for s in unreachable)]
solved = _fit_within([salts[i] for i in keep], targets, water, ridge,
{s: v for s, v in maxima.items() if s not in unreachable},
None if x0 is None else [x0[i] for i in keep])
grams = [0.0] * len(salts)
for index, value in zip(keep, solved["grams"]):
grams[index] = value
achieved = forward(salts, grams, water)
loss, rows = score(achieved, targets)
return {"grams": grams, "loss": loss, "rows": rows}
cache: dict = {}
solved = _fit(salts, targets, water, ridge, maxima, x0=x0, grams_cache=cache)
# symbol -> [shift, (lo shift, lo excess > 0) or None, (hi shift, hi excess < 0) or None, side]
Expand Down
16 changes: 16 additions & 0 deletions tests/test_incidental_maximum.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ def test_violation_is_never_silent(self):
self.assertFalse(result["feasible"])
self.assertEqual(result["limitViolations"][0]["symbol"], "Cl")

def test_a_ceiling_the_water_already_breaks_is_reported_not_a_solver_failure(self):
# #86: 9% of Shomar et al. (2024) station 104 brings 115.2 mg/L Cl, over a 106.35 mg/L
# (3 mmol/L) ceiling, while the Na ceiling binds. This raised "NNLS did not reach optimality".
well = {'Ca': 244, 'Mg': 100.8, 'K': 44.2, 'Na': 680, 'Cl': 1280.4, 'S': 241.43, 'N_NO3': 3.474}
water = {k: v * .09 for k, v in well.items()}
tomato = next(f["targets"] for f in LIBRARY["formulas"] if f["name"] == "Tomato (Howard Resh)")
result = recommend(LIBRARY["salts"], tomato, water, maximum_concentrations={"Na": 68.97, "Cl": 106.35})
self.assertFalse(result["feasible"])
self.assertEqual([v["symbol"] for v in result["limitViolations"]], ["Cl"])
self.assertAlmostEqual(final(result, "Cl"), 1280.4 * .09, places=9) # the products add none
# It is the infinite-multiplier limit: the fit without any chloride product.
reference = recommend([s for s in LIBRARY["salts"] if not s["elements"].get("Cl")], tomato, water,
maximum_concentrations={"Na": 68.97, "Cl": 1e9})
self.assertEqual(result["salts"], reference["salts"])
self.assertEqual(result["loss"], reference["loss"])

def test_invalid_maximum_is_rejected(self):
with self.assertRaises(Exception):
recommend(LIBRARY["salts"], FORMULA["targets"], {}, maximum_concentrations={"Cl": -1})
Expand Down
Loading
0