4

I have an interval, in seconds with a decimal, that I'd like to display in human-readable (H:MM:SS.SSS) format. For example, 16633.284 should be displayed as 4:37:13.284. All these times are under 24 hours, but if they were over it'd still just be hours.

A few more examples:

   0      → 0:00:00.000
  60.394  → 0:01:00.394
8944.77   → 2:29:04.770

Note it's fixed-width for under 10 hours. Of course, that's fairly easily done with printf.

I'm posting my solution as an answer, but it feels like there must be a better way to do it, so I'm asking: what are the other ways? I'm open to ways that are bashisms, zshisms, etc.

Note: This is related to Displaying seconds as days/hours/mins/seconds? but those approaches all don't work because arithmetic in bash is integer-only.

derobert
  • 109,670

5 Answers5

7

The fractional part doesn't contribute anything to the number of hours, minutes, or seconds, so you could simply leave it aside, do the calculations without it, and add it back at the end. Something like this:

#!/bin/sh
seconds=16633.284
f=${seconds##*.}
if [ "$f" = "$seconds" ]; then f=0; fi
t=${seconds%.*}
s=$((t%60))
m=$((t/60%60))
h=$((t/60/60))
printf '%d:%02d:%06.3f\n' $h $m $s.$f

Output:

   0     → 0:00:00.000
  33.    → 0:00:33.000
    .21  → 0:00:00.210
  60.394 → 0:01:00.394
8944.77  → 2:29:04.770

This works in any sh-like shell (except some ancient pre-POSIX ones).

Satō Katsura
  • 13,368
  • 2
  • 31
  • 50
  • Plain sh doesn't have let, but you can use arithmetic syntax instead. You don't need the prefix trick in test except in some antiques that don't have ${…%…} or arithmetic anyway. – Gilles 'SO- stop being evil' May 20 '17 at 13:01
  • Strictly speaking, printf %f is not POSIX though I don't know of any implementation that doesn't support it. However many honour the locale's decimal separator, so you'd want to force the locale to C to make sure that . in $s.$f is taken as a decimal separator (though that means a dot will be used for output as well instead of the user's decimal separator). But, because of the problem of 59.9999 being turned into 60.000, you might as well do printf '%d:%02d:%02d.%.3s\n' "$h" "$m" "$s" "${f}000" – Stéphane Chazelas May 20 '17 at 21:45
  • (Or printf '...%s...\n' ... "$(locale decimal_point)" ... if you want to honour the user's decimal separator) – Stéphane Chazelas May 20 '17 at 21:47
3

My approach is this, using dc to do the calculations:

seconds=16633.284
printf '%i:%02i:%06.3f\n' \
    $(dc -e "$seconds d 3600 / n [ ] n d 60 / 60 % n [ ] n 60 % f")

The printf is hopefully rather familiar, but if not, %i means an integer. %02i means to pad the integer with a leading 0 if its a single-digit. %06.3f is the weirdest one, but it means 3 digits after the decimal point, and to pad with leading 0s if the total length is less than 6 (including the decimal point).

The intentionally not quoted $(…) substitution gives those three parameters, using dc:

First, push $seconds (expanded, so the actual number) on to the stack; duplicate it; then divide (with truncate) by 3600 to get hours. Pop and print that, then push space and print it ([ ] n).

That leaves just the seconds on the stack. Duplicate it, divide by 60 (again truncating). That gives minutes; take it mod 60 to throw away the hours. Print it, again with the space. Leaving just the seconds on the stack.

Finally, mod 60 that gives just the seconds field—and in dc, modulus is perfectly happy with decimals and preserves the precision. f then prints the stack plus a newline.

derobert
  • 109,670
3

You can easily do it in plain sh. You can make the code slightly shorter with GNU date (hope you don't mind rounding the milliseconds down, rounding to nearest would be slightly longer):

hours=$((${seconds%.*} / 3600))
min_s_nano=$(TZ=GMT date -d @$seconds +%M:%S.%N)
time_string=$hours:${min_s_nano%??????}
derobert
  • 109,670
1

For durations of less than 24 hours, in ksh93, you can do:

$ TZ=UTC0 printf '%(%T.%3N)T\n' '#86399.99'
23:59:59.990

Portably, doing the calculation by hand is relatively easy. And awk with its math and formatting abilities sounds like the most obvious choice:

$ awk -v t=123456789.123456 'BEGIN{
    printf "%d:%02d:%06.3f\n", t/3600, (t/60)%60, t%60}'
34293:33:09.123

Like with your printf approach, you may have some unexpected results because of rounding though:

$ awk -v t=59.9999 'BEGIN{
   printf "%d:%02d:%06.3f\n", t/3600, (t/60)%60, t%60}'
0:00:60.000

So you may want to do:

$ awk -v t=599.9999 '
   BEGIN{t=int(t*1000);
   printf "%d:%02d:%02d.%03d\n", t/3600000, t/60000%60, t/1000%60, t%1000}'
0:09:59.999
1

We invoke dc and place the number of seconds on it's stack and perform the compuatations(secs->HH/MM/SS.MSEC), formating, and displaying results.

seconds=16633.284

dc <<DC
# rearrange stack for number < 1hr
[r ldx q]sb

# rearrange stack for number < 1min
[rd ldxq]sc

# min++, sec-=60   hrs++, min-=60
[r1+r 60-d 60!>a]sa

# display result as h:mm:ss.sss
[n 58an 2lpx 58an 2lpx 46an 3lpx 10an]sd

# perform the conversion of seconds -> hours, minutes, seconds
[d60 >c lax r0rd60 >b lax r ldx]si

[
d1000* 1000% 1/r   # fractional portion
 1000* 1000/ 1/0r  # integer    portion
lix
]sh

# left zero-pad a number
[lk1+d sk 0n le >g ]sg
[sedZd sk    le >gn]sp

# setup the number on the stack and begin computation
$seconds lhx
DC

Results

0                 -->  0:00:00.000
60.394            -->  0:01:00.394
8944.77           -->  2:29:04.770
86399.99          -->  23:59:59.990
59.9999           -->  0:00:59.999
599.9999          -->  0:09:59.999
16633.284         -->  4:37:13.284
33                -->  0:00:33.000
.21               -->  0:00:00.210
123456789.123456  -->  34293:33:09.123