Search code examples
clojurejava-time

Convert float seconds to seconds and nanoOfSecond for java-time


I have a floating point value representing seconds. I'd like to split it into two integers representing whole seconds and nanoseconds in order to pass it as the last two arguments to the java.time.ZonedDateTime.of() constructor.

This is my current approach, but I worry that I might be needlessly losing precision or recreating some sort of existing java-time functionality:

;;seconds >= 0 and < 60
(defn seconds-and-nanos
  [seconds]
  (cond
    (integer? seconds) [seconds 0]
    (float? seconds) [(int seconds) (int (* (mod seconds (int seconds)) 1000000000))]))


;;repl> (seconds-and-nanos 3.4)
;;[3 399999999]

Is there a better way? Thanks for any help.

Update, this appears to work better, but still curious if it could be improved:

;;seconds >= 0 and < 60
(defn seconds-and-nanos
  [seconds]
  (cond
    (integer? seconds)
    [seconds 0]
    
    (float? seconds)
    (let [whole (int seconds)
          nano (Math/round (* (- seconds whole) 1000000000))]
      (if (= nano 1000000000)
        [whole (- nano 1)]
        [whole nano]))))

;;repl> (seconds-and-nanos 3.4)
;;[3 400000000]
;;repl> (seconds-and-nanos 3.9999999999)
;;[3 999999999]
;;repl> (seconds-and-nanos 3.999999999)
;;[3 999999999]

Update 2

Per the accepted answer and comment, and per some of my own experience repurposing this code, I consolidated to:

(defn seconds-and-nanos
  [seconds]
  (let [whole (long (Math/floor seconds))
        fraction (- seconds whole)
        nano (* fraction 1000000000)
        nano (cond
               (float? nano) (Math/round nano)
               (instance? clojure.lang.BigInt nano) (long nano)
               :else nano)]
    (if (= nano 1000000000)
      [whole (- nano 1)]
      [whole nano])))

seconds are no longer constrained to 60 or fewer. The if (= nano 1000000000) block is highly context dependent and could be eliminated or rewritten as needed; for my use cases I decided this is a rare enough edge case (arising only when somehow dealing with seconds of well beyond nano precision, which is already dubious) that I decided to go the much more convenient path of always rounding down to avoid adding a whole second and potentially cascading up every other unit (minute, hour etc).

Thanks for all the help.


Solution

  • I would use something like the following, beginning from my favorite template project:

    (ns tst.demo.core
      (:use demo.core tupelo.core tupelo.test)
      (:require
        [schema.core :as s])
      (:import
        [java.time Instant]))
    
    (def SECOND->NANOS 1.0e9)
    
    (s/defn epoch-sec->Instant :- Instant
      "Accepts a floating point value of epoch second and converts to a java.time.Instant"
      [epoch-seconds :- s/Num]
      (assert (<= 0 epoch-seconds)) ; throw for negative times for simplicity
      (let [esec-dbl      (double epoch-seconds)
            esec-whole    (Math/floor esec-dbl)
            esec-fraction (- esec-dbl esec-whole)
            esec-nanos    (Math/round (* esec-fraction SECOND->NANOS))
            result        (Instant/ofEpochSecond (long esec-whole) (long esec-nanos))]
        result))
    

    with unit test

    (verify
      (throws? (epoch-sec->Instant -1))
      (is= "1970-01-01T00:00:00Z" (str (epoch-sec->Instant 0.0)))
      (is= "1970-01-01T00:00:00.100Z" (str (epoch-sec->Instant 0.1)))
      (is= "1970-01-01T00:00:00.999990Z" (str (epoch-sec->Instant 0.99999)))
      (is= "1970-01-01T00:00:00.999999900Z" (str (epoch-sec->Instant 0.9999999))))
    

    Then, you can build a ZDT from the Instant value using the static method

    static ZonedDateTime  ofInstant(Instant instant, ZoneId zone)
    

    You can find a large number of conversion and convenience functions for java.time already written here.