8000 flahax mix: sessions by name, readable small weights, which container sets the batch (#105) by rafatahmed · Pull Request #106 · 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

- `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.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion src/flahax/batch_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ def run_batch(options):
summary = summarize_batch(workflow, products, title=title, water_label=label, injection_ratio=options.ratio,
rules=rules, temperature_c=options.temperature_c)
if volumes is not None:
summary['volumes'] = asdict(volumes)
summary['volumes'] = dict(asdict(volumes), mode=options.mode)
weighed = {p['name'] for t in summary['tanks']['tanks'] for p in t['products']}
summary['product_notes'] = {r['name']: list(r.get('compatibility_notes') or (r.get('grade') or {}).get('compatibility_notes') or [])
for r in products if r.get('name') in weighed}
Expand Down
23 changes: 21 additions & 2 deletions src/flahax/grower_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,33 @@ 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':
return ''
capacity = volumes['capacity_litres']
smallest = min(capacity, key=capacity.get)
if all(c == capacity[smallest] for c in capacity.values()):
return ''
name = 'acid container' if smallest == 'acid' else f'tank {smallest}'
return f' (your {litres(capacity[smallest])} L {name} sets the batch)'


def _grams(product):
return f'{number(product["gramsPerBatch"], 1)} g'
return grams(product['gramsPerBatch'])


def _tank_card(tank, volumes, blocked):
name = tank['name']
head = (f'prepare <b>{litres(volumes["prepared_litres"][name])} L</b> in your '
f'{litres(volumes["capacity_litres"][name])} L tank') if volumes else ''
f'{litres(volumes["capacity_litres"][name])} L tank{limited_by(volumes)}') if volumes else ''
box = '<span class="muted">-</span>' if blocked else '<span class="tick"></span>'
rows = ''.join(f'<tr><td>{box}</td><td>{text(p["name"])}</td><td class="num">{_grams(p)}</td></tr>'
for p in tank['products'])
Expand Down
30 changes: 24 additions & 6 deletions src/flahax/mix_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
once every step is answered: Ctrl+C or end of input saves nothing.
"""
import argparse
from dataclasses import asdict
from datetime import date
import json
import math
Expand All @@ -21,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, key_reasons, litres, render_mix_sheet # noqa: F401 (STATUS_WORDS re-exported)
from .grower_sheet import STATUS_WORDS, grams, key_reasons, limited_by, litres, render_mix_sheet # noqa: F401 (STATUS_WORDS re-exported)
from .materials 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 @@ -127,7 +128,8 @@ def ask_spec(p):
except DeliveryError as exc:
p.say(f' {exc}')
p.say(f' {number(volumes.irrigation_litres, 0)} L of irrigation; prepare '
f'{litres(volumes.prepared_litres["A"])} L in each container.')
f'{litres(volumes.prepared_litres["A"])} L in each container'
f'{limited_by(dict(asdict(volumes), mode="once" if once else "fill"))}.')
return dict(tank_litres=tanks, ratio=ratio, temperature_c=temperature, mode='once' if once else 'fill',
irrigation_litres=irrigation)

Expand Down Expand Up @@ -344,10 +346,20 @@ def interview(p, store):


def find_session(store, spec):
"""A session file by path, or by its saved name typed any way ('Tomato Stage.1' finds tomato-stage-1)."""
for candidate in (Path(spec), Path(spec + '.json'), store.root / spec, store.root / (spec + '.json')):
if candidate.is_file():
return candidate
raise DeliveryError('unknown_item', f'no session file {spec!r} (looked here and in {store.root / "sessions"})')
name = spec.replace('\\', '/').split('sessions/', 1)[-1]
try:
candidate = store.path('sessions', slug(name))
except DeliveryError:
candidate = None
if candidate is not None and candidate.is_file():
return candidate
saved = store.list('sessions')
raise DeliveryError('unknown_item', f'no saved session {spec!r}. '
+ ('Saved: ' + ', '.join(saved) if saved else 'Nothing is saved yet: run flahax mix'))



Expand All @@ -366,9 +378,10 @@ def summarize(summary, say):
name = tank['name']
say(f'Tank {name}: ' + ('not to be prepared (do not mix)' if blocked else
f'prepare {litres(volumes["prepared_litres"][name])} L in your '
f'{litres(volumes["capacity_litres"][name])} L tank' + ('' if tank['acid_allowed'] else '; no acid')))
f'{litres(volumes["capacity_litres"][name])} L tank{limited_by(volumes)}'
+ ('' if tank['acid_allowed'] else '; no acid')))
for product in tank['products']:
say(f' {"-" if blocked else "[ ]"} {product["name"]}: {number(product["gramsPerBatch"], 1)} g')
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 '
Expand All @@ -381,14 +394,19 @@ def summarize(summary, say):
def main(argv=None, *, ask=input, say=print, store=None):
parser = argparse.ArgumentParser(prog='flahax mix', description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('session', nargs='?', help='a saved session file, e.g. sessions/tomato-ro')
parser.add_argument('session', nargs='*', help='a saved session, e.g. tomato-ro (flahax mix sessions lists them)')
parser.add_argument('--output', help='new directory for the sheets (default: ./flahax-mix-<session>)')
args = parser.parse_args(argv)
if args.output and Path(args.output).exists():
say(f'flahax mix: {args.output} already exists; choose a new directory')
return 1
store = store or Store()
p = Prompts(ask, say)
args.session = ' '.join(args.session)
if args.session.strip('/\\') == 'sessions':
saved = store.list('sessions')
say('Saved sessions: ' + ', '.join(saved) if saved else 'Nothing is saved yet: run flahax mix')
return 0
try:
if args.session:
path = find_session(store, args.session)
Expand Down
25 changes: 24 additions & 1 deletion tests/test_mix_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ def test_first_run_tomato_ro_fill_mode(self):
self.assertIn('2,000 L of irrigation', sheet)
self.assertEqual(summary['volumes'], {'irrigation_litres': 2000.0, 'prepared_litres':
{'A': 20.0, 'B': 20.0, 'acid': 20.0},
'capacity_litres': {'A': 20.0, 'B': 20.0, 'acid': 20.0}})
'capacity_litres': {'A': 20.0, 'B': 20.0, 'acid': 20.0},
'mode': 'fill'})
self.assertEqual(summary['tanks']['temperature_c'], 25)
self.assertTrue(any(line.startswith('Tank A') for line in lines), lines)
word = {'ready': 'ready', 'requires_review': 'review notes before mixing'}[summary['status']]
Expand Down Expand Up @@ -199,6 +200,28 @@ def test_a_report_without_chloride_is_not_blocked(self):
self.assertIn('has no chloride, so it is closed on the alkalinity', report)
self.assertNotIn('chloride', (target / 'batch-sheet.html').read_text(encoding='utf-8').lower())

def test_owners_second_session_by_name_and_readable_grams(self):
# Field test 2026-10-23: 100 L tanks with a 10 L acid container, saved as 'tomato stage 1'.
spec = ['100', '100', '10', '', '', '1']
stage1 = ['1', 'tomato stage.1']
code, lines, _ = self.run_mix(spec + RO + stage1 + CATALOGUE + NITRIC + ['6', '1', 'tomato stage 1'],
'--output', str(Path(self.tmp.name) / 'first'))
self.assertEqual(code, 0, '\n'.join(lines))
self.assertIn(' 1,000 L of irrigation; prepare 10 L in each container (your 10 L acid container sets the batch).',
lines)
self.assertTrue(any(l.startswith('Tank A: prepare 10 L in your 100 L tank (your 10 L acid container') for l in lines))
weights = [l for l in lines if l.startswith(' [ ] ')]
self.assertFalse([l for l in weights if l.endswith(': 0 g')], weights)
for typed in (['Tomato', 'Stage.1'], ['tomato', 'stage', '1'], ['sessions/Tomato', 'Stage', '1']):
with self.subTest(typed=typed):
code, again, _ = self.run_mix([], *typed, '--output', str(Path(self.tmp.name) / '-'.join(typed)))
self.assertEqual(code, 0, '\n'.join(again))
code, listed, _ = self.run_mix([], 'sessions')
self.assertEqual((code, listed), (0, ['Saved sessions: tomato-stage-1']))
code, missing, _ = self.run_mix([], 'sessions/x')
self.assertEqual(code, 1)
self.assertIn("Saved: tomato-stage-1", missing[-1])

def test_blank_is_never_guessed(self):
values = dict(BALANCED, Fe='', Mn='0', Zn='<0.02')
code, lines, _ = self.full(water_answers=water('blanks', values, alkalinity='2'))
Expand Down
Loading
0