Solar & simulation API
Two free JSON APIs, no key needed, built for AI agents, installer tools and anyone comparing roofs and batteries:
-
PV yield
(
/api/v1/pv-yield): instant solar PV yield for any point in Sweden, any tilt and azimuth: annual and monthly specific yield (kWh per kWp), plane-of-array irradiation, the local optimum and how close an orientation gets to it. Answers in milliseconds. -
Simulation
(
/api/v1/simulation): one year of hourly energy flows, electricity costs and battery dispatch for one house, from your PV and use series. Compare battery sizes and battery strategies (own solar first, cheap-hour charging, lower power peaks, combined) in one call.
Machine-readable description of both: openapi.json
(OpenAPI 3.1; the same document is at /api/v1/pv-yield/openapi.json).
PV yield
Contains PVGIS 5.3 data © European Union, 2001–2026 (CC BY 4.0), transposed with Numsolar solar-core poa v2. Source: PVGIS (European Commission, Joint Research Centre), PVGIS 5.3 API; licence CC BY 4.0. Please keep this line when you republish the numbers.
Quick start
curl 'https://numsolar.com:5000/api/v1/pv-yield?lat=59.33&lng=18.07&tilt=35&azimuth=180&kwp=9.24'
Machine-readable description: openapi.json (OpenAPI 3.1). Dataset, coverage, limits and measured accuracy: /api/v1/pv-yield/meta.
Conventions and units
-
Azimuth
is a compass bearing the panel faces: 0 = north, 90 = east, 180 = south, 270 = west (360 = 0). PVGIS's "aspect" is
azimuth − 180. - Tilt: 0 = flat, 90 = vertical. Steps in the table: 5° tilt, 10° azimuth; values between are interpolated.
-
Energies are kWh;
_kwh_kwpis per kWp of installed DC peak power. Irradiation is kWh/m². -
14 % system loss by default (PVGIS's default: inverter, cabling, soiling, mismatch).
system_lossrescales exactly by(1 − loss) / 0.86. -
Free horizon: terrain and nearby obstacles are not modelled; pass your own
shadingfactor (0–1). -
orientation_factor= annual yield / the best orientation's annual yield at that point (0–1).
Routes
GET /api/v1/pv-yield
Parameters: lat, lng, tilt, azimuth
(required); system_loss
(0–0.6), shading
(0–1), kwp
(adds annual_kwh
and monthly_kwh).
{"input": {"lat": 59.33, "lng": 18.07, "tilt_deg": 35.0, "azimuth_deg": 180.0, "system_loss": 0.14, "shading": 1.0, "kwp": 9.24},
"annual_kwh_kwp": 983.6, "monthly_kwh_kwp": [16.5, 34.9, …],
"annual_kwh": 9088.2, "monthly_kwh": […],
"poa_annual_kwh_m2": 1172.5, "ghi_annual_kwh_m2": 995.5,
"optimal": {"tilt_deg": 40, "azimuth_deg": 180, "annual_kwh_kwp": 985.7},
"orientation_factor": 0.998,
"cell": {"lat": 59.25, "lng": 18.0, "grid_deg": [0.25, 0.5], "elevation_m": 48.0,
"radiation_db": "PVGIS-SARAH3", "era5_corrected": false, "interpolation": "bilinear"},
"model": {"poa_model": "v2", "sky_model": "hay_davies", "albedo": 0.2, "system_loss_default": 0.14, "horizon": "free", …},
"dataset": {"version": "se-2026.10", "coverage": "SE bbox 55.0–69.5N 10.5–24.5E", "sources": […], …},
"attribution": "Contains PVGIS 5.3 data © European Union, 2001–2026 (CC BY 4.0), transposed with Numsolar solar-core poa v2"}
GET /api/v1/pv-yield/matrix
lat, lng
(+ system_loss, shading): annual kWh/kWp for all 19 tilts × 36 azimuths (annual_kwh_kwp[tilt][azimuth]) and the optimum, for heatmaps and "which roof face is best here".
POST /api/v1/pv-yield/batch
Body {"items": [{"id": "south", "lat": 59.33, "lng": 18.07, "tilt": 27, "azimuth": 170, "kwp": 6.3}, …]}, up to 50 items (e.g. every face of a roof). Each result is
{"id", "ok": true, …}
or {"id", "ok": false, "error": {…}}; a batch counts as its item count against the limit.
GET /api/v1/pv-yield/meta and /openapi.json
Dataset version and sources, coverage (bbox, grid, cell counts), angle steps, limits and the measured accuracy below.
Errors, limits and caching
-
Errors:
{"error": {"code", "field"?, "message"}}—invalid_parameter422,outside_coverage422 (outside the Sweden bbox, or at sea),rate_limited429,invalid_api_key401,dataset_unavailable503. -
Without a key: 60 requests per minute per IP (10 for
/matrix). With anx-api-keyheader: 600 per minute (60 for/matrix). Over the limit: 429 withRetry-After; every answer carriesX-RateLimit-Limit/X-RateLimit-Remaining. -
Answers are deterministic for a dataset version:
Cache-Control: public, max-age=86400and anETag; sendIf-None-Matchto get a 304. -
CORS is open (
Access-Control-Allow-Origin: *): call it straight from a browser.
How the numbers are made
PVGIS 5.3 monthly horizontal irradiation, diffuse fraction and temperature (2005–2023; SARAH3, ERA5 north of 65° N corrected towards SARAH3) for every node of a 0.25° × 0.5° grid (≈ 25 km), transposed to every tilt and azimuth with Numsolar's own solar-core engine (poa model v2, Hay–Davies sky: circumsolar and isotropic diffuse light), and interpolated bilinearly in space and angle. Nothing is fetched at request time.
Accuracy
- South 35°, against PVGIS at 39 random points: RMS 2.32 %, worst 4.51 %.
- Interpolation between grid cells: p95 3.4 % measured on a coarser 50 km grid (an upper bound for the 25 km grid served).
- Flat, east-facing and vertical south panels are within a few per cent of PVGIS. Known limitation: north-facing panels read high, ≈ +6 % at 35° in most of Sweden and ≈ +12 % north of 65° N (where PVGIS uses ERA5 data).
Simulation
One year (365 days, hour by hour) of a house's energy flows and electricity bill, with and
without a battery. You send the PV production (for example from
/api/v1/pv-yield), the use and the prices; the answer gives the yearly and
monthly energy, the bill, the savings and what the battery does. It runs the same engine
as the Numsolar calculator (solar-core): CPU only, deterministic, nothing is fetched or
stored.
Quick start
A complete body: a ≈ 9 kWp south roof in Stockholm (pv.monthly_profile: one typical day,
kWh per clock hour, for each month), a 15 000 kWh household, Swedish spot prices and a 10 kWh battery.
More examples (flat prices, a comparison): docs/public_api.md.
curl -s -X POST 'https://numsolar.com:5000/api/v1/simulation' -H 'content-type: application/json' -d '{
"location": {"lat": 59.33, "lng": 18.07},
"consumption": {"yearly_kwh": 15000},
"prices": {"preset": "se_spot"},
"battery": {"capacity_kwh": 10, "strategy": "arbitrage"},
"pv": {"monthly_profile": [
[0.0,0.0,0.0,0.0,0.0,0.1,0.1,0.2,0.4,0.5,0.7,0.9,1.0,1.0,0.9,0.7,0.5,0.4,0.2,0.1,0.1,0.0,0.0,0.0],
[0.0,0.0,0.0,0.0,0.1,0.1,0.2,0.4,0.7,1.1,1.5,1.8,2.0,2.0,1.8,1.5,1.1,0.7,0.4,0.2,0.1,0.1,0.0,0.0],
[0.0,0.0,0.0,0.0,0.1,0.2,0.4,0.8,1.3,1.9,2.6,3.1,3.5,3.5,3.1,2.6,1.9,1.3,0.8,0.4,0.2,0.1,0.0,0.0],
[0.0,0.0,0.0,0.1,0.1,0.3,0.6,1.1,1.8,2.7,3.7,4.5,4.9,4.9,4.5,3.7,2.7,1.8,1.1,0.6,0.3,0.1,0.1,0.0],
[0.0,0.0,0.0,0.1,0.2,0.4,0.8,1.4,2.3,3.4,4.6,5.5,6.1,6.1,5.5,4.6,3.4,2.3,1.4,0.8,0.4,0.2,0.1,0.0],
[0.0,0.0,0.0,0.1,0.2,0.4,0.8,1.5,2.5,3.7,5.0,6.1,6.7,6.7,6.1,5.0,3.7,2.5,1.5,0.8,0.4,0.2,0.1,0.0],
[0.0,0.0,0.0,0.1,0.2,0.4,0.8,1.5,2.4,3.5,4.8,5.8,6.4,6.4,5.8,4.8,3.5,2.4,1.5,0.8,0.4,0.2,0.1,0.0],
[0.0,0.0,0.0,0.1,0.2,0.3,0.7,1.3,2.1,3.1,4.1,5.0,5.5,5.5,5.0,4.1,3.1,2.1,1.3,0.7,0.3,0.2,0.1,0.0],
[0.0,0.0,0.0,0.0,0.1,0.3,0.5,0.9,1.5,2.3,3.1,3.8,4.1,4.1,3.8,3.1,2.3,1.5,0.9,0.5,0.3,0.1,0.0,0.0],
[0.0,0.0,0.0,0.0,0.1,0.2,0.3,0.6,1.0,1.4,1.9,2.3,2.6,2.6,2.3,1.9,1.4,1.0,0.6,0.3,0.2,0.1,0.0,0.0],
[0.0,0.0,0.0,0.0,0.0,0.1,0.1,0.3,0.4,0.7,0.9,1.1,1.2,1.2,1.1,0.9,0.7,0.4,0.3,0.1,0.1,0.0,0.0,0.0],
[0.0,0.0,0.0,0.0,0.0,0.0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.7,0.6,0.5,0.4,0.3,0.2,0.1,0.0,0.0,0.0,0.0]]}}'
Request (POST /api/v1/simulation)
-
pv(required):hourly_kwh(8 760 numbers, kWh in each hour, January 1 00:00 first) ormonthly_profile(12 × 24 numbers: one typical day per month).faces(tilt, azimuth, kWp) answers 422not_supportedfor now. -
consumption:yearly_kwh(default 15 000) andev_km_per_day(an electric car, 0.19 kWh/km), spread over the year with Numsolar's household profile; orhourly_kwh(8 760 numbers, the whole load). -
prices:{"preset": "se_spot"}(the default) is the price area's (SE1–SE4, fromlocation) average Nord Pool price per month and clock hour over the last 365 days, plus a 0.05 SEK/kWh retailer markup, a 0.28 SEK/kWh grid fee, the 0.36 SEK/kWh energy tax and 25 % VAT, and a 600 SEK/month fee. Or your own:import_sek_per_kwhandexport_sek_per_kwh, each a number, 12 × 24 (month × clock hour) or 8 760 numbers (averaged per month × clock hour). Both takemonthly_fee_sekandpeak_charge_sek_per_kw_month(a power charge on each month's highest hourly grid import). -
battery:capacity_kwh,power_kw(default capacity × 0.5),round_trip_efficiency(0.9),soc_min(0.1),soc_max(1.0),strategy,arbitrage_min_spread_sek_per_kwh(0.2),peak_target_kw(null = automatic per month),priority(forcombined),frequency_reserve.
Battery strategies
-
self_consumption(default): stores the solar surplus and uses it when the sun is gone. Never charges from the grid. -
arbitrage: also charges from the grid in the cheapest hours of the day and uses the energy when the price is high, if the gain after losses is above the spread. Needs prices that change within the day. -
peak_shaving: keeps the highest hourly grid import of each month low. Only pays with a power charge. -
combined: power peaks first, then own solar, then cheap hours.
Each strategy plans one day at a time with that day's 24 prices (day-ahead, as a real battery controller), and the charge carries to the next day. The battery never exports to the grid.
frequency_reserve
is an electricity provider's battery program: the provider uses part of the battery for
grid services and pays by its terms. Off by default: the battery stays private. Send
{"program": "flower"}
(Flower: 57 SEK per kW of battery power a month, flower.se/hub, as of 2026-10-08) or your
provider's own terms ({"provider", "terms": {"fixed_sek_per_month", "sek_per_kwh_capacity_per_year", "sek_per_kw_per_year", "reserved_power_share", "reserved_capacity_share"}, "source_url", "as_of"}).
The reserved part (by default half the power) is not available to the strategy, and
yearly.total_yearly_benefit_sek
(the whole system) and yearly.battery.yearly_benefit_sek
(the battery only) add the program's income. The numbers are the provider's stated terms, not a promise.
Response
{"input": {…your body with the defaults filled in…},
"yearly": {"produced_kwh", "consumed_kwh", "imported_kwh", "exported_kwh",
"self_consumption_pct", "self_sufficiency_pct",
"cost_without_system_sek", "cost_with_system_sek", "savings_sek", "total_yearly_benefit_sek",
"battery": {"strategy", "capacity_kwh", "power_kw", "throughput_kwh", "grid_charged_kwh", "cycles",
"savings_sek", "self_consumption_savings_sek", "arbitrage_savings_sek", "peak_savings_sek",
"peak_charge_applies", "warranty": {"cycle_limit", "calendar_years", "years_to_cycle_limit"},
"frequency_reserve": null | {"provider", "as_of", "source_url", "reserve_kw", "reserve_kwh", "yearly_sek", …},
"yearly_benefit_sek"}},
"monthly": {"produced_kwh": [12], "imported_kwh": [12], "exported_kwh": [12], "battery_throughput_kwh": [12],
"peak_kw": [12] | null, …},
"model": {"engine": "solar-core src-…", "time_step": "hourly", "dispatch": "day_ahead_greedy", "horizon_hours": 24,
"prices": "month_x_hour_profile" | "flat", …},
"attribution": "…"}
battery.savings_sek
is the bill without the battery minus the bill with it (the same house and prices).
self_consumption_pct
is about the PV only (produced minus exported, over produced). self_sufficiency_pct
is the share of the use that neither the grid nor grid-charged battery energy covered.
POST /api/v1/simulation/battery-strategies
The same body plus {"compare": {"sizes_kwh": [0, 5, 10, 15, 20], "strategies": [...], "cost_sek_by_size": {"10": 85000}}}
(at most 25 sizes × strategies). The answer has a
matrix
(one row per size and strategy: savings, battery savings, cycles, grid-charged energy,
program income, and the total benefit: the whole system's savings plus the program income) and best: the best strategy for each size (the highest
savings, ties to the simpler strategy, only strategies that can apply to your prices;
the provider program is never chosen for you), the cell with the highest total benefit,
and the shortest payback when you send cost_sek_by_size.
Errors and limits
-
Errors:
invalid_json400,body_too_large413 (over 2 MB),invalid_parameter422 with thefield(for examplebattery.soc_min),not_supported422,rate_limited429,invalid_api_key401,prices_unavailable503 (no spot profile loaded for the zone yet; send your own prices or retry). -
Without a key: 10 requests and
50 cells per minute per IP for both routes. One
/simulationcall is 1 cell; a/battery-strategiescall is sizes × strategies cells. Every call counts as a request; cells are charged only when the simulation runs, so a refused, invalid or busy call costs no cells. With anx-api-key: 100 requests and 500 cells per minute. Over a limit: 429 withRetry-After; answers carryX-RateLimit-*andX-RateLimit-Cells-*headers. -
One
/simulationcall takes about 0.1–0.3 s; a full 5 × 4 comparison under 1 s.
Changelog
-
Simulation v1
(October 2026):
/api/v1/simulationand/battery-strategies; the four battery strategies and electricity-provider battery programs. - se-2026.10 (October 2026): first release; PVGIS 5.3, 1 306 cells, Sweden bbox 55.0–69.5 N, 10.5–24.5 E; Hay–Davies sky.