8000 Accept printed supplier labels: 1 % charge limit (#93) by rafatahmed · Pull Request #96 · 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

- 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).
- A material is defined by what it puts in the water, not by its catalogue name (owner decision 2026-10-22). `material_chemistry(record)` resolves a compound from its stated `ions` (with `chelating_agent` for chelates) or its `formula` (fertilizer ion table plus hydrate water), and a blend from its analysis by nutrient form (declared-ions grade); a record with only a name stops with `material_undefined` and says what to give. The 32 catalogue products are pre-filled materials whose ions are their identities' `ion_forms`; counterions and ligands follow the ions, so the name-based special cases (potassium carbonate and bicarbonate, macro-salt formula counterions) are gone. `chemistry_for_product`, `PRODUCT_CHEMISTRY` and `convert_catalogue_product_dose` remain as views. Every catalogue product converts bit for bit as before at five doses, and the 26 packaged formulas in RO water give identical recipes, pH and reagent decisions; a material list that shares no names with the catalogue gives the same recipe, pH and acid dose as the catalogue. Urea's component list was a string (a missing tuple comma) and is now a tuple.
- Assess untargeted nutrients once, and name where each comes from (#92). `recommend` keeps its 10 % ammonium allowance (`ammoniumLimit`), but the workflow re-assessed the first fit against the caller's limits only, so ammonium within the allowance read 'no declared maximum' and made the formulation `requires_review` while the coupled pH refit rated it `within_limit`. The workflow now assesses against the caller's limits plus the allowance `recommend` applied (a declared N_NH4 maximum still wins; an explicit N_NH4 target is fitted as given), so first fit, refit and every later stage agree. Every incidental row carries `source`, the dosed products (or water) that supply it, e.g. N_NH4 from Calcium Nitrate (ag grade), and the summary's formulation rows for untargeted nutrients carry it too. Recipes are unchanged: the 26 packaged formulas in RO water give identical salts, first fit and refit.
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 @@ -223,7 +223,7 @@ A plan or state label does not execute hardware or prove physical mixing occurre
| `feasible` is false | Inspect all nutrient rows and available assays; do not force approval by hiding residuals. |
| `unknown_product` or identity mismatch | Use exact catalogue names and IDs for catalogue-only chemistry. |
| `material_undefined` | A product has only a name. Give its formula, its ions, or its analysis by nutrient form. |
| `identity_inconsistent` | A label does not fit its chemical (its charges do not balance within 0.1 %). Check the label; rounded labels can trip this (#93). |
| `identity_inconsistent` | A label does not fit its chemical: its charges are more than 1 % out of balance (rounded labels and commercial grades stay well inside; a one-point typing error does not). Check the label. |
| `ph_not_planned_for_product` | A compound's data sheet is incomplete or does not balance: it is used for nutrients, but no pH reagent is planned. Complete the nitrogen forms, chelating agent and counter-ions. |
| `too_many_tanks` | The products need more than two concentrate tanks under the rules; the message names the conflicting products. |
| `tank_too_small` | One irrigation needs more stock than a tank holds; the message names the tank. |
Expand Down
2 changes: 1 addition & 1 deletion src/flahax/material_chemistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .catalogue_chemistry import PHREEQC_MINTEQ_V4, ProductChemistry
from .composition import parse_formula
from .delivery_contracts import DeliveryError
from .materials import _CHARGE_RESIDUAL_LIMIT_PERCENT

UNDEFINED = 'give its formula, its ions, or its analysis by nutrient form'

Expand Down Expand Up @@ -63,7 +64,6 @@
'o,o-EDDHA': 'Yunta et al. 2003 o,o-EDDHA profile; Fe(III) log beta 35.1 at 25 C'}
_UREA_SOURCE = 'analytical urea; hydrolysis is kinetic and excluded from 25 C instantaneous equilibrium'
_BALANCE_TOLERANCE = 1e-6
_CHARGE_RESIDUAL_LIMIT_PERCENT = 0.1 # materials._CHARGE_RESIDUAL_LIMIT_PERCENT; #93 is the owner's


def _charge(ion: str) -> float:
Expand Down
4 changes: 3 additions & 1 deletion src/flahax/materials.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ def _oxide_factor(element: str, count: int, oxygens: int) -> float:
CHELATING_AGENTS = ('EDTA', 'DTPA', 'o,o-EDDHA', None)
N_FORMS = ('N_NO3', 'N_NH4', 'N_UREA')
_BANDS = ('typical', 'minimum', 'maximum')
_CHARGE_RESIDUAL_LIMIT_PERCENT = 0.1
# Printed labels are rounded and commercial grades are not the pure salt (Yara Calcinit: -0.56 %); a one-point
# typing error moves the balance by about 2 % (#93, owner decision 2026-10-23).
_CHARGE_RESIDUAL_LIMIT_PERCENT = 1.0
# Theory is the pure compound, not the commercial solution or the chelate product as sold,
# so a grade range of these identities is not compared with it (the charge test still applies).
_RANGE_TEST_EXEMPT = frozenset({'aqueous_solution', 'chelate_salt_defined'})
Expand Down
30 changes: 27 additions & 3 deletions tests/test_materials.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,15 @@ def test_lot_grade_mismatch_raises(self):
self.assertEqual(caught.exception.code, 'material_mismatch')

def test_identity_inconsistent_flag_on_old_mg_nitrate_analysis(self):
# 0.553 % is within the 1 % label limit (#93, owner 2026-10-23); a one-point Mg error is not.
old = grade(identity_id='idn-magnesium-nitrate-hexahydrate', analysis={
'N_NO3': {'typical': 11.0}, 'Mg': {'typical': 9.65}})
self.assertIn('identity_inconsistent', old.flags)
self.assertAlmostEqual(old.charge_residual_percent, 0.553, places=3)
resolved = resolve_material('Mg Nitrate', grade=old)
self.assertNotIn('identity_inconsistent', old.flags)
wrong = grade(identity_id='idn-magnesium-nitrate-hexahydrate', analysis={
'N_NO3': {'typical': 11.0}, 'Mg': {'typical': 10.65}})
self.assertIn('identity_inconsistent', wrong.flags)
resolved = resolve_material('Mg Nitrate', grade=wrong)
self.assertIn('identity_inconsistent', resolved.flags)
theory = load_identities()['idn-magnesium-nitrate-hexahydrate']['theoretical_analysis']
exact = grade(identity_id='idn-magnesium-nitrate-hexahydrate', analysis={
Expand Down Expand Up @@ -752,7 +756,8 @@ def test_user_grade_drives_o5_gate(self):
self.assertAlmostEqual(residual['percent'], 0.5532, places=3)
r = self.ec({'Mg Nitrate': 1.0}, materials={'Mg Nitrate': retired})
self.assertEqual((r.status, r.reason), ('not_supported', 'assay_charge_residual_too_large'))
self.assertIn('identity_inconsistent', r.audit['materials']['Mg Nitrate']['flags'])
# The label itself is within the 1 % limit (#93); the EC screen keeps its own 0.1 % gate.
self.assertNotIn('identity_inconsistent', r.audit['materials']['Mg Nitrate']['flags'])
shipped = self.ec({'Mg Nitrate': 1.0})
self.assertNotEqual(shipped.reason, 'assay_charge_residual_too_large')

Expand Down Expand Up @@ -1246,5 +1251,24 @@ def test_declared_compound_matches_equivalent_salts(self):
self.assertAlmostEqual(compound.ph, salts.ph, delta=1e-6)


class LabelToleranceTests(unittest.TestCase):
"""#93: printed labels are rounded and commercial grades are not the pure salt; 1 % (owner, 2026-10-23)."""

def calcinit(self, cao):
return grade(grade_id='grd-yara-calcinit', supplier='Yara', product='YaraLiva Calcinit', identity_id=CAN,
analysis={'N_NO3': {'typical': 14.4}, 'N_NH4': {'typical': 1.1}, 'CaO': {'typical': cao}})

def test_yaras_printed_calcinit_label_is_accepted(self):
label = self.calcinit(26.3)
self.assertAlmostEqual(label.charge_residual_percent, -0.565, places=3)
self.assertNotIn('identity_inconsistent', label.flags)
self.assertNotIn('identity_inconsistent', resolve_material('Calcium Nitrate (ag grade)', grade=label).flags)

def test_a_one_point_calcium_error_is_refused(self):
typo = self.calcinit(26.3 * 19.8 / 18.8) # Ca 19.8 % typed for 18.8 %
self.assertGreater(abs(typo.charge_residual_percent), 1.0)
self.assertIn('identity_inconsistent', typo.flags)


if __name__ == '__main__':
unittest.main()
Loading
0