Skip to content
Closed

FIT #3779

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
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,46 @@ mkdocs serve # Live preview at http://localhost:8000
```

When adding a new doc page, add it to `mkdocs.yml`. The published site at <https://springfall2008.github.io/batpred/> is built automatically from `main` via GitHub Actions.

## Feed-in Tariff (FIT) Support

Predbat supports UK Feed-in Tariff schemes where users earn a generation tariff on all solar production plus a deemed export payment on a percentage of generation (typically 50%).

### How It Works

When `metric_fit_generation_rate` is set above 0 (Expert Mode), FIT mode is activated:

- **Export rate zeroed in optimizer**: Since deemed export pays regardless of actual export, the optimizer treats actual export as having zero additional value. This makes the optimizer prefer self-consumption of solar over exporting it.
- **Battery headroom for solar**: The optimizer will not charge the battery to 100% from the grid when solar generation is forecast, leaving room for solar to charge the battery during the day.
- **FIT income tracked**: Generation and deemed export income are subtracted from the cost metric for accurate cost/savings display.

### Config Items

All under Expert Mode in `config.py`:

| Setting | Default | Description |
|---------|---------|-------------|
| `metric_fit_generation_rate` | 0 p/kWh | FIT generation tariff rate |
| `metric_fit_deemed_export_rate` | 0 p/kWh | Deemed export tariff rate |
| `metric_fit_deemed_export_percentage` | 50% | Deemed export percentage |

### HA Sensors (when FIT enabled)

| Sensor | Description |
|--------|-------------|
| `predbat.fit_income` | Predicted FIT income (base plan) |
| `predbat.fit_income_best` | Predicted FIT income (best/optimised plan) |
| `predbat.fit_income_yesterday` | Predicted FIT income for yesterday's baseline |

All sensors include attributes: `generation_income`, `deemed_export_income`, `generation_rate`, `deemed_export_rate`, `deemed_export_percentage`.

### Key Files

| File | What changed |
|------|-------------|
| `config.py` | Three new `CONFIG_ITEMS` entries for FIT settings |
| `fetch.py` | Loads FIT config values, logs when FIT is enabled |
| `prediction.py` | Zeros export rate when FIT enabled; tracks FIT income per simulation step |
| `plan.py` | Extracts FIT income from prediction results; publishes `fit_income` / `fit_income_best` sensors |
| `output.py` | Extracts FIT income from yesterday predictions; publishes `fit_income_yesterday` sensor |
| `tests/test_infra.py` | FIT defaults added to test config and `reset_inverter()` |
36 changes: 36 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,42 @@
"enable": "expert_mode",
"default": 0.0,
},
{
"name": "metric_fit_generation_rate",
"friendly_name": "FIT Generation Rate",
"type": "input_number",
"min": 0,
"max": 100,
"step": 0.1,
"unit": "p/kWh",
"icon": "mdi:solar-power",
"enable": "expert_mode",
"default": 0,
},
{
"name": "metric_fit_deemed_export_rate",
"friendly_name": "FIT Deemed Export Rate",
"type": "input_number",
"min": 0,
"max": 100,
"step": 0.1,
"unit": "p/kWh",
"icon": "mdi:transmission-tower-export",
"enable": "expert_mode",
"default": 0,
},
{
"name": "metric_fit_deemed_export_percentage",
"friendly_name": "FIT Deemed Export Percentage",
"type": "input_number",
"min": 0,
"max": 100,
"step": 1,
"unit": "%",
"icon": "mdi:percent",
"enable": "expert_mode",
"default": 50,
},
{
"name": "set_reserve_min",
"friendly_name": "Set Reserve Min (Inverter 1)",
Expand Down
5 changes: 5 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,11 @@ def fetch_config_options(self):
self.metric_min_improvement_export_freeze = self.get_arg("metric_min_improvement_export_freeze")
self.metric_battery_cycle = self.get_arg("metric_battery_cycle")
self.metric_self_sufficiency = self.get_arg("metric_self_sufficiency")
self.metric_fit_generation_rate = self.get_arg("metric_fit_generation_rate")
self.metric_fit_deemed_export_rate = self.get_arg("metric_fit_deemed_export_rate")
self.metric_fit_deemed_export_percentage = self.get_arg("metric_fit_deemed_export_percentage")
if self.metric_fit_generation_rate > 0:
self.log("FIT enabled: generation rate {}p/kWh, deemed export rate {}p/kWh at {}%".format(dp2(self.metric_fit_generation_rate), dp2(self.metric_fit_deemed_export_rate), dp2(self.metric_fit_deemed_export_percentage)))
self.metric_future_rate_offset_import = self.get_arg("metric_future_rate_offset_import")
self.metric_future_rate_offset_export = self.get_arg("metric_future_rate_offset_export")
self.metric_inday_adjust_damping = self.get_arg("metric_inday_adjust_damping")
Expand Down
27 changes: 27 additions & 0 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2885,6 +2885,10 @@ def calculate_yesterday(self):
final_carbon_g,
) = self.run_prediction(charge_limit_best, charge_window_best, [], [], False, end_record=end_record, save="yesterday")

# Extract FIT income from the baseline prediction
fit_generation_income_baseline = self.prediction.final_fit_generation_income
fit_deemed_export_income_baseline = self.prediction.final_fit_deemed_export_income

# Add back in battery value
overall_metric, battery_value_baseline = self.compute_metric(
end_record, final_soc, final_soc, metric_baseline, metric_baseline, final_iboost, final_iboost, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh
Expand Down Expand Up @@ -3079,13 +3083,36 @@ def calculate_yesterday(self):
},
)

# Publish FIT income for yesterday's baseline prediction
if self.metric_fit_generation_rate > 0:
fit_total_income_yesterday = fit_generation_income_baseline + fit_deemed_export_income_baseline
self.dashboard_item(
self.prefix + ".fit_income_yesterday",
state=dp2(fit_total_income_yesterday),
attributes={
"friendly_name": "Predicted FIT income yesterday",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:solar-power",
"generation_income": dp2(fit_generation_income_baseline),
"deemed_export_income": dp2(fit_deemed_export_income_baseline),
"generation_rate": dp2(self.metric_fit_generation_rate),
"deemed_export_rate": dp2(self.metric_fit_deemed_export_rate),
"deemed_export_percentage": dp2(self.metric_fit_deemed_export_percentage),
},
)

# Simulate no PV or battery
self.soc_kw = 0
self.soc_max = 0

self.prediction = Prediction(self, yesterday_pv_step_zero, yesterday_pv_step_zero, yesterday_load_step, yesterday_load_step)
metric_no_pvbat, import_kwh_battery, import_kwh_house, export_kwh, soc_min, final_soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = self.run_prediction([], [], [], [], False, end_record=end_record, save="yesterday")

# Extract FIT income from the no-PV/battery prediction (will be zero since no PV)
fit_generation_income_no_pvbat = self.prediction.final_fit_generation_income
fit_deemed_export_income_no_pvbat = self.prediction.final_fit_deemed_export_income

# Add back in battery value
overall_metric, battery_value_no_pvbat = self.compute_metric(
end_record, final_soc, final_soc, metric_no_pvbat, metric_no_pvbat, final_iboost, final_iboost, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh
Expand Down
36 changes: 36 additions & 0 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -3361,6 +3361,8 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
final_import_kwh = pred.final_import_kwh
final_import_kwh_house = pred.final_import_kwh_house
final_import_kwh_battery = pred.final_import_kwh_battery
final_fit_generation_income = pred.final_fit_generation_income
final_fit_deemed_export_income = pred.final_fit_deemed_export_income
hours_left = pred.hours_left
final_car_soc = pred.final_car_soc
import_kwh_h0 = pred.import_kwh_h0
Expand Down Expand Up @@ -3590,6 +3592,23 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
"icon": "mdi:molecule-co2",
},
)
if self.metric_fit_generation_rate > 0:
fit_total_income = final_fit_generation_income + final_fit_deemed_export_income
self.dashboard_item(
self.prefix + ".fit_income",
state=dp2(fit_total_income),
attributes={
"friendly_name": "Predicted FIT income",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:solar-power",
"generation_income": dp2(final_fit_generation_income),
"deemed_export_income": dp2(final_fit_deemed_export_income),
"generation_rate": dp2(self.metric_fit_generation_rate),
"deemed_export_rate": dp2(self.metric_fit_deemed_export_rate),
"deemed_export_percentage": dp2(self.metric_fit_deemed_export_percentage),
},
)

if save and save == "best":
self.log("Saving plan best values to HA entities")
Expand Down Expand Up @@ -3844,6 +3863,23 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
"icon": "mdi:molecule-co2",
},
)
if self.metric_fit_generation_rate > 0:
fit_total_income = final_fit_generation_income + final_fit_deemed_export_income
self.dashboard_item(
self.prefix + ".fit_income_best",
state=dp2(fit_total_income),
attributes={
"friendly_name": "Predicted FIT income best",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:solar-power",
"generation_income": dp2(final_fit_generation_income),
"deemed_export_income": dp2(final_fit_deemed_export_income),
"generation_rate": dp2(self.metric_fit_generation_rate),
"deemed_export_rate": dp2(self.metric_fit_deemed_export_rate),
"deemed_export_percentage": dp2(self.metric_fit_deemed_export_percentage),
},
)

if save and save == "debug":
self.dashboard_item(
Expand Down
22 changes: 22 additions & 0 deletions apps/predbat/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ def __init__(self, base=None, pv_forecast_minute_step=None, pv_forecast_minute10
self.prediction_cache = {}
self.plan_interval_minutes = base.plan_interval_minutes
self.charge_scaling10 = base.charge_scaling10
self.metric_fit_generation_rate = base.metric_fit_generation_rate
self.metric_fit_deemed_export_rate = base.metric_fit_deemed_export_rate
self.metric_fit_deemed_export_percentage = base.metric_fit_deemed_export_percentage

# Store this dictionary in global so we can reconstruct it in the thread without passing the data
PRED_GLOBAL["dict"] = self.__dict__.copy()
Expand Down Expand Up @@ -459,6 +462,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
final_battery_cycle = battery_cycle
final_metric_keep = metric_keep
final_carbon_g = carbon_g
fit_generation_income = 0
fit_deemed_export_income = 0
final_fit_generation_income = 0
final_fit_deemed_export_income = 0
metric = self.cost_today_sofar
final_soc = soc
first_charge_soc = soc
Expand Down Expand Up @@ -575,6 +582,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
import_rate = self.rate_max # Assume in worst case that slot goes away and max rate applies
export_rate = rate_export.get(minute_absolute, 0)

# FIT deemed export: actual exports have no additional value since deemed export pays regardless
if self.metric_fit_generation_rate > 0:
export_rate = 0

# Alert?
alert_keep = all_active_keep.get(minute_absolute, 0)

Expand Down Expand Up @@ -657,6 +668,13 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
# Count PV kWh
pv_kwh += pv_now

# FIT income: generation tariff on all solar production, deemed export on a percentage
if self.metric_fit_generation_rate > 0:
fit_generation_income += pv_now * self.metric_fit_generation_rate
fit_deemed_export_income += pv_now * (self.metric_fit_deemed_export_percentage / 100.0) * self.metric_fit_deemed_export_rate
metric -= pv_now * self.metric_fit_generation_rate
metric -= pv_now * (self.metric_fit_deemed_export_percentage / 100.0) * self.metric_fit_deemed_export_rate

# Modelling reset of charge/discharge rate
if set_charge_window or set_export_window:
charge_rate_now = battery_rate_max_charge
Expand Down Expand Up @@ -1119,6 +1137,8 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
final_carbon_g = carbon_g
final_load_kwh = load_kwh
final_pv_kwh = pv_kwh
final_fit_generation_income = fit_generation_income
final_fit_deemed_export_income = fit_deemed_export_income

# Store export data
if diff < 0:
Expand Down Expand Up @@ -1167,6 +1187,8 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
self.final_pv_kwh = round(final_pv_kwh, 4)
self.final_iboost_kwh = round(final_iboost_kwh, 4)
self.final_battery_cycle = round(final_battery_cycle, 4)
self.final_fit_generation_income = round(final_fit_generation_income, 4)
self.final_fit_deemed_export_income = round(final_fit_deemed_export_income, 4)
self.final_soc_min = round(soc_min, 4)
self.final_soc_min_minute = soc_min_minute
self.export_to_first_charge = export_to_first_charge
Expand Down
6 changes: 6 additions & 0 deletions apps/predbat/tests/test_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ def get_default_config(self):
"metric_min_improvement_export_freeze": 0.5,
"metric_battery_cycle": 0.6,
"metric_self_sufficiency": 0.7,
"metric_fit_generation_rate": 0,
"metric_fit_deemed_export_rate": 0,
"metric_fit_deemed_export_percentage": 50,
"metric_future_rate_offset_import": 0.8,
"metric_future_rate_offset_export": 0.9,
"metric_inday_adjust_damping": 1.0,
Expand Down Expand Up @@ -470,6 +473,9 @@ def reset_inverter(my_predbat):
my_predbat.battery_rate_max_scaling = 1.0
my_predbat.battery_rate_max_scaling_discharge = 1.0
my_predbat.metric_battery_cycle = 0
my_predbat.metric_fit_generation_rate = 0
my_predbat.metric_fit_deemed_export_rate = 0
my_predbat.metric_fit_deemed_export_percentage = 50
my_predbat.num_cars = 0
my_predbat.car_charging_slots[0] = []
my_predbat.car_charging_from_battery = True
Expand Down
Loading