The moon_phase field in WeatherAPI’s astronomy endpoint returns a string like "Waxing Gibbous" or "Full Moon". Fine for a UI label. Nearly useless if you’re trying to compute how much ambient light the moon is actually contributing to the sky on a given night at a given location.
The phase name maps to a rough position in the lunar cycle, but it doesn’t tell you the illumination fraction — and illumination fraction is what actually matters for wildlife tracking apps, astrophotography schedulers, fishing and hunting window tools, or anything that needs to reason about nighttime visibility conditions.
What WeatherAPI Actually Returns
The astronomy endpoint (/v1/astronomy.json) returns, per day:
moonriseandmoonset— local time stringsmoon_phase— a human-readable stringmoon_illumination— an integer percentage (0–100)
moon_illumination is the field most developers discard. The phase string is more legible, so it gets displayed and the illumination value gets dropped. That’s the wrong call.
A Waxing Gibbous at 68% illumination is a very different sky than a Waxing Gibbous at 85%. The phase name doesn’t distinguish them. The illumination percentage does.
Illumination Fraction Alone Still Isn’t Enough
Even moon_illumination on its own doesn’t tell you how bright the sky is at a specific hour. The moon has to actually be above the horizon, and how high above it matters — low-angle moonlight travels through more atmosphere and contributes less to sky brightness than a moon near zenith.
The API gives you moonrise and moonset as time strings. Moon elevation angle over the course of the night you have to derive, or approximate.
For most practical scheduling use cases, a workable approximation is:
- Parse
moonriseandmoonsetinto epoch timestamps — use thelocaltime_epochfrom the location block for timezone anchoring, not a system offset. - Determine whether the moon is above the horizon for a given hour by checking if that hour’s epoch falls between the moonrise and moonset epochs. Watch for the cross-midnight case where
moonsetis earlier in the day thanmoonrise— this happens regularly and will break naive comparisons. - Estimate a simplified elevation factor: assume the moon peaks midway between moonrise and moonset, and linearly ramp the elevation factor from 0 at rise/set to 1.0 at peak. This ignores the actual altitude of the peak and the non-linearity of the sky path, but for “is this hour dark enough for astrophotography” decisions it’s close enough.
- Multiply
moon_illuminationby that elevation factor to get a relative sky-brightness score for each hour.
from datetime import datetime, timezone
def lunar_brightness_score(moonrise_epoch, moonset_epoch, hour_epoch, illumination_pct):
# Handle case where moon is below horizon
if hour_epoch < moonrise_epoch or hour_epoch > moonset_epoch:
return 0.0
transit_epoch = (moonrise_epoch + moonset_epoch) / 2
half_window = (moonset_epoch - moonrise_epoch) / 2
distance_from_transit = abs(hour_epoch - transit_epoch)
elevation_factor = 1.0 - (distance_from_transit / half_window)
return (illumination_pct / 100.0) * elevation_factor
The output is a 0–1 score. A full moon near zenith gets close to 1.0. A crescent moon just above the horizon gets something like 0.05. That’s a meaningful signal for downstream logic — not a phase string that tells you nothing quantitative.
The Cross-Midnight Edge Case Will Get You
This is the most common source of bugs in this kind of logic, so it’s worth being explicit.
The API returns moonrise and moonset as local time strings — something like "11:42 PM" and "10:14 AM". Parse both against the same calendar date and you get a moonset that precedes moonrise. Your between-check returns false for every hour of the night when it should return true for most of them.
The fix: if moonset_epoch is less than moonrise_epoch, add 86400 seconds to moonset_epoch before doing any comparisons. If you’re building a full-night window and moonset bleeds into the next calendar day, request astronomy data for two consecutive days.
There are also nights when the moon never rises, and nights when it never sets — higher latitudes especially. The API returns empty strings or "No moonrise"-style values for these. Check for that before parsing epochs. A failed parse that silently sets your epoch to 0 will treat every hour as nighttime.
Combining With Cloud Cover
A bright full moon under 100% overcast is still a dark sky at ground level. The lunar brightness score above is a clear-sky number — what the moon could contribute, not what it does contribute given actual conditions.
Hourly forecast data includes cloud_cover as a percentage. A linear transmissivity multiplier is coarse but directionally correct: 70% cloud cover reduces lunar illumination contribution by roughly 70%. Weighting for cloud type would be more defensible, but that’s not in the API payload and isn’t worth engineering around unless you have a genuinely precision-sensitive use case.
effective_score = lunar_brightness_score(...) * (1.0 - cloud_cover_pct / 100.0)
Now you have something that varies usefully by hour, accounts for cloud conditions, and scales with lunar phase — rather than a label that tells you nothing a nine-year-old couldn’t tell you by looking up at the sky.
Where This Breaks Down
The elevation factor assumes a symmetric arc, which is only approximately true. The moon’s actual altitude at transit depends on its declination and the observer’s latitude — a full moon in December from Scotland sits much lower in the sky than the same full moon from the Mediterranean, so identical moon_illumination values produce meaningfully different sky brightness in Glasgow versus Athens. If your app covers latitudes above 55° or below -40°, the simplified model will overestimate lunar sky brightness in winter months.
For anything requiring real precision — actual sky brightness in magnitudes per square arcsecond — you need a proper ephemeris library, not API-derived estimates. PyEphem or Skyfield will give you actual altitude angles. The WeatherAPI response works well as a proxy for “which hours of this night are dark enough to do X”; it’s not a substitute for ephemeris math when the stakes are high.
The moon_illumination value is also a daily snapshot, not interpolated across the night. Illumination changes slowly enough — under 0.5% per hour — that using a daily value for hourly scoring is fine in practice.
Practical Use Cases Worth Building
This pattern applies to any context where nighttime ambient light is an operational variable: astrophotography session planners, wildlife camera trip planning, backcountry navigation apps, marine fishing tools (many species respond to lunar light cycles), or energy management systems that need to know when artificial lighting compensation is warranted.
For most of these, the operationally interesting threshold lands somewhere around a 15–20% effective score — below that, you’re in meaningful astronomical darkness; above it, the moon is a factor. Where exactly you draw that line depends on your use case, and it’s worth exposing as a configurable parameter rather than baking in a magic number.
To sanity-check your output: compare computed effective scores against a known astrophotography planning tool for the same dates and locations. Disagreements will almost always trace back to either the elevation approximation or the cross-midnight parsing bug — not the underlying data.
