A fixed-schedule solar baseline drifts out of sync with the sun throughout the year. In Phoenix the sun is up for 13 hours 10 minutes in late August and 10 hours 2 minutes at the December solstice. A flat daily kWh target flags that entire winter as a fault, then stays quiet on the July afternoon when one string dies at 2pm under full sun. The fix is to anchor the baseline to the actual sun instead of the clock, and most of what you need for that does not require an irradiance forecast.
One thing before any code: sun geometry tells you when a system should be producing and when it should peak. It does not tell you how much light actually reached the panels. That is irradiance, and cloud cover swamps it. If you want modeled output in kWh, reach for Forecast.Solar or Solcast, which fold in weather and your array's tilt and azimuth. What follows is the free, dependency-light layer underneath that: the daylight window, the solar-noon peak, and the day-length trend.
TL;DR
- Sun geometry (sunrise, sunset, solar noon, day length) catches a specific class of solar underperformance with no irradiance data.
- Gate alerts to the real daylight window so your monitor stops crying "underperformance" before sunrise.
- Track the daily production peak relative to solar noon. A persistent shift across comparable days can reveal shading, orientation, or system changes that a total-kWh check misses.
- Normalize a flat kWh target by day length so winter stops tripping false alarms. First-order fix, not a physics model.
- One call to an astronomy endpoint returns all of it. Code below in curl, Python, and Node.
- For real production forecasting, use an irradiance API. Sun times are the sanity layer, not the forecaster.
Sun times will not predict your kWh, but they eliminate common timing-based false alarms and can surface useful production-shape anomalies early. Pull sunrise, sunset, solar noon, and day length once a day, gate your alerts to daylight, watch the peak, and scale the target for season.
What sun times can and cannot tell you
Sun geometry gives you three usable things: the window a system can physically produce in, the moment it should peak, and how that window changes through the year. All three come from a plain lat/long lookup and none of them costs you a weather subscription.
What they leave out is the part that actually varies day to day. A clear July noon and an overcast one have the same solar noon and nearly the same day length, but wildly different output. So sun times are a denominator, not a measurement. Treat a gap between expected window and actual production as a question, not a verdict, and reserve the confident "this panel is broken" call for the cases geometry can isolate on its own.
Plenty of tools return sun times: Sunrise-Sunset.org is free with no signup, SunCalc computes positions in the browser without an API at all, and NOAA has a solar calculator. I am using ipgeolocation.io's Astronomy API here because one call returns the window, solar noon, day length, twilight, and the sun's position together, which keeps the code short. Pick whatever you already have in your stack.
The three checks worth building
None of these needs irradiance. Each one closes a real gap in the naive "compare today's kWh to a fixed number" monitor.
Gate alerts to the real daylight window
The cheapest win is to stop evaluating production when the sun is not up. A monitor that compares output to a target every fifteen minutes will happily fire a "low production" alert at 06:00 in December, when sunrise is at 07:25 and the correct output is zero. Gate every underperformance rule behind the sunrise-to-sunset window and that class of alert disappears.
Trim the edges with a margin. Output in the first and last half hour of daylight is near zero because the sun is barely above the horizon, so a low-production alert there is just physics, not a fault. A 30-minute margin inside sunrise and sunset is a reasonable start; widen it for a site with a hard eastern or western horizon. The one edge case to handle is high latitude in midsummer or midwinter, where the API can report the sun as up or down all day; check sun_status before trusting the window there.
Watch the peak drift off solar noon
This is the check that earns its keep. Total daily kWh can look fine while a single string slowly fails, because the healthy strings mask the loss in the daily sum. The shape of the day gives it away sooner.
When the daily peak persistently shifts away from its normal offset, or the curve repeatedly becomes lopsided under comparable conditions, it can point to partial shading, uneven soiling, orientation effects, or inverter behavior. A single cloudy day's peak is weak evidence, so look for the change across multiple days rather than treating one maximum as a fault.
Orientation is the easy thing to get wrong here. A west-facing array peaks after solar noon by design and an east-facing one before it, so the threshold is relative to where your array should peak, not a universal number. Compare today's peak time to solar noon, keep a rolling few weeks of the offset, and alert on the change rather than the absolute value.
Normalize the baseline by day length
A flat kWh target assumes every day has the same amount of daylight, which is why it flags winter. Scaling the target by today's day length against a summer reference removes most of that error in about three lines. A 40 kWh midsummer target becomes roughly 29 kWh at the December solstice (10 hours 2 minutes of daylight against a 14-hour reference); the flat 40 kWh number would have marked a perfectly healthy winter day about 27% low.
Be honest about what this does not fix. Winter days are not just shorter, the sun is also lower, so each daylight hour delivers less energy and real output drops more than the day-length ratio predicts. Day-length scaling gets you from "wildly wrong" to "roughly right," which is enough to stop the seasonal false alarms. Closing the rest of the gap means modeling irradiance and sun elevation, which is exactly the job Forecast.Solar, Solcast, and the pvlib library exist to do.
Pull the sun data
The endpoint is a single GET. The free tier is 1,000 requests a day with no credit card, and one request covers a full day, so a per-site daily pull sits comfortably inside it. Sign up for a key on the Astronomy API and keep it out of source control.
# Free tier: 1000 requests/day, no card. One call returns the whole day's geometry.
# 2s timeout so a slow response can't hang a monitor loop.
curl -sS --max-time 2 \
"https://api.ipgeolocation.io/v3/astronomy?apiKey=$IPGEO_API_KEY&lat=33.4484&long=-112.0740&date=2026-08-25&time_zone=America/Phoenix"
The response carries far more than the four fields we use. That is the point of one call:
{
"location": {
"latitude": "33.44840",
"longitude": "-112.07400",
"city": "Phoenix",
"state_prov": "Arizona",
"elevation": "331"
},
"astronomy": {
"date": "2026-08-25",
"current_time": "05:46:58.255",
"sunrise": "2026-08-25 05:54",
"sunset": "2026-08-25 19:05",
"solar_noon": "2026-08-25 12:30",
"day_length": "13:10",
"sun_altitude": -3.03,
"sun_azimuth": 75.14,
"sun_distance": 151249918.79,
"sun_status": "-",
"morning": {
"civil_twilight_begin": "2026-08-25 05:32",
"civil_twilight_end": "2026-08-25 05:54",
"golden_hour_begin": "2026-08-25 05:42",
"golden_hour_end": "2026-08-25 06:31"
},
"evening": {
"golden_hour_begin": "2026-08-25 18:29",
"golden_hour_end": "2026-08-25 19:18",
"civil_twilight_begin": "2026-08-25 19:05",
"civil_twilight_end": "2026-08-25 19:28"
},
"moon_phase": "WAXING_GIBBOUS",
"moonrise": "2026-08-25 17:48",
"moonset": "2026-08-25 03:26",
"moon_illumination_percentage": "93.10"
}
}
Two things to notice before you write parsers. day_length is a string, "13:10", meaning 13 hours 10 minutes, not a decimal. And because the request set time_zone, the times come back as "YYYY-MM-DD HH:mm" in that zone rather than bare clock times. Both matter for the code. One more, in case it trips you up: sun_altitude and sun_azimuth are the sun's position at current_time, which is when the call ran, not at noon. In this capture that was just before sunrise, hence the negative altitude. The checker below ignores those fields, but they are useful for a live "is the sun even up right now" readout.
Pitfall: timestamps are returned in the
time_zoneyou request. The curl example above asks for Phoenix local time, while the checker below deliberately requests UTC so the timestamps can be parsed unambiguously. Keepnowtimezone-aware and compare absolute times consistently. Thedateyou request should still be the calendar date at the solar site.
Build the baseline checker
Fetch once per day, then run all three checks against that one response. Calling the API per check wastes credits and adds latency for data that does not change within a day. A small cron job at local midnight that caches the day's response is enough; there is no reason to hit the endpoint again until the date rolls over.
import os
import sys
from datetime import datetime, timedelta, timezone
import requests
IPGEO_API_KEY = os.environ.get("IPGEO_API_KEY")
ASTRONOMY_URL = "https://api.ipgeolocation.io/v3/astronomy"
def fetch_sun_day(lat, lon, date_str):
"""One day of sun geometry. Costs one credit, so call it once per day and
reuse the result for every check instead of hitting the API per rule."""
if not IPGEO_API_KEY:
raise RuntimeError("Set IPGEO_API_KEY in the environment")
params = {
"apiKey": IPGEO_API_KEY,
"lat": lat,
"long": lon,
"date": date_str,
# Request UTC so returned timestamps are unambiguous when parsed.
"time_zone": "UTC",
}
try:
# (connect, read) timeouts so a slow upstream can't stall the monitor loop
resp = requests.get(ASTRONOMY_URL, params=params, timeout=(1.0, 1.5))
resp.raise_for_status()
except requests.RequestException as err:
# Fail open: this is a sanity layer, not a safety system. If it's down,
# skip the geometry checks rather than block the rest of monitoring.
print(f"sun lookup failed, skipping geometry checks: {err}", file=sys.stderr)
return None
return (resp.json() or {}).get("astronomy")
def hhmm_to_minutes(value):
"""day_length arrives as a string like '10:02' (hours:minutes), not decimal."""
if not value or ":" not in value:
return None
hours, minutes = value.split(":")[:2]
return int(hours) * 60 + int(minutes)
def parse_dt(value):
"""Parse the API's UTC 'YYYY-MM-DD HH:mm' timestamps as aware datetimes."""
if not value:
return None
try:
return datetime.strptime(
value, "%Y-%m-%d %H:%M"
).replace(tzinfo=timezone.utc)
except ValueError:
return None
Those three helpers absorb the two gotchas: the string day length and the dated timestamps. Everything below returns cleanly on missing fields so a partial response never throws.
def in_daylight(now, astro, margin_minutes=30):
"""True only inside the productive window. The margin trims dawn and dusk."""
status = astro.get("sun_status")
if status == "Always above the twilight angle":
return True
if status == "Always below the twilight angle":
return False
sunrise = parse_dt(astro.get("sunrise"))
sunset = parse_dt(astro.get("sunset"))
if not sunrise or not sunset:
return True # unknown, so don't suppress a potentially real alert
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
now_utc = now.astimezone(timezone.utc)
start = sunrise + timedelta(minutes=margin_minutes)
end = sunset - timedelta(minutes=margin_minutes)
return start <= now_utc <= end
def peak_drift_minutes(observed_peak, astro):
"""How far the day's production peak sat from solar noon, in minutes.
observed_peak must be a timezone-aware datetime."""
solar_noon = parse_dt(astro.get("solar_noon"))
if not solar_noon or not observed_peak:
return None
if observed_peak.tzinfo is None:
raise ValueError("observed_peak must be timezone-aware")
observed_peak_utc = observed_peak.astimezone(timezone.utc)
return abs((observed_peak_utc - solar_noon).total_seconds()) / 60.0
def seasonal_target(base_kwh, astro, reference_day_length="14:00"):
"""Scale a flat kWh target by today's day length against a summer reference.
Removes the gross seasonal error. It under-corrects, because winter also has a
lower sun (less energy per daylight hour); closing that gap needs irradiance."""
today = hhmm_to_minutes(astro.get("day_length"))
reference = hhmm_to_minutes(reference_day_length)
if today is None or reference is None or reference == 0:
return base_kwh
return round(base_kwh * (today / reference), 1)
Use the daylight gate around real-time production alerts, but run the peak-drift and daily-kWh checks only after the day's production window is complete. A full-day kWh target should never be compared with energy accumulated halfway through the day. The tolerance band below is still only a rough weather buffer, not a substitute for irradiance data.
def evaluate_completed_day(
observed_peak,
actual_kwh,
base_kwh,
lat,
lon,
date_str,
):
"""Run after the day's production window has finished."""
astro = fetch_sun_day(lat, lon, date_str)
if astro is None:
return
drift = peak_drift_minutes(observed_peak, astro)
if drift is not None and drift > 90:
print(
f"peak offset was {drift:.0f} min from solar noon; "
"track whether that shift persists across comparable days"
)
expected = seasonal_target(base_kwh, astro)
if actual_kwh < expected * 0.75:
print(
f"actual {actual_kwh} kWh below seasonal expectation "
f"{expected} kWh"
)
The Node version is the same shape. fetch with a hard timeout, optional chaining on the response, and the identical string-and-timestamp parsing. It assumes Node 18 or newer, where fetch and AbortSignal.timeout are built in; on older runtimes add node-fetch and an abort polyfill.
const ASTRONOMY_URL = "https://api.ipgeolocation.io/v3/astronomy";
async function fetchSunDay(lat, lon, dateStr) {
const key = process.env.IPGEO_API_KEY;
if (!key) throw new Error("Set IPGEO_API_KEY in the environment");
// dateStr should be the calendar date at the solar site.
// Request event timestamps in UTC so Date parsing is unambiguous.
const url = `${ASTRONOMY_URL}?apiKey=${key}&lat=${lat}&long=${lon}`
+ `&date=${dateStr}&time_zone=UTC`;
try {
// 1.5s timeout so a slow upstream can't stall the monitor loop
const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
return body?.astronomy ?? null;
} catch (err) {
// Fail open: sanity layer, not a safety system
console.error(`sun lookup failed, skipping geometry checks: ${err.message}`);
return null;
}
}
// day_length is a string like "10:02" (h:mm), not a number
function hhmmToMinutes(value) {
if (!value || !value.includes(":")) return null;
const [h, m] = value.split(":");
return Number(h) * 60 + Number(m);
}
// We request UTC from the API, so the timestamp can be parsed unambiguously.
function parseDt(value) {
if (!value || value === "-:-") return null;
const d = new Date(`${value.replace(" ", "T")}:00Z`);
return Number.isNaN(d.getTime()) ? null : d;
}
function inDaylight(now, astro, marginMin = 30) {
if (astro?.sun_status === "Always above the twilight angle") return true;
if (astro?.sun_status === "Always below the twilight angle") return false;
const sunrise = parseDt(astro?.sunrise);
const sunset = parseDt(astro?.sunset);
if (!sunrise || !sunset) return true; // unknown, don't suppress a real alert
const start = new Date(sunrise.getTime() + marginMin * 60000);
const end = new Date(sunset.getTime() - marginMin * 60000);
return now >= start && now <= end;
}
function seasonalTarget(baseKwh, astro, referenceDayLength = "14:00") {
const today = hhmmToMinutes(astro?.day_length);
const reference = hhmmToMinutes(referenceDayLength);
if (today === null || reference === null || reference === 0) return baseKwh;
return Math.round(baseKwh * (today / reference) * 10) / 10;
}
Peak drift is the same idea on parsed values:
function peakDriftMinutes(observedPeak, astro) {
const solarNoon = parseDt(astro?.solar_noon);
if (!solarNoon || !observedPeak) return null;
return Math.abs(observedPeak.getTime() - solarNoon.getTime()) / 60000;
}
Flag it against your array's orientation, same caveat as before: a west-facing array measures drift from its own expected offset, not from solar noon.
Where this stops and irradiance begins
Run the checker against a week of clear days and it behaves. Run it against a stretch of overcast ones and the seasonal check will nag, because a cloudy day and a broken panel look identical to sun geometry. That is the wall. The 25% tolerance band is a blunt way to ride through normal weather, and it works until a genuinely bad week hides a genuinely bad panel.
The honest boundary is this: use sun times as the reference for checks about timing and geometry. The daylight window is deterministic; peak offset is useful when it persists across multiple comparable days, but clouds can move a single day's observed maximum. Lean on day-length scaling only to keep the seasonal baseline in the right ballpark. The moment you need to tell "cloudy" from "faulty," or you care about temperature derating and soiling trends, you have left what geometry can answer and you want an irradiance model: Forecast.Solar and Solcast expose one over an API (Forecast.Solar drops straight into Home Assistant if that is where your dashboard lives), pvlib gives you one in Python, and any of them will take your panel tilt and azimuth into account.
Drop the daylight gate in first. It is the cheapest change and stops the most false alarms. Add the peak-drift check once you have per-interval data to compare against solar noon. And when geometry-level checks stop being enough, treat that as the cue to pull an irradiance forecast, not to keep widening the thresholds until the alerts go quiet.
Top comments (0)