Solar Yield Estimator avatar

Solar Yield Estimator

Pricing

from $200.00 / 1,000 production estimates

Go to Apify Store
Solar Yield Estimator

Solar Yield Estimator

Turn coordinates into a complete solar business case. Monthly production, hourly self-consumption, payback, NPV, IRR and Monte Carlo risk. Models each roof face separately and worldwide. No API keys, free data sources only.

Pricing

from $200.00 / 1,000 production estimates

Rating

0.0

(0)

Developer

Ernest Marzá

Ernest Marzá

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

2 days ago

Last modified

Share

Solar PV Yield, Self-Consumption & Payback Estimator

Coordinates in, payback out.

An Apify Actor. Give it coordinates and an array description; it returns monthly and annual production, the optimal tilt, an hour-by-hour year, how much of that energy you would actually use, and the full financial case — savings, payback, NPV, IRR and LCOE — in any currency.

No API keys. Every upstream service it uses is free and public. Worldwide coverage.

Plenty of tools tell you how many kWh a roof makes; PVGIS does it for free and this Actor calls it. What is harder, and what this exists for, is everything after that: how much of that energy you actually use, and therefore whether the system is worth building.

  • Self-consumption is simulated hour by hour against seven demand profiles, not assumed as a flat percentage. When you use power decides the economics.
  • Multi-string roofs — real roofs have two faces at different tilts, and that changes the shape of the day, not just the annual total.
  • Answers come with a probability: "6.1 years" becomes "between 5.3 and 6.1 in eight scenarios out of ten".

What it answers

QuestionWhere in the output
How much will this array make each month?production.monthly[]
How good is this site, independent of array size?production.specificYieldKwhPerKwp
What's the bad-year number?production.p90Kwh
Am I pointing the panels the right way?insights.optimalGeometry
Which roof face contributes what?production.arrays[]
How much of it will I actually use?selfConsumption
What is it worth in dollars?productionValue, economics.inOtherCurrencies
When does it pay for itself?economics.simplePaybackYears, discountedPaybackYears
Is it a good investment?economics.npv, irrPercent, lcoePerKwh
Is a battery worth it?insights.batterySizing
How many panels should I actually fit?sizingSweep
How much does the inverter throw away?clipping
How likely is it to pay off?risk
What does the terrain block?horizon
Have I sized this right?insights.sizing

Minimal input

{ "latitude": 37.3891, "longitude": -5.9845, "panelCount": 12, "panelWattPeak": 450 }

That returns production only. Add annualConsumptionKwh and the self-consumption and financial sections appear:

{
"latitude": 37.3891,
"longitude": -5.9845,
"panelCount": 12,
"panelWattPeak": 450,
"tilt": 30,
"azimuth": 180,
"annualConsumptionKwh": 9000,
"loadProfile": "poolVilla",
"currency": "EUR",
"electricityPricePerKwh": 0.22,
"feedInTariffPerKwh": 0.06,
"batterySizing": true
}

Full field documentation lives in .actor/input_schema.json and renders as a form in the Apify console.

Multi-string systems

Real roofs are rarely one plane. Pass arrays to model each face separately — anything you omit from a sub-array falls back to the top-level field of the same name:

{
"latitude": 37.3891,
"longitude": -5.9845,
"arrays": [
{ "label": "East roof", "panelCount": 10, "panelWattPeak": 450, "tilt": 30, "azimuth": 90 },
{ "label": "West roof", "panelCount": 10, "panelWattPeak": 450, "tilt": 30, "azimuth": 270 }
]
}

Each sub-array is modelled independently and the results are combined, with a per-face breakdown in production.arrays[]. This is not cosmetic: an east array and a west array of the same total size produce a broad twin-peaked day rather than one midday spike, and self-consumption turns entirely on that shape. Summing two separate runs would give you the right annual kWh and the wrong economics.

The aggregation is not uniformly additive, which is easy to get subtly wrong:

  • Energy adds.
  • Irradiance is per square metre and does not; it is weighted by each face's kWp.
  • Percentage losses are re-weighted by the energy each face actually contributes.
  • Standard deviations add linearly, not in quadrature. Sub-arrays sit under the same sky, so their year-to-year swings are fully correlated. Treating them as independent would understate the real spread and flatter P90.
  • The reported geometryUsed is the energy-weighted mean orientation — a summary, not a real plane. Azimuths are averaged as unit vectors so the 0/360 wrap does not break them.

useOptimalAngles and the sensitivity sweep apply to a single orientation only; with several sub-arrays they are skipped and a warning explains why.

Azimuth convention

azimuth is a compass bearing: 0 = North, 90 = East, 180 = South, 270 = West, in both hemispheres. This is not the convention PVGIS uses internally (it measures from south); the translation happens inside the Actor. Southern-hemisphere arrays should face 0.

How it works

coordinates ─┬─> PVGIS v5.3 (SARAH3, 2005-2023, terrain shading) ──> monthly + annual + P90
└─> NASA POWER climatology + own irradiance model ──> fallback, global
v
hourly shape (8760 h, local time)
synthetic demand profile (7 shapes) ──> hour-by-hour dispatch
(solar → load → battery → grid)
v
self-consumption ──> 25-year cash flow ──> payback / NPV / IRR / LCOE

Two design decisions worth knowing about:

The hourly series borrows its shape from one year and its magnitude from fifteen. A single year of weather is a poor estimate of the long-run average, but it is the only thing that carries a realistic hour-to-hour shape. So the Actor takes the hourly profile from one year, then rescales it month by month to match the multi-year PVcalc averages. The monthly table and the hourly series can never contradict each other.

Self-consumption is simulated, not assumed. A flat annual consumption figure tells you nothing about how much solar you'll actually use — that depends entirely on when you use electricity. The seven loadProfile shapes exist because an empty-during-the-day household and a villa with a mid-day pool pump have wildly different economics from the same array.

Inverter clipping

Set inverterAcKw (or dcToAcRatio) and the AC ceiling is applied hour by hour, with monthly and annual figures recomputed from the clipped series so the tables cannot contradict each other. PVGIS models the array, not the inverter: it never caps the midday peak, which inflates exactly the hours that matter most in summer.

One honest caveat, stated in the output too: the series is hourly average energy, so a brief noon spike gets smoothed into its hour and disappears. The clipping figure is therefore a floor — measured clipping at 5- or 15-minute resolution is consistently higher.

Risk, not just a single number

riskAnalysis resamples the three assumptions that actually drive the result — electricity price inflation, annual yield (using the interannual spread PVGIS measured at this location) and degradation — and reports NPV P10/P50/P90 plus the probability of paying back within 10 and 15 years. A lone "6.1 years" implies a precision nobody has.

The generator is seeded, so identical input always returns identical output. An unseeded Monte Carlo would give a different answer on every run and neither could be trusted.

How many panels

sizingSweep models a range of system sizes off the same hourly series (production scales linearly with kWp for a fixed orientation, so it costs no extra upstream requests) and reports NPV, payback and self-sufficiency for each.

Watch optimumAtRangeEdge. When it is true, NPV was still climbing at the largest size tested — there is no optimum in range, and the real limit is roof space or budget rather than economics. The sweep says so instead of pretending the edge of its own range is a recommendation. bestPaybackPanelCount is usually far smaller than the NPV optimum: best total value and best value per euro are different questions.

Fallback behaviour

PVGIS covers most of the populated world (SARAH3 for Europe/Africa/Asia, ERA5 elsewhere). Where it cannot serve a point, the Actor models production itself from NASA POWER monthly climatology using published correlations — Erbs for the diffuse split, Collares-Pereira & Rabl for the hourly distribution, Liu & Jordan isotropic transposition, and an NOCT cell temperature model. That path is flagged in production.source and raises a warning, since it has no terrain shading and no interannual spread to derive P90 from.

Accuracy

Two checks were run against real data rather than assumed.

Against a real installation. A 20-panel residential system in Spain, split across two roof faces at different tilts, with four years of logged inverter data. Modelled production came out +10.7% above measured across months with complete data, with the seasonal shape tracking closely. Note that the panel wattage was not known and had to be assumed, so part of that gap is the assumption rather than the model. The rest is what you would expect from a four-year-old array: soiling, degradation and shading that a digital elevation model does not see.

Practical consequence: the 14% default for systemLoss is a new-installation number. For an existing array, 18-20% will land closer.

Own model against PVGIS. At the same site, the NASA POWER fallback path produced 8162 kWh/year against PVGIS's 8392 — a -2.7% difference — and picked an optimal tilt of 34° against PVGIS's 37°. The fallback is a genuine model, not a placeholder.

A PVGIS bug this Actor works around

PVGIS's optimalangles=1 is unreliable in the southern hemisphere. At Sydney it returns a slope of -1° facing -180° worth 7217 kWh/year — while a plain 30° north-facing array at the same coordinates yields 8406 kWh. Its optimiser does not appear to search across the equator-facing direction below the equator.

The Actor detects this two ways: below the equator it never trusts optimalangles and runs its own two-phase sweep instead; above it, it uses the fast built-in answer but rejects any "optimum" that comes out worse than the configured geometry. At Sydney this recovers 8420 kWh — a 17% correction — and agrees with the independent NASA model's 34°.

Output

One dataset item per run, plus the same object in the key-value store as OUTPUT. With includeHourlySeries, the 8760-hour series is written to the key-value store as both hourly-series (JSON) and hourly-series.csv, and the URLs are returned in hourlySeries.

Running it

Local test server with a web UI

Development aid only. It is not part of the deployed Actorpublic/ is excluded from the Docker image, and the Actor itself never serves HTTP.

npm install
npm run serve # http://127.0.0.1:3000

Opens a form where you can add and remove sub-arrays, run the model and see the monthly chart, the per-string breakdown and the financials. PORT and HOST are honoured.

It runs the same runEstimate() the Actor callssrc/engine.ts holds the entire model and knows nothing about Apify, so main.ts (platform) and server.ts (local) cannot drift apart. There is no second implementation to keep in sync.

RouteWhat it does
GET /the UI
POST /api/estimateJSON body in, full result out
GET /api/estimate?latitude=..&longitude=..same, via query string, for curl
GET /api/hourly.csv / .jsonlast run's 8760-hour series
GET /healthliveness
curl -s -X POST localhost:3000/api/estimate -H 'content-type: application/json' -d '{
"latitude": 37.3891, "longitude": -5.9845,
"arrays": [{"panelCount": 8, "tilt": 30, "azimuth": 180},
{"panelCount": 12, "tilt": 10, "azimuth": 180}],
"annualConsumptionKwh": 9000, "loadProfile": "poolVilla"
}' | jq '.production.annualKwh, .economics.simplePaybackYears'

Bad input returns 400 with a message naming the field; an upstream failure returns 502.

Standby mode (the Actor as a live API)

usesStandbyMode is enabled in actor.json. In Standby the container stays warm and answers over HTTP instead of doing one job and exiting — roughly 1.4 s per request against about 4 s for a cold run, with the difference being container start-up. That is the difference between a batch job and something an installer's website can call while a visitor waits.

GET /estimate?latitude=37.3891&longitude=-5.9845&panelCount=12
POST /estimate (JSON body, same shape as the Actor input)
GET /health

It runs the same runEstimate() as the batch path, so the two modes cannot disagree. The 8760-hour series is deliberately not served here — it would dominate a response meant to come back immediately. Use a normal run for that.

As an Actor, locally

npm run build
mkdir -p storage/key_value_stores/default
echo '{"latitude":37.3891,"longitude":-5.9845,"panelCount":12,"panelWattPeak":450}' \
> storage/key_value_stores/default/INPUT.json
node dist/main.js
# result in storage/key_value_stores/default/OUTPUT.json

Deploying

npm install -g apify-cli
apify login # interactive, needs your API token
apify push

apify validate-schema passes for both the input and dataset schemas, and apify run executes the Actor exactly as the platform does. Both are worth running before a push.

Output format. The Actor emits one dataset item: a nested JSON object, plus the same object in the key-value store under OUTPUT. Nested JSON is fine — a dataset item is a JSON object, not text, and the only hard limit is 9 MB per item, which this is nowhere near.

The catch is tabular rendering. Console tables and CSV/Excel exports want flat rows, and there is no automatic flattening. That is what .actor/dataset_schema.json handles: it defines four views (overview, monthly, arrays, cashflow) so the 12 months and the 25 cash-flow years appear as rows rather than one collapsed cell.

Two undocumented details cost real time here, both verified by probing the live dataset API:

unwind only resolves top-level field names. It cannot reach production.monthly; pointing a view at a dotted path silently yields an empty table. So the item also carries flat copies of the three tabular arrays at the top level — monthlyProduction, subArrays and cashflow — purely so the views can unwind them. The nested originals are untouched and remain the ones to read when consuming the JSON.

Transformations apply in the order flattenfieldsunwind. The consequence is counterintuitive: fields must name the array being unwound, not the child keys, because at filter time the children do not exist yet. Listing them returns a single empty row.

?unwind=monthlyProduction&fields=month,monthName -> [{}] wrong
?unwind=monthlyProduction&fields=monthlyProduction -> 12 rows right

Column selection for unwound views is therefore left entirely to display.properties, which is applied to the final rows. The overview view is unaffected by this because flatten runs before fields, so its dotted paths already exist when the filter runs.

Views only affect the Console UI — they do not change what a user downloads as CSV, which is precisely why the hourly series is written to the key-value store as a ready-made CSV instead of being pushed through the dataset.

The Docker image builds from .actor/Dockerfile (base apify/actor-node:22, multi-stage). A typical run takes 2-4 seconds; the sensitivity sweep adds roughly 30.

To exercise the image locally, write the input inside the container rather than bind-mounting it. Mounting a host directory at /usr/src/app/storage/key_value_stores/default does not work: the SDK purges the default key-value store on startup and deletes the INPUT.json you just put there.

docker build -f .actor/Dockerfile -t solar-yield-estimator:local .
docker run --rm --entrypoint sh solar-yield-estimator:local -c '
mkdir -p storage/key_value_stores/default
echo "{\"latitude\":37.3891,\"longitude\":-5.9845,\"panelCount\":12,\"panelWattPeak\":450}" \
> storage/key_value_stores/default/INPUT.json
node dist/main.js'

Limitations

  • Timezone is estimated from longitude (round(lon/15)), ignoring DST and political boundaries. Worst case is an hour of misalignment between production and demand, which moves self-consumption by a point or two.
  • P90 is resource-only — it reflects year-to-year weather variability, not model or equipment uncertainty, so it is narrower than a bankable P90 from a formal yield assessment.
  • Demand profiles are synthetic. They are realistic shapes, not your meter data.
  • Degradation is applied to a fixed year-1 energy split. Later years scale the same self-consumed/exported ratio rather than being re-simulated.
  • No shading model beyond terrain. A tree or a neighbouring building is not represented.