this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor, move under scripts

alice 6524c801 b50e0e40

+217 -322
+8 -77
generate_airport_tz_list.py scripts/generate_airport_tz_list.py
··· 29 29 from pathlib import Path 30 30 from datetime import datetime, timedelta, timezone 31 31 from typing import Dict, List, Tuple 32 - from functools import lru_cache 33 - 34 - import zoneinfo # stdlib >=3.9 32 + from tz_common import get_tz_details as _get_tz_details, find_dst_transitions as _find_dst_transitions 35 33 from bs4 import BeautifulSoup # type: ignore 36 34 import airportsdata # pip install airportsdata 37 35 import pandas as pd # pip install pandas pyarrow 38 36 import requests 39 37 import io 40 38 import gzip 41 - 42 - # --------------------------------------------------------------------------- 43 - # Helper functions (copied & trimmed from generate_tz_list.py) 44 - # --------------------------------------------------------------------------- 45 - 46 - def _get_tz_details(tz_name: str, dt_utc: datetime) -> Tuple[int, timedelta] | None: 47 - """Return (total_offset_seconds, dst_component) or None if tz is invalid.""" 48 - try: 49 - tz = zoneinfo.ZoneInfo(tz_name) 50 - off = tz.utcoffset(dt_utc) 51 - dst = tz.dst(dt_utc) or timedelta(0) 52 - if off is not None: 53 - return int(off.total_seconds()), dst 54 - except Exception: 55 - pass 56 - return None 57 - 58 - @lru_cache(maxsize=None) 59 - def _find_dst_transitions(tz_name: str, year: int) -> Tuple[int, int, int, int]: 60 - """Return (std_offset_s, dst_offset_s, dst_start_utc_ts, dst_end_utc_ts). 61 - 62 - If the zone does not observe DST, std == dst and the transition timestamps 63 - are 0. 64 - """ 65 - std_offset_sec = None 66 - dst_offset_sec = None 67 - start_ts = 0 68 - end_ts = 0 69 - 70 - # Iterate hour by hour from [year-01-01 00:00-01h] through end of the year 71 - current_dt = datetime(year, 1, 1, tzinfo=timezone.utc) - timedelta(hours=1) 72 - initial = _get_tz_details(tz_name, current_dt) 73 - if not initial: 74 - return (0, 0, 0, 0) 75 - 76 - prev_off, prev_dst = initial 77 - total_hours = (366 * 24) + 3 # cover leap + buffer 78 - 79 - for _ in range(total_hours): 80 - current_dt += timedelta(hours=1) 81 - details = _get_tz_details(tz_name, current_dt) 82 - if not details: 83 - continue 84 - cur_off, cur_dst = details 85 - 86 - # Track seen std/dst offsets 87 - if cur_dst == timedelta(0): 88 - std_offset_sec = cur_off 89 - else: 90 - dst_offset_sec = cur_off 91 - 92 - # Detect transition when DST component toggles 93 - if cur_dst != prev_dst: 94 - ts = int(current_dt.timestamp()) 95 - if current_dt.year == year: 96 - if prev_dst == timedelta(0) and cur_dst > timedelta(0): 97 - start_ts = ts 98 - elif prev_dst > timedelta(0) and cur_dst == timedelta(0): 99 - end_ts = ts 100 - prev_off, prev_dst = cur_off, cur_dst 101 - 102 - if std_offset_sec is None: 103 - std_offset_sec = prev_off 104 - if dst_offset_sec is None: 105 - dst_offset_sec = std_offset_sec 106 - 107 - # If offsets differ by <1 min, treat as no DST. 108 - if abs(std_offset_sec - dst_offset_sec) < 60: 109 - start_ts = 0 110 - end_ts = 0 111 - dst_offset_sec = std_offset_sec 112 - 113 - return (std_offset_sec, dst_offset_sec, start_ts, end_ts) 114 39 115 40 # --------------------------------------------------------------------------- 116 41 # Build ranked list of airports with route counts (fallback if HTML omitted) ··· 427 352 default=Path("top1000.html"), 428 353 help="Path to GetToCenter HTML file (top1000.html)", 429 354 ) 430 - parser.add_argument("--out", type=Path, default=Path("src/c/airport_tz_list.c"), help="C output file path") 355 + default_out_path = Path(__file__).parent / "../src/c/airport_tz_list.c" 356 + parser.add_argument( 357 + "--out", 358 + type=Path, 359 + default=default_out_path, 360 + help="C output file path" 361 + ) 431 362 parser.add_argument( 432 363 "--top", 433 364 type=int,
-243
generate_tz_list.py
··· 1 - # Requires Python 3.9+ for zoneinfo 2 - import zoneinfo 3 - from datetime import datetime, timedelta, timezone 4 - from functools import lru_cache 5 - # No unused imports found (time is used for .timestamp()) 6 - 7 - # Helper to get offset and DST component safely 8 - def get_tz_details(tz_name: str, dt_utc: datetime) -> tuple[int, timedelta] | None: 9 - """Gets total offset in seconds and DST component as timedelta.""" 10 - try: 11 - tz = zoneinfo.ZoneInfo(tz_name) 12 - offset_td = tz.utcoffset(dt_utc) 13 - dst_td = tz.dst(dt_utc) 14 - 15 - # Ensure DST is not None, default to zero if it is (e.g., for UTC) 16 - if dst_td is None: 17 - dst_td = timedelta(0) 18 - 19 - if offset_td is not None: 20 - return int(offset_td.total_seconds()), dst_td 21 - # If offset_td is None, implicitly returns None below 22 - except Exception: 23 - # print(f"Warning: Could not get details for {tz_name} at {dt_utc}: {e}") 24 - pass # Silently ignore errors for individual lookups 25 - return None 26 - 27 - # Function to find DST transitions within a year 28 - @lru_cache(maxsize=None) 29 - def find_dst_transitions_accurate(tz_name: str, year: int) -> tuple[int, int, int, int]: 30 - """ Finds precise DST transition UTC timestamps for a given year. 31 - Returns (std_offset_sec, dst_offset_sec, last_start_utc_ts, last_end_utc_ts) 32 - Timestamps are UTC seconds (epoch). 0 if no transition/no DST found in the year. 33 - """ 34 - start_ts = 0 35 - end_ts = 0 36 - std_offset_sec = None 37 - dst_offset_sec = None # Offset *during* DST 38 - initial_offset_sec = None # Store the very first valid offset 39 - 40 - try: 41 - # Start iterating from one hour before the target year begins 42 - # Ensures transitions exactly at year start are caught 43 - current_dt = datetime(year , 1, 1, 0, 0, 0, tzinfo=timezone.utc) - timedelta(hours=1) 44 - initial_details = get_tz_details(tz_name, current_dt) 45 - 46 - if not initial_details: 47 - # Fallback if start fails: try noon on Jan 1st 48 - current_dt = datetime(year , 1, 1, 12, 0, 0, tzinfo=timezone.utc) 49 - initial_details = get_tz_details(tz_name, current_dt) 50 - if not initial_details: 51 - print(f"Warning: Cannot get initial offset for {tz_name} in {year}") 52 - return 0, 0, 0, 0 53 - 54 - prev_offset_sec, prev_dst_td = initial_details 55 - initial_offset_sec = prev_offset_sec # Store the first offset we found 56 - 57 - # Iterate hour by hour through the target year plus a few hours into the next 58 - total_hours_to_check = (366 * 24) + 3 # Cover leap year + buffer 59 - 60 - for _ in range(total_hours_to_check): 61 - current_dt += timedelta(hours=1) 62 - details = get_tz_details(tz_name, current_dt) 63 - 64 - if not details: continue # Skip if data unavailable for this hour 65 - 66 - current_offset_sec, current_dst_td = details 67 - 68 - # --- Determine Standard vs DST offset --- 69 - # Continuously update std/dst based on whether DST is active 70 - if current_dst_td == timedelta(0): 71 - std_offset_sec = current_offset_sec 72 - if current_dst_td > timedelta(0): 73 - dst_offset_sec = current_offset_sec 74 - 75 - # --- Detect transition based on change in DST component --- 76 - if prev_dst_td != current_dst_td: 77 - transition_ts = int(current_dt.timestamp()) 78 - 79 - # Record transition timestamp if it happens *within* the target year 80 - if current_dt.year == year: 81 - if prev_dst_td == timedelta(0) and current_dst_td > timedelta(0): 82 - # Entered DST (Std -> Dst) 83 - start_ts = transition_ts 84 - # Ensure offsets are recorded based on this transition 85 - if std_offset_sec is None: std_offset_sec = prev_offset_sec 86 - if dst_offset_sec is None: dst_offset_sec = current_offset_sec 87 - 88 - elif prev_dst_td > timedelta(0) and current_dst_td == timedelta(0): 89 - # Exited DST (Dst -> Std) 90 - end_ts = transition_ts 91 - # Ensure offsets are recorded based on this transition 92 - if std_offset_sec is None: std_offset_sec = current_offset_sec 93 - if dst_offset_sec is None: dst_offset_sec = prev_offset_sec 94 - 95 - prev_offset_sec, prev_dst_td = current_offset_sec, current_dst_td 96 - 97 - # --- Post-processing --- 98 - # Use the very first offset seen if std/dst couldn't be determined otherwise 99 - if std_offset_sec is None: std_offset_sec = initial_offset_sec 100 - if dst_offset_sec is None: dst_offset_sec = std_offset_sec # Default DST offset to STD if not seen 101 - 102 - # If offsets are effectively the same, clear transition timestamps 103 - OFFSET_DIFF_THRESHOLD_SECONDS = 60 # Use a named constant 104 - if abs(std_offset_sec - dst_offset_sec) < OFFSET_DIFF_THRESHOLD_SECONDS: 105 - start_ts = 0 106 - end_ts = 0 107 - dst_offset_sec = std_offset_sec # Ensure they are identical if no DST 108 - 109 - except zoneinfo.ZoneInfoNotFoundError: 110 - print(f"Warning: Timezone '{tz_name}' not found during transition check.") 111 - return 0, 0, 0, 0 112 - except Exception as e: 113 - print(f"Error finding transitions for {tz_name}: {e}") 114 - return 0, 0, 0, 0 115 - 116 - # Ensure we return non-None values 117 - std_offset_sec = std_offset_sec if std_offset_sec is not None else 0 118 - dst_offset_sec = dst_offset_sec if dst_offset_sec is not None else 0 119 - 120 - return std_offset_sec, dst_offset_sec, start_ts, end_ts 121 - 122 - def generate_tz_list_c_code(): 123 - """Generates C code for a static timezone list with DST transition timestamps.""" 124 - 125 - target_year = datetime.now().year # Use current year for transitions 126 - print(f"Finding DST transitions for year {target_year}...") 127 - 128 - available_zones = zoneinfo.available_timezones() 129 - print(f"Found {len(available_zones)} available timezones.") 130 - 131 - processed_zones = {} # Key: TUPLE(std_offset_s, dst_offset_s, start_utc, end_utc), Value: Dict of zone data 132 - 133 - for tz_name in available_zones: 134 - # Basic filtering (no dead code here) 135 - if tz_name.startswith("Etc/") or "/" not in tz_name: continue 136 - if tz_name in ["Factory", "factory"] or tz_name.lower().startswith("right/") or tz_name.lower().startswith("posix/"): continue 137 - 138 - std_offset_s, dst_offset_s, start_utc, end_utc = find_dst_transitions_accurate(tz_name, target_year) 139 - city_name = tz_name.split('/')[-1].replace('_', ' ') 140 - 141 - # --- Filter out generic names --- 142 - # Comprehensive list based on review of tz_list.c 143 - generic_names_to_exclude = { 144 - "Samoa", "Hawaii", "Aleutian", "Alaska", "Pacific", "Arizona", "Yukon", 145 - "Mountain", "General", "Saskatchewan", "Central", "Knox IN", "EasterIsland", 146 - "Acre", "Jamaica", "Michigan", "Eastern", "East-Indiana", "Atlantic", 147 - "Continental", "Newfoundland", "East", "Bahia", "Noronha", "South Georgia", 148 - "Canary", "Faeroe", "Faroe", "Guernsey", "Isle of Man", "Jersey", 149 - "Madeira", "Jan Mayen", "West", "North", "South", "ACT", "NSW", 150 - "Tasmania", "Victoria", "Queensland", "Yap", "South Pole", "Kanton", 151 - # Add or remove names as needed 152 - } 153 - # Case-insensitive check for exclusion 154 - if city_name.lower() in {name.lower() for name in generic_names_to_exclude}: 155 - continue # Skip this generic name 156 - 157 - # Convert offsets back to hours for potential display, but keep seconds for key 158 - std_offset_h = std_offset_s / 3600.0 159 - dst_offset_h = dst_offset_s / 3600.0 160 - 161 - # Group by the unique combination of std offset, dst offset, and transitions 162 - key_tuple = (std_offset_s, dst_offset_s, start_utc, end_utc) 163 - if city_name and city_name[0].isupper(): 164 - if key_tuple not in processed_zones: 165 - processed_zones[key_tuple] = { 166 - "std_offset_s": std_offset_s, # Store seconds internally 167 - "dst_offset_s": dst_offset_s, 168 - "start_utc": start_utc, 169 - "end_utc": end_utc, 170 - "names": [] 171 - } 172 - # Add city name if not already present 173 - if city_name not in processed_zones[key_tuple]["names"]: 174 - processed_zones[key_tuple]["names"].append(city_name) 175 - 176 - # Convert dict values to a list and sort by std offset, then DST offset, then by DST start/end to keep consistent ordering 177 - tz_data_list = sorted( 178 - processed_zones.values(), 179 - key=lambda x: ( 180 - x["std_offset_s"], 181 - x["dst_offset_s"], 182 - x["start_utc"], 183 - x["end_utc"] 184 - ) 185 - ) 186 - print(f"Generated data for {len(tz_data_list)} unique offset/DST rule combinations.") 187 - 188 - # --- C Code Generation: Flatten name pool and tz_list entries --- 189 - # Build a flat pool of city names and compute offsets 190 - names_pool = [] 191 - for zone in tz_data_list: 192 - sorted_names = sorted(zone['names']) 193 - zone['name_offset'] = len(names_pool) 194 - zone['name_count'] = len(sorted_names) 195 - names_pool.extend(sorted_names) 196 - 197 - # Begin C output 198 - c_code = "// Generated by Python script using zoneinfo\n" 199 - c_code += f"// Contains Standard & DST offsets for {target_year}.\n" 200 - c_code += "// WARNING: DST rules accurate only for the generated year.\n\n" 201 - c_code += "#include <stdint.h>\n\n" 202 - 203 - # Flattened list of all city names 204 - c_code += "static const char* tz_name_pool[] = {\n" 205 - for name in names_pool: 206 - c_code += f" \"{name}\",\n" 207 - c_code += "};\n\n" 208 - 209 - # TzInfo struct with name pool indices 210 - c_code += "typedef struct {\n" 211 - c_code += " float std_offset_hours;\n" 212 - c_code += " float dst_offset_hours;\n" 213 - c_code += " int64_t dst_start_utc;\n" 214 - c_code += " int64_t dst_end_utc;\n" 215 - c_code += " int name_offset;\n" 216 - c_code += " int name_count;\n" 217 - c_code += "} TzInfo;\n\n" 218 - 219 - # Main tz_list entries 220 - c_code += "static const TzInfo tz_list[] = {\n" 221 - for zone in tz_data_list: 222 - std_h = zone['std_offset_s'] / 3600.0 223 - dst_h = zone['dst_offset_s'] / 3600.0 224 - start = zone['start_utc'] 225 - end = zone['end_utc'] 226 - offs = zone['name_offset'] 227 - cnt = zone['name_count'] 228 - c_code += f" {{ {std_h:.2f}f, {dst_h:.2f}f, {start}LL, {end}LL, {offs}, {cnt} }},\n" 229 - c_code += "};\n\n" 230 - c_code += f"#define TZ_LIST_COUNT (sizeof(tz_list)/sizeof(tz_list[0]))\n" 231 - c_code += f"#define TZ_NAME_POOL_COUNT (sizeof(tz_name_pool)/sizeof(tz_name_pool[0]))\n" 232 - return c_code 233 - 234 - # --- Main execution --- 235 - if __name__ == "__main__": 236 - c_code_output = generate_tz_list_c_code() 237 - output_filename = "src/c/tz_list.c" # Output path 238 - try: 239 - with open(output_filename, "w") as f: 240 - f.write(c_code_output) 241 - print(f"\nSuccessfully written timezone data (with accurate DST timestamps) to {output_filename}") 242 - except IOError as e: 243 - print(f"\nError: Could not write to file {output_filename}: {e}")
+2 -2
run.sh
··· 6 6 # Function to run the timezone generation script 7 7 generate_tz_list() { 8 8 echo "Generating timezone lists..." 9 - uv run python generate_tz_list.py 10 - uv run python generate_airport_tz_list.py 9 + uv run python scripts/generate_tz_list.py 10 + uv run python scripts/generate_airport_tz_list.py 11 11 } 12 12 13 13 # Function to build the project
+131
scripts/generate_tz_list.py
··· 1 + # Requires Python 3.9+ for zoneinfo 2 + import zoneinfo 3 + from datetime import datetime, timedelta, timezone 4 + from os import path 5 + # No unused imports found (time is used for .timestamp()) 6 + 7 + # Removed local caching and DST logic imports; using shared tz_common instead 8 + from tz_common import get_tz_details, find_dst_transitions as find_dst_transitions_accurate 9 + 10 + def generate_tz_list_c_code(): 11 + """Generates C code for a static timezone list with DST transition timestamps.""" 12 + 13 + target_year = datetime.now().year # Use current year for transitions 14 + print(f"Finding DST transitions for year {target_year}...") 15 + 16 + available_zones = zoneinfo.available_timezones() 17 + print(f"Found {len(available_zones)} available timezones.") 18 + 19 + processed_zones = {} # Key: TUPLE(std_offset_s, dst_offset_s, start_utc, end_utc), Value: Dict of zone data 20 + 21 + for tz_name in available_zones: 22 + # Basic filtering (no dead code here) 23 + if tz_name.startswith("Etc/") or "/" not in tz_name: continue 24 + if tz_name in ["Factory", "factory"] or tz_name.lower().startswith("right/") or tz_name.lower().startswith("posix/"): continue 25 + 26 + std_offset_s, dst_offset_s, start_utc, end_utc = find_dst_transitions_accurate(tz_name, target_year) 27 + city_name = tz_name.split('/')[-1].replace('_', ' ') 28 + 29 + # --- Filter out generic names --- 30 + # Comprehensive list based on review of tz_list.c 31 + generic_names_to_exclude = { 32 + "Samoa", "Hawaii", "Aleutian", "Alaska", "Pacific", "Arizona", "Yukon", 33 + "Mountain", "General", "Saskatchewan", "Central", "Knox IN", "EasterIsland", 34 + "Acre", "Jamaica", "Michigan", "Eastern", "East-Indiana", "Atlantic", 35 + "Continental", "Newfoundland", "East", "Bahia", "Noronha", "South Georgia", 36 + "Canary", "Faeroe", "Faroe", "Guernsey", "Isle of Man", "Jersey", 37 + "Madeira", "Jan Mayen", "West", "North", "South", "ACT", "NSW", 38 + "Tasmania", "Victoria", "Queensland", "Yap", "South Pole", "Kanton", 39 + # Add or remove names as needed 40 + } 41 + # Case-insensitive check for exclusion 42 + if city_name.lower() in {name.lower() for name in generic_names_to_exclude}: 43 + continue # Skip this generic name 44 + 45 + # Convert offsets back to hours for potential display, but keep seconds for key 46 + std_offset_h = std_offset_s / 3600.0 47 + dst_offset_h = dst_offset_s / 3600.0 48 + 49 + # Group by the unique combination of std offset, dst offset, and transitions 50 + key_tuple = (std_offset_s, dst_offset_s, start_utc, end_utc) 51 + if city_name and city_name[0].isupper(): 52 + if key_tuple not in processed_zones: 53 + processed_zones[key_tuple] = { 54 + "std_offset_s": std_offset_s, # Store seconds internally 55 + "dst_offset_s": dst_offset_s, 56 + "start_utc": start_utc, 57 + "end_utc": end_utc, 58 + "names": [] 59 + } 60 + # Add city name if not already present 61 + if city_name not in processed_zones[key_tuple]["names"]: 62 + processed_zones[key_tuple]["names"].append(city_name) 63 + 64 + # Convert dict values to a list and sort by std offset, then DST offset, then by DST start/end to keep consistent ordering 65 + tz_data_list = sorted( 66 + processed_zones.values(), 67 + key=lambda x: ( 68 + x["std_offset_s"], 69 + x["dst_offset_s"], 70 + x["start_utc"], 71 + x["end_utc"] 72 + ) 73 + ) 74 + print(f"Generated data for {len(tz_data_list)} unique offset/DST rule combinations.") 75 + 76 + # --- C Code Generation: Flatten name pool and tz_list entries --- 77 + # Build a flat pool of city names and compute offsets 78 + names_pool = [] 79 + for zone in tz_data_list: 80 + sorted_names = sorted(zone['names']) 81 + zone['name_offset'] = len(names_pool) 82 + zone['name_count'] = len(sorted_names) 83 + names_pool.extend(sorted_names) 84 + 85 + # Begin C output 86 + c_code = "// Generated by Python script using zoneinfo\n" 87 + c_code += f"// Contains Standard & DST offsets for {target_year}.\n" 88 + c_code += "// WARNING: DST rules accurate only for the generated year.\n\n" 89 + c_code += "#include <stdint.h>\n\n" 90 + 91 + # Flattened list of all city names 92 + c_code += "static const char* tz_name_pool[] = {\n" 93 + for name in names_pool: 94 + c_code += f" \"{name}\",\n" 95 + c_code += "};\n\n" 96 + 97 + # TzInfo struct with name pool indices 98 + c_code += "typedef struct {\n" 99 + c_code += " float std_offset_hours;\n" 100 + c_code += " float dst_offset_hours;\n" 101 + c_code += " int64_t dst_start_utc;\n" 102 + c_code += " int64_t dst_end_utc;\n" 103 + c_code += " int name_offset;\n" 104 + c_code += " int name_count;\n" 105 + c_code += "} TzInfo;\n\n" 106 + 107 + # Main tz_list entries 108 + c_code += "static const TzInfo tz_list[] = {\n" 109 + for zone in tz_data_list: 110 + std_h = zone['std_offset_s'] / 3600.0 111 + dst_h = zone['dst_offset_s'] / 3600.0 112 + start = zone['start_utc'] 113 + end = zone['end_utc'] 114 + offs = zone['name_offset'] 115 + cnt = zone['name_count'] 116 + c_code += f" {{ {std_h:.2f}f, {dst_h:.2f}f, {start}LL, {end}LL, {offs}, {cnt} }},\n" 117 + c_code += "};\n\n" 118 + c_code += f"#define TZ_LIST_COUNT (sizeof(tz_list)/sizeof(tz_list[0]))\n" 119 + c_code += f"#define TZ_NAME_POOL_COUNT (sizeof(tz_name_pool)/sizeof(tz_name_pool[0]))\n" 120 + return c_code 121 + 122 + # --- Main execution --- 123 + if __name__ == "__main__": 124 + c_code_output = generate_tz_list_c_code() 125 + output_filename = path.join(path.dirname(__file__), "../src/c/tz_list.c") # Default output path 126 + try: 127 + with open(output_filename, "w") as f: 128 + f.write(c_code_output) 129 + print(f"\nSuccessfully written timezone data (with accurate DST timestamps) to {output_filename}") 130 + except IOError as e: 131 + print(f"\nError: Could not write to file {output_filename}: {e}")
+76
scripts/tz_common.py
··· 1 + # tz_common.py 2 + """Common timezone utilities shared by both generators.""" 3 + from __future__ import annotations 4 + from datetime import datetime, timedelta, timezone 5 + import zoneinfo 6 + from functools import lru_cache 7 + 8 + 9 + def get_tz_details(tz_name: str, dt_utc: datetime) -> tuple[int, timedelta] | None: 10 + """Return (offset_seconds, dst_timedelta) or None if the timezone is invalid.""" 11 + try: 12 + tz = zoneinfo.ZoneInfo(tz_name) 13 + offset_td = tz.utcoffset(dt_utc) 14 + dst_td = tz.dst(dt_utc) or timedelta(0) 15 + if offset_td is not None: 16 + return int(offset_td.total_seconds()), dst_td 17 + except Exception: 18 + pass 19 + return None 20 + 21 + 22 + @lru_cache(maxsize=None) 23 + def find_dst_transitions(tz_name: str, year: int) -> tuple[int, int, int, int]: 24 + """Return (std_offset_sec, dst_offset_sec, dst_start_utc_ts, dst_end_utc_ts). 25 + If the zone does not observe DST, std == dst and transition timestamps are 0. 26 + """ 27 + std_offset_sec = None 28 + dst_offset_sec = None 29 + start_ts = 0 30 + end_ts = 0 31 + 32 + # Start one hour before the year to catch boundary transitions 33 + current_dt = datetime(year, 1, 1, tzinfo=timezone.utc) - timedelta(hours=1) 34 + initial = get_tz_details(tz_name, current_dt) 35 + if not initial: 36 + return 0, 0, 0, 0 37 + 38 + prev_off, prev_dst = initial 39 + total_hours = (366 * 24) + 3 # cover leap year + buffer 40 + 41 + for _ in range(total_hours): 42 + current_dt += timedelta(hours=1) 43 + details = get_tz_details(tz_name, current_dt) 44 + if not details: 45 + continue 46 + cur_off, cur_dst = details 47 + 48 + # Track seen std/dst offsets 49 + if cur_dst == timedelta(0): 50 + std_offset_sec = cur_off 51 + else: 52 + dst_offset_sec = cur_off 53 + 54 + # Detect DST toggles 55 + if cur_dst != prev_dst: 56 + ts = int(current_dt.timestamp()) 57 + if current_dt.year == year: 58 + if prev_dst == timedelta(0) and cur_dst > timedelta(0): 59 + start_ts = ts 60 + elif prev_dst > timedelta(0) and cur_dst == timedelta(0): 61 + end_ts = ts 62 + prev_off, prev_dst = cur_off, cur_dst 63 + 64 + # Fallback if never set 65 + if std_offset_sec is None: 66 + std_offset_sec = prev_off 67 + if dst_offset_sec is None: 68 + dst_offset_sec = std_offset_sec 69 + 70 + # If offsets differ by less than 1 minute, treat as no DST 71 + if abs(std_offset_sec - dst_offset_sec) < 60: 72 + start_ts = 0 73 + end_ts = 0 74 + dst_offset_sec = std_offset_sec 75 + 76 + return std_offset_sec, dst_offset_sec, start_ts, end_ts