Jython Elapsed Time, Millis and Joda Time Functions

I thought I would post some functions that I’m using to give back a little to the community. I find myself needing Elapsed time for many different cases in my OH system, for notifications and other purposes, so this was one of the first helper functions I put into action once I got Jython up and running. I also wanted a shorter way of updating a DateTime item to “now”, so I can now simply use joda_now(), and over the years have found many reasons to use a millisecond timestamp for timing and other things in python code, so I have a millis() function in there as well. There are also a few other functions that are dependencies within the code. I’m sure there’s a 95% chance that I’ve re-invented the wheel here, and I’m self-taught at programming in general, so this will probably look pretty “hacky” to some folks. I’d love to hear suggestions on ways to make the code better, or point me in the right direction if this sort of thing already exists somewhere. I personally think it would be cool if these functions could end up in the openHAB helper libraries date.py, which I’d be glad to figure out how to contribute to if anyone was interested.

I have the following code in a file called utilities.py placed in the /automation/lib/python/personal folder. Then in my jython rule script I place (as needed):

from personal.utilities import joda_now, millis, elapsed

Calling millis() returns the milliseconds as int since Unix epoch.

Calling joda_now() returns the Joda DateTime type string for that instant in the form 2019-09-03T15:22:33.650-05:00. This can be directly placed into an item update, like this: events.postUpdate("SomeDateTimeItem", joda_now()). By calling joda_now(False) or joda_now(string=False) it will instead return the Joda DateTime type object.

Calling elapsed() will return the elapsed time in various forms. elapsed() only requires one argument, a DateTime object, item or Joda DTT string. When only one argument is included, the elapsed time from that time stamp to “now” will be used. If two arguments are included, the elapsed time between them will be given. The elapsed time will default to a digital string output in the form Dd HH:MM:SS (d will only be included for elapsed times greater than 24 hours). See the code block for the other output possibilities, one of which is human readable text string, which should be voiceTTS friendly.

Here’s the code!

# ---------------
# --- utilities.py
# ----- Module for various utility functions to be used across jython rules scripts
# ----- Place in /conf/automation/lib/python/personal
# ---------------

# *** = If incorporated into date.py, these would NOT be needed
from core.date import to_python_datetime    # ***
from core.date import to_joda_datetime      # ***
from core.date import human_readable_seconds # ***
from core.date import _pythonTimezone       # ***
from java.time import ZonedDateTime         # ***, Note Uppercase letters for Java/Joda
import datetime                             # ***, Note Lowercase letters for Python

import time

from core.log import logging, LOG_PREFIX    # Only needed if logging from this script
title = "personal.utilities.py"
default_log = logging.getLogger("{}.{}".format(LOG_PREFIX, title))


# ----- FUNCTION: Get the current time in millis -----
def millis():
    # Get the current time in millis (unix epoch) as an int
    #   - To simplify generic timing applications
    return int(round(time.time() * 1000))


# ----- FUNCTION: Get the current time (Joda DateTimeType) -----
def joda_now(string=True):
    # If string == True: (Default)
    #   Returns the string of the Joda DateTimeType to be used
    #   in updating a DateTime item
    # If string == False: 
    #   Returns a Joda DateTime object
    if string == True:
        return str(to_joda_datetime(ZonedDateTime.now()))
    else: 
        return to_joda_datetime(ZonedDateTime.now())


# ----- FUNCTION: Get the elapsed time between two times -----
def elapsed(start, end=joda_now(), format='digital'):
    # Args:
    #   start   - Start time in any datetime type or joda zdtt string (Required)
    #   end     - End time, optional. If not included the current time will be used
    #   format  - 'digital' - Returns string '(-)HH:MM:SS' or '(-)Dd HH:MM:SS'
    #           - 'text'    - Returns human readable string '2 days, 3 hours, 45 minutes'
    #           - 'seconds' - Returns the seconds as a float (with ms precision)

    e_digital = '00:00:00'      
    e_text    = '0 seconds'
    e_seconds = 0.0

    try:    start_pdt = to_python_datetime(start)
    except  TypeError:  # 'start' may have been a string returned by joda_now()
        start_pdt = str_to_python_datetime(start)
    try:    end_pdt = to_python_datetime(end)
    except  TypeError:  # 'end' may have been a string returned by joda_now()
        end_pdt = str_to_python_datetime(end)
    
    td = (end_pdt - start_pdt)          # Total time delay (as timedelta)
    ts = e_seconds = td.total_seconds() 
    sign = '-' if (ts < 0) else ''      # Get the sign of 'ts'          
    ts = abs(ts)                        # Make 'ts' a positive number

    days, seconds = divmod(ts, 86400)
    minutes, seconds = divmod(seconds, 60)
    hours, minutes = divmod(minutes, 60)
    seconds = int(round(float(seconds) + (float(td.microseconds) / 1000.0 / 1000.0)))
    days = int(days)
    hours = int(hours)
    minutes = int(minutes)
    
    # Format the digital elapsed output
    if days > 0:
        e_digital = "{}{}d {:02d}:{:02d}:{:02d}".format(sign, days, hours, minutes, seconds)
    else:
        e_digital = "{}{:02d}:{:02d}:{:02d}".format(sign, hours, minutes, seconds)
    
    # Format the text (voice TTS) elapsed output
    e_text = human_readable_seconds(ts)        # Added
    if sign == '-': e_text = "Negative " + e_text

    #default_log.debug("DIGITAL: {}".format(e_digital))
    #default_log.debug("TEXT:    {}".format(e_text))
    #default_log.debug("SECONDS: {}".format(e_seconds))

    if   format == 'seconds':   return e_seconds
    elif format == 'text':      return e_text
    else:                       return e_digital

    ''' 
    Example Use:
    from personal.<this_script> import elapsed, joda_now

    example.log.info("Elapsed: {}".format(elapsed(items.SomeItem, joda_now())))  
    example.log.info("Elapsed: {}".format(elapsed(items.SomeItem, joda_now(False), format='text')))  
    example.log.info("Elapsed: {}".format(elapsed(items.SomeItem, joda_now(), format='seconds')))  

    Output:
    Elapsed: 405d 13:37:17
    Elapsed: 405 days, 13 hours, 37 minutes
    Elapsed: 35041036.273

    '''

# ----- FUNCTION: Convert Joda Zoned Date Time String to Python datetime -----
def str_to_python_datetime(time_str):
    # Manually parse TZ info, generate the offset and replace in python datetime
    # Accepts time_str:  String representation of joda zoned datetimetype
    # Returns:           Timezone aware python datetime object

    naive_time = time_str[:23]                  # removes '-05:00' (TZ) from joda zdtt str
    tz_hr, tz_min = time_str[23:].split(':')    # Splits TZ into hr, min
    offset = int(tz_hr) * 60 + int(tz_min)      # Gets minutes of offset
    naive_pdt = datetime.datetime.strptime(naive_time, '%Y-%m-%dT%H:%M:%S.%f')
    return naive_pdt.replace(tzinfo=_pythonTimezone(offset))
        # Build tzinfo objects for fixed-offset time zones
        # https://docs.python.org/2.7/library/datetime.html
1 Like

Have you looked at core.date? I also recently added this…

I did look at core.date, and actually use a function or two from it in mine. But, once I got heads-down working on mine I guess I overlapped quite a bit without realizing it!

I could probably shorten up my elapsed() code by incorporating more from date.py. It’s fun to see how someone who knows how to code properly attacks a similar problem. Your human_readable_seconds is very clean.

1 Like

@5iver, I just knocked out about 25 lines of the elapsed() code by using your human_readable_seconds() function! But you may want to add some int() calls to the output of days, hours and minutes, and potentially round the seconds to the nearest second. I currently am showing this as output from it:

Elapsed: 405.0 days, 15.0 hours and 2.72299999744 seconds

I modified my code above to use your _pythonTimezone() class instead of the one I provided.

It makes sense to convert the argument to an integer. Done… I just need to test and commit. This function in intended to take an integer as the argument. I usually use it like…

time_interval = human_readable_seconds(seconds_between(PersistenceExtensions.previousState(item).timestamp, DateTime.now()))

That works!

1 Like