Replies: 2 comments 3 replies
|
Thanks @LesIT1, this is very close to what I had been sketching for this phase. Go from my side, and yes, I'll validate each step. On validation: A- Two-hybrid site: two Growatt MOD XH hybrids (10 kW and 9 kW AC, ~10.7 and ~9.7 kWp, ~25 and ~20 kWh), one grid connection. It was only commissioned as a dual setup on 28/09, so history for the "handful of real days" in PR 3 will build up over the coming weeks. I'll start collecting per-inverter PV, per-battery power and SoC, load and prices now. B- Mixed site: a string inverter, an AC-coupled battery and a hybrid plug-in battery with its own PV. That's three inverters of three different shapes in your model, so I can offer it for the mixed-site path too. A few points I'd like to raise:
Happy to help with the cookbook side once PR 3 is in, since the setpoint mapping per inverter is exactly what we run in HA today. |
|
I have an Enphase micro-inverter system with 32 IQ7+ units, grouped physically as 12 east-facing and 20 south-west-facing panels. In Home Assistant I can already measure the actual AC production of both orientation groups separately by summing the individual micro-inverter sensors. For my use case I would not want to model 32 micro-inverters as 32 EMHASS inverters. What is interesting is preserving the two PV plants/orientations separately:
I noticed PR 2 in this proposal would stop the forecaster from summing plants and retain per-plant PV series ( Would that design also allow a micro-inverter installation like this to be represented as two logical PV plants behind the same AC site, without modelling every micro-inverter individually? Longer term, could the separate measured PV sensors also be used for per-plant forecast adjustment/bias learning rather than adjusting only the aggregate PV forecast? I can provide real Home Assistant measurements for both orientation groups if that would be useful for validation. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This is the design thread I promised in #870 for the inverter split, phase 3 of the plan from #610. Phase 1 (#1032) gave EMHASS any number of batteries. This phase gives it any number of inverters, so a site with two hybrid inverters, each with its own battery and strings, stops being planned as one big DC bus. I'd like to take it ahead of phase 2 (V2G, intermittent connection) because the two-hybrid site can validate it once a few weeks of history are in, with a mixed site alongside it. Phase 2 would then put an intermittent battery on an inverter like any other battery, so nothing here has to be undone for it. I've asked @scruysberghs to validate each step on that site, and on a mixed site (a string inverter, an AC-coupled battery and a hybrid plug-in with its own PV) that covers the other two shapes, since I can't reproduce either here. @ffrog8 tagging as promised on #1020, your R1 to R6 and R8 are covered near the end. @davidusb-geek nothing gets built until you're happy with the shape, and it's cheaper to change now than after the code exists.
Edited after a round of review: the first version had an interim mode in PR 2 that couldn't work, mishandled
set_nodischarge_to_gridon mixed sites, and treated PV forecasts as DC numbers when they are AC-side. All three are fixed below. Edited again after scruysberghs's reply: a priced spill instead of a hard infeasibility on hybrids with curtailment off, an efficiency warning, aninverterattribute on the battery sensors, an opt-in cross-inverter lock on the later list, and the mixed site in the validation. Edited after renaatdb's comment: his Enphase site added to the PR 2 check, and the index lists defined at one inverter.What the model does today
One electrical bus, whichever way you configure it. With
inverter_is_hybridall PV and all batteries sit on one DC bus behind one virtual inverter with one AC cap per direction and one efficiency pair. AC-coupled (the default) puts PV, batteries, loads and grid on one AC bus with no inverter cap at all. PV reaches the optimizer as one aggregate series because every forecast method sums its plants before returning, and batteries are summed before they enter either balance.Where that is in the code (master 237d0fe)
p_pv - curtailment + sum(battery power) == p_dc_ac - p_ac_dc(optimization.py:2925-2932), oneis_dc_sourcingbinary and per-direction caps (2941-2942). The cap isinverter_ac_output_max; only a config without that key (REST-built, since config_defaults ships 5000) falls back to the sum of everypv_inverter_modelentry's Paco, or to 0 W (2867-2899).inverter_efficiency_dc_acon top, which is harmless at the shipped default of 1.0.solar_forecast_kwpfor every plane at 824). A runtimepv_power_forecastis one flat list.set_nocharge_from_gridcompares aggregate charge to aggregate PV (2993).So on a two-hybrid site the plan can move PV from inverter A into battery B as a lossless DC transfer, and it can overload inverter A as long as the sum stays under the sum of the caps. That is a planning error rather than a dispatch problem, so I don't think it can be left to an HA automation the way David suggested for the per-phase limits in #622.
Target model
Explicit inverters, and that's the only new device kind. An inverter is a DC/AC converter with its own DC bus, its own AC caps and efficiencies, and zero or more PV plants and zero or more batteries attached. This is the three-collection shape @scruysberghs described on #1020 (inverters, strings, batteries), with PV plants and batteries as the two existing lists and inverters as the new one. Today's hybrid formulation is the template, replicated per inverter i:
sum(assigned PV) - curtailment_i + sum(assigned battery power) == p_dc_ac_i - p_ac_dc_i(with a pricedspill_iin place ofcurtailment_ion a hybrid that has curtailment off, see below)p_inverter_i == p_dc_ac_i * eff_dc_ac_i - p_ac_dc_i / eff_ac_dc_i, per-direction caps and a direction binary, as todaysum(p_inverter_i) - deferrables - load + grid == 0, in place of the singlep_hybrid_inverterPV from inverter A can still end up in battery B, but only as DC to AC to DC through both efficiencies and both caps, which is what physically happens. And each inverter's AC limit binds on its own.
Three device shapes fall out without a mode flag:
p_ac_dc_iis fixed to zero, because with nothing on the DC side to absorb it the only thing AC-to-DC could do is burn energy through a round trip, which the solver would happily do at negative prices. No direction binary on these. If curtailment is off for that inverter, its PV series is clipped toinverter_ac_output_max / inverter_efficiency_dc_ac, the same DC-side bound the hybrid path puts onp_dc_ac, before it enters the problem, otherwise a forecast above the cap is infeasible with nothing to absorb it. A hybrid with curtailment off is not clipped. PV above that bound has to go into its own battery, and when the battery can't take it either, the overflow goes to a per-inverter spill variable, bounded by that inverter's PV and priced at a fixed multiple of the largest absolute price in the horizon, import or export: the same 100x factor that Make set_battery_first_priority a soft penalty so it cannot cause infeasibility (#1002) #1014's battery-first penalty and the softsoc_finaltarget already use, with the same small floor so an all-zero tariff can't make it free. That puts it above any realistic export, stress or battery weight cost and at or above the terminal-SoC penalty, so the plan spills only when the battery and the AC path are both full. Like thesoc_finalpenalty it is a soft cost, so the solve never fails on it. The price is a nonneg scalar Parameter set every solve, like thesoc_finalpenalty, so it stays above every price in the horizon across warm-started re-solves. It is a result column,P_PV_spill_{i}, so the plan shows where it expects inverter i to clip and the per-inverter DC balance still holds in the CSV and the plan API, and a solve with non-zero spill logs one line per inverter naming each interval that spills and the energy in it, so it lines up against the forecast interval that caused it. That is the Make set_battery_first_priority a soft penalty so it cannot cause infeasibility (#1002) #1014 direction rather than a hard infeasibility, which matters with Solcast and solar.forecast since neither is clipped per inverter. One inverter keeps today's behaviour, where such an interval is infeasible; the same spill there would be its own small PR.p_dc_ac_i <= E[k] * cap_out_i / eff_dc_ac_iandp_ac_dc_i <= (1 - E[k]) * cap_in_i * eff_ac_dc_i, so no extra binary is needed and the inverter can't run both ways at once. It matches today's AC-coupled battery when its caps are at or above the battery's power limits (including any runtimebattery_*_power_maxoverride), its efficiencies are 1.0 and its stress cost is 0. On a mixed site that means those three are lists, not a broadcast scalar, or the hybrid's values land on the battery-only unit too.Two hybrids can still round-trip energy through the AC bus (A converting DC to AC while B converts AC to DC, then swapping), and at a negative import price the solver will import to burn the battery and conversion losses, because at that price the burn is revenue. That widens a class the model already has since #1032, where two batteries on one bus can charge and discharge against each other. The caps bound it, and a battery weight above the round-trip loss fraction times the most negative import price removes it. The defaults are zero weights, so the docs have to say that, as #1032's docs already do for cross-battery churn. The 1.0 inverter efficiency defaults are a different problem: at 1.0 the leg between units is lossless, so PV can move from one unit into another's battery at no cost and the plan treats that routing as an arbitrary tie, which the hardware isn't. PR 3 logs a warning for that, once when the Optimization object is built (not in the constraint builder, so the relaxed retry and a horizon resize don't repeat it), when any inverter's DC-to-AC efficiency and any other inverter's AC-to-DC efficiency are both still 1.0, string inverters excluded on the AC-in side since theirs is fixed to zero, and the message names the battery weights as the cure for the churn.
What the PV series means
Every forecast method returns AC-side power. pvlib simulates each plant with its own
pv_inverter_modelentry and returns that model's AC output, clipped at its Paco. Solcast and solar.forecast estimate AC. The one-inverter hybrid path already puts that number on the DC bus and applies its efficiency on top, and this design keeps that convention rather than fixing it here: one inverter stays unchanged, and above one inverter each plant's series enters its inverter's DC bus the same way. With the 1.0 efficiency defaults that is one clip inside pvlib plus the inverter cap. Settinginverter_efficiency_dc_acbelow 1 on a pvlib plant stacks it on pvlib's own inverter model, as it does today.So
pv_inverter_modelkeeps its pvlib role (the forecast-side model for that plant) andinverter_ac_output_maxplus the efficiencies are the dispatch-side limits. They describe the same physical box and should agree. Two strings on one hybrid are simulated by pvlib as two separate inverters, each clipped at its own entry's Paco, the same as at one inverter today; the shared cap is enforced by the optimizer, and DC energy above Paco that a DC-coupled battery could have absorbed is dropped in pvlib before the optimizer sees it, as it is today. A DC-side PV forecast that keeps that energy is a later piece.Config shape
Same pattern as phase 1 and the deferrable loads, no new config block. Indexes are 0-based, matching
_battery{k}.number_of_inverters(plant, int, default 1).inverter_efficiency_dc_ac,inverter_efficiency_ac_dc,inverter_stress_costandcompute_curtailmentaccept a scalar (broadcast) or a list ofnumber_of_invertersentries.inverter_ac_output_maxandinverter_ac_input_maxmust both be given as lists above one inverter: a scalar is rejected rather than broadcast, because at that point it is either the 5000 W default from config_defaults or a site total carried over from a one-inverter config, and neither is a per-unit cap. The one-inverter fallback of input to output does not apply above one, so omitting either lands on the default scalar and is rejected. Wrong length is a hard error, not padded, same as the battery arrays.inverter_stress_segmentsstays a global scalar, likebattery_stress_segments. The Paco-sum fallback is not extended above one inverter.battery_inverter_index(one int per battery) andpv_plant_inverter_index(one int per PV plant). Topology comes from config for every method, so each inverter's shape (hybrid, PV-only, battery-only) is fixed at build time and the cache key sees it. Indexes are validated againstnumber_of_inverters, and an inverter with no battery and no PV plant is a config error. There is no default because an all-zeros default would leave every other inverter empty, which is that error. Atnumber_of_invertersabsent or 1 both lists are ignored with a warning that namesnumber_of_inverters, the same treatmentinverter_is_hybridgets above one, since there is only one inverter for them to point at and nothing about one inverter changes.inverter_is_hybridis only read at one inverter. Above that it is ignored with a warning if set, and I'd make that a real promise rather than a convention: every read site (nine today across optimization.py and command_line.py, from variable creation through the cache key and the publish path) stops looking at it oncenumber_of_inverters> 1.compute_curtailmentgets the same audit, since it has about as many bare truthiness reads and a typed bool in the cache key. Every inverter is explicit and hybrid-ness is whatever you attached.pv_inverter_modelentries stay as they are. Nothing about one inverter changes, including the Paco sum.I went with explicit index lists rather than ffrog8's shared index, where inverter k owns battery k, because two strings on one hybrid are common and the lists are cheap. They also allow two batteries behind one inverter, which scruysberghs wasn't sure exists. Nothing depends on it, so if nobody has one I'm happy to limit it to one battery per inverter. Names are open.
A PV plant is one
pv_module_modelentry for pvlib and solar.forecast. For Solcast the rooftops are matched to plants by position, so above one inverter the rooftop count must equal the plant count. For list and CSV input a plant is one series per inverter that carries PV, sopv_plant_inverter_indexthere is one entry per PV-carrying inverter, in ascending order, and the payload has to match it. The index list is checked against the active method's plant count when the forecast runs.The forecast split
This is the part that makes it more than an optimizer change. The forecaster has to stop summing.
solar_forecast_kwpto accept a per-plane list, since today one kWp goes out for every plane. That key lives with the secrets and the add-on options, so the list form touches the add-on schema as well. The weather cache stores the per-plant series too.pv_power_forecastabove one inverter: a list ofnumber_of_inverterslists, one per inverter, an empty list for an inverter that has no PV plant in config, and the same shape for thepv_power_forecast_p10companion from feat: support caller-supplied PV P10 and bias calibration #1132. The timestamped-mapping form becomes a list ofnumber_of_invertersmappings, same rule. A flat list, a single mapping, or a payload that doesn't match the configured topology fails the action with the same invalid sentinel the P10 companion uses today, rather than the log-and-fall-back a wrong-shape flat list gets now. CSV input grows to oneyhatcolumn per PV-carrying inverter.P_PVandset_nocharge_from_gridsee PV the inverters can actually deliver (the export ceiling is built per inverter, below). PV forecast adjustment (set_use_adjusted_pv) keeps fitting on the aggregate, and the adjusted aggregate is split back by each inverter's share of the raw aggregate at that timestep, or by nameplate cap where the raw aggregate is zero. A true per-inverter adjustment needs per-inverter PV sensors and is a later piece.A two-hybrid runtime payload would look like this, for the REST-only users:
{ "prediction_horizon": 4, "pv_power_forecast": [[0, 450, 1200, 2100], [0, 300, 900, 1500]], "soc_init": [0.42, 0.68] }What stays plant-wide
maximum_power_from_gridandmaximum_power_to_gridunchanged.set_nocharge_from_grid: aggregate PV against aggregate charge, as today. That rule is already a proxy (it can't tell PV-to-load plus grid-to-battery from PV-to-battery), and per-inverter buses make it more visible: a battery-only inverter can charge up to what the other inverter's PV covers. The strict per-inverter form,p_sto_neg_i + pv_i >= 0, would also forbid the legitimate AC path, so I'd leave the plant-wide form and offer the strict one later as an option.set_nodischarge_to_gridis two rules today:E[k] <= Dfor hybrid (3004-3006), which ties every battery to the one plant-wide grid direction, and the PV-surplus export ceiling for AC-coupled (3007-3016, the set_nodischarge_to_grid over-constrains battery discharge since 0.17.3 (#796): E<=D forbids discharge-to-local-load during export-capable timesteps #936 fix), which is one bound on grid export. Above one inverter I'd use the export ceiling for everything, taken on the AC side: export at mostmax(0, sum over i of min(pv_i * eff_dc_ac_i, cap_i) - load), so a clipped or converted watt can't be exported as if it were PV.E[k] <= Ddoesn't survive explicit inverters, because it would block battery A whenever inverter B exports its own PV, which is the set_nodischarge_to_grid over-constrains battery discharge since 0.17.3 (#796): E<=D forbids discharge-to-local-load during export-capable timesteps #936 over-constraint again across units. A hybrid that moves to two inverters therefore moves fromE[k] <= Dto the ceiling. The two differ only in what a discharging battery may feed during an export slot: deferrable loads or another battery under the ceiling, nothing underE[k] <= D. As with the AC-coupled rule today, a second bound keeps export at or below the PV left after each inverter's curtailment or spill, so a curtailed or spilled watt can't be replaced by battery energy and exported. Neither rule lets a battery cover house load while the site exports.p_inverter_i.Outputs
Unsuffixed columns and sensors stay site totals, as
P_battdid in phase 1:P_PV,P_PV_curtailment,P_hybrid_inverter. Above one inverter they get per-inverter siblingsP_PV_{i},P_PV_curtailment_{i},P_PV_spill_{i}(only on a hybrid with curtailment off, published next to the curtailment one),P_hybrid_inverter_{i}and sensorssensor.p_pv_forecast_inverter{i}and so on, the same suffix pattern as_battery{k}. Above one inverter, the per-battery power and SoC sensors (the bare battery sensors when there is one battery) carry aninverterattribute, kept in the saved entity metadata so continual publish doesn't drop it, so an executor can route a setpoint to the right device without keeping its own mapping. One inverter publishes exactly today's entity set and attributes. The new columns go into docs/plan_output_schema.md with a minor bump of the plan schema version (1.0 to 1.1), which is what its semver table says for added columns, starting with the PR 2 diagnostic columns. #1032 addedP_batt_{k}andSOC_opt_{k}without doing either, so that gets caught up at the same time.Compatibility
number_of_invertersabsent or 1: the hybrid and the AC-coupled paths are both mathematically unchanged, pinned by regression tests the way feat: support N stationary batteries via the deferrable-loads array pattern (#610 phase 1) #1032 pinned one battery. No existing config changes meaning.soc_finalpenalty.Phasing, six PRs
Each lands on its own, keeps one inverter a no-op, and carries its own reference docs. (I said seven in #870; it's six here plus the later list.)
number_of_invertersabove 1 is rejected with a clear error, so no release ships a key that does nothing. The new params appear in the web UI from this PR with the same free add/remove the PV lists have today; the sync wiring is PR 5.P_PV_plant{j}) at any inverter count, so they show up in opt_res_latest.csv and /api/v1/plan without being HA sensors (that is PR 4). That is what makes this checkable before PR 3: the per-plant forecast columns against what each real inverter produces on scruysberghs's sites, and against the two orientation groups renaatdb meters separately behind one AC connection on his Enphase site, at one inverter, with no change to the live plan.inverterattribute), plan API and the web UI chart. That chart only plots the bareSOC_opttoday, so there is no SOC chart above one battery either. That fix is its own one-file PR, not part of this phase.number_of_inverterslike the battery section. Separate PR, the way the identification UI wiring was kept out of feat: per-battery identification at number_of_batteries > 1 #1046. Nothing to check on site for 1, 4 or 5.Later, as separate proposals, not part of this phase: per-inverter
set_nocharge_from_grid; a per-inverter production subsidy (the Belgian GSC case from #544, paid on what the inverter produces, so an objective term on that inverter's PV minus its curtailment, not on its AC output, which would also pay for battery discharge through a hybrid) once there's a design for how that price comes in; a DC-side PV forecast; DC-side signals so identification can split cell loss from inverter loss; a per-battery runtimesoc_targetlist; an opt-in lock against battery transfer across inverters, on the direction binaries the model already has, so no new binaries: the AC-in form (no battery elsewhere discharges while any inverter takes AC in) rules out the round trip through the AC bus but not battery A carrying the house while battery B soaks up its own PV, and it also rules out battery A carrying the house while battery B charges from a cheap grid window; a per-battery form (no battery charges while another discharges) catches both swaps at the cost of also blocking B absorbing PV above its cap while A covers the house; PV from one hybrid can still charge another's battery under either, and either way it is a switch and never a default; the spill for a one-inverter hybrid; a part-load efficiency curve per inverter (the efficiency-curve request in #746, extended from the battery to the inverter). Also not here: V2G and intermittent connection (phase 2, its own design round), per-phase limits (#622), and a SOC-balance cost.ffrog8's findings from #1020
R1 (an indexed value silently inheriting a top-level one): values come from config_defaults, the user config and runtime params in that order, a scalar broadcasts to every inverter and a list is exact-length, and the two caps refuse a scalar above one inverter because that is exactly where a stale site total would hide. R2 (admission reading inactive top-level flags): that is the
inverter_is_hybridpromise above, it is not read anywhere once there is more than one inverter. R3 (static mode surviving a runtime override): there is no mode, andnumber_of_invertersis read once after runtime params merge, at the same pointnumber_of_batteriesis. R4 (nested SOC target clamp):soc_initandsoc_finalare per-battery lists since #1032,soc_finalclamped per battery against its own bounds and logged with the submitted and clamped values, and the single runtimesoc_targetis clamped per battery the same way. A per-batterysoc_targetlist is in the later list above. R5 adopted. R6 (InfluxDB export demanding forecasts it never uses): the export action builds no forecaster or optimizer, and the runtime forecast validation only runs on a key that was actually passed, so export never demands one. The nested validation keeps both properties. R8 was about proving that a SOC-balance cost actually changes dispatch. I said in #610 that the SOC-balance idea would fold into this phase; having got here, I'm not adding it, so there is nothing to prove yet. Per-battery weights and the tie-break give deterministic dispatch, not an even split: on an exact tie the tilt uses battery 0 first, and with constant inverter efficiencies the model sees no loss difference between 2 kW from one battery and 1 kW from each, which is scruysberghs's question from #610. A part-load curve would change that, hence the later list.The ask
If the shape works, a go is enough and I'll start on PR 1. The two one-file PRs (PV list length check, SOC chart) go up regardless. Defaults unless you say otherwise: this phase going ahead of phase 2, explicit index lists,
inverter_is_hybridignored with a warning above one inverter, the two caps required as lists above one inverter, a priced spill instead of an infeasible interval for a hybrid with curtailment off above one inverter, nested runtime forecast required,set_nocharge_from_gridplant-wide, and the export ceiling as the singleset_nodischarge_to_gridrule above one inverter. The one ordering I'd hold is PR 2 before PR 3, since the optimizer can't be validated without the split forecast.All reactions