Computing Temporal Hours from Cached Data Daniel Kalak 2026-03-30 Summary I give a POSIX shell script, with a dependency on GNU date and logic largely in the awk language, that converts timestamps to and from "temporal hours" -- an ancient convention that divides the time between sunrise and sunset into twelve equal parts, varying in length over the seasons. The script relies on data about the local sunrise and sunset times, which it reads from a text file. The script provides a command to fill this text file for one or more consecutive years, either using a web API or Solderpunk's "day" script (as submitted to OFFLFIRSOCH 2025). The computed temporal hours are printed in a familiar 24-hour HH:MM:SS notation, or, optionally, in Roman numerals, in a tra- ditional 12-hour notation enhanced by Arabic-numeral minute and second parts. This document is my submission to this year's OFFLFIRSOCH. Introduction On 2026-03-01, I read up again about "temporal hours" (also known as "relative", "seasonal", "variable", "unequal", "an- cient", "Roman", "biblical", "Jewish", or "halachic hours"): The Romans used to divide the time between sunrise and sunset into 12 equal hours of the day, and the time between sunset and sun- rise into 12 equal hours of the night (which also made up four equal "night watches" consisting of three night hours each). This practice was also common in other cultures, as well as in Rabbinic Judaism. In the northern hemisphere, the hours of the day would be longer during summer and shorter during winter. Noon would be around the end of the sixth hour of the day, mid- night at the end of the second night watch. (Note that apparent solar noon can deviate slightly from the exact midpoint between sunrise and sunset.) For more information about temporal hours, I recommend the Wikipedia articles "Unequal hours", "Relative hour", and "Roman timekeeping" as starting points for further reading. Curiously, "Temporal hour" redirects to "Relative hour", but "Temporal hours" redirects to "Unequal hours". I then stumbled upon Neville Park's shell script at https://codeberg.org/nev/temporal-hours which seems to make a network request to a web API every time it is executed. This makes the script less reliable than it could be (because it depends on an internet connection), as well as unsuitable for frequent use, like in a status bar (because of the avoidable network traffic and the time it takes to com- plete). So I wanted to write my own version to avoid this. The idea was to cache the returned sunrise and sunset times from the web API in a simple text file. Only later, while browsing gopherspace on 2026-03-22, did I realize that my script -- especially given its motivation -- would be a suitable submission to Solderpunk's gopherly renowned annual "OFFLFIRSOCH" software challenge, announced for this year at gopher://zaibatsu.circumlunar.space/0/~solderpunk/ phlog/announcing-offlfirsoch-2026.txt I realized even later, on 2026-03-26, that Solderpunk's own sub- mission to last year's OFFLFIRSOCH, a Lua script called "day", provides an offline solution to the only problem for which my script still relied on the internet, namely fetching the sunrise and sunset times. I added an integration for it on 2026-03-29, rendering the script completely independent of an internet con- nection. So here goes my first submission. I first describe how to use the script, then give a detailed account about some minute aspects of its design, suggest a closer combination with Solder- punk's "day" script, and conclude with a complete source code listing in the appendix. Usage Note that the script requires grep, awk, and some programs that are part of the GNU core utilities (printf, seq, wc, and date). The script relies on GNU date's "-d" and "+%s" features, which might not be available in your system's version of "date". Also, the script requires curl and jq if you want to populate the cache file using the web API, or Solderpunk's "day" script otherwise. You can read Solderpunk's announcement for it at gopher://zaibatsu.circumlunar.space/0/~solderpunk/ phlog/an-offline-sun-time-calculator.txt and you can get the script at https://git.sr.ht/~solderpunk/day The location of the cache file is hard-coded in the script, given initially as "~/suncache.dat", which you can change. In the examples, I refer to the script as "hours", but you can in- stall it under any other name you prefer. The script supports three operations: populating the cache file for one or more consecutive years, converting equinoctial hours (our common system) to temporal hours, and converting tem- poral hours back to equinoctial hours. The syntax for populating the cache file is hours init api|day LAT LON YEAR_START [YEAR_END] If you give the "api" option, the script will make a request to the web API; this requires an internet connection. If you give the "day" option, the script will run the "day" script. Give the coordinates as decimal degrees; north and east are positive, south and west negative. (I use openstreetmap.org to find coor- dinates.) Remember that 4 decimal places already give a preci- sion of around 11 meters or (often) less. The cache file will contain one line per day, consisting of the date in a YYYY-MM-DD format, the sunrise time as a Unix timestamp, and the sunset time as a Unix timestamp, separated by spaces. The syntax for converting equinoctial to temporal hours is hours [romanh|romanw] [TIME [DATE]] The DATE argument should be in a YYYY-MM-DD format. The TIME argument should be in an HH:MM:SS format, but you can leave out the seconds. (In fact, the TIME argument is attached with a "T" to the DATE argument and then passed to "date -d" to convert it to a Unix timestamp, so technically you can give whatever works there.) If you omit any of the arguments, then the current date and time are used. (The script assumes that your "date" program uses a similar timezone as the location you used to initialize the cache file; there could be interesting bugs if your "date" program uses a different matching between dates and Unix time- stamps than listed in the cache file.) The result is printed in a 24-hour HH:MM:SS format, where sunrise is defined as 06:00:00, sunset as 18:00:00, 12:00:00 as the midpoint between sunrise and sunset, and 00:00:00 as mid- night. Note that the minute and second parts refer to 1/60 or 1/3600 of a temporal hour, respectively; they do not refer to the timespan of 60 or 1 SI seconds. If you give the "romanh" option, then the result is printed in Roman numerals, counted in the Roman fashion; uppercase for the hours of the day and lowercase for the hours of the night. The numeral is also accompanied by unpadded minute and second parts, to see how far along you are. (You can easily strip them away with "cut" or "sed".) 13:02:03 would thus be rendered as VIII 2' 3", 21:05:06 as iv 5' 6". If you give the "romanw" option, then the hours of the day are printed like with the "romanh" option, but the lowercase nu- merals for the night don't refer to one of the 12 hours of the night, but to one of the 4 night watches. (The "h" and "w" parts of the options stand for "hour" and "watch".) The minute and second parts also relate to the duration of the night watch, so they are 3 times as long as they would be in "hour" mode. 21:05:06 would thus be rendered as ii 1' 42", 00:03:06 as iii 1' 2". The syntax for converting temporal hours back to equinoctial hours is hours rev TIME [DATE] The DATE should be in a YYYY-MM-DD format again (as used in the cache file). The TIME should be in the same 24-hour HH:MM:SS notation as returned by the opposite operation, but you can take some liberty, such as leaving out the seconds, the minutes, or leading zeroes, or replacing the colons with spaces. If you omit the date, then the current date is used. A straightforward application of the script is to find out the current time in temporal hours, by typing "hours" without any arguments. But the script can answer more questions: "What will be the temporal hour at 1 pm?" ("hours 13:00"); "When will the sun set today?" ("hours rev 18:00"); "When will the sun rise on April 1?" ("hours rev 06:00 2026-04-01"); "Will 9 pm be be- fore or after sunset on the day of the solstice?" ("hours 21:00 2026-06-21"); "When will the `ninth hour' end on Good Friday?" ("hours rev 15:00 2026-04-03"); "If the temporal hours read 01:23:45, what night watch would that be, and how far along?" ("hours romanw $(hours rev 01:23:45)"). Design Over the course of the script's development, I have changed my mind about a few things, which I would like to document here. You can see this section as a list of "lessons learned" or "choices made". Display in HH:MM:SS Notation by Default The first version of the script had two separate shell functions with an awk program each: one to calculate the ratio of how much time had passed during the current day or night, and one to dis- play the result in Roman numerals (in the same form as "hours romanw" does in the final version, which was the only form I had implemented at the time). This spawned two awk processes, and since the script handed over the result of the first to the sec- ond function as an argument instead of in a pipeline, the two processes ran in sequence instead of in parallel. When I com- bined the two steps in the same awk program, execution times went down from around 40 milliseconds to around 30 milliseconds on my machine. (I only checked all of the execution times while preparing this document; my principal concern at the time was to make the code simpler.) In the same change, I decided to display the result in a 24-hour HH:MM:SS notation. This format is easier for other pro- grams to read and process, allowing for easy extensions via ex- ternal filter programs to convert the result into arbitrary other formats -- including the Roman numeral notation. The no- tation is also more familiar and thus perhaps easier to under- stand; displaying temporal hours in the same format as our com- mon "synchronized" clocks, instead of an obscure Roman notation, is similar, in a way, to clock towers showing local time before their synchronization in the late 19th century. Make the Cache Rich and the Queries Stupid Early versions of the script would fetch information about sun- rise and sunset times only as needed: the script would first query the sunrise and sunset times of the current day, compare them to the current time, and then query the previous or next day's times if necessary -- that is, before sunrise or after sunset. For each such query, the script would first examine the cache file and only make an API request, extending the cache file, if there was no entry for the queried day. I have since decided to populate the cache file beforehand for the entire year instead of falling back to the API on a day- to-day basis. This has two benefits: Firstly, the script stays independent of an internet connection for the entire year. Sec- ondly, the code gets simpler: since the cache file is sorted, you can get the information for yesterday, today, and tomorrow all at once with "grep -C1", without any conditional checks for necessity, in the same time that it would have taken to make the first "query" for the current day. This brought the execution times further down, from around 30 milliseconds to around 10-15 milliseconds on my machine. Use Unix Timestamps At that stage, the script still populated the cache file with sunrise and sunset times in a 24-hour HH:MM:SS notation, to al- low for human readability and easy manual editing. Internal calculations counted the seconds since midnight. However, this led to trouble around daylight saving time: if a sunset was listed before and the following sunrise after a switch to or from daylight saving time, then the night would be counted as one hour longer or shorter than it actually was, skewing the re- sults. Also, timestamps to be converted could be ambiguous around the hour of the switch. I thought about adding a column with the UTC offset to the cache file, but this would have made manual editing harder, and it would have complicated the computations. I also thought about ignoring the problem altogether, because the switches are so rare. But I couldn't leave the matter be: a program concern- ing itself with a pre-modern practice should not stop working because of a modern complication such as daylight saving time; exactly there is where it needs to be working reliably -- to defy the complications and to do the work, so that its user can be fully at ease and ignorant of it. I then noticed that the API offers Unix timestamps, which solved the problem at once: they produce correct timespans even across daylight saving time changes, are unambiguous, and have the benefit of being second-precision integers (like seconds of the day) already. Use "Semi-Defensive" Programming When I started to write the logic for the script, I thought about publishing it as a phlog post, providing different shell functions to be sourced and used interactively, such that read- ers could write their own interfaces for private use. But when I decided to submit the script to OFFLFIRSOCH, my focus switched to the command-line interface in order to provide a standalone program, hiding the internal shell functions from interaction. So I started to think about input validation. Should I com- plain about malformed input or let the subroutines crash at some point? Should I check dates and times only syntactically for a YYYY-MM-DD or HH:MM:SS structure, or also compute whether they actually exist? I was about to write EREs for the coordinates and even check whether any argument contained a newline (which a simple "grep -Eq" wouldn't help against)! It all seemed feasi- ble, because I did, after all, know at every step which things might go wrong (or so I believed); but the validation code would have exceeded the actual logic in size, and it felt weird to think of the user as a kind of attacker. And so I took a less rigid approach. The script still checks against "honest mistakes": the wrong number of arguments (in which case it prints its usage) or insufficient data in the cache (which can happen before the first use or at the end of the last fetched year). It also does sanity checks where expe- dient: whether the times in the cache are increasing (to prevent divisions by zero) and whether an API request really returned 365 or 366 days per year. But it no longer checks against mali- cious input. When you give the correct number of arguments, the script won't go out of its way to second-guess whether you re- ally provided a date, or whether it is valid in the Gregorian calendar. Who would you be fooling but yourself? In some cases, malformed input will surface at the subrou- tine level and block operations: since timestamps need to be translated to Unix time before conversion, "date" will complain about strings it doesn't understand; "seq", when presented with a start year that lies after the end year, turns into a no-op; and with invalid coordinates, the API won't return usable data (the lack of which the script will notice). In some cases, this lack in rigidity allows for more liberty in the input, like leaving out the seconds. This is especially noticeable in the reverse operation: you can leave out minutes and seconds, add multiple leading zeroes or remove them en- tirely, and let minutes and seconds exceed 60. But because the script searches for the date in the cache file verbatim, a mal- formed date in the reverse operation will either lead to an er- ror message about the lack of data or about duplicates in the cache file. Use a Single Awk Program After the change of the default output format to HH:MM:SS, the script still contained three separate awk programs: one to con- vert equinoctial to temporal hours, one for the reverse opera- tion, and one to optionally convert the new HH:MM:SS format to the old Roman-numeral format. Thus the script took about 3 mil- liseconds longer to print Roman output, because the shell needed to spawn a separate awk process for the conversion. But the ma- jor problem was that the two conversion functions consisted mainly of duplicate, or very similar, code. I then noticed on 2026-03-25 that I could unite all conver- sions in the same awk program using an eight-entry matrix, thus eliminating not only the duplication between the two conversion functions, but also a central if-else-cascade that checked which of yesterday's, today's, or tomorrow's times to use. (You can see the details in the code.) Finally, I added the Roman-nu- meral conversion back into the same awk program, avoiding the 3-millisecond delay. Future Work Solderpunk's "day" script computes the sunrise and sunset times from a pair of coordinates and a date using only arithmetic. I can envision a script that combines this arithmetic to compute the sunrise and sunset times with the arithmetic in my script to compute the temporal hours from those times. This would render the script not only independent of an internet connection, but also of the cache file. But it would require some experiments to see whether computing the sunrise and sunset times with every execution would be significantly slower than reading pre-com- puted times from a file. I would have attempted such a script myself if I had read about Solderpunk's script earlier; for now, the idea lies beyond the scope of this document. Colophon I have typed up this document with GNU nano and typeset it with GNU nroff, for ASCII output, without any macros. The line length is 64, the page offset is 3 (to give a gopherly familiar sum of 67), and indentations take steps of 4 columns. I have formatted the listing below with "nl -ba -w3 -s' '". Indentations use 4 spaces (although I am used to 8-column tabs for shell scripts, especially because of the "<<-" redirect for here-docs), to stay within 64 columns; 4-space indentations are common in the awk book too. Appendix The code is in POSIX shell. I have written it in such a way that leading whitespace is not significant (as would be the case with here-docs, leading to errors when you replace tabs with spaces). But note that I took some liberty to make some charac- ters line up, as is common in the original awk book by Alfred Aho, Brian Kernighan, and Peter Weinberger, so collapsing the spaces might change the appearance. 1 #!/bin/sh 2 3 cache=~/suncache.dat 4 5 year_via_api() { # $1 lat, $2 lon, $3 year 6 uri='https://api.sunrisesunset.io/json' 7 uri="${uri}?lat=${1}" 8 uri="${uri}&lng=${2}" 9 uri="${uri}&date_start=${3}-01-01" 10 uri="${uri}&date_end=${3}-12-31" 11 uri="${uri}&time_format=unix" 12 13 curl -s "$uri" | 14 jq -r ' 15 .results | 16 .[]? | 17 "\(.date) \(.sunrise) \(.sunset)" 18 ' 19 } 20 21 date_diff() { # $1 date, $2 date 22 unix1="$(date +%s -d "$1")" 23 unix2="$(date +%s -d "$2")" 24 25 printf '%s %s\n' "$unix1" "$unix2" | 26 awk '{ 27 d = ($2 - $1) / (24 * 3600) 28 # round half away from zero 29 d += (d < 0) ? (-0.5) : 0.5 30 print int(d) 31 }' 32 } 33 34 year_via_day() { # $1 lat, $2 lon, $3 year 35 # we need midnight's timestamp, so don't use "now" 36 offset1="$(date_diff "$(date +%F)" "${3}-01-01")" 37 offset2="$(date_diff "$(date +%F)" "${3}-12-31")" 38 39 for i in $(seq "$offset1" "$offset2") 40 do 41 LATLON="${1},${2}" day "+${i}d" | # "+-" seems to work 42 awk ' 43 NR == 1 { date = $1 } 44 /^Sunrise/ { sunrise = date "T" $2 } 45 /^Sunset/ { sunset = date "T" $2 } 46 END { print date, sunrise, sunset } 47 ' | { 48 read -r date sunrise sunset 49 sunr="$(date +%s -d "$sunrise")" 50 suns="$(date +%s -d "$sunset")" 51 printf '%s %s %s\n' "$date" "$sunr" "$suns" 52 } 53 done 54 } 55 56 convert() { # $1 date, $2 cache, $3 time, $4 mode (h/w/empty) 57 { 58 grep -sC1 "$1" "$2" 59 # cache has spaces, so tr instead of FS=":" in awk 60 printf '%s\n' "$3" | tr : ' ' 61 } | 62 63 awk -v "mode=$4" ' 64 function error(msg) { 65 print msg > "/dev/stderr" 66 exit 1 67 } 68 69 BEGIN { 70 h = 3600; min = 60 71 # array layout: 2 x 4; a[0,1-4] for the "ideal" 72 # temporal times (in seconds since midnight), 73 # a[1,1-4] for the "real" times (in unix time); 74 # the four cells meaning: 75 a[0,1] = -6*h # sunset yesterday 76 a[0,2] = 6*h # sunrise today 77 a[0,3] = 18*h # sunset today 78 a[0,4] = (24+6)*h # sunrise tomorrow 79 } 80 81 NR == 1 { a[1,1] = $3 } # yesterday 82 NR == 2 { a[1,2] = $2; a[1,3] = $3 } # today 83 NR == 3 { a[1,4] = $2 } # tomorrow 84 NR == 4 { 85 # check $1 instead of NF for more liberty 86 if ($1 < 24) 87 # temporal time 88 now = $1*h + $2*min + $3 89 else 90 # unix time 91 now = $1 92 } 93 94 END { 95 ordered = a[1,1] < a[1,2] && 96 a[1,2] < a[1,3] && 97 a[1,3] < a[1,4] 98 # half-open intervals 99 in_bounds = ( 0 <= now && now < 24*h ) || 100 (a[1,1] <= now && now < a[1,4]) 101 102 if (NR < 4) 103 error("lacking data; init cache?") 104 else if (NR > 4) 105 error("cache has duplicates") 106 else if (!ordered) 107 error("cache file out of order") 108 else if (!in_bounds) 109 error("time out of bounds") 110 111 for (i = 1; i <= 3; ++i) 112 for (j = 0; j <= 1; ++j) 113 # half-open intervals 114 if (a[j,i] <= now && now < a[j,i+1]) { 115 targ_offs = a[!j,i] 116 targ_span = a[!j,i+1] - a[!j,i] 117 curr_span = a[ j,i+1] - a[ j,i] 118 ratio = (now - a[j,i]) / curr_span 119 time = targ_offs + ratio * targ_span 120 # round half away from zero 121 time += (time < 0) ? (-0.5) : 0.5 122 time = int(time) 123 } 124 125 if (time > 48*h) { 126 # unix time 127 print time 128 exit 129 } 130 131 # temporal time; midnights might not align (< 0): 132 time = (time + 24*h) % (24*h) 133 134 if (mode == "h" || mode == "w") { 135 day = (6*h <= time && time < 18*h) 136 time = (time + 6*h) % (12*h) 137 if (!day && mode == "w") 138 # no rounding; count only completed parts 139 time = int(time / 3) 140 } 141 142 hours = int(time / h) 143 mins = int((time % h) / min) 144 secs = time % min 145 146 if (mode == "h" || mode == "w") { 147 split("I II III IV V VI VII VIII IX X XI XII", 148 roman) 149 hours = roman[hours + 1] 150 if (!day) 151 hours = tolower(hours) 152 printf("%s %d'\'' %d\"\n", hours, mins, secs) 153 } else { 154 printf("%02d:%02d:%02d\n", hours, mins, secs) 155 } 156 } 157 ' 158 } 159 160 usage_exit() { 161 name="$(basename "$0")" 162 { 163 printf 'Usage:\n' 164 printf ' Populate the cache file:\n' 165 printf ' %s init api|day LAT LON ' "$name" 166 printf 'YEAR_START [YEAR_END]\n' 167 printf ' Convert equinoctial to temporal hours:\n' 168 printf ' %s [romanh|romanw] [TIME [DATE]]\n' "$name" 169 printf ' Convert temporal to equinoctial hours:\n' 170 printf ' %s rev TIME [DATE]\n' "$name" 171 } >&2 172 exit 1 173 } 174 175 case "$1" in 176 init) 177 case "$2" in 178 api|day) via="$2";; 179 *) usage_exit;; 180 esac 181 182 lat="$3" lon="$4" year_start="$5" 183 184 case "$#" in 185 5) year_end="$year_start";; 186 6) year_end="$6";; 187 *) usage_exit;; 188 esac 189 190 for year in $(seq "$year_start" "$year_end") 191 do 192 case "$via" in 193 api) year_via_api "$lat" "$lon" "$year";; 194 day) year_via_day "$lat" "$lon" "$year";; 195 esac 196 done > "$cache" 197 198 for year in $(seq "$year_start" "$year_end") 199 do 200 case "$(grep "^$year" "$cache" | wc -l)" in 201 365|366) ;; 202 *) 203 printf 'year %s seems incomplete' "$year" 204 [ "$via" = api ] && printf '; network error?' 205 printf '\n' 206 ;; 207 esac >&2 208 done 209 ;; 210 211 rev) 212 time="$2" 213 214 case "$#" in 215 2) date="$(date +%F)";; 216 3) date="$3";; 217 *) usage_exit;; 218 esac 219 220 unix="$(convert "$date" "$cache" "$time")" && 221 date +%T -d@"$unix" 222 ;; 223 224 *) 225 case "$1" in 226 romanh) mode=h; shift;; 227 romanw) mode=w; shift;; 228 esac 229 230 case "$#" in 231 0) date="$(date +%F)" 232 time="$(date +%s)";; 233 1) date="$(date +%F)" 234 time="$(date +%s -d "${date}T${1}")" || usage_exit;; 235 2) date="$2" 236 time="$(date +%s -d "${date}T${1}")" || usage_exit;; 237 *) usage_exit;; 238 esac 239 240 convert "$date" "$cache" "$time" "$mode" 241 ;; 242 esac