Script: NZ Tide Table Generator

Recovery Library — Source Code

This Python script generates the NZ Tide Tables (2026–2030) referenced by Doc #12 (Tide Tables). It implements harmonic tidal prediction using the standard formula from Schureman (1958) and Pugh & Woodworth (2014), computing high and low water times and heights for three NZ standard ports.

Requirements: Python 3.6+ with standard library only (no external dependencies).

Usage:

python scripts/generate_tide_tables.py [start_year] [end_year] [output_file]
# Default: 2026 2030 tables-tides.md

Output: tables-tides.md — monthly tide tables for Auckland, Wellington, and Nelson over the specified period.

Harmonic constants: From LINZ published data as reproduced in Doc #12, Appendix A. Uses 5 principal constituents (M2, S2, N2, K1, O1) with nodal modulation factors for the 18.61-year lunar node cycle.

Accuracy: Approximately ±15–20 minutes in time and ±0.1–0.2 m in height. For higher accuracy, add the remaining 20–25 LINZ-published constituents for each port (see Doc #12, Section 2.4).


Source Code

#!/usr/bin/env python3
"""
Generate NZ tide tables for the Recovery Library.

Produces tables-tides.md with high/low water predictions for 2026–2030
at three NZ standard ports: Auckland, Wellington, and Nelson.

Uses the standard harmonic prediction formula:
    h(t) = Z0 + sum(An * fn * cos(wn*t + Vn + un - gn))

Harmonic constants are from LINZ published data as reproduced in
Doc #12 (Tide Tables), Appendix A. Only the principal constituents
(M2, S2, N2, K1, O1) are used — sufficient for predictions accurate
to ~15–20 min in time and 0.1–0.2 m in height.

Includes nodal modulation factors for the 18.61-year lunar node cycle.
Uses only Python standard library (no external dependencies).

Usage:
    python generate_tide_tables.py [start_year] [end_year] [output_file]
    Default: 2026 2030 ../tables-tides.md
"""

import math
import sys
import os
from datetime import datetime, timedelta

# ---------------------------------------------------------------------------
# Tidal constituent angular speeds (degrees per solar hour)
# From Schureman, Manual of Harmonic Analysis and Prediction of Tides (1958)
# ---------------------------------------------------------------------------
CONSTITUENT_SPEEDS = {
    'M2': 28.984104,   # Principal lunar semidiurnal
    'S2': 30.000000,   # Principal solar semidiurnal
    'N2': 28.439730,   # Larger lunar elliptic semidiurnal
    'K1': 15.041069,   # Luni-solar diurnal
    'O1': 13.943036,   # Principal lunar diurnal
}

# Periods in hours (for reference)
CONSTITUENT_PERIODS = {k: 360.0 / v for k, v in CONSTITUENT_SPEEDS.items()}

# ---------------------------------------------------------------------------
# Port harmonic constants
# From Doc #12 Appendix A (representative LINZ values)
# Z0 = mean water level above chart datum (metres)
# For each constituent: (amplitude_m, phase_g_degrees)
# ---------------------------------------------------------------------------
PORTS = {
    'Auckland (Waitematā)': {
        'Z0': 1.74,
        'constituents': {
            'M2': (1.07, 255),
            'S2': (0.21, 289),
            'N2': (0.21, 237),
            'K1': (0.07, 175),
            'O1': (0.04, 151),
        },
        'region': 'Auckland',
    },
    'Wellington': {
        'Z0': 0.75,
        'constituents': {
            'M2': (0.44, 174),
            'S2': (0.07, 219),
            'N2': (0.09, 154),
            'K1': (0.06,  21),
            'O1': (0.04, 350),
        },
        'region': 'Wellington',
    },
    'Nelson': {
        'Z0': 2.15,
        'constituents': {
            'M2': (1.44, 179),
            'S2': (0.29, 215),
            'N2': (0.28, 160),
            'K1': (0.07, 352),
            'O1': (0.04, 322),
        },
        'region': 'Tasman Bay',
    },
}

# ---------------------------------------------------------------------------
# Astronomical functions
# ---------------------------------------------------------------------------

def julian_date(year, month, day, hour=0.0):
    """Compute Julian Date from calendar date (UTC)."""
    if month <= 2:
        year -= 1
        month += 12
    A = int(year / 100)
    B = 2 - A + int(A / 4)
    JD = (int(365.25 * (year + 4716)) + int(30.6001 * (month + 1))
          + day + hour / 24.0 + B - 1524.5)
    return JD


def julian_centuries(jd):
    """Julian centuries from J2000.0 epoch."""
    return (jd - 2451545.0) / 36525.0


def lunar_node_longitude(T):
    """
    Longitude of the mean ascending node of the lunar orbit (degrees).
    T = Julian centuries from J2000.0.
    From Meeus, Astronomical Algorithms.
    """
    N = 125.0445479 - 1934.1362891 * T + 0.0020754 * T**2
    return N % 360.0


def mean_lunar_longitude(T):
    """Mean longitude of the Moon (s), degrees. From Schureman/Meeus."""
    s = 218.3164477 + 481267.88123421 * T - 0.0015786 * T**2
    return s % 360.0


def mean_solar_longitude(T):
    """Mean longitude of the Sun (h), degrees."""
    h = 280.46646 + 36000.76983 * T + 0.0003032 * T**2
    return h % 360.0


def mean_lunar_perigee(T):
    """Mean longitude of the lunar perigee (p), degrees."""
    p = 83.3532465 + 4069.0137287 * T - 0.0103200 * T**2
    return p % 360.0


def mean_solar_perigee(T):
    """Mean longitude of the solar perigee (p1), degrees."""
    p1 = 282.93768 + 1.71946 * T + 0.00045688 * T**2
    return p1 % 360.0


def nodal_factors(T):
    """
    Compute nodal modulation factors f and u for each constituent.
    T = Julian centuries from J2000.0.

    f = amplitude modulation factor (dimensionless, ~1.0)
    u = phase modulation (degrees)

    Simplified formulas from Schureman (1958) and Pugh & Woodworth (2014).
    """
    N = math.radians(lunar_node_longitude(T))
    cos_N = math.cos(N)
    sin_N = math.sin(N)

    # M2: f = 1.0004 - 0.0373*cos(N) + 0.0002*cos(2N)
    #     u = -2.14*sin(N) degrees
    f_M2 = 1.0004 - 0.0373 * cos_N + 0.0002 * math.cos(2 * N)
    u_M2 = -2.14 * math.sin(N)

    # S2: no nodal modulation (purely solar)
    f_S2 = 1.0
    u_S2 = 0.0

    # N2: same nodal factors as M2
    f_N2 = f_M2
    u_N2 = u_M2

    # K1: f = 1.0060 + 0.1150*cos(N) - 0.0088*cos(2N)
    #     u = -8.86*sin(N) degrees
    f_K1 = 1.0060 + 0.1150 * cos_N - 0.0088 * math.cos(2 * N)
    u_K1 = -8.86 * math.sin(N)

    # O1: f = 1.0089 + 0.1871*cos(N) - 0.0147*cos(2N)
    #     u = 10.80*sin(N) degrees
    f_O1 = 1.0089 + 0.1871 * cos_N - 0.0147 * math.cos(2 * N)
    u_O1 = 10.80 * math.sin(N)

    return {
        'M2': (f_M2, u_M2),
        'S2': (f_S2, u_S2),
        'N2': (f_N2, u_N2),
        'K1': (f_K1, u_K1),
        'O1': (f_O1, u_O1),
    }


def astronomical_arguments(T):
    """
    Compute equilibrium arguments V0 for each constituent at T.
    T = Julian centuries from J2000.0.

    V0 is the phase of the equilibrium tide at Greenwich at the given time.
    Formulas from Schureman (1958), Table 2.

    Uses astronomical variables:
        s = mean lunar longitude
        h = mean solar longitude
        p = mean lunar perigee longitude
    """
    s = mean_lunar_longitude(T)
    h = mean_solar_longitude(T)
    p = mean_lunar_perigee(T)

    # Equilibrium arguments (V0) for each constituent
    # These follow from the Doodson numbers
    V0 = {
        'M2': 2 * (h - s) % 360,           # 2h - 2s
        'S2': 0.0,                           # S2 V0 = 0 by definition
        'N2': (2 * h - 3 * s + p) % 360,    # 2h - 3s + p
        'K1': (h + 90) % 360,               # h + 90°
        'O1': (h - 2 * s - 90) % 360,       # h - 2s - 90°
    }
    return V0


def predict_height(dt, port_data, T_ref=None, nf=None, V0_ref=None):
    """
    Predict tide height at a given datetime for a port.

    dt: datetime object (UTC)
    port_data: dict with Z0 and constituents
    T_ref: Julian centuries at start of year (cached)
    nf: nodal factors (cached)
    V0_ref: astronomical arguments at start of year (cached)

    Returns height in metres above chart datum.
    """
    jd = julian_date(dt.year, dt.month, dt.day,
                     dt.hour + dt.minute / 60.0 + dt.second / 3600.0)

    if T_ref is None:
        T_ref = julian_centuries(julian_date(dt.year, 1, 1))
    if nf is None:
        nf = nodal_factors(T_ref)
    if V0_ref is None:
        V0_ref = astronomical_arguments(T_ref)

    # Hours since the start of the year
    jd_start = julian_date(dt.year, 1, 1)
    t_hours = (jd - jd_start) * 24.0

    h = port_data['Z0']
    for name, (amp, phase_g) in port_data['constituents'].items():
        f, u = nf[name]
        V0 = V0_ref[name]
        speed = CONSTITUENT_SPEEDS[name]  # degrees per hour
        angle = speed * t_hours + V0 + u - phase_g
        h += amp * f * math.cos(math.radians(angle))

    return h


def find_extrema(dt_start, dt_end, port_data, step_minutes=6):
    """
    Find all high and low water events between dt_start and dt_end.

    Uses a coarse scan at step_minutes intervals, then refines each
    turning point to 1-second precision using bisection.

    Returns list of (datetime, height, type) where type is 'HW' or 'LW'.
    """
    # Cache astronomical parameters for the year
    T_ref = julian_centuries(julian_date(dt_start.year, 1, 1))
    nf = nodal_factors(T_ref)
    V0_ref = astronomical_arguments(T_ref)

    step = timedelta(minutes=step_minutes)
    extrema = []

    t = dt_start
    h_prev = predict_height(t, port_data, T_ref, nf, V0_ref)
    t += step
    h_curr = predict_height(t, port_data, T_ref, nf, V0_ref)
    t += step
    direction_prev = 1 if h_curr > h_prev else -1

    while t <= dt_end:
        h_next = predict_height(t, port_data, T_ref, nf, V0_ref)
        direction_curr = 1 if h_next > h_curr else -1

        if direction_curr != direction_prev and direction_prev != 0:
            # Turning point between t-step and t
            t_lo = t - 2 * step
            t_hi = t
            ext_type = 'HW' if direction_prev > 0 else 'LW'

            # Refine to ~1 second
            for _ in range(20):
                t_mid = t_lo + (t_hi - t_lo) / 2
                h_lo = predict_height(
                    t_lo + (t_mid - t_lo) / 2, port_data, T_ref, nf, V0_ref)
                h_hi = predict_height(
                    t_mid + (t_hi - t_mid) / 2, port_data, T_ref, nf, V0_ref)

                if ext_type == 'HW':
                    if h_lo > h_hi:
                        t_hi = t_mid
                    else:
                        t_lo = t_mid
                else:
                    if h_lo < h_hi:
                        t_hi = t_mid
                    else:
                        t_lo = t_mid

            t_ext = t_lo + (t_hi - t_lo) / 2
            h_ext = predict_height(t_ext, port_data, T_ref, nf, V0_ref)
            extrema.append((t_ext, h_ext, ext_type))

        h_prev = h_curr
        h_curr = h_next
        direction_prev = direction_curr
        t += step

    return extrema


def format_time_nzst(dt_utc):
    """Convert UTC datetime to NZST (UT+12) and format as HH:MM."""
    dt_nzst = dt_utc + timedelta(hours=12)
    return dt_nzst.strftime('%H:%M'), dt_nzst


def day_of_week(dt):
    """Return 2-letter day abbreviation."""
    days = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
    return days[dt.weekday()]


def moon_phase_approx(dt):
    """
    Approximate moon phase. Returns symbol or empty string.
    Based on the synodic month (29.53059 days) from a known new moon.
    """
    known_new_moon = datetime(2026, 1, 29, 12, 36)
    synodic = 29.53059
    days_since = (dt - known_new_moon).total_seconds() / 86400.0
    phase = (days_since % synodic) / synodic

    if abs(phase) < 0.02 or abs(phase - 1.0) < 0.02:
        return '●'  # New moon
    elif abs(phase - 0.5) < 0.02:
        return '○'  # Full moon
    return ''


def generate_month_table(year, month, port_name, port_data):
    """Generate tide table for one port for one month."""
    import calendar

    days_in_month = calendar.monthrange(year, month)[1]
    month_name = calendar.month_name[month].upper()

    dt_start = datetime(year, month, 1) - timedelta(hours=12)
    if month == 12:
        dt_end = datetime(year + 1, 1, 1) + timedelta(hours=12)
    else:
        dt_end = datetime(year, month + 1, 1) + timedelta(hours=12)

    extrema = find_extrema(dt_start, dt_end, port_data)

    # Group extrema by NZST date
    daily = {}
    for t_utc, h, etype in extrema:
        _, t_nzst = format_time_nzst(t_utc)
        day = t_nzst.day
        m = t_nzst.month
        y = t_nzst.year
        if y == year and m == month:
            if day not in daily:
                daily[day] = []
            time_str = t_nzst.strftime('%H:%M')
            daily[day].append((time_str, h, etype))

    lines = []
    lines.append(f'**{port_name.upper()}{month_name} {year}**')
    lines.append(
        'Times in NZST (UT +12:00). Heights in metres above Chart Datum (LAT).')
    lines.append('')
    lines.append('```')
    lines.append(
        'Day   HW Time  Ht(m)   LW Time  Ht(m)   '
        'HW Time  Ht(m)   LW Time  Ht(m)')

    for day in range(1, days_in_month + 1):
        dt_day = datetime(year, month, day)
        dow = day_of_week(dt_day)
        moon = moon_phase_approx(dt_day)

        events = daily.get(day, [])
        events.sort(key=lambda x: x[0])

        line_parts = f'{day:2d} {dow}'
        for t, h, e in events[:4]:
            line_parts += f'  {t}  {h:5.1f}'

        if moon:
            line_parts += f'  {moon}'

        lines.append(line_parts)

    lines.append('```')
    lines.append('')
    lines.append('● = New Moon    ○ = Full Moon')
    lines.append('')
    return '\n'.join(lines)


def generate_tables(start_year=2026, end_year=2030, output_file=None):
    """Generate the complete tide tables document."""
    if output_file is None:
        output_file = os.path.join(
            os.path.dirname(os.path.abspath(__file__)),
            '..', 'tables-tides.md')

    print(f"Generating NZ tide tables for {start_year}{end_year}...")

    lines = []
    lines.append('---')
    lines.append('title: "NZ Tide Tables: 2026–2030"')
    lines.append('subtitle: "Recovery Library — Computed Reference Data"')
    lines.append(f'date: "{datetime.now().strftime("%Y-%m-%d")}"')
    lines.append('---')
    lines.append('')
    lines.append('# NZ Tide Tables: 2026–2030')
    lines.append('')
    lines.append('*Recovery Library — Computed Reference Data*')
    lines.append('')
    lines.append('**Computation method:** Harmonic tidal prediction ...')
    # [Full header text omitted for brevity in this display;
    #  see the actual script for complete output formatting]
    lines.append('---')

    for port_name, port_data in PORTS.items():
        lines.append('')
        lines.append(f'## {port_name}')
        lines.append('')
        for year in range(start_year, end_year + 1):
            lines.append(f'### {year}')
            lines.append('')
            for month in range(1, 13):
                print(f"  {port_name}{year}/{month:02d}")
                table = generate_month_table(
                    year, month, port_name, port_data)
                lines.append(table)

    content = '\n'.join(lines)

    with open(output_file, 'w') as f:
        f.write(content)

    print(f"\nTide tables written to {output_file}")


if __name__ == '__main__':
    start = int(sys.argv[1]) if len(sys.argv) > 1 else 2026
    end = int(sys.argv[2]) if len(sys.argv) > 2 else 2030
    out = sys.argv[3] if len(sys.argv) > 3 else None
    generate_tables(start, end, out)