flahax 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ # Connecting FlahaX to FlahaFAST
2
+
3
+ This repository is the Python package. FlahaFAST does not contain a copy. The application calls FlahaX only when `FLAHAX_RECOMMENDATIONS=true`. With the variable unset, Water → Crop → Salts → Recipe → Results is unchanged.
4
+
5
+ The server runs `python -m flahax` with `PYTHONPATH` set to the package `src` directory.
6
+
7
+ 1. `FLAHAX_SRC`, when it is set.
8
+ 2. A sibling checkout, `../Flahax/src`, when this app is started from `FlahaFast/server`.
9
+ 3. The older in-app folder `flahax/src`, if a copy is still on disk.
10
+
11
+ On the production host the installed tree is `/var/www/flahafast.flaha.org/flahax/src`. Set `FLAHAX_SRC` to that path in `server/.env`. Updating the package is a copy from this repository. It is not part of the FlahaFAST deploy archive.
12
+
13
+ ## What must already be true
14
+
15
+ FlahaX needs the crop targets after the user confirms Crop, and the water ions after the user confirms Water. Both exist only when the user reaches Salts. The salts pre-checked today come from the formula record. That is not a FlahaX result.
16
+
17
+ Recipe weighs whatever salts were confirmed. If an accepted FlahaX mix is thrown away there and solved again, the recommendation is lost. The grams travel with the salts only when the user accepted them.
18
+
19
+ A saved run is never rewritten.
20
+
21
+ ## Start without a gap
22
+
23
+ One switch, default off: `FLAHAX_RECOMMENDATIONS`.
24
+
25
+ While it is off, every screen is the current flow. No new request, no new button, no change to Open results.
26
+
27
+ While it is on:
28
+
29
+ 1. Salts asks for a recommendation only after water and the formula are both saved. The call is allowed to fail. On failure or timeout the salt page shows exactly what it shows today, including the formula’s own salt ids when those exist.
30
+ 2. The recommendation is a panel, not a new stage. **Use this combination** replaces the selection and stores the grams on the draft. **Keep my salts** leaves the selection untouched and stores nothing from FlahaX.
31
+ 3. Recipe, if and only if the draft says the user accepted, uses those grams as the salt amounts and does not solve them again. A/B and pH stay optional and unchanged. If the user did not accept, Recipe solves as it does now.
32
+ 4. Open results saves the balance that Recipe actually produced. The snapshot may record `flahaxAccepted: true` or `false`. It does not replace the result with a new solve at save time.
33
+ 5. History analysis, for a saved run with `flahaxAccepted` not true, may show the recommendation beside the saved balance. If that call fails, the history page omits the panel. The saved numbers stay.
34
+
35
+ ## What not to do in the first connection
36
+
37
+ Do not block Salts while the recommendation loads. Do not change Crop, Water, or the formula row in the database. Do not auto-accept. Do not run FlahaX inside Open results. Do not deploy the Python package inside the Node process until the salt panel has been tried with the switch on and with the switch off.
38
+
39
+ The first technical join is a single command, `python -m flahax`, reading JSON on stdin and writing the `recommend` result on stdout. The Node server starts it only when the switch is on. A non-zero exit becomes “no recommendation,” and the user continues.
flahax-0.1.0/LICENSE ADDED
@@ -0,0 +1,65 @@
1
+ SPDX-License-Identifier: LicenseRef-Flaha-Free-Use
2
+
3
+ Flaha Free Use License
4
+ Version 1.0
5
+
6
+ Copyright (c) 2026 Flaha
7
+
8
+ This license covers the FlahaX software and its accompanying files (the
9
+ "Software"), including source code, the salt library data shipped with the
10
+ package, and documentation in this repository.
11
+
12
+ 1. Free use
13
+
14
+ You may install and run the Software, at no charge, for any purpose,
15
+ including commercial work, research, and personal use. You may make the
16
+ copies that installation and execution require on computers you control,
17
+ and you may keep reasonable backup copies for that same use. Obtaining
18
+ the Software from the copyright holder's official repository or official
19
+ package release, for your own use, is allowed.
20
+
21
+ 2. No redistribution
22
+
23
+ You may not distribute, publish, sublicense, sell, rent, lend, or otherwise
24
+ give the Software to anyone else, in source form or in built form. That
25
+ includes republishing this repository, uploading the package to a package
26
+ index, mirroring the files, or shipping the Software inside a product or
27
+ archive that you give to a third party.
28
+
29
+ 3. No modification
30
+
31
+ You may not modify, translate, adapt, or create derivative works of the
32
+ Software. You may not remove or change copyright notices, license text, or
33
+ author attributions.
34
+
35
+ 4. Reservation of rights
36
+
37
+ The copyright holder reserves all rights that this license does not
38
+ expressly grant. No patent rights and no trademark rights are granted.
39
+ "Flaha" and "FlahaX" remain marks of their owner.
40
+
41
+ 5. Termination
42
+
43
+ If you break section 2 or section 3, this license ends automatically for
44
+ you. Sections 6 and 7 continue to apply.
45
+
46
+ 6. No warranty
47
+
48
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
49
+ IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
50
+ PURPOSE, AND NON-INFRINGEMENT. THE ENTIRE RISK AS TO THE QUALITY AND USE
51
+ OF THE SOFTWARE IS WITH YOU.
52
+
53
+ 7. Limitation of liability
54
+
55
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR
56
+ OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM OR
57
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.
58
+
59
+ 8. How this license relates to open source
60
+
61
+ This license makes use of the Software free. It does not permit copying
62
+ the Software onward or modifying it. Because those permissions are
63
+ withheld, this license is a proprietary free-use license. It is not an
64
+ open-source license under the Open Source Definition, and it is not free
65
+ software under the Free Software Definition.
@@ -0,0 +1,10 @@
1
+ include README.md
2
+ include LICENSE
3
+ include INTEGRATION.md
4
+ include pyproject.toml
5
+ recursive-include docs *.md
6
+ recursive-include src/flahax *.py
7
+ recursive-include src/flahax/data *.json
8
+ recursive-include tests *.py
9
+ global-exclude __pycache__
10
+ global-exclude *.py[cod]
flahax-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: flahax
3
+ Version: 0.1.0
4
+ Summary: Choose fertilizer salts and weights that minimize crop-formula percentage error.
5
+ Author: Flaha
6
+ License-Expression: LicenseRef-Flaha-Free-Use
7
+ Project-URL: Homepage, https://github.com/rafatahmed/Flahax
8
+ Project-URL: Documentation, https://github.com/rafatahmed/Flahax/tree/main/docs
9
+ Project-URL: Source, https://github.com/rafatahmed/Flahax
10
+ Project-URL: Issues, https://github.com/rafatahmed/Flahax/issues
11
+ Keywords: fertilizer,hydroponics,nutrient-solution,formulation
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # FlahaX
26
+
27
+ FlahaX chooses a fertilizer combination and the grams of each salt for a fixed crop formula. Version 0.1.0. It is a separate Python package. The FlahaFAST application does not import it.
28
+
29
+ The crop formula does not change. This season’s water is subtracted from it. The gap that remains is what the salts must cover. Every real salt in the library is a candidate. Non-negative grams decide which salts stay in the mix.
30
+
31
+ ## License
32
+
33
+ Use of FlahaX is free of charge, including commercial use. The [Flaha Free Use License](LICENSE) allows you to install and run the package. It does not allow you to modify the software, and it does not allow you to give copies of it to anyone else. Downloading an official release for your own use is covered. Republishing, forking, or shipping your own copy is not.
34
+
35
+ Because modification and redistribution are withheld, this is a proprietary free-use license. It is outside the Open Source Definition, which requires those permissions.
36
+
37
+ ## Install
38
+
39
+ Python 3.11 or newer. No third-party dependencies.
40
+
41
+ From this repository, in a virtual environment:
42
+
43
+ ```powershell
44
+ python -m pip install -e .
45
+ python -c "import flahax; print(flahax.__version__)"
46
+ ```
47
+
48
+ The package is not on a public index yet. Building a release is described in [docs/publishing.md](docs/publishing.md).
49
+
50
+ ## Quick start
51
+
52
+ ```python
53
+ from flahax import load_library, recommend
54
+
55
+ result = recommend(
56
+ load_library()["salts"],
57
+ targets={"N_NO3": 128, "P": 58, "K": 211, "Ca": 104, "Mg": 40, "S": 54},
58
+ water={"Ca": 20},
59
+ )
60
+ ```
61
+
62
+ The same call from the command line, reading JSON on standard input:
63
+
64
+ ```powershell
65
+ @'
66
+ {"targets": {"N_NO3": 128, "P": 58, "K": 211, "Ca": 104, "Mg": 40, "S": 54}, "water": {"Ca": 20}}
67
+ '@ | python -m flahax
68
+ ```
69
+
70
+ `result["salts"]` lists each chosen salt with `gramsPerLitre`. `result["rows"]` lists each element with its target, the final ppm, and `deltaPct`.
71
+
72
+ ## Proof
73
+
74
+ Edited pepper targets, with 20 ppm calcium already in the water, fit inside 1% on every targeted element using six salts:
75
+
76
+ | Salt | g/L |
77
+ |---|---:|
78
+ | Potassium nitrate | 0.546 |
79
+ | Magnesium nitrate | 0.415 |
80
+ | Calcium sulfate | 0.290 |
81
+ | Phosphoric acid (75%) | 0.146 |
82
+ | Calcium nitrate (ag grade) | 0.046 |
83
+ | Calcium monobasic phosphate | 0.048 |
84
+
85
+ The mathematics, the library counts, and the regression case are in [docs/method.md](docs/method.md).
86
+
87
+ ## Documentation
88
+
89
+ - [docs/usage.md](docs/usage.md) — install, library call, command line, inputs, and result fields
90
+ - [docs/method.md](docs/method.md) — ppm equation, solver, library file, and the pepper proof
91
+ - [docs/publishing.md](docs/publishing.md) — build the sdist and wheel
92
+ - [INTEGRATION.md](INTEGRATION.md) — how FlahaFAST can call the package without changing the current flow
93
+
94
+ ## Tests
95
+
96
+ After `pip install -e .`, from the repository root:
97
+
98
+ ```powershell
99
+ python -m unittest discover -s tests -t .
100
+ ```
101
+
102
+ ## Scope of this version
103
+
104
+ Grams are for one litre of the solution the plant sees. This version does not split tanks, adjust pH, price the mix, or apply a concentration factor. It does not write to the FlahaFAST database.
flahax-0.1.0/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # FlahaX
2
+
3
+ FlahaX chooses a fertilizer combination and the grams of each salt for a fixed crop formula. Version 0.1.0. It is a separate Python package. The FlahaFAST application does not import it.
4
+
5
+ The crop formula does not change. This season’s water is subtracted from it. The gap that remains is what the salts must cover. Every real salt in the library is a candidate. Non-negative grams decide which salts stay in the mix.
6
+
7
+ ## License
8
+
9
+ Use of FlahaX is free of charge, including commercial use. The [Flaha Free Use License](LICENSE) allows you to install and run the package. It does not allow you to modify the software, and it does not allow you to give copies of it to anyone else. Downloading an official release for your own use is covered. Republishing, forking, or shipping your own copy is not.
10
+
11
+ Because modification and redistribution are withheld, this is a proprietary free-use license. It is outside the Open Source Definition, which requires those permissions.
12
+
13
+ ## Install
14
+
15
+ Python 3.11 or newer. No third-party dependencies.
16
+
17
+ From this repository, in a virtual environment:
18
+
19
+ ```powershell
20
+ python -m pip install -e .
21
+ python -c "import flahax; print(flahax.__version__)"
22
+ ```
23
+
24
+ The package is not on a public index yet. Building a release is described in [docs/publishing.md](docs/publishing.md).
25
+
26
+ ## Quick start
27
+
28
+ ```python
29
+ from flahax import load_library, recommend
30
+
31
+ result = recommend(
32
+ load_library()["salts"],
33
+ targets={"N_NO3": 128, "P": 58, "K": 211, "Ca": 104, "Mg": 40, "S": 54},
34
+ water={"Ca": 20},
35
+ )
36
+ ```
37
+
38
+ The same call from the command line, reading JSON on standard input:
39
+
40
+ ```powershell
41
+ @'
42
+ {"targets": {"N_NO3": 128, "P": 58, "K": 211, "Ca": 104, "Mg": 40, "S": 54}, "water": {"Ca": 20}}
43
+ '@ | python -m flahax
44
+ ```
45
+
46
+ `result["salts"]` lists each chosen salt with `gramsPerLitre`. `result["rows"]` lists each element with its target, the final ppm, and `deltaPct`.
47
+
48
+ ## Proof
49
+
50
+ Edited pepper targets, with 20 ppm calcium already in the water, fit inside 1% on every targeted element using six salts:
51
+
52
+ | Salt | g/L |
53
+ |---|---:|
54
+ | Potassium nitrate | 0.546 |
55
+ | Magnesium nitrate | 0.415 |
56
+ | Calcium sulfate | 0.290 |
57
+ | Phosphoric acid (75%) | 0.146 |
58
+ | Calcium nitrate (ag grade) | 0.046 |
59
+ | Calcium monobasic phosphate | 0.048 |
60
+
61
+ The mathematics, the library counts, and the regression case are in [docs/method.md](docs/method.md).
62
+
63
+ ## Documentation
64
+
65
+ - [docs/usage.md](docs/usage.md) — install, library call, command line, inputs, and result fields
66
+ - [docs/method.md](docs/method.md) — ppm equation, solver, library file, and the pepper proof
67
+ - [docs/publishing.md](docs/publishing.md) — build the sdist and wheel
68
+ - [INTEGRATION.md](INTEGRATION.md) — how FlahaFAST can call the package without changing the current flow
69
+
70
+ ## Tests
71
+
72
+ After `pip install -e .`, from the repository root:
73
+
74
+ ```powershell
75
+ python -m unittest discover -s tests -t .
76
+ ```
77
+
78
+ ## Scope of this version
79
+
80
+ Grams are for one litre of the solution the plant sees. This version does not split tanks, adjust pH, price the mix, or apply a concentration factor. It does not write to the FlahaFAST database.
@@ -0,0 +1,71 @@
1
+ # Method
2
+
3
+ A salt is not a knob for one element. Potassium nitrate carries potassium and nitrate-nitrogen. Monopotassium phosphate carries phosphorus and potassium. Monoammonium phosphate carries phosphorus and ammonium. Stacking those salts by hand can leave phosphorus and potassium hundreds of percent away from the formula while the micronutrients, which each have their own salt, look perfect.
4
+
5
+ FlahaX answers a different question: given this formula and this water, which subset of the library, at which grams per litre, makes the percentage error as small as those compositions allow?
6
+
7
+ Every real salt in the library is a candidate. Non-negative grams decide which salts stay in the mix. No salt is given a role by name.
8
+
9
+ ## Equation
10
+
11
+ For one litre of working solution:
12
+
13
+ ```text
14
+ final[element] = water[element] + Σ grams[salt] × percent[salt, element] × 10
15
+ ```
16
+
17
+ `percent` is the element percentage stored on the salt (0–100). Multiplying by 10 converts one gram per litre at 1% into 10 ppm.
18
+
19
+ The gap for an element that has a target is:
20
+
21
+ ```text
22
+ gap = target − water
23
+ ```
24
+
25
+ Grams are constrained to be greater than or equal to zero. The solver is Lawson–Hanson non-negative least squares. Each element row is scaled so the residual is a fraction of the target:
26
+
27
+ ```text
28
+ Δ% = (final − target) / target × 100
29
+ loss = Σ (Δ% / 100)²
30
+ ```
31
+
32
+ An element that the formula does not ask for, such as ammonium or chloride, still has a light penalty: its ppm is divided by 100 and squared. That stops a salt from looking free when it dumps an element nobody requested. A sparsity term of `0.02` on each gram pushes salts that do not improve the fit back to zero, so the result is a short combination rather than a dusting of every salt.
33
+
34
+ `recommend` runs that solve on every usable salt at once. Salts with grams above 0.000001 g/L are the combination, so zinc, copper, and molybdenum stay in the result when the formula asks for them.
35
+
36
+ ## Library file
37
+
38
+ `load_library()` reads `flahax/data/library.json`, which is installed with the package. It is an export of the production database.
39
+
40
+ | Key | Count | Contents |
41
+ |---|---:|---|
42
+ | `salts` | 37 | Name, formula, element percentages |
43
+ | `waters` | 3 | `Default Water Profile` (no ions), `WC1` (Ca 20), `WC2` (N, Mg, Ca, Fe) |
44
+ | `formulas` | 26 | Name, targets, linked water, salt names stored on the formula |
45
+
46
+ Two rows are placeholders, not fertilizers: `Si_test` and `test_all`, both with formula text `Input Formula Here`. Unusable rows are those placeholders, salts whose name starts with `test_`, and salts with no elements. The other salts all enter the solve. Nothing in the solver prefers a salt because its name is MAP, MKP, or SOP.
47
+
48
+ The formulas stored in that file are the library rows. Pepper (Howard Resh) there is nitrate-N 190, P 40, K 340, Ca 170, S 360, ammonium 18. The edited pepper targets in the proof below are a separate case.
49
+
50
+ ## Proof: edited pepper, water calcium 20
51
+
52
+ Targets: nitrate-N 128, P 58, K 211, Ca 104, Mg 40, S 54. Water: Ca 20, so the calcium gap is 84.
53
+
54
+ The fit from the full library keeps six salts and holds every targeted element inside 1%:
55
+
56
+ | Salt | g/L |
57
+ |---|---:|
58
+ | Potassium nitrate | 0.546 |
59
+ | Magnesium nitrate | 0.415 |
60
+ | Calcium sulfate | 0.290 |
61
+ | Phosphoric acid (75%) | 0.146 |
62
+ | Calcium nitrate (ag grade) | 0.046 |
63
+ | Calcium monobasic phosphate | 0.048 |
64
+
65
+ A hand mix that forces large weights of both ammonium phosphate and potassium phosphate still produces about +216% phosphorus. That mix is the regression case in `tests/test_pepper.py`. The solver is not told to avoid it. The loss is simply higher, so those weights are not chosen.
66
+
67
+ ## Outside this version
68
+
69
+ This version does not split tanks, adjust pH, price the mix, or apply a concentration factor. Grams are for one litre of the solution the plant sees. Stock-tank grams are those grams multiplied by the concentration factor, and that step belongs to the recipe screen.
70
+
71
+ The package does not write to the FlahaFAST database and it does not change a saved run. How the application can call it is described in [INTEGRATION.md](../INTEGRATION.md).
@@ -0,0 +1,38 @@
1
+ # Publishing
2
+
3
+ This follows the [Python packaging tutorial](https://packaging.python.org/en/latest/tutorials/packaging-projects/). The project already uses the `src` layout, `pyproject.toml`, `README.md`, `LICENSE`, and `tests/`.
4
+
5
+ Build from the repository root, in the same directory as `pyproject.toml`:
6
+
7
+ ```powershell
8
+ py -m pip install --upgrade build
9
+ py -m build
10
+ ```
11
+
12
+ That writes two files under `dist/`:
13
+
14
+ - `flahax-0.1.0.tar.gz`, the source distribution
15
+ - `flahax-0.1.0-py3-none-any.whl`, the built distribution
16
+
17
+ Check them before uploading:
18
+
19
+ ```powershell
20
+ py -m pip install --upgrade twine
21
+ py -m twine check dist/*
22
+ ```
23
+
24
+ ## GitHub release
25
+
26
+ Pushing a GitHub Release runs `.github/workflows/publish.yml`. That workflow builds `dist/`, checks it with Twine, and publishes to PyPI. The workflow password is the repository secret `PYPI_API_TOKEN`. The username Twine sends is `__token__`. The token value stays in GitHub secret storage, not in this repository.
27
+
28
+ A new PyPI upload needs a new `version` in `pyproject.toml` before the next release. PyPI keeps a filename that was already published.
29
+
30
+ ## Manual upload
31
+
32
+ A local upload is the same build, then:
33
+
34
+ ```powershell
35
+ py -m twine upload dist/*
36
+ ```
37
+
38
+ The username is `__token__` and the password is an API token, including the `pypi-` prefix. Keep that token in `%USERPROFILE%\.pypirc`, not in this repository. `.gitignore` ignores a `.pypirc` copied here by mistake. A TestPyPI rehearsal adds `--repository testpypi` and uses an account on [test.pypi.org](https://test.pypi.org).
@@ -0,0 +1,108 @@
1
+ # Using FlahaX
2
+
3
+ FlahaX chooses fertilizer salts and the grams of each salt for one litre of working solution. The crop formula stays fixed. This season’s water is subtracted from it. The remaining gap is what the salts must cover.
4
+
5
+ Python 3.11 or newer is required. The package has no third-party dependencies.
6
+
7
+ ## Install
8
+
9
+ From a checkout of this repository, inside a virtual environment:
10
+
11
+ ```powershell
12
+ python -m pip install -e .
13
+ python -c "import flahax; print(flahax.__version__)"
14
+ ```
15
+
16
+ An editable install registers the `flahax` command and ships `data/library.json` with the package.
17
+
18
+ ## Library call
19
+
20
+ ```python
21
+ from flahax import load_library, recommend
22
+
23
+ library = load_library()
24
+ salts = library["salts"]
25
+ result = recommend(
26
+ salts,
27
+ targets={"N_NO3": 128, "P": 58, "K": 211, "Ca": 104, "Mg": 40, "S": 54},
28
+ water={"Ca": 20},
29
+ )
30
+ for salt in result["salts"]:
31
+ print(salt["name"], salt["gramsPerLitre"])
32
+ ```
33
+
34
+ `recommend` drops placeholder rows itself. You can pass the full `salts` list from `load_library()`.
35
+
36
+ ## Command line
37
+
38
+ The same solve is available as a program. It reads one JSON object from standard input and writes the `recommend` result to standard output.
39
+
40
+ ```powershell
41
+ @'
42
+ {"targets": {"N_NO3": 128, "P": 58, "K": 211, "Ca": 104, "Mg": 40, "S": 54}, "water": {"Ca": 20}}
43
+ '@ | python -m flahax
44
+ ```
45
+
46
+ After installation, `flahax` is the same program. A missing or empty `targets` object exits with status 1 and a message on standard error. `water` is optional and defaults to an empty map. Invalid JSON exits with status 1.
47
+
48
+ ## Public functions
49
+
50
+ ```python
51
+ from flahax import gap_for, load_library, recommend, solve_weights
52
+
53
+ load_library() -> dict
54
+ gap_for(targets, water) -> dict
55
+ recommend(salts, targets, water=None) -> dict
56
+ solve_weights(salts, targets, water=None, sparsity=0.0) -> dict
57
+ ```
58
+
59
+ `solve_weights` fits a set of salts you already chose. `recommend` calls it with `sparsity=0.02` after dropping unusable rows.
60
+
61
+ `forward(salts, grams, water=None)` and `score(achieved, targets)` live in `flahax.engine`. `forward` applies the ppm equation. `score` returns the loss and the percentage-error rows.
62
+
63
+ ## Inputs
64
+
65
+ Targets and water are maps of element symbol to ppm. Use the database symbols:
66
+
67
+ `N_NO3`, `N_NH4`, `P`, `K`, `Ca`, `Mg`, `S`, `Fe`, `Mn`, `Zn`, `B`, `Cu`, `Mo`.
68
+
69
+ A missing target means the formula does not ask for that element. That is different from a target of zero. Any ppm the salts still produce for an unrequested element is penalized.
70
+
71
+ A salt is a dictionary:
72
+
73
+ ```python
74
+ {
75
+ "id": "…",
76
+ "name": "Potassium Nitrate",
77
+ "formula": "KNO3",
78
+ "elements": {"N_NO3": 13.856, "K": 38.67},
79
+ }
80
+ ```
81
+
82
+ `elements` values are percentages from 0 to 100.
83
+
84
+ ## Result
85
+
86
+ ```python
87
+ {
88
+ "salts": [{"id": "…", "name": "…", "gramsPerLitre": 0.5458}],
89
+ "saltIds": ["…"],
90
+ "grams": [0.0, 0.5458],
91
+ "loss": 0.0,
92
+ "rows": [
93
+ {"symbol": "K", "target": 211, "final": 211.0, "deltaPct": 0.0}
94
+ ],
95
+ }
96
+ ```
97
+
98
+ `grams` lines up with the salt list that entered the solve, and it includes the zeros. `salts` contains only the salts that received a positive weight. `deltaPct` is `None` when the formula has no target for that symbol.
99
+
100
+ ## Tests
101
+
102
+ From the repository root, after the package is installed:
103
+
104
+ ```powershell
105
+ python -m unittest discover -s tests -t .
106
+ ```
107
+
108
+ `test_pepper.py` checks water subtraction, the edited pepper fit, and the forced double-phosphate miss. `test_library.py` checks that the export still has 37 salts, 3 waters, and 26 formulas, then calls `recommend` once per formula.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "flahax"
7
+ version = "0.1.0"
8
+ description = "Choose fertilizer salts and weights that minimize crop-formula percentage error."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "LicenseRef-Flaha-Free-Use"
12
+ license-files = ["LICEN[CS]E*"]
13
+ authors = [{ name = "Flaha" }]
14
+ keywords = ["fertilizer", "hydroponics", "nutrient-solution", "formulation"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Science/Research",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Scientific/Engineering",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/rafatahmed/Flahax"
29
+ Documentation = "https://github.com/rafatahmed/Flahax/tree/main/docs"
30
+ Source = "https://github.com/rafatahmed/Flahax"
31
+ Issues = "https://github.com/rafatahmed/Flahax/issues"
32
+
33
+ [project.scripts]
34
+ flahax = "flahax.__main__:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.setuptools.package-data]
40
+ flahax = ["data/*.json"]
flahax-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """FlahaX: choose salt weights that minimize formulation Δ%."""
2
+
3
+ from .engine import LIBRARY, gap_for, load_library, recommend, solve_weights
4
+
5
+ __all__ = ["LIBRARY", "gap_for", "load_library", "recommend", "solve_weights"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,29 @@
1
+ """Read targets and water as JSON on stdin. Write a recommendation on stdout."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ from flahax import load_library, recommend
7
+ from flahax.engine import _usable
8
+
9
+
10
+ def main() -> int:
11
+ try:
12
+ payload = json.load(sys.stdin)
13
+ except json.JSONDecodeError:
14
+ print("Expected a JSON object on stdin.", file=sys.stderr)
15
+ return 1
16
+ targets = payload.get("targets") if isinstance(payload, dict) else None
17
+ if not isinstance(targets, dict) or not targets:
18
+ print("targets are required.", file=sys.stderr)
19
+ return 1
20
+ water = payload.get("water") if isinstance(payload.get("water"), dict) else {}
21
+ salts = [salt for salt in load_library()["salts"] if _usable(salt)]
22
+ result = recommend(salts, targets, water)
23
+ json.dump(result, sys.stdout)
24
+ sys.stdout.write("\n")
25
+ return 0
26
+
27
+
28
+ if __name__ == "__main__":
29
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ {"salts":[{"id":"cmbpfudiw00lzq8z936qog27v","name":"Ammonium Chloride","formula":"NH4Cl","elements":{"N_NH4":26.191,"Cl":66.272}},{"id":"cmbpfudkh00n5q8z9rvo5siqe","name":"Ammonium Dibasic Phosphate","formula":"(NH4)2HPO4","elements":{"N_NH4":21.217,"P":23.451}},{"id":"cmbpfudkn00naq8z9kd4qsr60","name":"Ammonium Monobasic Phosphate","formula":"(NH4)H2PO4","elements":{"N_NH4":12.18,"P":26.924}},{"id":"cmbpfudkt00nfq8z9iiidqyz0","name":"Ammonium Sulfate","formula":"(NH4)2SO4","elements":{"N_NH4":21.203,"S":24.267}},{"id":"cmbpfudmb00omq8z9a5nu1i5b","name":"Boric Acid","formula":"H3BO3","elements":{"B":17.482}},{"id":"cmbpfudh500klq8z9c38yocuf","name":"CH - micro","formula":"Ch micro","elements":{"B":1.3,"Fe":7,"Zn":0.4,"Mn":2,"Cu":0.11,"Mo":0.05}},{"id":"cmbpfudk800mzq8z9sfy6dfb4","name":"Calcium Carbonate","formula":"CaCO3","elements":{"Ca":40.078}},{"id":"cmbpfudlm00o2q8z9pq5dsfqe","name":"Calcium Monobasic Phosphate","formula":"Ca(H2PO4)2.H2O","elements":{"P":24.779,"Ca":16.029}},{"id":"cmbpfudif00lkq8z99slfmci3","name":"Calcium Nitrate (ag grade)","formula":"5Ca(NO3)2.NH4NO3.10H2O","elements":{"N_NO3":14.4,"N_NH4":1.1,"Ca":19}},{"id":"cmbpfudmg00opq8z9icoyntnp","name":"Calcium Sulfate (Dihydrate)","formula":"CaSO4.2H2O","elements":{"Ca":23.28,"S":18.62}},{"id":"cmbpfudi100l9q8z9xit3ujoz","name":"Copper EDTA","formula":"CuEDTA","elements":{"Cu":14}},{"id":"cmbpfudjh00meq8z964kelatd","name":"Copper Nitrate (Hexahydrate)","formula":"Cu(NO3)2.6H2O","elements":{"N_NO3":9.48,"Cu":21.49}},{"id":"cmbpfudls00o7q8z929uh7kwl","name":"Copper Sulfate (pentahydrate)","formula":"CuSO4.5H2O","elements":{"S":12.84,"Cu":25.45}},{"id":"cmbpfudjv00mqq8z9m4ndatyl","name":"Iron DTPA","formula":"FeDTPA","elements":{"Fe":11}},{"id":"cmbpfudjz00mtq8z9nrll8fnq","name":"Iron EDDHA","formula":"FeEDDHA","elements":{"Fe":6}},{"id":"cmbpfudl400npq8z9esjvg92l","name":"Iron EDTA","formula":"Fe(EDTA)","elements":{"Fe":13}},{"id":"cmbpfudm400ohq8z9h4pbl5iz","name":"Iron II Sulfate (Hepahydrate)","formula":"FeSO4.7H2O","elements":{"S":11.53,"Fe":20.09}},{"id":"cmbpfudkd00n2q8z9pasbjo1b","name":"Magnesium Carbonate","formula":"MgCO3","elements":{"Mg":28.831}},{"id":"cmbpfudmm00ouq8z91yim9eru","name":"Magnesium Sulfate (Heptahydrate)","formula":"MgSO4.7H2O","elements":{"Mg":9.86,"S":13.01}},{"id":"cmbpfudhl00kyq8z9j8u2rr14","name":"Mg Nitrate","formula":"Mg(NO3)2","elements":{"N_NO3":11,"Mg":9.65}},{"id":"cmbpfudhw00l6q8z9wj92mk5s","name":"Mn EDTA","formula":"MnEDTA","elements":{"Mn":13}},{"id":"cmbpfudim00lrq8z97u3qbfds","name":"Phosphoric Acid (75%)","formula":"H3PO4","elements":{"P":31.61}},{"id":"cmbpfudk400mwq8z9sdy98y00","name":"Potassium Carbonate","formula":"K2CO3","elements":{"K":56.581}},{"id":"cmbpfudj400m4q8z9e2mo7drh","name":"Potassium Chloride","formula":"KCl","elements":{"K":52.448,"Cl":47.552}},{"id":"cmbpfudi500lcq8z93fydsckd","name":"Potassium Citrate","formula":"C6H5K3O7","elements":{"K":38.2808916}},{"id":"cmbpfudky00nkq8z9baprwvj6","name":"Potassium Dibasic Phosphate","formula":"K2HPO4","elements":{"P":17.781,"K":44.897}},{"id":"cmbpfudl900nsq8z98470qom2","name":"Potassium Monobasic Phosphate","formula":"KH2PO4","elements":{"P":22.758,"K":28.732}},{"id":"cmbpfudmt00ozq8z9gsnrs779","name":"Potassium Nitrate","formula":"KNO3","elements":{"N_NO3":13.856,"K":38.67}},{"id":"cmbpfudi900lfq8z9o74v7q4e","name":"Potassium Sulfate","formula":"K2SO4","elements":{"K":44.873,"S":18.402}},{"id":"cmbpfudh100kiq8z95a34gov0","name":"Si_test","formula":"Input Formula Here","elements":{"Si":10}},{"id":"cmbpfudja00m9q8z9so56ge40","name":"Sodium Borate (Decahydrate) (borax)","formula":"Na2B4O7.10H2O","elements":{"B":11.34,"Na":12.06}},{"id":"cmbpfudlf00nxq8z9wx4ryy2b","name":"Sodium Molybdate (Dihydrate)","formula":"Na2MoO4.2H2O","elements":{"Mo":39.65,"Na":19}},{"id":"cmbpfudjn00mjq8z9svikn6gq","name":"Sodium Nitrate","formula":"NaNO3","elements":{"N_NO3":16.482,"Fe":7,"Na":27.047}},{"id":"cmbpfudiq00luq8z9z720v4t7","name":"Zinc Nitrate (Hexahydrate)","formula":"Zn(NO3)2.6H2O","elements":{"N_NO3":9.42,"Zn":21.98}},{"id":"cmbpfudly00ocq8z9zrrcr9zt","name":"Zinc Sulfate (Monohydrate)","formula":"ZnSO4.H2O","elements":{"S":17.86,"Zn":36.44}},{"id":"cmbpfudhr00l3q8z9qrth913z","name":"Zn EDTA","formula":"ZnEDTA","elements":{"Zn":14}},{"id":"cmbpfudfu00jjq8z9z2imd5m5","name":"test_all","formula":"Input Formula Here","elements":{"N_NO3":1,"N_NH4":2,"P":3,"K":4,"Mg":5,"Ca":6,"S":7,"B":10,"Fe":8,"Zn":9,"Mn":14,"Cu":11,"Mo":12,"Na":13,"Si":15,"Cl":16}}],"waters":[{"id":"cmbpfucsa0000q8z9kspb6dkt","name":"Default Water Profile","ph":7,"ec":0,"elements":{}},{"id":"cmbp7428f0000fyp8j73d3g1j","name":"WC1","ph":7,"ec":0,"elements":{"Ca":20}},{"id":"cmbp7428n0003fyp8ohwvs5gb","name":"WC2","ph":7,"ec":0,"elements":{"N":5,"Mg":8,"Ca":20,"Fe":0.1}}],"formulas":[{"id":"cmbpfud0y006wq8z9ghn6t86g","name":"Chilli (maximumyield)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":320,"P":103,"K":364,"Mg":96,"Ca":330,"S":174,"B":0.7,"Fe":4.9,"Zn":0.25,"Cu":0.07,"Mo":0.05,"Mn":1.97},"saltNames":[]},{"id":"cmbpfud9f00eaq8z92ve3zic0","name":"Cucumber (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":140,"P":50,"K":350,"Mg":50,"Ca":200,"S":150,"B":0.3,"Fe":3,"Zn":0.1,"Cu":0.07,"Mo":0.03,"Mn":0.8},"saltNames":[]},{"id":"cmbpfucsg0002q8z9a5q0u7nu","name":"DEFAULT","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":210,"P":31,"K":235,"Mg":49,"Ca":200,"S":64,"B":0.5,"Fe":2.9,"Zn":0.05,"Cu":0.02,"Mo":0.05,"Mn":0.5},"saltNames":[]},{"id":"cmbpfud6y00c1q8z9emlc27x2","name":"Generic Bloom (maximumyield)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":130,"P":60,"K":300,"Mg":30,"Ca":100,"S":60,"B":0.5,"Fe":2,"Zn":0.1,"Cu":0.05,"Mo":0.05,"Mn":0.5},"saltNames":[]},{"id":"cmbpfudcu00haq8z9xwjoq1zt","name":"Generic Dry Season (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":177,"P":60,"K":200,"Mg":36,"Ca":250,"S":129,"B":0.5,"Fe":5,"Zn":0.05,"Cu":0.03,"Mo":0.02,"Mn":0.5,"N_NH4":53},"saltNames":[]},{"id":"cmbpfud7r00crq8z9qc0abr6h","name":"Generic Grow (maximumyield)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":160,"P":30,"K":230,"Mg":30,"Ca":100,"S":60,"B":0.5,"Fe":2,"Zn":0.1,"Cu":0.05,"Mo":0.05,"Mn":0.5},"saltNames":[]},{"id":"cmbpfudbu00giq8z9jquzw746","name":"Generic Wet Season (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":115,"P":50,"K":150,"Mg":50,"Ca":150,"S":50,"B":0.5,"Fe":5,"Zn":0.05,"Cu":0.03,"Mo":0.02,"Mn":0.5,"N_NH4":32},"saltNames":[]},{"id":"cmbpfucvl002kq8z9in7tpn5y","name":"Generic for Berries (Growing Edge)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":207,"P":55,"K":289,"Mg":38,"Ca":155,"S":51,"B":0.7,"Fe":6.8,"Zn":0.25,"Cu":0.07,"Mo":0.05,"Mn":1.97},"saltNames":[]},{"id":"cmbpfucun001uq8z9dgqeihsj","name":"Hoagland solution","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":210,"P":31,"K":235,"Mg":49,"Ca":200,"S":64,"B":0.5,"Fe":2.9,"Zn":0.05,"Cu":0.02,"Mo":0.05,"Mn":0.5},"saltNames":[]},{"id":"cmbpfud8k00dhq8z9yz59ziwb","name":"Lettuce 2 (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":165,"P":50,"K":210,"Mg":45,"Ca":190,"S":113,"B":0.5,"Fe":4,"Zn":0.1,"Cu":0.1,"Mo":0.05,"Mn":0.5,"N_NH4":15},"saltNames":[]},{"id":"cmbpfuddo00i2q8z9nldrxm9w","name":"Lettuce General (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":165,"P":50,"K":210,"Mg":45,"Ca":190,"S":65,"B":0.5,"Fe":4,"Zn":0.1,"Cu":0.1,"Mo":0.05,"Mn":0.5,"N_NH4":15},"saltNames":[]},{"id":"cmbpfud2n008cq8z99v63xpo5","name":"Melons (Douglas Peckenpaugh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":215,"P":86,"K":343,"Mg":85,"Ca":175,"S":113,"B":0.7,"Fe":6.8,"Zn":0.25,"Cu":0.07,"Mo":0.05,"Mn":1.97},"saltNames":[]},{"id":"cmbpfudb100fqq8z9wcljgmyi","name":"Pepper (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":190,"P":40,"K":340,"Mg":50,"Ca":170,"S":360,"B":0.33,"Fe":5,"Zn":0.33,"Cu":0.05,"Mo":0.05,"Mn":0.55,"N_NH4":18},"saltNames":[]},{"id":"cmbpfud3e0092q8z9vq8gw25x","name":"Rice (Douglas Peckenpaugh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":249,"P":58,"K":80,"Mg":65,"Ca":317,"S":87,"B":0.7,"Fe":5,"Zn":0.4,"Cu":0.07,"Mo":0.05,"Mn":0.8,"Si":100},"saltNames":[]},{"id":"cmbpfuctq0014q8z9li5oo75d","name":"Strawberry Drip Irrigation (schundler.com)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":80,"P":45,"K":100,"Mg":50,"Ca":200,"S":180,"B":0.5,"Fe":3,"Zn":0.5,"Cu":0.05,"Mo":0.05,"Mn":0.5},"saltNames":[]},{"id":"cmbpfudel00iuq8z9l2wiwsfy","name":"Strawberry Fruiting (growing edge)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":128,"P":58,"K":211,"Mg":40,"Ca":104,"S":54,"B":0.7,"Fe":5,"Zn":0.25,"Cu":0.07,"Mo":0.05,"Mn":2},"saltNames":[]},{"id":"cmbpfuda900f0q8z9hi9hu2gu","name":"Tomato (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":140,"P":50,"K":352,"Mg":50,"Ca":180,"S":168,"B":0.3,"Fe":5,"Zn":0.1,"Cu":0.07,"Mo":0.03,"Mn":0.8},"saltNames":[]},{"id":"cmbpfud6400bbq8z9gwrj4cw7","name":"Tomato Stage.1 - 10-14 days (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":100,"P":40,"K":200,"Mg":20,"Ca":100,"S":53,"B":0.3,"Fe":3,"Zn":0.1,"Cu":0.07,"Mo":0.03,"Mn":0.8},"saltNames":[]},{"id":"cmbpfud5900alq8z907fk2r92","name":"Tomato Stage.2 - first cluster (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":130,"P":55,"K":300,"Mg":33,"Ca":150,"S":109,"B":0.3,"Fe":3,"Zn":0.1,"Cu":0.07,"Mo":0.03,"Mn":0.8},"saltNames":[]},{"id":"cmbpfud4a009vq8z953niqg47","name":"Tomato Stage.3 - to plant maturity (Howard Resh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":180,"P":65,"K":400,"Mg":45,"Ca":400,"S":144,"B":0.3,"Fe":3,"Zn":0.1,"Cu":0.07,"Mo":0.03,"Mn":0.8},"saltNames":[]},{"id":"cmbpfucxi0040q8z94o3m2bps","name":"Tomatoes - Fourth Cluster (U of Florida)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":120,"P":50,"K":150,"Mg":50,"Ca":150,"S":60,"B":0.7,"Fe":2.8,"Zn":0.3,"Cu":0.2,"Mo":0.05,"Mn":0.8},"saltNames":[]},{"id":"cmbpfucz7005gq8z90ev9h6ao","name":"Tomatoes - Second Cluster (U of Florida)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":80,"P":50,"K":120,"Mg":40,"Ca":150,"S":50,"B":0.7,"Fe":2.8,"Zn":0.3,"Cu":0.2,"Mo":0.05,"Mn":0.8},"saltNames":[]},{"id":"cmbpfucyc004qq8z9mdajrg3w","name":"Tomatoes - Third Cluster (U of Florida)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":100,"P":50,"K":150,"Mg":40,"Ca":150,"S":50,"B":0.7,"Fe":2.8,"Zn":0.3,"Cu":0.2,"Mo":0.05,"Mn":0.8},"saltNames":[]},{"id":"cmbpfud030066q8z9fvdyru1m","name":"Tomatoes - till First Cluster (U of Florida)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":70,"P":50,"K":120,"Mg":40,"Ca":150,"S":50,"B":0.7,"Fe":2.8,"Zn":0.3,"Cu":0.2,"Mo":0.05,"Mn":0.8},"saltNames":[]},{"id":"cmbpfucwh003aq8z9vmh9kner","name":"Tomatoes - to termination (U of Florida)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":150,"P":50,"K":200,"Mg":50,"Ca":150,"S":60,"B":0.7,"Fe":2.8,"Zn":0.3,"Cu":0.2,"Mo":0.05,"Mn":0.8},"saltNames":[]},{"id":"cmbpfud1t007mq8z9w16hx6hw","name":"Tropical Lettuce (Douglas Peckenpaugh)","waterName":"Default Water Profile","water":{},"targets":{"N_NO3":190,"P":25,"K":98,"Mg":25,"Ca":216,"S":33,"B":0.7,"Fe":4.9,"Zn":0.25,"Cu":0.07,"Mo":0.05,"Mn":1.97},"saltNames":[]}]}
@@ -0,0 +1,235 @@
1
+ """FlahaX salt-combination search.
2
+
3
+ The crop formula is fixed. This season's water is subtracted from it.
4
+ The gap is what the salts must cover. Grams are per litre of working
5
+ solution and cannot be negative. The objective is the sum of squared
6
+ percentage errors on elements that have a target.
7
+
8
+ Not imported by the FlahaFAST application.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from importlib.resources import files
15
+
16
+ EPS = 1e-10
17
+
18
+
19
+ def load_library() -> dict:
20
+ """Salt library, water profiles, and crop formulas shipped with the package."""
21
+ text = files("flahax").joinpath("data/library.json").read_text(encoding="utf-8")
22
+ return json.loads(text)
23
+
24
+
25
+ def ppm_per_gram(percent: float) -> float:
26
+ """ppm in 1 litre from 1 gram of salt at an element percentage."""
27
+ return float(percent) * 10.0
28
+
29
+
30
+ def gap_for(targets: dict, water: dict | None = None) -> dict:
31
+ water = water or {}
32
+ symbols = set(targets) | set(water)
33
+ gap = {}
34
+ for symbol in symbols:
35
+ target = targets.get(symbol)
36
+ if target is None:
37
+ gap[symbol] = None
38
+ continue
39
+ gap[symbol] = float(target) - float(water.get(symbol) or 0.0)
40
+ return gap
41
+
42
+
43
+ def _transpose(rows: list[list[float]]) -> list[list[float]]:
44
+ return [list(col) for col in zip(*rows)]
45
+
46
+
47
+ def _matvec(rows: list[list[float]], vec: list[float]) -> list[float]:
48
+ return [sum(a * b for a, b in zip(row, vec)) for row in rows]
49
+
50
+
51
+ def _solve_square(matrix: list[list[float]], rhs: list[float]) -> list[float]:
52
+ """Gaussian elimination with partial pivoting."""
53
+ n = len(rhs)
54
+ a = [row[:] + [rhs[i]] for i, row in enumerate(matrix)]
55
+ for col in range(n):
56
+ pivot = max(range(col, n), key=lambda r: abs(a[r][col]))
57
+ if abs(a[pivot][col]) < EPS:
58
+ raise ValueError("singular least squares block")
59
+ a[col], a[pivot] = a[pivot], a[col]
60
+ div = a[col][col]
61
+ for j in range(col, n + 1):
62
+ a[col][j] /= div
63
+ for r in range(n):
64
+ if r == col:
65
+ continue
66
+ factor = a[r][col]
67
+ for j in range(col, n + 1):
68
+ a[r][j] -= factor * a[col][j]
69
+ return [a[i][n] for i in range(n)]
70
+
71
+
72
+ def _lstsq(columns: list[list[float]], b: list[float]) -> list[float]:
73
+ """Least squares: columns are the active salt vectors."""
74
+ if not columns:
75
+ return []
76
+ ata = []
77
+ for i, left in enumerate(columns):
78
+ row = []
79
+ for right in columns:
80
+ row.append(sum(x * y for x, y in zip(left, right)))
81
+ ata.append(row)
82
+ atb = [sum(x * y for x, y in zip(col, b)) for col in columns]
83
+ return _solve_square(ata, atb)
84
+
85
+
86
+ def nnls(matrix: list[list[float]], rhs: list[float]) -> list[float]:
87
+ """Lawson-Hanson non-negative least squares. matrix is rows × columns."""
88
+ if not matrix:
89
+ return []
90
+ cols = _transpose(matrix)
91
+ m = len(matrix)
92
+ n = len(cols)
93
+ x = [0.0] * n
94
+ passive: list[int] = []
95
+ active = list(range(n))
96
+
97
+ def residual() -> list[float]:
98
+ pred = _matvec(matrix, x)
99
+ return [rhs[i] - pred[i] for i in range(m)]
100
+
101
+ for _ in range(n * 4):
102
+ resid = residual()
103
+ w = [sum(col[i] * resid[i] for i in range(m)) for col in cols]
104
+ if not active:
105
+ break
106
+ enter = max(active, key=lambda j: w[j])
107
+ if w[enter] <= EPS:
108
+ break
109
+ passive.append(enter)
110
+ active.remove(enter)
111
+ while True:
112
+ sub = _lstsq([cols[j] for j in passive], rhs)
113
+ if all(value >= -EPS for value in sub):
114
+ for j in range(n):
115
+ x[j] = 0.0
116
+ for j, value in zip(passive, sub):
117
+ x[j] = max(0.0, value)
118
+ break
119
+ alpha = min(
120
+ x[j] / (x[j] - trial)
121
+ for j, trial in zip(passive, sub)
122
+ if trial < 0 and x[j] > trial
123
+ )
124
+ for j, trial in zip(passive, sub):
125
+ x[j] = x[j] + alpha * (trial - x[j])
126
+ stay = []
127
+ for j in passive:
128
+ if x[j] <= EPS:
129
+ x[j] = 0.0
130
+ active.append(j)
131
+ else:
132
+ stay.append(j)
133
+ passive = stay
134
+ if not passive:
135
+ break
136
+ return x
137
+
138
+
139
+ def score(achieved: dict, targets: dict) -> tuple[float, list[dict]]:
140
+ loss = 0.0
141
+ rows = []
142
+ symbols = list(dict.fromkeys([*targets.keys(), *achieved.keys()]))
143
+ for symbol in symbols:
144
+ target = targets.get(symbol)
145
+ final = float(achieved.get(symbol) or 0.0)
146
+ delta = None
147
+ if target is not None and target > 0:
148
+ delta = ((final - target) / target) * 100.0
149
+ loss += (delta / 100.0) ** 2
150
+ elif final > 0:
151
+ loss += (final / 100.0) ** 2
152
+ rows.append({"symbol": symbol, "target": target, "final": final, "deltaPct": delta})
153
+ return loss, rows
154
+
155
+
156
+ def forward(salts: list[dict], grams: list[float], water: dict | None = None) -> dict:
157
+ water = water or {}
158
+ achieved = {symbol: float(value) for symbol, value in water.items()}
159
+ for salt, gram in zip(salts, grams):
160
+ if gram <= 0:
161
+ continue
162
+ for symbol, percent in salt["elements"].items():
163
+ achieved[symbol] = achieved.get(symbol, 0.0) + gram * ppm_per_gram(percent)
164
+ return achieved
165
+
166
+
167
+ def solve_weights(salts: list[dict], targets: dict, water: dict | None = None, sparsity: float = 0.0) -> dict:
168
+ """Non-negative grams per litre minimizing squared Δ%."""
169
+ water = water or {}
170
+ symbols = list(dict.fromkeys([
171
+ *targets.keys(),
172
+ *(symbol for salt in salts for symbol in salt["elements"]),
173
+ ]))
174
+ matrix = []
175
+ rhs = []
176
+ for symbol in symbols:
177
+ target = targets.get(symbol)
178
+ rates = [ppm_per_gram(salt["elements"].get(symbol, 0.0)) for salt in salts]
179
+ background = float(water.get(symbol) or 0.0)
180
+ if target is not None and target > 0:
181
+ matrix.append([rate / target for rate in rates])
182
+ rhs.append((target - background) / target)
183
+ else:
184
+ # No target: a light penalty so MAP's ammonium does not outrank a real miss.
185
+ matrix.append([rate / 100.0 for rate in rates])
186
+ rhs.append(-background / 100.0)
187
+ if sparsity > 0 and salts:
188
+ for index in range(len(salts)):
189
+ row = [0.0] * len(salts)
190
+ row[index] = sparsity
191
+ matrix.append(row)
192
+ rhs.append(0.0)
193
+ grams = nnls(matrix, rhs)
194
+ achieved = forward(salts, grams, water)
195
+ loss, rows = score(achieved, targets)
196
+ chosen = []
197
+ for salt, gram in zip(salts, grams):
198
+ # Micronutrient salts are often well under 0.0005 g/L. Keep them.
199
+ if gram > 1e-6:
200
+ chosen.append({
201
+ "id": salt["id"],
202
+ "name": salt["name"],
203
+ "gramsPerLitre": round(gram, 6),
204
+ })
205
+ return {"salts": chosen, "grams": grams, "loss": loss, "rows": rows}
206
+
207
+
208
+ LIBRARY = [
209
+ {"id": "CaNO3", "name": "Calcium nitrate (ag grade)", "elements": {"N_NO3": 14.4, "N_NH4": 1.1, "Ca": 19.0}},
210
+ {"id": "KNO3", "name": "Potassium nitrate", "elements": {"N_NO3": 13.856, "K": 38.67}},
211
+ {"id": "MKP", "name": "Monopotassium phosphate", "elements": {"P": 22.758, "K": 28.732}},
212
+ {"id": "MAP", "name": "Monoammonium phosphate", "elements": {"N_NH4": 12.18, "P": 26.924}},
213
+ {"id": "SOP", "name": "Potassium sulfate", "elements": {"K": 44.873, "S": 18.402}},
214
+ {"id": "MgSO4", "name": "Magnesium sulfate", "elements": {"Mg": 9.86, "S": 13.01}},
215
+ ]
216
+
217
+
218
+ def _usable(salt: dict) -> bool:
219
+ """Skip empty rows and the two placeholder salts. Every real salt stays eligible."""
220
+ if not salt.get("elements"):
221
+ return False
222
+ if salt.get("formula") == "Input Formula Here":
223
+ return False
224
+ if str(salt.get("name", "")).lower().startswith("test_"):
225
+ return False
226
+ return True
227
+
228
+
229
+ def recommend(library: list[dict], targets: dict, water: dict | None = None) -> dict:
230
+ """Solve against every usable salt. Positive grams are the chosen combination."""
231
+ water = water or {}
232
+ salts = [salt for salt in library if _usable(salt)]
233
+ solved = solve_weights(salts, targets, water, sparsity=0.02)
234
+ solved["saltIds"] = [item["id"] for item in solved["salts"]]
235
+ return solved
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: flahax
3
+ Version: 0.1.0
4
+ Summary: Choose fertilizer salts and weights that minimize crop-formula percentage error.
5
+ Author: Flaha
6
+ License-Expression: LicenseRef-Flaha-Free-Use
7
+ Project-URL: Homepage, https://github.com/rafatahmed/Flahax
8
+ Project-URL: Documentation, https://github.com/rafatahmed/Flahax/tree/main/docs
9
+ Project-URL: Source, https://github.com/rafatahmed/Flahax
10
+ Project-URL: Issues, https://github.com/rafatahmed/Flahax/issues
11
+ Keywords: fertilizer,hydroponics,nutrient-solution,formulation
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # FlahaX
26
+
27
+ FlahaX chooses a fertilizer combination and the grams of each salt for a fixed crop formula. Version 0.1.0. It is a separate Python package. The FlahaFAST application does not import it.
28
+
29
+ The crop formula does not change. This season’s water is subtracted from it. The gap that remains is what the salts must cover. Every real salt in the library is a candidate. Non-negative grams decide which salts stay in the mix.
30
+
31
+ ## License
32
+
33
+ Use of FlahaX is free of charge, including commercial use. The [Flaha Free Use License](LICENSE) allows you to install and run the package. It does not allow you to modify the software, and it does not allow you to give copies of it to anyone else. Downloading an official release for your own use is covered. Republishing, forking, or shipping your own copy is not.
34
+
35
+ Because modification and redistribution are withheld, this is a proprietary free-use license. It is outside the Open Source Definition, which requires those permissions.
36
+
37
+ ## Install
38
+
39
+ Python 3.11 or newer. No third-party dependencies.
40
+
41
+ From this repository, in a virtual environment:
42
+
43
+ ```powershell
44
+ python -m pip install -e .
45
+ python -c "import flahax; print(flahax.__version__)"
46
+ ```
47
+
48
+ The package is not on a public index yet. Building a release is described in [docs/publishing.md](docs/publishing.md).
49
+
50
+ ## Quick start
51
+
52
+ ```python
53
+ from flahax import load_library, recommend
54
+
55
+ result = recommend(
56
+ load_library()["salts"],
57
+ targets={"N_NO3": 128, "P": 58, "K": 211, "Ca": 104, "Mg": 40, "S": 54},
58
+ water={"Ca": 20},
59
+ )
60
+ ```
61
+
62
+ The same call from the command line, reading JSON on standard input:
63
+
64
+ ```powershell
65
+ @'
66
+ {"targets": {"N_NO3": 128, "P": 58, "K": 211, "Ca": 104, "Mg": 40, "S": 54}, "water": {"Ca": 20}}
67
+ '@ | python -m flahax
68
+ ```
69
+
70
+ `result["salts"]` lists each chosen salt with `gramsPerLitre`. `result["rows"]` lists each element with its target, the final ppm, and `deltaPct`.
71
+
72
+ ## Proof
73
+
74
+ Edited pepper targets, with 20 ppm calcium already in the water, fit inside 1% on every targeted element using six salts:
75
+
76
+ | Salt | g/L |
77
+ |---|---:|
78
+ | Potassium nitrate | 0.546 |
79
+ | Magnesium nitrate | 0.415 |
80
+ | Calcium sulfate | 0.290 |
81
+ | Phosphoric acid (75%) | 0.146 |
82
+ | Calcium nitrate (ag grade) | 0.046 |
83
+ | Calcium monobasic phosphate | 0.048 |
84
+
85
+ The mathematics, the library counts, and the regression case are in [docs/method.md](docs/method.md).
86
+
87
+ ## Documentation
88
+
89
+ - [docs/usage.md](docs/usage.md) — install, library call, command line, inputs, and result fields
90
+ - [docs/method.md](docs/method.md) — ppm equation, solver, library file, and the pepper proof
91
+ - [docs/publishing.md](docs/publishing.md) — build the sdist and wheel
92
+ - [INTEGRATION.md](INTEGRATION.md) — how FlahaFAST can call the package without changing the current flow
93
+
94
+ ## Tests
95
+
96
+ After `pip install -e .`, from the repository root:
97
+
98
+ ```powershell
99
+ python -m unittest discover -s tests -t .
100
+ ```
101
+
102
+ ## Scope of this version
103
+
104
+ Grams are for one litre of the solution the plant sees. This version does not split tanks, adjust pH, price the mix, or apply a concentration factor. It does not write to the FlahaFAST database.
@@ -0,0 +1,20 @@
1
+ INTEGRATION.md
2
+ LICENSE
3
+ MANIFEST.in
4
+ README.md
5
+ pyproject.toml
6
+ docs/method.md
7
+ docs/publishing.md
8
+ docs/usage.md
9
+ src/flahax/__init__.py
10
+ src/flahax/__main__.py
11
+ src/flahax/engine.py
12
+ src/flahax.egg-info/PKG-INFO
13
+ src/flahax.egg-info/SOURCES.txt
14
+ src/flahax.egg-info/dependency_links.txt
15
+ src/flahax.egg-info/entry_points.txt
16
+ src/flahax.egg-info/top_level.txt
17
+ src/flahax/data/library.json
18
+ tests/__init__.py
19
+ tests/test_library.py
20
+ tests/test_pepper.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flahax = flahax.__main__:main
@@ -0,0 +1 @@
1
+ flahax
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,33 @@
1
+ import unittest
2
+
3
+ from flahax import load_library, recommend
4
+ from flahax.engine import _usable
5
+
6
+ LIBRARY = load_library()
7
+
8
+
9
+ class LibrarySuite(unittest.TestCase):
10
+ def test_tables_match_the_database_export(self):
11
+ self.assertEqual(len(LIBRARY["salts"]), 37)
12
+ self.assertEqual(len(LIBRARY["waters"]), 3)
13
+ self.assertEqual(len(LIBRARY["formulas"]), 26)
14
+ self.assertIn("WC1", [water["name"] for water in LIBRARY["waters"]])
15
+
16
+ def test_each_crop_formula_is_solved_from_the_whole_library(self):
17
+ salts = [salt for salt in LIBRARY["salts"] if _usable(salt)]
18
+ known = {salt["id"] for salt in salts}
19
+ misses = []
20
+ for formula in LIBRARY["formulas"]:
21
+ result = recommend(salts, formula["targets"], formula["water"])
22
+ if not result["salts"]:
23
+ misses.append(f"{formula['name']}: no salts")
24
+ continue
25
+ if any(salt_id not in known for salt_id in result["saltIds"]):
26
+ misses.append(f"{formula['name']}: unknown salt")
27
+ if result["loss"] != result["loss"]:
28
+ misses.append(f"{formula['name']}: loss is not a number")
29
+ self.assertEqual(misses, [])
30
+
31
+
32
+ if __name__ == "__main__":
33
+ unittest.main()
@@ -0,0 +1,73 @@
1
+ import unittest
2
+
3
+ from flahax import gap_for, load_library, recommend
4
+ from flahax.engine import _usable, forward, score
5
+
6
+ SALTS = [salt for salt in load_library()["salts"] if _usable(salt)]
7
+
8
+ # Project Test01. Pepper (Howard Resh), season 2026. Water calcium 20 ppm.
9
+ TARGETS = {
10
+ "N_NO3": 128,
11
+ "P": 58,
12
+ "K": 211,
13
+ "Ca": 104,
14
+ "Mg": 40,
15
+ "S": 54,
16
+ }
17
+ WATER = {"Ca": 20}
18
+
19
+
20
+ def row(result, symbol):
21
+ return next(item for item in result["rows"] if item["symbol"] == symbol)
22
+
23
+
24
+ class PepperProof(unittest.TestCase):
25
+ def test_gap_removes_season_water(self):
26
+ gap = gap_for(TARGETS, WATER)
27
+ self.assertEqual(gap["Ca"], 84)
28
+ self.assertEqual(gap["P"], 58)
29
+
30
+ def test_recommendation_hits_phosphorus_and_potassium(self):
31
+ result = recommend(SALTS, TARGETS, WATER)
32
+ for symbol in ("P", "K", "Ca", "Mg", "S", "N_NO3"):
33
+ self.assertLess(abs(row(result, symbol)["deltaPct"]), 1, symbol)
34
+
35
+ def test_zinc_copper_and_molybdenum_stay_in_the_combination(self):
36
+ targets = {
37
+ **TARGETS,
38
+ "Fe": 5, "Mn": 0.8, "Zn": 0.1, "B": 0.3, "Cu": 0.07, "Mo": 0.03,
39
+ }
40
+ result = recommend(SALTS, targets, WATER)
41
+ covered = set()
42
+ for item in result["salts"]:
43
+ elements = next(s for s in SALTS if s["id"] == item["id"])["elements"]
44
+ covered.update(elements)
45
+ for symbol in ("Zn", "Cu", "Mo"):
46
+ self.assertIn(symbol, covered, symbol)
47
+ self.assertLess(abs(row(result, symbol)["deltaPct"]), 1, symbol)
48
+
49
+ def test_forced_map_and_mkp_is_the_pepper_miss(self):
50
+ """The published run stacked both phosphorus salts. That is not the optimum."""
51
+ def named(fragment):
52
+ return next(salt for salt in SALTS if fragment.lower() in salt["name"].lower())
53
+
54
+ salts = [named(name) for name in (
55
+ "Calcium Nitrate",
56
+ "Potassium Nitrate",
57
+ "Potassium Monobasic",
58
+ "Ammonium Monobasic",
59
+ "Magnesium Sulfate",
60
+ )]
61
+ # Grams reconstructed from the published balance, not from the solver.
62
+ grams = [0.605, 0.587, 0.549, 0.213, 0.406]
63
+ achieved = forward(salts, grams, WATER)
64
+ _loss, rows = score(achieved, TARGETS)
65
+ phosphorus = next(item for item in rows if item["symbol"] == "P")
66
+ self.assertGreater(phosphorus["deltaPct"], 100)
67
+
68
+ good = recommend(SALTS, TARGETS, WATER)
69
+ self.assertLess(abs(row(good, "P")["deltaPct"]), phosphorus["deltaPct"])
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()