Solar Yield Estimator
Pricing
from $200.00 / 1,000 production estimates
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á
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
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
| Question | Where 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
geometryUsedis 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│vhourly shape (8760 h, local time)│synthetic demand profile (7 shapes) ──> hour-by-hour dispatch│ (solar → load → battery → grid)vself-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 Actor — public/ is excluded from
the Docker image, and the Actor itself never serves HTTP.
npm installnpm 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 calls — src/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.
| Route | What it does |
|---|---|
GET / | the UI |
POST /api/estimate | JSON body in, full result out |
GET /api/estimate?latitude=..&longitude=.. | same, via query string, for curl |
GET /api/hourly.csv / .json | last run's 8760-hour series |
GET /health | liveness |
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=12POST /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 buildmkdir -p storage/key_value_stores/defaultecho '{"latitude":37.3891,"longitude":-5.9845,"panelCount":12,"panelWattPeak":450}' \> storage/key_value_stores/default/INPUT.jsonnode dist/main.js# result in storage/key_value_stores/default/OUTPUT.json
Deploying
npm install -g apify-cliapify login # interactive, needs your API tokenapify 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 flatten → fields → unwind. 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/defaultecho "{\"latitude\":37.3891,\"longitude\":-5.9845,\"panelCount\":12,\"panelWattPeak\":450}" \> storage/key_value_stores/default/INPUT.jsonnode 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.