Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.0.0] - 2026-10-15

### Changed
- Every feed now includes the vitamin/mineral premix, as agreed with the FASA team on 15 October 2026. Callers choose its rate from 1%, 1.5% or 2% (`premix_rate` `0.01`, `0.015` or `0.02`; default 2%, previously 0.5%) and send its price per kg as the new required `premix_price`. The premix cost is now part of `cost_per_kg` and `total_cost`, which rise by `premix_rate × premix_price`.
- The premix is still assumed to supply the vitamins and trace minerals, and its own nutrients are not counted, so a larger premix leaves less room for the other ingredients. With the test prices, 10 of the 28 pelleted species and stage combinations have no solution at 2%, against 6 at the old 0.5%.
- Python callers of `fasa_core.formulate` get the same rule as the API: a `premix_rate` other than 0.01, 0.015 or 0.02 raises an error.

### Removed
- The premix on/off switch (`premix_enabled`, on `/formulate` and `/validate-recipe`), the fish-meal cost-share cap (`max_fishmeal_cost_share`) and the binder cap (`max_binder_inclusion`); responses no longer echo them. Stop sending these fields: requests that include them are now rejected (422).
- The option to change which vitamins and trace minerals the premix is assumed to supply (`custom_premix_mask_codes`): as agreed on 15 October, the premix composition is fixed. Stop sending this field: requests that include it are now rejected (422).
- The 32 IAFFD vitamin and mineral premix products from the ingredient pool (331 to 299 ingredients), so a premix cannot be priced in on top of the fixed one. Vitamin C (Rovimix Stay-C 35, code 60128) stays and can be added at a fixed rate through `ingredient_limits`.

## [0.5.0] - 2026-10-14

### Added
Expand Down
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,9 @@ Project changes are tracked in [`CHANGELOG.md`](CHANGELOG.md).
## What's in this MVP

- **Linear programming engine** (PuLP + HiGHS) that solves a digestibility-aware least-cost feed formulation against the active ASNS constraints for a chosen species/stage/production-system tuple.
- **Premix-aware constraint masking**: vitamins and trace minerals are assumed satisfied by a fixed-rate vitamin/mineral premix (default 0.5%), keeping the LP focused on macros, amino acids, digestible energy, Ca, P, fatty acids, and toxin ceilings.
- **Hard ceilings on toxins / anti-nutrients**: aflatoxin B, gossypol, phytic acid, glucosinolates, tannins, etc. are enforced as Maximum constraints regardless of premix or override settings.
- **Premix always included**: every feed carries a vitamin/mineral premix at 1, 1.5 or 2% of the feed (`premix_rate`, default 2%), priced by the caller (`premix_price`) and counted in the feed cost. Vitamins and trace minerals are assumed supplied by it, keeping the LP focused on macros, amino acids, digestible energy, Ca, P, fatty acids, and toxin ceilings. The premix's own nutrients are not counted. Premix products are not in the ingredient pool.
- **Hard ceilings on toxins / anti-nutrients**: aflatoxin B, gossypol, phytic acid, glucosinolates, tannins, etc. are enforced as Maximum constraints and are never masked by the premix.
- **Min/max inclusion layer**: a second layer of bounds on top of composition data, because nutrient tables alone let the LP build diets that pass every target yet cannot be extruded or grown on. `fasa_core/config/nutrient_limits.csv` adds Minimum/Maximum bounds on dietary nutrient levels (the tighter of ASNS and the limit binds); `fasa_core/config/ingredient_limits.csv` bounds how much of any single ingredient may go in. The ingredient table carries per-species maximums for 75 pool ingredients; the nutrient table ships empty pending the data, and filling its columns activates it with no code change. Either can be supplied per request via `nutrient_limits` / `ingredient_limits`.
- **Opt-in advisory caps**: collective binder mass cap and fish-meal cost-share cap are available as optional inputs but are *not* applied by default. Toxicity and ASNS nutrient limits remain the only fixed constraints; callers that want to force a sustainability or pellet-quality ceiling can pass `max_fishmeal_cost_share` and/or `max_binder_inclusion` explicitly.
- **Hard-fail with IIS reporting**: when the constraint set is infeasible at the supplied prices/pool, a deletion-filter algorithm extracts a minimal Irreducible Inconsistent Subset of constraints and returns it to the caller so the miller knows exactly why the recipe could not be built.
- **PAFF benchmark gate**: independent recomputation of PAFF reference recipes' nutrient composition acts as the correctness test for the data-loading and crosswalk pipeline.
- **FastAPI surface** with `/formulate`, `/supported`, `/validate-recipe`, and `/health` endpoints. `/validate-recipe` scores a caller-supplied recipe against the same inclusion limits and constraint set the optimizer uses, so a hand-edited formulation can be checked without re-running the LP.
Expand All @@ -65,7 +64,7 @@ fasa_engine/
│ │ ├── ingredient_pool_africa.csv country-tagged ingredient pool (KE/NG/ZM)
│ │ ├── nutrient_limits.csv min/max bounds on dietary nutrient levels
│ │ ├── ingredient_limits.csv min/max bounds on single-ingredient inclusion
│ │ └── defaults.py numeric defaults (premix rate, caps, etc.)
│ │ └── defaults.py numeric defaults (premix rates, solver, tolerances)
│ ├── data_loader.py ASNS / FICD / PAFF loaders (cached)
│ ├── crosswalk.py spec → FICD parameter resolver
│ ├── ingredient_pool.py pool filter
Expand Down Expand Up @@ -166,8 +165,8 @@ For external teams integrating this API into other systems, see [`docs/integrati
"production_system": "General-LowCost",
"processing_method": "pelleted",
"country": "ZM",
"premix_enabled": true,
"premix_rate": 0.005,
"premix_rate": 0.02,
"premix_price": 2.50,
"batch_size_kg": 100,
"prices": {
"30355": 0.30,
Expand All @@ -188,7 +187,9 @@ layer. They override the configured CSVs for the codes they name. Where ASNS alr
states the same bound, the tighter value binds. Note that the response echoes an
ingredient bound as a **percentage** (`max_inclusion_percent: 15.0`), not a fraction.

`country` is optional (ISO-2: `KE`, `NG`, `ZM`; discover valid values via `/supported`). When supplied, each recipe line is flagged `locally_available`. It is an advisory highlight only — the optimizer always draws on the full pool (millers routinely buy imported soy, premix, etc.), so a country is **never** a hard filter.
`premix_rate` must be `0.01`, `0.015` or `0.02` (1, 1.5 or 2% of the feed; default `0.02`). `premix_price` is required: the premix price per kg, in the same currency as `prices`.

`country` is optional (ISO-2: `KE`, `NG`, `ZM`; discover valid values via `/supported`). When supplied, each recipe line is flagged `locally_available`. It is an advisory highlight only — the optimizer always draws on the full pool (millers routinely buy imported soy, fish meal, etc.), so a country is **never** a hard filter.

Example call with auth:

Expand All @@ -202,6 +203,7 @@ curl -X POST "http://127.0.0.1:8000/formulate" \
### Reading the response (high level)

- **`recipe`**: ingredient inclusions (percent of final feed) + cost breakdown. If `batch_size_kg` was supplied on the request, each line also carries `quantity_kg`. If `country` was supplied, each line carries `locally_available` (`true`/`false`); it is `null` when no `country` was requested. Each line also echoes the inclusion box that was applied (`min_inclusion_percent`, `max_inclusion_percent`, `limit_source`).
- **`cost_per_kg`**: cost of the whole feed, premix included: the sum of the recipe lines' `cost_contribution` plus `premix_rate × premix_price`. Both premix values are echoed.
- **`batch_size_kg` / `premix_quantity_kg` / `total_cost`**: echoed only when `batch_size_kg` was supplied; otherwise `null`.
- **`composition`**: per-spec achieved vs target, including toxin ceilings. `source` says whether the target came from ASNS alone or from the inclusion-limit layer.
- **`status`**:
Expand Down Expand Up @@ -285,14 +287,14 @@ Configure:
## Modeling notes

1. **Decision variables** `x_i ∈ [min_inclusion_i, max_inclusion_i]` = mass fraction of each priced+available ingredient; the box comes from the inclusion-limit layer and defaults to `[0, 1]`.
2. **Objective** `min Σ price_i × x_i` (USD/kg or local currency, supplied at runtime).
2. **Objective** `min Σ price_i × x_i` (USD/kg or local currency, supplied at runtime). The premix cost `premix_rate × premix_price` is a constant, added to the reported cost.
3. **Mass balance** `Σ x_i = 1 − premix_rate` so the premix takes a fixed slice.
4. **Nutritional constraints** generated 1:1 from active ASNS rows for the given (species, system, stage):
- Minimum / Maximum specs become inequalities,
- Ratio specs (e.g., DP/DE) are linearized.
- The `dig_*_fish_percent` and `dig_p_*_percent` FICD columns are bound directly to the corresponding ASNS digestible-nutrient codes — *digestibility is baked into the constraint LHS, not bolted on after*.
5. **Energy column selection** is data-driven: ASNS itself carries the species-appropriate energy code (Tilapia ⇒ ED02 DE-Omni, African Catfish ⇒ ED01 DE-Carni). The crosswalk then maps that code to the pelleted vs. extruded FICD variant per the request's `processing_method`.
6. **Hard toxin ceilings** are NEVER masked, even via override (TX01–TX16 always emit Maximum constraints).
6. **Hard toxin ceilings** are NEVER masked (TX01–TX16 always emit Maximum constraints).
7. **Nutrient inclusion limits** join the ASNS row set before emission, so they inherit unit conversion, the composition report, and IIS reporting. Where ASNS states the same direction the tighter value binds; a code the layer bounds is never masked by the premix.
8. **Soft warnings** are appended (not enforced) when any single ingredient exceeds 40 % inclusion.
9. **On infeasibility**, a deletion-filter IIS is returned so the user can see which constraints conflict. Ingredient bounds that cannot sum to the feed mass are caught before the solve and reported separately as `bound_conflicts`.
Expand Down
5 changes: 5 additions & 0 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ Recommended client behavior:
- Validate connectivity with `/health` and `/ready`.
- Call `/supported` first to fetch valid `species`, `production_system`, `stage`, and `countries`.
- Build `/formulate` payload from those discovered values.
- Always send `premix_price` (premix price per kg, same currency as `prices`). Every feed
carries the premix; `premix_rate` is `0.01`, `0.015` or `0.02` (default `0.02`), and
`cost_per_kg` includes `premix_rate × premix_price`. Premix products cannot be priced in
`prices`. Since 1.0.0 `premix_enabled`, `max_fishmeal_cost_share`,
`max_binder_inclusion` and `custom_premix_mask_codes` are gone; sending them returns 422.
- Optionally send `country` (ISO-2: `KE`/`NG`/`ZM`) to have each recipe line flagged
`locally_available` (`true`/`false`; `null` when no `country` is sent). This is an
advisory highlight only — the optimizer is never restricted to local ingredients.
Expand Down
Loading
Loading