OH3: the dreaded time conversions

  • Platform information:
    • Hardware: Raspberry Pi 4 Model B Rev 1.1 ; 4GB memory
    • Host: Linux openhabian 5.15.76-v7l+ #1597 SMP Fri Nov 4 12:14:58 GMT 2022 armv7l GNU/Linux
    • Distro: Raspbian GNU/Linux 11 (bullseye)
    • openjdk version “11.0.18” 2023-01-17
    • OpenJDK Runtime Environment (build 11.0.18+10-post-Raspbian-1deb11u1)
    • OpenJDK Server VM (build 11.0.18+10-post-Raspbian-1deb11u1, mixed mode)
  • OH Version: 3.4.3 (Build)
    • Installation method: openhabian

I have a rule, which, in essence, should output a duration formatted as HH:mm.

The rule takes the battery SoC, calculates how much capacity is missing; takes the current battery input power and calculates the time when the battery reaches a SoC of 100%.

Here it is:

rule "SP-PRO: calculate when battery charging is complete"
    when
        Item spm_Battery_SoC changed
/*
        or
        //         s m    h D M DoW Y
        Time cron "1 0/1 * * * ?"
        // here: every 10 minutes; use http://www.cronmaker.com/
*/
    then
        //val RULE_ID = 05

        if ((tod_TimeOfDay.state == "DAY") && (spp_SOC100RanOnce.state == OFF))
        {
            // in Wh: 3.2V * 16 cells * 400 Ah
            val BATTERY_CAPACITY = 20480

            val MISSING_CAPACITY = (BATTERY_CAPACITY - (BATTERY_CAPACITY * (spm_Battery_SoC.state as Number) / 100)).intValue
            val HOURS_TO_CHARGE = MISSING_CAPACITY / spm_Battery_Power.state as Number

            // split decimal hours to hours and MINUTES
            val HOURS = (Math::floor(HOURS_TO_CHARGE.floatValue)).intValue
            val MINUTES = Math::round((HOURS_TO_CHARGE.floatValue - HOURS) * 60)

            // date time as string
            // 2024-03-27T01:30:01.740017+10:00[Australia/Brisbane]
            val CHARGED_AT_TIME = now().plusHours(HOURS).plusMinutes(MINUTES)

            // brute force formatting of 2024-03-25T13:02:34.021835+10:00[Australia/Brisbane]
            // change the time format to string; split the string at "T" and
            // get the second element: 13:02:34.021835+10:00[Australia/Brisbane]
            val CHARGED_AT_TIME_PART = CHARGED_AT_TIME.toString().split("T").get(1)

            // split second element at the dot nd get the first element: 13:02:34
            val TIME_STAMP_PART = CHARGED_AT_TIME_PART.split("\\.").get(0)

            // take the first element and split at the colon
            val TIME_STAMP_SPLIT = TIME_STAMP_PART.split(":")
            val HOUR = TIME_STAMP_SPLIT.get(0)
            val MINUTE = TIME_STAMP_SPLIT.get(1)

            //logInfo(LOG_PREFIX + RULE_ID + ".01", "Battery charged at {}:{}", HOUR, MINUTE)

            postUpdate(SPPRO_Battery_Charged_ETA, HOUR + ":" + MINUTE)
        }
end

I have used brute force to convert DateTime to HH:mm. The problem is, when the ETA to SoC of 100% is falling onto the next day. E.g., it may show 02:45.

There must be a more elegant approach.
So I tried this:

rule "SP-PRO: calculate when battery charging is complete"
    when
        Item spm_Battery_SoC changed
        or
        //         s m    h D M DoW Y
        Time cron "1 0/1 * * * ?"
        // here: every 10 minutes; use http://www.cronmaker.com/
    then
        val RULE_ID = 05

        if ((tod_TimeOfDay.state == "DAY") && (spp_SOC100RanOnce.state == OFF))
        {
            // in Wh: 3.2V * 16 cells * 400 Ah
            val BATTERY_CAPACITY = 20480

            val MISSING_CAPACITY = (BATTERY_CAPACITY - (BATTERY_CAPACITY * (spm_Battery_SoC.state as Number) / 100)).intValue
            val HOURS_TO_CHARGE = MISSING_CAPACITY / spm_Battery_Power.state as Number

            // split decimal hours to hours and MINUTES
            val HOURS = (Math::floor(HOURS_TO_CHARGE.floatValue)).intValue
            val MINUTES = Math::round((HOURS_TO_CHARGE.floatValue - HOURS) * 60)

            // date time as string
            // 2024-03-27T01:30:01.740017+10:00[Australia/Brisbane]
            val CHARGED_AT_TIME = now().plusHours(HOURS).plusMinutes(MINUTES)

// change starts here!

            // PT16H16M59.998243S
            val CHARGED_IN = Duration.between(now(), CHARGED_AT_TIME)

            val DURATION_HOURS = CHARGED_IN.toHours             // e.g. 6.75
            val DURATION_MINUTES = CHARGED_IN.toMinutesPart     // e.g. 45

            var DEBUG_FLAG = 1

            if (DEBUG_FLAG == 1)
            {
                logInfo(LOG_PREFIX + RULE_ID + ".0c", "Battery charged at {}", CHARGED_AT_TIME)
                logInfo(LOG_PREFIX + RULE_ID + ".0d", "Battery charged in {}", CHARGED_IN)
                logInfo(LOG_PREFIX + RULE_ID + ".0e", "DURATION_HOURS ... {}", DURATION_HOURS)
                logInfo(LOG_PREFIX + RULE_ID + ".0f", "DURATION_MINUTES . {}", DURATION_MINUTES)
            }

            val DURATION_STRING = CHARGED_IN.format(DateTimeFormatter.ofPattern("HH:mm"))
            logInfo(LOG_PREFIX + RULE_ID + ".0g", "DURATION_STRING .. {}", DURATION_STRING)

            postUpdate(SPPRO_Battery_Charged_ETA, DURATION_STRING)
        }
end

However, I get stuck with The method or field DateTimeFormatter is undefined… in the line:

var duration_string = charged_in.format(DateTimeFormatter.ofPattern("HH:mm"))

Any hints appreciated.

Hi, @Max_G.

I believe OH 3.X used Java 11, and the documentation for the Duration Java class didn’t include any function called format. That seems to be a function of the String class.

Searching on the web, I found the following: date formatting - How to format a duration in java? (e.g format H:MM:SS) - Stack Overflow and it seems you have to write a conversion function, but as far as I remember, that wasn’t possible in Rules DSL.

Don’t know if you can also do it with Joda Time. I’ll see if I can find something in my old DSL rules.

1 Like

I got that line I am stuck on from another post I cannot find, and, in hindsight, could be referring to JS script?!
Well, for the moment I kept my brute force method :slight_smile:

Why don’t you use openHAB standard methods?

rule "SP-PRO: calculate when battery charging is complete"
when
    Item spm_Battery_SoC changed or                                          // should affect estimated time
    Item spm_Battery_Power changed                                           // should also affect estimated time
then
    if(tod_TimeOfDay.state != "DAY" || spp_SOC100RanOnce.state != OFF)
        return;                                                              // early return if conditions not met

    if(!(spm_Battery_SoC.state instanceof Number)) {
        logWarn("spPro","spm_Battery_SoC state is not a number ({}), so stop rule right now!", spm_Battery_SoC.state)
        return;
    }
    val SoC              = (spm_Battery_SoC.state as Number).floatValue      // get state of charge

    if(!(spm_Battery_Power.state instanceof Number)) {
        logWarn("spPro","spm_Battery_Power state is not a number ({}), so stop rule right now!", spm_Battery_Power.state)
        return;
    }
    val nPower           = (spm_Battery_Power.state as Number).floatValue    // get current power

    val BATTERY_CAPACITY = 20480                                             // in Wh: 3.2V * 16 cells * 400 Ah
    val SoDC             = 100 - SoC                                         // get state of discharge
    val MISSING_CAPACITY = BATTERY_CAPACITY * SoDC / 100                     // get missing Wh
    val HOURS_TO_CHARGE  = MISSING_CAPACITY / nPower                         // teim to charge in hourse and decimal fractions of hours
    val iHOURS           = HOURS_TO_CHARGE.intValue                          // get full hours
    val iMINUTES         = ((HOURS_TO_CHARGE - iHOURS) * 60 + 0.5).intValue  // get fraction of hours in minutes (more than 30 Seconds -> extra minute)
    val ESTIMATED_DT     = now.plusHours(iHOURS).plusMinutes(iMINUTES)       // get timestamp of estimated end of charge

    logInfo("spPro", "Battery charged at {}", ESTIMATED_DT)                  // log the full timestamp

    SPPRO_Battery_Charged_ETA.postUpdate(new DateTimeType(ESTIMATED_DT))     // write timestamp to a DateTime item
end

Of course you’ll have to define SPPRO_Battery_Charged_ETA as a DateTime Item. You can setup the display format to whatever you want :slight_smile: (so with or without date information)

For some reason I got a warning for some constant names, and of course I tend to write them in CamelCase :slight_smile: therefor some slightly changed names and some new ones.
It’s really important to check wether a number item has a number as state, therefor some additional lines of code. And some minor changes in the method of calculation (just a habit…).

Please be aware, that log commands use the first string as part of logger name. So at least use only a simple string and use the same string through the whole rule. You can get rid of the INFO message by setting

log:set WARN org.openhab.core.model.script.spPro

through the karaf console. After that command the INFO line wont be displayed, but warnings still get logged. If you don’t want them either, set the logger to ERROR. If you want them back, set it to DEFAULT or INFO.
Please, don’t misuse the first string of log commands.

1 Like

Thank you for your detailed demonstration on how this rule could be optimised.

The short of it is:

  1. delete everything from //brute force to the end
  2. replace the last line with
SPPRO_Battery_Charged_ETA.postUpdate(new DateTimeType(CHARGED_AT_TIME))

I have some question WRT your style / approach (out of curiosity not criticism):

  1. is the first return a better option than keeping my brackets. The compiler would evaluate and bail if the conditions are not met!?
  2. Why test instanceof Number, when the item is a Number item?
  3. I get the shorter lines, by splitting calculations into simpler ones.
  4. Agree, with the getting rid of the split() get() business.

And yes, I want to use what DSL offers, but couldn’t hack it, hence, my post :slight_smile:

Also, I don’t use camelCase. It seems the same is valid in Java as it is in C, where constants are all upper case.

Thanks for pointing out the log settings, which I never made use of. I basically use the logInfo() during rule development and then get rid of them.

What do you mean by:

I use the RULE_ID, in case I add new ones or reshuffle them in the .rules. file, w/o having to renumber these.

Again, thank you for your post… sometimes I wish I had someone reviewing my code. I would even pay for that, with a post session explaining the reasoning for the improvements.

  1. I don’t know if it’s better, but there is less indentation (obviously you don’t need no indentation at all, but it’s code style) and another thing is: you get the “here it ends” info at the very beginning. Please be aware that I also set values only after ensuring i will need them.
  2. A Number Item can at least hold NULL and UNDEF as a state, both are not a number and thus the rule will cause a nullPointerException if not checked.
  3. If it’s about shorter lines, please take formatting into account. In fact, I could get shorter lines with shorter vals:
val SoDC = (100 - (spm_Battery_SoC.state as Number).floatValue) / 100
val TTCh = 20480 * SoDC / nPower
val iHours = TTCh.intValue
val iMinutes = ((TTCh - iHours) * 60 + 0.5).intValue
val dtETA = now.plusHours(iHours).plusMinutes(iMinutes)

The first string of log commands is the logger name. You can use whatever you want, but it’s not meant to be a freely chosen string. for sure you already know that the first string is part of the log line (within brackets […]), and that is the logger name, of course shortened from left to 36 chars.

1 Like