The Lie Every Solar Calculator Tells You
I was sizing a battery bank for an off-grid cabin build, and every calculator I found had the same silent bug: they treated Amp-hours as Amp-hours, full stop. Type in "100Ah" and it just multiplied by voltage and called it a day.
That's not how batteries work. Not even close.
A 100Ah LiFePO4 battery and a 100Ah lead-acid AGM battery are not interchangeable numbers — one gives you roughly 90% usable capacity, the other caps out around 50% before you start killing the plates. Feed both into a calculator that ignores chemistry and you'll walk away thinking you can run a fridge overnight on a battery that'll trip your inverter's low-voltage cutoff by 2 AM.
On top of that, most of these tools:
- Ignore inverter round-trip efficiency entirely (you lose 5–15% converting DC to AC — that's not optional, it's physics)
- Treat Depth of Discharge as a footnote instead of the thing that determines whether your battery lasts 300 cycles or 5,000
- Bury the actual result behind ad units, so you're doing life-safety-adjacent math next to an autoplay video for gutter guards So I built the Solar Battery Bank Sizing Calculator — a tool that takes chemistry, efficiency, and autonomy seriously, ships zero ads, and gets you a real Ah/kWh number you can actually build against.
This post is the engineering breakdown: the actual sizing math, the TypeScript core that implements it, and the architectural decisions that keep it instant.
The Engineering: Why "Just Multiply Ah by Volts" Is Wrong
Sizing a battery bank correctly means answering one real question: how much usable energy do I need to store to survive N days without sun? Everything below builds toward that number.
1. Daily AC Demand → DC Demand (Inverter Loss)
Your appliances draw AC power, but batteries store DC. Every watt has to pass through an inverter, and that conversion isn't free:
dcDemandWh = dailyAcLoadWh / inverterEfficiency // typically 0.85–0.95
Skip this step and you'll undersize by 5–15% before you've even touched the battery math.
2. Autonomy Scaling
"Days of autonomy" is how many cloudy days your bank needs to carry you through without any solar input at all:
autonomyDemandWh = dcDemandWh × daysOfAutonomy
3. Depth of Discharge (the number that actually separates chemistries)
You never draw a battery down to 0% — you draw it down to its safe DoD floor, because going past that either damages the cells (lead-acid) or trips the BMS (lithium):
DoD_LiFePO4 ≈ 0.85 // usable ~80-90%
DoD_AGM ≈ 0.50 // usable ~50% max, or the plates sulfate
grossEnergyWh = autonomyDemandWh / depthOfDischarge
This single divisor is why a lead-acid bank has to be built at roughly double the nominal capacity of a lithium bank to deliver the same usable energy. Most naive calculators skip this entirely and just report nameplate Ah.
4. Temperature Derating (optional but real)
Battery capacity drops in cold weather — lithium chemistry loses meaningful capacity below freezing. A temperature factor lets the tool stay honest in cold climates instead of quietly overpromising:
adjustedEnergyWh = grossEnergyWh / temperatureFactor // 1.0 in ideal conditions, lower in cold
5. Amp-Hour Conversion at Bank Voltage
Finally, convert the energy requirement into the number you actually shop for — total Amp-hours at your chosen bank voltage:
bankCapacityAh = adjustedEnergyWh / bankVoltage // 12V, 24V, or 48V
Worked Example (matches the calculator's own copy)
- 3,000W array × 4.5 peak sun hours = 13,500 Wh/day generated
- ÷ 0.90 inverter efficiency = 15,000 Wh gross DC demand
- ÷ 0.85 DoD (LiFePO4, 1 day autonomy) = 17,647 Wh (17.65 kWh)
- ÷ 48V bank voltage = 367.6 Ah at 48V That's the number that actually tells you how many batteries to buy — not a nameplate figure that quietly assumes 100% DoD and a lossless inverter.
The TypeScript Core: Chemistry as a First-Class Type
Same architectural rule as every calculator in this suite: the math has zero knowledge of React. It's a pure function, fully typed, chemistry-aware by construction rather than by a stray if statement buried in a component.
// lib/calculateSolarBatteryBank.ts
export type BatteryChemistry = "lifepo4" | "agm" | "flooded";
export interface SolarBatteryInput {
dailyAcLoadWh: number;
inverterEfficiency?: number; // default 0.90
daysOfAutonomy?: number; // default 1
chemistry: BatteryChemistry;
bankVoltage: 12 | 24 | 48;
temperatureFactor?: number; // default 1.0 (no derating)
}
export interface SolarBatteryResult {
dcDemandWh: number;
autonomyDemandWh: number;
grossEnergyWh: number;
adjustedEnergyWh: number;
bankCapacityAh: number;
bankCapacityKWh: number;
depthOfDischarge: number;
recommendedBatteryCount: (singleBatteryAh: number) => number;
}
const DOD_BY_CHEMISTRY: Record<BatteryChemistry, number> = {
lifepo4: 0.85,
agm: 0.5,
flooded: 0.5,
};
export function calculateSolarBatteryBank({
dailyAcLoadWh,
inverterEfficiency = 0.9,
daysOfAutonomy = 1,
chemistry,
bankVoltage,
temperatureFactor = 1.0,
}: SolarBatteryInput): SolarBatteryResult {
if (dailyAcLoadWh <= 0) {
throw new Error("Daily AC load must be a positive number.");
}
if (inverterEfficiency <= 0 || inverterEfficiency > 1) {
throw new Error("Inverter efficiency must be between 0 and 1.");
}
const depthOfDischarge = DOD_BY_CHEMISTRY[chemistry];
const dcDemandWh = dailyAcLoadWh / inverterEfficiency;
const autonomyDemandWh = dcDemandWh * daysOfAutonomy;
const grossEnergyWh = autonomyDemandWh / depthOfDischarge;
const adjustedEnergyWh = grossEnergyWh / temperatureFactor;
const bankCapacityAh = adjustedEnergyWh / bankVoltage;
const bankCapacityKWh = adjustedEnergyWh / 1000;
return {
dcDemandWh: Number(dcDemandWh.toFixed(1)),
autonomyDemandWh: Number(autonomyDemandWh.toFixed(1)),
grossEnergyWh: Number(grossEnergyWh.toFixed(1)),
adjustedEnergyWh: Number(adjustedEnergyWh.toFixed(1)),
bankCapacityAh: Number(bankCapacityAh.toFixed(1)),
bankCapacityKWh: Number(bankCapacityKWh.toFixed(2)),
depthOfDischarge,
recommendedBatteryCount: (singleBatteryAh: number) =>
Math.ceil(bankCapacityAh / singleBatteryAh),
};
}
Why recommendedBatteryCount is a closure instead of a fixed field: the result object shouldn't need to know a specific battery SKU's Ah rating to exist. Callers pass in whatever battery they're pricing out — "how many of this 100Ah unit do I need?" — without forcing a recompute of the whole chain. It's a cheap, honest way to keep the pure calculation decoupled from a UI dropdown of battery models.
The Client Component: Chemistry-Aware, Still Zero-Lag
// CalculatorForm.tsx
"use client";
import { useMemo, useState } from "react";
import {
calculateSolarBatteryBank,
type BatteryChemistry,
} from "@/lib/calculateSolarBatteryBank";
const BANK_VOLTAGES = [12, 24, 48] as const;
export default function CalculatorForm() {
const [dailyLoad, setDailyLoad] = useState(2000); // Wh/day
const [autonomy, setAutonomy] = useState(1);
const [chemistry, setChemistry] = useState<BatteryChemistry>("lifepo4");
const [voltage, setVoltage] = useState<(typeof BANK_VOLTAGES)[number]>(24);
const result = useMemo(() => {
if (dailyLoad <= 0) return null;
return calculateSolarBatteryBank({
dailyAcLoadWh: dailyLoad,
daysOfAutonomy: autonomy,
chemistry,
bankVoltage: voltage,
});
}, [dailyLoad, autonomy, chemistry, voltage]);
return (
<div className="space-y-4">
<label className="flex flex-col gap-1 text-sm text-zinc-600">
Daily AC Load (Wh)
<input
type="number"
value={dailyLoad}
onChange={(e) => setDailyLoad(Number(e.target.value))}
className="rounded-lg border border-zinc-300 px-3 py-2 outline-none focus:border-indigo-500"
/>
</label>
<label className="flex flex-col gap-1 text-sm text-zinc-600">
Days of Autonomy
<input
type="number"
min={1}
max={5}
value={autonomy}
onChange={(e) => setAutonomy(Number(e.target.value))}
className="rounded-lg border border-zinc-300 px-3 py-2 outline-none focus:border-indigo-500"
/>
</label>
<label className="flex flex-col gap-1 text-sm text-zinc-600">
Battery Chemistry
<select
value={chemistry}
onChange={(e) => setChemistry(e.target.value as BatteryChemistry)}
className="rounded-lg border border-zinc-300 px-3 py-2 outline-none focus:border-indigo-500"
>
<option value="lifepo4">LiFePO4 (Lithium)</option>
<option value="agm">AGM (Lead-Acid)</option>
<option value="flooded">Flooded Lead-Acid</option>
</select>
</label>
<label className="flex flex-col gap-1 text-sm text-zinc-600">
Bank Voltage
<select
value={voltage}
onChange={(e) =>
setVoltage(Number(e.target.value) as (typeof BANK_VOLTAGES)[number])
}
className="rounded-lg border border-zinc-300 px-3 py-2 outline-none focus:border-indigo-500"
>
{BANK_VOLTAGES.map((v) => (
<option key={v} value={v}>
{v}V
</option>
))}
</select>
</label>
{result && (
<div className="mt-4 space-y-2 rounded-xl border border-indigo-200 bg-indigo-50 p-4 text-sm">
<Row label="Required Capacity" value={`${result.bankCapacityAh} Ah`} />
<Row label="Energy Storage" value={`${result.bankCapacityKWh} kWh`} />
<Row
label="Usable DoD"
value={`${(result.depthOfDischarge * 100).toFixed(0)}%`}
/>
<Row
label="100Ah Batteries Needed"
value={result.recommendedBatteryCount(100)}
/>
</div>
)}
</div>
);
}
function Row({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex items-center justify-between">
<span className="text-zinc-500">{label}</span>
<span className="font-mono font-semibold text-zinc-900">{value}</span>
</div>
);
}
The select for chemistry is deliberately not a boolean toggle. It's tempting to ship "Lithium vs. Lead-Acid" as a switch, but flooded and AGM lead-acid have different maintenance and discharge realities worth keeping distinct in the type — BatteryChemistry already models that correctly, so the UI just has to not throw it away.
Performance: What "No Ad Bloat" Actually Buys You Here
The content column in the live page carries genuine FAQ and reference material — that's intentional, it's what people are actually searching for ("how many amp hours do I need to power a refrigerator overnight"). The difference from a legacy site isn't less content, it's what's sitting next to that content:
| Typical Solar Calculator Sites | HypeCalc | |
|---|---|---|
| Ad slots injected into layout | 8–15 | 0 |
| Sticky calculator sidebar | Fights with sticky ad units | Sticky, uncontested |
| Recalculation on input change | Often a full page reload or server round-trip | Sub-millisecond, useMemo-driven |
| JSON-LD / SEO schema | Present, but loaded behind ad-tech JS | Inlined server-side, zero client cost |
| Time to first meaningful result | Several seconds, ad-dependent | Instant on interaction |
The StickyWrapper around the calculator card isn't fighting a sticky sidebar ad for scroll real estate, because there isn't one. That's the whole trick — a sticky, always-visible calculator is a much better UX than a modal or footer form, but it only works if you're not also trying to stick an ad next to it.
Try It on Real Numbers
The full sizing flow — chemistry-aware DoD, inverter losses, autonomy scaling, live Ah/kWh output — is running in production right now:
HypeCalc Solar Battery Bank Calculator →
Plug in your own daily load and watch the LiFePO4 vs. AGM numbers diverge in real time — it's a genuinely useful way to see why chemistry choice isn't cosmetic.
Discussion
Three things I want this community's take on:
-
Where do you draw the line on "pure calculation function"? I kept
recommendedBatteryCountas a closure on the result rather than a separate exported function — was that the right call, or am I overcomplicating a tiny utility? - Domain-specific calculators live or die on getting the underlying physics right, not just the UI. How much domain research do you do before writing the first line of a calculator/tool like this — and has a subtle domain mistake ever slipped past your tests?
- Sticky sidebars vs. inline results — for a content-heavy SEO page like this one, would you keep the calculator sticky in the viewport, or is that pattern getting too common to stand out anymore? Curious where people land — especially if you've built anything in the solar/energy space and hit modeling edge cases I didn't cover here (partial shading, charge controller losses, etc.).
Top comments (0)