are planning next 12 months’s birthday celebrations for 3 friends: Gabriel, Jacques, and Camille. All three of them were born in 1996, in Paris, France, so that they can be 30 years old next 12 months in 2026. Gabriel and Jacques will occur to be in Paris on their respective birthdays, while Camille can be in Tokyo, Japan, during hers. Gabriel and Camille are inclined to rejoice their birthdays in any given 12 months on the “official” days mentioned on their birth certificates — January 18 and May 5, respectively. Jacques, who was born on February 29, prefers to rejoice his birthday (or ) on March 1 in non-leap years.
We use to maintain our calendar in sync with the Earth’s orbit across the Sun. A — the time it takes the Earth to finish one full orbit across the Sun — is roughly 365.25 days. By convention, the Gregorian calendar assigns twelve months to annually, aside from leap years, which get three hundred and sixty six days to compensate for the fractional drift over time. This makes you wonder: will any of your folks be celebrating their birthday on the “real” anniversary of their day of birth, i.e., the day that the Sun can be in the identical position within the sky (relative to the Earth) because it was once they were born? Could it’s that your folks will find yourself celebrating turning 30 — a special milestone — a day too soon or a day too late?
The next article uses this birthday problem to introduce readers to some interesting and broadly applicable open-source data science Python packages for astronomical computation and geospatial-temporal analytics, including skyfield
, timezonefinder
, geopy
, and pytz
. To realize hands-on experience, we are going to use these packages to resolve our fun problem of accurately predicting the “real birthday” (or date of ) in a given future 12 months. We’ll then discuss how such packages will be leveraged in other real-life applications.
Real Birthday Predictor
Project Setup
All implementation steps below have been tested on macOS Sequoia 15.6.1 and ought to be roughly similar on Linux and Windows.
Allow us to start by organising the project directory. We can be using uv
to administer the project (see installation instructions here). Confirm the installed version within the Terminal:
uv --version
Initialize a project directory called real-birthday-predictor
at an acceptable location in your local machine:
uv init --bare real-birthday-predictor
Within the project directory, create a requirements.txt
file with the next dependencies:
skyfield==1.53
timezonefinder==8.0.0
geopy==2.4.1
pytz==2025.2
Here’s a transient overview of every of those packages:
skyfield
provides functions for astronomical computation. It could be used to compute precise positions of celestial bodies (e.g., Sun, Moon, planets, and satellites) to assist determine rise/set times, eclipses, and orbital paths. It relies on so-called (tables of positional data for various celestial bodies extrapolated over a few years), that are maintained by organizations reminiscent of the NASA Jet Propulsion Laboratory (JPL). For this text, we are going to use the lightweight DE421 ephemeris file, which covers dates from July 29, 1899, through October 9, 2053.timezonefinder
has functions for mapping geographical coordinates (latitudes and longitudes) to timezones (e.g., “Europe/Paris”). It could do that offline.geopy
offers functions for geospatial analytics, reminiscent of mapping between addresses and geographical coordinates. We’ll use it along with theNominatim
geocoder for OpenStreetMap data to map the names of cities and countries to coordinates.pytz
provides functions for temporal analytics and time zone conversion. We’ll use it to convert between UTC and native times using regional daylight-saving rules.
We may also use a couple of other built-in modules, reminiscent of datetime
for parsing and manipulating date/time values, calendar
for checking leap years, and time
for sleeping between geocoding retries.
Next, create a virtual Python 3.12 environment contained in the project directory, activate the environment, and install the dependencies:
uv venv --python=3.12
source .venv/bin/activate
uv add -r requirements.txt
Check that the dependencies have been installed:
uv pip list
Implementation
On this section, we are going to go piece by piece through the code for predicting the “real” birthday date and time in a given future 12 months and site of celebration. First, we import the crucial modules:
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
Then we define the strategy, using meaningful variable names and docstring text:
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 12 months,
accounting for the time zone on the birth location and the time zone
at the present location. Uses March 1 in non-leap years for the civil
anniversary if the official birth date is February 29.
"""
Note that current_country
and current_city
jointly consult with the placement at which the birthday is to be celebrated within the goal 12 months.
We validate the inputs before working with them:
# Determine goal 12 months
if target_year is None:
target_year = datetime.now().12 months
else:
try:
target_year = int(target_year)
except ValueError:
raise ValueError(f"Invalid goal 12 months '{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 sound 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).")
Next, we use geopy
with the Nominatim
geocoder to determine the birth and current locations. To avoid getting timeout errors, we set a fairly long timeout value of ten seconds; that is how long our safe_geocode
function waits for the geocoding service to reply before raising a geopy.exc.GeocoderTimedOut
exception. To be extra protected, the function attempts the lookup procedure thrice with one-second delays before giving up:
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"Couldn't retrieve location for '{query}' after {retries} attempts. "
"The geocoding service could also 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("Couldn't find coordinates for one among the locations. Please check spelling.")
Using the geographical coordinates of the birth and current locations, we discover the respective time zones and the UTC date and time at birth. We also assume that individuals like Jacques, who were born on February 29, will prefer to rejoice their birthday on March 1 in non-leap years:
# 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("Couldn't determine timezone for one among 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.12 months):
raise ValueError(f"{birth_date.12 months} just isn't a intercalary 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.12 months, birth_month, birth_day,
birth_hour, birth_minute
))
birth_dt_utc = birth_local_dt.astimezone(pytz.utc)
Using the DE421 ephemeris data, we calculate where the Sun was (i.e., its ) at the precise time and place the person was born:
# 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.12 months, 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
Note that, the primary time the road eph = load("de421.bsp")
is executed, the de421.bsp
file can be downloaded and placed within the project directory; in all future executions, the downloaded file can be used directly. Additionally it is possible to change the code to load one other ephemeris file (e.g., de440s.bsp
, which covers years through January 22, 2150).
Now comes an interesting a part of the function: we are going to make an initial guess of the “real” birthday date and time within the goal 12 months, define protected upper and lower bounds for the true date and time value (e.g., two days either side of the initial guess), and perform a binary search with early-stopping to efficiently home in on the true value:
# Initial guess for goal 12 months 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.12 months, 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 protected 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 resolve 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
See this article for more examples of using binary search and to know why this algorithm is a very important one for data scientists to master.
Finally, the date and time of the “real” birthday identified by the binary search is converted to the present location’s time zone, formatted as needed, and returned:
# 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
Testing
Now we're ready to predict the “real” birthdays of Gabriel, Jacques, and Camille in 2026.
To make the function output easier to digest, here's a helper function we are going to use to pretty-print the outcomes of every query:
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 12 months {target_year}, your real birthday is on {date_str} at {time_str} ({current_tz_name})n")
except ValueError as e:
print("Error:", e)
Listed here are the test cases:
# 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"
)
And listed here are the outcomes:
Official birthday and time: 18-01-1996 at 02:30
In 12 months 2026, your real birthday is on 17/01 at 09:21 (Europe/Paris)
Official birthday and time: 29-02-1996 at 05:45
In 12 months 2026, your real birthday is on 28/02 at 12:37 (Europe/Paris)
Official birthday and time: 05-05-1996 at 20:30
In 12 months 2026, your real birthday is on 06/05 at 09:48 (Asia/Tokyo)
As we see, the “real” birthday (or moment of solar return) is different from the official birthday for all three of your folks: Gabriel and Jacques could theoretically start celebrating a day before their official birthdays in Paris, while Camille must wait yet one more day before celebrating her thirtieth in Tokyo.
As an easier alternative to following the steps above, the creator of this text has created a Python library called solarius
to realize the identical result (see details here). Install the library with pip install solarius
or uv add solarius
and use it as shown below:
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"
)
In fact, there may be more to birthdays than predicting solar returns — these special days are steeped in centuries of tradition. Here's a short video on the fascinating origins of birthdays:
Beyond Birthdays
The intention of the above section was to provide readers a fun and intuitive use case for applying the varied packages for astronomical computation and geospatial-temporal analytics. Nevertheless, the usefulness of such packages goes far beyond predicting birthdays.
For instance, all of those packages will be used for other cases of astronomical event prediction (e.g., determining when a sunrise, sunset, or eclipse will occur on a future date in a given location). Predicting the movement of satellites and other celestial bodies could also play a very important part in planning space missions.
The packages may be used to optimize the deployment of solar panels in a specific location, reminiscent of a residential neighborhood or a business site. The target could be to predict how much sunlight is more likely to fall on that location at different times of the 12 months and use this information to regulate the position, tilt, and usage schedules of the solar panels for max energy capture.
Finally, the packages will be leveraged for historical event reconstruction (e.g., within the context of archaeological or historical research, and even legal forensics). The target here could be to recreate the sky conditions for a selected past date and site to assist researchers higher understand the lighting and visibility conditions at the moment.
Ultimately, by combining these open-source packages and built-in modules in various ways, it is feasible to resolve interesting problems that cut across various domains.