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=utcnow&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 is Dishwasher_CalculatedStartTime
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.

3 Likes

This topic was automatically closed 41 days after the last reply. New replies are no longer allowed.

I’ve been working on a new binding for integrating Energi Data Service:

Initially this replaced the usage of the HTTP binding in the dishwasher automation with a more robust integration with internal caches, retry policies, error handling etc. Perhaps more importantly, this integration not only takes the spot prices into account, but the total price including all tariffs and taxes. Still exposing price data as a channel with a JSON payload.

In the next iteration I created an action for getting the prices directly into a rule without deserializing JSON. On my Raspberry Pi 3 this saved about 300 ms of time.

With the most recent developments I have now made moved the calculations into the binding also so they are more easily accessible and reusable.

Here is my simplified version of the customized rule for specific ECO program on my dishwasher:

rule "Calculate dishwasher start time based on lowest accurate price (ECO)"
when
    Item EnergiDataService_FuturePrices 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)

    val actions = getActions("energidataservice", "energidataservice:service:energidataservice");

    var result = actions.calculateCheapestPeriod(now.toInstant().plusSeconds(60).truncatedTo(ChronoUnit.MINUTES), programmedEnd, programDuration, phases, 0.1 | kWh)

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

    var lowestPrice = (result.get("LowestPrice") as Number).doubleValue
    var highestPrice = (result.get("HighestPrice") as Number).doubleValue
    var cheapestStart = (result.get("CheapestStart") as Instant)

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

    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 < 3)
    {
        logInfo("Dishwasher", "Best timeslot is same as already programmed, skipping override.")
        Dishwasher_CalculatedStartTime.postUpdate(UNDEF)
    }
    else
    {
        val cheapestDt = new DateTimeType(ZonedDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()))
        Dishwasher_CalculatedStartTime.postUpdate(cheapestDt)
    }
    Dishwasher_CalculatedPrice.postUpdate(lowestPrice)
end

With this change, I went from a calculation speed of a few seconds down to ~50 ms (still brute force, it has optimization potential):

2023-02-08 22:56:58.752 [INFO ] [openhab.core.model.script.Dishwasher] - Starting rate/timeslot calculation for program 2023-02-09T05:00 - 2023-02-09T09:00
2023-02-08 22:56:58.757 [DEBUG] [nal.handler.EnergiDataServiceHandler] - Cached spot prices still valid, skipping download.
2023-02-08 22:56:58.759 [DEBUG] [nal.handler.EnergiDataServiceHandler] - Cached net tariffs still valid, skipping download.
2023-02-08 22:56:58.764 [DEBUG] [nal.handler.EnergiDataServiceHandler] - Cached system tariffs still valid, skipping download.
2023-02-08 22:56:58.768 [DEBUG] [nal.handler.EnergiDataServiceHandler] - Cached electricity taxes still valid, skipping download.
2023-02-08 22:56:58.772 [DEBUG] [nal.handler.EnergiDataServiceHandler] - Cached transmission net tariffs still valid, skipping download.
2023-02-08 22:56:58.807 [INFO ] [openhab.core.model.script.Dishwasher] - Finished rate/timeslot calculation with lowest price 0.9879883271907429, starting 2023-02-09T02:00. Highest price was: 1.0340372527153086

And here is the new version of the generic calculation assuming linear consumption for unmapped programs:

rule "Calculate dishwasher start time based on lowest price"
when
    Item EnergiDataService_FuturePrices 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)

    val actions = getActions("energidataservice", "energidataservice:service:energidataservice");

    var result = actions.calculateCheapestPeriod(now.toInstant().plusSeconds(60).truncatedTo(ChronoUnit.MINUTES), programmedEnd, programDuration)

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

    var cheapestStart = (result.get("CheapestStart") as Instant)

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

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

I imagine the next evolution could be reimplementing these calculations in a more central component outside the binding (e.g. core), so they could be reused across different bindings (Tibber, aWATTar, ENTSO-E, etc.). Bindings would then implement an API for sharing data in a well-defined manner.

Obviously this next step would take considerably more effort and involve more people in order to be generic and usable for most common scenarios we can think of. Ideas can be shared and discussed here:

1 Like

Here is another rule example in the same category for a tumble dryer - using the Energi Data Service binding and VAT transformation:

rule "Tumble dryer final price"
when
    Item TumbleDryer_RawState changed to 7 // End
then
    val elapsedTime = (TumbleDryer_ElapsedTime.state as QuantityType<Number>).intValue
    val Instant endTime = now.toInstant()
    val Instant startTime = endTime.minusSeconds(elapsedTime * 60)

    logInfo("Tumble Dryer", "Calculating price for " + startTime.toString + " - " + endTime.toString)

    val actions = getActions("energidataservice", "energidataservice:service:energidataservice");
    var price = Float::parseFloat(transform("VAT", "DK", actions.calculatePrice(startTime, endTime, 667 | W).toString))
    sendPushMessage.apply("Tumble dryer", "Price for run program: " + String.format("%.2f", price) + " kr.")
end
2 Likes

I have just finished migrating my dishwasher rules from DSL to JavaScript, so I thought I would share the new version.

var notification = require('jlaur/notification.js');

// Timetables for programs: 0.1 kWh consumed after each duration.

const maintenancePhases = [
    time.Duration.ofMinutes(10), time.Duration.ofMinutes(4), time.Duration.ofMinutes(2),
    time.Duration.ofMinutes(5), time.Duration.ofMinutes(2), time.Duration.ofMinutes(4),
    time.Duration.ofMinutes(3), time.Duration.ofMinutes(2), time.Duration.ofMinutes(4),
    time.Duration.ofMinutes(51), time.Duration.ofMinutes(1), time.Duration.ofMinutes(5),
    time.Duration.ofMinutes(2), time.Duration.ofMinutes(4), time.Duration.ofMinutes(3),
    time.Duration.ofMinutes(3)
];

const ecoPhases = [
    time.Duration.ofMinutes(37), time.Duration.ofMinutes(8), time.Duration.ofMinutes(4),
    time.Duration.ofMinutes(2), time.Duration.ofMinutes(4), time.Duration.ofMinutes(36),
    time.Duration.ofMinutes(41)
];

const quickPowerWashPhases = [
    time.Duration.ofMinutes(5), time.Duration.ofMinutes(3), time.Duration.ofMinutes(3),
    time.Duration.ofMinutes(3), time.Duration.ofMinutes(3), time.Duration.ofMinutes(4),
    time.Duration.ofMinutes(3), time.Duration.ofMinutes(4), time.Duration.ofMinutes(10),
    time.Duration.ofMinutes(3), time.Duration.ofMinutes(3), time.Duration.ofMinutes(3)
];

rules.when()
    .item("EnergiDataService_HourlyPrices").changed()
    .or().item("Dishwasher_StartTime").changed()
    .or().item("Dishwasher_EndTime").changed()
    .or().item("Dishwasher_RawState").changed().to(4) // Waiting to start
    .then(event =>
    {
        if (items.Dishwasher_RawState.isUninitialized || items.Dishwasher_RawState.numericState != 4) {
            return;
        }

        if (items.Dishwasher_StartTime.isUninitialized || items.Dishwasher_EndTime.isUninitialized) {
            return;
        }

        var programmedStart = time.toZDT(items.Dishwasher_StartTime);
        var programmedEnd = time.toZDT(items.Dishwasher_EndTime);

        if (programmedEnd.isBefore(programmedStart)) {
            return;
        }

        var phases;
        switch (items.Dishwasher_RawProgram.numericState) {
            case 27: // Maintenance
                phases = maintenancePhases;
                break;
            case 28: // ECO
                phases = ecoPhases;
                break;
            case 38: // QuickPowerWash
                phases = quickPowerWashPhases;
                break;
        }

        var programmedDuration = time.Duration.between(programmedStart, programmedEnd);

        console.log("Starting rate/timeslot calculation for dishwasher program "
            + programmedStart.toLocalDateTime() + " - "
            + programmedEnd.toLocalDateTime());

        var edsActions = actions.get("energidataservice", "energidataservice:service:energidataservice");

        var result;
        if (typeof phases === 'undefined') {
            result = edsActions.calculateCheapestPeriod(
                time.Instant.now().plusSeconds(60).truncatedTo(time.ChronoUnit.MINUTES),
                programmedEnd.toInstant(),
                programmedDuration);
        } else {
            result = edsActions.calculateCheapestPeriod(
                time.Instant.now().plusSeconds(60).truncatedTo(time.ChronoUnit.MINUTES),
                programmedEnd.toInstant(),
                programmedDuration,
                phases,
                Quantity("0.1 kWh"));
        }

        if (result.get("CheapestStart") == null) {
            console.log("Unable to perform rate/timeslot calculation for upcoming program: " + programmedStart.toLocalDateTime());
            return;
        }

        cheapestStart = time.LocalDateTime.ofInstant(utils.javaInstantToJsInstant(result.get("CheapestStart")));

        if (time.Duration.between(cheapestStart, programmedStart.toLocalDateTime()).toMinutes() < 3) {
            console.log("Best timeslot is same as already programmed, skipping override.");
            items.Dishwasher_CalculatedStartTime.postUpdate("UNDEF");
        }
        else {
            items.Dishwasher_CalculatedStartTime.postUpdate(cheapestStart);
        }

        if (typeof phases === 'undefined') {
            console.log("Finished rate/timeslot calculation with cheapest start "
                + cheapestStart);
        } else {
            lowestPrice  = actions.Transformation.transform("VAT", "DK", result.get("LowestPrice").toString());
            highestPrice = actions.Transformation.transform("VAT", "DK", result.get("HighestPrice").toString());

            console.log("Finished rate/timeslot calculation with lowest price " + lowestPrice
                + ", starting " + cheapestStart
                + ". Highest price was: " + highestPrice);

            items.Dishwasher_CalculatedPrice.postUpdate(lowestPrice);
        }
    })
    .build("Dishwasher price calculation", "Calculate dishwasher start time based on lowest price");

rules.when()
    .item("Dishwasher_CalculatedStartTime").changed()
    .then(event =>
    {
        if (items.Dishwasher_CalculatedStartTime.isUninitialized) {
            return;
        }

        var calculatedStartTimeFormatted = time.toZDT(event.newState).format(time.DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm"));
        if (items.Dishwasher_CalculatedPrice.isUninitialized) {
            notification.send("Dishwasher", "Program start changed to "
                + calculatedStartTimeFormatted + " after calculation of lowest price.");
        } else {
            notification.send("Dishwasher", "Program start changed to "
                + calculatedStartTimeFormatted + " after calculation of lowest price: "
                + items.Dishwasher_CalculatedPrice.numericState.toFixed(2).replace('.', ','));
        }
    })
    .build("Dishwasher price notification", "Send notification after dishwasher calculation");

rules.when()
    .item("Dishwasher_RawState").changed().from(4) // Waiting to start
    .or().item("Dishwasher_RawState").changed().from(6).to(1) // Paused to Off
    .then(event =>
    {
        if (event.newState == 6) {
            // When opening door while waiting to start, this will happen.
            return;
        }
        items.Dishwasher_CalculatedStartTime.postUpdate("UNDEF");
        items.Dishwasher_CalculatedPrice.postUpdate("UNDEF");
    })
    .build("Dishwasher clear calculation result", "Clear calculated start time for dishwasher");

rules.JSRule({
    name: "Dishwasher start",
    description: "Start dishwasher based on calculated price",
    triggers: [triggers.DateTimeTrigger('Dishwasher_CalculatedStartTime')],
    execute: event => {
        if (items.Dishwasher_RawState.isUninitialized || items.Dishwasher_RawState.numericState != 4 || items.Dishwasher_CalculatedStartTime.isUninitialized) {
            return;
        }
        items.Dishwasher_Switch.sendCommand("ON");
    }
});

I think your date time handling is quite a bit more complicated than it needs to be.

I don’t totally know how the eds action works so I guess the Durations are OK. But if you are ever in a situation where you need to convert a Duration to a ZonedDateTime (e.g. plusMinutes()) you can just use the ISO8601 duration string which is often much more concise. For example time.toZDT('PT2M'); is the same as time.ZonedDateTime.now.plus(time.Duration.ofMinutes(2));.

        var programmedStart = time.toZDT(items.Dishwasher_StartTime).toInstant();
        var programmedEnd = time.toZDT(items.Dishwasher_EndTime).toInstant();

Why the toInstant()? You are not doing anything with these variables that can’t be done with just a ZonedDateTime much more simply that you are now. For example, is these are left as ZonedDateTimes

    logInfo("Dishwasher", "Starting rate/timeslot calculation for program "
        + programmedStart.toLocalDateTime.toString() + " - "
        + programmedEnd.toLocalDateTime.toString())

Because a ZonedDateTime is already an Instant, it will work as is in the calls to time.Duration.bewteen() too. Converting these to Instants is just making your code more complex.

time.toZDT() should also be able to convert a Java Instant to a JS ZonedDateTime too so I’d replace the call to utils.javaInstantToJsInstant() just with a call to time.toZDT().

If you can stick to using ZonedDateTimes all the work you’ve done to maintain the timezone is no longer required.

Thanks a lot for your feedback, @rlkoshak. I’m still new in JavaScript, so exactly what I was hoping for and much appreciated.

I was not aware, that’s pretty neat. However, in this specific example my preference would probably be the long/verbose version to have strict types checks. At least, I think so - because I’m used to have that in Java. :slightly_smiling_face: So for example ‘P2M’ would not yield any compiler warning. Maybe it’s just old habbits and I need to loosen up when working with JavaScript.

The original reason is that the thing actions require Instant rather than ZonedDateTime. But I may be settling on Instant too soon rather than performing .toInstant() at the moment of calling the action.

I guess it comes down to:

time.LocalDateTime.ofInstant(programmedStart).toString()

vs.

programmedStart.toLocalDateTime.toString()

which are pretty equal, except the number of characters. In the first case it’s calling a static method of LocalDateTime, in the second case it’s calling instance method of programmedStart. So I’d say it’s a tie.

I agree, that would be be possible in the exact same way. I guess I sticked with Instant because I needed the Instant representations for the thing action anyway.

Actually, I believe it wouldn’t work. I was struggling with this. IIRC the problem is that I’m getting a Java Map with Java Instant inside as values. This will not be converted automatically.

See also for additional related information:

You are probably referring to:

cheapestStart = utils.javaInstantToJsInstant(result.get("CheapestStart"));
var zoneId = time.toZDT(items.Dishwasher_StartTime).zone();
items.Dishwasher_CalculatedStartTime.postUpdate(time.ZonedDateTime.ofInstant(cheapestStart, zoneId));

This was bugging me and was the last piece quickly written just to get it to work.

Basically I need to convert the received Instant (from the thing action) to a ZonedDateTime in order to post it to the item. To do that I need to apply a time-zone. Default time-zone would be “good enough”, but there is no time.ZonedDateTime.ofInstant overload not requiring a time-zone. So I decided to grab it from the other item. I didn’t think of this, but perhaps it’s actually possible to post the Instant directly as it will probably be used as a string in the DateTimeType constructor, which supports parsing of Instant IIRC. I will check.

Oh, one last thing that was also slightly bugging me: It seems the Rule Builder API doesn’t support the DateTimeTrigger (like Time is <DateTimeItem> in DSL). But of course that’s only a problem for file-based rules. :wink:

You’re not going to get anything like that in JavaScript rules. You can kind of approach that for Typescript but I don’t think Typescript is supportable in rules.

Even with the longer chain you won’t get any “compiler” warnings because there is no compiler. It’s all interpreted. For example, given:

time.ZonedDateTime.now.plus(<something totally bogus>);
time.ZonedDatetime.nothingValid.bogus();

In neither cases either will you get an error until runtime. All that gets checked at “compile” time is basic syntax. Type checking or even if stuff like functions exist on an Object do not happen until runtime.

Just a note, it needs to be PT2M. The T is important as it separates the days from the times. For example P2W1DT5H is 15 days (two weeks and one day) and one hour.

Think of time.toZDT() as a kind of factory. You give it anything reasonable that can either be converted to a ZonedDateTime or it makes sense to add to now and it will return such a ZonedDateTime. If there’s a problem, I can’t remember if you just get an error log and it returns null or an exception (I think an exception).

One of the most complicated thing most OH users have to muck with is working with DateTimes. time.toZDT()'s purpose is to take most of that mess on so with one short function call they can normalize just about anything reasonable into a time.ZonedDateTime so they never have to worry about timezones, converting or any of that mess.

Indeed, it’s better if you stick with a ZonedDateTime or even a LocalDateTime and not converting to an Instant until the last moment will eliminate a lot of the gymnastics caused by the fact that you’ve effectively thrown away the timezone immediately and have to recover/reset it later.

If number of characters is all you care about that would go against the arguments you have previously. But from a practical perspective: time.LocalDateTime.ofInstant(programmedStart).toString() creates a whole new Object built from the pieces of data contained in programmedStart and programmedStart.toLocalDateTime.toString() already has the LocalDateTime built as part of itself and you are just calling a getter.

Performance really doesn’t matter in rules so much so that’s not a big deal, but in general it’s better to use the Objects you have instead of rebuilding again an Object you used to already have but threw away.

I’m not certain this is true any more. But the code would be a lot easier to read and, my biggest concern, represent to other users the preferred way to work with date time Objects in rules if it waited to convert to Instant until the last moment where it’s required.

The Map part really isn’t relevant here. But it should be the case if you pass an Instant to toZDT() it recognizes that it’s a Java java.time.Instant and it calls that same utils.javaInstantToJsInstant() function on it’s own, only it would need to assume a timezone using the system default. If it doesn’t do that now, I’d recommend filing an issue to have it do so.

That’s pretty close how it handles other Java types too.

Right, because an Instant doesn’t have a TimeZone. It’s kind of just a wrapper around epoch.

I’m not sure that works or not. I think the parser expects a timezone but it’s definitely worth a test.

If that’s the case it’s definitely an oversight. It should support all the trigger types. That definitely needs an issue opened.

One final thing I forgot to mention in my first reply is that I think it just might be possible to merge these all into one rule. The reason why you might want to do that is to be able to create a Rule Template out of it which would allow end users to simply install and configure rules from it instead of copy/paste/edit. But you’ll have to get good at catching and processing the event to determine how the rule was triggered. I do this in a lot of my rule templates though. For example, if you trigger my Debounce rule manually it just checks the config to make sure your Items and metadata is reasonable. When triggered by an Item event, it performs the debounce. Another possibly more relevant example is my MQTT EventBus rule which merged the publisher and subscriber into a single rule so not it’s just a single rule template for both directions. It figures out whether the event is something to publish or subscribe based on the way the rule was triggered.

Food for thought only…

Right, so it’s purely a “habit” for me when usually working with type-safe languages like Java and C#. Since the JavaScript date API here is almost identical to the Java API it’s hard not to write it in the same way for me. For Java I would definitely prefer something like ZonedDateTime.now().plus(Duration.ofMinutes(2)); over ZonedDateTime.now().plus(Duration.parse("PT2M"));

So, we are not disagreeing, I think it’s a matter of preference. But still thanks for your version as I was not aware of this “now” magic being possible with toZDT(Duration).

Yes, that was my point, but since none of the versions will create “compile”-time issues, the point is moot. :wink:

:+1: I tend to stay away from LocalDateTime until formatting it to a user of some sort. This way there will be no issues if suddenly changing the system time-zone or going into DST (or out of). And actually that is also the reason why I tend to stay away from ZonedDateTime for as long as possible, since usually the time-zone only matters when displaying the timestamp, not when obtaining it.

This is currently a mess (IMHO) where bindings currently usually provide DateTimes from ZonedDateTime, which requires a TimeZoneProvider injected. The time-zone at this stage shouldn’t matter at all - it would be better to apply that when displaying the Instant in the UI formatted in local time-zone.

See also:

But you have a good point about sticking with ZonedDateTime which is better supported in openhab-js.

Just to be clear, I believe you misquoted - you wrote that statement. :slightly_smiling_face:

Yes, but it could have had a method overload with system default time-zone. To my surprise LocalDateTime.ofInstant defaults that:

time.LocalDateTime.ofInstant(programmedStart)

Documented here:

:+1: @florian-h05 - perhaps you can confirm? Perhaps I’m missing something, but I don’t see it here:

So I was not able to use the Rule Builder for this rule:

rules.JSRule({
    name: "Dishwasher start",
    description: "Start dishwasher based on calculated price",
    triggers: [triggers.DateTimeTrigger('Dishwasher_CalculatedStartTime')],
    execute: event => {
        if (items.Dishwasher_RawState.isUninitialized || items.Dishwasher_RawState.numericState != 4 || items.Dishwasher_CalculatedStartTime.isUninitialized) {
            return;
        }
        items.Dishwasher_Switch.sendCommand("ON");
    }
});

Nope, I’m admitting to making an assumption that upon looking at the javadocs proved to be false. ZonedDateTime does not in fact inherit from Instant. I’m not sure why it was attributing that to you. I meant to quote my statement and make clear I was wrong.

There were some issues opened to standardize on Instance in openHAB core but unless and until some work is done in that direction the standard DateTime used by most of OH remains ZonedDateTime. The fact that this one binding requires Instants is the exception to this rule.

I have updated the calculation rule with simplified date handling.

1 Like

Here’s the fixed version with correct handling of Number:Time without assuming minutes as internal unit (my bad code was broken by 4.0):

rule "Tumble dryer final price"
when
    Item TumbleDryer_RawState changed to 7 // End
then
    val elapsedTime = (TumbleDryer_ElapsedTime.state as QuantityType<Duration>).toUnit("s").intValue
    val Instant endTime = now.toInstant()
    val Instant startTime = endTime.minusSeconds(elapsedTime)

    logInfo("Tumble Dryer", "Calculating price for " + startTime.toString + " - " + endTime.toString)

    val actions = getActions("energidataservice", "energidataservice:service:energidataservice");
    var price = Float::parseFloat(transform("VAT", "DK", actions.calculatePrice(startTime, endTime, 667 | W).toString))
    sendPushMessage.apply("Tumble dryer", "Price for run program: " + String.format("%.2f", price) + " kr.")
end

And here’s the JavaScript equivalent:

rules.when()
    .item("TumbleDryer_RawState").changed().to(7) // End
    .then(event =>
    {
        var elapsed = time.Duration.ofSeconds(Quantity(items.TumbleDryer_ElapsedTime.state).toUnit("s").int);
        var endTime = time.Instant.now();
        var edsActions = actions.get("energidataservice", "energidataservice:service:energidataservice");
        var rawPrice = edsActions.calculatePrice(endTime.minus(elapsed), endTime, Quantity("667 W"));
        var price = parseFloat(actions.Transformation.transform("VAT", "DK", rawPrice.toString())).toFixed(2).replace('.', ',');

        notification.send("Tumble dryer", "Price for run program: " + price + " kr.");
    })
    .build("Tumble dryer finished", "Calculate tumble dryer final price");

I can confirm that it is missing. I’ve added the DateTimeTrigger to the triggers for JSRule myself and haven’t thought of rule builder.

Can you please open an issue on the openhab-ja repo?

Sure:

1 Like