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_kwp is per kWp of installed DC peak power. Irradiation is kWh/m².
  • 14 % system loss by default (PVGIS's default: inverter, cabling, soiling, mismatch). system_loss rescales exactly by (1 − loss) / 0.86.
  • Free horizon: terrain and nearby obstacles are not modelled; pass your own shading factor (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_parameter 422, outside_coverage 422 (outside the Sweden bbox, or at sea), rate_limited 429, invalid_api_key 401, dataset_unavailable 503.
  • Without a key: 60 requests per minute per IP (10 for /matrix). With an x-api-key header: 600 per minute (60 for /matrix). Over the limit: 429 with Retry-After; every answer carries X-RateLimit-Limit / X-RateLimit-Remaining.
  • Answers are deterministic for a dataset version: Cache-Control: public, max-age=86400 and an ETag; send If-None-Match to 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) or monthly_profile (12 × 24 numbers: one typical day per month). faces (tilt, azimuth, kWp) answers 422 not_supported for now.
  • consumption: yearly_kwh (default 15 000) and ev_km_per_day (an electric car, 0.19 kWh/km), spread over the year with Numsolar's household profile; or hourly_kwh (8 760 numbers, the whole load).
  • prices: {"preset": "se_spot"} (the default) is the price area's (SE1–SE4, from location) 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_kwh and export_sek_per_kwh, each a number, 12 × 24 (month × clock hour) or 8 760 numbers (averaged per month × clock hour). Both take monthly_fee_sek and peak_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 (for combined), 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_json 400, body_too_large 413 (over 2 MB), invalid_parameter 422 with the field (for example battery.soc_min), not_supported 422, rate_limited 429, invalid_api_key 401, prices_unavailable 503 (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 /simulation call is 1 cell; a /battery-strategies call 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 an x-api-key: 100 requests and 500 cells per minute. Over a limit: 429 with Retry-After; answers carry X-RateLimit-* and X-RateLimit-Cells-* headers.
  • One /simulation call takes about 0.1–0.3 s; a full 5 × 4 comparison under 1 s.

Changelog

  • Simulation v1 (October 2026): /api/v1/simulation and /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.