Every formula the calculator runs is below. The constants are sourced from peer-reviewed sports-physiology literature where possible and from product labels where it has to be. Nothing is rounded, nothing is hidden.
If you spot an error, please email me — I'll fix it and credit you.
Seven things you tell the calculator. Everything downstream is derived from these.
| Symbol | Variable | Units | Example |
|---|---|---|---|
| BW | Body weight | kg | 75 |
| SR | Sweat rate | L/hr | 1.3 |
| T | Session duration | hr | 5 |
| [Na]sw | Sweat sodium profile | mg/L | 1500 |
| H | Heat multiplier | × | 1.15 |
| CHOhr | Carb target | g/hr | 60 |
| Vb | Bottle volume | ml | 600 |
Body weight is collected but doesn't feed the formulas directly — it's there so a future version of the calculator can scale carb targets to g/kg/hr. The other six drive everything below.
For each hour of activity, how much fluid and electrolyte you're shedding into the air and your shirt.
You don't want to replace 100% of what you lose. Partial replacement is safer (less GI distress, no hyponatremia risk) and matches how the body actually wants to work — it can pull from its own reserves between bottles. Each loss is multiplied by a fraction:
Fluid at 75% — the upper bound where most athletes can drink without GI revolt. Drinking back 100% of sweat losses is for ultras with very low intensity and very long timelines; at race pace you'll spend more energy peeing than running.
Sodium at 65% — Baker (2017) and the GSSI Sports Science Exchange #153 both land here for endurance work over 2 hours. Higher than this and you risk salty, off-putting drinks that you stop drinking; lower and cramping risk climbs.
Potassium at 100% — K losses are small enough that full replacement is cheap and well-tolerated.
Magnesium at 50% — Mg has a long body half-life and large stores in bone. Acute partial replacement is fine.
Now we need to know how many bottles you have to drink per hour to hit the fluid target. That's how we'll split the per-hour electrolyte targets across bottles.
Spread each hourly target evenly across the bottles you'll drink that hour.
You don't pour sodium directly — you pour sodium chloride. So every per-bottle mineral target gets divided by the mineral's mass fraction in its source compound.
Volume hints in the calculator (≈ pinch / ≈ 1/4 tsp / etc.) use these grams-per-teaspoon approximations for level measurements: salt 5.7 g/tsp · KCl 4.5 g/tsp · Calm 2 g/tsp · dextrose 12 g/tbsp . They're for sanity-checking the scale, not for replacing it.
Two concentration checks the calculator runs against the bottle you're about to mix:
A 50K trail race for someone roughly like me: 75 kg, sweat rate 1.3 L/hr, salty sweater (1500 mg/L Na profile), hot day (heat factor 1.15), 5 hours of running, 60 g/hr of carbs, 600 ml bottles.
Both checks pass: sodium at 1,495 mg/L is in the 500–1500 mg/L band, carbs at 6.2% are in the 4–8% SGLT1 zone. 9 bottles to prep, weigh, shake, and pack. Race day, you grab them out of the cooler.
Every constant lives in one file. Change one number there and the calculator follows everywhere — this page, the home calculator, the worked example above. No black box.
src/lib/electrolytes.ts
// Mass fractions of active mineral per gram of compound
export const NA_PER_G_NACL = 393; // mg Na per g NaCl (22.99/58.44 × 1000)
export const K_PER_G_KCL = 524; // mg K per g KCl (39.10/74.55 × 1000)
export const MG_PER_G_CALM = 80; // mg Mg per g Natural Vitality Calm
// Typical sweat composition
export const K_SWEAT_MGPL = 200;
export const MG_SWEAT_MGPL = 30;
// Replacement ratios (conservative — avoid GI distress / hyponatremia)
export const REPLACE_FLUID = 0.75;
export const REPLACE_NA = 0.65;
export const REPLACE_K = 1.0;
export const REPLACE_MG = 0.5;
// Volume-to-mass approximations (fine-grain powders, level measure)
export const G_PER_TSP_SALT = 5.7;
export const G_PER_TSP_KCL = 4.5;
export const G_PER_TSP_CALM = 2.0;
export const G_PER_TBSP_DEX = 12; The table below is the human-readable view. If you want to fork the calculator and tune it for your own physiology, this is the file you change.
| Constant | Value | Source |
|---|---|---|
| NA_PER_G_NACL | 393 mg/g | 22.99 / 58.44 (atomic mass) |
| K_PER_G_KCL | 524 mg/g | 39.10 / 74.55 (atomic mass) |
| MG_PER_G_CALM | 80 mg/g | Natural Vitality Calm label |
| K_SWEAT_MGPL | 200 mg/L | Baker 2017 population mean |
| MG_SWEAT_MGPL | 30 mg/L | Maughan & Shirreffs sweat panel |
| REPLACE_FLUID | 0.75 | ACSM upper bound for endurance |
| REPLACE_NA | 0.65 | GSSI SSE #153 |
| REPLACE_K | 1.00 | Full replacement; tolerated well |
| REPLACE_MG | 0.5 | Acute partial; bone reserves |
Every step on this page, top to bottom, compiled into the function the calculator actually runs. The input interface lists exactly what you tell it; the body is the same sequence of formulas you just read in section 01 through 06.
src/lib/electrolytes.ts
export interface CalcInput {
bw: number; // kg
sr: number; // L/hr (= kg/hr)
dur: number; // hours
naProfile: number; // mg/L sweat Na
heatMult: number; // multiplier on Na conc
carbsPerHr: number; // g/hr
bottleMl: number;
}
export interface CalcResult {
bottlesPerHr: number;
totalBottles: number;
naPerBottle: number;
kPerBottle: number;
mgPerBottle: number;
choPerBottle: number;
gSalt: number;
gKcl: number;
gMg: number;
gDex: number;
fluidPerHrMl: number;
naPerHrMg: number;
choPerHrG: number;
totalFluidL: number;
totalNaMg: number;
totalChoG: number;
naConcMgL: number;
choConcPct: number;
}
export function calculate(s: CalcInput): CalcResult {
const fluidLossMl = s.sr * 1000;
const naLossMg = s.sr * s.naProfile * s.heatMult;
const kLossMg = s.sr * K_SWEAT_MGPL;
const mgLossMg = s.sr * MG_SWEAT_MGPL;
const fluidTargetMl = fluidLossMl * REPLACE_FLUID;
const naTargetMg = naLossMg * REPLACE_NA;
const kTargetMg = kLossMg * REPLACE_K;
const mgTargetMg = mgLossMg * REPLACE_MG;
const choTargetG = s.carbsPerHr;
const bottlesPerHr = fluidTargetMl / s.bottleMl;
const totalBottles = Math.max(1, Math.ceil(bottlesPerHr * s.dur));
const naPerBottle = naTargetMg / bottlesPerHr;
const kPerBottle = kTargetMg / bottlesPerHr;
const mgPerBottle = mgTargetMg / bottlesPerHr;
const choPerBottle = choTargetG / bottlesPerHr;
const gSalt = naPerBottle / NA_PER_G_NACL;
const gKcl = kPerBottle / K_PER_G_KCL;
const gMg = mgPerBottle / MG_PER_G_CALM;
const gDex = choPerBottle;
const naConcMgL = (naPerBottle / s.bottleMl) * 1000;
const choConcPct = (choPerBottle / s.bottleMl) * 100;
return {
bottlesPerHr,
totalBottles,
naPerBottle,
kPerBottle,
mgPerBottle,
choPerBottle,
gSalt,
gKcl,
gMg,
gDex,
fluidPerHrMl: fluidTargetMl,
naPerHrMg: naTargetMg,
choPerHrG: choTargetG,
totalFluidL: (fluidTargetMl * s.dur) / 1000,
totalNaMg: naTargetMg * s.dur,
totalChoG: choTargetG * s.dur,
naConcMgL,
choConcPct,
};
} That's the whole thing. No remote API call, no hidden multipliers, no second pass — what you see here is what runs in your browser every time you nudge a slider.