2026, Jan 03 18:02

Как починить расчёты восхода и заката: нормализуем T, а не UT

Почему расчёты восхода и заката ломаются на границах суток и года. Фиксируем баг с RA: нормализуем T к [0,24), UT не нормализуем. Плюс советы по диапазонам дат.

Расчёты времени восхода и заката на основе известного сниппета со Stack Overflow работают на удивление хорошо для многих точек, но ломаются в двух местах: около смены года и в дни, когда два события могут приходиться на одни сутки по UTC. Симптомы знакомые: если принудительно нормализовать UT к [0, 24), часть событий теряется; если не нормализовать, неверно отрабатывает первый или последний день года. Разберёмся, что именно ломается и как исправить это без изменения общего алгоритма.

Воспроизводим проблему

Ниже — компактный класс, который выполняет привычную аппроксимацию времени восхода и заката. Он повторяет распространённую реализацию: UT округляется и не приводится принудительно к диапазону; T (местное среднее время) оставляется как есть. Этого достаточно, чтобы увидеть описанные выше расхождения.

import math
from datetime import datetime, timedelta, time, timezone

RAD = math.pi / 180.0

class SolarError(Exception):
    def __init__(self, msg):
        super(SolarError, self).__init__(msg)

class SolarClock:
    def __init__(self, lat_deg, lon_deg):
        self._lat = lat_deg
        self._lon = lon_deg
        self.lonHours = self._lon / 15.0

    def rise_time(self, on_date=datetime.now(), tz=timezone.utc):
        dt_offset = self.compute_UT(on_date, tz=tz, as_rise=True)
        if dt_offset is None:
            raise SolarError('The sun never rises on this location (on the specified date)')
        elif isinstance(dt_offset, str):
            return dt_offset
        else:
            return datetime.combine(on_date, time(tzinfo=tz)) + dt_offset

    def set_time(self, on_date=datetime.now(), tz=timezone.utc):
        dt_offset = self.compute_UT(on_date, tz=tz, as_rise=False)
        if dt_offset is None:
            raise SolarError('The sun never rises on this location (on the specified date)')
        elif isinstance(dt_offset, str):
            return dt_offset
        else:
            return datetime.combine(on_date, time(tzinfo=tz)) + dt_offset

    def compute_UT(self, on_date, tz, as_rise=True, zenith=90.8):
        if tz is None:
            tz = datetime.now().tzinfo

        day_index = on_date.timetuple().tm_yday

        if as_rise:
            approxT = day_index + ((6 - self.lonHours) / 24.0)
        else:
            approxT = day_index + ((18 - self.lonHours) / 24.0)

        meanAnom = (0.9856 * approxT) - 3.289

        trueLong = meanAnom + (1.916 * math.sin(RAD * meanAnom)) + (0.020 * math.sin(RAD * 2 * meanAnom)) + 282.634
        trueLong = self.wrap_range(trueLong, 360)

        sinDec = 0.39782 * math.sin(RAD * trueLong)
        cosDec = math.cos(math.asin(sinDec))

        cosH = (math.cos(RAD * zenith) - (sinDec * math.sin(RAD * self._lat))) / (cosDec * math.cos(RAD * self._lat))
        if cosH > 1:
            return 'constant_eclipse'
        if cosH < -1:
            return 'constant_sun'

        if as_rise:
            hourAngle = 360 - (1 / RAD) * math.acos(cosH)
        else:
            hourAngle = (1 / RAD) * math.acos(cosH)
        hourAngle /= 15.0

        rightAsc = (1 / RAD) * math.atan(0.91764 * math.tan(RAD * trueLong))
        rightAsc = self.wrap_range(rightAsc, 360)
        Lquad = (math.floor(trueLong / 90)) * 90
        RAquad = (math.floor(rightAsc / 90)) * 90
        rightAsc = (rightAsc + (Lquad - RAquad)) / 15.0

        localMean = hourAngle + rightAsc - (0.06571 * approxT) - 6.622

        utHours = localMean - self.lonHours
        if tz:
            utHours += tz.utcoffset(on_date).total_seconds() / 3600.0

        utHours = round(utHours, 2)

        return timedelta(hours=utHours)

    @staticmethod
    def wrap_range(val, maxv):
        if val < 0:
            return val + maxv
        elif val >= maxv:
            return val - maxv
        return val

Включите или отключите принудительную нормализацию UT в этом варианте — вы увидите ровно тот компромисс, о котором говорилось выше: при нормализации UT теряются события внутри суток, а без нормализации возникают ошибки на граничных днях.

Что на самом деле идёт не так

Аппроксимация вычисляет одно значение прямого восхождения и склонения для 24‑часового окна, привязанного к дате входа, и меняет их только в 00:00 UTC. Такое упрощение хрупко там, где время восхода/заката смещается быстро, например возле первых или последних событий на высоких широтах. Более того, в районе мартовского равноденствия у прямого восхождения есть дискретный скачок: оно «переворачивается» примерно с 23,9 ч на 0 ч. Поскольку T вычисляется как H + RA − 0,06571·t − 6,622, этот переход RA добавляет ложный скачок на −24 часа в T. Если затем принудительно приводить UT к [0, 24), теряется корректная информация о том, что событие относится к следующему дню; именно из‑за этого при коротких днях и особенностях границ часовых поясов два события могут попасть в одни сутки по UTC. Напротив, если UT не нормализовать, всплывает ложный скачок, исходящий из T, — он проявляется на границе года и в другие чувствительные даты.

Исправление

Принудительно нормализуйте T к [0, 24) и не приводите UT к диапазону. Нормализация местного среднего времени перед пересчётом в UT убирает искусственный шаг −24 ч, который появляется из‑за «переворота» RA, и при этом сохраняет принадлежность события к предыдущим/последующим суткам по UTC. Ниже — исправленное ядро с единственным критическим изменением.

    def compute_UT(self, on_date, tz, as_rise=True, zenith=90.8):
        if tz is None:
            tz = datetime.now().tzinfo

        day_index = on_date.timetuple().tm_yday
        approxT = day_index + ((6 - self.lonHours) / 24.0) if as_rise else day_index + ((18 - self.lonHours) / 24.0)

        meanAnom = (0.9856 * approxT) - 3.289
        trueLong = meanAnom + (1.916 * math.sin(RAD * meanAnom)) + (0.020 * math.sin(RAD * 2 * meanAnom)) + 282.634
        trueLong = self.wrap_range(trueLong, 360)

        sinDec = 0.39782 * math.sin(RAD * trueLong)
        cosDec = math.cos(math.asin(sinDec))
        cosH = (math.cos(RAD * zenith) - (sinDec * math.sin(RAD * self._lat))) / (cosDec * math.cos(RAD * self._lat))
        if cosH > 1:
            return 'constant_eclipse'
        if cosH < -1:
            return 'constant_sun'

        hourAngle = (360 - (1 / RAD) * math.acos(cosH)) if as_rise else ((1 / RAD) * math.acos(cosH))
        hourAngle /= 15.0

        rightAsc = (1 / RAD) * math.atan(0.91764 * math.tan(RAD * trueLong))
        rightAsc = self.wrap_range(rightAsc, 360)
        Lquad = (math.floor(trueLong / 90)) * 90
        RAquad = (math.floor(rightAsc / 90)) * 90
        rightAsc = (rightAsc + (Lquad - RAquad)) / 15.0

        localMean = hourAngle + rightAsc - (0.06571 * approxT) - 6.622
        localMean = self.wrap_range(localMean, 24)

        utHours = localMean - self.lonHours
        if tz:
            utHours += tz.utcoffset(on_date).total_seconds() / 3600.0

        utHours = round(utHours, 2)
        return timedelta(hours=utHours)

Как только T нормализовано, а UT — нет, вы сохраняете события, попадающие на соседние сутки, и избавляетесь от искусственной разрывности.

Как безопасно работать с диапазонами дат

Если нужно построить таблицу за длинный период (например, за год), вычисляйте события ещё на один день до начала и на один день после конца. Затем сохраняйте результат, привязывая его к дате, на которую приходится событие, а не к дате входа, и отбрасывайте всё вне целевого интервала. Так вы обходите ситуацию, когда событие «падает на следующий день», и поддерживаете чистую последовательность. Ниже — минимальный драйвер с нужным паттерном.

from datetime import datetime, timedelta, timezone

sol = SolarClock(-30, -100)
utz = timezone(timedelta(hours=0))

rows = []
start0 = datetime(2024, 1, 1, tzinfo=timezone.utc)

# Выделить место заранее по целевым датам
ptr = start0
count = 0
while ptr.year == start0.year:
    rows.append([ptr.date().isoformat(), [], []])  # дата, восходы, закаты
    ptr += timedelta(days=1)
    count += 1

# Пройти на день раньше и на день позже целевого интервала
d = start0 - timedelta(days=1)
ix_r = 0
ix_s = 0
for _ in range(count + 2):
    r = sol.rise_time(d, utz)
    if isinstance(r, datetime) and r.year == start0.year:
        key = r.date().isoformat()
        while rows[ix_r][0] != key:
            ix_r += 1
        rows[ix_r][1].append(r.time().isoformat())

    s = sol.set_time(d, utz)
    if isinstance(s, datetime) and s.year == start0.year:
        key = s.date().isoformat()
        while rows[ix_s][0] != key:
            ix_s += 1
        rows[ix_s][2].append(s.time().isoformat())

    d += timedelta(days=1)

print(*rows, sep='\n')

Этот подход также показывает, почему два заката (или восхода) могут оказаться в одни и те же сутки по UTC. Время событий меняется и порой между ними проходит меньше 24 часов; срез в 00:00 UTC может отнести оба к одной календарной дате, когда день и ночь короткие.

Запросы на один день без пропусков событий

Чтобы надёжно получить все события на конкретную дату, посчитайте предыдущий, текущий и следующий день. Оставьте только те результаты, у которых получившаяся дата-время падает на целевую дату. Это компактная версия стратегии с диапазоном.

def collect_sets_for_date(base_date, tz):
    out = []
    d = base_date - timedelta(days=1)
    for _ in range(3):
        val = sol.set_time(d, tz)
        if isinstance(val, datetime) and val.date() == base_date.date():
            out.append(val.time().isoformat())
        d += timedelta(days=1)
    return out

Почему эта деталь важна

Алгоритм намеренно упрощает видимое движение Солнца, удерживая прямое восхождение и склонение постоянными в 24‑часовых блоках, привязанных к полуночи UTC. Это удобно, но усиливает крайовые эффекты там, где суточное изменение велико. «Переворот» RA у мартовского равноденствия внедряет в T ложный шаг на −24 часа; принудительная нормализация UT затем скрывает, к какому дню событие действительно относится, и отбрасывает корректные события, когда два происходят в одни сутки по UTC. Нормализация T, напротив, убирает ложный шаг и не теряет реальную информацию.

Выводы

Если при принудительной нормализации UT к [0, 24) у вас пропадают восходы или закаты, прекратите нормализовать UT и сначала нормализуйте T к [0, 24), а уже затем переводите в UT. Формируя посуточные таблицы или отвечая на запросы за один день, считайте соседние дни и классифицируйте по получившейся дате-времени, а не по дате входа. Так вы сохраните корректную привязку событий к суткам и избежите искусственных разрывов, внесённых «переворотом» RA.