8000 Every output of one batch agrees: pH path, weights, acid, warnings (#107-#110) by rafatahmed · Pull Request #111 · 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

- Every output of one batch now agrees (#107-#110), found by generating the owner's session at all three depths. The agronomist sheet's pH path and the report's 'go from pH' used the first recipe's pH (6.71) while the report's chemistry used the weighed recipe (7.93), whose nitrate salts are reduced by the acid's nitrate: all now show the weighed recipe mixed without its reagent (`summary['ph']['mixed_ph']`), and the report also names the first recipe's pH. Weights and acid volumes use one format on the terminal, sheet and report (no more '0' weights or 86 vs 85.7 mL); each warning appears once (the pH stage no longer repeats the review rows and the iron(II) advisory); the report shows '1:100' and the status in words. A new test (`tests/test_output_consistency.py`) runs the owner's session once and checks every shared number across terminal, sheet at each depth, report and summary. Recipes and chemistry are unchanged.
- `flahax mix` finds a saved session by the name you typed (spaces, capitals, with or without `sessions/`), `flahax mix sessions` lists them, and a miss lists them too; small weights show 2-3 decimals and never read '0 g'; in fill mode the outputs say which container sets the batch ('your 10 L acid container sets the batch') (#105).
- Plain `flahax` at a terminal shows where to start (`flahax mix` for growers) instead of waiting silently for JSON; `flahax --version` works; the usage line says `flahax`, not the path to python.exe; Ctrl+C prints 'Stopped.' without a traceback (#102). Piped JSON requests work as before.
- 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.
Expand Down
56 changes: 43 additions & 13 deletions src/flahax/grower_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@
MICRO = ('Fe', 'Mn', 'Zn', 'B', 'Cu', 'Mo')


def litres(value):
"""A volume a grower can measure: one decimal from 10 L, three below (never '0 L' for a small batch)."""
return number(value, 1 if value >= 10 else 3)




def grams(value):
"""A weight a grower can read on a scale: never '0 g' for a product that is in the recipe."""
digits = 1 if value >= 10 else 2 if value >= .1 else 3
shown = number(value, digits)
return f'{shown} g' if float(shown.replace(',', '')) > 0 else 'less than 0.001 g'




def millilitres(value):
"""A reagent volume, one decimal, the same on every output."""
return number(value, 1)


def text(value):
return escape(str(value), quote=True)

Expand Down Expand Up @@ -168,12 +189,14 @@ def candidate(c):
status=c.status, volume_ml_per_batch=None if c.reagent_volume_litres is None
else 1000 * c.reagent_volume_litres, mass_g_per_batch=c.reagent_mass_g,
nutrient_contribution=c.nutrient_contribution, message=c.message)
ph = dict(status=ph_stage.status, direction=plan.direction, unadjusted_ph=plan.unadjusted_ph,
# mixed_ph: the weighed recipe without its reagent (the refit when a reagent is chosen, #107).
weighed = chosen.mixed.ph if chosen is not None and chosen.mixed is not None else plan.unadjusted_ph
ph = dict(status=ph_stage.status, direction=plan.direction, unadjusted_ph=plan.unadjusted_ph, mixed_ph=weighed,
target_ph=plan.target_ph, selected=candidate(chosen) if chosen is not None else None,
candidates=[candidate(c) for c in plan.candidates if c.status != 'not_applicable'])
elif ph_stage is not None and ph_stage.error_code == NOT_MODELLABLE:
# A product that cannot be modelled: the nutrients stand, no reagent is planned; the sheet names it.
ph = dict(status=ph_stage.status, direction=None, unadjusted_ph=None, target_ph=workflow.target_ph,
ph = dict(status=ph_stage.status, direction=None, unadjusted_ph=None, mixed_ph=None, target_ph=workflow.target_ph,
selected=None, candidates=[], message=ph_stage.message)

chemistry = ec = None
Expand Down Expand Up @@ -213,11 +236,15 @@ def candidate(c):

warnings = list(recipe['warnings'])
for name, stage in stages.items():
if stage['status'] in ('blocked', 'requires_review') and stage['message']:
warnings.append(f'{name}: {stage["message"]}')
message = stage['message']
if name == 'ph_adjustment' and chemistry and chosen is not None and chosen.message and message and message.startswith(chosen.message):
message = message[len(chosen.message):].lstrip('; ') # listed below as review rows and advisories
if stage['status'] in ('blocked', 'requires_review') and message:
warnings.append(f'{name}: {message}')
if chemistry:
warnings += [f'Review: {r["phase"]} SI {r["si"]:.2f}. {r["reason"]}' for r in chemistry['review']]
warnings += chemistry['advisories']
warnings = list(dict.fromkeys(warnings))
if ph_stage is not None and ph_stage.error_code == NOT_MODELLABLE:
status = 'blocked' if workflow.stages['formulation'].status == 'blocked' else 'requires_review'
warnings.append(f'{ph_stage.message}. pH was not planned: measure it after mixing and adjust before use.')
Expand Down Expand Up @@ -309,6 +336,7 @@ def candidate(c):
@media print{main{padding:0}}
"""

STATUS_WORDS = {'ready': 'ready', 'requires_review': 'review notes before mixing', 'blocked': 'do not mix'}
STATUS_TEXT = {
'ready': ('ok', 'Every target is met and the pH plan is accepted.'),
'requires_review': ('warn', 'Usable after review: read the warnings before mixing.'),
Expand Down Expand Up @@ -337,8 +365,8 @@ def _reagent_line(summary):
return (f'No acceptable pH reagent: the feed mixes at pH {number(ph["unadjusted_ph"], 2)} and needs '
f'{"an acid" if ph["direction"] == "acid" else "a base"} to reach {number(ph["target_ph"], 2)}. '
'See the candidates and their reasons.')
return (f'Add {number(chosen["volume_ml_per_batch"], 0)} mL of {chosen["product"]} ({chosen["formula"]}) per '
f'{number(summary["batch_litres"], 0)} L to go from pH {number(ph["unadjusted_ph"], 2)} to '
return (f'Add {millilitres(chosen["volume_ml_per_batch"])} mL of {chosen["product"]} ({chosen["formula"]}) per '
f'{number(summary["batch_litres"], 0)} L to go from pH {number(ph["mixed_ph"], 2)} to '
f'{number(ph["target_ph"], 2)}. Add it in steps and measure.')


Expand All @@ -352,7 +380,7 @@ def render_report(summary):
f'<h1>{text(s["title"])}</h1><p>Water: {text(s["water_label"])}. Batch {number(s["batch_litres"], 0)} L, '
f'target pH {number(s["target_ph"], 2)}.</p>'
'<div class="cards">'
+ _card('Plan', s['status'].replace('_', ' '), 'ready / requires review / blocked')
+ _card('Plan', s['status'].replace('_', ' '), STATUS_WORDS[s['status']])
+ _card('Targets met', 'Yes' if f['feasible'] else 'No', f'{len(f["unmet"])} missed')
+ _card('Feed pH', number(chem['final_ph'], 2) if chem else 'not planned',
f'mixed {number(chem["mixed_ph"], 2)}' if chem else 'needs complete water chemistry')
Expand All @@ -364,7 +392,7 @@ def render_report(summary):
+ '</section>'
'<section><h2>Recipe</h2>'
+ table(['Product', 'g/L', f'g per {number(s["batch_litres"], 0)} L'],
[(x['name'], number(x['gramsPerLitre'], 4), number(x['gramsPerBatch'], 1)) for x in f['salts']],
[(x['name'], number(x['gramsPerLitre'], 4), grams(x['gramsPerBatch'])) for x in f['salts']],
'Products to weigh')
+ ''.join(bars(f'{label.capitalize()}: target and delivered', [r['symbol'] for r in group],
[('Target', [r['target'] for r in group]), ('Delivered', [r['final'] for r in group])], 'mg/L')
Expand All @@ -380,11 +408,13 @@ def render_report(summary):
+ '</section>')
if ph is not None and ph['unadjusted_ph'] is not None:
body += ('<section><h2>pH adjustment</h2>'
f'<p>The recipe mixes at pH {number(ph["unadjusted_ph"], 2)}; it needs '
f'<p>The first recipe mixes at pH {number(ph["unadjusted_ph"], 2)}; it needs '
f'{"an acid" if ph["direction"] == "acid" else "a base"} to reach {number(ph["target_ph"], 2)}. Each '
'candidate is dosed to the target and the recipe is refitted with its nutrients counted.</p>'
'candidate is dosed to the target and the recipe is refitted with its nutrients counted'
+ (f'; the weighed recipe mixes at pH {number(ph["mixed_ph"], 2)} before the reagent'
if ph['selected'] and ph['mixed_ph'] is not None else '') + '.</p>'
+ table(['Reagent', 'Formula', 'mL per batch', 'Status', 'Reason'],
[(c['product'], c['formula'], number(c['volume_ml_per_batch'], 0), c['status'], c['message'] or '')
[(c['product'], c['formula'], millilitres(c['volume_ml_per_batch']), c['status'], c['message'] or '')
for c in ph['candidates']], 'Reagents evaluated')
+ '</section>')
if chem is not None:
Expand Down Expand Up @@ -470,13 +500,13 @@ def _water_section(s):


def _tank_section(s, tanks):
rows = [(f'Tank {t["name"]}', p['name'], number(p['gramsPerBatch'], 1), number(p['stockGramsPerLitre'], 2), p['basis'])
rows = [(f'Tank {t["name"]}', p['name'], grams(p['gramsPerBatch']), number(p['stockGramsPerLitre'], 2), p['basis'])
for t in tanks['tanks'] for p in t['products']]
notes = ''.join(f'<li>Tank {text(t["name"])}: {text(n)}</li>' for t in tanks['tanks'] for n in t['notes'])
separated = ''.join(f'<li>{text(a)} / {text(b)}: {text(why)}</li>' for a, b, why in tanks['separations'])
waived = ''.join(f'<li>{text(a)} / {text(b)}: {text(why)}</li>' for a, b, why in tanks['waived'])
return ('<section><h2>Concentrate tanks</h2>'
+ table(['Tank', 'Product', f'g per {number(s["batch_litres"], 0)} L', f'Stock g/L at 1:{tanks["injection_ratio"]}',
+ table(['Tank', 'Product', f'g per {number(s["batch_litres"], 0)} L', f'Stock g/L at 1:{tanks["injection_ratio"]:g}',
'Why here'], rows, 'Tanks from the default compatibility table')
+ table(['Tank', 'Product', 'Stock g/L', f'Dissolves up to g/L at {number(tanks["temperature_c"], 1)} °C',
'Solubility', 'Source'], _solubility_rows(s), 'Does each concentrate dissolve?')
Expand Down
24 changes: 6 additions & 18 deletions src/flahax/grower_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
the limits and the pH path. Depth 3 (developer, manager) writes the full report beside it.
Every number comes from ``summarize_batch``, i.e. from the recipe that is weighed.
"""
from .grower_report import SHEET_STYLE, _limit_rows, _page, _reagent_line, number, text
from .grower_report import (SHEET_STYLE, STATUS_WORDS, _limit_rows, _page, _reagent_line, grams, # noqa: F401
litres, millilitres, number, text)

STATUS_WORDS = {'ready': 'ready', 'requires_review': 'review notes before mixing', 'blocked': 'do not mix'}
LABEL = {'N_NO3': 'NO3-N', 'N_NH4': 'NH4-N', 'N_UREA': 'Urea-N'}
CARD_STYLE = SHEET_STYLE + """
.cards{display:grid;grid-template-columns:1fr 1fr;gap:8px}.card{border:1.5px solid #103f49;border-radius:6px;padding:7px 10px}
Expand All @@ -16,11 +16,6 @@
"""


def litres(value):
"""A volume a grower can measure: one decimal from 10 L, three below (never '0 L' for a small batch)."""
return number(value, 1 if value >= 10 else 3)


def key_reasons(summary, n=3):
"""The warnings a grower must read, blocking ones first (a blocked stage, a hard limit, a stock that won't dissolve)."""
stopped = tuple(f'{name}:' for name, s in summary['stages'].items() if s['status'] == 'blocked')
Expand All @@ -44,13 +39,6 @@ def agronomist_rows(summary):
return sorted(rows, key=lambda r: r['target'] is None)


def grams(value):
"""A weight a grower can read on a scale: never '0 g' for a product that is in the recipe."""
digits = 1 if value >= 10 else 2 if value >= .1 else 3
shown = number(value, digits)
return f'{shown} g' if float(shown.replace(',', '')) > 0 else 'less than 0.001 g'


def limited_by(volumes):
"""In fill mode, the container that sets the batch when another one could hold more."""
if not volumes or volumes.get('mode') != 'fill':
Expand Down Expand Up @@ -86,10 +74,10 @@ def _reagent_card(summary, volumes):
return f'<div class="card warn"><h2>pH reagent</h2><p>{text(_reagent_line(summary))}</p></div>'
title = 'Acid' if chosen['direction'] == 'acid' else 'pH reagent'
per_1000 = chosen['volume_ml_per_batch'] * 1000.0 / summary['batch_litres']
container = (f'<p>Acid container: add <b>{number(chosen["volume_ml_per_batch"], 1)} mL</b> to water and make up to '
container = (f'<p>Acid container: add <b>{millilitres(chosen["volume_ml_per_batch"])} mL</b> to water and make up to '
f'<b>{litres(volumes["prepared_litres"]["acid"])} L</b>.</p>') if volumes else ''
return (f'<div class="card"><h2>{title}</h2><p><b>{text(chosen["product"])}</b> ({text(chosen["formula"])}): '
f'{number(per_1000, 1)} mL per 1,000 L of irrigation, on its own injector.</p>{container}'
f'{millilitres(per_1000)} mL per 1,000 L of irrigation, on its own injector.</p>{container}'
'<p class="small"><b>Add acid to water, never water to acid.</b></p></div>')


Expand Down Expand Up @@ -127,8 +115,8 @@ def cell(v, digits=2):
path = []
if chem:
path.append(f'water {number(chem["water_ph"], 2)}')
if ph and ph.get('unadjusted_ph') is not None:
path.append(f'after fertilizers {number(ph["unadjusted_ph"], 2)}')
if ph and ph.get('mixed_ph') is not None:
path.append(f'after fertilizers {number(ph["mixed_ph"], 2)}')
if ph and ph['selected']:
path.append(f'after {ph["selected"]["direction"]} {number(ph["target_ph"], 2)}')
adds = ', '.join(f'{LABEL.get(k, k)} {number(v, 2)} mg/L'
Expand Down
4 changes: 2 additions & 2 deletions src/flahax/mix_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from .delivery_contracts import DeliveryError
from .engine import load_library
from .grower_report import number, render_report
from .grower_sheet import STATUS_WORDS, grams, key_reasons, limited_by, litres, render_mix_sheet # noqa: F401 (STATUS_WORDS re-exported)
from .grower_sheet import STATUS_WORDS, grams, key_reasons, limited_by, litres, millilitres, render_mix_sheet # noqa: F401 (STATUS_WORDS re-exported)
from .material 10000 s import declared_ions_check, load_identities, make_grade, resolve_material
from .mix_store import Store, slug
from .mix_water import MICROS, WATER_IONS, ask_analysis, to_elements # noqa: F401 (re-exported)
Expand Down Expand Up @@ -384,7 +384,7 @@ def summarize(summary, say):
say(f' {"-" if blocked else "[ ]"} {product["name"]}: {grams(product["gramsPerBatch"])}')
selected = summary['ph'] and summary['ph']['selected']
if selected and not blocked:
say(f'Acid container: {number(selected["volume_ml_per_batch"], 0)} mL {selected["product"]}, added to water '
say(f'Acid container: {millilitres(selected["volume_ml_per_batch"])} mL {selected["product"]}, added to water '
f'and made up to {litres(volumes["prepared_litres"]["acid"])} L (acid to water, never water to acid)')
elif not blocked:
say('pH reagent: none planned' + (f'. {summary["ph"]["message"]}' if summary['ph'] and
Expand Down
90 changes: 90 additions & 0 deletions tests/test_output_consistency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""One batch, every output: terminal, batch sheet (each depth), full report and summary must agree (#107-#110).

The owner's session of 2026-10-23: RO water, Tomato Stage.1, 100 L tanks with a 10 L acid container,
nitric 65 % (1.3 kg/L), pH 6, answered once and written at the full depth.
"""
import html
import json
import os
from pathlib import Path
import re
import tempfile
import unittest
from unittest import mock

from flahax.grower_report import grams, millilitres, number
from flahax.grower_sheet import render_mix_sheet
from flahax.mix_cli import main

ANSWERS = ['100', '100', '10', '100', '25', '1', '1', '', '1', 'tomato', '2', '1', '1',
'1', 'HNO3', '65', '1.3', 'HNO3 65%', '2', '6', '3', 'tomato stage 1']


def text_of(page):
return html.unescape(re.sub(r'<[^>]+>', ' ', page))


class OwnerSessionConsistency(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
out, answers, lines = Path(cls.tmp.name) / 'out', list(ANSWERS), []
with mock.patch.dict(os.environ, {'FLAHAX_HOME': str(Path(cls.tmp.name) / 'home')}):
code = main(['--output', str(out)], ask=lambda prompt: answers.pop(0),
say=lambda *a: lines.append(' '.join(map(str, a))))
assert code == 0, lines
cls.terminal = '\n'.join(lines)
cls.summary = json.loads((out / 'summary.json').read_text(encoding='utf-8'))
cls.report = text_of((out / 'report.html').read_text(encoding='utf-8'))
cls.sheets = {d: text_of(render_mix_sheet(cls.summary, d)) for d in (1, 2, 3)}
cls.selected = cls.summary['ph']['selected']

@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()

def outputs(self):
return {'terminal': self.terminal, 'report': self.report, **{f'sheet {d}': s for d, s in self.sheets.items()}}

def test_every_weight_reads_the_same_everywhere_and_never_zero(self):
for tank in self.summary['tanks']['tanks']:
for product in tank['products']:
shown = grams(product['gramsPerBatch'])
self.assertNotIn(shown, ('0 g', '0.0 g'))
for name, page in self.outputs().items():
with self.subTest(product=product['name'], output=name):
self.assertIn(f'{product["name"]}', page)
self.assertIn(shown.replace(' g', ''), page)
self.assertNotRegex(self.report, r'\(Monohydrate\)\s+[\d.e-]+\s+0\s') # no '0' weight in the report

def test_the_acid_reads_the_same_everywhere(self):
per_batch = millilitres(self.selected['volume_ml_per_batch'])
for name, page in self.outputs().items():
with self.subTest(output=name):
self.assertIn(f'{per_batch} mL', page)
self.assertNotIn(f'{number(self.selected["volume_ml_per_batch"], 0)} mL of', page)

def test_one_ph_after_fertilizers_the_weighed_recipe(self):
mixed = number(self.summary['chemistry']['mixed_ph'], 2)
self.assertEqual(number(self.summary['ph']['mixed_ph'], 2), mixed)
self.assertNotEqual(mixed, number(self.summary['ph']['unadjusted_ph'], 2)) # the case that hid the bug
for d in (2, 3):
self.assertIn(f'after fertilizers {mixed}', self.sheets[d])
self.assertIn(f'mixed {mixed}', self.report)
self.assertIn(f'from pH {mixed} to', self.report)

def test_each_warning_once(self):
for name, page in (('report', self.report), ('sheet 2', self.sheets[2])):
for phrase in ('Hydroxylapatite does not form directly', 'Iron supplied as iron(II) oxidizes'):
with self.subTest(output=name, phrase=phrase):
reasons = page.split('Why review')[-1] if name.startswith('sheet') else page.split('Recipe')[0]
self.assertEqual(reasons.count(phrase), 1, reasons)

def test_report_formatting(self):
self.assertNotIn('1:100.0', self.report)
self.assertNotIn('ready / requires review / blocked', self.report)
self.assertIn('review notes before mixing', self.report)


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