Skip to content

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

@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

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"); // 120

API 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.5

BasisConverter

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/day

FeedSchemaLoader

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), never if/else branches.
  • Hard boundary at the solver. CoefficientResolver is the only place a Quantity becomes a number; nothing past that point should import either package.
  • One public facade. Application code should call NutritionMath and CoefficientResolver; it should not reach into nutrition-rules.ts or the shared CalculationRuleRegistry directly except in advanced cases.

Requirements

  • @vetwo/units (peer dependency)
  • TypeScript ^5.x

License

MIT

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages