Dishwasher price calculation automation

Here’s a small example of how I have used the Miele and HTTP bindings to automate calculation and execution of running the dishwasher during the night when the electricity prices are lowest. The implementation uses rates from https://www.energidataservice.dk/ provided in JSON format, so the concrete implementation is quite tailored to my needs.

The example could serve as inspiration though - and I could get lucky as well and receive feedback for improvements/other ways of doing things. :slight_smile:

Prerequisites

For the concrete usage of the Miele binding, this pull request is needed in order to provide stable start/end times for scheduled program to avoid recalculations each minute:

Notes

I have everything implemented (for now) as DSL rules with limitations in terms of reuse etc. I would love to have implemented it using jsscripting, but unfortunately memory leaks are still preventing this. Additionally, the algorithms use brute force and there is room for optimizations.

Setup

http.things

Thing http:url:energidataservice "EnergiDataService" [ baseURL="https://api.energidataservice.dk/dataset/Elspotprices?start=%1$tY-%1$tm-%1$td&filter={\"PriceArea\":\"DK1\"}&columns=HourUTC,SpotPriceDKK", refresh=3600] {
        Channels:
                Type string : records
}

http.items

String EnergiDataService_Records { channel = "http:url:energidataservice:records" }

miele.items

Number Dishwasher_RawState "Raw state" (Dishwasher) ["Point"] {channel="miele:dishwasher:home:dishwasher:rawState"}
Number Dishwasher_RawProgram "Raw program" (Dishwasher) ["Point"] {channel="miele:dishwasher:home:dishwasher:rawProgram"}
DateTime Dishwasher_StartTime "Start time [%1$td.%1tm.%1$tY %1$tR]" <time> (Dishwasher) ["Point"] {channel="miele:dishwasher:home:dishwasher:start"}
DateTime Dishwasher_EndTime "End time [%1$td.%1tm.%1$tY %1$tR]" <time> (Dishwasher) ["Point"] {channel="miele:dishwasher:home:dishwasher:end"}
DateTime Dishwasher_CalculatedStartTime "Calculated start time [%1$td.%1tm.%1$tY %1$tR]" <time> (Dishwasher)
Number Dishwasher_CalculatedPrice "Calculated price [%.2f kr.]" <price> (Dishwasher)

Rules

First a generic rule which assumes a linear energy consumption during the program.

rule "Calculate dishwasher start time based on lowest price"
when
    Item EnergiDataService_Records changed or
    Item Dishwasher_StartTime changed or
    Item Dishwasher_EndTime changed or
    Item Dishwasher_RawState changed to 4 // Waiting to start
then
    if (Dishwasher_RawState.state != 4) // Waiting to start
    {
        return
    }

    // Use dedicated rule for the ECO program.
    if (Dishwasher_RawProgram.state == 28) // ECO
    {
        return
    }

    val DateTimeType startTimeState = (Dishwasher_StartTime.state as DateTimeType)
    val DateTimeType endTimeState = (Dishwasher_EndTime.state as DateTimeType)

    if (startTimeState == UNDEF || endTimeState == UNDEF)
    {
        return
    }

    val ZonedDateTime startTimeZoned = startTimeState.getZonedDateTime()
    val ZonedDateTime endTimeZoned = endTimeState.getZonedDateTime()

    if (endTimeZoned.isBefore(startTimeZoned))
    {
        return
    }

    val Instant programmedStart = startTimeZoned.toInstant()
    val Instant programmedEnd = endTimeZoned.toInstant()
    val Duration programDuration = Duration.between(programmedStart, programmedEnd)

    logInfo("Dishwasher", "Starting rate/timeslot calculation for program "
        + LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString + " - "
        + LocalDateTime.ofInstant(programmedEnd, ZoneId.systemDefault()).toString)

    var calcStart = now.toInstant().plusSeconds(60).truncatedTo(ChronoUnit.MINUTES)
    var calcEnd = calcStart.plusSeconds(programDuration.getSeconds())

    val String json = (EnergiDataService_Records.state as StringType).toString

    val Number numRows = Integer::parseInt(transform("JSONPATH", "$.total", json))
    var Instant cheapestStart
    var Number cheapestPrice = 99999999
    val HashMap<Instant, Number> priceMap = new HashMap()

    for (var i = 0; i < numRows; i++)
    {
        val Instant hourStart = Instant.parse(transform("JSONPATH", "$.records[" + i + "].HourUTC", json) + "Z")
        val Number hourPrice = Float::parseFloat(transform("JSONPATH", "$.records[" + i + "].SpotPriceDKK", json))

        priceMap.put(hourStart, hourPrice)
    }

    while (calcEnd.compareTo(programmedEnd) <= 0)
    {
        var Instant hourStart = calcStart.truncatedTo(ChronoUnit.HOURS)
        var Instant hourEnd   = hourStart.plus(1, ChronoUnit.HOURS)
        var Number price = 0

        while (calcStart.isBefore(hourEnd) && calcEnd.isAfter(hourStart))
        {
            var Instant priceStart = hourStart
            if (calcStart.isAfter(priceStart))
            {
                priceStart = calcStart
            }
            var Instant priceEnd = hourEnd
            if (calcEnd.isBefore(priceEnd))
            {
                priceEnd = calcEnd
            }

            val Number hourPrice = priceMap.get(hourStart)
            if (hourPrice === null)
            {
                logWarn("Dishwasher", "Unable to perform rate/timeslot calculation for upcoming program: "
                    + LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString
                    + ": missing rate for " + hourStart.toString)
                return
            }
            var Number seconds = Duration.between(priceStart, priceEnd).getSeconds
            price += (hourPrice * (seconds/3600))

            hourStart = hourStart.plus(1, ChronoUnit.HOURS)
            hourEnd = hourEnd.plus(1, ChronoUnit.HOURS)
        }

        if (price < cheapestPrice)
        {
            cheapestPrice = price
            cheapestStart = calcStart
        }
        else if (price == cheapestPrice && calcStart.isBefore(cheapestStart))
        {
            cheapestStart = calcStart
        }

        // Now fast forward to the nearest start or end intersecting with a different hourly rate.
        val Instant nextStartHour = calcStart.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)
        val Instant nextEndHour = calcEnd.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)
        if (calcStart.until(nextStartHour, ChronoUnit.SECONDS) < calcEnd.until(nextEndHour, ChronoUnit.SECONDS))
        {
            calcStart = nextStartHour
            calcEnd = calcStart.plusSeconds(programDuration.getSeconds())
        }
        else
        {
            calcEnd = nextEndHour
            calcStart = calcEnd.minusSeconds(programDuration.getSeconds())
        }
    }
    if (cheapestStart === null)
    {
        logWarn("Dishwasher", "Unable to perform rate/timeslot calculation for upcoming program: "
            + LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString)
        return
    }

    logInfo("Dishwasher", "Finished rate/timeslot calculation with cheapest: " + cheapestPrice.toString + " with start "
        + LocalDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()).toString)

    if (Duration.between(cheapestStart, programmedStart).toMinutes < 2)
    {
        logInfo("Dishwasher", "Best timeslot is same as already programmed, skipping override.")
    }
    else
    {
        val cheapestDt = new DateTimeType(ZonedDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()))
        Dishwasher_CalculatedStartTime.postUpdate(cheapestDt)
    }
end

Next rule is based on a mapped program where I have used the powerConsumption channel to record 0.1 kWh data points to know exactly when the program uses the energy:

2022-09-16 02:01:31.420, 0.0
2022-09-16 02:37:13.428, 0.1
2022-09-16 02:45:05.096, 0.2
2022-09-16 02:49:15.462, 0.3
2022-09-16 02:51:35.576, 0.4
2022-09-16 02:55:28.788, 0.5
2022-09-16 03:31:06.045, 0.6
2022-09-16 04:12:54.429, 0.7

This makes it possible to perform a much more accurate calculation and better fit the program into the lowest price intervals.

The result will usually be quite different from the generic calculation, because the appliance actually doesn’t use any significant energy during the last 1:45 of the ECO program. Also, the concrete price is calculated, because it is known exactly how much energy is used by the program.

rule "Calculate dishwasher start time based on lowest accurate price (ECO)"
when
    Item EnergiDataService_Records changed or
    Item Dishwasher_StartTime changed or
    Item Dishwasher_EndTime changed or
    Item Dishwasher_RawState changed to 4 // Waiting to start
then
    if (Dishwasher_RawState.state != 4) // Waiting to start
    {
        return
    }

    if (Dishwasher_RawProgram.state != 28) // ECO
    {
        return
    }

    // Timetable for program, 0.1 kWh consumed after each duration.
    val ArrayList<Duration> phases = new ArrayList<Duration>()
    phases.add(Duration.ofMinutes(37))
    phases.add(Duration.ofMinutes(8))
    phases.add(Duration.ofMinutes(4))
    phases.add(Duration.ofMinutes(2))
    phases.add(Duration.ofMinutes(4))
    phases.add(Duration.ofMinutes(36))
    phases.add(Duration.ofMinutes(41))

    val DateTimeType startTimeState = (Dishwasher_StartTime.state as DateTimeType)
    val DateTimeType endTimeState = (Dishwasher_EndTime.state as DateTimeType)

    if (startTimeState == UNDEF || endTimeState == UNDEF)
    {
        return
    }

    val ZonedDateTime startTimeZoned = startTimeState.getZonedDateTime()
    val ZonedDateTime endTimeZoned = endTimeState.getZonedDateTime()

    if (endTimeZoned.isBefore(startTimeZoned))
    {
        return
    }

    val Instant programmedStart = startTimeZoned.toInstant()
    val Instant programmedEnd = endTimeZoned.toInstant()
    val Duration programDuration = Duration.between(programmedStart, programmedEnd)

    logInfo("Dishwasher", "Starting rate/timeslot calculation for program "
        + LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString + " - "
        + LocalDateTime.ofInstant(programmedEnd, ZoneId.systemDefault()).toString)

    var calcStart = now.toInstant().plusSeconds(60).truncatedTo(ChronoUnit.MINUTES)
    var calcEnd = calcStart.plusSeconds(programDuration.getSeconds())

    val String json = (EnergiDataService_Records.state as StringType).toString

    val Number numRows = Integer::parseInt(transform("JSONPATH", "$.total", json))
    var Instant cheapestStart
    var Number lowestPrice = 99999999
    var Number highestPrice = 0
    val HashMap<Instant, Number> priceMap = new HashMap()

    for (var i = 0; i < numRows; i++)
    {
        val Instant hourStart = Instant.parse(transform("JSONPATH", "$.records[" + i + "].HourUTC", json) + "Z")
        val Number hourPrice = Float::parseFloat(transform("JSONPATH", "$.records[" + i + "].SpotPriceDKK", json))

        priceMap.put(hourStart, hourPrice)
    }

    while (calcEnd.compareTo(programmedEnd) <= 0)
    {
        var Instant calcPartStart = calcStart
        var Instant calcPartEnd
        var Number calcPrice = 0
        var long minSecondsUntilNextHour = 60*60

        for (var i = 0; i < phases.size(); i++)
        {
            val Duration current = phases.get(i)
            calcPartEnd = calcPartStart.plus(current)

            var Instant hourStart = calcPartStart.truncatedTo(ChronoUnit.HOURS)
            var Instant hourEnd   = hourStart.plus(1, ChronoUnit.HOURS)

            // Get next intersection with hourly rate change.
            var long secondsUntilNextHour = Duration.between(calcPartStart, hourEnd).getSeconds
            if (secondsUntilNextHour < minSecondsUntilNextHour)
            {
                minSecondsUntilNextHour = secondsUntilNextHour
            }

            var Number partPrice = 0
            while (calcPartStart.isBefore(hourEnd) && calcPartEnd.isAfter(hourStart))
            {
                val Number hourPrice = priceMap.get(hourStart)
                if (hourPrice === null)
                {
                    logWarn("Dishwasher", "Unable to perform rate/timeslot calculation for upcoming program: "
                        + LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString
                        + ": missing rate for " + hourStart.toString)
                    return
                }
                var Instant subPartStart = hourStart
                if (calcPartStart.isAfter(subPartStart))
                {
                    subPartStart = calcPartStart
                }
                var Instant subPartEnd = hourEnd
                if (calcPartEnd.isBefore(subPartEnd))
                {
                    subPartEnd = calcPartEnd
                }

                var Number subPartSeconds = Duration.between(subPartStart, subPartEnd).getSeconds
                var Number partSeconds = Duration.between(calcPartStart, calcPartEnd).getSeconds
                partPrice += (hourPrice / 10 * (subPartSeconds / partSeconds))

                hourStart = hourStart.plus(1, ChronoUnit.HOURS)
                hourEnd = hourEnd.plus(1, ChronoUnit.HOURS)
            }

            calcPrice += partPrice
            calcPartStart = calcPartEnd
        }

        if (calcPrice < lowestPrice)
        {
            lowestPrice = calcPrice
            cheapestStart = calcStart
        }
        else if (calcPrice == lowestPrice && calcStart.isBefore(cheapestStart))
        {
            cheapestStart = calcStart
        }
        if (calcPrice > highestPrice)
        {
            highestPrice = calcPrice
        }

        // Now fast forward to next hourly rate intersection.
        calcStart = calcStart.plusSeconds(minSecondsUntilNextHour)
        calcEnd = calcStart.plusSeconds(programDuration.getSeconds())
    }

    if (cheapestStart === null)
    {
        logWarn("Dishwasher", "Unable to perform rate/timeslot calculation for upcoming program: "
            + LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString)
        return
    }

    // Convert to kr. and add VAT.
    lowestPrice  = lowestPrice  * 1.25 / 1000
    highestPrice = highestPrice * 1.25 / 1000

    logInfo("Dishwasher", "Finished rate/timeslot calculation with lowest price " + lowestPrice.toString + ", starting "
        + LocalDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()).toString + ". Highest price was: " + highestPrice.toString)

    if (Duration.between(cheapestStart, programmedStart).toMinutes < 2)
    {
        logInfo("Dishwasher", "Best timeslot is same as already programmed, skipping override.")
    }
    else
    {
        val cheapestDt = new DateTimeType(ZonedDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()))
        Dishwasher_CalculatedStartTime.postUpdate(cheapestDt)
        Dishwasher_CalculatedPrice.postUpdate(lowestPrice)
    }
end

And finally some rules for maintenance, notifications and triggering of the program at the right time:

rule "Send notification after dishwasher calculation"
when
    Item Dishwasher_CalculatedStartTime changed
then
    if (Dishwasher_CalculatedStartTime.state == NULL || Dishwasher_CalculatedStartTime.state == UNDEF)
    {
        return
    }

    val DateTimeType calculatedStartTimeState = (Dishwasher_CalculatedStartTime.state as DateTimeType)
    val LocalDateTime calculatedStartTime = calculatedStartTimeState.getZonedDateTime().toLocalDateTime()
    val DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm")

    if (Dishwasher_CalculatedPrice.state != UNDEF)
    {
        sendPushMessage.apply("Dishwasher", "Program start changes to "
            + calculatedStartTime.format(formatter).toString + " after calculation of lowest price: "
            + String.format("%.2f", (Dishwasher_CalculatedPrice.state as Number).doubleValue).replace(".", ",")
            + " kr.")
    }
    else
    {
        sendPushMessage.apply("Dishwasher", "Program start changes to "
        + calculatedStartTime.format(formatter).toString + " after calculation of lowest price.")
    }
end

rule "Start dishwasher based on calculated price"
when
    Time cron "0 0/1 * * * ?" or
    Item Dishwasher_CalculatedStartTime changed
then
    if (Dishwasher_RawState.state != 4) // Waiting to start
    {
        return
    }

    if (Dishwasher_CalculatedStartTime.state == NULL || Dishwasher_CalculatedStartTime.state == UNDEF)
    {
        return
    }

    val ZonedDateTime calculatedStart = (Dishwasher_CalculatedStartTime.state as DateTimeType).getZonedDateTime()
    if (now.isBefore(calculatedStart))
    {
        return
    }

    logInfo("Dishwasher", "Starting program ahead of time to get lower electricity price")
    Dishwasher_Switch.sendCommand(ON)
end

rule "Clear calculated start time for dishwasher"
when
    Item Dishwasher_RawState changed from 4 // Waiting to start
then
    Dishwasher_CalculatedStartTime.postUpdate(UNDEF)
    Dishwasher_CalculatedPrice.postUpdate(UNDEF)
end

One last note: When my Miele dishwasher is started this way, for some reason it will stay in the last phase: Finished with remaining 0:00. So it needs to be manually turned on and off again to get out of the state.

2 Likes