Compare commits
6
Commits
v1.4.0
..
bfc7aa3031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc7aa3031 | ||
|
|
2f63b9630c | ||
|
|
d07a083e03 | ||
|
|
b65f700d56 | ||
|
|
f4cea3874b | ||
|
|
134f0abb5f |
@@ -169,7 +169,7 @@ def get_prices(
|
|||||||
|
|
||||||
Response ``points`` carries per-slot:
|
Response ``points`` carries per-slot:
|
||||||
- ``buy = total`` (Tibber all-inclusive price)
|
- ``buy = total`` (Tibber all-inclusive price)
|
||||||
- ``sell = total − energy_tax − sell_adjust`` (from active version values)
|
- ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)
|
||||||
- ``level`` (Tibber price level, may be null)
|
- ``level`` (Tibber price level, may be null)
|
||||||
|
|
||||||
``tariff`` is null.
|
``tariff`` is null.
|
||||||
@@ -222,7 +222,8 @@ def get_prices(
|
|||||||
)
|
)
|
||||||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||||
|
|
||||||
# Derive sell price per-point using version values (energy_tax + sell_adjust).
|
# Derive sell price per-point using version values
|
||||||
|
# (energy_tax + sell_fee + sell_adjust).
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
def _d(v: Any) -> Decimal:
|
def _d(v: Any) -> Decimal:
|
||||||
@@ -230,12 +231,13 @@ def get_prices(
|
|||||||
|
|
||||||
energy = version.values.get("energy", {}) if version.values else {}
|
energy = version.values.get("energy", {}) if version.values else {}
|
||||||
energy_tax = _d(energy.get("energy_tax", 0))
|
energy_tax = _d(energy.get("energy_tax", 0))
|
||||||
|
sell_fee = _d(energy.get("sell_fee", 0))
|
||||||
sell_adjust = _d(energy.get("sell_adjust", 0))
|
sell_adjust = _d(energy.get("sell_adjust", 0))
|
||||||
|
|
||||||
points = []
|
points = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
total = _d(row.total)
|
total = _d(row.total)
|
||||||
sell = float(total - energy_tax - sell_adjust)
|
sell = float(total - energy_tax - sell_fee - sell_adjust)
|
||||||
points.append(
|
points.append(
|
||||||
PricePointSchema(
|
PricePointSchema(
|
||||||
starts_at=_as_utc(row.starts_at),
|
starts_at=_as_utc(row.starts_at),
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ class ManualProfile(BaseModel):
|
|||||||
class TibberEnergySpec(BaseModel):
|
class TibberEnergySpec(BaseModel):
|
||||||
source: str # must be "tibber_api"
|
source: str # must be "tibber_api"
|
||||||
energy_tax: FieldSpec # subtracted from total to derive sell price
|
energy_tax: FieldSpec # subtracted from total to derive sell price
|
||||||
|
sell_fee: FieldSpec # verkoopvergoeding (feed-in fee); always subtracted from sell; default 0.0248
|
||||||
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
||||||
|
|
||||||
|
|
||||||
@@ -268,10 +269,13 @@ def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dic
|
|||||||
|
|
||||||
|
|
||||||
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
||||||
"""Return a copy of *values* with sell_adjust and management_fee defaults applied."""
|
"""Return a copy of *values* with sell_fee, sell_adjust and management_fee defaults applied."""
|
||||||
filled = dict(values)
|
filled = dict(values)
|
||||||
|
|
||||||
energy = dict(filled.get("energy", {}))
|
energy = dict(filled.get("energy", {}))
|
||||||
|
# Apply default for sell_fee (default=0.0248) if absent.
|
||||||
|
if "sell_fee" not in energy and profile.energy.sell_fee.default is not None:
|
||||||
|
energy["sell_fee"] = profile.energy.sell_fee.default
|
||||||
# Apply default for sell_adjust (default=0) if absent.
|
# Apply default for sell_adjust (default=0) if absent.
|
||||||
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
||||||
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
||||||
@@ -347,6 +351,7 @@ def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> d
|
|||||||
|
|
||||||
# Required energy fields.
|
# Required energy fields.
|
||||||
_require_numeric("energy", "energy_tax", energy)
|
_require_numeric("energy", "energy_tax", energy)
|
||||||
|
_require_numeric("energy", "sell_fee", energy)
|
||||||
_require_numeric("energy", "sell_adjust", energy)
|
_require_numeric("energy", "sell_adjust", energy)
|
||||||
# Required standing fields.
|
# Required standing fields.
|
||||||
_require_numeric("standing", "management_fee", standing)
|
_require_numeric("standing", "management_fee", standing)
|
||||||
@@ -361,9 +366,10 @@ def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"""Validate a contract-values dict against the named profile structure.
|
"""Validate a contract-values dict against the named profile structure.
|
||||||
|
|
||||||
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
||||||
``sell_adjust``, tibber ``management_fee``) are silently filled in when
|
``sell_fee``, ``sell_adjust``, tibber ``management_fee``) are silently
|
||||||
absent from *values*. Fields with no default that are absent, or fields
|
filled in when absent from *values*. Fields with no default that are
|
||||||
whose value is not a number, cause a ``ProfileValidationError``.
|
absent, or fields whose value is not a number, cause a
|
||||||
|
``ProfileValidationError``.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ kind: tibber
|
|||||||
label: Tibber 动态电价(15 分钟)
|
label: Tibber 动态电价(15 分钟)
|
||||||
|
|
||||||
energy:
|
energy:
|
||||||
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_adjust
|
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_fee − sell_adjust
|
||||||
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
||||||
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread)
|
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding (feed-in fee, incl. VAT); always subtracted from sell
|
||||||
|
sell_adjust: { unit: EUR/kWh, default: 0 } # manual sell-price adjustment; net-metering: set = −energy_tax to refund the tax
|
||||||
|
|
||||||
standing: # fixed charges; UI fills per month, engine prorates to days
|
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||||
management_fee: { unit: EUR/month, default: 5.99 }
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
|
|||||||
@@ -209,12 +209,23 @@ def _tibber_strategy(
|
|||||||
query on ``starts_at``.
|
query on ``starts_at``.
|
||||||
|
|
||||||
Formula (§3.4):
|
Formula (§3.4):
|
||||||
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
|
- ``buy = total`` (Tibber's all-inclusive price; already includes energy
|
||||||
- ``sell = total − energy_tax − sell_adjust``
|
tax, VAT and the buy-side ``inkoopvergoeding``)
|
||||||
|
- ``sell = total − energy_tax − sell_fee − sell_adjust``
|
||||||
- ``import_cost = (Δd1 + Δd2) × buy``
|
- ``import_cost = (Δd1 + Δd2) × buy``
|
||||||
- ``export_revenue = (Δr1 + Δr2) × sell``
|
- ``export_revenue = (Δr1 + Δr2) × sell``
|
||||||
- ``net_cost = import_cost − export_revenue``
|
- ``net_cost = import_cost − export_revenue``
|
||||||
|
|
||||||
|
``sell_fee`` models Tibber's per-kWh **verkoopvergoeding** (feed-in fee,
|
||||||
|
€0.0248/kWh incl. VAT since 2026-01-01). It is always deducted from the
|
||||||
|
feed-in payout: even under the net-metering (saldering) scheme, Tibber pays
|
||||||
|
``total − verkoopvergoeding`` per returned kWh (Tibber NL: "€0,28 − €0,0248
|
||||||
|
= €0,2552"). ``total`` already contains the equal buy-side
|
||||||
|
``inkoopvergoeding``, so the two fees do **not** cancel — the feed-in price
|
||||||
|
sits ``sell_fee`` below the buy price. ``sell_adjust`` is a separate manual
|
||||||
|
correction: under net metering it carries back the refunded energy tax
|
||||||
|
(``sell_adjust = −energy_tax``), leaving ``sell = total − sell_fee``.
|
||||||
|
|
||||||
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
||||||
API price applies to the full delivered/returned volume.
|
API price applies to the full delivered/returned volume.
|
||||||
|
|
||||||
@@ -248,11 +259,12 @@ def _tibber_strategy(
|
|||||||
|
|
||||||
energy = values.get("energy", {})
|
energy = values.get("energy", {})
|
||||||
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||||
|
sell_fee = _to_decimal(energy.get("sell_fee", 0))
|
||||||
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
||||||
|
|
||||||
total = _to_decimal(price_row.total)
|
total = _to_decimal(price_row.total)
|
||||||
buy = total
|
buy = total
|
||||||
sell = total - energy_tax - sell_adjust
|
sell = total - energy_tax - sell_fee - sell_adjust
|
||||||
|
|
||||||
total_delivered = deltas.d1 + deltas.d2
|
total_delivered = deltas.d1 + deltas.d2
|
||||||
total_returned = deltas.r1 + deltas.r2
|
total_returned = deltas.r1 + deltas.r2
|
||||||
@@ -269,6 +281,7 @@ def _tibber_strategy(
|
|||||||
"buy": str(buy),
|
"buy": str(buy),
|
||||||
"sell": str(sell),
|
"sell": str(sell),
|
||||||
"energy_tax": str(energy_tax),
|
"energy_tax": str(energy_tax),
|
||||||
|
"sell_fee": str(sell_fee),
|
||||||
"sell_adjust": str(sell_adjust),
|
"sell_adjust": str(sell_adjust),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,20 +30,39 @@ logger = logging.getLogger(__name__)
|
|||||||
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
||||||
_DEFAULT_TIMEOUT = 15.0
|
_DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
# GraphQL query to fetch a range of 15-minute price nodes.
|
# GraphQL query to fetch the forward-looking today + tomorrow price curve at
|
||||||
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
|
# 15-minute resolution.
|
||||||
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max,
|
#
|
||||||
# but the Tibber API typically starts from the current slot and returns at
|
# ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
|
||||||
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
|
# * ``today`` — always the full current local day (96 quarter-hourly slots,
|
||||||
# "today + tomorrow").
|
# 00:00 → 23:45 local), regardless of the current time.
|
||||||
|
# * ``tomorrow`` — the full next local day (96 slots) once Tibber publishes the
|
||||||
|
# day-ahead prices (around 13:00–15:00 local); empty before that.
|
||||||
|
#
|
||||||
|
# This is deliberately NOT ``priceInfoRange``: that field is a historical cursor
|
||||||
|
# connection whose range ends at "now" (it never returns future slots), so it
|
||||||
|
# cannot supply upcoming prices. ``priceInfo`` is forward-looking, so every
|
||||||
|
# 15-minute slot's price is present in the DB *before* the slot closes — which is
|
||||||
|
# what makes per-slot billing accurate (each period finds its own exact slot
|
||||||
|
# instead of falling back to a stale earlier price) and keeps the live current-
|
||||||
|
# price entity fresh. The hourly refresh job re-runs this query, so tomorrow's
|
||||||
|
# prices are picked up within an hour of publication without a restart.
|
||||||
_PRICE_RANGE_QUERY = """
|
_PRICE_RANGE_QUERY = """
|
||||||
{
|
{
|
||||||
viewer {
|
viewer {
|
||||||
homes {
|
homes {
|
||||||
id
|
id
|
||||||
currentSubscription {
|
currentSubscription {
|
||||||
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
priceInfo(resolution: QUARTER_HOURLY) {
|
||||||
nodes {
|
today {
|
||||||
|
startsAt
|
||||||
|
total
|
||||||
|
energy
|
||||||
|
tax
|
||||||
|
currency
|
||||||
|
level
|
||||||
|
}
|
||||||
|
tomorrow {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
energy
|
energy
|
||||||
@@ -230,11 +249,15 @@ def fetch_price_range(
|
|||||||
*,
|
*,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
) -> list[PricePoint]:
|
) -> list[PricePoint]:
|
||||||
"""Fetch a range of 15-minute price nodes from the Tibber API.
|
"""Fetch the forward-looking today + tomorrow 15-minute price curve from Tibber.
|
||||||
|
|
||||||
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query
|
Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
|
||||||
and parses every returned node into a ``PricePoint``. The number of nodes
|
and parses every node from both lists (today first, then tomorrow) into a
|
||||||
is not assumed — all returned nodes are parsed regardless of count.
|
``PricePoint``. ``priceInfo`` is forward-looking — ``today`` is always the
|
||||||
|
full current local day and ``tomorrow`` is populated once Tibber publishes the
|
||||||
|
day-ahead prices — so upcoming slots are returned, unlike ``priceInfoRange``
|
||||||
|
which only reaches "now". ``tomorrow`` may be empty (before publication); the
|
||||||
|
number of nodes is not assumed and all returned nodes are parsed.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -268,10 +291,15 @@ def fetch_price_range(
|
|||||||
home = _pick_home(homes, home_id)
|
home = _pick_home(homes, home_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
|
price_info = home["currentSubscription"]["priceInfo"]
|
||||||
|
today = price_info["today"]
|
||||||
|
tomorrow = price_info["tomorrow"]
|
||||||
except (KeyError, TypeError) as exc:
|
except (KeyError, TypeError) as exc:
|
||||||
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc
|
raise TibberError("Tibber API response missing priceInfo today/tomorrow") from exc
|
||||||
|
|
||||||
|
# tomorrow is null/empty until Tibber publishes the day-ahead prices; treat
|
||||||
|
# a missing list as empty so we still return today's slots.
|
||||||
|
nodes = list(today or []) + list(tomorrow or [])
|
||||||
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ credits:
|
|||||||
kind: tibber
|
kind: tibber
|
||||||
label: Tibber 动态电价(15 分钟)
|
label: Tibber 动态电价(15 分钟)
|
||||||
energy:
|
energy:
|
||||||
source: tibber_api # buy = total; sell = total − energy_tax − sell_adjust
|
source: tibber_api # buy = total; sell = total − energy_tax − sell_fee − sell_adjust
|
||||||
energy_tax: { unit: EUR/kWh }
|
energy_tax: { unit: EUR/kWh }
|
||||||
|
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding, always subtracted
|
||||||
sell_adjust: { unit: EUR/kWh, default: 0 }
|
sell_adjust: { unit: EUR/kWh, default: 0 }
|
||||||
standing:
|
standing:
|
||||||
management_fee: { unit: EUR/month, default: 5.99 }
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
@@ -164,7 +165,7 @@ credits:
|
|||||||
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**:`Δd1,Δd2,Δr1,Δr2`。
|
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**:`Δd1,Δd2,Δr1,Δr2`。
|
||||||
2. 取 active 合同**在 t0 生效的版本** + 其 strategy:
|
2. 取 active 合同**在 t0 生效的版本** + 其 strategy:
|
||||||
- `manual`:`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`(`buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
|
- `manual`:`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`(`buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
|
||||||
- `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_adjust`;`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
|
- `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding,默认 0.0248,见下修正说明);`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
|
||||||
3. `net_cost = import_cost − export_revenue`;**upsert** `energy_cost_period`,**快照**当时用的价 + `contract_version_id`。
|
3. `net_cost = import_cost − export_revenue`;**upsert** `energy_cost_period`,**快照**当时用的价 + `contract_version_id`。
|
||||||
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
|
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
|
||||||
|
|
||||||
@@ -240,7 +241,7 @@ credits:
|
|||||||
|
|
||||||
1. **两层电价模型**:profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
|
1. **两层电价模型**:profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
|
||||||
2. **kind 不叫 "fixed"**:`manual`(人工填、可双费率、可带时段)/ `tibber`(API 动态);合同 `name` UI 自由填。
|
2. **kind 不叫 "fixed"**:`manual`(人工填、可双费率、可带时段)/ `tibber`(API 动态);合同 `name` UI 自由填。
|
||||||
3. **买价**:tibber = API `total`(全包,已证 total=energy+tax);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_adjust`;manual = `sell_档`(回送价,无能源税)。均含 VAT。
|
3. **买价**:tibber = API `total`(全包,已证 total=energy+tax;含 inkoopvergoeding);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding 默认 0.0248);manual = `sell_档`(回送价,无能源税)。均含 VAT。
|
||||||
4. **双费率**:manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
|
4. **双费率**:manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
|
||||||
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
|
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
|
||||||
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
|
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
|
||||||
@@ -475,7 +476,7 @@ Phase D(API + 前端)
|
|||||||
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
|
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
|
||||||
|
|
||||||
1. **买价**:✅ tibber = API `total`(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
|
1. **买价**:✅ tibber = API `total`(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
|
||||||
2. **卖价残差(tibber)**:`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。
|
2. **卖价残差(tibber)**:~~`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)~~ → **已修正(2026-07,见 references §3.1)**:`total` 含 inkoopvergoeding,净计量回送 = `total − verkoopvergoeding`,两费**不抵消**。公式改为 `sell = total − energy_tax − sell_fee − sell_adjust`,新增 `sell_fee`(默认 0.0248,始终扣除);`sell_adjust` 净计量期设 `−energy_tax`。真实账单确认后若有残差再调 `sell_fee`。
|
||||||
3. **双费率寄存器映射**:`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
|
3. **双费率寄存器映射**:`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
|
||||||
4. **能源税年值**:manual/tibber 的 `energy_tax` 默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。
|
4. **能源税年值**:manual/tibber 的 `energy_tax` 默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。
|
||||||
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
|
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
|
||||||
|
|||||||
@@ -113,16 +113,26 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
|
|||||||
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
|
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
|
||||||
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
|
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
|
||||||
|
|
||||||
→ **买卖服务费相等(均 €0.0248/kWh)**,在买卖里一进一出**相互抵消**。
|
→ **买卖服务费金额相等(均 €0.0248/kWh),但两者对住户都是成本、不互相抵消**:
|
||||||
|
- 买侧 inkoopvergoeding 已经**包含在 Tibber API 的 `total` 里**(见下 §3.1 的实证拆解),买电按 `total` 计价即已含它。
|
||||||
|
- 卖侧 verkoopvergoeding 则是从回送价里**额外扣掉**的一笔——所以回送价 = `total − 0.0248`,比买价低 0.0248/kWh。
|
||||||
|
- ⚠️ **早期版本误判为"一进一出抵消 → 回送=total"**,这是错的:`total` 里那笔 inkoopvergoeding 不会退回来充抵 verkoopvergoeding。代码里用 `energy.sell_fee`(默认 0.0248)建模这笔卖侧费用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
|
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
|
||||||
|
|
||||||
### 3.1 回送价(净计量期内,文档原文)
|
### 3.1 回送价(净计量期内,文档原文 + 实证)
|
||||||
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
|
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
|
||||||
|
|
||||||
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`。因 inkoopvergoeding = verkoopvergoeding 抵消 → **= 全额零售价**(spot+能源税+VAT),正是 saldering "回送 1 度 = 用 1 度"的本质。
|
> **Worked example(Tibber NL 原文)**:"Stel dat tussen 14:00 en 14:15 de totale stroomprijs €0,28 per kWh incl. is, dan krijg je €0,28 − €0,0248 verkoopvergoeding = **€0,2552** per teruggeleverde kWh terug."
|
||||||
|
|
||||||
|
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`,而官方例子直接写成 **`回送价 = totale stroomprijs − verkoopvergoeding = total − 0.0248`**。能源税**退回**(留在 total 里没动),只有 verkoopvergoeding 这 0.0248 被扣。
|
||||||
|
|
||||||
|
**✅ 实证(本项目生产库,2026-07-20 三个刻钟)**:按 21% VAT 拆 `total`:`total = 现货×1.21 + energiebelasting(0.11085) + inkoopvergoeding(0.0248)`,三段解出的 inkoop 都精确等于 **0.0248**。→ **我们存的 `tibber_price.total` 就是官方 "totale stroomprijs"(含 inkoopvergoeding 的买价)**,因此:
|
||||||
|
- 买价 `buy = total`(已含 inkoopvergoeding,正确)。
|
||||||
|
- 净计量回送价 `sell = total − verkoopvergoeding = total − 0.0248`。
|
||||||
|
- ⚠️ 所以 saldering 下"回送 1 度"仍比"用 1 度"少 0.0248——**不是完全 1:1**。代码用 `sell_fee` 建模这笔扣减,`sell_adjust` 只负责在净计量期把能源税补回(`sell_adjust = −energy_tax`)。
|
||||||
|
|
||||||
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
|
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
|
||||||
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
|
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
|
||||||
@@ -146,9 +156,11 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
|
|||||||
|
|
||||||
> spot 取 API `energy`;`total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
|
> spot 取 API `energy`;`total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
|
||||||
|
|
||||||
- **Tibber 动态合同**(post-2027 口径):
|
- **Tibber 动态合同**:
|
||||||
- 买价 `buy = price.total`
|
- 买价 `buy = price.total`(含 energy_tax + VAT + inkoopvergoeding)
|
||||||
- 卖价 `sell = price.total − energy_tax_per_kwh − sell_adjust`(`sell_adjust` 默认 0;含 VAT 归己;买卖费抵消已隐含在 total 里)
|
- 卖价 `sell = price.total − energy_tax − sell_fee − sell_adjust`
|
||||||
|
- `sell_fee`:verkoopvergoeding(卖侧上网费,默认 **0.0248**,含 VAT),**始终扣除**——即使净计量期也扣(见 §3.1)。
|
||||||
|
- `sell_adjust`:手动修正项(默认 0)。**净计量期**设为 `−energy_tax`(把能源税补回),得 `sell = total − sell_fee`;**2027 取消净计量后**设为 0,得 `sell = total − energy_tax − sell_fee`(无能源税、纯市场价再扣上网费)。
|
||||||
- **固定合同(manual,双费率)**:
|
- **固定合同(manual,双费率)**:
|
||||||
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal})
|
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal})
|
||||||
- 卖价 `sell_档 = sell_档`(回送价,**无能源税**)
|
- 卖价 `sell_档 = sell_档`(回送价,**无能源税**)
|
||||||
@@ -242,7 +254,7 @@ extra_device_timestamp, extra_device_delivered # 燃气表(m³,每
|
|||||||
## 8. 待真实数据核对(合同生效后用真实 token / 账单)
|
## 8. 待真实数据核对(合同生效后用真实 token / 账单)
|
||||||
|
|
||||||
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
|
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
|
||||||
2. **卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉、回送 VAT 口径 → 调 `sell_adjust`(默认 0)。
|
2. ~~**卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉~~ → **已核实(2026-07)**:`total` 含 inkoopvergoeding(0.0248),净计量回送价 = `total − verkoopvergoeding(0.0248)`,两费**不抵消**;代码以 `sell_fee`(默认 0.0248)建模。仍待真实账单核对 `sell_fee` / VAT 口径的最终残差。
|
||||||
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
|
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
|
||||||
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`。
|
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`。
|
||||||
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
|
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
|
||||||
|
|||||||
Vendored
+1
-1
@@ -261,7 +261,7 @@ export interface paths {
|
|||||||
*
|
*
|
||||||
* Response ``points`` carries per-slot:
|
* Response ``points`` carries per-slot:
|
||||||
* - ``buy = total`` (Tibber all-inclusive price)
|
* - ``buy = total`` (Tibber all-inclusive price)
|
||||||
* - ``sell = total − energy_tax − sell_adjust`` (from active version values)
|
* - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)
|
||||||
* - ``level`` (Tibber price level, may be null)
|
* - ``level`` (Tibber price level, may be null)
|
||||||
*
|
*
|
||||||
* ``tariff`` is null.
|
* ``tariff`` is null.
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
* 2. Empty state (no active contract / no kind).
|
* 2. Empty state (no active contract / no kind).
|
||||||
* 3. Renders tibber chart when tibber kind data is available.
|
* 3. Renders tibber chart when tibber kind data is available.
|
||||||
* 4. Shows tariff table for manual kind.
|
* 4. Shows tariff table for manual kind.
|
||||||
|
* 5. Marks the currently active price slot (dot + caption).
|
||||||
|
* 6. Hovering past midnight resolves tomorrow's slot, not today's (regression).
|
||||||
|
* 7. buildChartRows: unique X keys across midnight.
|
||||||
|
* 8. findActiveSlotIndex: which slot is currently active.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { screen, waitFor } from '@testing-library/react'
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -42,7 +46,107 @@ vi.mock('../api/client', () => ({
|
|||||||
// Import component
|
// Import component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { TibberPrices } from './TibberPrices'
|
import { TibberPrices, buildChartRows, findActiveSlotIndex } from './TibberPrices'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart size harness
|
||||||
|
//
|
||||||
|
// jsdom reports every element as 0x0, so Recharts renders an empty plot and no
|
||||||
|
// pointer interaction is possible. These helpers hand the chart a fixed size:
|
||||||
|
// - the ResponsiveContainer gets 800x300 from its bounding rect + a ResizeObserver
|
||||||
|
// that reports the same size,
|
||||||
|
// - the chart wrapper reports 800x260 (the height the component asks for), which
|
||||||
|
// is what Recharts uses to translate clientX/clientY into chart coordinates,
|
||||||
|
// - everything else stays 0x0 so the legend does not eat the whole plot area.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHART_W = 800
|
||||||
|
const CONTAINER_H = 300
|
||||||
|
const CHART_H = 260
|
||||||
|
|
||||||
|
function fakeRect(width: number, height: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: width,
|
||||||
|
bottom: height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
toJSON: () => {},
|
||||||
|
} as DOMRect
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver
|
||||||
|
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
'offsetWidth',
|
||||||
|
)
|
||||||
|
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
'offsetHeight',
|
||||||
|
)
|
||||||
|
|
||||||
|
function installChartSize() {
|
||||||
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||||
|
if (this.classList.contains('recharts-responsive-container')) {
|
||||||
|
return fakeRect(CHART_W, CONTAINER_H)
|
||||||
|
}
|
||||||
|
if (this.classList.contains('recharts-wrapper')) return fakeRect(CHART_W, CHART_H)
|
||||||
|
return fakeRect(0, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recharts divides rect size by offset size to undo CSS transform scaling;
|
||||||
|
// matching them keeps the scale factor at 1.
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||||
|
configurable: true,
|
||||||
|
value: CHART_W,
|
||||||
|
})
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
||||||
|
configurable: true,
|
||||||
|
value: CHART_H,
|
||||||
|
})
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class implements ResizeObserver {
|
||||||
|
private readonly cb: ResizeObserverCallback
|
||||||
|
constructor(cb: ResizeObserverCallback) {
|
||||||
|
this.cb = cb
|
||||||
|
}
|
||||||
|
observe() {
|
||||||
|
this.cb(
|
||||||
|
[{ contentRect: { width: CHART_W, height: CONTAINER_H } } as ResizeObserverEntry],
|
||||||
|
this,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreChartSize() {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver
|
||||||
|
if (offsetWidthDescriptor) {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidthDescriptor)
|
||||||
|
}
|
||||||
|
if (offsetHeightDescriptor) {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeightDescriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hourly price points, one per hour starting at `startUtc`, with unique prices. */
|
||||||
|
function hourlyPoints(startUtc: number, count: number) {
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
starts_at: new Date(startUtc + i * 3600_000).toISOString(),
|
||||||
|
buy: 0.1 + i / 1000,
|
||||||
|
sell: 0.05 + i / 1000,
|
||||||
|
level: 'NORMAL',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipText(): string {
|
||||||
|
return document.querySelector('.recharts-tooltip-wrapper')?.textContent ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
@@ -50,6 +154,10 @@ import { TibberPrices } from './TibberPrices'
|
|||||||
|
|
||||||
describe('TibberPrices', () => {
|
describe('TibberPrices', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
restoreChartSize()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders loading state initially', () => {
|
it('renders loading state initially', () => {
|
||||||
mockGet.mockImplementation(() => new Promise(() => {}))
|
mockGet.mockImplementation(() => new Promise(() => {}))
|
||||||
@@ -135,4 +243,146 @@ describe('TibberPrices', () => {
|
|||||||
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
|
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
|
||||||
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
|
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks the currently active price slot with a dot and a caption', async () => {
|
||||||
|
installChartSize()
|
||||||
|
|
||||||
|
const SLOT_MS = 15 * 60 * 1000
|
||||||
|
// Start of the quarter-hour slot that contains "now".
|
||||||
|
const currentSlot = Math.floor(Date.now() / SLOT_MS) * SLOT_MS
|
||||||
|
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
kind: 'tibber',
|
||||||
|
currency: 'EUR',
|
||||||
|
points: [
|
||||||
|
{ starts_at: new Date(currentSlot - SLOT_MS).toISOString(), buy: 0.11, sell: 0.05 },
|
||||||
|
{ starts_at: new Date(currentSlot).toISOString(), buy: 0.2431, sell: 0.1102 },
|
||||||
|
{ starts_at: new Date(currentSlot + SLOT_MS).toISOString(), buy: 0.31, sell: 0.15 },
|
||||||
|
],
|
||||||
|
tariff: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tibber-current-price')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const marker = screen.getByTestId('tibber-current-price')
|
||||||
|
expect(marker).toHaveTextContent('0.2431')
|
||||||
|
expect(marker).toHaveTextContent('0.1102')
|
||||||
|
|
||||||
|
// One dot on the buy line, one on the sell line — visible without hovering.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('.recharts-reference-dot')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves the hovered slot past midnight to tomorrow, not today', async () => {
|
||||||
|
installChartSize()
|
||||||
|
|
||||||
|
// 26 hourly points starting at 2020-01-01T00:00Z, so "00:00" and "01:00"
|
||||||
|
// each appear twice. Fixed past dates keep the "now" marker out of range.
|
||||||
|
const points = hourlyPoints(Date.UTC(2020, 0, 1), 26)
|
||||||
|
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { kind: 'tibber', currency: 'EUR', points, tariff: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('tibber-chart')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByTestId('tibber-current-price')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const wrapper = document.querySelector('.recharts-wrapper')
|
||||||
|
expect(wrapper).not.toBeNull()
|
||||||
|
|
||||||
|
// Right edge of the plot area = the last slot (day 2, 01:00, buy 0.1250).
|
||||||
|
fireEvent.mouseMove(wrapper!, { clientX: 770, clientY: CHART_H / 2 })
|
||||||
|
|
||||||
|
await waitFor(() => expect(tooltipText()).toContain('0.1250'))
|
||||||
|
|
||||||
|
// Label carries the date, so day 2 is distinguishable from day 1.
|
||||||
|
expect(tooltipText()).toContain('1/2/2020')
|
||||||
|
expect(tooltipText()).toContain('0.0750')
|
||||||
|
|
||||||
|
// The active dots must sit on the hovered point (right half of the plot).
|
||||||
|
// The bug put them on day 1's identically-labelled slot near the left edge.
|
||||||
|
const dots = Array.from(document.querySelectorAll('.recharts-active-dot circle'))
|
||||||
|
expect(dots).toHaveLength(2)
|
||||||
|
for (const dot of dots) {
|
||||||
|
expect(Number(dot.getAttribute('cx'))).toBeGreaterThan(CHART_W / 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildChartRows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('buildChartRows', () => {
|
||||||
|
it('keeps X-axis keys unique across midnight', () => {
|
||||||
|
// Same local time-of-day on two consecutive days: as "HH:mm" labels these
|
||||||
|
// collided, which made Recharts resolve the hovered point to the first match
|
||||||
|
// (today) instead of the hovered one (tomorrow).
|
||||||
|
const rows = buildChartRows([
|
||||||
|
{ starts_at: '2026-07-26T22:00:00Z', buy: 0.1, sell: 0.05 },
|
||||||
|
{ starts_at: '2026-07-27T22:00:00Z', buy: 0.2, sell: 0.06 },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(new Set(rows.map((r) => r.ts)).size).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts rows by slot start and parses naive timestamps as UTC', () => {
|
||||||
|
const rows = buildChartRows([
|
||||||
|
{ starts_at: '2026-07-27T02:00:00', buy: 0.3, sell: 0.07 },
|
||||||
|
{ starts_at: '2026-07-27T01:00:00Z', buy: 0.2, sell: 0.06 },
|
||||||
|
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(rows.map((r) => r.buy)).toEqual([0.1, 0.2, 0.3])
|
||||||
|
expect(rows.map((r) => r.ts)).toEqual([
|
||||||
|
'2026-07-27T00:00:00.000Z',
|
||||||
|
'2026-07-27T01:00:00.000Z',
|
||||||
|
'2026-07-27T02:00:00.000Z',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findActiveSlotIndex
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('findActiveSlotIndex', () => {
|
||||||
|
const rows = buildChartRows([
|
||||||
|
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
|
||||||
|
{ starts_at: '2026-07-27T00:15:00Z', buy: 0.2, sell: 0.06 },
|
||||||
|
{ starts_at: '2026-07-27T00:30:00Z', buy: 0.3, sell: 0.07 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const at = (iso: string) => new Date(iso).getTime()
|
||||||
|
|
||||||
|
it('returns the slot containing now', () => {
|
||||||
|
expect(findActiveSlotIndex(rows, at('2026-07-27T00:20:00Z'))).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the slot at its exact start boundary', () => {
|
||||||
|
expect(findActiveSlotIndex(rows, at('2026-07-27T00:15:00Z'))).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null before the first slot', () => {
|
||||||
|
expect(findActiveSlotIndex(rows, at('2026-07-26T23:59:00Z'))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays on the last slot until its inferred end, then returns null', () => {
|
||||||
|
expect(findActiveSlotIndex(rows, at('2026-07-27T00:44:00Z'))).toBe(2)
|
||||||
|
expect(findActiveSlotIndex(rows, at('2026-07-27T00:45:00Z'))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for empty data', () => {
|
||||||
|
expect(findActiveSlotIndex([], Date.now())).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* TibberPrices — price curve visualization.
|
* TibberPrices — price curve visualization.
|
||||||
*
|
*
|
||||||
* - Fetches today + tomorrow price range using useEnergyPrices.
|
* - Fetches today + tomorrow price range using useEnergyPrices.
|
||||||
* - For tibber kind: Recharts LineChart showing buy/sell prices over time.
|
* - For tibber kind: Recharts LineChart showing buy/sell prices over time,
|
||||||
|
* with the currently active price slot marked by a dot.
|
||||||
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
|
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
|
||||||
* - Handles: no active contract, empty data, loading, error.
|
* - Handles: no active contract, empty data, loading, error.
|
||||||
*
|
*
|
||||||
* Recharts imports are isolated to this file only.
|
* Recharts imports are isolated to this file only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -29,10 +31,20 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
|
ReferenceDot,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { useEnergyPrices } from './hooks'
|
import { useEnergyPrices } from './hooks'
|
||||||
import { formatLocalTime } from '../utils/datetime'
|
import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
|
||||||
|
|
||||||
|
const BUY_COLOR = '#2196f3'
|
||||||
|
const SELL_COLOR = '#4caf50'
|
||||||
|
|
||||||
|
/** Slot length assumed for the very last point, when no next point bounds it. */
|
||||||
|
const FALLBACK_SLOT_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** How often the "current price" marker re-evaluates which slot is active. */
|
||||||
|
const NOW_TICK_MS = 30 * 1000
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Time range helpers
|
// Time range helpers
|
||||||
@@ -51,34 +63,121 @@ function getTomorrowEnd(): string {
|
|||||||
return d.toISOString()
|
return d.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart data helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PricePoint {
|
||||||
|
starts_at: string
|
||||||
|
buy: number
|
||||||
|
sell: number
|
||||||
|
level?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartRow {
|
||||||
|
/**
|
||||||
|
* X-axis category key — the full instant, NOT a "HH:mm" label.
|
||||||
|
*
|
||||||
|
* Must be unique per slot: Recharts resolves the hovered point by *value*
|
||||||
|
* (findEntryInArray on the axis dataKey), so a repeated key makes the tooltip
|
||||||
|
* and the active dot snap back to the first match. With "HH:mm" labels, every
|
||||||
|
* time of day appears twice in a today+tomorrow range, which pinned the dot on
|
||||||
|
* today once the cursor passed midnight. Formatting to HH:mm happens in the
|
||||||
|
* tick / tooltip formatters instead.
|
||||||
|
*/
|
||||||
|
ts: string
|
||||||
|
/** Slot start as epoch ms; NaN when starts_at is unparseable. */
|
||||||
|
tsMs: number
|
||||||
|
buy: number
|
||||||
|
sell: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map API price points to chart rows with unique X keys, sorted by slot start. */
|
||||||
|
export function buildChartRows(points: PricePoint[]): ChartRow[] {
|
||||||
|
return points
|
||||||
|
.map((p) => {
|
||||||
|
const d = parseBackendTimestamp(p.starts_at)
|
||||||
|
const tsMs = d.getTime()
|
||||||
|
return {
|
||||||
|
ts: Number.isFinite(tsMs) ? d.toISOString() : p.starts_at,
|
||||||
|
tsMs,
|
||||||
|
buy: p.buy,
|
||||||
|
sell: p.sell,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Unparseable timestamps sort last so the ascending scan below can stop early.
|
||||||
|
if (!Number.isFinite(a.tsMs)) return Number.isFinite(b.tsMs) ? 1 : 0
|
||||||
|
if (!Number.isFinite(b.tsMs)) return -1
|
||||||
|
return a.tsMs - b.tsMs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the row whose slot contains `nowMs`, or null when now is outside the
|
||||||
|
* fetched range. A slot ends where the next one starts; the last row has no next
|
||||||
|
* slot, so it falls back to the series spacing (quarter-hourly for Tibber).
|
||||||
|
*/
|
||||||
|
export function findActiveSlotIndex(rows: ChartRow[], nowMs: number): number | null {
|
||||||
|
let idx = -1
|
||||||
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
|
if (!Number.isFinite(rows[i].tsMs) || rows[i].tsMs > nowMs) break
|
||||||
|
idx = i
|
||||||
|
}
|
||||||
|
if (idx < 0) return null
|
||||||
|
|
||||||
|
const spacing = rows.length > 1 ? rows[1].tsMs - rows[0].tsMs : NaN
|
||||||
|
const slotMs = Number.isFinite(spacing) && spacing > 0 ? spacing : FALLBACK_SLOT_MS
|
||||||
|
const slotEnd = idx + 1 < rows.length ? rows[idx + 1].tsMs : rows[idx].tsMs + slotMs
|
||||||
|
return nowMs < slotEnd ? idx : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticking clock so the active-slot marker follows slot boundaries while open. */
|
||||||
|
function useNowMs(intervalMs = NOW_TICK_MS): number {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), intervalMs)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [intervalMs])
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tibber chart
|
// Tibber chart
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface TibberChartProps {
|
interface TibberChartProps {
|
||||||
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
|
points: PricePoint[]
|
||||||
currency: string
|
currency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function TibberChart({ points, currency }: TibberChartProps) {
|
function TibberChart({ points, currency }: TibberChartProps) {
|
||||||
const data = points.map((p) => ({
|
const data = useMemo(() => buildChartRows(points), [points])
|
||||||
time: formatLocalTime(p.starts_at),
|
const nowMs = useNowMs()
|
||||||
buy: p.buy,
|
const activeIndex = findActiveSlotIndex(data, nowMs)
|
||||||
sell: p.sell,
|
const activeRow = activeIndex == null ? null : data[activeIndex]
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" data-testid="tibber-chart">
|
<Stack gap="xs" data-testid="tibber-chart">
|
||||||
<Title order={6} c="dimmed">
|
<Group gap="xs" justify="space-between" align="baseline">
|
||||||
Price curve ({currency})
|
<Title order={6} c="dimmed">
|
||||||
</Title>
|
Price curve ({currency})
|
||||||
|
</Title>
|
||||||
|
{activeRow && (
|
||||||
|
<Text size="xs" c="dimmed" data-testid="tibber-current-price">
|
||||||
|
Now {formatLocalTime(activeRow.ts)} · buy {activeRow.buy.toFixed(4)} · sell{' '}
|
||||||
|
{activeRow.sell.toFixed(4)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="ts"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
interval="preserveStartEnd"
|
interval="preserveStartEnd"
|
||||||
|
tickFormatter={(v: string) => formatLocalTime(v)}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
@@ -89,12 +188,17 @@ function TibberChart({ points, currency }: TibberChartProps) {
|
|||||||
formatter={(val: any) =>
|
formatter={(val: any) =>
|
||||||
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
||||||
}
|
}
|
||||||
|
labelFormatter={(label) =>
|
||||||
|
typeof label === 'string'
|
||||||
|
? `${formatLocalDate(label)} ${formatLocalTime(label)}`
|
||||||
|
: label
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="buy"
|
dataKey="buy"
|
||||||
stroke="#2196f3"
|
stroke={BUY_COLOR}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Buy"
|
name="Buy"
|
||||||
@@ -102,11 +206,32 @@ function TibberChart({ points, currency }: TibberChartProps) {
|
|||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="sell"
|
dataKey="sell"
|
||||||
stroke="#4caf50"
|
stroke={SELL_COLOR}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Sell"
|
name="Sell"
|
||||||
/>
|
/>
|
||||||
|
{/* Currently active price slot, marked by default (no hover needed). */}
|
||||||
|
{activeRow && (
|
||||||
|
<ReferenceDot
|
||||||
|
x={activeRow.ts}
|
||||||
|
y={activeRow.buy}
|
||||||
|
r={4}
|
||||||
|
fill={BUY_COLOR}
|
||||||
|
stroke="#fff"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeRow && (
|
||||||
|
<ReferenceDot
|
||||||
|
x={activeRow.ts}
|
||||||
|
y={activeRow.sell}
|
||||||
|
r={4}
|
||||||
|
fill={SELL_COLOR}
|
||||||
|
stroke="#fff"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -701,7 +701,7 @@
|
|||||||
"api-energy"
|
"api-energy"
|
||||||
],
|
],
|
||||||
"summary": "Get Prices",
|
"summary": "Get Prices",
|
||||||
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
|
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
|
||||||
"operationId": "get_prices_api_energy_prices_get",
|
"operationId": "get_prices_api_energy_prices_get",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -527,9 +527,9 @@ paths:
|
|||||||
\ (most recent first\n within the window, then reversed to ascending order\
|
\ (most recent first\n within the window, then reversed to ascending order\
|
||||||
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
|
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
|
||||||
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
|
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
|
||||||
\ price)\n - ``sell = total − energy_tax − sell_adjust`` (from active\
|
\ price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from\
|
||||||
\ version values)\n - ``level`` (Tibber price level,\
|
\ active version values)\n - ``level`` (Tibber price\
|
||||||
\ may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
|
\ level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
|
||||||
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
|
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
|
||||||
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
|
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
|
||||||
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
|
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
|
||||||
|
|||||||
@@ -363,13 +363,65 @@ def test_prices_tibber_contract_returns_points(energy_client):
|
|||||||
starts_at_list = [p["starts_at"] for p in body["points"]]
|
starts_at_list = [p["starts_at"] for p in body["points"]]
|
||||||
assert starts_at_list == sorted(starts_at_list)
|
assert starts_at_list == sorted(starts_at_list)
|
||||||
|
|
||||||
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_adjust=0.245-0.1108-0.0
|
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_fee-sell_adjust
|
||||||
|
# (this version has no sell_fee/sell_adjust → both default to 0 at read time).
|
||||||
for p in body["points"]:
|
for p in body["points"]:
|
||||||
assert abs(p["buy"] - 0.245) < 1e-6
|
assert abs(p["buy"] - 0.245) < 1e-6
|
||||||
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
|
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
|
||||||
assert p["level"] == "NORMAL"
|
assert p["level"] == "NORMAL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prices_tibber_sell_reflects_sell_fee(energy_client):
|
||||||
|
"""/prices sell price deducts sell_fee (verkoopvergoeding), net-metering config."""
|
||||||
|
client, engine, _app = energy_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Net-metering version: sell_adjust = −energy_tax (refund tax), sell_fee = 0.0248.
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="Tibber NetMeter",
|
||||||
|
kind="tibber",
|
||||||
|
active=True,
|
||||||
|
currency="EUR",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.flush()
|
||||||
|
session.add(
|
||||||
|
EnergyContractVersion(
|
||||||
|
contract_id=contract.id,
|
||||||
|
effective_from=now - timedelta(days=30),
|
||||||
|
effective_to=None,
|
||||||
|
values={
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"sell_fee": 0.0248,
|
||||||
|
"sell_adjust": -0.1108,
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 25.0},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
_make_tibber_prices(engine, count=3)
|
||||||
|
|
||||||
|
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
|
||||||
|
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
|
||||||
|
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "tibber"
|
||||||
|
assert len(body["points"]) == 3
|
||||||
|
# sell = 0.245 − 0.1108 − 0.0248 − (−0.1108) = 0.245 − 0.0248 = 0.2202
|
||||||
|
for p in body["points"]:
|
||||||
|
assert abs(p["buy"] - 0.245) < 1e-6
|
||||||
|
assert abs(p["sell"] - 0.2202) < 1e-4
|
||||||
|
|
||||||
|
|
||||||
def test_prices_tibber_limit_caps_results(energy_client):
|
def test_prices_tibber_limit_caps_results(energy_client):
|
||||||
client, engine, _app = energy_client
|
client, engine, _app = energy_client
|
||||||
_login(client)
|
_login(client)
|
||||||
|
|||||||
@@ -1263,6 +1263,9 @@ class TestSummarizePrincipleC:
|
|||||||
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
||||||
"""A fully future window (all local dates > today) counts 0 days.
|
"""A fully future window (all local dates > today) counts 0 days.
|
||||||
|
|
||||||
|
Pins ``local_now`` to June 25 2026 noon AMS so the 7/1→8/1 window is
|
||||||
|
genuinely in the future regardless of the actual wall-clock date
|
||||||
|
(mirrors the sibling window tests, which all pin ``local_now``).
|
||||||
Matches table row: 7/1→8/1 (all future) → 0 days.
|
Matches table row: 7/1→8/1 (all future) → 0 days.
|
||||||
"""
|
"""
|
||||||
eff_utc = _ams_midnight(2026, 6, 1)
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
@@ -1270,7 +1273,9 @@ class TestSummarizePrincipleC:
|
|||||||
|
|
||||||
start = _ams_midnight(2026, 7, 1)
|
start = _ams_midnight(2026, 7, 1)
|
||||||
end = _ams_midnight(2026, 8, 1)
|
end = _ams_midnight(2026, 8, 1)
|
||||||
result = self._run_summarize_ams(energy_db, start, end)
|
# Pin local_now to June 25 2026 noon AMS so 7/1→8/1 stays fully future.
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
||||||
|
|
||||||
assert result["fixed_costs"] == 0.0, (
|
assert result["fixed_costs"] == 0.0, (
|
||||||
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ class TestLoadProfileTibber:
|
|||||||
profile = load_profile("tibber")
|
profile = load_profile("tibber")
|
||||||
assert profile.energy.sell_adjust.default == 0
|
assert profile.energy.sell_adjust.default == 0
|
||||||
|
|
||||||
|
def test_sell_fee_has_default_verkoopvergoeding(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.energy.sell_fee.unit == "EUR/kWh"
|
||||||
|
assert profile.energy.sell_fee.default == 0.0248
|
||||||
|
|
||||||
def test_management_fee_has_default(self) -> None:
|
def test_management_fee_has_default(self) -> None:
|
||||||
profile = load_profile("tibber")
|
profile = load_profile("tibber")
|
||||||
assert profile.standing.management_fee.default is not None
|
assert profile.standing.management_fee.default is not None
|
||||||
@@ -350,6 +355,19 @@ class TestValidateValuesTibber:
|
|||||||
filled = validate_values("tibber", values)
|
filled = validate_values("tibber", values)
|
||||||
assert filled["energy"]["sell_adjust"] == 0
|
assert filled["energy"]["sell_adjust"] == 0
|
||||||
|
|
||||||
|
def test_sell_fee_default_applied_when_absent(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"sell_adjust": 0.0,
|
||||||
|
# sell_fee absent — has default 0.0248 (verkoopvergoeding)
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
filled = validate_values("tibber", values)
|
||||||
|
assert filled["energy"]["sell_fee"] == 0.0248
|
||||||
|
|
||||||
def test_management_fee_default_applied_when_absent(self) -> None:
|
def test_management_fee_default_applied_when_absent(self) -> None:
|
||||||
values = {
|
values = {
|
||||||
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
|
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Acceptance criteria covered
|
|||||||
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
|
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
|
||||||
3. Manual strategy: Decimal precision — no float binary rounding errors.
|
3. Manual strategy: Decimal precision — no float binary rounding errors.
|
||||||
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
|
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
|
||||||
5. Tibber strategy: buy=total, sell=total−energy_tax−sell_adjust.
|
5. Tibber strategy: buy=total, sell=total−energy_tax−sell_fee−sell_adjust.
|
||||||
6. Tibber strategy: negative total → negative export_revenue (not clamped).
|
6. Tibber strategy: negative total → negative export_revenue (not clamped).
|
||||||
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
|
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
|
||||||
8. ``register_strategy`` / ``get_strategy`` round-trip works.
|
8. ``register_strategy`` / ``get_strategy`` round-trip works.
|
||||||
@@ -371,6 +371,68 @@ class TestTibberStrategy:
|
|||||||
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
|
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
|
||||||
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
|
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
|
||||||
|
|
||||||
|
def test_sell_deducts_sell_fee(self, tibber_db) -> None:
|
||||||
|
"""verkoopvergoeding (sell_fee) is subtracted from the feed-in price.
|
||||||
|
|
||||||
|
Under net metering the energy tax is refunded (sell_adjust = −energy_tax),
|
||||||
|
so sell should equal total − sell_fee. Verifies the fee is a first-class,
|
||||||
|
always-deducted term and does NOT cancel against the buy-side inkoopvergoeding
|
||||||
|
that is already baked into total.
|
||||||
|
"""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3073)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Net-metering config: sell_adjust = −energy_tax refunds the tax;
|
||||||
|
# sell_fee = 0.0248 (Tibber verkoopvergoeding) is still deducted.
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.11085,
|
||||||
|
"sell_fee": 0.0248,
|
||||||
|
"sell_adjust": -0.11085,
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session, values=values)
|
||||||
|
|
||||||
|
# sell = 0.3073 − 0.11085 − 0.0248 − (−0.11085) = 0.3073 − 0.0248 = 0.2825
|
||||||
|
expected_sell = (
|
||||||
|
Decimal("0.3073") - Decimal("0.11085") - Decimal("0.0248") - Decimal("-0.11085")
|
||||||
|
)
|
||||||
|
assert expected_sell == Decimal("0.2825")
|
||||||
|
assert result["export_revenue"] == Decimal("1") * expected_sell
|
||||||
|
assert result["pricing"]["sell_fee"] == "0.0248"
|
||||||
|
assert Decimal(result["pricing"]["sell"]) == Decimal("0.2825")
|
||||||
|
|
||||||
|
def test_sell_fee_absent_defaults_to_zero(self, tibber_db) -> None:
|
||||||
|
"""A version without sell_fee (pre-migration) reads it as 0 — no silent deduction."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"energy": {"energy_tax": 0.10, "sell_adjust": 0.0}, # no sell_fee key
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session, values=values)
|
||||||
|
# sell = 0.25 − 0.10 − 0 − 0 = 0.15
|
||||||
|
assert result["export_revenue"] == Decimal("0.15")
|
||||||
|
assert result["pricing"]["sell_fee"] == "0"
|
||||||
|
|
||||||
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
|
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
|
||||||
"""Correct row: starts_at ≤ t0, most recent wins."""
|
"""Correct row: starts_at ≤ t0, most recent wins."""
|
||||||
t0 = _ts(10, 0)
|
t0 = _ts(10, 0)
|
||||||
|
|||||||
+90
-35
@@ -69,22 +69,28 @@ _THREE_NODES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
_PRICE_RANGE_RESPONSE = {
|
def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict:
|
||||||
"data": {
|
"""Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response."""
|
||||||
"viewer": {
|
return {
|
||||||
"homes": [
|
"data": {
|
||||||
{
|
"viewer": {
|
||||||
"id": "home-id-1",
|
"homes": [
|
||||||
"currentSubscription": {
|
{
|
||||||
"priceInfoRange": {
|
"id": "home-id-1",
|
||||||
"nodes": _THREE_NODES,
|
"currentSubscription": {
|
||||||
}
|
"priceInfo": {
|
||||||
},
|
"today": today,
|
||||||
}
|
"tomorrow": tomorrow if tomorrow is not None else [],
|
||||||
]
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
_PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES)
|
||||||
|
|
||||||
_CURRENT_PRICE_RESPONSE = {
|
_CURRENT_PRICE_RESPONSE = {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -173,23 +179,8 @@ def test_fetch_price_range_parses_nodes(monkeypatch):
|
|||||||
|
|
||||||
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
||||||
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
||||||
# Build a response with a single node only.
|
# Build a response with a single today node and no tomorrow yet.
|
||||||
one_node_response = {
|
one_node_response = _price_info_response([_THREE_NODES[0]])
|
||||||
"data": {
|
|
||||||
"viewer": {
|
|
||||||
"homes": [
|
|
||||||
{
|
|
||||||
"id": "home-id-1",
|
|
||||||
"currentSubscription": {
|
|
||||||
"priceInfoRange": {
|
|
||||||
"nodes": [_THREE_NODES[0]],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transport = _make_transport(200, one_node_response)
|
transport = _make_transport(200, one_node_response)
|
||||||
|
|
||||||
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
@@ -202,6 +193,68 @@ def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
|||||||
assert len(points) == 1
|
assert len(points) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_concatenates_today_and_tomorrow(monkeypatch):
|
||||||
|
"""today and tomorrow node lists are both parsed (today first, then tomorrow)."""
|
||||||
|
tomorrow_nodes = [
|
||||||
|
{
|
||||||
|
"startsAt": "2026-06-24T00:00:00.000+02:00",
|
||||||
|
"total": 0.40,
|
||||||
|
"energy": 0.32,
|
||||||
|
"tax": 0.08,
|
||||||
|
"currency": "EUR",
|
||||||
|
"level": "EXPENSIVE",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
response = _price_info_response(_THREE_NODES, tomorrow_nodes)
|
||||||
|
transport = _make_transport(200, response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
# 3 today + 1 tomorrow, in order.
|
||||||
|
assert len(points) == 4
|
||||||
|
# First node is today's first; last node is tomorrow's.
|
||||||
|
assert points[0].starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC)
|
||||||
|
# 2026-06-24T00:00:00+02:00 → 2026-06-23T22:00:00Z
|
||||||
|
assert points[-1].starts_at == datetime(2026, 6, 23, 22, 0, 0, tzinfo=UTC)
|
||||||
|
assert points[-1].total == pytest.approx(0.40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_tomorrow_null_returns_today_only(monkeypatch):
|
||||||
|
"""A null tomorrow (before day-ahead publication) yields today's nodes only."""
|
||||||
|
response = {
|
||||||
|
"data": {
|
||||||
|
"viewer": {
|
||||||
|
"homes": [
|
||||||
|
{
|
||||||
|
"id": "home-id-1",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfo": {
|
||||||
|
"today": _THREE_NODES,
|
||||||
|
"tomorrow": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport = _make_transport(200, response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
assert len(points) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_price_range_home_id_selection(monkeypatch):
|
def test_fetch_price_range_home_id_selection(monkeypatch):
|
||||||
"""When home_id is specified, the matching home is selected."""
|
"""When home_id is specified, the matching home is selected."""
|
||||||
two_homes_response = {
|
two_homes_response = {
|
||||||
@@ -211,16 +264,18 @@ def test_fetch_price_range_home_id_selection(monkeypatch):
|
|||||||
{
|
{
|
||||||
"id": "home-id-first",
|
"id": "home-id-first",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": [_THREE_NODES[0]],
|
"today": [_THREE_NODES[0]],
|
||||||
|
"tomorrow": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "home-id-second",
|
"id": "home-id-second",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": [_THREE_NODES[1], _THREE_NODES[2]],
|
"today": [_THREE_NODES[1], _THREE_NODES[2]],
|
||||||
|
"tomorrow": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user