8000 feat: complete grower batch and mix acceptance by rafatahmed · Pull Request #232 · 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ each release, with numbers, sources and decisions, are in

### Added

- Evaluate confirmed catalogue alternatives separately and report the direction, search result,
charge check and nutrient misses for a grower batch (#231). Show solid pH reagent doses in
grams in batch and mix (#229).

- Add native console/PTY and controlled terminal lifecycle checks, including
canonical-result and stale-session rejection in the full-screen workflow (#182).

Expand Down
12 changes: 12 additions & 0 deletions docs/site/agronomist/batch-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ This page takes you from a water analysis and a crop to a printed batch sheet in
`flahax batch`, then works through a tomato example on a real brackish well. FlahaX plans a batch;
it does not run pumps or dose anything. Its evidence is computational: it has not been validated in
the laboratory or in the field, so every sheet ends with measurements you take after mixing.
For an acid certificate, `--acid FORMULA:FRACTION:DENSITY:NAME:SOLVENT_FRACTION`
declares the solvent in grams per gram of product. If the certificate omits that
last field, batch can use a labelled sequential pH calculation. Joint fitting of
a dry reagent also needs `--max-solid-reagent-grams`, the maximum dry mass for
this batch. The liquid volume cap applies to liquids. Check the selected method
and limitations on the report.

For a restricted product store, repeat `--confirmed-product CATALOGUE_ID` for
each excluded fertilizer that is actually available. Batch calculates each
alternative separately and keeps the original recipe. A saved mix session accepts
the same IDs in `confirmed_products` and an optional `max_solid_reagent_grams`.

## Step 1. Enter your water analysis

Save the analysis as a small JSON file, for example `well.json`:
Expand Down
21 changes: 18 additions & 3 deletions docs/site/developer/joint-problem.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@ change the final stationarity threshold. A separate Decimal cubic-root witness
checks a correlated-source nonlinear optimum.


The batch workflow enables paired steps and `whole_mix=True` with equilibrium
sensitivities and stationarity checks. When paired steps make little progress in
both nutrient loss and stationarity, the solver can adjust all doses together,
subject to the charge equation and declared caps. Near a binding cap it can
refine the doses along that boundary. All proposals share the original iteration
budget. Final pH, charge, hard limits and G25 nutrient fit are checked separately.
The batch candidate's joint record reports iterations and solver evaluations.

Charge-pivot and paired-source selection use the caller's existing
`active_bound_tolerance`. A positive dose within that lower-bound distance is
not treated as a freely adjustable charge pivot. This prevents microscopic
Expand Down Expand Up @@ -337,13 +345,20 @@ The original charge tolerance and independent final verification still apply.
## Public joint workflow

`plan_fertilizer_workflow(..., ph_adjustment={..., "joint": config})` routes every
offered reagent through the qualified joint solver. Initial recipe pH does not
exclude a reagent in this mode. Supply all of these `config` fields:
offered reagent through the joint solver in general redesign mode. With
`correct_mixed_ph=True`, batch first selects the correction direction from the
unadjusted recipe. It skips the opposite direction and uses no reagent when the
mix is already at target. Batch uses a sequential alternative when a declared
solvent fraction or dry mass cap is missing and no eligible joint fit verifies.
The candidate records which method ran and why. Supply these `config` fields:

- `solvent_mass_kg`: final solvent inventory, equal to `equilibrium.water_mass_kg`.
- `reagent_solvent_fractions`: reagent ID to explicitly declared solvent mass fraction.
- `maximum_reagent_mass_g`: reagent ID to hard product-mass cap. The existing
`maximum_reagent_volume_litres` is also enforced.
`maximum_reagent_volume_litres` is enforced for liquid and general joint inputs. Batch
treats an explicitly identified dry solid in `solid_reagent_ids` as mass-only: its
declared gram cap replaces the liquid volume cap. Without a gram cap it can use
only the labelled sequential alternative, which supplies no mass-cap verification.
- `controls`: the explicit `solve_joint_problem` keyword controls, or a mapping
from reagent ID to its controls. Each dose-step map names exactly that
candidate's fertilizer IDs and reagent ID.
Expand Down
16 changes: 12 additions & 4 deletions docs/site/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,12 @@ The same from files in one command, for scripts.
```text
usage: flahax batch [-h] [--formula FORMULA | --crop CROP | --targets TARGETS] [--list-formulas]
[--water WATER] [--water-share PERCENT] [--volume VOLUME] [--ph PH]
[--ratio RATIO] [--ec EC] [--acid FORMULA:FRACTION:DENSITY[:NAME]]
[--ratio RATIO] [--ec EC]
[--acid FORMULA:FRACTION:DENSITY[:NAME[:SOLVENT_FRACTION]]]
[--limit SYMBOL=MG_PER_L] [--max-reagent-litres MAX_REAGENT_LITRES]
[--products PRODUCTS] [--rules RULES] [--output OUTPUT]
[--confirmed-product CATALOGUE_ID]
[--max-solid-reagent-grams MAX_SOLID_REAGENT_GRAMS] [--products PRODUCTS]
[--rules RULES] [--output OUTPUT]

``flahax batch``: one formula and one water to a report, a batch sheet and a JSON summary (#79).

Expand Down Expand Up @@ -127,13 +130,18 @@ options:
--ratio RATIO final litres per litre of concentrate (default 100)
--ec EC requested EC25 in mS/cm, compared with the estimated range (never
enforced)
--acid FORMULA:FRACTION:DENSITY[:NAME]
an acid from your certificate, e.g. HNO3:0.60:1.37; repeat for more
--acid FORMULA:FRACTION:DENSITY[:NAME[:SOLVENT_FRACTION]]
certificate acid; append the declared solvent fraction for joint fitting
(e.g. HNO3:0.60:1.37:aqueous:0.40)
--limit SYMBOL=MG_PER_L
a maximum concentration in the final solution, e.g. Cl=106.35; repeat for
more. Checked on the laboratory analysis; none is assumed
--max-reagent-litres MAX_REAGENT_LITRES
largest reagent volume per batch (default volume/200)
--confirmed-product CATALOGUE_ID
rerun an available excluded catalogue fertilizer as a separate remedy
--max-solid-reagent-grams MAX_SOLID_REAGENT_GRAMS
maximum dry pH reagent mass per batch (g) for joint fitting
--products PRODUCTS JSON array of your product records (default: the packaged catalogue)
--rules RULES JSON array of extra compatibilityRule records separating products
--output OUTPUT new directory for report.html, batch-sheet.html and summary.json
Expand Down
175 changes: 164 additions & 11 deletions src/flahax/batch_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
from .delivery_quantities import SolventBasis, VolumeLitres
from .engine import load_library
from .fertilizer_workflow import plan_fertilizer_workflow
from .grower_report import millilitres, render_batch_sheet, render_report, summarize_batch
from .grower_report import reagent_amount, render_batch_sheet, render_report, summarize_batch
from .measured_water import balance_water_analysis
from .ph_adjustment import catalogue_ph_reagents
from .ph_adjustment import catalogue_ph_reagents, nutrient_salts
from .planning_cli import serializable
from .product_conversion import MOLAR_MASS
from .ph_adjustment import REAGENTS
Expand Down Expand Up @@ -343,11 +343,12 @@ def limit_entry(spec):


def acid_record(spec):
"""``FORMULA:MASS_FRACTION:DENSITY_KG_PER_L[:NAME]`` from your supplier certificate."""
"""Parse a supplier certificate, optionally including its declared solvent mass fraction."""
parts = spec.split(':')
if len(parts) not in (3, 4):
if len(parts) not in (3, 4, 5):
raise DeliveryError(
'invalid_value', f'--acid {spec}: expected FORMULA:MASS_FRACTION:DENSITY[:NAME]'
'invalid_value',
f'--acid {spec}: expected FORMULA:MASS_FRACTION:DENSITY[:NAME[:SOLVENT_FRACTION]]',
)
numbers = []
for label, text in (('assay (mass fraction)', parts[1]), ('density (kg/L)', parts[2])):
Expand All @@ -369,7 +370,19 @@ def acid_record(spec):
'invalid_value',
f'--acid {spec}: the assay is a fraction in (0, 1] and the density in kg/L',
)
name = parts[3].strip() if len(parts) == 4 else f'{formula} {fraction:.0%}'
name = parts[3].strip() if len(parts) >= 4 else f'{formula} {fraction:.0%}'
solvent = None
if len(parts) == 5:
try:
solvent = float(parts[4])
except ValueError as error:
raise DeliveryError(
'invalid_value', 'invalid declared reagent solvent fraction'
) from error
if not (math.isfinite(solvent) and 0 <= solvent <= 1 and fraction + solvent <= 1):
raise DeliveryError(
'invalid_value', 'solvent and active fractions must fit in one gram'
)
return dict(
id=f'{formula}-{fraction:g}-{density:g}',
name=name,
Expand All @@ -378,6 +391,7 @@ def acid_record(spec):
recordedAt='2026-01-01T00:00:00Z',
chemicalFormula=formula,
massFraction=fraction,
solventMassFraction=solvent,
densityKgPerL=density,
)

Expand Down Expand Up @@ -432,6 +446,107 @@ class BatchOptions:
temperature_c: float = 25
depth: int = 3
title: str | None = None
max_solid_reagent_grams: float | None = None
confirmed_products: list[str] = field(default_factory=list)


# Batch search controls. The loss threshold is an exact-fit shortcut. A constrained
# local solution can finish by stationarity even when nutrient targets are missed.
JOINT_CONTROLS = dict(
charge_scale=0.001,
charge_tolerance=2e-12,
loss_tolerance=1e-16,
sensitivity_tolerance=0.01,
trust_radius=10000,
sensitivity_method='equilibrium',
curvature_block=True,
whole_mix=True,
stationarity_tolerance=1e-6,
active_bound_tolerance=1e-8,
max_iterations=60,
max_backtracks=20,
)
JOINT_DOSE_STEP_G_PER_L = 1e-4


def joint_configuration(products, reagents, solvent_mass_kg, cap_litres, solid_cap=None):
"""The explicit joint-fit configuration for every offered reagent.

Only declared solvent fractions enter the joint problem. Liquid mass caps use their
recorded densities. Dry solids use a separately declared mass cap.
"""
if solid_cap is not None and (
isinstance(solid_cap, bool)
or not isinstance(solid_cap, (int, float))
or not math.isfinite(solid_cap)
or solid_cap < 0
):
raise DeliveryError(
'invalid_value', 'solid reagent mass cap must be finite and nonnegative'
)
pool_ids = [p['id'] for p in nutrient_salts(products)]
solvents, masses, fallbacks, solids = {}, {}, {}, []
for reagent in reagents:
identity = reagent['id']
if reagent.get('solventMassFraction') is not None:
solvents[identity] = reagent['solventMassFraction']
if reagent.get('physicalForm') == 'solid':
solids.append(identity)
if solid_cap is not None:
masses[identity] = solid_cap
elif reagent.get('densityKgPerL') is not None:
masses[identity] = 1000 * cap_litres * reagent['densityKgPerL']
else:
# Neither a known solid nor a liquid with a verifiable volume cap.
continue
if identity not in solvents:
fallbacks[identity] = 'joint fit needs a declared solvent fraction'
elif identity not in masses and identity in solids:
fallbacks[identity] = 'joint fit needs a declared solid reagent mass cap'
return dict(
controls={
r['id']: dict(
JOINT_CONTROLS,
dose_steps={i: JOINT_DOSE_STEP_G_PER_L for i in [*pool_ids, r['id']]},
)
for r in reagents
},
solvent_mass_kg=solvent_mass_kg,
reagent_solvent_fractions=solvents,
maximum_reagent_mass_g=masses,
sequential_fallbacks=fallbacks,
solid_reagent_ids=solids,
)


def left_out_products(library, products, confirmed_products=()):
"""One declared remedy per catalogue fertilizer the grower's product list leaves out.

Only excluded catalogue IDs explicitly confirmed by the grower are rerun.
"""
present = {p['id'] for p in products}
extras = [p for p in nutrient_salts(library['salts']) if p['id'] not in present]
if not isinstance(confirmed_products, (list, tuple)) or not all(
isinstance(i, str) for i in confirmed_products
):
raise DeliveryError('invalid_remedy', 'confirmed products must be a list of catalogue IDs')
confirmed = set(confirmed_products)
invalid = confirmed - {p['id'] for p in extras}
if invalid or len(confirmed) != len(confirmed_products):
raise DeliveryError(
'invalid_remedy',
'confirmed IDs must be unique excluded catalogue fertilizers'
+ (': ' + ', '.join(sorted(invalid)) if invalid else ''),
)
return [
dict(
id='catalogue:' + p['name'],
kind='catalogue',
available=p['id'] in confirmed,
changes=dict(products=[p]),
)
for p in extras
]


def run_batch(options):
Expand Down Expand Up @@ -460,6 +575,23 @@ def run_batch(options):
rules = read_json(options.rules, 'rules') if options.rules else ()
reagents = [acid_record(a) for a in options.acid] + catalogue_ph_reagents()
limits = dict(options.limit)
cap_litres = options.max_reagent_litres or volume / 200
joint = (
None
if chemistry is None
else joint_configuration(
products,
reagents,
volume * chemistry['concentration_basis']['water_kg_per_litre'],
cap_litres,
options.max_solid_reagent_grams,
)
)
remedies = left_out_products(library, products, options.confirmed_products)
if joint is not None and options.confirmed_products:
joint['additional_product_steps'] = dict.fromkeys(
options.confirmed_products, JOINT_DOSE_STEP_G_PER_L
)
workflow = plan_fertilizer_workflow(
products,
targets,
Expand All @@ -477,9 +609,13 @@ def run_batch(options):
),
ph_adjustment=dict(
products=reagents,
maximum_reagent_volume_litres=options.max_reagent_litres or volume / 200,
correct_mixed_ph=True,
maximum_reagent_volume_litres=cap_litres,
**({} if joint is None else {'joint': joint}),
),
maximum_concentrations=limits or None,
remedy_options=remedies,
max_remedy_evaluations=len(options.confirmed_products),
)
summary = summarize_batch(
workflow,
Expand Down Expand Up @@ -557,6 +693,8 @@ def build(args):
acid=args.acid,
limit=args.limit,
max_reagent_litres=args.max_reagent_litres,
max_solid_reagent_grams=args.max_solid_reagent_grams,
confirmed_products=args.confirmed_product,
ec=args.ec,
products=args.products,
rules=args.rules,
Expand Down Expand Up @@ -614,8 +752,9 @@ def main(argv=None):
'--acid',
action='append',
default=[],
metavar='FORMULA:FRACTION:DENSITY[:NAME]',
help='an acid from your certificate, e.g. HNO3:0.60:1.37; repeat for more',
metavar='FORMULA:FRACTION:DENSITY[:NAME[:SOLVENT_FRACTION]]',
help='certificate acid; append the declared solvent fraction for joint fitting '
'(e.g. HNO3:0.60:1.37:aqueous:0.40)',
)
parser.add_argument(
'--limit',
Expand All @@ -631,6 +770,18 @@ def main(argv=None):
type=number_in('--max-reagent-litres', 0),
help='largest reagent volume per batch (default volume/200)',
)
parser.add_argument(
'--confirmed-product',
action='append',
default=[],
metavar='CATALOGUE_ID',
help='rerun an available excluded catalogue fertilizer as a separate remedy',
)
parser.add_argument(
'--max-solid-reagent-grams',
type=number_in('--max-solid-reagent-grams', 0, above=False),
help='maximum dry pH reagent mass per batch (g) for joint fitting',
)
parser.add_argument(
'--products', help='JSON array of your product records (default: the packaged catalogue)'
)
Expand Down Expand Up @@ -661,9 +812,11 @@ def main(argv=None):
for tank in summary['tanks']['tanks']:
print(f' Tank {tank["name"]}: ' + ', '.join(p['name'] for p in tank['products']))
selected = summary['ph'] and summary['ph']['selected']
if selected:
if summary['ph'] and summary['ph'].get('direction') == 'none':
print(' pH: already within target tolerance; no reagent needed')
elif selected:
print(
f' pH: {millilitres(selected["volume_ml_per_batch"])} mL {selected["product"]} per '
f' pH: {reagent_amount(selected)} {selected["product"]} per '
f'{summary["batch_litres"]:g} L'
)
print(f'Wrote {", ".join(files)} to {output}')
Expand Down
4 changes: 3 additions & 1 deletion src/flahax/canonical_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,10 @@ def result_statuses(summary):
stages = summary['stages']
chemistry = summary.get('chemistry')
stocks = summary['tanks']
selected = (summary.get('ph') or {}).get('selected') or {}
joint = selected.get('joint') or {}
return dict(
numerical='not_reported',
numerical=joint.get('status', 'not_reported'),
formulation='feasible' if summary['formulation']['feasible'] else 'infeasible',
chemistry=stages.get('chemistry', {}).get('status', 'needs_input'),
phase='requires_review'
Expand Down
Loading
Loading
0