DEV Community

Cover image for I Kept Getting Lied to by Solar Battery Calculators, So I Built One That Actually Respects Battery Chemistry
G S
G S

Posted on

I Kept Getting Lied to by Solar Battery Calculators, So I Built One That Actually Respects Battery Chemistry

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Where do you draw the line on "pure calculation function"? I kept recommendedBatteryCount as a closure on the result rather than a separate exported function — was that the right call, or am I overcomplicating a tiny utility?
  2. 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?
  3. 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)