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.
- flahax-0.1.0/INTEGRATION.md +39 -0
- flahax-0.1.0/LICENSE +65 -0
- flahax-0.1.0/MANIFEST.in +10 -0
- flahax-0.1.0/PKG-INFO +104 -0
- flahax-0.1.0/README.md +80 -0
- flahax-0.1.0/docs/method.md +71 -0
- flahax-0.1.0/docs/publishing.md +38 -0
- flahax-0.1.0/docs/usage.md +108 -0
- flahax-0.1.0/pyproject.toml +40 -0
- flahax-0.1.0/setup.cfg +4 -0
- flahax-0.1.0/src/flahax/__init__.py +6 -0
- flahax-0.1.0/src/flahax/__main__.py +29 -0
- flahax-0.1.0/src/flahax/data/library.json +1 -0
- flahax-0.1.0/src/flahax/engine.py +235 -0
- flahax-0.1.0/src/flahax.egg-info/PKG-INFO +104 -0
- flahax-0.1.0/src/flahax.egg-info/SOURCES.txt +20 -0
- flahax-0.1.0/src/flahax.egg-info/dependency_links.txt +1 -0
- flahax-0.1.0/src/flahax.egg-info/entry_points.txt +2 -0
- flahax-0.1.0/src/flahax.egg-info/top_level.txt +1 -0
- flahax-0.1.0/tests/__init__.py +1 -0
- flahax-0.1.0/tests/test_library.py +33 -0
- flahax-0.1.0/tests/test_pepper.py +73 -0
|
@@ -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.
|
flahax-0.1.0/MANIFEST.in
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|