DEV Community

Cover image for Building AhCalc: A Solar and Battery Sizing Calculator That Works
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Building AhCalc: A Solar and Battery Sizing Calculator That Works

Most solar sizing conversations start the same way: “I have a 12V battery, a 500W inverter, and a few appliances… how long will it run?” Or “How many panels do I need for a 1kW load?” Or the classic: “How many Ah battery for my home backup?”

After answering those questions for friends, followers, and a few clients for the hundredth time, I realized the real problem wasn’t the math. It was the friction. People were being forced into spreadsheets, gated calculators, WhatsApp-forwarded charts, or tools that felt like they were designed to capture leads rather than give answers.

So I built AhCalc: a small, fast, public calculator that helps people size batteries (Ah/Wh), inverters, and solar panels for off-grid and backup systems—without logins, without a backend, and without the “fill this form and we’ll email you the report” dance.

This isn’t a “how to build a React app” tutorial. It’s a founder-builder story about taking repeated real-world questions and turning them into a tool people actually use—by making a few opinionated product and engineering decisions.

The failure mode I kept seeing: calculators that “work” but don’t help

Most sizing tools technically compute something. The reason they still fail in practice is predictable:

  • They’re slow (heavy pages, multiple steps, too many fields).
  • They’re gated (phone number/email required to see results).
  • They’re opaque (no clear assumptions, no explanation of what changed the result).
  • They’re non-shareable (you can’t send a link that preserves inputs).
  • They’re overconfident (they spit out a single “recommended” product like it’s universally correct).

And a subtle one: many tools treat solar sizing as a generic global problem. In reality, the assumptions that matter—sun hours, common system voltages, typical appliance mixes—are regional. I’m in India, and I kept seeing advice that was technically plausible but practically mismatched to how people actually buy and wire systems here.

The bar I set for AhCalc was simple:

  1. Instant answers (no multi-step form flow).
  2. Shareable state (send a link, get the same result).
  3. No backend (keep it cheap, reliable, and private by default).
  4. Math that respects reality (efficiency losses, surge, DoD, battery type).
  5. Recommendations without pretending certainty (show ranges and constraints, not “buy this exact thing”).

That set the tone for every technical decision.

The product decision that mattered most: “pure math first, UI second”

Sizing logic turns into a mess when it’s intertwined with UI state. If you’ve ever debugged “why did the battery Ah change when I toggled this checkbox?”, you know what I mean.

I forced myself into a constraint: the core logic must be pure calculation functions.

  • No reading from global state.
  • No hidden defaults inside random components.
  • Every function takes explicit inputs and returns explicit outputs.
  • The UI is just a thin layer that gathers inputs and renders outputs.

This does two things:

  1. It makes the calculator trustworthy because the assumptions are centralized.
  2. It makes it easier to add features without breaking existing results.

Here’s a simplified version of the battery sizing logic (TypeScript). The goal isn’t to model every chemistry edge case—it’s to be honest about the assumptions that actually change outcomes.

// calc/battery.ts
export type BatteryInputs = {
  loadWatts: number;          // average running load
  backupHours: number;        // required runtime
  systemVoltage: 12 | 24 | 48;
  inverterEfficiency: number; // e.g. 0.85 - 0.93
  batteryEfficiency: number;  // e.g. 0.9 for lead-acid, 0.95+ for LiFePO4
  depthOfDischarge: number;   // usable fraction: 0.5 lead-acid, 0.8 LiFePO4
  safetyMargin: number;       // 1.1 - 1.3
};

export type BatteryResult = {
  energyWhRequired: number;
  batteryWhNominal: number;
  batteryAhNominal: number;
};

export function sizeBattery(inputs: BatteryInputs): BatteryResult {
  const {
    loadWatts,
    backupHours,
    systemVoltage,
    inverterEfficiency,
    batteryEfficiency,
    depthOfDischarge,
    safetyMargin,
  } = inputs;

  if (loadWatts <= 0 || backupHours <= 0) {
    return { energyWhRequired: 0, batteryWhNominal: 0, batteryAhNominal: 0 };
  }

  // Energy that must be delivered to the AC load
  const energyWhRequired = loadWatts * backupHours;

  // Account for inverter + battery losses and DoD limits
  const usableFraction = inverterEfficiency * batteryEfficiency * depthOfDischarge;
  const batteryWhNominal = (energyWhRequired / usableFraction) * safetyMargin;

  const batteryAhNominal = batteryWhNominal / systemVoltage;

  return {
    energyWhRequired,
    batteryWhNominal,
    batteryAhNominal,
  };
}
Enter fullscreen mode Exit fullscreen mode

A few opinionated choices are embedded here:

  • Depth of discharge (DoD) is non-negotiable. Lead-acid users routinely kill batteries by “using the full capacity.” A tool that doesn’t model DoD is worse than no tool.
  • Efficiency is multiplicative, not additive. Small mistakes here compound.
  • A safety margin is explicit. People can argue the value, but they can’t pretend it doesn’t exist.

This “pure math first” approach made everything else easier: testing, URL state, UI transitions, and even copywriting.

The engineering choice that made it shareable: URL-encoded state (no backend)

The moment a calculator is useful, people want to share it:

  • “Check this setup.”
  • “Is this enough for my fridge + fans?”
  • “What if I switch to 24V?”

If your tool can’t preserve inputs in a link, the sharing happens via screenshots—and screenshots kill iteration.

AhCalc keeps state in the URL. Not in a database, not in localStorage (though that can be a nice add-on), and not in a server session.

Why?

  • No backend: less maintenance, fewer failure points.
  • Privacy by default: inputs don’t leave the browser.
  • Instant share: copy/paste the URL and you’re done.

The main tradeoff is obvious: URLs can get long. The fix is to encode only what matters, keep keys short, and compress when needed.

A pragmatic pattern is:

  • Maintain a typed AppState.
  • Serialize to a compact object with short keys.
  • Encode as query params.
  • Debounce updates so the URL doesn’t thrash while typing.

Example pattern (simplified):

// state/urlState.ts
import { z } from "zod";

export type AppState = {
  w: number;          // load watts
  h: number;          // backup hours
  v: 12 | 24 | 48;    // system voltage
  dod: number;        // depth of discharge
};

const StateSchema = z.object({
  w: z.number().min(0).max(20000),
  h: z.number().min(0).max(72),
  v: z.union([z.literal(12), z.literal(24), z.literal(48)]),
  dod: z.number().min(0.1).max(0.95),
});

export function encodeState(state: AppState): string {
  const params = new URLSearchParams();
  params.set("w", String(state.w));
  params.set("h", String(state.h));
  params.set("v", String(state.v));
  params.set("dod", String(state.dod));
  return params.toString();
}

export function decodeState(search: string, fallback: AppState): AppState {
  const params = new URLSearchParams(search);
  const candidate = {
    w: Number(params.get("w") ?? fallback.w),
    h: Number(params.get("h") ?? fallback.h),
    v: Number(params.get("v") ?? fallback.v),
    dod: Number(params.get("dod") ?? fallback.dod),
  };

  const parsed = StateSchema.safeParse(candidate);
  return parsed.success ? parsed.data : fallback;
}
Enter fullscreen mode Exit fullscreen mode

Two details matter more than they seem:

  1. Validation on decode. People will paste weird URLs, bots will crawl, and you’ll ship new versions. If you don’t validate, you’ll ship runtime errors.
  2. Stable keys. Once people share URLs, those links become quasi-API contracts. Breaking them is breaking trust.

If you want to go further, you can compress the state into a single s= parameter using something like lz-string. I avoided that early because debugging becomes harder and the “contract” becomes opaque. For AhCalc, short keys + a small state object was enough.

Region defaults and “honest fallbacks”: building for India without locking out everyone else

A calculator is only as good as its defaults. Defaults are product decisions disguised as engineering.

For India-focused usage, a few defaults are simply more likely:

  • Common inverter/battery systems at 12V/24V for small setups.
  • Typical residential backup expectations (fans, lights, router, TV, fridge).
  • Solar production assumptions based on peak sun hours that match real planning ranges.

But hardcoding India-only assumptions would be a mistake. People travel, people build in different regions, and the web is global.

The pattern I used is what I’d call region-first defaults with honest fallbacks:

  • Prefer a region default when the user hasn’t expressed a preference.
  • Make the assumption visible and editable.
  • If the region cannot be determined reliably, fall back to a conservative global default.

I deliberately didn’t add geolocation prompts. Asking for location permission to calculate battery Ah is the kind of “growth” move that quietly ruins trust.

A practical approach is:

  • Use a lightweight heuristic (locale/timezone) to pick a default.
  • Never block usage.
  • Never hide the chosen default.

Example heuristic:

// region/defaults.ts
export type Region = "IN" | "GLOBAL";

export function detectRegion(): Region {
  // Avoid permission prompts; keep it predictable.
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
  if (tz.startsWith("Asia/Kolkata")) return "IN";

  const lang = navigator.language || "";
  if (lang.toLowerCase().startsWith("en-in")) return "IN";

  return "GLOBAL";
}

export function defaultPeakSunHours(region: Region): number {
  // Conservative planning defaults.
  return region === "IN" ? 4.5 : 4.0;
}
Enter fullscreen mode Exit fullscreen mode

Tradeoff: timezone/locale is imperfect. That’s fine. Defaults are not destiny; they’re a starting point.

The real win is that users get a result immediately that feels relevant, and advanced users can change assumptions without fighting the UI.

Recommendations without pretending certainty: product matching logic that doesn’t feel random

A lot of calculators jump from “you need 143Ah” to “buy this exact 150Ah battery.” That’s seductive, and it’s also misleading.

Real purchasing constraints include:

  • Available capacities in your market (100Ah, 150Ah, 200Ah…)
  • Series/parallel configurations (especially when moving to 24V/48V)
  • Surge loads and inverter headroom
  • Battery chemistry differences (lead-acid vs LiFePO4)

If you recommend a single SKU, you’re implicitly endorsing a wiring plan, a chemistry, and a budget. That’s not a calculator anymore—it’s a sales funnel.

AhCalc’s approach is closer to: compute the requirement, then show a small set of plausible configurations that meet it, with clear constraints.

Pattern:

  1. Compute required Wh/Ah.
  2. Generate candidate configurations from common building blocks.
  3. Filter out candidates that violate constraints.
  4. Sort by “least overshoot” (or cost proxy if you have pricing).
  5. Present 3–6 options, not 30.

Example (simplified candidate generation):

// calc/matching.ts
export type BatteryBlock = { ah: number; voltage: 12 | 24 | 48 };
export type BatteryConfig = {
  blocksInSeries: number;
  stringsInParallel: number;
  totalVoltage: number;
  totalAh: number;
};

export function matchBatteryConfigs(
  requiredAh: number,
  systemVoltage: 12 | 24 | 48,
  blocks: BatteryBlock[] = [
    { ah: 100, voltage: 12 },
    { ah: 150, voltage: 12 },
    { ah: 200, voltage: 12 },
  ]
): BatteryConfig[] {
  const configs: BatteryConfig[] = [];

  for (const block of blocks) {
    // Only consider blocks that can be composed to the system voltage
    if (systemVoltage % block.voltage !== 0) continue;

    const series = systemVoltage / block.voltage;

    for (let parallel = 1; parallel <= 6; parallel++) {
      const totalAh = block.ah * parallel;
      if (totalAh < requiredAh) continue;

      configs.push({
        blocksInSeries: series,
        stringsInParallel: parallel,
        totalVoltage: systemVoltage,
        totalAh,
      });
    }
  }

  return configs
    .sort((a, b) => a.totalAh - b.totalAh)
    .slice(0, 6);
}
Enter fullscreen mode Exit fullscreen mode

This is not “AI recommendations.” It’s deterministic, explainable logic. That’s a feature.

Failure modes to watch:

  • Too many candidates: users freeze. Keep it tight.
  • Candidates that look equal: add tie-breakers (fewer parallel strings is often preferable for simplicity).
  • Market mismatch: if your block list doesn’t match what people can buy, the tool feels fake. Keep the block list regional or user-selectable.

If you ever add pricing, do it carefully. Price data goes stale fast and creates support burden. For most calculators, “configuration plausibility” beats “exact shopping cart.”

The small UI details that made it feel fast: animated numbers, React 19, and avoiding form fatigue

The math can be correct and the tool can still feel unpleasant.

Two UX problems show up in calculators:

  1. Users don’t trust results when numbers jump abruptly.
  2. Users abandon when input feels like a tax (too many fields, too much reading).

AhCalc leaned into instant feedback: change one input, watch outputs update smoothly. That’s not decoration; it’s comprehension. When the number animates from 120Ah to 180Ah as you increase backup hours, your brain understands causality.

Implementation-wise, I kept it simple:

  • Use React 19 + TypeScript for predictable state and rendering.
  • Keep calculations synchronous and cheap.
  • Animate only the displayed number (not the underlying state).

If you’re building something similar, resist the urge to introduce a state management library early. For a calculator, local state + derived computed values is usually enough.

Also: avoid “wizard” flows unless you absolutely need them. A single-page, editable view wins because users compare scenarios. They don’t fill a form once; they tweak.

One more tradeoff I’ll defend: I intentionally kept the app backend-free. Yes, you lose analytics depth and account-based features. But you gain:

  • Near-zero operational overhead
  • Better performance and reliability
  • A product that feels neutral and trustworthy

If you want lightweight analytics, use privacy-respecting tools and keep them optional. For example, Plausible (https://plausible.io/) is a common choice for simple, cookie-light analytics.

What I’d recommend if you’re turning repeated questions into a public tool

Building AhCalc reinforced a few rules I now treat as defaults for “small, useful software”:

  • Start with the repeated question, not the feature list. The question already contains the UX.
  • Make assumptions explicit (DoD, efficiency, sun hours). Hidden assumptions are where tools become untrustworthy.
  • Don’t hide the answer behind a funnel. If the tool is genuinely useful, distribution happens anyway.
  • Keep the core logic pure and testable. UI can change weekly; math should be stable.
  • Make it shareable by design. If your users can’t send a link that reproduces results, you’ve added friction where people most want speed.

If you’re deciding what to build first, here’s the decision rule I’d actually use:

If your calculator can’t produce a decent answer in under 15 seconds without requiring personal info, don’t ship it yet. Fix that first. Everything else—SEO pages, product comparisons, “download report” buttons—comes later.

If you’re curious (or you just want to stop doing battery math in your head), try AhCalc at https://ahcalc.com and see how it fits your own solar, battery, and inverter sizing needs.


Read the full post on QCode: https://qcode.in/building-ahcalc-a-solar-and-battery-sizing-tool-people-actually-use/

Top comments (0)