diff --git a/pyphoon.py b/pyphoon.py index 97db6ca..a8e10c5 100644 --- a/pyphoon.py +++ b/pyphoon.py @@ -34,26 +34,22 @@ from moons import backgrounds # If you change the aspect ratio, the canned backgrounds won't work. ASPECTRATIO = 0.5 -def putmoon(fracphase, lines, hemisphere, atfiller='@'): # pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-arguments - """Print the moon - - Arguments: - fracphase: A float 0 <= n < 1 representing the current point in the cycle - lines: An integer representing the number of lines in the output - hemisphere: A string 'north' or 'south' representing the observer's hemisphere - atfiller: What character to use in place of '@' - """ +def putmoon(pctphase, lines, atfiller, hemisphere): # pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-arguments + """Print the moon""" output = "" + def putchar(char): + nonlocal output + output += char # Find the length of the atfiller string atflrlen = len(atfiller) # Fix waxes and wanes direction for south hemisphere if hemisphere == 'south': - fracphase = 1 - pctphase + pctphase = 1 - pctphase - angphase = fracphase * 2.0 * math.pi + angphase = pctphase * 2.0 * math.pi mcap = -math.cos(angphase) # Figure out how big the moon is @@ -79,7 +75,7 @@ def putmoon(fracphase, lines, hemisphere, atfiller='@'): # pylint: disable=too- # Now output the slice col = 0 while col < colleft: - output += ' ' + putchar(' ') col += 1 while col <= colright: if hemisphere == 'north': @@ -98,16 +94,16 @@ def putmoon(fracphase, lines, hemisphere, atfiller='@'): # pylint: disable=too- # rotate char upside-down if needed char = char.translate(str.maketrans("().`_'", - "!!!!!!")) + ")(`.^,")) if char != '@': - output += char + putchar(char) else: - output += atfiller[atflridx] + putchar(atfiller[atflridx]) atflridx = (atflridx + 1) % atflrlen col += 1 - output += '\n' + putchar('\n') lin += 1 return output diff --git a/xaphoon.py b/xaphoon.py index b5fb49e..1cb8ae2 100755 --- a/xaphoon.py +++ b/xaphoon.py @@ -1,70 +1,105 @@ #!/usr/bin/env python -"""Xaphoon - Displays the phase of the moon as well as other related information.""" - -import time - -from argparse import ArgumentParser from datetime import datetime, timezone -from skyfield import almanac -from skyfield_data import get_skyfield_data_path -import skyfield.api - +import math +import time +from argparse import ArgumentParser +import ephem from pyphoon import putmoon -# Initialize certain skyfield parameters globally -sf_load = skyfield.api.Loader(get_skyfield_data_path(), expire=False) # loader -ts = sf_load.timescale(builtin=False) # timescale -eph = sf_load('de421.bsp') # ephemerides -earth, sun, moon = eph['Earth'], eph['Sun'], eph['Moon'] # moooon +# Second resolution for culmination/illumination calculations +DAY_INCREMENT=1/86400 -def to_timestr(t, date=False, local=True): - """Convert a skyfield time to a time string, optionally in the local time zone.""" - t = t.utc_datetime() +def to_deg(rad): + """Convert radians to a displayable integer number of degrees.""" + return round(math.degrees(rad)) + +def to_timestr(date, local=True): + """Convert a pyephem date to a time string in the local time zone.""" if local: - t = t.astimezone() - if date: - return t.strftime('%Y-%m-%d %H:%M:%S') - return t.strftime('%H:%M:%S') + date = ephem.localtime(date) + else: + date = date.datetime() + return date.strftime("%H:%M:%S") -def fmt(cols, t, az, el, phase, illum, moonrise, transit, moonset, moonlines, hemi): - """Formats data into string to print""" - _date = t.utc_datetime().astimezone().strftime('%Y-%m-%d %H:%M:%S') # 18 chars - _azel = f"Az:{az.degrees:.0f}° El:{el.degrees:.0f}°".ljust(16) # 16 chars - _phil = f"Ph: {phase.degrees:.0f}° Ill:{illum*100:.0f}%".rjust(16) # 16 chars - _r = f"R:{to_timestr(moonrise)}" # 10 chars - _t = f"T:{to_timestr(transit)}" # 10 chars - _s = f"S:{to_timestr(moonset)}" # 10 chars - _moon = putmoon(phase.degrees/360, moonlines, hemi) # ! scalable width +def find_target_rising(moon, me): + """Return the relevant moonrise to base display and calculations off of.""" + if moon.alt == 0: # i would love a better way to do this + me = me.copy() + me.date = me.previous_rising(moon) + return me.next_rising(moon) + if moon.alt > 0: + return me.previous_rising(moon) + # moon.alt < 0 + return me.next_rising(moon) - # 2 groups of spacing, filling cols minus total RTS width - _rts_spacing = ' '*int((cols-30)/2) +def cmp_culmination(moon, me, t): + """Determine whether the culmination is before, after, or at t. - ret = f"{_date.center(cols)}\n" - ret += f"{_azel}{' '*(cols-32)}{_phil}\n" - # split moon on newlines, right-pad to center moon in original width, center to center - # in new width, then rejoin with newlines and tack an extra newline on the end - ret += '\n'.join([line.ljust(44).center(cols) for line in _moon.split('\n')]) + '\n' - ret += f"{_r}{_rts_spacing}{_t}{_rts_spacing}{_s}" - return ret + Returns 0 if t is the culmination, -1 if t if culmination is before t, or 1 + if culmination is after t. Assumes there is exactly one peak elevation, + which seems to cause error of up to about 7 seconds due to float precision. + """ + me.date = t - DAY_INCREMENT + moon.compute(me) + e1 = moon.alt + me.date = t + moon.compute(me) + e2 = moon.alt + me.date = t + DAY_INCREMENT + moon.compute(me) + e3 = moon.alt + + if e1 > e2: + return -1 + if e3 > e2: + return 1 + return 0 + +def find_culmination(moon, me, rising, setting): + """Finds culmination via binary search. + + Assumes rising and setting are from same pass. + """ + moon = moon.copy() + me = me.copy() + t1 = rising + t3 = setting + while True: + t2 = (t1 + t3) / 2 + match cmp_culmination(moon,me,t2): + case 0: return ephem.date(t2) + case -1: t3 = t2 + case 1: t1 = t2 + +def cmp_illumination(moon, me, t): + """Determine whether the moon is waxing, waning, or either full or new. + + Returns 0 if the moon is either full or new, -1 if moon is waning, or 1 + if moon is waxing. + """ + moon = moon.copy() + me = me.copy() + me.date = t - DAY_INCREMENT + moon.compute(me) + i1 = moon.moon_phase + me.date = t + DAY_INCREMENT + moon.compute(me) + i2 = moon.moon_phase + + if i1 > i2: + return -1 + if i1 < i2: + return 1 + return 0 def main(): - """Main function - - Parses arguments, calculates values, and displays them. - """ parser = ArgumentParser() parser.add_argument("lat", - help="Observer latitude", - type=float) - parser.add_argument("lon", - help="Observer longitude", - type=float) + help="Observer latitude") + parser.add_argument("long", + help="Observer longitude") parser.add_argument("elevation", help="Observer elevation in meters", - type=float) - parser.add_argument("-l", "--lines", - help="Number of lines for the output to use (default 25)", - default=25, type=int) parser.add_argument("-c", "--columns", help="Number of columns for the output to use (default 70)", @@ -76,38 +111,47 @@ def main(): type=int) args = parser.parse_args() - t = ts.from_datetime(datetime.fromtimestamp(args.time, timezone.utc)) # current time + now = ephem.date(datetime.fromtimestamp(args.time, timezone.utc)) + print(f"Current time: {to_timestr(now)}") - obs_geo = skyfield.api.wgs84.latlon(args.lat, args.lon, - elevation_m=args.elevation) # geographic position vector - obs = earth + obs_geo # barycentric position vector + me = ephem.Observer() + me.date = now + me.lat = args.lat + me.lon = args.long + me.elevation = args.elevation - moon_apparent = obs.at(t).observe(moon).apparent() - el, az, _ = moon_apparent.altaz('standard') + moon = ephem.Moon(me) - # Find relevant moonrise. el is based on apparent location, so accounts - # for atmospheric refraction. y shouldn't be needed unless user is near - # one of the poles, so ignored for now. First [0] discards y (second - # element of tuple); second [] selects from array of moonrises/moonsets - if el.degrees > 0: - # Moon is up. Find last moonrise in the past 24 hours. - moonrise = almanac.find_risings(obs, moon, t-1, t)[0][-1] + az = to_deg(moon.az) + el = to_deg(moon.alt) + print(f"Az: {az}° El: {el}°") + + rising = find_target_rising(moon, me) + setting = me.next_setting(moon) + + print (f"Rise: {to_timestr(rising)} Set: {to_timestr(setting)}") + + culm = find_culmination(moon, me, rising, setting) + + print(f"Culmination: {to_timestr(culm)}") + + direction = cmp_illumination(moon, me, now) + match direction: + case -1: + direction_indicator = '-' + case 0: + direction_indicator = '' + case 1: + direction_indicator = '+' + + print(f"Phase: {moon.moon_phase:.0%}{direction_indicator}") + + # Convert illumination percentage and waxing/waning status to percent through full cycle + if direction < 0: # waning + full_cycle_phase = 1 - (moon.moon_phase / 2) else: - # Moon is not up. Find first moonrise in the next 24 hours. - moonrise = almanac.find_risings(obs, moon, t, t+1)[0][0] + full_cycle_phase = moon.moon_phase / 2 - # Find first moonset in the next 24 hours after moonrise. - moonset = almanac.find_settings(obs, moon, moonrise, moonrise+1)[0][0] - - transit = almanac.find_transits(obs, moon, moonrise, moonrise+1)[0] - - phase = almanac.moon_phase(eph, t) - illum = moon_apparent.fraction_illuminated(sun) - hemi = 'north' if args.lat > 0 else 'south' - - #print(phase.degrees/360*100) - #print(putmoon(phase.degrees/360, 21, 'north' if args.lat > 0 else 'south')) - print(fmt(args.columns, t, az, el, phase, illum, moonrise, transit, moonset, args.lines - 4,hemi)) - #print(putmoon(phase.degrees/360, 21, '@', hemi)) + print(putmoon(full_cycle_phase, 20, '@', 'northern' if me.lat > 0 else 'southern')) main()