了解你的真实生日-使用-Python-进行天文计算和地理时空分析

了解你的真实生日:使用 Python 进行天文计算和地理时空分析

了解你的真实生日:使用 Python 进行天文计算和地理时空分析

正在计划为三位朋友:加布里埃尔、雅克和卡米耶来年的生日庆祝活动。他们三人都在 1996 年出生于法国巴黎,所以他们在 2026 年将满 30 岁。加布里埃尔和雅克将在各自的生日当天恰好身处巴黎,而卡米耶将在她的生日当天身处日本东京。加布里埃尔和卡米耶倾向于在任何给定年份的“官方”日期(即他们出生证明上提到的日期)庆祝他们的生日——分别是 1 月 18 日和 5 月 5 日。出生于 2 月 29 日的雅克更喜欢在非闰年的 3 月 1 日庆祝他的生日(或称公历纪念日)。

我们使用闰年来使我们的日历与地球围绕太阳的轨道保持同步。太阳年——地球完成一次完整轨道所需的时间——大约是 365.25 天。按照惯例,格里高利历将 365 天分配给每年的年份,除了闰年,闰年有 366 天来补偿时间上的分数漂移。这让你想知道:你的朋友中是否有人在他们的“真实”纪念日(即太阳在天空中的位置相对于地球与出生时相同的那一天)庆祝生日?会不会你的朋友最终会在提前一天或晚一天庆祝他们 30 岁——一个特别的里程碑?

以下文章使用这个生日问题向读者介绍一些有趣且广泛适用的开源数据科学 Python 包,用于天文计算和地理时空分析,包括 skyfieldtimezonefindergeopypytz。为了获得实践经验,我们将使用这些包来解决我们有趣的问题,即准确预测给定未来年份的“真实生日”(或太阳回归日)。然后我们将讨论如何利用这些包在其他实际应用中。

真实生日预测器

项目设置

以下所有实现步骤已在 macOS Sequoia 15.6.1 上测试,应该与 Linux 和 Windows 大致相似。

让我们首先设置项目目录。我们将使用 uv 来管理项目(请参阅安装说明这里)。在终端中验证已安装的版本:

uv --version

在你的本地机器上合适的位置创建一个名为 real-birthday-predictor 的项目目录:

uv init --bare real-birthday-predictor

在项目目录中,创建一个包含以下依赖项的 requirements.txt 文件:

skyfield==1.53
timezonefinder==8.0.0
geopy==2.4.1
pytz==2025.2

下面是这些包的简要概述:

  • skyfield 提供了天文计算的函数。它可以用来计算天体的精确位置(例如,太阳、月亮、行星和卫星),以帮助确定升起/落下时间、日食和轨道路径。它依赖于所谓的星历(各种天体的位置数据表,在多年内外推),这些数据由像美国宇航局喷气推进实验室(JPL)这样的组织维护。对于本文,我们将使用轻量级的 DE421 星历文件,该文件涵盖了从 1899 年 7 月 29 日到 2053 年 10 月 9 日的日期。

  • timezonefinder 提供了将地理坐标(纬度和经度)映射到时区(例如,“Europe/Paris”)的函数。它可以离线完成此操作。

  • geopy 提供了地理空间分析的函数,例如在地址和地理坐标之间进行映射。我们将使用它与 Nominatim 地理编码器一起,用于 OpenStreetMap 数据,将城市和国家名称映射到坐标。

  • pytz 提供了时间分析和时区转换的函数。我们将使用它来根据区域夏令时规则在 UTC 和本地时间之间进行转换。

我们还将使用一些其他内置模块,例如 datetime 用于解析和操作日期/时间值,calendar 用于检查闰年,以及 time 用于在地理编码重试之间休眠。

接下来,在项目目录内创建一个虚拟 Python 3.12 环境,激活该环境,并安装依赖项:

uv venv --python=3.12 
source .venv/bin/activate
uv add -r requirements.txt

检查依赖项是否已安装:

uv pip list

实现

在本节中,我们将逐部分分析预测给定未来年份和庆祝地点的“真实”生日日期和时间的代码。首先,我们导入必要的模块:

from datetime import datetime, timedelta
from skyfield.api import load, wgs84
from timezonefinder import TimezoneFinder
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut
import pytz
import calendar
import time

然后我们定义方法,使用有意义的变量名和文档字符串文本:

def get_real_birthday_prediction(
    official_birthday: str,
    official_birth_time: str,
    birth_country: str,
    birth_city: str,
    current_country: str,
    current_city: str,
    target_year: str = None
):
    """
    Predicts the "real" birthday (solar return) for a given year,
    accounting for the time zone at the birth location and the time zone
    at the current location. Uses March 1 in non-leap years for the civil 
    anniversary if the official birth date is February 29.
    """

注意,current_countrycurrent_city 一起指代目标年份庆祝生日的位置。

在处理输入之前,我们验证它们:

 # Determine target year
    if target_year is None:
        target_year = datetime.now().year
    else:
        try:
            target_year = int(target_year)
        except ValueError:
            raise ValueError(f"Invalid target year '{target_year}'. Please use 'yyyy' format.")

    # Validate and parse birth date
    try:
        birth_date = datetime.strptime(official_birthday, "%d-%m-%Y")
    except ValueError:
        raise ValueError(
            f"Invalid birth date '{official_birthday}'. "
            "Please use 'dd-mm-yyyy' format with a valid calendar date."
        )

    # Validate and parse birth time
    try:
        birth_hour, birth_minute = map(int, official_birth_time.split(":"))
    except ValueError:
        raise ValueError(
            f"Invalid birth time '{official_birth_time}'. "
            "Please use 'hh:mm' 24-hour format."
        )

    if not (0 <= birth_hour <= 23):
        raise ValueError(f"Hour '{birth_hour}' is out of range (0-23).")
    if not (0 <= birth_minute <= 59):
        raise ValueError(f"Minute '{birth_minute}' is out of range (0-59).")

接下来,我们使用 geopyNominatim 地理编码器来确定出生地和当前地点。为了避免超时错误,我们设置了一个合理的长超时值,即十秒;这是我们的 safe_geocode 函数在抛出 geopy.exc.GeocoderTimedOut 异常之前等待地理编码服务响应的时间;为了额外的安全,该函数在放弃之前尝试查找过程三次,每次间隔一秒:

 geolocator = Nominatim(user_agent="birthday_tz_lookup", timeout=10)

    # Helper function to call geocode API with retries
    def safe_geocode(query, retries=3, delay=1):
        for attempt in range(retries):
            try:
                return geolocator.geocode(query)
            except GeocoderTimedOut:
                if attempt < retries - 1:
                    time.sleep(delay)
                else:
                    raise RuntimeError(
                        f"Could not retrieve location for '{query}' after {retries} attempts. "
                        "The geocoding service may be slow or unavailable. Please try again later."
                    )

    birth_location = safe_geocode(f"{birth_city}, {birth_country}")
    current_location = safe_geocode(f"{current_city}, {current_country}")

    if not birth_location or not current_location:
        raise ValueError("Could not find coordinates for one of the locations. Please check spelling.")

使用出生地和当前地点的地理坐标,我们确定相应的时区和出生时的 UTC 日期和时间。我们还假设像 Jacques 这样在 2 月 29 日出生的人,在非闰年更愿意在 3 月 1 日庆祝他们的生日:

 # Get time zones
    tf = TimezoneFinder()
    birth_tz_name = tf.timezone_at(lng=birth_location.longitude, lat=birth_location.latitude)
    current_tz_name = tf.timezone_at(lng=current_location.longitude, lat=current_location.latitude)

    if not birth_tz_name or not current_tz_name:
        raise ValueError("Could not determine timezone for one of the locations.")

    birth_tz = pytz.timezone(birth_tz_name)
    current_tz = pytz.timezone(current_tz_name)

    # Set civil anniversary date to March 1 for February 29 birthdays in non-leap years
    birth_month, birth_day = birth_date.month, birth_date.day
    if (birth_month, birth_day) == (2, 29):
        if not calendar.isleap(birth_date.year):
            raise ValueError(f"{birth_date.year} is not a leap year, so February 29 is invalid.")
        civil_anniversary_month, civil_anniversary_day = (
            (3, 1) if not calendar.isleap(target_year) else (2, 29)
        )
    else:
        civil_anniversary_month, civil_anniversary_day = birth_month, birth_day

    # Parse birth datetime in birth location's local time
    birth_local_dt = birth_tz.localize(datetime(
        birth_date.year, birth_month, birth_day,
        birth_hour, birth_minute
    ))
    birth_dt_utc = birth_local_dt.astimezone(pytz.utc)

使用 DE421 星历数据,我们计算出生时个体所在的确切时间和地点的太阳位置(即其黄经):

 # Load ephemeris data and get Sun's ecliptic longitude at birth
    eph = load("de421.bsp")  # Covers dates 1899-07-29 through 2053-10-09
    ts = load.timescale()
    sun = eph["sun"]
    earth = eph["earth"]
    t_birth = ts.utc(birth_dt_utc.year, birth_dt_utc.month, birth_dt_utc.day,
                     birth_dt_utc.hour, birth_dt_utc.minute, birth_dt_utc.second)

    # Birth longitude in tropical frame from POV of birth observer on Earth's surface
    birth_observer = earth + wgs84.latlon(birth_location.latitude, birth_location.longitude)
    ecl = birth_observer.at(t_birth).observe(sun).apparent().ecliptic_latlon(epoch='date')
    birth_longitude = ecl[1].degrees

注意,当第一次执行eph = load("de421.bsp")行时,de421.bsp文件将被下载并放置在项目目录中;在所有未来的执行中,将直接使用下载的文件。也可以修改代码以加载另一个星历文件(例如,de440s.bsp,它涵盖了到 2150 年 1 月 22 日的年份)。

现在来到函数的一个有趣部分:我们将对目标年份中的“真实”生日日期和时间进行初步猜测,为真实日期和时间值定义安全的上限和下限(例如,在初始猜测的两侧各两天),并执行带有早期停止的二分查找,以高效地找到真实值:

 # Initial guess for target year solar return
    approx_dt_local_birth_tz = birth_tz.localize(datetime(
        target_year, civil_anniversary_month, civil_anniversary_day,
        birth_hour, birth_minute
    ))
    approx_dt_utc = approx_dt_local_birth_tz.astimezone(pytz.utc)

    # Compute Sun longitude from POV of current observer on Earth's surface
    current_observer = earth + wgs84.latlon(current_location.latitude, current_location.longitude)

    def sun_longitude_at(dt):
        t = ts.utc(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)
        ecl = current_observer.at(t).observe(sun).apparent().ecliptic_latlon(epoch='date')
        return ecl[1].degrees

    def angle_diff(a, b):
        return (a - b + 180) % 360 - 180

    # Set safe upper and lower bounds for search space
    dt1 = approx_dt_utc - timedelta(days=2)
    dt2 = approx_dt_utc + timedelta(days=2)

    # Use binary search with early-stopping to solve for exact solar return in UTC
    old_angle_diff = 999
    for _ in range(50):
        mid = dt1 + (dt2 - dt1) / 2
        curr_angle_diff = angle_diff(sun_longitude_at(mid), birth_longitude)
        if old_angle_diff == curr_angle_diff:  # Early-stopping condition
            break
        if curr_angle_diff > 0:
            dt2 = mid
        else:
            dt1 = mid
        old_angle_diff = curr_angle_diff

    real_dt_utc = dt1 + (dt2 - dt1) / 2

请参阅这篇文章,了解更多使用二分查找的示例,以及为什么这个算法是数据科学家需要掌握的重要算法。

最后,通过二分查找确定的“真实”生日日期和时间被转换为当前位置的时区,按需格式化,并返回:

 # Convert to current location's local time and format output
    real_dt_local_current = real_dt_utc.astimezone(current_tz)
    date_str = real_dt_local_current.strftime("%d/%m")
    time_str = real_dt_local_current.strftime("%H:%M")

    return date_str, time_str, current_tz_name

测试

现在,我们可以预测加布里埃尔、雅克和卡米勒在 2026 年的“真实”生日。

为了使函数输出更容易理解,这里是一个我们将使用的辅助函数,用于美化打印每个查询的结果:

def print_real_birthday(
    official_birthday: str,
    official_birth_time: str,
    birth_country: str,
    birth_city: str,
    current_country: str,
    current_city: str,
    target_year: str = None):
    """Pretty-print output while hiding verbose error traces."""

    print("Official birthday and time:", official_birthday, "at", official_birth_time)

    try:
        date_str, time_str, current_tz_name = get_real_birthday_prediction(
            official_birthday,
            official_birth_time,
            birth_country,
            birth_city,
            current_country,
            current_city,
            target_year
        )

        print(f"In year {target_year}, your real birthday is on {date_str} at {time_str} ({current_tz_name})\n")

    except ValueError as e:
        print("Error:", e)

这里是测试用例:

# Gabriel
print_real_birthday(
    official_birthday="18-01-1996", 
    official_birth_time="02:30",
    birth_country="France",
    birth_city="Paris",
    current_country="France",
    current_city="Paris",
    target_year="2026"
)

# Jacques
print_real_birthday(
    official_birthday="29-02-1996", 
    official_birth_time="05:45",
    birth_country="France",
    birth_city="Paris",
    current_country="France",
    current_city="Paris",
    target_year="2026"
)

# Camille
print_real_birthday(
    official_birthday="05-05-1996", 
    official_birth_time="20:30",
    birth_country="Paris",
    birth_city="France",
    current_country="Japan",
    current_city="Tokyo",
    target_year="2026"
)

下面是结果:

Official birthday and time: 18-01-1996 at 02:30
In year 2026, your real birthday is on 17/01 at 09:21 (Europe/Paris)

Official birthday and time: 29-02-1996 at 05:45
In year 2026, your real birthday is on 28/02 at 12:37 (Europe/Paris)

Official birthday and time: 05-05-1996 at 20:30
In year 2026, your real birthday is on 06/05 at 09:48 (Asia/Tokyo)

如我们所见,“真实”的生日(或太阳回归的时刻)与三位朋友:加布里埃尔和雅克的理论上可以在巴黎官方生日前一天开始庆祝,而卡米勒应该在东京等待一天后再庆祝她的 30 岁生日。

作为上述步骤的更简单替代方案,本文作者创建了一个名为solarius的 Python 库来实现相同的结果(详情请见这里)。使用pip install solariusuv add solarius安装库,并按以下方式使用:

from solarius.model import SolarReturnCalculator

calculator = SolarReturnCalculator(ephemeris_file="de421.bsp")

# Predict without printing
date_str, time_str, tz_name = calculator.predict(
    official_birthday="18-01-1996",
    official_birth_time="02:30",
    birth_country="France",
    birth_city="Paris",
    current_country="France",
    current_city="Paris",
    target_year="2026"
)

print(date_str, time_str, tz_name)

# Or use the convenience printer
calculator.print_real_birthday(
    official_birthday="18-01-1996",
    official_birth_time="02:30",
    birth_country="France",
    birth_city="Paris",
    current_country="France",
    current_city="Paris",
    target_year="2026"
)

当然,生日不仅仅是预测太阳回归——这些特殊的日子沉浸在几个世纪的传统文化中。这里有一个关于生日起源的有趣短视频:

生日之外

上文的目的在于为读者提供一个有趣且直观的应用案例,展示如何使用各种天文计算和地理时空分析包。然而,这些包的实用性远不止于预测生日。

例如,所有这些包都可以用于其他天文事件预测的情况(例如,确定在给定地点的某个未来日期何时日出、日落或发生日食)。预测卫星和其他天体的运动也可能在规划太空任务中发挥重要作用。

这些包也可以用于优化特定地点(如住宅区或商业场所)太阳能电池板的部署。目标将是预测在不同时间一年中该地点可能有多少阳光照射,并利用这些知识来调整太阳能电池板的放置、倾斜和用电时间表,以实现最大能量捕获。

最后,这些包可以用于历史事件的重建(例如,在考古学或历史研究或甚至法律法医学的背景下)。这里的目的是重现特定过去日期和地点的天空条件,以帮助研究人员更好地理解当时的照明和能见度条件。

最终,通过以各种方式结合这些开源包和内置模块,可以解决跨越多个领域的一些有趣问题。

posted @ 2026-03-27 10:43  布客飞龙IV  阅读(1)  评论(0)    收藏  举报