···2929from pathlib import Path
3030from datetime import datetime, timedelta, timezone
3131from typing import Dict, List, Tuple
3232-from functools import lru_cache
3333-3434-import zoneinfo # stdlib >=3.9
3232+from tz_common import get_tz_details as _get_tz_details, find_dst_transitions as _find_dst_transitions
3533from bs4 import BeautifulSoup # type: ignore
3634import airportsdata # pip install airportsdata
3735import pandas as pd # pip install pandas pyarrow
3836import requests
3937import io
4038import gzip
4141-4242-# ---------------------------------------------------------------------------
4343-# Helper functions (copied & trimmed from generate_tz_list.py)
4444-# ---------------------------------------------------------------------------
4545-4646-def _get_tz_details(tz_name: str, dt_utc: datetime) -> Tuple[int, timedelta] | None:
4747- """Return (total_offset_seconds, dst_component) or None if tz is invalid."""
4848- try:
4949- tz = zoneinfo.ZoneInfo(tz_name)
5050- off = tz.utcoffset(dt_utc)
5151- dst = tz.dst(dt_utc) or timedelta(0)
5252- if off is not None:
5353- return int(off.total_seconds()), dst
5454- except Exception:
5555- pass
5656- return None
5757-5858-@lru_cache(maxsize=None)
5959-def _find_dst_transitions(tz_name: str, year: int) -> Tuple[int, int, int, int]:
6060- """Return (std_offset_s, dst_offset_s, dst_start_utc_ts, dst_end_utc_ts).
6161-6262- If the zone does not observe DST, std == dst and the transition timestamps
6363- are 0.
6464- """
6565- std_offset_sec = None
6666- dst_offset_sec = None
6767- start_ts = 0
6868- end_ts = 0
6969-7070- # Iterate hour by hour from [year-01-01 00:00-01h] through end of the year
7171- current_dt = datetime(year, 1, 1, tzinfo=timezone.utc) - timedelta(hours=1)
7272- initial = _get_tz_details(tz_name, current_dt)
7373- if not initial:
7474- return (0, 0, 0, 0)
7575-7676- prev_off, prev_dst = initial
7777- total_hours = (366 * 24) + 3 # cover leap + buffer
7878-7979- for _ in range(total_hours):
8080- current_dt += timedelta(hours=1)
8181- details = _get_tz_details(tz_name, current_dt)
8282- if not details:
8383- continue
8484- cur_off, cur_dst = details
8585-8686- # Track seen std/dst offsets
8787- if cur_dst == timedelta(0):
8888- std_offset_sec = cur_off
8989- else:
9090- dst_offset_sec = cur_off
9191-9292- # Detect transition when DST component toggles
9393- if cur_dst != prev_dst:
9494- ts = int(current_dt.timestamp())
9595- if current_dt.year == year:
9696- if prev_dst == timedelta(0) and cur_dst > timedelta(0):
9797- start_ts = ts
9898- elif prev_dst > timedelta(0) and cur_dst == timedelta(0):
9999- end_ts = ts
100100- prev_off, prev_dst = cur_off, cur_dst
101101-102102- if std_offset_sec is None:
103103- std_offset_sec = prev_off
104104- if dst_offset_sec is None:
105105- dst_offset_sec = std_offset_sec
106106-107107- # If offsets differ by <1 min, treat as no DST.
108108- if abs(std_offset_sec - dst_offset_sec) < 60:
109109- start_ts = 0
110110- end_ts = 0
111111- dst_offset_sec = std_offset_sec
112112-113113- return (std_offset_sec, dst_offset_sec, start_ts, end_ts)
1143911540# ---------------------------------------------------------------------------
11641# Build ranked list of airports with route counts (fallback if HTML omitted)
···427352 default=Path("top1000.html"),
428353 help="Path to GetToCenter HTML file (top1000.html)",
429354 )
430430- parser.add_argument("--out", type=Path, default=Path("src/c/airport_tz_list.c"), help="C output file path")
355355+ default_out_path = Path(__file__).parent / "../src/c/airport_tz_list.c"
356356+ parser.add_argument(
357357+ "--out",
358358+ type=Path,
359359+ default=default_out_path,
360360+ help="C output file path"
361361+ )
431362 parser.add_argument(
432363 "--top",
433364 type=int,
-243
generate_tz_list.py
···11-# Requires Python 3.9+ for zoneinfo
22-import zoneinfo
33-from datetime import datetime, timedelta, timezone
44-from functools import lru_cache
55-# No unused imports found (time is used for .timestamp())
66-77-# Helper to get offset and DST component safely
88-def get_tz_details(tz_name: str, dt_utc: datetime) -> tuple[int, timedelta] | None:
99- """Gets total offset in seconds and DST component as timedelta."""
1010- try:
1111- tz = zoneinfo.ZoneInfo(tz_name)
1212- offset_td = tz.utcoffset(dt_utc)
1313- dst_td = tz.dst(dt_utc)
1414-1515- # Ensure DST is not None, default to zero if it is (e.g., for UTC)
1616- if dst_td is None:
1717- dst_td = timedelta(0)
1818-1919- if offset_td is not None:
2020- return int(offset_td.total_seconds()), dst_td
2121- # If offset_td is None, implicitly returns None below
2222- except Exception:
2323- # print(f"Warning: Could not get details for {tz_name} at {dt_utc}: {e}")
2424- pass # Silently ignore errors for individual lookups
2525- return None
2626-2727-# Function to find DST transitions within a year
2828-@lru_cache(maxsize=None)
2929-def find_dst_transitions_accurate(tz_name: str, year: int) -> tuple[int, int, int, int]:
3030- """ Finds precise DST transition UTC timestamps for a given year.
3131- Returns (std_offset_sec, dst_offset_sec, last_start_utc_ts, last_end_utc_ts)
3232- Timestamps are UTC seconds (epoch). 0 if no transition/no DST found in the year.
3333- """
3434- start_ts = 0
3535- end_ts = 0
3636- std_offset_sec = None
3737- dst_offset_sec = None # Offset *during* DST
3838- initial_offset_sec = None # Store the very first valid offset
3939-4040- try:
4141- # Start iterating from one hour before the target year begins
4242- # Ensures transitions exactly at year start are caught
4343- current_dt = datetime(year , 1, 1, 0, 0, 0, tzinfo=timezone.utc) - timedelta(hours=1)
4444- initial_details = get_tz_details(tz_name, current_dt)
4545-4646- if not initial_details:
4747- # Fallback if start fails: try noon on Jan 1st
4848- current_dt = datetime(year , 1, 1, 12, 0, 0, tzinfo=timezone.utc)
4949- initial_details = get_tz_details(tz_name, current_dt)
5050- if not initial_details:
5151- print(f"Warning: Cannot get initial offset for {tz_name} in {year}")
5252- return 0, 0, 0, 0
5353-5454- prev_offset_sec, prev_dst_td = initial_details
5555- initial_offset_sec = prev_offset_sec # Store the first offset we found
5656-5757- # Iterate hour by hour through the target year plus a few hours into the next
5858- total_hours_to_check = (366 * 24) + 3 # Cover leap year + buffer
5959-6060- for _ in range(total_hours_to_check):
6161- current_dt += timedelta(hours=1)
6262- details = get_tz_details(tz_name, current_dt)
6363-6464- if not details: continue # Skip if data unavailable for this hour
6565-6666- current_offset_sec, current_dst_td = details
6767-6868- # --- Determine Standard vs DST offset ---
6969- # Continuously update std/dst based on whether DST is active
7070- if current_dst_td == timedelta(0):
7171- std_offset_sec = current_offset_sec
7272- if current_dst_td > timedelta(0):
7373- dst_offset_sec = current_offset_sec
7474-7575- # --- Detect transition based on change in DST component ---
7676- if prev_dst_td != current_dst_td:
7777- transition_ts = int(current_dt.timestamp())
7878-7979- # Record transition timestamp if it happens *within* the target year
8080- if current_dt.year == year:
8181- if prev_dst_td == timedelta(0) and current_dst_td > timedelta(0):
8282- # Entered DST (Std -> Dst)
8383- start_ts = transition_ts
8484- # Ensure offsets are recorded based on this transition
8585- if std_offset_sec is None: std_offset_sec = prev_offset_sec
8686- if dst_offset_sec is None: dst_offset_sec = current_offset_sec
8787-8888- elif prev_dst_td > timedelta(0) and current_dst_td == timedelta(0):
8989- # Exited DST (Dst -> Std)
9090- end_ts = transition_ts
9191- # Ensure offsets are recorded based on this transition
9292- if std_offset_sec is None: std_offset_sec = current_offset_sec
9393- if dst_offset_sec is None: dst_offset_sec = prev_offset_sec
9494-9595- prev_offset_sec, prev_dst_td = current_offset_sec, current_dst_td
9696-9797- # --- Post-processing ---
9898- # Use the very first offset seen if std/dst couldn't be determined otherwise
9999- if std_offset_sec is None: std_offset_sec = initial_offset_sec
100100- if dst_offset_sec is None: dst_offset_sec = std_offset_sec # Default DST offset to STD if not seen
101101-102102- # If offsets are effectively the same, clear transition timestamps
103103- OFFSET_DIFF_THRESHOLD_SECONDS = 60 # Use a named constant
104104- if abs(std_offset_sec - dst_offset_sec) < OFFSET_DIFF_THRESHOLD_SECONDS:
105105- start_ts = 0
106106- end_ts = 0
107107- dst_offset_sec = std_offset_sec # Ensure they are identical if no DST
108108-109109- except zoneinfo.ZoneInfoNotFoundError:
110110- print(f"Warning: Timezone '{tz_name}' not found during transition check.")
111111- return 0, 0, 0, 0
112112- except Exception as e:
113113- print(f"Error finding transitions for {tz_name}: {e}")
114114- return 0, 0, 0, 0
115115-116116- # Ensure we return non-None values
117117- std_offset_sec = std_offset_sec if std_offset_sec is not None else 0
118118- dst_offset_sec = dst_offset_sec if dst_offset_sec is not None else 0
119119-120120- return std_offset_sec, dst_offset_sec, start_ts, end_ts
121121-122122-def generate_tz_list_c_code():
123123- """Generates C code for a static timezone list with DST transition timestamps."""
124124-125125- target_year = datetime.now().year # Use current year for transitions
126126- print(f"Finding DST transitions for year {target_year}...")
127127-128128- available_zones = zoneinfo.available_timezones()
129129- print(f"Found {len(available_zones)} available timezones.")
130130-131131- processed_zones = {} # Key: TUPLE(std_offset_s, dst_offset_s, start_utc, end_utc), Value: Dict of zone data
132132-133133- for tz_name in available_zones:
134134- # Basic filtering (no dead code here)
135135- if tz_name.startswith("Etc/") or "/" not in tz_name: continue
136136- if tz_name in ["Factory", "factory"] or tz_name.lower().startswith("right/") or tz_name.lower().startswith("posix/"): continue
137137-138138- std_offset_s, dst_offset_s, start_utc, end_utc = find_dst_transitions_accurate(tz_name, target_year)
139139- city_name = tz_name.split('/')[-1].replace('_', ' ')
140140-141141- # --- Filter out generic names ---
142142- # Comprehensive list based on review of tz_list.c
143143- generic_names_to_exclude = {
144144- "Samoa", "Hawaii", "Aleutian", "Alaska", "Pacific", "Arizona", "Yukon",
145145- "Mountain", "General", "Saskatchewan", "Central", "Knox IN", "EasterIsland",
146146- "Acre", "Jamaica", "Michigan", "Eastern", "East-Indiana", "Atlantic",
147147- "Continental", "Newfoundland", "East", "Bahia", "Noronha", "South Georgia",
148148- "Canary", "Faeroe", "Faroe", "Guernsey", "Isle of Man", "Jersey",
149149- "Madeira", "Jan Mayen", "West", "North", "South", "ACT", "NSW",
150150- "Tasmania", "Victoria", "Queensland", "Yap", "South Pole", "Kanton",
151151- # Add or remove names as needed
152152- }
153153- # Case-insensitive check for exclusion
154154- if city_name.lower() in {name.lower() for name in generic_names_to_exclude}:
155155- continue # Skip this generic name
156156-157157- # Convert offsets back to hours for potential display, but keep seconds for key
158158- std_offset_h = std_offset_s / 3600.0
159159- dst_offset_h = dst_offset_s / 3600.0
160160-161161- # Group by the unique combination of std offset, dst offset, and transitions
162162- key_tuple = (std_offset_s, dst_offset_s, start_utc, end_utc)
163163- if city_name and city_name[0].isupper():
164164- if key_tuple not in processed_zones:
165165- processed_zones[key_tuple] = {
166166- "std_offset_s": std_offset_s, # Store seconds internally
167167- "dst_offset_s": dst_offset_s,
168168- "start_utc": start_utc,
169169- "end_utc": end_utc,
170170- "names": []
171171- }
172172- # Add city name if not already present
173173- if city_name not in processed_zones[key_tuple]["names"]:
174174- processed_zones[key_tuple]["names"].append(city_name)
175175-176176- # Convert dict values to a list and sort by std offset, then DST offset, then by DST start/end to keep consistent ordering
177177- tz_data_list = sorted(
178178- processed_zones.values(),
179179- key=lambda x: (
180180- x["std_offset_s"],
181181- x["dst_offset_s"],
182182- x["start_utc"],
183183- x["end_utc"]
184184- )
185185- )
186186- print(f"Generated data for {len(tz_data_list)} unique offset/DST rule combinations.")
187187-188188- # --- C Code Generation: Flatten name pool and tz_list entries ---
189189- # Build a flat pool of city names and compute offsets
190190- names_pool = []
191191- for zone in tz_data_list:
192192- sorted_names = sorted(zone['names'])
193193- zone['name_offset'] = len(names_pool)
194194- zone['name_count'] = len(sorted_names)
195195- names_pool.extend(sorted_names)
196196-197197- # Begin C output
198198- c_code = "// Generated by Python script using zoneinfo\n"
199199- c_code += f"// Contains Standard & DST offsets for {target_year}.\n"
200200- c_code += "// WARNING: DST rules accurate only for the generated year.\n\n"
201201- c_code += "#include <stdint.h>\n\n"
202202-203203- # Flattened list of all city names
204204- c_code += "static const char* tz_name_pool[] = {\n"
205205- for name in names_pool:
206206- c_code += f" \"{name}\",\n"
207207- c_code += "};\n\n"
208208-209209- # TzInfo struct with name pool indices
210210- c_code += "typedef struct {\n"
211211- c_code += " float std_offset_hours;\n"
212212- c_code += " float dst_offset_hours;\n"
213213- c_code += " int64_t dst_start_utc;\n"
214214- c_code += " int64_t dst_end_utc;\n"
215215- c_code += " int name_offset;\n"
216216- c_code += " int name_count;\n"
217217- c_code += "} TzInfo;\n\n"
218218-219219- # Main tz_list entries
220220- c_code += "static const TzInfo tz_list[] = {\n"
221221- for zone in tz_data_list:
222222- std_h = zone['std_offset_s'] / 3600.0
223223- dst_h = zone['dst_offset_s'] / 3600.0
224224- start = zone['start_utc']
225225- end = zone['end_utc']
226226- offs = zone['name_offset']
227227- cnt = zone['name_count']
228228- c_code += f" {{ {std_h:.2f}f, {dst_h:.2f}f, {start}LL, {end}LL, {offs}, {cnt} }},\n"
229229- c_code += "};\n\n"
230230- c_code += f"#define TZ_LIST_COUNT (sizeof(tz_list)/sizeof(tz_list[0]))\n"
231231- c_code += f"#define TZ_NAME_POOL_COUNT (sizeof(tz_name_pool)/sizeof(tz_name_pool[0]))\n"
232232- return c_code
233233-234234-# --- Main execution ---
235235-if __name__ == "__main__":
236236- c_code_output = generate_tz_list_c_code()
237237- output_filename = "src/c/tz_list.c" # Output path
238238- try:
239239- with open(output_filename, "w") as f:
240240- f.write(c_code_output)
241241- print(f"\nSuccessfully written timezone data (with accurate DST timestamps) to {output_filename}")
242242- except IOError as e:
243243- print(f"\nError: Could not write to file {output_filename}: {e}")
+2-2
run.sh
···66# Function to run the timezone generation script
77generate_tz_list() {
88 echo "Generating timezone lists..."
99- uv run python generate_tz_list.py
1010- uv run python generate_airport_tz_list.py
99+ uv run python scripts/generate_tz_list.py
1010+ uv run python scripts/generate_airport_tz_list.py
1111}
12121313# Function to build the project
+131
scripts/generate_tz_list.py
···11+# Requires Python 3.9+ for zoneinfo
22+import zoneinfo
33+from datetime import datetime, timedelta, timezone
44+from os import path
55+# No unused imports found (time is used for .timestamp())
66+77+# Removed local caching and DST logic imports; using shared tz_common instead
88+from tz_common import get_tz_details, find_dst_transitions as find_dst_transitions_accurate
99+1010+def generate_tz_list_c_code():
1111+ """Generates C code for a static timezone list with DST transition timestamps."""
1212+1313+ target_year = datetime.now().year # Use current year for transitions
1414+ print(f"Finding DST transitions for year {target_year}...")
1515+1616+ available_zones = zoneinfo.available_timezones()
1717+ print(f"Found {len(available_zones)} available timezones.")
1818+1919+ processed_zones = {} # Key: TUPLE(std_offset_s, dst_offset_s, start_utc, end_utc), Value: Dict of zone data
2020+2121+ for tz_name in available_zones:
2222+ # Basic filtering (no dead code here)
2323+ if tz_name.startswith("Etc/") or "/" not in tz_name: continue
2424+ if tz_name in ["Factory", "factory"] or tz_name.lower().startswith("right/") or tz_name.lower().startswith("posix/"): continue
2525+2626+ std_offset_s, dst_offset_s, start_utc, end_utc = find_dst_transitions_accurate(tz_name, target_year)
2727+ city_name = tz_name.split('/')[-1].replace('_', ' ')
2828+2929+ # --- Filter out generic names ---
3030+ # Comprehensive list based on review of tz_list.c
3131+ generic_names_to_exclude = {
3232+ "Samoa", "Hawaii", "Aleutian", "Alaska", "Pacific", "Arizona", "Yukon",
3333+ "Mountain", "General", "Saskatchewan", "Central", "Knox IN", "EasterIsland",
3434+ "Acre", "Jamaica", "Michigan", "Eastern", "East-Indiana", "Atlantic",
3535+ "Continental", "Newfoundland", "East", "Bahia", "Noronha", "South Georgia",
3636+ "Canary", "Faeroe", "Faroe", "Guernsey", "Isle of Man", "Jersey",
3737+ "Madeira", "Jan Mayen", "West", "North", "South", "ACT", "NSW",
3838+ "Tasmania", "Victoria", "Queensland", "Yap", "South Pole", "Kanton",
3939+ # Add or remove names as needed
4040+ }
4141+ # Case-insensitive check for exclusion
4242+ if city_name.lower() in {name.lower() for name in generic_names_to_exclude}:
4343+ continue # Skip this generic name
4444+4545+ # Convert offsets back to hours for potential display, but keep seconds for key
4646+ std_offset_h = std_offset_s / 3600.0
4747+ dst_offset_h = dst_offset_s / 3600.0
4848+4949+ # Group by the unique combination of std offset, dst offset, and transitions
5050+ key_tuple = (std_offset_s, dst_offset_s, start_utc, end_utc)
5151+ if city_name and city_name[0].isupper():
5252+ if key_tuple not in processed_zones:
5353+ processed_zones[key_tuple] = {
5454+ "std_offset_s": std_offset_s, # Store seconds internally
5555+ "dst_offset_s": dst_offset_s,
5656+ "start_utc": start_utc,
5757+ "end_utc": end_utc,
5858+ "names": []
5959+ }
6060+ # Add city name if not already present
6161+ if city_name not in processed_zones[key_tuple]["names"]:
6262+ processed_zones[key_tuple]["names"].append(city_name)
6363+6464+ # Convert dict values to a list and sort by std offset, then DST offset, then by DST start/end to keep consistent ordering
6565+ tz_data_list = sorted(
6666+ processed_zones.values(),
6767+ key=lambda x: (
6868+ x["std_offset_s"],
6969+ x["dst_offset_s"],
7070+ x["start_utc"],
7171+ x["end_utc"]
7272+ )
7373+ )
7474+ print(f"Generated data for {len(tz_data_list)} unique offset/DST rule combinations.")
7575+7676+ # --- C Code Generation: Flatten name pool and tz_list entries ---
7777+ # Build a flat pool of city names and compute offsets
7878+ names_pool = []
7979+ for zone in tz_data_list:
8080+ sorted_names = sorted(zone['names'])
8181+ zone['name_offset'] = len(names_pool)
8282+ zone['name_count'] = len(sorted_names)
8383+ names_pool.extend(sorted_names)
8484+8585+ # Begin C output
8686+ c_code = "// Generated by Python script using zoneinfo\n"
8787+ c_code += f"// Contains Standard & DST offsets for {target_year}.\n"
8888+ c_code += "// WARNING: DST rules accurate only for the generated year.\n\n"
8989+ c_code += "#include <stdint.h>\n\n"
9090+9191+ # Flattened list of all city names
9292+ c_code += "static const char* tz_name_pool[] = {\n"
9393+ for name in names_pool:
9494+ c_code += f" \"{name}\",\n"
9595+ c_code += "};\n\n"
9696+9797+ # TzInfo struct with name pool indices
9898+ c_code += "typedef struct {\n"
9999+ c_code += " float std_offset_hours;\n"
100100+ c_code += " float dst_offset_hours;\n"
101101+ c_code += " int64_t dst_start_utc;\n"
102102+ c_code += " int64_t dst_end_utc;\n"
103103+ c_code += " int name_offset;\n"
104104+ c_code += " int name_count;\n"
105105+ c_code += "} TzInfo;\n\n"
106106+107107+ # Main tz_list entries
108108+ c_code += "static const TzInfo tz_list[] = {\n"
109109+ for zone in tz_data_list:
110110+ std_h = zone['std_offset_s'] / 3600.0
111111+ dst_h = zone['dst_offset_s'] / 3600.0
112112+ start = zone['start_utc']
113113+ end = zone['end_utc']
114114+ offs = zone['name_offset']
115115+ cnt = zone['name_count']
116116+ c_code += f" {{ {std_h:.2f}f, {dst_h:.2f}f, {start}LL, {end}LL, {offs}, {cnt} }},\n"
117117+ c_code += "};\n\n"
118118+ c_code += f"#define TZ_LIST_COUNT (sizeof(tz_list)/sizeof(tz_list[0]))\n"
119119+ c_code += f"#define TZ_NAME_POOL_COUNT (sizeof(tz_name_pool)/sizeof(tz_name_pool[0]))\n"
120120+ return c_code
121121+122122+# --- Main execution ---
123123+if __name__ == "__main__":
124124+ c_code_output = generate_tz_list_c_code()
125125+ output_filename = path.join(path.dirname(__file__), "../src/c/tz_list.c") # Default output path
126126+ try:
127127+ with open(output_filename, "w") as f:
128128+ f.write(c_code_output)
129129+ print(f"\nSuccessfully written timezone data (with accurate DST timestamps) to {output_filename}")
130130+ except IOError as e:
131131+ print(f"\nError: Could not write to file {output_filename}: {e}")
+76
scripts/tz_common.py
···11+# tz_common.py
22+"""Common timezone utilities shared by both generators."""
33+from __future__ import annotations
44+from datetime import datetime, timedelta, timezone
55+import zoneinfo
66+from functools import lru_cache
77+88+99+def get_tz_details(tz_name: str, dt_utc: datetime) -> tuple[int, timedelta] | None:
1010+ """Return (offset_seconds, dst_timedelta) or None if the timezone is invalid."""
1111+ try:
1212+ tz = zoneinfo.ZoneInfo(tz_name)
1313+ offset_td = tz.utcoffset(dt_utc)
1414+ dst_td = tz.dst(dt_utc) or timedelta(0)
1515+ if offset_td is not None:
1616+ return int(offset_td.total_seconds()), dst_td
1717+ except Exception:
1818+ pass
1919+ return None
2020+2121+2222+@lru_cache(maxsize=None)
2323+def find_dst_transitions(tz_name: str, year: int) -> tuple[int, int, int, int]:
2424+ """Return (std_offset_sec, dst_offset_sec, dst_start_utc_ts, dst_end_utc_ts).
2525+ If the zone does not observe DST, std == dst and transition timestamps are 0.
2626+ """
2727+ std_offset_sec = None
2828+ dst_offset_sec = None
2929+ start_ts = 0
3030+ end_ts = 0
3131+3232+ # Start one hour before the year to catch boundary transitions
3333+ current_dt = datetime(year, 1, 1, tzinfo=timezone.utc) - timedelta(hours=1)
3434+ initial = get_tz_details(tz_name, current_dt)
3535+ if not initial:
3636+ return 0, 0, 0, 0
3737+3838+ prev_off, prev_dst = initial
3939+ total_hours = (366 * 24) + 3 # cover leap year + buffer
4040+4141+ for _ in range(total_hours):
4242+ current_dt += timedelta(hours=1)
4343+ details = get_tz_details(tz_name, current_dt)
4444+ if not details:
4545+ continue
4646+ cur_off, cur_dst = details
4747+4848+ # Track seen std/dst offsets
4949+ if cur_dst == timedelta(0):
5050+ std_offset_sec = cur_off
5151+ else:
5252+ dst_offset_sec = cur_off
5353+5454+ # Detect DST toggles
5555+ if cur_dst != prev_dst:
5656+ ts = int(current_dt.timestamp())
5757+ if current_dt.year == year:
5858+ if prev_dst == timedelta(0) and cur_dst > timedelta(0):
5959+ start_ts = ts
6060+ elif prev_dst > timedelta(0) and cur_dst == timedelta(0):
6161+ end_ts = ts
6262+ prev_off, prev_dst = cur_off, cur_dst
6363+6464+ # Fallback if never set
6565+ if std_offset_sec is None:
6666+ std_offset_sec = prev_off
6767+ if dst_offset_sec is None:
6868+ dst_offset_sec = std_offset_sec
6969+7070+ # If offsets differ by less than 1 minute, treat as no DST
7171+ if abs(std_offset_sec - dst_offset_sec) < 60:
7272+ start_ts = 0
7373+ end_ts = 0
7474+ dst_offset_sec = std_offset_sec
7575+7676+ return std_offset_sec, dst_offset_sec, start_ts, end_ts