@vetwo/nutrition-units
v0.0.1
Published
Nutrition/feed-formulation calculation rules built on top of @vetwo/units. Contains ALL domain knowledge (nutrient reporting units, basis conversion, coefficient resolution for the LP solver). No generic unit-conversion logic lives here.
Maintainers
Readme
@vetwo/nutrition-units
Nutrition & feed-formulation calculation rules, built on top of
@vetwo/units.
This package contains all the domain knowledge for animal-nutrition
calculations: which unit each nutrient is reported in, how to convert
between as-fed and dry-matter basis, how to read a feed/animal JSON schema
into safe Quantity values, and how to turn that unit-safe math into plain
numbers your LP solver (HiGHS or any other) can consume directly.
It contains zero generic unit-conversion logic — that all lives in
@vetwo/units. This package only adds nutrition vocabulary on top, so it
stays small, testable, and easy to extend as new species/nutrients are
added.
import { Quantity } from "@vetwo/units";
import { nutritionMath } from "@vetwo/nutrition-units";
const cp = Quantity.of(12, "%"); // crude protein, as-fed
const intake = Quantity.of(10, "kg/day"); // feed intake
nutritionMath.calculate(intake, cp, "cp"); // Quantity(1200, "g/day")No /100, no *1000, no manual unit math anywhere in your application code.
Table of contents
- Installation
- Why a separate package
- Quick start
- API reference (every exported function/class)
- Default reporting units
- Integration patterns
- Design principles
Installation
npm install @vetwo/units @vetwo/nutrition-units
# or
bun add @vetwo/units @vetwo/nutrition-units@vetwo/units is a required peer dependency — both packages must be
installed together.
Why a separate package
@vetwo/units knows how to convert and combine units — it has no idea
that "cp" means crude protein, or that vitamins are reported in IU/day.
That knowledge (reporting-unit conventions, as-fed/dry-matter conversion,
solver-coefficient extraction) is domain-specific and lives here, cleanly
separated so the core engine stays reusable for other fields (chemistry,
finance, engineering, veterinary pharmacology...).
Quick start
import { Quantity } from "@vetwo/units";
import { nutritionMath, coefficientResolver, BasisConverter } from "@vetwo/nutrition-units";
const intake = Quantity.of(10, "kg/day");
// Nutrient contributions — reporting unit chosen automatically per nutrient
nutritionMath.calculate(intake, Quantity.of(12, "%"), "cp"); // 1200 g/day
nutritionMath.calculate(intake, Quantity.of(0.9, "%"), "ca"); // 90 g/day
nutritionMath.calculate(intake, Quantity.of(80, "mg/kg"), "fe"); // 800 mg/day
nutritionMath.calculate(intake, Quantity.of(3.2, "Mcal/kg"), "de"); // 32 Mcal/day
nutritionMath.calculate(intake, Quantity.of(5000, "IU/kg"), "vitA"); // 50000 IU/day
// Diet cost
coefficientResolver.getCostCoefficient(intake, Quantity.of(0.35, "cur/kg")); // 3.5 cur/day
// As-fed <-> dry-matter conversion
const dm = Quantity.of(88, "%");
const cpDmBasis = Quantity.of(15, "% DM");
BasisConverter.toAsFed(cpDmBasis, dm); // 13.2 % (as-fed)
// Plain numeric coefficient for an LP solver — units never reach the solver
coefficientResolver.getCoefficient(Quantity.of(1, "kg/day"), Quantity.of(12, "%"), "cp"); // 120API reference
NutritionMath
The main facade — the only entry point application code should call
for nutrient math. Wraps the generic nutrition.nutrientContribution and
nutrition.dietCost rules and normalizes results to each nutrient's
canonical reporting unit.
class NutritionMath {
constructor(rules?: CalculationRuleRegistry, targetUnits?: TargetUnitRegistry);
calculate(feedIntake: Quantity, nutrientConcentration: Quantity, nutrientKey: NutrientKey): Quantity;
calculateCost(feedIntake: Quantity, pricePerKg: Quantity): Quantity;
sum(contributions: Quantity[]): Quantity;
}
export const nutritionMath: NutritionMath; // ready-to-use default instance.calculate(feedIntake, nutrientConcentration, nutrientKey)
Computes a nutrient's contribution from a feed's inclusion rate and its nutrient concentration, automatically converted to that nutrient's registered reporting unit (see Default reporting units).
nutritionMath.calculate(
Quantity.of(10, "kg/day"), // how much of this feed the animal eats
Quantity.of(12, "%"), // the feed's CP concentration
"cp" // which nutrient — determines the output unit
); // Quantity(1200, "g/day")Works identically for any nutrient/unit combination — percent, ppm, mg/kg, Mcal/kg, IU/kg — because the underlying math is generic dimensional analysis, not per-nutrient formulas.
.calculateCost(feedIntake, pricePerKg)
Computes the diet-cost contribution of one feed.
nutritionMath.calculateCost(
Quantity.of(10, "kg/day"),
Quantity.of(0.35, "cur/kg")
); // Quantity(3.5, "cur/day").sum(contributions)
Sums multiple Quantity contributions of the same nutrient across
several feeds in a diet (e.g. total CP supplied by corn + soybean meal +
premix). Throws if the array is empty; throws UnitMismatchError if the
quantities aren't dimensionally compatible.
const totalCp = nutritionMath.sum([
nutritionMath.calculate(cornIntake, cornCp, "cp"),
nutritionMath.calculate(soyIntake, soyCp, "cp"),
]);CoefficientResolver
Strips units away entirely — the hard boundary your LP solver should sit
behind. Nothing downstream of this class should import @vetwo/units or
@vetwo/nutrition-units at all; it should only ever see number.
class CoefficientResolver {
constructor(math?: NutritionMath);
getCoefficient(feedUnitIntake: Quantity, nutrientConcentration: Quantity, nutrientKey: NutrientKey): number;
getCostCoefficient(feedUnitIntake: Quantity, pricePerKg: Quantity): number;
getBound(quantity: Quantity, targetUnit: string): number;
}
export const coefficientResolver: CoefficientResolver;.getCoefficient(feedUnitIntake, nutrientConcentration, nutrientKey)
Returns a plain number — the per-unit-of-decision-variable coefficient
for an LP constraint row (e.g. "how many grams of CP does 1 kg/day of this
feed contribute").
coefficientResolver.getCoefficient(
Quantity.of(1, "kg/day"),
Quantity.of(12, "%"),
"cp"
); // 120.getCostCoefficient(feedUnitIntake, pricePerKg)
Returns a plain number for the objective-function coefficient (cost per
unit of decision variable).
coefficientResolver.getCostCoefficient(Quantity.of(1, "kg/day"), Quantity.of(0.35, "cur/kg")); // 0.35.getBound(quantity, targetUnit)
Converts any bound (min/max inclusion, min/max nutrient requirement) to a plain number in a chosen target unit, for use as a constraint bound.
coefficientResolver.getBound(Quantity.of(2, "kg/day"), "kg/day"); // 2
coefficientResolver.getBound(Quantity.of(500, "g/day"), "kg/day"); // 0.5BasisConverter
Converts a nutrient value between as-fed and dry-matter reporting
basis. This requires the feed's dry-matter percentage as an extra input —
it is domain logic, not a pure unit conversion (a plain Quantity.to()
call deliberately refuses to bridge basis tags).
class BasisConverter {
static toAsFed(dmQuantity: Quantity, dryMatterPercent: Quantity): Quantity;
static toDryMatterBasis(asFedQuantity: Quantity, dryMatterPercent: Quantity): Quantity;
}BasisConverter.toAsFed(dmQuantity, dryMatterPercent)
dmQuantity must carry a "DM" basis tag (e.g. parsed from "15 % DM");
dryMatterPercent must be a plain "%" quantity (the feed's dry-matter
content). Returns the equivalent as-fed value (basis tag stripped).
const cpDmBasis = Quantity.of(15, "% DM");
const dm = Quantity.of(88, "%");
BasisConverter.toAsFed(cpDmBasis, dm); // Quantity(13.2, "%")BasisConverter.toDryMatterBasis(asFedQuantity, dryMatterPercent)
The inverse operation — converts an as-fed value into its dry-matter-basis
equivalent (adds the "DM" basis tag). Throws ConversionError if
dryMatterPercent is zero.
BasisConverter.toDryMatterBasis(Quantity.of(13.2, "%"), Quantity.of(88, "%")); // Quantity(15, "% DM")TargetUnitRegistry
Pure configuration — maps each nutrient key to its canonical reporting unit. Contains no calculation logic, so reporting conventions can change without touching any math.
type NutrientKey =
| "cp" | "lys" | "methionine" | "metCys" | "ee" | "cf" | "ndf" | "adf" | "ash"
| "starch" | "sugar" | "tdn"
| "ca" | "p" | "availableP" | "mg" | "k" | "na" | "cl" | "s"
| "fe" | "mn" | "cu" | "zn" | "co" | "i" | "se"
| "vitA" | "vitD" | "vitE"
| "de" | "me" | "nel"
| "cost";
class TargetUnitRegistry {
getTargetUnit(nutrientKey: NutrientKey): string;
override(nutrientKey: NutrientKey, unitSymbol: string): void;
}
export const defaultTargetUnitRegistry: TargetUnitRegistry;.getTargetUnit(nutrientKey)
Returns the unit string a nutrient should be reported in.
defaultTargetUnitRegistry.getTargetUnit("vitA"); // "IU/day"
defaultTargetUnitRegistry.getTargetUnit("fe"); // "mg/day".override(nutrientKey, unitSymbol)
Changes the reporting unit for one nutrient, without forking the package — useful if your product wants ME reported in Mcal instead of Kcal, for example.
defaultTargetUnitRegistry.override("me", "Mcal/day");
nutritionMath.calculate(intake, Quantity.of(3000, "Kcal/kg"), "me"); // now returns Mcal/dayFeedSchemaLoader
Reads a feed/animal/requirements JSON unit-map (as-fed and dry-matter
basis, animal data, requirements, solver variables, feed constraints,
minerals limits, economics) and returns Quantity factories per field —
the single place in your codebase that needs to know your schema's shape.
interface UnitSchema {
feed: { asFed: Record<string, string>; dryMatterBasis: Record<string, string> };
animal: Record<string, string>;
requirements: Record<string, string>;
solver: Record<string, string>;
feedConstraints: Record<string, string>;
mineralsLimits: Record<string, string>;
economics: Record<string, string>;
}
class FeedSchemaLoader {
constructor(schema: UnitSchema);
asFed(field: string, value: number): Quantity;
dryMatterBasis(field: string, value: number): Quantity;
requirement(field: string, value: number): Quantity;
animal(field: string, value: number): Quantity;
economics(field: string, value: number): Quantity;
feedConstraint(field: string, value: number): Quantity;
}import { FeedSchemaLoader } from "@vetwo/nutrition-units";
import schema from "./units-schema.json";
const loader = new FeedSchemaLoader(schema);
const cp = loader.asFed("cp", 12); // Quantity(12, "%")
const cpDm = loader.dryMatterBasis("cp", 13.6); // Quantity(13.6, "% DM")
const cpRequirement = loader.requirement("cp", 480); // Quantity(480, "g/day")
const bodyWeight = loader.animal("bodyWeight", 450); // Quantity(450, "kg")
const feedPrice = loader.economics("feedPrice", 0.35); // Quantity(0.35, "cur/kg")
const maxInclusion = loader.feedConstraint("maxKg", 3); // Quantity(3, "kg/day")Registered calculation rules
On import, this package registers two rules into @vetwo/units's shared
CalculationRuleRegistry. NutritionMath is the recommended way to call
them, but you can invoke them directly if you need to:
import { NUTRIENT_CONTRIBUTION_RULE, DIET_COST_RULE } from "@vetwo/nutrition-units";
import { defaultCalculationRuleRegistry } from "@vetwo/units";
defaultCalculationRuleRegistry.run(NUTRIENT_CONTRIBUTION_RULE, intake, concentration);
defaultCalculationRuleRegistry.run(DIET_COST_RULE, intake, price);| Constant | Registry key | Inputs | Behavior |
|---|---|---|---|
| NUTRIENT_CONTRIBUTION_RULE | "nutrition.nutrientContribution" | (feedIntake, nutrientConcentration) | feedIntake.multiply(nutrientConcentration) |
| DIET_COST_RULE | "nutrition.dietCost" | (feedIntake, pricePerKg) | feedIntake.multiply(pricePerKg) |
Default reporting units
| Category | Nutrients | Unit |
|---|---|---|
| Macro nutrients | cp, lys, methionine, metCys, ee, cf, ndf, adf, ash, starch, sugar, tdn | g/day |
| Macro minerals | ca, p, availableP, mg, k, na, cl, s | g/day |
| Trace minerals | fe, mn, cu, zn, co, i, se | mg/day |
| Vitamins | vitA, vitD, vitE | IU/day |
| Energy | de, nel | Mcal/day |
| Energy | me | Kcal/day |
| Economics | cost | cur/day |
Change any of these via TargetUnitRegistry.override() without forking the
package.
Integration patterns
LP solver integration
import { Quantity } from "@vetwo/units";
import { coefficientResolver } from "@vetwo/nutrition-units";
function buildConstraintRow(feed: { name: string; cp: number; ca: number; price: number }) {
const oneUnitIntake = Quantity.of(1, "kg/day"); // matches the solver's decision-variable unit
return {
feedName: feed.name,
cpCoefficient: coefficientResolver.getCoefficient(oneUnitIntake, Quantity.of(feed.cp, "%"), "cp"),
caCoefficient: coefficientResolver.getCoefficient(oneUnitIntake, Quantity.of(feed.ca, "%"), "ca"),
costCoefficient: coefficientResolver.getCostCoefficient(oneUnitIntake, Quantity.of(feed.price, "cur/kg")),
};
}The returned object contains only numbers — pass it straight into your
HiGHS (or any other LP) constraint matrix. The solver module itself never
needs to import this package or @vetwo/units.
Validation engine integration
import { Quantity, DimensionError } from "@vetwo/units";
function validateRange(value: Quantity, min: Quantity, max: Quantity) {
if (!value.hasSameDimension(min) || !value.hasSameDimension(max)) {
throw new DimensionError(min.unit.symbol, value.unit.symbol);
}
const v = value.toBase().value;
return v >= min.toBase().value && v <= max.toBase().value;
}A mismatched comparison (e.g. a Mcal/day requirement checked against a
g/day value) throws instead of silently producing a wrong diet.
Report generator integration
import { formatQuantity } from "@vetwo/units";
import { nutritionMath } from "@vetwo/nutrition-units";
const supplied = nutritionMath.calculate(intake, cp, "cp");
const required = Quantity.of(480, "g/day");
const pct = (supplied.toBase().value / required.toBase().value) * 100;
console.log(`CP: ${formatQuantity(supplied, { decimals: 1 })} / ${formatQuantity(required, { decimals: 1 })} (${pct.toFixed(0)}%)`);
// "CP: 1200.0 g/day / 480.0 g/day (250%)"Design principles
- All domain knowledge, zero generic unit logic. If a change is about
how units convert, it belongs in
@vetwo/units, not here. - Configuration over code. Reporting units are data
(
TargetUnitRegistry), neverif/elsebranches. - Hard boundary at the solver.
CoefficientResolveris the only place aQuantitybecomes anumber; nothing past that point should import either package. - One public facade. Application code should call
NutritionMathandCoefficientResolver; it should not reach intonutrition-rules.tsor the sharedCalculationRuleRegistrydirectly except in advanced cases.
Requirements
@vetwo/units(peer dependency)- TypeScript ^5.x
License
MIT
