Simulation API

One year, hour by hour, of a house's energy flows and electricity bill, with and without a battery. The same engine as the Numsolar calculator: CPU only, nothing is stored.

Endpoint What it does
POST /api/v1/simulation One year, hour by hour, of a house's energy flows and electricity bill, with and without a battery.
POST /api/v1/simulation/battery-strategies The same simulation for many battery sizes × battery strategies, with the best choice per size.
GET /api/v1/simulation/openapi.json The same OpenAPI 3.1 document as /api/v1/pv-yield/openapi.json.

What is simulated

  1. One year of 365 days (the calendar of 2027), hour by hour. Your PV and use series as you send them: no panel ageing, no inverter clipping.
  2. The battery starts at its lowest charge on January 1. Its strategy plans each day with that day's 24 prices, like a real battery controller. The battery never sells to the grid.
  3. Every call also runs the same house without a battery, so battery.savings_sek is the battery's own part of the savings.

Endpoints

POST /api/v1/simulation

One year, hour by hour, of a house's energy flows and electricity bill, with and without a battery.

All energies are kWh, all money SEK. Fields we do not know are ignored. Defaults are in brackets.

Field Type Description
pvrequired object Your PV production, one of: 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 gets 422 not_supported for now.
location object {lat, lng, zone?}. zone is SE1–SE4; without it a point in Sweden gets the zone of its latitude. Needed for prices.preset and a battery program.
consumption object [{"yearly_kwh": 15000}] yearly_kwh (0–1 000 000) spread over the year with Numsolar's household profile, plus ev_km_per_day (0–300, an electric car at 0.19 kWh/km); or hourly_kwh (8 760 numbers, the whole use).
prices object [{"preset": "se_spot"}] The zone's 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, 25 % VAT and a 600 SEK monthly fee. Or your own: import_sek_per_kwh (required) and export_sek_per_kwh [0], each a number, 12 × 24 numbers or 8 760 numbers (averaged per month × clock hour). Both forms take monthly_fee_sek and peak_charge_sek_per_kw_month [0] (a power charge on each month's highest hourly import).
battery object [none] capacity_kwh (required in the object; 0 = no battery), power_kw [capacity × 0.5], round_trip_efficiency [0.9], soc_min [0.1], soc_max [1.0], strategy [self_consumption], arbitrage_min_spread_sek_per_kwh [0.2], peak_target_kw [null = automatic per month], priority (for combined), frequency_reserve [null].

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 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 each month's highest hourly import low. Pays only with a power charge.
  • combined: power peaks first, then own solar, then cheap hours.

Battery programs (frequency_reserve)

An electricity provider can use part of the battery for grid services and pay for it. Off by default: the battery stays private. Send {"program": "flower"} (Flower: 57 SEK per kW of battery power a month, flower.se/hub, terms as of 2026-10-08), or your provider's 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. The program's income is added to total_yearly_benefit_sek and battery.yearly_benefit_sek. The numbers are the provider's stated terms, not a promise.

Example

A ≈ 9 kWp south roof in Stockholm, a 15 000 kWh household, flat prices and a 10 kWh battery. Save the body as simulation.request.json:

simulation.request.json
{
  "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]
    ]},
  "consumption": {"yearly_kwh": 15000},
  "prices": {"import_sek_per_kwh": 2.0, "export_sek_per_kwh": 0.6},
  "battery": {"capacity_kwh": 10, "strategy": "self_consumption"}
}
curl
curl -s -X POST 'https://numsolar.com/api/v1/simulation' -H 'content-type: application/json' \
  -d @simulation.request.json
JavaScript
// body: the object in simulation.request.json, or your own
const res = await fetch("https://numsolar.com/api/v1/simulation", {
  method: "POST",
  headers: {"content-type": "application/json"},
  body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
console.log(data.yearly.savings_sek, "SEK saved a year");
200 OK
{
  "attribution": "Simulation: Numsolar solar-core (hourly energy flows, day-ahead greedy battery dispatch)",
  "input": {
    "battery": {
      "arbitrage_min_spread_sek_per_kwh": 0.2,
      "capacity_kwh": 10.0,
      "frequency_reserve": null,
      "peak_target_kw": null,
      "power_kw": 5.0,
      "priority": ["peak_shaving", "self_consumption", "arbitrage"],
      "round_trip_efficiency": 0.9,
      "soc_max": 1.0,
      "soc_min": 0.1,
      "strategy": "self_consumption"
    },
    "consumption": {
      "ev_km_per_day": 0,
      "kind": "household_model",
      "yearly_kwh": 1.5e4
    },
    "location": null,
    "prices": {
      "export": "flat",
      "export_avg_sek_per_kwh": 0.6,
      "import": "flat",
      "import_avg_sek_per_kwh": 2.0,
      "kind": "custom",
      "monthly_fee_sek": 0.0,
      "peak_charge_sek_per_kw_month": 0.0
    },
    "pv": {
      "kind": "monthly_profile",
      "yearly_kwh": 10988.6
    }
  },
  "model": {
    "ageing": "none within the simulated year (year 1 of a new system)",
    "battery_start": "at its SoC floor on January 1",
    "dispatch": "day_ahead_greedy",
    "engine": "solar-core src-92ea6fdb337b",
    "horizon_hours": 24,
    "prices": "flat",
    "simulated_days": 365,
    "simulated_year": 2027,
    "time_step": "hourly"
  },
  "monthly": {
    "battery_throughput_kwh": [0.0, 28.7, 273.1, 226.0, 131.3, 201.1, 274.3, 282.2, 270.0, 131.4, 0.0, 0.0],
    "consumed_kwh": [1949.8, 1640.7, 1344.6, 637.7, 463.2, 593.5, 747.4, 909.3, 1184.8, 1515.6, 1918.2, 2098.3],
    "cost_with_system_sek": [3416.0, 2403.0, 1079.1, -255.3, -635.8, -603.7, -485.2, -104.9, 690.0, 1770.9, 3260.5, 3849.4],
    "cost_without_system_sek": [3899.6, 3281.4, 2689.3, 1275.3, 926.4, 1186.9, 1494.9, 1818.5, 2369.6, 3031.3, 3836.5, 4196.6],
    "exported_kwh": [0.0, 0.0, 37.7, 558.3, 1059.7, 1006.1, 838.0, 570.5, 180.3, 0.0, 0.0, 0.0],
    "grid_charged_kwh": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
    "imported_kwh": [1708.0, 1201.5, 550.8, 39.8, 0.0, 0.0, 8.8, 118.7, 399.1, 885.4, 1630.2, 1924.7],
    "peak_kw": null,
    "produced_kwh": [241.8, 442.4, 861.8, 1188.0, 1537.6, 1620.0, 1605.8, 1388.8, 996.0, 644.8, 288.0, 173.6]
  },
  "yearly": {
    "battery": {
      "arbitrage_savings_sek": 0.0,
      "capacity_kwh": 10.0,
      "cycles": 202.0,
      "frequency_reserve": null,
      "grid_charged_kwh": 0.0,
      "peak_charge_applies": false,
      "peak_savings_sek": 0.0,
      "power_kw": 5.0,
      "savings_sek": 2424.1,
      "self_consumption_savings_sek": 2424.1,
      "strategy": "self_consumption",
      "throughput_kwh": 1818.1,
      "warranty": {
        "calendar_years": 10,
        "capped": true,
        "cycle_limit": 6000,
        "years_to_cycle_limit": 10.0
      },
      "yearly_benefit_sek": 2424.1
    },
    "consumed_kwh": 15003.1,
    "cost_with_system_sek": 14383.9,
    "cost_without_system_sek": 30006.2,
    "exported_kwh": 4250.6,
    "imported_kwh": 8467.1,
    "produced_kwh": 10988.6,
    "savings_sek": 15622.3,
    "self_consumption_pct": 61.3,
    "self_sufficiency_pct": 43.6,
    "total_yearly_benefit_sek": 15622.3
  }
}
  • Rounding: kWh, kW, SEK and cycles to 0.1; percentages to 0.1 %.
  • savings_sek is the whole system (PV + battery) against no system. battery.savings_sek is the battery's part, split into self_consumption_savings_sek, arbitrage_savings_sek and peak_savings_sek.
  • self_consumption_pct is about the PV only: (produced − exported) ÷ produced. self_sufficiency_pct is the share of the use that neither the grid nor grid-charged battery energy covered.
  • battery.cycles = throughput ÷ the usable capacity. warranty: the warranty's cycle limit and years; years_to_cycle_limit is the years until the cycle limit at this use; capped: true means it is cut to the calendar years.
  • monthly.peak_kw: each month's highest hourly import, only with a power charge (else null).
  • model.engine changes when the engine changes: compare it to know if an old answer is still current.

POST /api/v1/simulation/battery-strategies

The same simulation for many battery sizes × battery strategies, with the best choice per size.

The same body plus compare: sizes_kwh [0, 5, 10, 15, 20], strategies [all four] and cost_sek_by_size (optional, the battery's price per size). At most 25 sizes × strategies. Every size keeps the battery's power-to-capacity ratio and its other fields.

battery_strategies.request.json
{
  "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]
    ]},
  "consumption": {"yearly_kwh": 15000},
  "prices": {"import_sek_per_kwh": 2.0, "export_sek_per_kwh": 0.6, "peak_charge_sek_per_kw_month": 50},
  "battery": {"capacity_kwh": 10},
  "compare": {
    "sizes_kwh": [0, 5, 10],
    "strategies": ["self_consumption", "peak_shaving"],
    "cost_sek_by_size": {"5": 45000, "10": 75000}
  }
}
curl
curl -s -X POST 'https://numsolar.com/api/v1/simulation/battery-strategies' -H 'content-type: application/json' \
  -d @battery_strategies.request.json
200 OK (2 of 6 matrix rows)
{
  "attribution": "Simulation: Numsolar solar-core (hourly energy flows, day-ahead greedy battery dispatch)",
  "best": {
    "by_payback": {
      "cost_sek": 4.5e4,
      "payback_years": 28.5,
      "size_kwh": 5.0,
      "strategy": "self_consumption"
    },
    "by_size": [
      {
        "size_kwh": 0.0,
        "strategy": "self_consumption"
      },
      {
        "size_kwh": 5.0,
        "strategy": "self_consumption"
      },
      {
        "size_kwh": 10.0,
        "strategy": "self_consumption"
      }
    ],
    "by_total_benefit": {
      "size_kwh": 10.0,
      "strategy": "self_consumption",
      "total_yearly_benefit_sek": 16121.8
    }
  },
  "input": {
    "battery": {
      "arbitrage_min_spread_sek_per_kwh": 0.2,
      "capacity_kwh": 10.0,
      "frequency_reserve": null,
      "peak_target_kw": null,
      "power_kw": 5.0,
      "priority": ["peak_shaving", "self_consumption", "arbitrage"],
      "round_trip_efficiency": 0.9,
      "soc_max": 1.0,
      "soc_min": 0.1,
      "strategy": "self_consumption"
    },
    "compare": {
      "cost_sek_by_size": {
        "10": 7.5e4,
        "5": 4.5e4
      },
      "sizes_kwh": [0.0, 5.0, 10.0],
      "strategies": ["self_consumption", "peak_shaving"]
    },
    "consumption": {
      "ev_km_per_day": 0,
      "kind": "household_model",
      "yearly_kwh": 1.5e4
    },
    "location": null,
    "prices": {
      "export": "flat",
      "export_avg_sek_per_kwh": 0.6,
      "import": "flat",
      "import_avg_sek_per_kwh": 2.0,
      "kind": "custom",
      "monthly_fee_sek": 0.0,
      "peak_charge_sek_per_kw_month": 50.0
    },
    "pv": {
      "kind": "monthly_profile",
      "yearly_kwh": 10988.6
    }
  },
  "matrix": [
    {
      "battery_savings_sek": 0.0,
      "best_for_size": true,
      "cycles": 0.0,
      "frequency_reserve_net_sek": 0.0,
      "grid_charged_kwh": 0.0,
      "savings_sek": 13513.3,
      "size_kwh": 0.0,
      "strategy": "self_consumption",
      "total_yearly_benefit_sek": 13513.3
    },
    {
      "battery_savings_sek": 0.0,
      "best_for_size": false,
      "cycles": 0.0,
      "frequency_reserve_net_sek": 0.0,
      "grid_charged_kwh": 0.0,
      "savings_sek": 13513.3,
      "size_kwh": 0.0,
      "strategy": "peak_shaving",
      "total_yearly_benefit_sek": 13513.3
    }
  ],
  "model": {
    "ageing": "none within the simulated year (year 1 of a new system)",
    "battery_start": "at its SoC floor on January 1",
    "dispatch": "day_ahead_greedy",
    "engine": "solar-core src-92ea6fdb337b",
    "horizon_hours": 24,
    "prices": "flat",
    "simulated_days": 365,
    "simulated_year": 2027,
    "time_step": "hourly"
  }
}
  • matrix: one row per size × strategy. savings_sek and total_yearly_benefit_sek are the whole system's; battery_savings_sek is the battery's part.
  • best.by_size: per size, the strategy with the highest savings (ties go to the simpler one). Only strategies that can work with your prices compete; a battery program is never chosen for you.
  • best.by_total_benefit: the cell with the highest total benefit. best.by_payback: the shortest payback, only with cost_sek_by_size.

GET /api/v1/simulation/openapi.json

The same OpenAPI 3.1 document as /api/v1/pv-yield/openapi.json.

Open the document.

Errors

Code Status Retry-After When
invalid_json 400 – The body is not valid JSON.
body_too_large 413 – The body is larger than 2 MB.
invalid_parameter 422 – A field is missing or wrong. field is its JSON path (for example battery.soc_min).
not_supported 422 – pv.faces is not supported yet: send pv.hourly_kwh or pv.monthly_profile.
invalid_api_key 401 – The x-api-key header has a key we do not know. A wrong key does not fall back to the free limits.
rate_limited 429 seconds until the next clock minute Over the request or the cell limit of this minute, or 10 failed calls (a wrong key, a body that is not valid JSON or too large) from your IP this minute: then every call of that IP gets 429 until the minute ends.
prices_unavailable 503 60 prices.preset: se_spot and no spot price profile is loaded for the zone. Send your own prices, or try again.
busy 503 1 The server already runs its maximum of simulations. Try again in a second.

A body that is not valid JSON (400) or is larger than 2 MB (413) is refused before the limits: it uses no request and no cell, but it counts as a failed call of your IP (see Limits). One simulation takes about 0.1–0.3 s; a full 5 × 4 comparison less than 1 s.