8000 A water report without chloride closes on its alkalinity instead of blocking (#100) by rafatahmed · Pull Request #104 · 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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## Unreleased

- Field test of `flahax mix` (2026-10-23): fix what a first session ran into. The formula step listed nothing and Enter looped on 'This one is needed.' (#98): Enter now lists every formula (and every chemical in step 4) to choose by number, a miss says how to see the list, and the step says the fertilizers come in step 4. A blocked plan said 'do not mix' while showing unrelated notes, because the ph_adjustment and formulation blocks carried no message: they now say why ("the feed mixes at pH 6.90 and needs an acid to reach pH 5.80; none was offered: add an acid", each candidate's reason, a declared maximum broken, or the targets missed by more than 1 %), and that reason comes first. The terminal no longer points to a report.html it did not write; small batches show their volume (0.01 L, not 0 L); a water analysis that fails the 5 % screen can be corrected one value at a time instead of retyped; the engine says 'None of the available products supplies B' instead of 'No salt in this library'. A water report without measured chloride keeps the #84 rule (closed on chloride, never another ion) and now says what to do (#100, owner decision 2026-10-23): `flahax mix` notes at entry that chloride is not measured, and the balancing errors end with 'Ask your lab for chloride and sodium'. `main` now carries 0.6.0.dev0 (#99), so installing from GitHub with `--upgrade` replaces an installed 0.5.0; the publishing procedure opens the next development version after each release, and CITATION.cff keeps the released version.
- A water report without chloride is no longer blocked (#100, owner decision 2026-10-23). Every lab report is slightly out of charge balance; FlahaX closes the gap at the measured pH on chloride, and now, when chloride is not measured, zero or below detection, on the alkalinity (the carbonate the measured pH depends on) instead (`reconciliation='closed_on_alkalinity'`). This is noted in the full report only and does not put the plan in review. The entry note and 'ask your lab' messages from #101 are replaced; RO water is unaffected. The growers page and the report explain why a lab report is balanced and why FlahaX calculates pH.
- Field test of `flahax mix` (2026-10-23): fix what a first session ran into. The formula step listed nothing and Enter looped on 'This one is needed.' (#98): Enter now lists every formula (and every chemical in step 4) to choose by number, a miss says how to see the list, and the step says the fertilizers come in step 4. A blocked plan said 'do not mix' while showing unrelated notes, because the ph_adjustment and formulation blocks carried no message: they now say why ("the feed mixes at pH 6.90 and needs an acid to reach pH 5.80; none was offered: add an acid", each candidate's reason, a declared maximum broken, or the targets missed by more than 1 %), and that reason comes first. The terminal no longer points to a report.html it did not write; small batches show their volume (0.01 L, not 0 L); a water analysis that fails the 5 % screen can be corrected one value at a time instead of retyped; the engine says 'None of the available products supplies B' instead of 'No salt in this library'. `main` now carries 0.6.0.dev0 (#99), so installing from GitHub with `--upgrade` replaces an installed 0.5.0; the publishing procedure opens the next development version after each release, and CITATION.cff keeps the released version.
- Package sourced stock solubility for 20 products (owner approval 2026-10-23), so `flahax mix` and `flahax batch` check that each concentrate dissolves at the entered temperature. Values are the CRC Handbook aqueous solubility table (18 products, 0-40 °C), Yara's CALCINIT sheet for calcium nitrate (ag grade; its 20 °C value is a floor from 20 to 40 °C, not checked below 20 °C) and the U.S. Coast Guard CHRIS compilation for urea, converted to grams of product as sold per litre at a density of 1 g/cm3: a lower bound, so a stock that would not dissolve is never passed. The six chelates, CH - micro and potassium citrate have no sourced data and stay `not_checked`. Checks remain single-salt in pure water, with no safety margin.
- Accept correctly printed supplier labels (#93, owner decision 2026-10-23). A label's charge balance against its chemical may now differ by up to 1 % (was 0.1 %): printed values are rounded and commercial grades are not the pure salt, so Yara's YaraLiva Calcinit label (N-NO3 14.4 %, N-NH4 1.1 %, CaO 26.3 %; -0.56 %) was refused. A one-point typing error (Ca 19.8 % for 18.8 %) moves the balance by about 2 % and is still refused. One limit now serves supplier grades and materials defined by formula or ions. An old magnesium nitrate analysis (N 11.0 %, Mg 9.65 %; 0.55 %) is no longer flagged.
- Open FlahaX to growers and agronomists with `flahax mix`, an interactive five-step entry (specification, water analysis, crop formula, fertilizers available, pH requirement) that saves waters, formulas, product stores, acids and sessions under `$FLAHAX_HOME` (default `~/.flahax`) in the formats `flahax batch` reads, and writes a batch sheet at the chosen depth: grower (tank A and tank B cards with prepared litres and grams, the acid container, checks after mixing), agronomist (adds the nutrient table: target, from water, required, achieved, final, difference; limits; pH path) or developer and manager (adds the full report). Lab reports are typed in their own units (mg/L, meq/L, mmol/L, alkalinity as CaCO3, micronutrients in mg/L or µmol/L); Enter is not measured, 0 a measured zero, `<DL` below detection; missing alkalinity is flagged at entry. Stock volumes follow the fill mode: fill the tanks full (the smallest container sets the irrigation volume) or make one irrigation (`stock_volumes.prepare_stocks`, `tank_too_small`). Two concentrate tanks (owner decision 2026-10-22): magnesium sulfate shares tank B with the phosphates, a waiver of Yara p. 65 listed on the report; a compound containing phosphate goes to tank B; more than two tanks is refused (`too_many_tanks`). Supplier label grades enter the chemistry and pH stages (graded id `<catalogue id>+<grade_id>`; an inconsistent label is `identity_inconsistent`), and compounds are declared from their data sheet (`declared_ions`: nitrogen forms, oxides, micronutrients, chelating agent, optional Na and Cl; checked by implied protons per phosphate between HPO4 2- and H2PO4-; one flag per unstated ion; their chelate sodium counted in the Na row); a product that cannot be modelled is formulated but gets no pH plan (`ph_not_planned_for_product`). Stock solubility is checked per product at the entered temperature from sourced data (`stock_solubility.check_stock_solubility`); no data is packaged yet, so every product shows not checked until the owner approves the sourced table. The review reasons on every output now quote the recipe that is weighed (#91).
Expand Down
18 changes: 18 additions & 0 deletions docs/site/growers.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ without sourced solubility data are listed as not checked.
The steps below do the same with `flahax batch`, one command with files, for
scripts and for checking a plan by hand.

## Why FlahaX needs your water's pH and balances the lab report

**Why calculate pH at all.** The acid you add depends on how strongly the
water and the fertilizers resist a pH change. Bicarbonate in well water is the
main buffer: the more of it, the more acid. FlahaX calculates the pH the mix
will have and the acid that brings it to your target, instead of a rule of
thumb, so it needs the water's composition and its measured pH.

**Why the lab report is balanced.** In any water the positive charges (calcium,
magnesium, sodium, potassium) equal the negative ones (bicarbonate, chloride,
sulfate, nitrate). A lab report never adds up exactly, because every value
carries a small measurement error. To calculate pH, FlahaX closes that small
gap: on chloride, or on the alkalinity when the report has no chloride. This
changes only the chemistry model; your targets and limits always use the
values on the report. A report more than 5 % out of balance is flagged when
you enter it, because that usually means a typing or unit error. RO water has
nothing to balance.

## What you need before you start

| You need | Where it comes from |
Expand Down
4 changes: 3 additions & 1 deletion docs/site/guide/grower-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ 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
is refused. An analysis without chloride (not measured, zero or below
detection) is closed on its alkalinity instead and the report notes it; 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)).

Expand Down
7 changes: 6 additions & 1 deletion docs/site/guide/water-to-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ below_detection=None)` speciates a laboratory analysis at its measured pH.
`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
(`fixed_balance_ion`, `missing_balance_ion`). When the default `Cl-` is not
measured, zero or below detection and the analysis has carbonate, the rest is
closed on the carbonate instead (`closed_on_alkalinity`, `balance_ion` `CO3-2`;
owner decision 2026-10-23, #100): alkalinity is the value the measured pH
depends on and usually the least precise. It is noted in the report and does
not put the plan in review. 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
Expand Down
2 changes: 1 addition & 1 deletion src/flahax/data/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ A plan or state label does not execute hardware or prove physical mixing occurre
| `missing_carbon` or `ambiguous_carbon` | Measured-pH balancing needs inorganic carbon (`CO3-2`, 0 for water measured carbon-free) or titrated alkalinity, exactly one of them. |
| `alkalinity_out_of_range` | The alkalinity is not consistent with the measured pH and ions, or needs carbon beyond the dilute model. Check units (eq/kg, not mg/L as CaCO3). |
| `missing_balance_ion`, `balance_ion_exhausted` or `unsupported_balance_ion` | Balance on `Cl-` or `Na+`, which must be present in the analysis and large enough to absorb the imbalance. An analysis that already balances at its measured pH needs no balance ion. |
| `fixed_balance_ion` or `reconciliation_out_of_bounds` | The analysis is out of balance and the balance ion is a measured zero, below detection or fixed, or the closure exceeds its stated uncertainty. FlahaX does not try another ion: review the analysis or state the uncertainty of a measured ion. Nutrient accounting is unaffected. |
| `fixed_balance_ion` or `reconciliation_out_of_bounds` | The analysis is out of balance and the balance ion is a measured zero, below detection or fixed, or the closure exceeds its stated uncertainty. Without chloride the default closes on the alkalinity instead; a named `Na+` is never switched. Review the analysis or state the uncertainty of a measured ion. Nutrient accounting is unaffected. |
| `contradictory_water_inputs` | The water was given two ways that disagree (a `water_balance` record with `measured_ph`, or with other `water_totals`, or analytical totals that differ from the record's). Supply one. |
| `activity_model_out_of_range` | The solution exceeds the accepted dilute model; do not extrapolate to concentrated stock. |
| `wrong_reagent_direction` | Nitric acid cannot reach a target requiring base. |
Expand Down
10 changes: 10 additions & 0 deletions src/flahax/grower_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,12 +440,22 @@ def _water_section(s):
elif state == 'unchanged':
note = (f'<p>The analysis closes its charge at its measured pH {number(w["measured_ph"], 2)}: no adjustment '
'was needed, and the chemistry uses it as measured.</p>')
elif state == 'closed_on_alkalinity':
note = (f'<p>For the chemistry only, the analysis ({number(w["imbalance_percent"], 2)}% out of charge balance '
f'at its measured pH {number(w["measured_ph"], 2)}) has no chloride, so it is closed on the alkalinity '
'(the carbonate the measured pH depends on). That is a modelling step, not a corrected measurement: '
'targets and limits use the laboratory values.</p>')
else:
how = (f'within its stated ±{number(w["balance_ion_uncertainty_percent"], 2)}% measurement uncertainty'
if state == 'reconciled' else 'without a stated measurement uncertainty, so the chemistry needs review')
note = (f'<p>For the chemistry only, the analysis ({number(w["imbalance_percent"], 2)}% out of charge balance '
f'at its measured pH {number(w["measured_ph"], 2)}) is closed on {text(w["balance_ion"])}, {how}. That is '
'a modelling step, not a corrected measurement: targets and limits use the laboratory values.</p>')
if state is not None:
note += ('<p class="small">Why: a laboratory report never adds up exactly, because every value carries a '
'small measurement error. To calculate the pH of the mix, and so the acid, the water\'s positive and '
'negative charges must balance at the pH the lab measured, so FlahaX closes the small gap: on '
'chloride, or on the alkalinity when there is no chloride.</p>')
if w['below_detection']:
note += ('<p>Reported below detection (taken as zero, not confirmed zero): '
+ text(', '.join(w['below_detection'])) + '.</p>')
Expand Down
37 changes: 34 additions & 3 deletions src/flahax/measured_water.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
'H3BO3': 'B', 'MoO4-2': 'Mo', 'Ure': 'N_UREA'}


ASK_LAB = ('Ask your lab for chloride and sodium: FlahaX closes the analysis at its measured pH on chloride, '
'so chloride must be measured and above zero.')
ASK_LAB = ('Ask your lab for chloride and alkalinity: FlahaX closes the small gap every analysis has on chloride, '
'or on the alkalinity when there is no chloride.')


def nutrient_mg_per_litre(totals, water_mass_kg=1., final_volume_litres=1.):
Expand Down Expand Up @@ -80,7 +80,8 @@ class BalancedWaterAnalysis:
phases: tuple = ()
units: str = UNITS
# 'unchanged' (closed within CLOSURE_TOLERANCE), 'reconciled' (within the stated uncertainty of
# balance_ion) or 'assumed' (closed on balance_ion without a stated uncertainty: review).
# balance_ion), 'assumed' (closed on balance_ion without a stated uncertainty: review) or
# 'closed_on_alkalinity' (no chloride: closed on the carbonate, balance_ion 'CO3-2'; #100).
reconciliation: str = 'assumed'
fixed: tuple = ()
below_detection: dict = None # component -> detection limit, mol/kg water
Expand Down Expand Up @@ -188,6 +189,32 @@ def alk(carbon):
return (low + high) / 2


def _close_on_carbonate(working, state, measured_ph, phases, imbalance, carbon_source, record):
"""Adjust total inorganic carbon until the charge closes at the measured pH (secant on CO3-2)."""
analysed = working['CO3-2']
lo, f_lo = analysed, state.charge_balance
hi = analysed * 1.01 + 1e-9
f_hi = solve(dict(working, **{'CO3-2': hi}), measured_ph, phases=phases, start=state).charge_balance
for _ in range(100):
if f_hi == f_lo:
break
carbon = hi - f_hi * (hi - lo) / (f_hi - f_lo)
if carbon <= 0:
raise DeliveryError('alkalinity_exhausted',
f'the analysis is {imbalance:.2f}% out of balance and has no chloride; closing it on '
f'the alkalinity would need negative carbonate. {ASK_LAB}')
lo, f_lo = hi, f_hi
state = solve(dict(working, **{'CO3-2': carbon}), measured_ph, phases=phases, start=state)
hi, f_hi = carbon, state.charge_balance
if abs(f_hi) <= 1e-12:
break
else:
raise DeliveryError('nonconvergent', 'measured-pH charge balance did not close on the alkalinity')
working = dict(working, **{'CO3-2': hi})
return BalancedWaterAnalysis(working, float(measured_ph), state, 'CO3-2', analysed, hi - analysed, imbalance,
hi, carbon_source, reconciliation='closed_on_alkalinity', **record)


def balance_water_analysis(totals, *, measured_ph, alkalinity_eq_per_kg=None, balance_ion='Cl-',
maximum_imbalance_percent=DEFAULT_MAXIMUM_IMBALANCE_PERCENT,
phases=(), temperature_c=25., analysis_id=None, balance_ion_uncertainty_percent=None,
Expand Down Expand Up @@ -284,6 +311,10 @@ def balance_water_analysis(totals, *, measured_ph, alkalinity_eq_per_kg=None, ba
if balance_ion not in BALANCE_IONS:
raise DeliveryError('unsupported_balance_ion', f'balance_ion must be one of {sorted(BALANCE_IONS)}')
analysed = totals.get(balance_ion, 0.)
if balance_ion == 'Cl-' and (balance_ion in fixed or analysed <= 0) and carbon_source != 'none':
# No chloride to adjust: close the small gap on the carbonate, the value the measured pH already
# depends on and usually the least precise on a report (owner decision 2026-10-23, #100).
return _close_on_carbonate(working, state, measured_ph, phases, imbalance, carbon_source, record)
if balance_ion in fixed:
raise DeliveryError('fixed_balance_ion',
f'the analysis is {imbalance:.2f}% out of balance and {balance_ion} is held fixed '
Expand Down
3 changes: 0 additions & 3 deletions src/flahax/mix_water.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,6 @@ def keep(ion, value, limit):
mg = to_elements(values, units, alkalinity, micro)
cations, anions, apart, parts = water_balance(mg)
p.say(f'Charge balance: cations {cations:.2f} meq/L, anions {anions:.2f} meq/L, {apart:.1f} % apart.')
if not values.get('Cl'):
p.say(' Note: chloride is not measured (or zero). FlahaX closes the analysis at its pH on chloride; '
'if it does not close exactly, the pH plan is blocked. Ask your lab for chloride and sodium.')
if apart <= WATER_SCREEN_PERCENT:
break
big = sorted(parts, key=lambda ion: -abs(parts[ion]))[:3]
Expand Down
11 changes: 5 additions & 6 deletions tests/test_analytical_water.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,12 @@ def test_explicit_zero_carbon_is_not_missing_carbon(self):
b = balance_water_analysis(kno3 | {'CO3-2': 0.}, measured_ph=measured_at_own_ph(kno3 | {'CO3-2': 0.}))
self.assertEqual(b.carbon_source, 'none')

def test_fixed_zero_chloride_is_never_adjusted_and_no_other_ion_is_tried(self):
# 1% more potassium than nitrate: out of balance, chloride measured zero.
def test_fixed_zero_chloride_is_never_adjusted_and_closes_on_the_alkalinity(self):
# 1% more potassium than nitrate: out of balance, chloride measured zero (#100, owner 2026-10-23).
water = {'K+': .00505, 'NO3-': .005, 'CO3-2': .0002, 'Cl-': 0.}
with self.assertRaises(DeliveryError) as raised:
balance_water_analysis(water, measured_ph=7)
self.assertEqual(raised.exception.code, 'fixed_balance_ion')
self.assertIn('No other ion is adjusted', str(raised.exception))
closed = balance_water_analysis(water, measured_ph=7)
self.assertEqual((closed.balance_ion, closed.reconciliation), ('CO3-2', 'closed_on_alkalinity'))
self.assertEqual(closed.totals['Cl-'], 0.)
with self.assertRaises(DeliveryError) as raised:
balance_water_analysis(water, measured_ph=7, balance_ion='Na+')
self.assertEqual(raised.exception.code, 'missing_balance_ion')
Expand Down
Loading
Loading
0