From 0356e890f4b3be946c90b3cdd2afd6db518ddc1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 11:10:01 +0000 Subject: [PATCH] feat: add sell_price_equals_buy_price for net metering (NL saldering) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under net metering (Dutch "saldering", in force through 2026 — e.g. Tibber NL) every exported kWh offsets an imported one on the bill, so its effective value is the full buy price incl. markup, VAT and grid fees — not spot + export compensation. The optimizer undervalued exports, skewing charge/discharge decisions for NL users. New opt-in price setting sell_price_equals_buy_price (default off, so existing behavior is unchanged). When enabled the sell price formula returns the buy price; the export compensation and export spot multiplier fields are hidden in the UI and ignored. - PriceSettings + PriceManager: new flag, _calculate_sell_price returns _calculate_buy_price when set - settings_store: bootstrap default + schema migration for old stores - API: setup-complete payload field sellPriceEqualsBuyPrice, PRICE_MAP, live updates; PATCH works via the existing section pass-through - PRICE_REQUIRED_FIELDS extended (store-backed, per contract test) - UI: toggle in Price Calculation (Settings + wizard), live preview shows sell = buy when enabled - tests: PriceManager unit tests for flag on/off Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JD7RhRSGxkW8zZirZQFRFC --- backend/api.py | 2 ++ backend/api_conversion.py | 1 + backend/api_dataclasses.py | 1 + backend/settings_store.py | 4 +++ backend/tests/test_settings_contracts.py | 1 + core/bess/battery_system_manager.py | 4 +++ core/bess/price_manager.py | 8 +++++ core/bess/settings.py | 5 +++ core/bess/tests/unit/test_price_manager.py | 35 +++++++++++++++++++ .../settings/PricingFormSection.tsx | 19 ++++++---- frontend/src/pages/SettingsPage.tsx | 3 ++ frontend/src/pages/SetupWizardPage.tsx | 3 ++ frontend/src/types.ts | 2 ++ 13 files changed, 82 insertions(+), 6 deletions(-) diff --git a/backend/api.py b/backend/api.py index 85b3ba0f..846c62fa 100644 --- a/backend/api.py +++ b/backend/api.py @@ -3432,6 +3432,7 @@ async def setup_complete(payload: APISetupCompletePayload): "taxReduction": "tax_reduction", "spotMultiplier": "spot_multiplier", "exportSpotMultiplier": "export_spot_multiplier", + "sellPriceEqualsBuyPrice": "sell_price_equals_buy_price", } area = payload.area or payload.nordpoolArea if any(getattr(payload, f) is not None for f in _PRICE_MAP) or area: @@ -3570,6 +3571,7 @@ def _nn(d: dict) -> dict: "tax_reduction": payload.taxReduction, "spot_multiplier": payload.spotMultiplier, "export_spot_multiplier": payload.exportSpotMultiplier, + "sell_price_equals_buy_price": payload.sellPriceEqualsBuyPrice, } ) if live_updates: diff --git a/backend/api_conversion.py b/backend/api_conversion.py index f0cedf2a..f68b7766 100644 --- a/backend/api_conversion.py +++ b/backend/api_conversion.py @@ -96,6 +96,7 @@ "tax_reduction", "spot_multiplier", "export_spot_multiplier", + "sell_price_equals_buy_price", } ) diff --git a/backend/api_dataclasses.py b/backend/api_dataclasses.py index d27879f4..61fbbeb2 100644 --- a/backend/api_dataclasses.py +++ b/backend/api_dataclasses.py @@ -1115,6 +1115,7 @@ class APISetupCompletePayload(BaseModel): taxReduction: float | None = None spotMultiplier: float | None = None exportSpotMultiplier: float | None = None + sellPriceEqualsBuyPrice: bool | None = None # Energy provider provider: str | None = None # Nordpool HACS entity (required when provider == "nordpool_hacs") diff --git a/backend/settings_store.py b/backend/settings_store.py index 1fa2c124..47d2dfc4 100644 --- a/backend/settings_store.py +++ b/backend/settings_store.py @@ -372,6 +372,7 @@ def _bootstrap_defaults() -> dict: HOUSE_VOLTAGE_V, MARKUP_RATE, SAFETY_MARGIN_FACTOR, + SELL_PRICE_EQUALS_BUY_PRICE, SPOT_MULTIPLIER, TAX_REDUCTION, USE_ACTUAL_PRICE, @@ -406,6 +407,7 @@ def _bootstrap_defaults() -> dict: "area": DEFAULT_AREA, "spot_multiplier": SPOT_MULTIPLIER, "export_spot_multiplier": EXPORT_SPOT_MULTIPLIER, + "sell_price_equals_buy_price": SELL_PRICE_EQUALS_BUY_PRICE, "use_actual_price": USE_ACTUAL_PRICE, }, "energy_provider": { @@ -443,6 +445,7 @@ def _migrate_schema(self) -> None: BATTERY_EFFICIENCY_DISCHARGE, BATTERY_MIN_ACTION_PROFIT_THRESHOLD, EXPORT_SPOT_MULTIPLIER, + SELL_PRICE_EQUALS_BUY_PRICE, SPOT_MULTIPLIER, USE_ACTUAL_PRICE, ) @@ -522,6 +525,7 @@ def _migrate_schema(self) -> None: for key, default in ( ("spot_multiplier", SPOT_MULTIPLIER), ("export_spot_multiplier", EXPORT_SPOT_MULTIPLIER), + ("sell_price_equals_buy_price", SELL_PRICE_EQUALS_BUY_PRICE), ("use_actual_price", USE_ACTUAL_PRICE), ): if key not in price: diff --git a/backend/tests/test_settings_contracts.py b/backend/tests/test_settings_contracts.py index eee89f4b..8ce9daca 100644 --- a/backend/tests/test_settings_contracts.py +++ b/backend/tests/test_settings_contracts.py @@ -101,6 +101,7 @@ def _valid_options() -> dict: "tax_reduction": 0.2, "spot_multiplier": 1.0175, "export_spot_multiplier": 1.018, + "sell_price_equals_buy_price": False, "use_actual_price": False, }, } diff --git a/core/bess/battery_system_manager.py b/core/bess/battery_system_manager.py index 2e8f226c..4ec7c957 100644 --- a/core/bess/battery_system_manager.py +++ b/core/bess/battery_system_manager.py @@ -164,6 +164,7 @@ def __init__( area=self.price_settings.area, spot_multiplier=self.price_settings.spot_multiplier, export_spot_multiplier=self.price_settings.export_spot_multiplier, + sell_price_equals_buy_price=self.price_settings.sell_price_equals_buy_price, ) # Initialize monitors (created in start() if controller available) @@ -3084,6 +3085,9 @@ def update_settings(self, settings: dict[str, Any]) -> None: self._price_manager.export_spot_multiplier = ( self.price_settings.export_spot_multiplier ) + self._price_manager.sell_price_equals_buy_price = ( + self.price_settings.sell_price_equals_buy_price + ) self._price_manager.clear_cache() if "energy_provider" in settings: diff --git a/core/bess/price_manager.py b/core/bess/price_manager.py index 994a8382..49ebc368 100644 --- a/core/bess/price_manager.py +++ b/core/bess/price_manager.py @@ -391,6 +391,7 @@ def __init__( area: str, spot_multiplier: float = 1.0, export_spot_multiplier: float = 1.0, + sell_price_equals_buy_price: bool = False, ) -> None: """Initialize the price manager. @@ -403,6 +404,8 @@ def __init__( area: Price area code (e.g. "SE4", "NO1", "DK1") spot_multiplier: Multiplicative factor on spot buy price (1.0 = no adjustment) export_spot_multiplier: Multiplicative factor on spot sell price + sell_price_equals_buy_price: Net metering (e.g. NL saldering) — + sell price is the full buy price instead of spot + compensation """ self.price_source = price_source self.markup_rate = markup_rate @@ -412,6 +415,7 @@ def __init__( self.area = area self.spot_multiplier = spot_multiplier self.export_spot_multiplier = export_spot_multiplier + self.sell_price_equals_buy_price = sell_price_equals_buy_price self._logger = logging.getLogger(__name__) # Cache for today's prices @@ -456,6 +460,10 @@ def _calculate_sell_price(self, base_price: float) -> float: Returns: Calculated sell-back price """ + if self.sell_price_equals_buy_price: + # Net metering (e.g. NL saldering): every exported kWh offsets an + # imported one on the bill, so its value is the full buy price. + return self._calculate_buy_price(base_price) return base_price * self.export_spot_multiplier + self.tax_reduction def get_price_data(self, target_date: date | None = None) -> list: diff --git a/core/bess/settings.py b/core/bess/settings.py index b2c9cf45..286b5e28 100644 --- a/core/bess/settings.py +++ b/core/bess/settings.py @@ -29,6 +29,7 @@ ) SPOT_MULTIPLIER = 1.0 # multiplicative factor on spot (1.0 = no adjustment) EXPORT_SPOT_MULTIPLIER = 1.0 # multiplicative factor on spot for sell price +SELL_PRICE_EQUALS_BUY_PRICE = False # net metering (e.g. NL saldering): sell = buy MIN_PROFIT = 0.2 # Minimum profit per kWh to consider a charge/discharge cycle USE_ACTUAL_PRICE = False # Use raw Nordpool spot prices or include markup, VAT, etc. @@ -95,6 +96,10 @@ class PriceSettings: tax_reduction: float = TAX_REDUCTION spot_multiplier: float = SPOT_MULTIPLIER export_spot_multiplier: float = EXPORT_SPOT_MULTIPLIER + # Net metering (e.g. NL "saldering", in force through 2026): exported + # energy offsets imported energy 1:1, so the effective sell price is the + # full buy price incl. markup, VAT and grid fees — not spot + compensation. + sell_price_equals_buy_price: bool = SELL_PRICE_EQUALS_BUY_PRICE min_profit: float = MIN_PROFIT use_actual_price: bool = USE_ACTUAL_PRICE diff --git a/core/bess/tests/unit/test_price_manager.py b/core/bess/tests/unit/test_price_manager.py index 66305f4a..3d9ce43d 100644 --- a/core/bess/tests/unit/test_price_manager.py +++ b/core/bess/tests/unit/test_price_manager.py @@ -73,6 +73,41 @@ def test_spot_multiplier_defaults_to_no_adjustment(): assert pm.sell_prices[0] == 1.0 + 0.2 +def test_sell_price_equals_buy_price_enabled(): + """Net metering (NL saldering): sell price must equal the full buy price.""" + mock_source = MockSource([1.0, 2.0]) + pm = PriceManager( + price_source=mock_source, + markup_rate=0.02, + vat_multiplier=1.21, + additional_costs=0.0248, + tax_reduction=0.0248, + area="NL", + sell_price_equals_buy_price=True, + ) + + for base, buy, sell in zip([1.0, 2.0], pm.buy_prices, pm.sell_prices, strict=True): + assert buy == (base + 0.02) * 1.21 + 0.0248 + assert sell == buy + # tax_reduction must play no role while net metering is on + assert sell != base + 0.0248 + + +def test_sell_price_equals_buy_price_defaults_off(): + """Without the flag the sell price keeps the spot + compensation formula.""" + mock_source = MockSource([1.0]) + pm = PriceManager( + price_source=mock_source, + markup_rate=0.02, + vat_multiplier=1.21, + additional_costs=0.0248, + tax_reduction=0.0248, + area="NL", + ) + + assert pm.sell_prices[0] == 1.0 + 0.0248 + + def test_controller_price_fetching(): """Test price fetching from controller.""" mock_controller = MagicMock() diff --git a/frontend/src/components/settings/PricingFormSection.tsx b/frontend/src/components/settings/PricingFormSection.tsx index 0838cabe..182abe8b 100644 --- a/frontend/src/components/settings/PricingFormSection.tsx +++ b/frontend/src/components/settings/PricingFormSection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { numField, txtInput, radioGroup, SectionCard } from './FormHelpers'; +import { numField, txtInput, radioGroup, toggle, SectionCard } from './FormHelpers'; export interface PricingForm { currency: string; @@ -18,6 +18,7 @@ export interface PricingForm { taxReduction: number; spotMultiplier: number; exportSpotMultiplier: number; + sellPriceEqualsBuyPrice: boolean; } interface Props { @@ -36,7 +37,9 @@ export function PricingFormSection({ form, onChange }: Props) { const previewBuy = Number( ((previewSpot * sm + form.markupRate) * form.vatMultiplier + form.additionalCosts).toFixed(4), ); - const previewSell = Number((previewSpot * esm + form.taxReduction).toFixed(4)); + const previewSell = form.sellPriceEqualsBuyPrice + ? previewBuy + : Number((previewSpot * esm + form.taxReduction).toFixed(4)); return (
@@ -116,6 +119,8 @@ export function PricingFormSection({ form, onChange }: Props) { > {!isOctopus && ( <> + {toggle('Sell price equals buy price (net metering / saldering)', form.sellPriceEqualsBuyPrice, + v => onChange({ ...form, sellPriceEqualsBuyPrice: v }))}
{isEntsoe && numField('Import Spot Multiplier', form.spotMultiplier, v => onChange({ ...form, spotMultiplier: v }), @@ -129,10 +134,10 @@ export function PricingFormSection({ form, onChange }: Props) { {numField('Additional Costs', form.additionalCosts, v => onChange({ ...form, additionalCosts: v }), { unit: `${currency}/kWh`, min: 0, step: 0.001 })} - {isEntsoe && numField('Export Spot Multiplier', form.exportSpotMultiplier, + {isEntsoe && !form.sellPriceEqualsBuyPrice && numField('Export Spot Multiplier', form.exportSpotMultiplier, v => onChange({ ...form, exportSpotMultiplier: v }), { unit: 'factor (1.0 = no adjustment)', min: 0.5, max: 2.0, step: 0.0001 })} - {numField('Export Compensation', form.taxReduction, + {!form.sellPriceEqualsBuyPrice && numField('Export Compensation', form.taxReduction, v => onChange({ ...form, taxReduction: v }), { unit: `${currency}/kWh`, min: 0, step: 0.001 })}
@@ -149,7 +154,8 @@ export function PricingFormSection({ form, onChange }: Props) {

How the raw spot price is converted:

Buy price: (spot × import multiplier + markup) × VAT + additional costs

-

Sell price: spot × export multiplier + export compensation

+

Sell price: {form.sellPriceEqualsBuyPrice ? 'same as buy price (net metering)' : 'spot × export multiplier + export compensation'}

+
Sell price equals buy price: Enable under net metering ("saldering" in the Netherlands, in force through 2026): exported energy offsets imported energy 1:1 on your bill, so every exported kWh is worth the full buy price.
) : ( @@ -164,8 +170,9 @@ export function PricingFormSection({ form, onChange }: Props) {

How the raw spot price is converted:

Buy price: (raw spot + markup) × VAT multiplier + grid fees

-

Sell price: raw spot + export compensation

+

Sell price: {form.sellPriceEqualsBuyPrice ? 'same as buy price (net metering)' : 'raw spot + export compensation'}

Note: Markup is added before VAT (ex-VAT), while grid fees already include VAT.

+
Sell price equals buy price: Enable under net metering ("saldering" in the Netherlands, in force through 2026): exported energy offsets imported energy 1:1 on your bill, so every exported kWh is worth the full buy price. E.g. Tibber NL.
)} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 5812ae43..f498f4d0 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -55,6 +55,7 @@ const EMPTY_PRICING: PricingForm = { entsoeEntity: '', area: '', markupRate: 0, vatMultiplier: 1.25, additionalCosts: 0, taxReduction: 0, spotMultiplier: 1.0, exportSpotMultiplier: 1.0, + sellPriceEqualsBuyPrice: false, }; const EMPTY_INVERTER: InverterForm = { inverterPlatform: 'growatt_server_min', deviceId: '', controlMode: 'tou' }; @@ -203,6 +204,7 @@ const SettingsPage: React.FC = () => { taxReduction: elec_s.taxReduction ?? 0, spotMultiplier: elec_s.spotMultiplier ?? 1.0, exportSpotMultiplier: elec_s.exportSpotMultiplier ?? 1.0, + sellPriceEqualsBuyPrice: elec_s.sellPriceEqualsBuyPrice ?? false, }; setPricingForm(p); savedPricing.current = JSON.stringify(p); @@ -408,6 +410,7 @@ const SettingsPage: React.FC = () => { taxReduction: pricingForm.taxReduction, spotMultiplier: pricingForm.spotMultiplier, exportSpotMultiplier: pricingForm.exportSpotMultiplier, + sellPriceEqualsBuyPrice: pricingForm.sellPriceEqualsBuyPrice, useActualPrice: false, }, energyProvider: { diff --git a/frontend/src/pages/SetupWizardPage.tsx b/frontend/src/pages/SetupWizardPage.tsx index 3d94b58e..540179c1 100644 --- a/frontend/src/pages/SetupWizardPage.tsx +++ b/frontend/src/pages/SetupWizardPage.tsx @@ -88,6 +88,7 @@ const SetupWizardPage: React.FC = () => { taxReduction: 0.2, spotMultiplier: 1.0, exportSpotMultiplier: 1.0, + sellPriceEqualsBuyPrice: false, }); const handleScan = useCallback(async () => { @@ -257,6 +258,7 @@ const SetupWizardPage: React.FC = () => { taxReduction: elec.taxReduction ?? f.taxReduction, spotMultiplier: elec.spotMultiplier ?? f.spotMultiplier, exportSpotMultiplier: elec.exportSpotMultiplier ?? f.exportSpotMultiplier, + sellPriceEqualsBuyPrice: elec.sellPriceEqualsBuyPrice ?? f.sellPriceEqualsBuyPrice, // Restore saved config entry IDs so manual entries survive a wizard re-run nordpoolConfigEntryId: ep.nordpoolOfficial?.configEntryId ?? f.nordpoolConfigEntryId, nordpoolEntity: ep.nordpoolHacs?.entity ?? f.nordpoolEntity, @@ -328,6 +330,7 @@ const SetupWizardPage: React.FC = () => { taxReduction: pricingForm.taxReduction, spotMultiplier: pricingForm.spotMultiplier, exportSpotMultiplier: pricingForm.exportSpotMultiplier, + sellPriceEqualsBuyPrice: pricingForm.sellPriceEqualsBuyPrice, // Nordpool HACS entity nordpoolEntity: pricingForm.nordpoolEntity || undefined, // Octopus Energy entity IDs diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3f62d4c0..1c4bb5da 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -129,6 +129,8 @@ export interface ElectricitySettings { vatMultiplier: number; additionalCosts: number; taxReduction: number; + // Net metering (e.g. NL saldering): sell price equals full buy price + sellPriceEqualsBuyPrice?: boolean; area: string; }