Source code for contrib.misc.posix_time

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# contrib/misc/posix_time.py
#

import os
import sys
import importlib

import datetime as dtime
from unittest.mock import patch
from contextlib import redirect_stdout

PWD = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(os.path.dirname(PWD))
sys.path.append(BASE_DIR)

from badidatetime import BahaiCalendar, GregorianCalendar, datetime, timezone
badidt = importlib.import_module('badidatetime.datetime')


[docs] class PosixTests(BahaiCalendar): """ | POSIX converter: https://www.unixtimestamp.com/ | Julian Period Converter: https://aa.usno.navy.mil/data/JulianDate | Sunset: https://gml.noaa.gov/grad/solcalc/ The Python datetime package seems to always give local time from timestamps not UTC time. For Example: | In [18]: dtime.datetime.fromtimestamp(18000) This -5 hours from UTC time. | Out[19]: datetime.datetime(1970, 1, 1, 0, 0) """ #BADI_COORD = (35.682376, 51.285817, 3.5) #LOCAL_COORD = (35.5894, -78.7792, -5.0) GMT_COORD = (51.477928, -0.001545, 0) EPOCH = datetime(126, 16, 2, None, None, 7, 59, 32, 488800, tzinfo=timezone.utc) _MONTHS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 0, 19) def __init__(self): super().__init__() self.LOCAL_COORD = None self.gc = GregorianCalendar() #@patch.object(datetime, 'LOCAL_COORD', datetime.GMT_COORD)
[docs] def mktime(self, options) -> list: """ Test that the _mktime method produces the correct local timestamp. -m or --mktime with -S, and -E (Gregorian Dates) -A latitude -O longitude -Z zone Total derived range -1842 through 1161 with Gregorian years 2 - 3006. """ start = options.start end = options.end lat = options.latitude lon = options.longitude zone = options.zone tz = dtime.timezone(dtime.timedelta(hours=zone)) data = [] # The datetime.fromtimestamp(g_ts) method below internally needs the # local time, however, every run of this method could be with a # different set of coordinents, so we need to patch # badidatetime.datetime.LOCAL_COORD. with patch.object(badidt, 'LOCAL_COORD', (lat, lon, zone)): for year in range(start, end): g_date = (year, 1, 1) g_ts = dtime.datetime(*g_date, tzinfo=tz).timestamp() b_date = self.badi_date_from_timestamp(g_ts, lat, lon, zone, short=True) bd = datetime(*b_date[:3], None, None, *b_date[3:]) b_ts = self._mktime(bd, (lat, lon, zone)) g_leap = self._gc._is_leap_year(year) b_leap = self._is_leap_year(b_date[0]) diff = round(b_ts - g_ts, 6) data.append((g_date, g_leap, g_ts, b_date, b_leap, b_ts, diff)) return data
[docs] def posix(self, options): """ Test that the resultant values from the badi_date_from_timestamp() and timestamp_from_badi_date() methods work correctly with each other. -p or --posix with -S and -E (Gregorian Dates) -A latitude -O longitude -Z zone """ data = [] start = options.start end = options.end lat = options.latitude lon = options.longitude zone = options.zone tz = dtime.timezone(dtime.timedelta(hours=zone)) for year in range(start, end): leap = self._gc._is_leap_year(year) for month, days in enumerate(self._gc._MONTHS, start=1): days += 1 if month == 2 and leap else 0 for day in range(1, days + 1): g_date = (year, month, day) g_ts = dtime.datetime(*g_date, tzinfo=tz).timestamp() b_date = self.badi_date_from_timestamp( g_ts, lat, lon, zone, short=True) b_ts = self.timestamp_from_badi_date( b_date, lat, lon, zone) ts_diff = b_ts - g_ts data.append((g_date, g_ts, b_date, b_ts, ts_diff)) return data
[docs] def round_trip(self, options) -> list: """ Test the round trip of the timestamp methods. -r or --round-trip with -S and -E (Badí' Dates) -A latitude -O longitude -Z zone """ data = [] start = options.start end = options.end lat = options.latitude lon = options.longitude zone = options.zone tz = dtime.timezone(dtime.timedelta(hours=zone)) for year in range(start, end): is_leap = self._is_leap_year(year) for month in self._MONTHS: dm = 19 if month != 0 else 4 + is_leap for day in range(1, dm + 1): date = (year, month, day) ts = self.timestamp_from_badi_date(date, lat, lon, zone) b_date = self.badi_date_from_timestamp( ts, lat, lon, zone, short=True) valid = date == b_date g_date = self.gregorian_date_from_badi_date( date, lat, lon, zone, us=True) g_ts = dtime.datetime(*g_date, tzinfo=tz).timestamp() diff = ts - g_ts data.append((date, ts, b_date, valid, g_date, g_ts, diff)) return data
[docs] def sunset(self, options) -> list: """ Find the sunset for given years. This tests the self._get_badi_hms method below. -s or --sunset with -S and -E (Gregorian Dates) -A latitude -O longitude -Z zone """ start = options.start end = options.end coords = (options.latitude, options.longitude, options.zone) tz = dtime.timezone(dtime.timedelta(hours=coords[-1])) data = [] with patch.object(badidt, 'LOCAL_COORD', coords): for year in range(start, end): g_date = (year, 1, 1) g_ts = dtime.datetime(*g_date, tzinfo=tz).timestamp() bd = datetime.fromtimestamp(g_ts) b_date = bd.b_date ss, utc_hms, badi_hms = self._get_badi_hms(b_date, coords[:2]) data.append((g_date, b_date, ss, utc_hms, badi_hms)) return data
[docs] def all_timezones(self, options): """ Dump analysis files for all defined timezones. -t or --timezones with -P path, -S, and -E (Gregorian Dates) """ zones = _epoch_for_timezone() names = sorted(zones, key=lambda x: x[0]) save_path = options.path if save_path.startswith(os.sep): path = save_path else: path = os.path.join(BASE_DIR, save_path) if not os.path.exists(path): os.mkdir(path) for name in names: lat, lon, zone = zones[name] start_time = time.time() #print(name, lat, lon, zone, file=sys.stderr) zone_txt = f"{zone}" if zone < 0 else f"+{zone}" filename = f"posix-TS{zone_txt}-{name}.txt" fullpath = os.path.join(path, filename) with open(fullpath, mode='w') as f: options.latitude = lat options.longitude = lon options.zone = zone with redirect_stdout(f): _m_and_t_options(options, start_time, self.mktime(options)) with open(os.path.join(path, 'NOTES.txt'), mode='w') as f: basename = os.path.basename(__file__) msg = "To run all timezones use the command below.\n\n" msg += (f"./contrib/misc/{basename} -tS {options.start} " f"-E {options.end} -P {options.path}\n") f.write(msg)
# Supporting methods
[docs] def _mktime(self, dt: datetime, coords: tuple) -> float: """ This is the inverse function of localtime(). Its argument is the struct_time or full 9-tuple (since the dst flag is needed; use -1 as the dst flag if it is unknown) which expresses the time in local time, not UTC. It returns a floating-point number, for compatibility with time(). If the input value cannot be represented as a valid time, either OverflowError or ValueError will be raised (which depends on whether the invalid value is caught by Python or the underlying C libraries). https://en.wikipedia.org/wiki/Time_zone Derived Badí' time for the epoch 1970-01-01 ------------------------------------------- +------------------+---------------------------------+ | Time Zone | Badí' Date | +==================+=================================+ | GMT 0.0 time | 126-16-02T08:00:30.6684+00:00 | +------------------+---------------------------------+ | EST -5.0 time | 0126-16-02T01:48:23.8716-05:00 | +------------------+---------------------------------+ | Tehran 3.5 time | 0126-16-01T:10:28:46.1388+03:30 | +------------------+---------------------------------+ | Beijing 8.0 time | 0126-15-19T15:00:59.1084+08:00 | +------------------+---------------------------------+ """ return self.timestamp_from_badi_date(dt.b_date + dt.b_time, *coords)
[docs] def _get_badi_hms(self, b_date, coords: tuple) -> tuple: """ Find the correct Badí' hour, minute, and second after sunset equal to UTC midnight based on the coordinents. +------------------+---------------------------+--------+-------------+ | Time Zone | Badí' Date | Sunset | Approx JD | | | | before | | | | | epoch | | | | | in hrs | | +==================+===========================+========+=============+ | GMT 0.0 time | 0126-16-02T08:00:00+00:00 | 16:00 | 0.33333333 | +------------------+---------------------------+--------+-------------+ | EST -5.0 time | 0126-16-02T06:48:00-05:00 | 17:11 | 0.283310188 | +------------------+---------------------------+--------+-------------+ | Tehran 3.5 time | 0126-16-02T06:59:00+03:30 | 17:01 | 0.29097220 | +------------------+---------------------------+--------+-------------+ | Beijing 8.0 time | 0126-16-02T07:01:00+08:00 | 16:59 | 0.292361104 | +------------------+---------------------------+--------+-------------+ Use the last value returned, the first two are only for testing. """ jd = self.jd_from_badi_date(b_date, *coords) jd = self._meeus_from_exact(jd) ss = self._sun_setting(jd, *coords) partial_day = ss % 1 utc_hms = self._hms_from_decimal_day(partial_day + 0.5, us=True) b_time = 0.5 - partial_day badi_hms = self._hms_from_decimal_day(b_time, us=True) # print("Badí' date:", b_date, "UTC Midnight:", 0.5, # "UTC sunset time:", partial_day, # "Badí' time at UTC midnight:", b_time, file=sys.stderr) return ss, utc_hms, badi_hms
[docs] def fmt_float(value, left=4, right=4): """ Format one float so that it is visually centered on the decimal point. Parameters ---------- value : float | int | str The number to format. left : int Width to reserve on the left of the decimal (including any minus sign). right : int Number of digits to show after the decimal. """ value = round(value, right) s = f"{value:.{right}f}" left_part, right_part = s.split(".") return f"{left_part.rjust(left)}.{right_part.ljust(right)}"
[docs] def _group_sequences(lst: list) -> list: """ This function combines all years into a tuple. If there is a spread of years they the first and last year is placed in a tuple which is then placed in the outer tupple. Thanks to ChatGPT for deriving this algorithm. """ grouped = [] start = lst[0] # Start of a potential sequence prev = lst[0] # Previous number in sequence for i in range(1, len(lst)): # Check if the sequence continues if lst[i] == prev + 1: prev = lst[i] else: # If the sequence is 3 or more, add as a tuple (first, last) if prev - start >= 2: grouped.append((start, prev)) else: # Otherwise, add numbers individually grouped.extend(range(start, prev + 1)) # Reset start and prev for new sequence start = lst[i] prev = lst[i] # Handle the last sequence or number if prev - start >= 2: grouped.append((start, prev)) else: grouped.extend(range(start, prev + 1)) return grouped
[docs] def _epoch_for_timezone() -> tuple: """ https://timezonedb.com/ https://www.timeanddate.com/time/map/ https://latitudelongitude.org/ """ return { 'Baker_Island': (0.1958, 176.4792, -12.0), # 1 -43200 'American_Samoa': (14.2705, 170.1387, -11.0), # 2 -39600 'Honolulu': (21.306944, -157.858333, -10.0), # 3 -36000 'Anchorage': (61.21806, -149.90028, -9.0), # 4 -32400 'Boise': (43.618881, -116.215019, -8.0), # 5 -28800 'Bahia_Banderas': (20.80426, -105.30913, -7.0), # 6 -25200 'Belize': (17.189877, -88.49765, -6.0), # 7 -21600 'New_York': (40.71427, -74.00597, -5.0), # 8 -18000 'Aruba': (12.52111, -69.968338, -4.5), # 9 -16200 'Barbados': (13.193887, -59.543198, -4.0), # 10 -14400 'Goose_Bay': (53.3016826, -60.3260842, -3.5), # 11 -12600 'Montevideo': (-34.9033, -56.1882, -3.0), # 12 -10800 'Recife': (-8.047562, -34.876964, -2.0), # 13 -7200 'Azores': (37.741249, -25.675594, -1.0), # 14 -3600 'GMT': (51.477928, -0.001545, 0.0), # 15 0 'Paris': (48.856614, 2.3522219, 1.0), # 16 3600 'Latvia': (56.946, 24.10589, 2.0), # 17 7200 'Saratov': (51.54056, 46.00861, 3.0), # 18 10800 'Tehran': (35.682376, 51.285817, 3.5), # 19 12600 'Yerevan': (40.183333, 44.516667, 4.0), # 20 14400 'Balkh': (36.75635, 66.8972, 4.5), # 21 16200 'Yekaterinburg': (56.8519, 60.6122, 5.0), # 22 18000 'Colombo': (6.93194, 79.84778, 5.5), # 23 19800 'Kathmandu': (27.70169, 85.3206, 5.75), # 24 20700 'Omsk': (54.99244, 73.36859, 6.0), # 25 21600 'Yangon': (16.866069, 96.195132, 6.5), # 26 23400 'Christmas': (-10.5, 105.6667, 7.0), # 27 25200 'Perth': (-31.95224, 115.8614, 8.0), # 28 28800 'Eucla': (-31.6772316, 128.8897862, 8.75), # 29 31500 'Yokohama': (35.44778, 139.6425, 9.0), # 30 32400 'Adelaide': (-34.92866, 138.59863, 9.5), # 31 34200 'Brisbane': (-27.46794, 153.02809, 10.0), # 32 36000 'Lord_Howe': (-31.55, 159.08333, 10.5), # 33 37800 'Kosrae': (5.31086478, 162.9761931, 11.0), # 34 39600 'Kwajalein': (9.083333, 167.333333, 12.0), # 35 43200 'Chatham_Islands': (-44.00575230, -176.54006740, 12.75), # 36 45900 'Fakaofo': (-9.380255, -171.218836, 13.0), # 37 46800 'Kiritimati': (1.94, -157.475, 14.0), # 38 50400 }
[docs] def _m_and_t_options(options, start_time, data): k = '-K ' if options.kill_coeff else '' underline_length = 106 print(f"./contrib/misc/{basename} -mA {options.latitude} " f"-O {options.longitude} -Z {options.zone} {k}" f"-S {options.start} -E {options.end}") print("Gregorian DT Leap Greg Timestamp Badí' Date Equal to UTC " "Midnight Leap Badí' Timestamp Diff") print('-' * underline_length) [print(f"{str(g_date):>12} " f"{str(g_leap):5} " f"{fmt_float(g_ts, 12, 1)} " f"{str(b_date):32} " f"{str(g_leap):5} " f"{fmt_float(b_ts, 12, 6)} " f"{fmt_float(diff, 6, 6)}" ) for (g_date, g_leap, g_ts, b_date, b_leap, b_ts, diff) in data] print('-' * underline_length) diffs = [] # [(diff, year)...] dt = p = n = 0 for item in data: diff = item[6] dt += diff if diff != 0: year = item[0][0] diffs.append((diff, year)) if diff > 0: p += 1 elif diff < 0: n += 1 total_years = options.end - options.start print(f"Total Years Analyzed: {total_years:>4}") print(f"Positive Errors: {p:>4}") print(f"Negative Errors: {n:>4}") print("-" * 32) print(f"Total Errors: {len(diffs):>4}\n") set_items = set([diff for diff, year in diffs]) if set_items: print(f"Maximum deviation (seconds): " f"{fmt_float(max(set_items), 4, 6)}") print(f"Minimum deviation (seconds): " f"{fmt_float(min(set_items), 4, 6)}") print("Difference Average (seconds): " f"{fmt_float(dt/len(diffs), 4, 6)}") # items = [] # for diff in sorted(set_items): # years = [y for d, y in diffs if d == diff] # years = _group_sequences(years) # items.append((diff, len(years), years)) # print(f"\nThere is/are {len(set_items)} sequence(s) in items " # f"within the year {options.start} to {options.end-1} range:") # pprint.pp(items, width=70, compact=True) end_time = time.time() days, hours, minutes, seconds = pt._dhms_from_seconds( end_time - start_time) print(f"\nElapsed time: {hours:02} hours, {minutes:02} minutes, " f"{round(seconds, 6):02.6} seconds.")
if __name__ == "__main__": import time import argparse parser = argparse.ArgumentParser( description=("Test POSIX dates and times.")) parser.add_argument( '-m', '--mktime', action='store_true', default=False, dest='mktime', help="Test the _mktime() method for accuracy.") parser.add_argument( '-p', '--posix', action='store_true', default=False, dest='posix', help="Test the compatibility between the two timezone methods.") parser.add_argument( '-r', '--round-trip', action='store_true', default=False, dest='round_trip', help="Round trip test for the timestamp methods.") parser.add_argument( '-s', '--sunset', action='store_true', default=False, dest='sunset', help="Find the sunset for given years.") parser.add_argument( '-t', '--timezones', action='store_true', default=False, dest='timezones', help="Dump analysis files for all defined timezones.") parser.add_argument( '-D', '--debug', action='store_true', default=False, dest='debug', help="Run in debug mode.") parser.add_argument( '-E', '--end', type=int, default=None, dest='end', help="End Badí' year of sequence.") parser.add_argument( '-K', '--kill-coeff', action='store_true', default=False, dest='kill_coeff', help="Turn off all coefficients during an analysis.") parser.add_argument( '-S', '--start', type=int, default=None, dest='start', help="Start Badí' year of sequence.") parser.add_argument( '-A', '--latitude', type=float, default=None, dest='latitude', help="Latitude") parser.add_argument( '-O', '--logitude', type=float, default=None, dest='longitude', help="Longitude") parser.add_argument( '-Z', '--zone', type=float, default=None, dest='zone', help="Time zone.") parser.add_argument( '-P', '--path', type=str, default='txt', dest='path', help=("Path to timezone files. If it starts with a / then the path " "is absolute.")) options = parser.parse_args() pt = PosixTests() ret = 0 basename = os.path.basename(__file__) if options.debug: sys.stderr.write("DEBUG--options: {}\n".format(options)) if options.mktime: # -m if options.start is None or options.end is None: print("If option -m is used, -S and -E must also be used.", file=sys.stderr) ret = 1 else: start_time = time.time() _m_and_t_options(options, start_time, pt.mktime(options)) elif options.posix: # -p if options.start is None or options.end is None: print("If option -s is used, -S and -E must also be used.", file=sys.stderr) ret = 1 else: start_time = time.time() print(f"./contrib/misc/{basename} -pA {options.latitude} " f"-O {options.longitude} -Z {options.zone} " f"-S {options.start} -E {options.end}") data = pt.posix(options) underline_length = 100 print('-' * underline_length) print("Gregorian Date Greg TS Badí' Date", ' ' * 21, "Badí' TS", ' ' * 9, "TS Diff (seconds)") print('-' * underline_length) [print(f"{str(g_date):14} " f"{fmt_float(g_ts, 11, 1)} " f"{str(b_date):32} " f"{fmt_float(b_ts, 11, 6)} " f"{fmt_float(ts_diff, 6, 12)}" ) for g_date, g_ts, b_date, b_ts, ts_diff in data] print('-' * underline_length) diffs = [] dt = p = n = 0 for item in data: diff = item[4] dt += diff if diff != 0: diffs.append(diff) if diff > 0: p += 1 elif diff < 0: n += 1 total_years = options.end - options.start print(f"Total Years Analyzed: {total_years:>4}") print(f"Positive Errors: {p:>4}") print(f"Negative Errors: {n:>4}") print("-" * 32) print(f"Total Errors: {len(diffs):>4}\n") print(f"Maximum deviation (seconds): " f"{fmt_float(max(diffs), 4, 6)}") print(f"Minimum deviation (seconds): " f"{fmt_float(min(diffs), 4, 6)}") print("Difference Average (seconds): " f"{fmt_float(dt/len(diffs), 4, 6)}") end_time = time.time() days, hours, minutes, seconds = pt._dhms_from_seconds( end_time - start_time) print(f"\nElapsed time: {hours:02} hours, {minutes:02} minutes, " f"{round(seconds, 6):02.6} seconds.") elif options.round_trip: # -r if options.start is None or options.end is None: print("If option -s is used, -S and -E must also be used.", file=sys.stderr) ret = 1 else: start_time = time.time() data = pt.round_trip(options) underline_length = 135 print(f"./contrib/misc/{basename} -rA {options.latitude} " f"-O {options.longitude} -Z {options.zone} " f"-S {options.start} -E {options.end}") print("Original Date Badí' Timestamp", ' ' * 6, "Derived Date", ' ' * 2, "Valid Gregorian Date", ' ' * 19, "Gregorian Timestamp Timestamp Diff") print('-' * underline_length) [print(f"{str(date):<15} " f"{fmt_float(ts, 11, 10)} " f"{str(b_date):<15} " f"{str(valid):<5} " f"{str(g_date):<34} " f"{fmt_float(g_ts, 11, 10)} " f"{fmt_float(diff, 2, 10)}" ) for date, ts, b_date, valid, g_date, g_ts, diff in data] print('-' * underline_length) print(f"Total years processed {options.end - options.start}.\n") mn = mx = true = false = 0 average_diviation = [] for item in data: valid = item[3] diff = item[6] average_diviation.append(diff) if valid: true += 1 else: false += 1 if diff < 0 and diff < mn: mn = diff elif diff > 0 and diff > mx: mx = diff print(f" Days valid: {true}") print(f" Days invalid: {false}") print(f"Max negative diviation: {fmt_float(mn, 2, 10)}") print(f"Max positive diviation: {fmt_float(mx, 2, 10)}") diviation = sum(average_diviation) / len(average_diviation) print(f" Average diviation: {fmt_float(diviation, 2, 10)}") end_time = time.time() days, hours, minutes, seconds = pt._dhms_from_seconds( end_time - start_time) print(f"\nElapsed time: {hours:02} hours, {minutes:02} minutes, " f"{round(seconds, 6):02.6} seconds.") elif options.sunset: # -s if options.start is None or options.end is None: print("If option -s is used, -S and -E must also be used.", file=sys.stderr) ret = 1 else: start_time = time.time() print(f"./contrib/misc/{basename} -sA {options.latitude} " f"-O {options.longitude} -Z {options.zone} " f"-S {options.start} -E {options.end}") print("Gregorian DT Badí' Date UTC Sunset JD UTC Sunset HMS", " Badí' HMS equal") print(" " * 62, " to UTC midnight") underline_length = 84 print('-' * underline_length) data = pt.sunset(options) [print(f"{str(g_date):>12} " f"{str(b_date):>14} " f"{ss:14.6f} " f"{str(utc_hms):20} " f"{str(badi_hms):20}" ) for g_date, b_date, ss, utc_hms, badi_hms in data] print('-' * underline_length) end_time = time.time() days, hours, minutes, seconds = pt._dhms_from_seconds( end_time - start_time) print(f"\nElapsed time: {hours:02} hours, {minutes:02} minutes, " f"{round(seconds, 6):02.6} seconds.") elif options.timezones: # -t (Runs -m for each timezone) if options.start is None or options.end is None: print("If option -t is used, -S and -E must also be used.", file=sys.stderr) ret = 1 else: pt.all_timezones(options) else: parser.print_help() sys.exit(ret)