假设读者正在为三位朋友——加布里埃尔(Gabriel)、雅克(Jacques)和卡米尔(Camille)筹划明年的生日庆典。他们都出生于1996年法国巴黎,这意味着到2026年,他们都将迎来30岁的里程碑。届时,加布里埃尔和雅克恰好会在巴黎庆祝各自的生日,而卡米尔则将在日本东京度过她的生日。加布里埃尔和卡米尔通常会按照出生证明上的“官方”日期——分别是1月18日和5月5日——来庆祝生日。然而,雅克出生在2月29日,他更倾向于在非闰年时,将自己的生日(或称“民事纪念日”)定在3月1日。
闰年的设定是为了使公历与地球绕太阳的轨道周期保持同步。一个“太阳年”——即地球完成一次完整绕日公转所需的时间——大约是365.25天。按照惯例,格里高利历每年设定为365天,但每隔四年会增加一天,即闰年为366天,以此补偿日历与实际天文周期之间随时间累积的微小偏差。这不禁引发一个有趣的思考:这些朋友是否会在他们出生日的“真正”周年纪念日上庆祝生日?也就是说,太阳在天空中(相对于地球)的位置,是否会与他们出生时完全相同?考虑到30岁是一个特殊的里程碑,他们是否会不经意间提前一天或推迟一天庆祝自己的生日呢?
本文将以这个有趣的生日问题为引,向读者介绍一些实用且应用广泛的开源数据科学Python库,它们专注于天文计算和地理空间时序分析,其中包括 skyfield、timezonefinder、geopy 和 pytz。为了帮助读者获得实践经验,本文将利用这些库来解决准确预测未来某一年份“真实生日”(即“回归生日”或“太阳回归日”)的趣味问题。随后,文章还将探讨这些库如何在其他实际应用中发挥作用。
真实生日预测器
项目设置
以下所有实现步骤已在 macOS Sequoia 15.6.1 上进行测试,在 Linux 和 Windows 系统上的操作也应大致相似。
首先,设置项目目录。将使用 uv 来管理项目(安装说明可参阅此处)。在终端中验证 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提供了天文计算功能,可用于精确计算天体(例如太阳、月亮、行星和卫星)的位置,从而帮助确定日出/日落时间、日月食和轨道路径。它依赖于所谓的“星历表”(ephemerides),这些星历表是多年来各种天体位置数据的推算表格,由美国国家航空航天局喷气推进实验室(NASA 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
):
"""
预测给定年份的“真实”生日(太阳回归日),
同时考虑出生地时区和当前庆祝地时区。
如果官方出生日期是2月29日,则在非闰年时将民事纪念日设定为3月1日。
"""
请注意,current_country 和 current_city 共同指代目标年份中生日庆祝的地点。
在处理输入数据之前,首先对其进行有效性验证:
# 确定目标年份
if target_year is None:
target_year = datetime.now().year
else:
try:
target_year = int(target_year)
except ValueError:
raise ValueError(f"无效的目标年份 '{target_year}'。请使用 'yyyy' 格式。")
# 验证并解析出生日期
try:
birth_date = datetime.strptime(official_birthday, "%d-%m-%Y")
except ValueError:
raise ValueError(
f"无效的出生日期 '{official_birthday}'。 "
"请使用 'dd-mm-yyyy' 格式,并确保日期有效。"
)
# 验证并解析出生时间
try:
birth_hour, birth_minute = map(int, official_birth_time.split(":"))
except ValueError:
raise ValueError(
f"无效的出生时间 '{official_birth_time}'。 "
"请使用 'hh:mm' 24小时制格式。"
)
if not (0 <= birth_hour <= 23):
raise ValueError(f"小时 '{birth_hour}' 超出范围 (0-23)。")
if not (0 <= birth_minute <= 59):
raise ValueError(f"分钟 '{birth_minute}' 超出范围 (0-59)。")
接下来,使用 geopy 库结合 Nominatim 地理编码器来确定出生地和当前所在地。为避免出现超时错误,设置了一个相对较长的超时时间——十秒;这是 safe_geocode 函数在引发 geopy.exc.GeocoderTimedOut 异常之前,等待地理编码服务响应的时间。为了更加稳妥,该函数会在放弃之前尝试三次查找过程,每次尝试之间间隔一秒:
geolocator = Nominatim(user_agent="birthday_tz_lookup", timeout=10)
# 辅助函数:带重试机制调用地理编码API
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"无法在 {retries} 次尝试后检索到 '{query}' 的位置信息。 "
"地理编码服务可能较慢或不可用。请稍后再试。"
)
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("无法找到其中一个位置的坐标。请检查拼写。")
利用出生地和当前所在地的地理坐标,识别出各自的时区以及出生时的 UTC 日期和时间。同时,假设像雅克这样出生在2月29日的人,在非闰年会选择在3月1日庆祝生日:
# 获取时区
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("无法确定其中一个位置的时区。")
birth_tz = pytz.timezone(birth_tz_name)
current_tz = pytz.timezone(current_tz_name)
# 对于2月29日出生的生日,在非闰年时将民事纪念日设定为3月1日
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} 不是闰年,因此2月29日无效。")
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
# 解析出生日期时间(在出生地的本地时间)
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 星历数据,计算该个体出生时的确切时间和地点,太阳在天空中的位置(即其黄经度):
# 加载星历数据,并获取出生时太阳的黄经度
eph = load("de421.bsp") # 涵盖日期从 1899-07-29 到 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_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(
