Correcting My Grid Charge Calculations

I've been looking for ways to improve our (somewhat dire) solar battery savings ever since I calculated the daily savings that we were achieving.

One of the approaches that I identified and tried was scheduling grid charging in order to increase the level of use that the battery saw - the idea being that the battery would see use even on cloudy days, with more use generally equating to greater savings.

When I reviewed this change recently, I was quite disappointed to find that it had had quite a mixed effect, with negative savings occurring much more frequently than before. This wasn't entirely unexpected, as an inability to dynamically schedule charging means that we buy power in at the same time each day, regardless of price or consumption expectations.

Today, however, I found that these negative savings were actually the result of a (stupid) reporting bug in the grid-charging calculations and that grid-charges deliver better savings than previously reported.

In this post I talk a little about the bug itself as well as looking at the re-calculated figures.


The Bug

Let's start by looking at how (and why) I fucked up.

The TL:DR is complexity.

My solar amortisation calculations are performed by a Python script and the overall system is designed to be quite DRY.

Most of the values being calculated derive from the same few raw fields - tariff values and kWh counters. So, rather than querying the same underlying data repeatedly, I calculate and store some key metrics which are later consumed and adjusted in order to calculate derived values.

This isn't done just for efficiency's sake, it also allows my reporting to abstract generalised figures (like amortisation rates) away from specialised figures (like Octopus pricing information) in order to build a (fairly) normalised schema. If I change energy provider (or tariff) in future, there should be no need to change the schema or the queries used to retrieve stored amortisation values.

Calculating battery unlocked savings is relatively complex, with multiple possible input and outputs, so some of this complexity existed even before I implemented the python script. My PoC dashboard used multiple Flux queries and a complex chain of Grafana transforms:

Screenshot show Grafana cell edit. There are 4 flux queries and 7 transforms

Obviously, having each dashboard cell run and combine multiple queries before applying a chain of transforms is a recipe for mistakes (hah!) and therefore entirely unmaintainable. So, at the beginning of August, I built the Python script, which allowed the Grafana cells to be driven by a single simple query

Screenshot show Grafana cell edit. There is 1 simply InfluxQL query and 3 transforms

The remaining transforms don't actually interact with the data at all, they simply apply a timeshift to make column charts a little easier to read.


Schema Complexity and Naming

The data that the Python script queries is generated by downsampling tasks, each of which take raw data and outputs 30 minute aggregates.

For example, the following query takes my solar inverter's cumulative counters and combines them with Octopus tariff information in order to calculate what the equivalent export value of the energy stored to the battery would be:

pricing = from(bucket: "Systemstats/autogen")
    |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
    |> filter(fn: (r) => r._measurement == "octopus_pricing"
        and (r._field == "cost_inc_vat" or r._field == "override_price")
        and r.charge_type == "usage-charge"
        and r.tariff_direction != "EXPORT"
        )
    |> filter(fn: (r) => r.payment_method == "None")
    |> keep(columns: ["_time","_field","_value"])
    |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
    |> map(fn: (r) => ({
        _field: "cost_inc_vat",
        _time: r._time,
        _value: if exists r.override_price then r.override_price else r.cost_inc_vat
        }))
    |> aggregateWindow(every: 30m, fn: max)
battery_charge = from(bucket: "Systemstats/autogen")
    |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
    |> filter(fn: (r) => r._measurement == "solar_inverter" 
        and (r._field == "batteryChargeToday" 
        ))
    |> aggregateWindow(every: 30m, fn: max)
    |> difference(nonNegative: true)
    |> keep(columns: ["_time","_field","_value"])

join(tables: {price: pricing, activity: battery_charge}, on: ["_time"])    
|> map(fn: (r) => ({
    _time: r._time,
    _value: r._value_activity * r._value_price,
    _field: "battery_stored_value_if_exported",
    _measurement: "solar_value"

}))

The important thing to understand about this query is that it does two things:

  1. Calculates how much energy was stored into the battery
  2. Multiplies that by the export unit price

The resulting value is the "opportunity cost" of the stored energy - what we could have earned in export revenue instead.

But, this query makes no accommodation (at all) for charging the battery from the grid: Grid charging incurs no opportunity cost because the electricity was never ours to sell in the first place (and we've instead paid import prices to acquire it).

Grid charge values are calculated in a separate (and, necessarily, much more complex) query, which includes the following field calculations:

export_value = join(tables: {price: export_pricing, yield: charged}, on: ["_time"])
|> map(fn: (r) => ({ 
        _time: r._time,
        _value: r._value_price * r._value_yield,
        _field: "battery_subtracted_from_exported_value",
        _measurement: "solar_value"
    }))
|> fill(value: 0.00)


import_value = join(tables: {price: import_pricing, yield: charged}, on: ["_time"])
|> map(fn: (r) => ({ 
        _time: r._time,
        _value: r._value_price * r._value_yield,
        _field: "battery_charged_from_grid_cost",
        _measurement: "solar_value"
    }))
|> fill(value: 0.00)

The result is two fields

  • battery_charged_from_grid_cost: What we paid to buy the electricity to charge the battery
  • battery_subtracted_from_exported_value: What we should subtract from the total opportunity cost to reflect there was none for this energy

Rather than repeatedly querying the same raw stats, the idea was that the Python script would "credit" battery_subtracted_from_exported_value back during the opportunity cost calculations.

Grid charging isn't the only element that works this way, if energy is exported from the battery there's also a separate credit to be applied.

Which, is part of how the issue crept in.


Simplifying with Python

The Python script's battery savings calculation looked like this

def calculate_battery_savings(metrics):
    ''' Calculate how much money the battery has saved   
    '''

    output = metrics["battery_supplied"] + metrics["battery_exported_value"]

    # Credit back opportunity cost
    output_with_credit = output + metrics["battery_exported_value"]

    # opportunity cost + charged from grid
    input_cost = metrics['battery_stored_value_if_exported'] + metrics["battery_charged_from_grid_cost"] + metrics["battery_subtract_from_consumed_value"]

    return output_with_credit - input_cost

There's no denying that it's far simpler to follow and read than the Flux and Grafana mash-up that it was replacing.

But, there's a problem:

# Credit back opportunity cost
output_with_credit = output + metrics["battery_exported_value"]

The script isn't crediting battery_subtracted_from_exported_value back.

This oversight, really, stems from the naming that I've used.

The function passes a quick review, after all, we are crediting something export related back. The problem is, it's only one of the two credits required.

It's true that the missing field was intended to be subtracted from the equivalent export value (or opportunity cost), but that's not what its name implies. The field name that we're missing includes from_exported_value, so why would we apply it here?

Without this credit, the calculations effectively levy an opportunity cost of £0.15 on each kilo-watt hour charged from the grid which, as we saw in my earlier post, does not lead to a happy result:

Graph showing reported battery savings before I fixed this bug, there are lots of days where things go negative

(Red line is where I first enabled grid charging)


Finding The Bug

The earlier results, whilst unwelcome, are unfortunately quite plausible: Buying electricity from the grid in the hopes that we can consume at a higher equivalent value is a gamble - it's betting that enough of the energy will be consumed at a higher price to offset the initial import cost.

Detection of this was further confounded by quite a high level of irregularity in day-to-day pricing and consumption. We've been lucky enough to benefit from quite a few Octopus Powerups, giving us a couple of hours of free electricity to charge the battery with. Those days, as expected, showed a higher rate of savings, implying that things were working as they should.

Ultimately, it was "Plunge Pricing" (prices going negative) that led to me finding this issue today.

I was concerned that the calculations might not handle negative pricing correctly (NB: turns out that they do) and had built a driver to test the calculate_battery_savings function against a range of scenarios

def calculate_metrics(charged_unit_cost, consumed_unit_cost):
    ''' Build a metrics dict based on 10kWh being charged and then
        discharged.

        Arguments provide the unit cost at time of charging and 
        discharging.

        Export value is a fixed value (0.15 kWh)

        Export from battery is 0 (leading to battery_subtract_from_consumed 
        also being 0)
    '''
    return {
        "battery_stored_value_if_exported" : 10 * 0.15,
        "battery_supplied" : 10 * consumed_unit_cost,
        "battery_charged_from_grid_cost" : 10 * charged_unit_cost,
        "battery_exported_value" : 0,
        "battery_subtract_from_consumed_value" : 0
    }

# Specify the prices to use during testing
pricing = [
    # negative charge rate, positive discharge
    [-0.05, 0.1],

    # negative charge rate, more negative discharge
    [-0.05, -0.1],

    # negative charge rate, less negative discharge
    [-0.05, -0.02],

    # positive charge rate, negative discharge
    [0.05, -0.1],

    # positive charge rate, positive discharge
    [0.05, 0.1],

    # positive charge rate, less positive discharge
    [0.05, 0.02],

    # equal positive charge rate
    [0.05, 0.05],

    # equal negative charge rate
    [-0.05, -0.05]
]

# Calculate each
for i in pricing:
    metrics = calculate_metrics(i[0], i[1])
    #print(metrics)
    saving = calculate_battery_savings(metrics)
    print(f"{i} = {saving}")

The final two tests, logically, should both result in a saving of 0 because we bought and consumed at exactly the same rate. Yet, the output indicated a loss

ben@optimus:~$ python3 tmp/test_saving.py 
[-0.05, 0.1] = 0.0
[-0.05, -0.1] = -2.0
[-0.05, -0.02] = -1.2
[0.05, -0.1] = -3.0
[0.05, 0.1] = -1.0
[0.05, 0.02] = -1.8
[0.05, 0.05] = -1.5
[-0.05, -0.05] = -1.5

This loss was exactly the value of battery_stored_value_if_exported, highlighting that credits weren't being applied.

The fix was quite simple, I just needed to have the script actually credit battery_subtracted_from_exported_value back:

def calculate_battery_savings(metrics):
    ''' Calculate how much money the battery has saved   
    '''

    output = metrics["battery_supplied"] + metrics["battery_exported_value"]

    # Credit back opportunity cost
    output_with_credit = sum([
        output ,
        metrics["battery_exported_value"],
        metrics["battery_subtracted_from_exported_value"]
        ])

    # opportunity cost + charged from grid
    input_cost = sum([
        metrics['battery_stored_value_if_exported'],
        metrics["battery_charged_from_grid_cost"],
        metrics["battery_subtract_from_consumed_value"]
        ])

    return output_with_credit - input_cost

Corrected Values

With the script corrected, I re-ran it, obtaining a pleasing improvement in reported battery savings

Before:

Graph showing reported battery savings before I fixed this bug, there are lots of days where things go negative

After:

Graph showing reported battery savings after I fixed this bug, there are still negative days but the vast majority are now positive and by a decent amount

There are still some days where we made a loss (the final one being entirely my fault), but the vast majority are now positive (the details of that bumper day on the 20th are covered below).


The Benefit of Grid Charging

So, what impact has this had on the reported values?

The following query extracts the average daily battery saving since I enabled grid charging

SELECT
   mean("val") 
FROM (
   SELECT 
      difference(last("battery_total_generated_savings"))/100 AS "val" 
   FROM Systemstats.rp_720d.solar_amortisation 
   WHERE 
      time > '2023-09-11T00:00:00Z' 
   GROUP BY time(1d)
)

The average daily battery saving is now £0.529

The same query can be used with a different date range to get the mean battery saving before I introduced grid charging, but after our export tariff kicked in

SELECT
   mean("val") 
FROM (
   SELECT 
      difference(last("battery_total_generated_savings"))/100 AS "val" 
   FROM Systemstats.rp_720d.solar_amortisation 
   WHERE 
      time < '2023-09-11T00:00:00Z'  and time > '2023-07-25T00:00:00Z'
   GROUP BY time(1d)
)

The average daily battery saving before grid charging was £0.346

So, despite initial appearances to the contrary, enabling grid charging has driven the average daily saving up by a little shy of 53%!


Impact on Amortisation Time

Whilst this increase is, undoubtedly, good news, it has very little impact on my initial concerns: at the current rate of savings, the battery is still going to take more than its serviceable lifetime to break even with its purchase price.

However, the updated figures show that, with grid charging, the battery should be able to continue to deliver reasonable value in the winter months (when solar will be contributing much less).

With a bit of luck, increased wind in Winter will mean that Agile prices drop more frequently allowing me to better capitalise on having that storage available.

With both an Octopus Powerup and plunge pricing, the 20th of September showed just how well things can work out:

The effective unit cost on the 20th, it's near 0 most of the night and negative a little before 6. Octopus powerups meant that the cost dropped to 0 between 2 and 4pm

Because of the drop in prices overnight, I was able to charge the battery up until immediately before we got up, shaving the morning peak and running from the battery until nearly lunch-time, followed by a mid-afternoon freebie charge.

Speaking of shaving the peak, the mistake obviously had the potential to invalidate my findings when experimenting with cutting morning peak costs.

The values for that period are no longer negative, and although the (non-sunny) days don't really outperform the prior week by very much, there is a definite sign of improvement:

Daily battery savings between 30 Aug and 13 Sep. The days marked as being morning peak shaves outperform the prior week but not by very much

It's only a few days worth of data, but the morning charge is obviously well worth revisiting.


Conclusion

Fuck, I mean... fuck. What a bloody stupid mistake to let creep in. The underlying Flux query made the information available but I wasn't consuming it because of complexity in my design.

The irony is, this mistake crept in whilst seeking to simplify things to reduce the chances of this kind of mistake creeping in.

This simple mistake meant that my calculations were - in effect - overcharging for each unit of electricity, skewing the values reported since I first enabled regular grid charging early last month. Despite the values being incorrect, the exhibited pattern of behaviour largely conformed with the pattern that I was expecting, leading to them essentially affirming their own validity.

Mea Culpa.

Correcting the mistake has led to a respectable increase in the reported savings, even if not enough to (yet) make the battery a worthwhile purchase overall. However, the newly reported improvement from shaving the morning peak does suggest that there may yet be scope to drive daily savings higher.