From 326abf812bc3af6e3da430ec5c3a90916dd634f7 Mon Sep 17 00:00:00 2001 From: mandyslovestories-sudo Date: Sun, 28 Jun 2026 14:46:44 -0500 Subject: [PATCH] feat(solar): add /api/solar/forecast endpoint and SolarForecast component - GET /api/solar/forecast computes daily/weekly/monthly/yearly kWh and XLM revenue - Accounts for panel capacity, peak sun hours, efficiency, age degradation, system losses - GET /api/solar/irradiance-zones returns reference peak sun hours by climate zone - SolarForecast React component with climate zone picker, result cards and assumptions panel - Route registered at /api/solar in index.ts --- backend/src/index.ts | 2 + backend/src/routes/solar.ts | 172 +++++++++++ frontend/src/components/SolarForecast.tsx | 339 ++++++++++++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 backend/src/routes/solar.ts create mode 100644 frontend/src/components/SolarForecast.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index c103a0e..1558ef2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,7 @@ import { startUsageEventRetryWorker, } from "./lib/usageEvents.js"; import { metricsRouter } from "./routes/metrics.js"; +import { solarRouter } from "./routes/solar.js"; import { createRequire } from "module"; const _require = createRequire(import.meta.url); @@ -158,6 +159,7 @@ app.use("/api/allowlist", allowlistRouter); app.use("/api/collaborators", collaboratorRouter); app.use("/api/stats", statsRouter); app.use("/api/metrics", metricsRouter); +app.use("/api/solar", solarRouter); // #420: GET /api/health — version, uptime, dependency status app.get("/api/health", async (_req, res) => { diff --git a/backend/src/routes/solar.ts b/backend/src/routes/solar.ts new file mode 100644 index 0000000..5ffc129 --- /dev/null +++ b/backend/src/routes/solar.ts @@ -0,0 +1,172 @@ +import { Router, Request, Response } from "express"; +import { asyncHandler } from "../lib/asyncHandler.js"; +import { logger } from "../lib/logger.js"; + +export const solarRouter = Router(); + +// ── Constants ────────────────────────────────────────────────────────────── + +/** Average panel efficiency loss per year (degradation rate) */ +const DEGRADATION_RATE = 0.005; + +/** System losses: inverter, wiring, temperature, soiling (typical 14%) */ +const SYSTEM_LOSS = 0.86; + +/** Days per period */ +const PERIOD_DAYS: Record = { + daily: 1, + weekly: 7, + monthly: 30, + yearly: 365, +}; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface ForecastPeriod { + period: string; + days: number; + estimatedKwh: number; + estimatedRevenue: number; +} + +interface ForecastResponse { + panelCapacityKw: number; + peakSunHours: number; + efficiency: number; + panelAgeYears: number; + effectiveDegradation: number; + dailyKwh: number; + forecast: ForecastPeriod[]; + assumptions: { + systemLoss: number; + degradationRatePerYear: number; + ratePerKwh: number; + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Core solar production formula: + * kWh/day = capacity(kW) × peakSunHours × efficiency × systemLoss × degradationFactor + */ +function calcDailyKwh( + capacityKw: number, + peakSunHours: number, + efficiency: number, + panelAgeYears: number, +): number { + const degradationFactor = Math.pow(1 - DEGRADATION_RATE, panelAgeYears); + return capacityKw * peakSunHours * efficiency * SYSTEM_LOSS * degradationFactor; +} + +function parsePositiveFloat(val: unknown, name: string, max?: number): number { + const n = Number(val); + if (!Number.isFinite(n) || n <= 0) { + throw Object.assign(new Error(`${name} must be a positive number`), { code: "VALIDATION_ERROR" }); + } + if (max !== undefined && n > max) { + throw Object.assign(new Error(`${name} must be ≤ ${max}`), { code: "VALIDATION_ERROR" }); + } + return n; +} + +function parseNonNegativeFloat(val: unknown, name: string): number { + const n = Number(val); + if (!Number.isFinite(n) || n < 0) { + throw Object.assign(new Error(`${name} must be a non-negative number`), { code: "VALIDATION_ERROR" }); + } + return n; +} + +// ── Routes ───────────────────────────────────────────────────────────────── + +/** + * GET /api/solar/forecast + * + * Estimates solar energy production and revenue for a given panel setup. + * + * Query params: + * capacityKw — total panel capacity in kilowatts (required, e.g. 5) + * peakSunHours — average daily peak sun hours for the location (required, e.g. 5.5) + * efficiency — panel efficiency 0–1 (optional, default 0.20) + * panelAgeYears — age of panels in years for degradation calc (optional, default 0) + * ratePerKwh — local electricity rate in XLM/kWh (optional, default 0.12) + * + * Example: + * GET /api/solar/forecast?capacityKw=10&peakSunHours=5.5&efficiency=0.20&ratePerKwh=0.15 + */ +solarRouter.get( + "/forecast", + asyncHandler(async (req: Request, res: Response) => { + const { + capacityKw: rawCap, + peakSunHours: rawPsh, + efficiency: rawEff = "0.20", + panelAgeYears: rawAge = "0", + ratePerKwh: rawRate = "0.12", + } = req.query; + + // Validate inputs + const capacityKw = parsePositiveFloat(rawCap, "capacityKw", 10_000); + const peakSunHours = parsePositiveFloat(rawPsh, "peakSunHours", 24); + const efficiency = parsePositiveFloat(rawEff, "efficiency", 1); + const panelAgeYears = parseNonNegativeFloat(rawAge, "panelAgeYears"); + const ratePerKwh = parseNonNegativeFloat(rawRate, "ratePerKwh"); + + const degradationFactor = Math.pow(1 - DEGRADATION_RATE, panelAgeYears); + const effectiveDegradation = Number(((1 - degradationFactor) * 100).toFixed(2)); + + const dailyKwh = calcDailyKwh(capacityKw, peakSunHours, efficiency, panelAgeYears); + + const forecast: ForecastPeriod[] = Object.entries(PERIOD_DAYS).map( + ([period, days]) => { + const estimatedKwh = Number((dailyKwh * days).toFixed(3)); + const estimatedRevenue = Number((estimatedKwh * ratePerKwh).toFixed(4)); + return { period, days, estimatedKwh, estimatedRevenue }; + }, + ); + + const response: ForecastResponse = { + panelCapacityKw: capacityKw, + peakSunHours, + efficiency, + panelAgeYears, + effectiveDegradation, + dailyKwh: Number(dailyKwh.toFixed(3)), + forecast, + assumptions: { + systemLoss: SYSTEM_LOSS, + degradationRatePerYear: DEGRADATION_RATE, + ratePerKwh, + }, + }; + + logger.info( + { capacityKw, peakSunHours, efficiency, dailyKwh: response.dailyKwh }, + "Solar forecast computed", + ); + + res.json(response); + }), +); + +/** + * GET /api/solar/irradiance-zones + * + * Returns reference peak sun hour values for common climate zones. + * Useful for the frontend dropdown so users don't need to look this up. + */ +solarRouter.get("/irradiance-zones", (_req: Request, res: Response) => { + res.json({ + zones: [ + { name: "Equatorial (e.g. Kenya, Nigeria, Ghana)", peakSunHours: 6.0 }, + { name: "Tropical wet/dry (e.g. India, Brazil)", peakSunHours: 5.5 }, + { name: "Sub-Saharan savanna", peakSunHours: 6.5 }, + { name: "Mediterranean (e.g. Spain, South Africa)", peakSunHours: 5.2 }, + { name: "Temperate (e.g. Central Europe, UK)", peakSunHours: 3.5 }, + { name: "Desert (e.g. Sahara, Arizona, Middle East)", peakSunHours: 7.5 }, + { name: "Northern / cloudy (e.g. Scandinavia, Canada)", peakSunHours: 2.5 }, + ], + }); +}); diff --git a/frontend/src/components/SolarForecast.tsx b/frontend/src/components/SolarForecast.tsx new file mode 100644 index 0000000..f27c38e --- /dev/null +++ b/frontend/src/components/SolarForecast.tsx @@ -0,0 +1,339 @@ +"use client"; + +import { useState } from "react"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface ForecastPeriod { + period: string; + days: number; + estimatedKwh: number; + estimatedRevenue: number; +} + +interface ForecastResult { + panelCapacityKw: number; + peakSunHours: number; + efficiency: number; + panelAgeYears: number; + effectiveDegradation: number; + dailyKwh: number; + forecast: ForecastPeriod[]; + assumptions: { + systemLoss: number; + degradationRatePerYear: number; + ratePerKwh: number; + }; +} + +interface IrradianceZone { + name: string; + peakSunHours: number; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; + +const PERIOD_ICONS: Record = { + daily: "☀️", + weekly: "📅", + monthly: "🗓️", + yearly: "📊", +}; + +// ── Sub-components ───────────────────────────────────────────────────────── + +function ForecastCard({ period }: { period: ForecastPeriod }) { + return ( +
+
+ + {period.period} +
+

+ {period.estimatedKwh.toLocaleString()} kWh +

+

+ ≈ {period.estimatedRevenue.toFixed(2)} XLM +

+
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────── + +export default function SolarForecast() { + // Form state + const [capacityKw, setCapacityKw] = useState("5"); + const [peakSunHours, setPeakSunHours] = useState("5.5"); + const [efficiency, setEfficiency] = useState("0.20"); + const [panelAgeYears, setPanelAgeYears] = useState("0"); + const [ratePerKwh, setRatePerKwh] = useState("0.12"); + + // Async state + const [result, setResult] = useState(null); + const [zones, setZones] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [zonesLoaded, setZonesLoaded] = useState(false); + + // Load irradiance zones lazily on first focus of peakSunHours field + async function loadZones() { + if (zonesLoaded) return; + try { + const res = await fetch(`${API_BASE}/api/solar/irradiance-zones`); + if (res.ok) { + const data = await res.json(); + setZones(data.zones ?? []); + } + } catch { + // non-critical, silently ignore + } finally { + setZonesLoaded(true); + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + setResult(null); + + try { + const params = new URLSearchParams({ + capacityKw, + peakSunHours, + efficiency, + panelAgeYears, + ratePerKwh, + }); + + const res = await fetch(`${API_BASE}/api/solar/forecast?${params}`); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error ?? `Request failed (${res.status})`); + } + + setResult(data as ForecastResult); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setLoading(false); + } + } + + return ( +
+ + {/* Header */} +
+

+ Solar Production Forecast +

+

+ Estimate daily, weekly, monthly and yearly kWh output and XLM revenue for your solar installation. +

+
+ + {/* Form */} +
+
+ + {/* Panel capacity */} +
+ + setCapacityKw(e.target.value)} + className="rounded-lg border border-white/10 bg-solar-dark px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-solar-yellow focus:outline-none transition" + placeholder="e.g. 5" + /> +
+ + {/* Peak sun hours with zone picker */} +
+ +
+ setPeakSunHours(e.target.value)} + onFocus={loadZones} + className="flex-1 rounded-lg border border-white/10 bg-solar-dark px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-solar-yellow focus:outline-none transition" + placeholder="e.g. 5.5" + /> + {zones.length > 0 && ( + + )} +
+
+ + {/* Efficiency */} +
+ + setEfficiency(e.target.value)} + className="rounded-lg border border-white/10 bg-solar-dark px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-solar-yellow focus:outline-none transition" + placeholder="0.20" + /> +

Typical mono-PERC panels: 0.20–0.23

+
+ + {/* Panel age */} +
+ + setPanelAgeYears(e.target.value)} + className="rounded-lg border border-white/10 bg-solar-dark px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-solar-yellow focus:outline-none transition" + placeholder="0" + /> +
+ + {/* Rate per kWh */} +
+ + setRatePerKwh(e.target.value)} + className="w-full sm:w-1/2 rounded-lg border border-white/10 bg-solar-dark px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-solar-yellow focus:outline-none transition" + placeholder="0.12" + /> +
+
+ + +
+ + {/* Error */} + {error && ( +
+ ✕ {error} +
+ )} + + {/* Results */} + {result && ( +
+ + {/* Summary bar */} +
+
+

Daily output

+

+ {result.dailyKwh} kWh +

+
+ {result.effectiveDegradation > 0 && ( +
+

Panel degradation

+

+ −{result.effectiveDegradation}% +

+
+ )} +
+

System loss applied

+

+ {((1 - result.assumptions.systemLoss) * 100).toFixed(0)}% +

+
+
+ + {/* Period cards */} +
+ {result.forecast.map((p) => ( + + ))} +
+ + {/* Assumptions */} +
+ + View assumptions + +
    +
  • Capacity: {result.panelCapacityKw} kW
  • +
  • Peak sun hours: {result.peakSunHours} h/day
  • +
  • Panel efficiency: {(result.efficiency * 100).toFixed(0)}%
  • +
  • System losses (inverter, wiring, soiling): {((1 - result.assumptions.systemLoss) * 100).toFixed(0)}%
  • +
  • Annual degradation rate: {(result.assumptions.degradationRatePerYear * 100).toFixed(1)}%/yr
  • +
  • Revenue rate: {result.assumptions.ratePerKwh} XLM/kWh
  • +
+
+
+ )} +
+ ); +}