diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9916d..fac2ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 3f803ab..0a94091 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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, @@ -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: @@ -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`**: @@ -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`. diff --git a/docs/integration.md b/docs/integration.md index c4b1185..d45cd0a 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -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. diff --git a/docs/testing-guide.md b/docs/testing-guide.md index c66fef0..5ae0715 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -128,7 +128,7 @@ The crosswalk between ASNS constraint codes and FICD parameter names is maintain Energy columns are selected by processing method: for `pelleted` feeds, the engine reads `de_*_pelleted_kcal_kg` columns; for `extruded` feeds, it reads `de_*_extruded_kcal_kg` columns. -> **Iodine data note:** the FICD v11.1 export repeats the Dietary Electrolyte Balance values in its iodine column. The engine therefore uses iodine values from the previous FICD release; the six ingredients new in v11.1 (codes 30918, 31274, 31294, 31295, 31422, 70188) have no iodine value and count as 0. Iodine (`M13`) is premix-masked by default, so this affects the LP only when `premix_enabled = false` or when a `nutrient_limits` bound names `M13` (such a bound is never masked); the iodine figure reported in the composition is affected either way. +> **Iodine data note:** the FICD v11.1 export repeats the Dietary Electrolyte Balance values in its iodine column. The engine therefore uses iodine values from the previous FICD release; the six ingredients new in v11.1 (codes 30918, 31274, 31294, 31295, 31422, 70188) have no iodine value and count as 0. Iodine (`M13`) is premix-masked, so this affects the LP only when a `nutrient_limits` bound names `M13` (such a bound is never masked); the iodine figure reported in the composition is affected either way. [↑ Contents](#contents) @@ -136,17 +136,17 @@ Energy columns are selected by processing method: for `pelleted` feeds, the engi ## 4. Ingredient Pool -The optimizer draws from a **country-tagged ingredient pool** of 331 ingredients considered plausibly available in sub-Saharan Africa. Only ingredients from this pool that also appear in the `prices` dictionary of the request are admitted into the LP. +The optimizer draws from a **country-tagged ingredient pool** of 299 ingredients considered plausibly available in sub-Saharan Africa. Only ingredients from this pool that also appear in the `prices` dictionary of the request are admitted into the LP. -The pool is generated by `scripts/build_ingredient_pool.py` from a per-country usable-ingredient list intersected with FICD (the composition database), plus a small set of retained import variants. It is stored in `fasa_core/config/ingredient_pool_africa.csv` with these columns: +The pool is generated by `scripts/build_ingredient_pool.py` from a per-country usable-ingredient list intersected with FICD (the composition database), plus a small set of retained import variants, minus the IAFFD vitamin and mineral premix products (the premix is a fixed slice of every feed, §5.1). It is stored in `fasa_core/config/ingredient_pool_africa.csv` with these columns: | Column | Meaning | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `code` / `description` | FICD ingredient code and name | | `class` | Informational grouping label, auto-derived from the description; **not used by the LP** | -| `is_fishmeal` | `1` for fish-meal products; participates in the opt-in `max_fishmeal_cost_share` cap | -| `is_binder` | `1` for starch/flour/cassava binders; participates in the opt-in `max_binder_inclusion` cap | +| `is_fishmeal` | `1` for fish-meal products. Group tag; **not used by the LP** | +| `is_binder` | `1` for starch/flour/cassava binders. Group tag; **not used by the LP** | | `max_inclusion` | Optional per-ingredient ceiling; blank for all current entries. Superseded by `ingredient_limits.csv` where that file names the ingredient (§5.2) | | `countries` | Comma-separated ISO-2 codes (`KE`, `NG`, `ZM`) where the ingredient is locally available; blank = not locally available in any tracked country (imports) | @@ -166,9 +166,9 @@ Representative rows (the full list lives in the CSV): | 10073 | Fish meal, mixed fish, Mauritania, 66% CP | animal_protein | 1 | 0 | *(blank — import)* | -Currently 12 ingredients are flagged `is_fishmeal` and 7 `is_binder`. These flags are auto-derived from the description (fish-meal = descriptions beginning "Fish meal …"; binder = starch/flour/cassava products) and feed only the opt-in advisory caps in §5. +Currently 12 ingredients are flagged `is_fishmeal` and 7 `is_binder`. These flags are auto-derived from the description (fish-meal = descriptions beginning "Fish meal …"; binder = starch/flour/cassava products) and are not used by the LP since the fish-meal and binder caps were removed (1.0.0). -No ingredient in the pool CSV currently carries a hard per-ingredient inclusion limit. Per-ingredient bounds are now the job of the curated inclusion-limit layer (§5.2), which is hand-maintained rather than generated and so survives a pool rebuild; the pool's own `max_inclusion` column remains as a fallback for ingredients that layer does not name. Until the limit tables are populated, all ingredients are bounded only by nutritional constraints, toxin ceilings, and (optionally) the advisory binder/fish-meal caps. +No ingredient in the pool CSV currently carries a hard per-ingredient inclusion limit. Per-ingredient bounds are now the job of the curated inclusion-limit layer (§5.2), which is hand-maintained rather than generated and so survives a pool rebuild; the pool's own `max_inclusion` column remains as a fallback for ingredients that layer does not name. Until the limit tables are populated, all ingredients are bounded only by nutritional constraints and toxin ceilings. > **Coverage note:** 140 codes from the country list (all Zambia-only, a contiguous block) have no composition rows in FICD and are therefore excluded from the pool; they cannot be formulated until FICD gains their data. @@ -184,29 +184,27 @@ The LP is formulated as follows. **Decision variables:** `x_i` = mass fraction of ingredient `i` in the final feed (dimensionless, bounded `[min_inclusion_i, max_inclusion_i]` where the inclusion-limit layer defines them, otherwise `[0, 1]` — see §5.2). -**Objective:** minimise `Σ price_i × x_i` +**Objective:** minimise `Σ price_i × x_i`. The premix cost `premix_rate × premix_price` is a constant added to the reported `cost_per_kg`. **Constraints:** 1. **Mass balance:** `Σ x_i = 1 − premix_rate` - If `premix_enabled = true`, a fixed mass fraction (default 0.5%) is reserved for a vitamin/mineral premix. The premix mass is excluded from the LP decision variables; its nutritional contribution is **not modelled** (see §10.4). + Every feed carries a vitamin/mineral premix at `premix_rate` (1, 1.5 or 2%; default 2%). The premix mass is excluded from the LP decision variables; its nutritional contribution is **not modelled** (see §9.4). 2. **Nutritional constraints:** For each active ASNS row with a mapped FICD parameter: `Σ composition_ij × x_i ≥ target_j` (Minimum) `Σ composition_ij × x_i ≤ target_j` (Maximum) 3. **DP/DE ratio:** Linearised to a single minimum inequality: `Σ (dCP_i − rhs × dDE_i) × x_i ≥ 0`. -4. **Binder cap (opt-in):** `Σ_{i ∈ binders} x_i ≤ max_binder_inclusion`. Not applied by default; only emitted when the caller supplies a value in `[0, 1)`. -5. **Fish-meal cost-share cap (opt-in):** `Σ_{FM} price_i × x_i ≤ max_fishmeal_cost_share × Σ price_i × x_i` (linearised). Not applied by default; only emitted when the caller supplies a value in `[0, 1)`. -6. **Nutrient inclusion limits:** additional `Minimum`/`Maximum` rows contributed by the inclusion-limit layer (see §5.2). They are indistinguishable from ASNS rows in the LP. +4. **Nutrient inclusion limits:** additional `Minimum`/`Maximum` rows contributed by the inclusion-limit layer (see §5.2). They are indistinguishable from ASNS rows in the LP. The solver used is HiGHS (via PuLP), with a fallback to CBC. The time limit is 30 seconds per request. The solution is exact (continuous LP) and globally optimal within the model's assumptions. ### 5.1 Premix Masking -When `premix_enabled = true`, ASNS constraints for vitamins (V01–V15) and trace minerals (M08–M13) are **excluded from the LP** on the assumption that these micronutrients are supplied by the premix. Toxin constraints (TX01–TX16) are **always enforced**, regardless of premix settings. +ASNS constraints for vitamins (V01–V15) and trace minerals (M08–M13) are **excluded from the LP** on the assumption that these micronutrients are supplied by the premix, which every feed carries. Toxin constraints (TX01–TX16) are **always enforced**, regardless of premix settings. -For brood stock stages, Vitamin C (V09) is re-activated even when premix masking is enabled (it is removed from the default mask for the `Brood` stage). +For brood stock stages, Vitamin C (V09) is re-activated (it is removed from the default mask for the `Brood` stage). -Custom masking can be specified per request via `custom_premix_mask_codes` (see §7.2). +The mask is fixed: a request cannot change which specs the premix covers. ### 5.2 Min/Max Inclusion Limits @@ -221,7 +219,7 @@ That layer is now part of the engine. It has two halves: | Ingredient limits | FICD ingredient code | The decision variable's box: `min_inclusion ≤ x_i ≤ max_inclusion` | `fasa_core/config/ingredient_limits.csv` | -`ingredient_limits.csv` carries a maximum inclusion per species (Nile Tilapia, African Catfish) for 75 pool ingredients, so those ceilings apply to every request without the caller sending anything. Ingredients it does not name are bounded only by the ASNS constraints, the toxin ceilings and the opt-in advisory caps. `nutrient_limits.csv` ships with a documented header and **no data rows yet**; filling its columns activates it with no code change. Either layer can also be supplied per request via `nutrient_limits` / `ingredient_limits` (see §6.2), which overrides the table for the codes named. +`ingredient_limits.csv` carries a maximum inclusion per species (Nile Tilapia, African Catfish) for 75 pool ingredients, so those ceilings apply to every request without the caller sending anything. Ingredients it does not name are bounded only by the ASNS constraints and the toxin ceilings. `nutrient_limits.csv` ships with a documented header and **no data rows yet**; filling its columns activates it with no code change. Either layer can also be supplied per request via `nutrient_limits` / `ingredient_limits` (see §6.2), which overrides the table for the codes named. **Units — read this before sending limits.** The two halves use different units, and the ingredient half flips between request and response: @@ -302,12 +300,9 @@ All field definitions and defaults: | `country` | string or null | No | `null` | Optional ISO-2 country code (`"KE"`, `"NG"`, `"ZM"`; case-insensitive). When supplied, each recipe line is flagged `locally_available` per the pool's `countries` tags. Advisory highlight only — **never** restricts the LP | | `prices` | object | **Yes** | — | Dictionary mapping ingredient `code` (as a string) to price per kg (float). Only codes present in the configured pool are used; unlisted codes are ignored. At least one ingredient must be provided | | `processing_method` | string | No | `"pelleted"` | Feed manufacturing method. Selects the corresponding digestible energy column from FICD. Allowed: `"pelleted"` or `"extruded"` | -| `premix_enabled` | boolean | No | `true` | If `true`, vitamin and trace mineral constraints are excluded from the LP (assumed covered by premix). Toxin constraints are always active | -| `premix_rate` | float | No | `0.005` | Mass fraction of the feed reserved for the premix (0–0.10 exclusive). Default 0.005 = 0.5%. This mass is subtracted from the ingredient budget before the LP runs | +| `premix_rate` | float | No | `0.02` | Mass fraction of the feed taken by the premix: `0.01`, `0.015` or `0.02` (1, 1.5 or 2%); other values are rejected (422). This mass is subtracted from the ingredient budget before the LP runs | +| `premix_price` | float | **Yes** | — | Premix price per kg, same currency as `prices` (≥ 0). Counted in `cost_per_kg` and `total_cost` | | `batch_size_kg` | float or null | No | `null` | Total batch size in kg (0 exclusive, max 1 000 000). When supplied, the response includes per-ingredient `quantity_kg`, `premix_quantity_kg`, and `total_cost`. Omit to receive percent-only output | -| `max_fishmeal_cost_share` | float or null | No | `null` | Optional cap on fish-meal cost share (is_fishmeal = true). Range [0, 1]. Omit (or send `null`) to apply no cap — cost alone then drives fish-meal inclusion | -| `max_binder_inclusion` | float or null | No | `null` | Optional cap on collective binder mass fraction (is_binder = true). Range [0, 1]. Omit (or send `null`) to apply no cap | -| `custom_premix_mask_codes` | list of strings | No | `null` | If provided, **replaces** the default premix mask entirely. List of ASNS specification codes (e.g. `["V01","V02","M08"]`) to exclude from the LP. Toxin codes (TX*) in this list are still enforced. Maximum 200 codes | | `nutrient_limits` | object or null | No | `null` | Per-request override of the nutrient inclusion-limit layer (§5.2): ASNS spec code → `{"min": …, "max": …}` in that code's ASNS unit, e.g. `{"PA05": {"max": 8}}`. An entry **replaces** the configured limit for that code. Either key may be omitted. Maximum 300 codes | | `ingredient_limits` | object or null | No | `null` | Per-request override of the ingredient inclusion-limit layer (§5.2): FICD ingredient code → `{"min": …, "max": …}` as a mass fraction of total feed in [0, 1], e.g. `{"31605": {"max": 0.15}}`. An entry **replaces** the configured limit for that ingredient. Maximum 300 codes | @@ -328,7 +323,8 @@ All field definitions and defaults: "62135": 0.05, "61109": 2.50, "61111": 4.00 - } + }, + "premix_price": 2.50 } ``` @@ -344,6 +340,7 @@ Prices are in the currency of your choice; the returned `cost_per_kg` will be ex "stage": "< 5g (Starter)", "production_system": "General-LowCost", "prices": { "...": 0.0 }, + "premix_price": 2.50, "nutrient_limits": { "PA05": { "max": 8.0 } }, @@ -370,14 +367,12 @@ Both objects are optional and independent. Values sent here override the configu | `production_system` | string | Echo of input | | `processing_method` | string | Echo of input | | `country` | string or null | Echo of input (null when omitted) | -| `premix_enabled` | boolean | Echo of input | | `premix_rate` | float | Echo of input | -| `max_fishmeal_cost_share` | float or null | Echo of input (null when no cap was applied) | -| `max_binder_inclusion` | float or null | Echo of input (null when no cap was applied) | +| `premix_price` | float | Echo of input | | `batch_size_kg` | float or null | Echo of input (null when omitted) | -| `premix_quantity_kg`| float or null | `batch_size_kg × premix_rate`, rounded to 3 decimals. Populated only when `batch_size_kg` is supplied and `premix_enabled = true` | +| `premix_quantity_kg`| float or null | `batch_size_kg × premix_rate`, rounded to 3 decimals. Populated only when `batch_size_kg` is supplied | | `total_cost` | float or null | `cost_per_kg × batch_size_kg`, rounded to 2 decimals. Populated only when `batch_size_kg` is supplied and status is `"optimal"` | -| `cost_per_kg` | float or null | Minimised feed cost per kg (priced ingredients only; excludes the premix fraction). Null when status is not `"optimal"` | +| `cost_per_kg` | float or null | Minimised feed cost per kg, premix included: the recipe lines' `cost_contribution` plus `premix_rate × premix_price`. Null when status is not `"optimal"` | | `recipe` | list | Ingredient lines (see below). Empty when not optimal | | `composition` | list | Nutritional constraint lines (see below). Empty when not optimal | | `warnings` | list of strings | Non-fatal issues (see §7.4) | @@ -391,9 +386,9 @@ Both objects are optional and independent. Values sent here override the configu | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `code` | FICD ingredient code | | `description` | Ingredient name | -| `inclusion_percent` | Mass fraction in the feed, expressed as a percentage. When `premix_enabled = true`, the sum of all `inclusion_percent` values equals `(1 − premix_rate) × 100` and the remaining share (default 0.5%) is the premix; when `premix_enabled = false`, the sum equals 100 | +| `inclusion_percent` | Mass fraction in the feed, expressed as a percentage. The sum of all `inclusion_percent` values equals `(1 − premix_rate) × 100`; the remaining share (default 2%) is the premix | | `cost_per_kg` | Price as supplied in the request | -| `cost_contribution` | This ingredient's contribution to the total feed cost (fraction × price). Summing all `cost_contribution` values gives `cost_per_kg` | +| `cost_contribution` | This ingredient's contribution to the total feed cost (fraction × price). Summing all `cost_contribution` values and `premix_rate × premix_price` gives `cost_per_kg` | | `quantity_kg` | Absolute mass of this ingredient in kg, rounded to 3 decimals. Populated only when `batch_size_kg` is supplied on the request; otherwise `null` | | `locally_available` | Boolean or null. When a `country` was supplied on the request, `true`/`false` indicates whether this ingredient is tagged for that country; `null` when no `country` was requested | | `min_inclusion_percent` | The inclusion floor applied to this ingredient, as a **percentage** of feed (0-100) — note that `ingredient_limits` is *sent* as a fraction in [0, 1]; `null` when unbounded below (§5.2) | @@ -434,8 +429,7 @@ This endpoint scores a **user-defined recipe** without running the optimiser: it | `species` | string or null | No | `null` | Optional. Scopes the inclusion limits, and (with `stage` and `production_system`) enables the nutrient checks | | `stage` | string or null | No | `null` | Optional ASNS `stage_weight` label | | `production_system` | string or null | No | `null` | Optional. Required together with `species` and `stage` for the nutrient checks | -| `processing_method` | string | No | `"pelleted"` | Scopes the inclusion limits and selects the digestible-energy column for the nutrient checks | -| `premix_enabled` | boolean | No | `true` | Mirrors `/formulate`: when `true`, the vitamin and trace-mineral specs the premix covers are excluded from the nutrient checks (the premix's own contribution is not modelled) | +| `processing_method` | string | No | `"pelleted"` | Scopes the inclusion limits and selects the digestible-energy column for the nutrient checks. As in `/formulate`, the specs the premix covers are not checked | | `nutrient_limits` | object or null | No | `null` | Same shape, units and meaning as on `/formulate` (§6.2; units in §5.2 — ASNS unit per spec code); overrides the configured limits for the codes it names | | `ingredient_limits` | object or null | No | `null` | Same shape, units and meaning as on `/formulate` (§6.2; units in §5.2 — mass fraction in [0, 1], reported back as a percentage); overrides the configured limits | @@ -457,8 +451,8 @@ All fields other than `fractions` are optional, so the original payload shape (` ```json { - "composition": { "crude_fibre_percent": 4.817768 }, - "total_inclusion_percent": 99.5, + "composition": { "crude_fibre_percent": 4.914 }, + "total_inclusion_percent": 98.0, "in_limits": false, "inclusion_checks": [ { @@ -477,7 +471,7 @@ All fields other than `fractions` are optional, so the original payload shape (` "spec_label": "Crude Fibre", "restriction_type": "Maximum", "target": 7.0, - "achieved": 4.817768, + "achieved": 4.914, "unit": "%", "in_spec": true, "source": "asns" @@ -494,7 +488,7 @@ All fields other than `fractions` are optional, so the original payload shape (` | Warning | Meaning | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `[mass]` | The fractions sum to something other than 99–100% of feed mass, so they do not describe a complete diet | +| `[mass]` | The fractions sum to something other than 98–100% of feed mass, so they do not describe a complete diet | | `[skip]` | Either the scope was incomplete for the nutrient checks, or some codes are absent from the ingredient pool. Pool-absent codes still contribute to `composition` (that path reads FICD directly) but can hold no inclusion limit and contribute nothing to the nutrient checks — the same restriction `/formulate` has | @@ -514,7 +508,7 @@ A soft warning is generated (but the solution remains optimal) if any single ing ### 7.2 Infeasible -No combination of the supplied ingredients can simultaneously satisfy all active nutritional constraints within the structural limits (mass balance, toxin ceilings, ingredient inclusion limits, and — when the caller opted in — the binder or fish-meal cost-share caps). +No combination of the supplied ingredients can simultaneously satisfy all active nutritional constraints within the structural limits (mass balance, toxin ceilings, and ingredient inclusion limits). The `infeasibility` object contains: @@ -529,7 +523,7 @@ The `infeasibility` object contains: **Common causes of infeasibility:** -- The ingredient set is too small or nutritionally inadequate to meet one or more active constraints simultaneously (e.g. insufficient amino acid sources to satisfy a protein minimum, especially when an opt-in fish-meal cost-share cap is also being applied). +- The ingredient set is too small or nutritionally inadequate to meet one or more active constraints simultaneously (e.g. insufficient amino acid sources to satisfy a protein minimum). - A constraint requires a nutrient for which no supplied ingredient contains that nutrient in the FICD database (composition value is zero or absent for all included ingredients). - Conflicting constraints: e.g. a high protein minimum combined with a low crude fibre maximum may require ingredients that simultaneously violate another constraint. - An inclusion limit (§5.2) is too tight for the priced set: a nutrient ceiling below what any affordable combination can reach, or per-ingredient caps that leave no room for a nutrient minimum. @@ -541,13 +535,13 @@ The `infeasibility` object contains: "iis_codes": [], "iis_explanations": [], "bound_conflicts": [ - "Maximum inclusion limits on the priced ingredients sum to 24.00%, below the 99.50% of feed mass that must be filled. Price more ingredients or raise a maximum." + "Maximum inclusion limits on the priced ingredients sum to 24.00%, below the 98.00% of feed mass that must be filled. Price more ingredients or raise a maximum." ], "suggestion": "Adjust the ingredient inclusion limits so their range spans the required feed mass, or price additional ingredients." } ``` -**Diagnostic approach:** Check `bound_conflicts` first — when it is non-empty the inclusion limits alone are the cause and no IIS was computed. Examine the `iis_codes` returned. If, for example, the IIS names amino-acid codes (e.g. `AA05` Lysine) and you opted into `max_fishmeal_cost_share`, consider adding a synthetic lysine source (code `61109`) or removing/relaxing the fish-meal cost cap. If no opt-in cap is in play, the conflict lies entirely between the ingredient set and the ASNS targets. +**Diagnostic approach:** Check `bound_conflicts` first — when it is non-empty the inclusion limits alone are the cause and no IIS was computed. Examine the `iis_codes` returned. If, for example, the IIS names amino-acid codes (e.g. `AA05` Lysine), consider adding a synthetic lysine source (code `61109`). A lower `premix_rate` also leaves more room for nutrient-bearing ingredients, since the premix's own nutrients are not counted. ### 7.3 Error @@ -580,7 +574,7 @@ The following limitations reflect the current MVP implementation and should be c ### 9.1 Country Availability Is Advisory Only -Ingredients are tagged with the countries where they are locally available (ISO-2: Kenya `KE`, Nigeria `NG`, Zambia `ZM`), and an optional `country` on `/formulate` flags each recipe line as `locally_available`. This is **advisory only** — by design, the optimizer always draws on the full pool and is **never** restricted to local ingredients, because millers routinely buy imported soy, premix, etc., and a hard country filter would make the tool unusable where local data is sparse. Country tags therefore highlight local sourcing; they do not change the recipe or its cost. +Ingredients are tagged with the countries where they are locally available (ISO-2: Kenya `KE`, Nigeria `NG`, Zambia `ZM`), and an optional `country` on `/formulate` flags each recipe line as `locally_available`. This is **advisory only** — by design, the optimizer always draws on the full pool and is **never** restricted to local ingredients, because millers routinely buy imported soy, fish meal, etc., and a hard country filter would make the tool unusable where local data is sparse. Country tags therefore highlight local sourcing; they do not change the recipe or its cost. Two related gaps remain: only three countries are currently tracked, and 140 Zambia-specific codes from the source list lack composition data in FICD and are excluded from the pool until that data is added (see §4). @@ -592,13 +586,13 @@ The engine has no internal price reference. Prices must be supplied by the user `fasa_core/config/ingredient_limits.csv` holds per-species maximums for 75 pool ingredients, taken from the max-inclusion table supplied to the project in October 2026. That table lists 42 ingredient samples, and 25 of them are not loaded: 24 have no composition rows in FICD, so the engine cannot formulate with them at all and a limit would have no effect, and one (local sorghum brewery waste) has no FICD entry separate from the brewery and distillery grains already covered by another row. `fasa_core/config/nutrient_limits.csv` still ships without data rows. -Two consequences follow. Ingredients the table does not name — blood meal, wheat bran, maize and fish meal among them — are still bounded only by the ASNS nutritional constraints, the TX01–TX16 toxin ceilings, and the opt-in binder or fish-meal cost-share caps, so a recipe can still lean heavily on one of them. And a limit applies to each FICD code separately: a caller who prices two variants of the same ingredient (two rice brans, say) gets the ceiling on each, not on their sum. +Two consequences follow. Ingredients the table does not name — blood meal, wheat bran, maize and fish meal among them — are still bounded only by the ASNS nutritional constraints and the TX01–TX16 toxin ceilings, so a recipe can still lean heavily on one of them. And a limit applies to each FICD code separately: a caller who prices two variants of the same ingredient (two rice brans, say) gets the ceiling on each, not on their sum. Callers that want other bounds can supply them per request through `nutrient_limits` and `ingredient_limits` (§6.2); an entry overrides the table for the code it names. ### 9.4 Premix Nutrient Contribution Not Modelled -When `premix_enabled = true`, the LP reserves `premix_rate` of the feed mass for a premix and skips vitamin and trace mineral constraints. However, the nutritional content of the premix itself is **not added** to the composition output. The `composition` response reflects only the contribution of the optimised ingredient fraction. Achieved values for vitamins and trace minerals are not reported. +The LP reserves `premix_rate` of the feed mass for the premix and skips vitamin and trace mineral constraints. Because the premix's own nutrients are not counted, the other 98–99% of the feed must meet every remaining target on its own, so a higher premix rate can turn a feasible formulation infeasible. For the same reason the `composition` output reflects only the optimised ingredients, and achieved values for vitamins and trace minerals are not reported. ### 9.5 Anti-Nutritional Interactions Not Modelled diff --git a/examples/tilapia_starter_demo.py b/examples/tilapia_starter_demo.py index 7319792..28bd6e3 100644 --- a/examples/tilapia_starter_demo.py +++ b/examples/tilapia_starter_demo.py @@ -49,6 +49,7 @@ "61109": 3.00, # L-Lysine HCl "61111": 4.50, # DL-Methionine } +PREMIX_PRICE = 2.50 # USD/kg for the vitamin/mineral premix, always included def _run(label: str, **overrides): @@ -59,8 +60,7 @@ def _run(label: str, **overrides): production_system="General-LowCost", prices=DEMO_PRICES, processing_method="pelleted", - premix_enabled=True, - premix_rate=0.005, + premix_price=PREMIX_PRICE, **overrides, ) print(json.dumps(res.model_dump(), indent=2, default=str)) @@ -68,38 +68,25 @@ def _run(label: str, **overrides): def main() -> int: - # Scenario A — defaults (no fish-meal cost-share cap, no binder cap), - # with batch_size_kg=100 so the response also reports per-ingredient kg, - # premix kg, and total batch cost. Toxicity and ASNS nutrient limits - # remain the only hard constraints; cost alone drives fish-meal inclusion. + # Scenario A — defaults, with batch_size_kg=100 so the response also reports + # per-ingredient kg, premix kg, and total batch cost (premix included). # `country="ZM"` flags each recipe line's `locally_available` without changing # the LP (the optimizer always sees the full pool). - a = _run("DEFAULTS — no caps, batch_size_kg=100, country=ZM", + a = _run("DEFAULTS — 2 % premix, batch_size_kg=100, country=ZM", batch_size_kg=100.0, country="ZM") - # Scenario B — opt in to a 20 % fish-meal cost-share cap as a sustainability - # ceiling, while allowing generous binder use (40 %). Demonstrates that the - # caps are still wired up for callers that want them. - b = _run("Opt-in: 20 % FM cost-share + 40 % binder", - max_fishmeal_cost_share=0.20, max_binder_inclusion=0.40) - - # Scenario C — opt in to a stricter binder cap (25 %) but a looser fish-meal - # cost-share (40 %). Mirrors a premium mill that prioritises pellet quality. - c = _run("Opt-in: 40 % FM cost-share + 25 % binder", - max_fishmeal_cost_share=0.40, max_binder_inclusion=0.25) - - # Scenario D — the min/max inclusion layer, passed per request. Composition data + # Scenario B — the min/max inclusion layer, passed per request. Composition data # alone lets the LP load up on bran: it satisfies every nutrient target yet the # mash will not extrude and the fish will not grow on it. Here a dietary fibre # ceiling joins the ASNS set (the tighter of the two binds) and the two brans get # per-ingredient ceilings. The same bounds normally come from # `fasa_core/config/{nutrient,ingredient}_limits.csv`. - d = _run("Inclusion limits: fibre ≤ 8 % of diet, wheat/rice bran ≤ 15 % each", + b = _run("Inclusion limits: fibre ≤ 8 % of diet, wheat/rice bran ≤ 15 % each", nutrient_limits={"PA05": {"max": 8.0}}, ingredient_limits={"31605": {"max": 0.15}, "30937": {"max": 0.15}}) # Success if any scenario produced an optimal solution. - return 0 if "optimal" in {a.status, b.status, c.status, d.status} else 1 + return 0 if "optimal" in {a.status, b.status} else 1 if __name__ == "__main__": diff --git a/fasa_api/main.py b/fasa_api/main.py index 2d94d45..18df6c4 100644 --- a/fasa_api/main.py +++ b/fasa_api/main.py @@ -186,13 +186,13 @@ def formulate_endpoint( ) -> FormulateResponse: started_at = time.perf_counter() LOGGER.info( - "formulate.request species=%s stage=%s system=%s country=%s prices=%d premix_enabled=%s", + "formulate.request species=%s stage=%s system=%s country=%s prices=%d premix_rate=%s", req.species, req.stage, req.production_system, req.country, len(req.prices), - req.premix_enabled, + req.premix_rate, ) if req.species not in SUPPORTED_SPECIES: raise HTTPException( @@ -218,11 +218,8 @@ def formulate_endpoint( production_system=req.production_system, prices=req.prices, processing_method=req.processing_method, - premix_enabled=req.premix_enabled, premix_rate=req.premix_rate, - max_fishmeal_cost_share=req.max_fishmeal_cost_share, - max_binder_inclusion=req.max_binder_inclusion, - custom_premix_mask_codes=req.custom_premix_mask_codes, + premix_price=req.premix_price, batch_size_kg=req.batch_size_kg, country=req.country, nutrient_limits=req.nutrient_limits, @@ -271,7 +268,6 @@ def validate_recipe( stage=req.stage, production_system=req.production_system, processing_method=req.processing_method, - premix_enabled=req.premix_enabled, nutrient_limits=req.nutrient_limits, ingredient_limits=req.ingredient_limits, ) diff --git a/fasa_core/__init__.py b/fasa_core/__init__.py index 7e4e497..498444a 100644 --- a/fasa_core/__init__.py +++ b/fasa_core/__init__.py @@ -6,7 +6,8 @@ ... stage="< 5g (Starter)", ... production_system="General-LowCost", ... prices={"30355": 0.30, "31237": 0.55, ...}, + ... premix_price=2.50, ... processing_method="pelleted") """ -__version__ = "0.5.0" +__version__ = "1.0.0" diff --git a/fasa_core/config/defaults.py b/fasa_core/config/defaults.py index 33810f8..dba4efb 100644 --- a/fasa_core/config/defaults.py +++ b/fasa_core/config/defaults.py @@ -43,17 +43,29 @@ def as_percent(value: "float | None") -> "float | None": # --- premix --- -DEFAULT_PREMIX_RATE = 0.005 # 0.5 % of total feed mass (industry-typical for vit/min premix) +# Every feed carries a vitamin/mineral premix. It is modelled as a fixed slice of the +# feed that supplies the vitamins and trace minerals listed in premix_mask.json (their +# constraints are skipped) and adds nothing else: its own nutrients are not counted. +# Moving to a real premix with a known composition means crediting its nutrients in +# constraint_builder (the `constant` term) and trimming the mask to what it covers. +PREMIX_RATES = (0.01, 0.015, 0.02) # the rates a caller may choose: 1, 1.5 or 2 % of feed +DEFAULT_PREMIX_RATE = 0.02 + + +def check_premix_rate(rate: float) -> float: + """Return `rate` if it is one of PREMIX_RATES, else raise ValueError. + + Single source of truth for the premix-rate rule, shared by the API request + validator and the core `formulate()` entry point. + """ + if rate not in PREMIX_RATES: + raise ValueError(f"premix_rate must be one of {list(PREMIX_RATES)}") + return rate # --- processing --- DEFAULT_PROCESSING_METHOD = "pelleted" # "pelleted" | "extruded" -# --- safety / sanity caps --- -# Both caps default to None (no constraint applied) — they are opt-in advisory -# inputs surfaced through the API for callers that want to force a ceiling. -# Toxicity (TX01–TX16) and ASNS nutrient limits remain the only fixed constraints. -DEFAULT_MAX_FISHMEAL_COST_SHARE: float | None = None -DEFAULT_MAX_BINDER_INCLUSION: float | None = None +# --- soft warnings --- WARN_INGREDIENT_INCLUSION_THRESHOLD = 0.40 # log a soft warning above 40 % single-ingredient inclusion # --- numerical tolerances --- diff --git a/fasa_core/config/ingredient_limits.csv b/fasa_core/config/ingredient_limits.csv index 79cdcd3..9ea9898 100644 --- a/fasa_core/config/ingredient_limits.csv +++ b/fasa_core/config/ingredient_limits.csv @@ -41,7 +41,7 @@ # pool code that is the same ingredient as its sample name; `note` names the source row. # Table rows whose ingredient has no FICD composition are not listed here: the engine # cannot formulate with them, so a limit would have no effect. Ingredients not named -# below stay bounded only by nutrient, toxin and opt-in advisory constraints. +# below stay bounded only by nutrient and toxin constraints. code,species,production_system,stage_weight,processing_method,min_inclusion,max_inclusion,note 31408,Nile Tilapia,,,,,0.1,table row 9: Sunflower cake 31408,African Catfish,,,,,0.1,table row 9: Sunflower cake diff --git a/fasa_core/config/ingredient_pool_africa.csv b/fasa_core/config/ingredient_pool_africa.csv index ae72997..a5a4648 100644 --- a/fasa_core/config/ingredient_pool_africa.csv +++ b/fasa_core/config/ingredient_pool_africa.csv @@ -7,13 +7,14 @@ # import-only variants). Used ONLY to flag `locally_available` in the API # response — the LP is NEVER restricted to local ingredients. # is_binder / auto-derived from the description (fishmeal=1 only for "Fish meal ..."; -# is_fishmeal binder=1 for starch/flour/cassava binders). These feed only the opt-in, -# off-by-default advisory caps. +# is_fishmeal binder=1 for starch/flour/cassava binders). Group tags only; not used +# by the LP. # max_inclusion blank = no per-ingredient cap (LP decides). Only toxin (TX*) and ASNS # nutrient limits are fixed. # # Country-pool codes absent from FICD (no composition data) are excluded and cannot be -# formulated until FICD gains their rows. +# formulated until FICD gains their rows. Vitamin and mineral premix products are +# excluded too: the premix is a fixed slice of every feed, not a pool ingredient. code,description,class,is_fishmeal,is_binder,max_inclusion,countries 10009,"Fish meal, anchovy, 67% CP",animal_protein,1,0,,"KE,NG,ZM" 10011,"Fish meal, freshwater alewife, 65% CP",animal_protein,1,0,,"KE,NG,ZM" @@ -45,22 +46,6 @@ code,description,class,is_fishmeal,is_binder,max_inclusion,countries 23002,"Poultry by-product meal, feed-grade, 60% CP",animal_protein,0,0,, 23012,"Poultry bone meal, China, 45% CP",animal_protein,0,0,,"KE,NG,ZM" 51106,Poultry fat,animal_protein,0,0,,"KE,NG" -60112,"Vitamin premix IAFFD Standard, FW shrimp starter, 0.4%",animal_protein,0,0,,"KE,NG" -60113,"Vitamin premix IAFFD Standard, FW shrimp grower, 0.4%",animal_protein,0,0,,"KE,NG" -60114,"Vitamin premix IAFFD Standard, FW shrimp finisher, 0.4%",animal_protein,0,0,,"KE,NG" -60115,"Vitamin premix IAFFD Standard, FW shrimp broodstock, 0.4%",animal_protein,0,0,,"KE,NG" -60116,"Vitamin premix IAFFD Standard, SW shrimp starter, 0.4%",animal_protein,0,0,,"KE,NG" -60117,"Vitamin premix IAFFD Standard, SW shrimp grower, 0.4%",animal_protein,0,0,,"KE,NG" -60118,"Vitamin premix IAFFD Standard, SW shrimp finisher, 0.4%",animal_protein,0,0,,"KE,NG" -60119,"Vitamin premix IAFFD Standard, SW shrimp brood, 0.4%",animal_protein,0,0,,"KE,NG" -62114,"Mineral premix IAFFD Standard, FW shrimp starter, 0.25%",animal_protein,0,0,,"KE,NG" -62115,"Mineral premix IAFFD Standard, FW shrimp grower, 0.25%",animal_protein,0,0,,"KE,NG" -62116,"Mineral premix IAFFD Standard, FW shrimp finisher, 0.25%",animal_protein,0,0,,"KE,NG" -62117,"Mineral premix IAFFD Standard, FW shrimp brood, 0.25%",animal_protein,0,0,,"KE,NG" -62118,"Mineral premix IAFFD Standard, SW shrimp starter, 0.25%",animal_protein,0,0,,"KE,NG" -62119,"Mineral premix IAFFD Standard, SW shrimp grower, 0.25%",animal_protein,0,0,,"KE,NG" -62120,"Mineral premix IAFFD Standard, SW shrimp finisher, 0.25%",animal_protein,0,0,,"KE,NG" -62121,"Mineral premix IAFFD Standard, SW shrimp broodstock, 0.25%",animal_protein,0,0,,"KE,NG" 30307,"Cassava, tuber, meal",binder,0,1,,"KE,NG,ZM" 30310,"Cassava, chips",binder,0,1,,"KE,NG,ZM" 30311,"Cassava, starch 75%",binder,0,1,,"KE,NG,ZM" @@ -195,23 +180,7 @@ code,description,class,is_fishmeal,is_binder,max_inclusion,countries 62157,Sodium Bicarbonate,mineral,0,0,,"KE,NG" 70125,Mold inhibitor (calcium propionate),mineral,0,0,,"KE,NG" 70132,Mycotoxin binder (Mineral clay product: zeolites),mineral,0,0,,"KE,NG" -60120,"Vitamin premix IAFFD Standard, FW fish starter, 0.5%",premix,0,0,,"KE,NG" -60121,"Vitamin premix IAFFD Standard, FW fish grower, 0.5%",premix,0,0,,"KE,NG" -60122,"Vitamin premix IAFFD Standard, FW fish finisher, 0.5%",premix,0,0,,"KE,NG" -60123,"Vitamin premix IAFFD Standard, FW fish broodstock, 0.5%",premix,0,0,,"KE,NG" -60124,"Vitamin premix IAFFD Standard, SW fish starter, 0.5%",premix,0,0,,"KE,NG" -60125,"Vitamin premix IAFFD Standard, SW fish grower, 0.5%",premix,0,0,,"KE,NG" -60126,"Vitamin premix IAFFD Standard, SW fish finisher, 0.5%",premix,0,0,,"KE,NG" -60127,"Vitamin premix IAFFD Standard, SW fish brood, 0.5%",premix,0,0,,"KE,NG" 61105,Amino acid premix,premix,0,0,,"KE,NG" -62122,"Mineral premix IAFFD Standard, FW fish starter, 0.25%",premix,0,0,,"KE,NG" -62123,"Mineral premix IAFFD Standard, FW fish grower, 0.25%",premix,0,0,,"KE,NG" -62124,"Mineral premix IAFFD Standard, FW fish finisher, 0.25%",premix,0,0,,"KE,NG" -62125,"Mineral premix IAFFD Standard, FW fish broodstock, 0.25%",premix,0,0,,"KE,NG" -62126,"Mineral premix IAFFD Standard, SW fish starter, 0.25%",premix,0,0,,"KE,NG" -62127,"Mineral premix IAFFD Standard, SW fish grower, 0.25%",premix,0,0,,"KE,NG" -62128,"Mineral premix IAFFD Standard, SW fish finisher, 0.25%",premix,0,0,,"KE,NG" -62129,"Mineral premix IAFFD Standard, SW fish brood, 0.25%",premix,0,0,,"KE,NG" 13001,"Fish bone meal, fish processing byproduct",protein_meal,0,0,,"KE,NG,ZM" 27001,"Maggot meal, MagMeal, AgriProtein",protein_meal,0,0,,"KE,NG,ZM" 30105,"Banana meal, 4% CP",protein_meal,0,0,,"KE,NG,ZM" diff --git a/fasa_core/config/premix_mask.json b/fasa_core/config/premix_mask.json index 47a7ef5..df298e5 100644 --- a/fasa_core/config/premix_mask.json +++ b/fasa_core/config/premix_mask.json @@ -2,11 +2,10 @@ "_doc": [ "Premix mask: ASNS specification codes whose nutritional requirement is assumed", "to be satisfied by a fixed-rate vitamin/trace-mineral premix added to the feed.", - "When premix_enabled = true (default), constraints with these codes are SKIPPED", + "Every feed carries the premix, so constraints with these codes are always SKIPPED", "by the LP; everything else (macros, AAs, energy, Ca, P, fatty acids, toxins) stays.", "", - "FASA scientists can override per stage via the API by passing a custom mask;", - "the keys here are simply defaults sourced from common smallholder-feed-mill practice.", + "The codes here are defaults sourced from common smallholder-feed-mill practice.", "", "Toxins (TX*) are NEVER masked — they are hard ceilings driven by raw-ingredient", "selection, regardless of premix presence." diff --git a/fasa_core/constraint_builder.py b/fasa_core/constraint_builder.py index af942bc..3cb8180 100644 --- a/fasa_core/constraint_builder.py +++ b/fasa_core/constraint_builder.py @@ -17,9 +17,9 @@ } The premix mask is applied here: masked spec codes never make it into the -output. Toxin (TX*) maximums are NEVER masked even if the user passes a custom -mask that includes them — the premix doesn't add toxins, and the safety -ceilings on aflatoxin/gossypol/etc. are non-negotiable per the FASA brief. +output. Toxin (TX*) maximums are NEVER masked, even if premix_mask.json lists +them — the premix doesn't add toxins, and the safety ceilings on +aflatoxin/gossypol/etc. are non-negotiable per the FASA brief. The nutrient inclusion-limit layer (`inclusion_limits`) is folded into the ASNS row set before emission, so the limits inherit the whole existing pipeline — @@ -67,9 +67,6 @@ def build_constraints( production_system: str, pool: list[IngredientRecord], processing_method: str = "pelleted", - premix_enabled: bool = True, - premix_rate: float = 0.005, - premix_mask_override: Optional[list[str]] = None, nutrient_limits: Optional[dict[str, Bound]] = None, ) -> tuple[list[LinearConstraint], list[str]]: """Materialize the active constraint set. @@ -83,11 +80,8 @@ def build_constraints( asns_active, warnings = _apply_nutrient_limits(asns_active, nutrient_limits or {}) ficd_pool = attach_ficd_rows(pool).set_index("code") - mask = ( - crosswalk.premix_mask_codes(stage, premix_mask_override) - if premix_enabled - else set() - ) + # every feed carries the premix, so the specs it covers are always masked + mask = crosswalk.premix_mask_codes(stage) out: list[LinearConstraint] = [] @@ -104,7 +98,7 @@ def build_constraints( if code in mask and not code.startswith("TX") and source == "asns": # vit/trace-mineral satisfied by premix; skip silently continue - # toxins are NEVER masked, even via override. Neither is a row the limit layer + # toxins are NEVER masked. Neither is a row the limit layer # created or tightened — but a plain ASNS row for the same code stays masked, # so bounding zinc's maximum never resurrects the minimum the premix supplies. diff --git a/fasa_core/crosswalk.py b/fasa_core/crosswalk.py index 2984828..5540ed7 100644 --- a/fasa_core/crosswalk.py +++ b/fasa_core/crosswalk.py @@ -62,21 +62,15 @@ def resolve(spec_code: str, processing_method: str = "pelleted") -> Tuple[Any, A return (entry.get("ficd_param"), factor) -def premix_mask_codes(stage: str | None = None, - override: list[str] | None = None) -> set[str]: - """Return the set of ASNS spec codes whose constraints are skipped when - premix_enabled = True. +def premix_mask_codes(stage: str | None = None) -> set[str]: + """Return the set of ASNS spec codes whose constraints are skipped because + the premix supplies them. Parameters ---------- stage : optional ASNS stage_weight label; consulted for stage-specific add/remove tweaks declared in premix_mask.json - override : optional explicit list of codes; if supplied, used wholesale - (no additive merging with defaults). """ - if override is not None: - return set(override) - raw = _premix_raw() base = set(raw.get("default_mask_codes", [])) diff --git a/fasa_core/inclusion_limits.py b/fasa_core/inclusion_limits.py index b80380b..6f39dda 100644 --- a/fasa_core/inclusion_limits.py +++ b/fasa_core/inclusion_limits.py @@ -19,9 +19,9 @@ match, the most specific row wins; ties resolve toward the tighter bound, and each direction resolves independently. -Precedence per code: a request-supplied limit replaces the CSV-resolved one wholesale -(the `custom_premix_mask_codes` precedent), and for ingredients the pool CSV's generated -`max_inclusion` is the fallback when neither layer names the ingredient. +Precedence per code: a request-supplied limit replaces the CSV-resolved one wholesale, +and for ingredients the pool CSV's generated `max_inclusion` is the fallback when +neither layer names the ingredient. """ from __future__ import annotations diff --git a/fasa_core/ingredient_pool.py b/fasa_core/ingredient_pool.py index ec52fe0..34a7d34 100644 --- a/fasa_core/ingredient_pool.py +++ b/fasa_core/ingredient_pool.py @@ -1,10 +1,10 @@ """Filter the FICD wide table down to the ingredients the LP is allowed to use. The pool lives in `config/ingredient_pool_africa.csv` (regenerated by -`scripts/build_ingredient_pool.py`). Each row carries flags for the fish-meal -cost-share cap (`is_fishmeal`) and binder cap (`is_binder`), an optional -per-ingredient `max_inclusion`, and a `countries` column listing the ISO-2 codes -where the ingredient is locally available. The `countries` tag is advisory only: +`scripts/build_ingredient_pool.py`). Each row carries ingredient-group tags +(`is_fishmeal`, `is_binder`; not read by the engine), an optional per-ingredient +`max_inclusion`, and a `countries` column listing the ISO-2 codes where the +ingredient is locally available. The `countries` tag is advisory only: it drives the `locally_available` response flag and never restricts the LP. """ @@ -28,8 +28,6 @@ class IngredientRecord: code: str description: str cls: str # informational grouping label; not used by the LP - is_fishmeal: bool - is_binder: bool max_inclusion: Optional[float] # None ⇒ no per-ingredient cap countries: frozenset[str] = field(default_factory=frozenset) # ISO-2 local-availability tags @@ -58,8 +56,6 @@ def _load_full_pool() -> tuple[IngredientRecord, ...]: code=str(r["code"]).strip(), description=str(r["description"]), cls=str(r["class"]), - is_fishmeal=bool(int(r.get("is_fishmeal", 0) or 0)), - is_binder=bool(int(r.get("is_binder", 0) or 0)), max_inclusion=( float(r["max_inclusion"]) if pd.notna(r.get("max_inclusion")) else None ), diff --git a/fasa_core/models.py b/fasa_core/models.py index b94af52..0373d72 100644 --- a/fasa_core/models.py +++ b/fasa_core/models.py @@ -10,7 +10,12 @@ from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator -from .config.defaults import normalize_country +from .config.defaults import ( + DEFAULT_PREMIX_RATE, + PREMIX_RATES, + check_premix_rate, + normalize_country, +) # ---------- inclusion limits (shared by request and response) --------------- # @@ -115,39 +120,29 @@ def _normalize_country(cls, v): ) processing_method: Literal["pelleted", "extruded"] = "pelleted" - premix_enabled: bool = True - premix_rate: float = Field(0.005, ge=0.0, lt=0.10) - max_fishmeal_cost_share: Optional[float] = Field( - default=None, - ge=0.0, - le=1.0, + # The premix is always included; the caller picks its rate and price. + premix_rate: float = Field( + DEFAULT_PREMIX_RATE, description=( - "Optional cap on fish-meal cost share (fraction of total recipe cost). " - "Omit (or send null) to apply no cap — cost alone then drives fish-meal " - "inclusion. Kept as an opt-in advisory input for callers that want to " - "force a sustainability ceiling." + "Share of the feed taken by the vitamin/mineral premix, as a mass fraction: " + "0.01, 0.015 or 0.02 (1, 1.5 or 2%)." ), + json_schema_extra={"enum": list(PREMIX_RATES)}, ) - max_binder_inclusion: Optional[float] = Field( - default=None, + premix_price: float = Field( + ..., ge=0.0, - le=1.0, description=( - "Optional cap on collective binder mass fraction. Omit (or send null) " - "to apply no cap — appropriate when the binder product's manufacturer-" - "recommended inclusion rate is unknown. Kept as an opt-in advisory input." + "Premix price per kg, in the same currency as `prices`. Counted in " + "`cost_per_kg` and `total_cost`." ), ) - custom_premix_mask_codes: Optional[List[str]] = Field( - default=None, - description=( - "Override the default premix mask. If provided, replaces the default " - "list of ASNS spec codes whose constraints will be skipped." - ), - max_length=200, - ) + @field_validator("premix_rate") + @classmethod + def _premix_rate_is_offered(cls, v): + return check_premix_rate(v) nutrient_limits: Optional[Dict[str, NutrientLimit]] = Field( default=None, @@ -213,15 +208,8 @@ class ValidateRecipeRequest(BaseModel): default="pelleted", description=( "Scopes the inclusion limits and selects the digestible-energy column " - "used by the nutrient checks." - ), - ) - premix_enabled: bool = Field( - default=True, - description=( - "Mirrors /formulate: when true the vitamin and trace-mineral specs the " - "premix covers are excluded from the nutrient checks, since the premix's " - "own contribution is not modelled." + "used by the nutrient checks. As in /formulate, the vitamin and " + "trace-mineral specs the premix covers are not checked." ), ) @@ -329,11 +317,10 @@ class FormulateResponse(BaseModel): warnings: List[str] = [] infeasibility: Optional[InfeasibilityReport] = None - # always echoed back so the API client can display them - premix_enabled: bool + # always echoed back so the API client can display them; `cost_per_kg` includes + # premix_rate × premix_price on top of the recipe lines' cost_contribution premix_rate: float - max_fishmeal_cost_share: Optional[float] = None - max_binder_inclusion: Optional[float] = None + premix_price: float # populated only when batch_size_kg was supplied on the request batch_size_kg: Optional[float] = None @@ -392,7 +379,7 @@ class ValidateRecipeResponse(BaseModel): default=0.0, description=( "Sum of the supplied fractions, as a percentage of feed mass. A complete " - "diet sums to 100%, or 99-100% when a premix takes the remainder." + "diet sums to 100%, or 98-100% when a premix takes the remainder." ), ) in_limits: bool = Field( diff --git a/fasa_core/optimizer.py b/fasa_core/optimizer.py index a369e7d..7f4b597 100644 --- a/fasa_core/optimizer.py +++ b/fasa_core/optimizer.py @@ -29,8 +29,6 @@ from . import inclusion_limits from .config.defaults import ( - DEFAULT_MAX_BINDER_INCLUSION, - DEFAULT_MAX_FISHMEAL_COST_SHARE, DEFAULT_PREMIX_RATE, DEFAULT_PROCESSING_METHOD, DEFAULT_SOLVER, @@ -38,11 +36,12 @@ SOLVER_TIME_LIMIT_SECONDS, WARN_INGREDIENT_INCLUSION_THRESHOLD, as_percent, + check_premix_rate, normalize_country, ) from .constraint_builder import LinearConstraint, build_constraints, score_constraints from .inclusion_limits import Bound, LimitScope -from .ingredient_pool import IngredientRecord, load_pool +from .ingredient_pool import load_pool from .models import ( FormulateResponse, InfeasibilityReport, @@ -63,12 +62,9 @@ def formulate( production_system: str, prices: dict[str, float], *, + premix_price: float, processing_method: str = DEFAULT_PROCESSING_METHOD, - premix_enabled: bool = True, premix_rate: float = DEFAULT_PREMIX_RATE, - max_fishmeal_cost_share: Optional[float] = DEFAULT_MAX_FISHMEAL_COST_SHARE, - max_binder_inclusion: Optional[float] = DEFAULT_MAX_BINDER_INCLUSION, - custom_premix_mask_codes: Optional[list[str]] = None, batch_size_kg: Optional[float] = None, country: Optional[str] = None, nutrient_limits: Optional[dict] = None, @@ -76,6 +72,9 @@ def formulate( ) -> FormulateResponse: """Run the LP and return a structured response. + The premix always takes `premix_rate` of the feed; its cost + (`premix_rate * premix_price`) is a constant added to the LP's cost per kg. + On infeasibility we run a deletion-filter to extract an Irreducible Inconsistent Subset (IIS) of constraint codes and return that to the caller. """ @@ -83,17 +82,23 @@ def formulate( # direct callers get the same case/whitespace handling as the API layer, and # `locally_available` is compared against the pool's upper-cased ISO-2 tags. country = normalize_country(country) + check_premix_rate(premix_rate) + + # request fields every response echoes back, whatever its status + echoed = dict( + species=species, stage=stage, production_system=production_system, + processing_method=processing_method, country=country, + premix_rate=premix_rate, premix_price=premix_price, batch_size_kg=batch_size_kg, + ) # ---------- 1. pool & coefficient assembly ------------------------------ # pool = load_pool(only_codes=set(prices.keys())) if not pool: - return _err_response( - species, stage, production_system, processing_method, - premix_enabled, premix_rate, - max_fishmeal_cost_share, max_binder_inclusion, - batch_size_kg, country, - "No priced ingredients overlap with the configured pool.", + return FormulateResponse( + status="error", + warnings=["No priced ingredients overlap with the configured pool."], + **echoed, ) pool_by_code = {r.code: r for r in pool} @@ -120,9 +125,6 @@ def formulate( production_system=production_system, pool=pool, processing_method=processing_method, - premix_enabled=premix_enabled, - premix_rate=premix_rate, - premix_mask_override=custom_premix_mask_codes, nutrient_limits=nutrient_box, ) build_warnings += inclusion_limits.zero_cap_warnings(ingredient_box, pool_by_code) @@ -131,27 +133,11 @@ def formulate( # here when empirical pairwise data becomes available (Hua & Bureau 2012). constraints = _apply_anti_nutrient_digestibility_penalties(constraints) - target_mass = 1.0 - (premix_rate if premix_enabled else 0.0) - solver_kwargs = dict( - premix_rate=premix_rate if premix_enabled else 0.0, - max_fishmeal_cost_share=max_fishmeal_cost_share, - max_binder_inclusion=max_binder_inclusion, - bounds=ingredient_box, - ) + target_mass = 1.0 - premix_rate def _infeasible(report: InfeasibilityReport) -> FormulateResponse: return FormulateResponse( - status="infeasible", - species=species, stage=stage, production_system=production_system, - processing_method=processing_method, - country=country, - warnings=build_warnings, - infeasibility=report, - premix_enabled=premix_enabled, - premix_rate=premix_rate, - max_fishmeal_cost_share=max_fishmeal_cost_share, - max_binder_inclusion=max_binder_inclusion, - batch_size_kg=batch_size_kg, + status="infeasible", warnings=build_warnings, infeasibility=report, **echoed, ) # ---------- 2. bound-feasibility pre-check ------------------------------ # @@ -173,17 +159,13 @@ def _infeasible(report: InfeasibilityReport) -> FormulateResponse: # ---------- 3. build & solve the LP ------------------------------------- # - prob, x = _build_pulp_problem( - ingr_codes, pool_by_code, prices, constraints, **solver_kwargs - ) + prob, x = _build_pulp_problem(ingr_codes, prices, constraints, target_mass, ingredient_box) status = _solve(prob) # ---------- 4. infeasibility path --------------------------------------- # if status != pulp.LpStatusOptimal: - iis = _deletion_filter_iis( - ingr_codes, pool_by_code, prices, constraints, **solver_kwargs - ) + iis = _deletion_filter_iis(ingr_codes, prices, constraints, target_mass, ingredient_box) return _infeasible(InfeasibilityReport( iis_codes=[c.spec_code for c in iis], iis_explanations=[ @@ -194,7 +176,7 @@ def _infeasible(report: InfeasibilityReport) -> FormulateResponse: ], suggestion=( "Consider adding alternative ingredients (e.g., synthetic Lys/Met, " - "fish meal, soybean meal), increasing the premix rate, relaxing an " + "fish meal, soybean meal), a lower premix rate, relaxing an " "inclusion limit, or relaxing the production-system tier " "(General-LowCost ↔ General)." ), @@ -203,7 +185,7 @@ def _infeasible(report: InfeasibilityReport) -> FormulateResponse: # ---------- 5. extract & decorate solution ------------------------------ # solution_fractions = {c: float(v.value() or 0.0) for c, v in x.items()} - cost = float(pulp.value(prob.objective)) + cost = float(pulp.value(prob.objective)) + premix_rate * premix_price recipe = [] warnings = list(build_warnings) @@ -237,28 +219,17 @@ def _infeasible(report: InfeasibilityReport) -> FormulateResponse: recipe.sort(key=lambda r: -r.inclusion_percent) total_cost = round(cost * batch_size_kg, 2) if batch_size_kg is not None else None - premix_qty_kg = ( - round(batch_size_kg * premix_rate, 3) - if batch_size_kg is not None and premix_enabled - else None - ) + premix_qty_kg = round(batch_size_kg * premix_rate, 3) if batch_size_kg is not None else None return FormulateResponse( status="optimal", - species=species, stage=stage, production_system=production_system, - processing_method=processing_method, - country=country, cost_per_kg=round(cost, 6), recipe=recipe, composition=composition, warnings=warnings, - premix_enabled=premix_enabled, - premix_rate=premix_rate, - max_fishmeal_cost_share=max_fishmeal_cost_share, - max_binder_inclusion=max_binder_inclusion, - batch_size_kg=batch_size_kg, premix_quantity_kg=premix_qty_kg, total_cost=total_cost, + **echoed, ) @@ -269,12 +240,9 @@ def _infeasible(report: InfeasibilityReport) -> FormulateResponse: def _build_pulp_problem( ingr_codes: list[str], - pool_by_code: dict[str, IngredientRecord], prices: dict[str, float], constraints: list[LinearConstraint], - premix_rate: float, - max_fishmeal_cost_share: Optional[float], - max_binder_inclusion: Optional[float], + target_mass: float, bounds: dict[str, Bound], ): prob = pulp.LpProblem("FASA_FeedFormulation", pulp.LpMinimize) @@ -289,8 +257,8 @@ def _build_pulp_problem( # objective prob += pulp.lpSum(prices[c] * x[c] for c in ingr_codes), "TotalCost_per_kg" - # mass balance — note the premix takes a fixed slice - prob += pulp.lpSum(x[c] for c in ingr_codes) == 1.0 - premix_rate, "MassBalance" + # mass balance — the ingredients fill what the premix leaves (1 - premix_rate) + prob += pulp.lpSum(x[c] for c in ingr_codes) == target_mass, "MassBalance" # nutritional constraints for k, con in enumerate(constraints): @@ -303,19 +271,6 @@ def _build_pulp_problem( elif con.restriction_type == "Ratio": prob += lhs == con.rhs, name - # binder cap — opt-in; skipped when None or 1.0 - binders = [c for c in ingr_codes if pool_by_code[c].is_binder] - if binders and max_binder_inclusion is not None and max_binder_inclusion < 1.0: - prob += pulp.lpSum(x[c] for c in binders) <= max_binder_inclusion, "BinderCap" - - # fish-meal cost-share cap — opt-in; skipped when None or 1.0 - # Σ_{i ∈ FM} price_i * x_i <= share * Σ_i price_i * x_i - fishmeals = [c for c in ingr_codes if pool_by_code[c].is_fishmeal] - if fishmeals and max_fishmeal_cost_share is not None and max_fishmeal_cost_share < 1.0: - fm_cost = pulp.lpSum(prices[c] * x[c] for c in fishmeals) - all_cost = pulp.lpSum(prices[c] * x[c] for c in ingr_codes) - prob += fm_cost <= max_fishmeal_cost_share * all_cost, "FishMealCostShareCap" - return prob, x @@ -347,8 +302,7 @@ def _solve(prob: pulp.LpProblem) -> int: def _deletion_filter_iis( - ingr_codes, pool_by_code, prices, constraints, *, - premix_rate, max_fishmeal_cost_share, max_binder_inclusion, bounds, + ingr_codes, prices, constraints, target_mass, bounds, ) -> list[LinearConstraint]: """Greedy deletion-filter: returns a minimal infeasible subset. @@ -359,13 +313,7 @@ def _deletion_filter_iis( keep = list(constraints) def _is_infeasible(subset): - prob, _ = _build_pulp_problem( - ingr_codes, pool_by_code, prices, subset, - premix_rate=premix_rate, - max_fishmeal_cost_share=max_fishmeal_cost_share, - max_binder_inclusion=max_binder_inclusion, - bounds=bounds, - ) + prob, _ = _build_pulp_problem(ingr_codes, prices, subset, target_mass, bounds) st = _solve(prob) return st != pulp.LpStatusOptimal @@ -407,21 +355,3 @@ def _apply_anti_nutrient_digestibility_penalties( of ingredients that co-occur with high tannin / ANF / lectin content. """ return constraints - - -def _err_response( - species, stage, system, method, - premix_enabled, premix_rate, - max_fishmeal_cost_share, max_binder_inclusion, - batch_size_kg, country, - msg, -): - return FormulateResponse( - status="error", - species=species, stage=stage, production_system=system, - processing_method=method, country=country, warnings=[msg], - premix_enabled=premix_enabled, premix_rate=premix_rate, - max_fishmeal_cost_share=max_fishmeal_cost_share, - max_binder_inclusion=max_binder_inclusion, - batch_size_kg=batch_size_kg, - ) diff --git a/fasa_core/validator.py b/fasa_core/validator.py index 8540ab9..3f6b3e2 100644 --- a/fasa_core/validator.py +++ b/fasa_core/validator.py @@ -23,6 +23,7 @@ from . import inclusion_limits from .config.defaults import ( DEFAULT_PROCESSING_METHOD, + PREMIX_RATES, SOLUTION_FRACTION_TOL, VALIDATION_REL_TOL, as_percent, @@ -33,10 +34,9 @@ from .ingredient_pool import load_pool from .models import IngredientLimitLine, NutrientLine, ValidateRecipeResponse -# A complete diet sums to 100% of feed mass, or a little less when a vitamin/mineral -# premix takes the remainder (the LP returns 1 - premix_rate, default 99.5%). Same -# band the smoke tests assert on the LP's own output. -MIN_TOTAL_INCLUSION = 0.99 +# A complete diet sums to 100% of feed mass, or a little less when the vitamin/mineral +# premix takes the remainder (the LP returns 1 - premix_rate, 98-99%). +MIN_TOTAL_INCLUSION = 1.0 - max(PREMIX_RATES) MAX_TOTAL_INCLUSION = 1.0 @@ -84,7 +84,6 @@ def validate_recipe( stage: Optional[str] = None, production_system: Optional[str] = None, processing_method: str = DEFAULT_PROCESSING_METHOD, - premix_enabled: bool = True, nutrient_limits: Optional[dict] = None, ingredient_limits: Optional[dict] = None, ) -> ValidateRecipeResponse: @@ -152,7 +151,6 @@ def validate_recipe( production_system=production_system, pool=pool, processing_method=processing_method, - premix_enabled=premix_enabled, nutrient_limits=inclusion_limits.nutrient_bounds( scope, overrides=inclusion_limits.from_request(nutrient_limits) ), diff --git a/pyproject.toml b/pyproject.toml index da037c2..2008ffd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fasa-engine" -version = "0.5.0" +version = "1.0.0" description = "Least-cost, digestibility-aware aquaculture feed formulation engine (FASA MVP)." readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/build_ingredient_pool.py b/scripts/build_ingredient_pool.py index ad38b0e..10b7615 100644 --- a/scripts/build_ingredient_pool.py +++ b/scripts/build_ingredient_pool.py @@ -7,15 +7,17 @@ 2. ``data/FICD_…csv`` — ingredient composition DB; source of descriptions and the authority on which codes the LP can actually use. -Output pool rows = ``(countries ∩ FICD) ∪ EXTRA_IMPORT_CODES``. Codes present in the -country list but ABSENT from FICD have no nutrient coefficients and cannot enter the LP, -so they are excluded (the count and range are logged at build time). +Output pool rows = ``(countries ∩ FICD) ∪ EXTRA_IMPORT_CODES``, minus the vitamin and +mineral premix products (``EXCLUDED_PREFIXES``). Codes present in the country list but +ABSENT from FICD have no nutrient coefficients and cannot enter the LP, so they are +excluded (the count and range are logged at build time). Per-ingredient metadata is derived from the description: ``class`` (informational), ``is_fishmeal`` (1 only for "Fish meal ..."), and ``is_binder`` (1 for starch/flour/ -cassava binders). ``is_fishmeal``/``is_binder`` feed only the opt-in, off-by-default -advisory caps. Country membership is emitted as a comma-separated ISO-2 ``countries`` -column; an empty value means "not locally available in any tracked country". +cassava binders). ``is_fishmeal``/``is_binder`` are ingredient-group tags; the LP does +not use them since the fish-meal and binder caps were removed (1.0.0). Country +membership is emitted as a comma-separated ISO-2 ``countries`` column; an empty value +means "not locally available in any tracked country". Run from the repo root: python scripts/build_ingredient_pool.py @@ -52,6 +54,11 @@ # 30410 Cottonseed meal, degossypoled EXTRA_IMPORT_CODES = ("10073", "23002", "30410", "31252", "31621") +# The premix is not a pool ingredient: every feed carries one at a fixed `premix_rate`, +# priced through `premix_price`. The IAFFD vitamin and mineral premix products are +# therefore left out so a caller cannot price one in on top of it. +EXCLUDED_PREFIXES = ("Vitamin premix", "Mineral premix") + OUTPUT_COLUMNS = [ "code", "description", "class", "is_fishmeal", "is_binder", "max_inclusion", "countries", @@ -67,13 +74,14 @@ # import-only variants). Used ONLY to flag `locally_available` in the API # response — the LP is NEVER restricted to local ingredients. # is_binder / auto-derived from the description (fishmeal=1 only for "Fish meal ..."; -# is_fishmeal binder=1 for starch/flour/cassava binders). These feed only the opt-in, -# off-by-default advisory caps. +# is_fishmeal binder=1 for starch/flour/cassava binders). Group tags only; not used +# by the LP. # max_inclusion blank = no per-ingredient cap (LP decides). Only toxin (TX*) and ASNS # nutrient limits are fixed. # # Country-pool codes absent from FICD (no composition data) are excluded and cannot be -# formulated until FICD gains their rows. +# formulated until FICD gains their rows. Vitamin and mineral premix products are +# excluded too: the premix is a fixed slice of every feed, not a pool ingredient. """ BINDER_KEYWORDS = ("starch", "wheat flour") @@ -158,9 +166,10 @@ def main() -> int: if missing_imports: raise SystemExit(f"EXTRA_IMPORT_CODES absent from FICD: {missing_imports}") - # pool = (country codes present in FICD) ∪ (retained import variants) + # pool = (country codes present in FICD) ∪ (retained import variants) − premixes + premixes = {c for c in ficd_codes if str(ficd_desc[c]).startswith(EXCLUDED_PREFIXES)} pool_codes = sorted( - (attached_codes & ficd_codes) | set(EXTRA_IMPORT_CODES), + ((attached_codes & ficd_codes) | set(EXTRA_IMPORT_CODES)) - premixes, key=lambda c: int(c) if c.isdigit() else c, ) @@ -187,13 +196,14 @@ def main() -> int: # ---- summary ---- # The is_fishmeal/is_binder lists are printed in full so a human can audit the - # heuristic on every regeneration (these flags feed the opt-in advisory caps). + # heuristic on every regeneration. fishmeal_codes = out.loc[out["is_fishmeal"] == 1, "code"].tolist() binder_codes = out.loc[out["is_binder"] == 1, "code"].tolist() print(f"wrote {POOL_OUT.relative_to(REPO_ROOT)}: {len(out)} rows") print(f" is_fishmeal=1 ({len(fishmeal_codes)}): {', '.join(fishmeal_codes)}") print(f" is_binder=1 ({len(binder_codes)}): {', '.join(binder_codes)}") print(f" retained import variants (empty countries): {len(EXTRA_IMPORT_CODES)}") + print(f" premix products EXCLUDED: {len(premixes & attached_codes)}") print(f" country-pool codes EXCLUDED (absent from FICD): {len(excluded)}") if excluded: print(f" range: {excluded[0]}..{excluded[-1]}") diff --git a/tests/test_api.py b/tests/test_api.py index 0b71efa..6e4606f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,7 +3,7 @@ from fastapi.testclient import TestClient from fasa_api.main import app -from tests.test_smoke import DEMO_PRICES +from tests.test_smoke import BASE def _client(auth_required: bool = True, token: str = "test-token") -> TestClient: @@ -45,12 +45,7 @@ def test_supported_lists_countries(): def test_formulate_rejects_unsupported_country(): client = _client(auth_required=True, token="abc123") - payload = { - "species": "Nile Tilapia", - "stage": "< 5g (Starter)", - "prices": {"30355": 0.30}, - "country": "US", - } + payload = {**BASE, "country": "US"} r = client.post( "/formulate", json=payload, @@ -77,9 +72,7 @@ def test_formulate_accepts_inclusion_limits(): """Both limit layers travel through the HTTP surface and shape the response.""" client = _client(auth_required=True, token="abc123") payload = { - "species": "Nile Tilapia", - "stage": "< 5g (Starter)", - "prices": DEMO_PRICES, + **BASE, "nutrient_limits": {"PA05": {"max": 8.0}}, "ingredient_limits": {"30355": {"max": 0.35}}, } @@ -99,9 +92,7 @@ def test_formulate_accepts_inclusion_limits(): def test_formulate_rejects_an_out_of_range_ingredient_limit(): client = _client(auth_required=True, token="abc123") payload = { - "species": "Nile Tilapia", - "stage": "< 5g (Starter)", - "prices": {"30355": 0.30}, + **BASE, "ingredient_limits": {"30355": {"max": 1.5}}, # >100% of feed mass } r = client.post("/formulate", json=payload, headers={"Authorization": "Bearer abc123"}) @@ -126,7 +117,7 @@ def test_validate_recipe_checks_inclusion_rates(): """Inclusion limits travel through the HTTP surface and flag a breach.""" client = _client(auth_required=True, token="abc123") payload = { - "fractions": {"31605": 0.40, "31621": 0.595}, + "fractions": {"31605": 0.40, "31621": 0.58}, "parameters": [], "species": "Nile Tilapia", "stage": "< 5g (Starter)", @@ -138,7 +129,7 @@ def test_validate_recipe_checks_inclusion_rates(): assert r.status_code == 200 body = r.json() assert body["in_limits"] is False - assert body["total_inclusion_percent"] == 99.5 + assert body["total_inclusion_percent"] == 98.0 bran = next(c for c in body["inclusion_checks"] if c["code"] == "31605") assert (bran["max_inclusion_percent"], bran["in_limits"]) == (15.0, False) # full scope given, so the nutrient side was scored too @@ -167,3 +158,16 @@ def test_validate_recipe_rejects_an_unknown_parameter(): headers={"Authorization": "Bearer abc123"}) assert r.status_code == 400 assert "sid_lys_porcine_percent" in r.json()["detail"]["details"] + + +def test_removed_fields_are_rejected(): + """Fields removed in 1.0.0 are a 422, not silently ignored.""" + client = _client(auth_required=True, token="abc123") + headers = {"Authorization": "Bearer abc123"} + for field, value in [("premix_enabled", False), ("max_fishmeal_cost_share", 0.3), + ("max_binder_inclusion", 0.1), ("custom_premix_mask_codes", [])]: + r = client.post("/formulate", json={**BASE, field: value}, headers=headers) + assert r.status_code == 422, field + r = client.post("/validate-recipe", headers=headers, + json={"fractions": {"30355": 0.98}, "premix_enabled": True}) + assert r.status_code == 422 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 8114500..32bd1cd 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -89,8 +89,9 @@ def test_resolve_vitamin_unit_conversion(): def test_duplicate_vitamin_codes_are_skipped_without_a_warning(): - """V101/V111 restate V10/V11 in mg/kg and ug/kg; only V10/V11 may reach the LP.""" - res = formulate(**BASE, premix_enabled=False) + """V101/V111 restate V10/V11 in mg/kg and ug/kg and are not premix-masked, so only + the duplicate rule keeps them out of the LP.""" + res = formulate(**BASE) assert not any("V101" in w or "V111" in w for w in res.warnings) assert not {"V101", "V111"} & {line.code for line in res.composition} @@ -128,40 +129,39 @@ def test_premix_mask_excludes_amino_acids_and_macros(): "62138": 0.10, "62134": 0.80, "62135": 0.15, "61109": 3.00, "61111": 4.50, } +PREMIX_PRICE = 2.50 # per kg; every formulation carries the premix + +# the request most tests start from; vary one field with `formulate(**BASE, field=...)` +BASE = dict( + species="Nile Tilapia", + stage="< 5g (Starter)", + production_system="General-LowCost", + prices=DEMO_PRICES, + premix_price=PREMIX_PRICE, +) def test_formulate_tilapia_starter_returns_a_status(): - res = formulate( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices=DEMO_PRICES, - ) + res = formulate(**BASE) assert res.status in {"optimal", "infeasible", "error"} assert res.species == "Nile Tilapia" if res.status == "optimal": - # mass balance honored (≤ 100% because premix takes 0.5%) + # mass balance honored: the premix takes the rest total = sum(line.inclusion_percent for line in res.recipe) - assert 99.0 <= total <= 100.0 + assert abs(total - (1 - res.premix_rate) * 100) < 1e-3 def test_formulate_batch_size_kg_populates_absolute_quantities(): """When batch_size_kg is supplied, recipe carries kg per line and response reports premix kg + total batch cost. Sum of kg ≈ batch size.""" batch = 100.0 - res = formulate( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices=DEMO_PRICES, - batch_size_kg=batch, - ) + res = formulate(**BASE, batch_size_kg=batch) if res.status != "optimal": pytest.skip(f"LP did not solve to optimal on this priced pool (status={res.status})") # echoed assert res.batch_size_kg == batch - # premix_enabled defaults to True, so premix kg = batch * premix_rate + # the premix is always in, so premix kg = batch * premix_rate assert res.premix_quantity_kg == round(batch * res.premix_rate, 3) assert res.total_cost == round(res.cost_per_kg * batch, 2) @@ -177,14 +177,37 @@ def test_formulate_batch_size_kg_populates_absolute_quantities(): assert abs(total_kg - batch) < 0.05 +def test_premix_cost_is_counted_in_the_feed_cost(): + """cost_per_kg = recipe lines' cost_contribution + premix_rate * premix_price.""" + res = formulate(**BASE, premix_rate=0.015) + if res.status != "optimal": + pytest.skip(f"LP did not solve to optimal (status={res.status})") + lines = sum(line.cost_contribution for line in res.recipe) + assert (res.premix_rate, res.premix_price) == (0.015, PREMIX_PRICE) + assert abs(res.cost_per_kg - (lines + 0.015 * PREMIX_PRICE)) < 1e-5 + + +def test_formulate_request_accepts_only_the_offered_premix_rates(): + """Rate is 1, 1.5 or 2% (default 2%); the premix price is required.""" + from pydantic import ValidationError + + from fasa_core.models import FormulateRequest + + assert FormulateRequest(**BASE).premix_rate == 0.02 + for rate in (0.01, 0.015, 0.02): + FormulateRequest(**BASE, premix_rate=rate) + for bad in (0.005, 0.0, 2): + with pytest.raises(ValidationError): + FormulateRequest(**BASE, premix_rate=bad) + with pytest.raises(ValidationError): + FormulateRequest(**{k: v for k, v in BASE.items() if k != "premix_price"}) + with pytest.raises(ValueError): + formulate(**BASE, premix_rate=0.005) # direct callers get the same rule + + def test_formulate_without_batch_size_omits_absolute_quantities(): """Default (no batch_size_kg) returns percent-only output.""" - res = formulate( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices=DEMO_PRICES, - ) + res = formulate(**BASE) if res.status != "optimal": pytest.skip(f"LP did not solve to optimal on this priced pool (status={res.status})") assert res.batch_size_kg is None @@ -201,12 +224,7 @@ def test_formulate_request_rejects_non_positive_batch_size(): for bad in (0, -1, -100.5): with pytest.raises(ValidationError): - FormulateRequest( - species="Nile Tilapia", - stage="< 5g (Starter)", - prices={"30355": 0.30}, - batch_size_kg=bad, - ) + FormulateRequest(**BASE, batch_size_kg=bad) def test_formulate_catfish_uses_carni_track(): @@ -236,10 +254,13 @@ def test_pool_carries_country_tags_and_every_code_resolves_in_ficd(): """The expanded pool tags ingredients by ISO-2 country, and every code it references must have FICD composition (codes without data are excluded).""" from fasa_core.ingredient_pool import attach_ficd_rows, load_pool + from scripts.build_ingredient_pool import EXCLUDED_PREFIXES pool = load_pool() - assert len(pool) >= 300 + assert len(pool) >= 290 attach_ficd_rows(pool) # raises if any pool code is missing from FICD + # the premix is a fixed slice of every feed, so no premix product can be priced in + assert not [r.code for r in pool if r.description.startswith(EXCLUDED_PREFIXES)] by_code = {r.code: r for r in pool} # locally available across all three tracked countries @@ -253,13 +274,7 @@ def test_formulate_country_flags_locally_available(): from fasa_core.ingredient_pool import load_pool by_code = {r.code: r for r in load_pool(only_codes=set(DEMO_PRICES.keys()))} - res = formulate( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices=DEMO_PRICES, - country="ZM", - ) + res = formulate(**BASE, country="ZM") if res.status != "optimal": pytest.skip(f"LP did not solve to optimal (status={res.status})") @@ -273,12 +288,7 @@ def test_formulate_country_flags_locally_available(): def test_formulate_without_country_leaves_locally_available_none(): """Omitting country yields null locally_available (no local context).""" - res = formulate( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices=DEMO_PRICES, - ) + res = formulate(**BASE) if res.status != "optimal": pytest.skip(f"LP did not solve to optimal (status={res.status})") assert res.country is None @@ -291,21 +301,11 @@ def test_formulate_request_normalizes_and_validates_country(): from fasa_core.models import FormulateRequest - req = FormulateRequest( - species="Nile Tilapia", - stage="< 5g (Starter)", - prices={"30355": 0.30}, - country="ke", - ) + req = FormulateRequest(**BASE, country="ke") assert req.country == "KE" with pytest.raises(ValidationError): - FormulateRequest( - species="Nile Tilapia", - stage="< 5g (Starter)", - prices={"30355": 0.30}, - country="US", - ) + FormulateRequest(**BASE, country="US") def test_parse_countries_normalizes_and_handles_blanks(): @@ -324,13 +324,7 @@ def test_parse_countries_normalizes_and_handles_blanks(): def test_formulate_core_normalizes_country_for_direct_callers(): """Core formulate() upper-cases country itself, so a lowercase direct call still flags.""" - res = formulate( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices=DEMO_PRICES, - country="zm", - ) + res = formulate(**BASE, country="zm") if res.status != "optimal": pytest.skip(f"LP did not solve to optimal (status={res.status})") assert res.country == "ZM" @@ -339,14 +333,8 @@ def test_formulate_core_normalizes_country_for_direct_callers(): def test_country_never_changes_the_lp(): """The country tag is advisory: identical prices must yield an identical recipe/cost.""" - common = dict( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices=DEMO_PRICES, - ) - base = formulate(**common) - tagged = formulate(**common, country="ZM") + base = formulate(**BASE) + tagged = formulate(**BASE, country="ZM") if base.status != "optimal": pytest.skip(f"LP did not solve to optimal (status={base.status})") assert base.cost_per_kg == tagged.cost_per_kg @@ -358,13 +346,8 @@ def test_country_never_changes_the_lp(): def test_formulate_error_path_echoes_country(): """Non-optimal responses still echo the requested country.""" - res = formulate( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices={"00000000": 1.0}, # no overlap with the pool -> error status - country="NG", - ) + # no overlap with the pool -> error status + res = formulate(**{**BASE, "prices": {"00000000": 1.0}}, country="NG") assert res.status == "error" assert res.country == "NG" @@ -402,14 +385,6 @@ def test_paff_reproduction(paff_label): # --------------------------------------------------------------------------- # -BASE = dict( - species="Nile Tilapia", - stage="< 5g (Starter)", - production_system="General-LowCost", - prices=DEMO_PRICES, -) - - def _spec(res, code): return next(line for line in res.composition if line.code == code) @@ -466,7 +441,7 @@ def test_nutrient_limit_on_a_ratio_spec_is_skipped_with_a_warning(): def test_nutrient_limit_is_not_masked_by_the_premix(): """Zinc is premix-masked, but an explicit operator bound must still bind.""" - res = formulate(**BASE, premix_enabled=True, nutrient_limits={"M12": {"max": 250.0}}) + res = formulate(**BASE, nutrient_limits={"M12": {"max": 250.0}}) zinc = _spec(res, "M12") assert (zinc.restriction_type, zinc.target, zinc.source) == ("Maximum", 250.0, "request") @@ -637,7 +612,7 @@ def test_validate_recipe_accepts_the_lp_solution_as_in_spec(): assert off_spec == [] assert res.in_limits is True assert res.nutrient_checks, "nutrient checks should run when the full scope is given" - assert 99.0 <= res.total_inclusion_percent <= 100.0 + assert 98.0 <= res.total_inclusion_percent <= 100.0 def test_validate_recipe_still_catches_a_real_nutrient_violation():