Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend/api_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"tax_reduction",
"spot_multiplier",
"export_spot_multiplier",
"sell_price_equals_buy_price",
}
)

Expand Down
1 change: 1 addition & 0 deletions backend/api_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions backend/settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend/tests/test_settings_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
Expand Down
4 changes: 4 additions & 0 deletions core/bess/battery_system_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions core/bess/price_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions core/bess/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions core/bess/tests/unit/test_price_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 13 additions & 6 deletions frontend/src/components/settings/PricingFormSection.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +18,7 @@ export interface PricingForm {
taxReduction: number;
spotMultiplier: number;
exportSpotMultiplier: number;
sellPriceEqualsBuyPrice: boolean;
}

interface Props {
Expand All @@ -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 (
<div className="space-y-3">
Expand Down Expand Up @@ -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 }))}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{isEntsoe && numField('Import Spot Multiplier', form.spotMultiplier,
v => onChange({ ...form, spotMultiplier: v }),
Expand All @@ -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 })}
</div>
Expand All @@ -149,7 +154,8 @@ export function PricingFormSection({ form, onChange }: Props) {
<div className="space-y-2 pt-2 border-t border-blue-200 dark:border-blue-700">
<p className="font-medium text-blue-900 dark:text-blue-200">How the raw spot price is converted:</p>
<p className="pl-2 border-l-2 border-blue-300 dark:border-blue-600"><strong>Buy price:</strong> (spot × import multiplier + markup) × VAT + additional costs</p>
<p className="pl-2 border-l-2 border-blue-300 dark:border-blue-600"><strong>Sell price:</strong> spot × export multiplier + export compensation</p>
<p className="pl-2 border-l-2 border-blue-300 dark:border-blue-600"><strong>Sell price:</strong> {form.sellPriceEqualsBuyPrice ? 'same as buy price (net metering)' : 'spot × export multiplier + export compensation'}</p>
<div><span className="font-medium text-blue-900 dark:text-blue-200">Sell price equals buy price:</span> 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.</div>
</div>
</div>
) : (
Expand All @@ -164,8 +170,9 @@ export function PricingFormSection({ form, onChange }: Props) {
<div className="space-y-2 pt-2 border-t border-blue-200 dark:border-blue-700">
<p className="font-medium text-blue-900 dark:text-blue-200">How the raw spot price is converted:</p>
<p className="pl-2 border-l-2 border-blue-300 dark:border-blue-600"><strong>Buy price:</strong> (raw spot + markup) × VAT multiplier + grid fees</p>
<p className="pl-2 border-l-2 border-blue-300 dark:border-blue-600"><strong>Sell price:</strong> raw spot + export compensation</p>
<p className="pl-2 border-l-2 border-blue-300 dark:border-blue-600"><strong>Sell price:</strong> {form.sellPriceEqualsBuyPrice ? 'same as buy price (net metering)' : 'raw spot + export compensation'}</p>
<p className="text-gray-500 dark:text-gray-500 italic">Note: Markup is added before VAT (ex-VAT), while grid fees already include VAT.</p>
<div><span className="font-medium text-blue-900 dark:text-blue-200">Sell price equals buy price:</span> 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.</div>
</div>
</div>
)}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -408,6 +410,7 @@ const SettingsPage: React.FC = () => {
taxReduction: pricingForm.taxReduction,
spotMultiplier: pricingForm.spotMultiplier,
exportSpotMultiplier: pricingForm.exportSpotMultiplier,
sellPriceEqualsBuyPrice: pricingForm.sellPriceEqualsBuyPrice,
useActualPrice: false,
},
energyProvider: {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/SetupWizardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const SetupWizardPage: React.FC = () => {
taxReduction: 0.2,
spotMultiplier: 1.0,
exportSpotMultiplier: 1.0,
sellPriceEqualsBuyPrice: false,
});

const handleScan = useCallback(async () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down