season_start_analyzer.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. #!/usr/bin/env python3
  2. """
  3. 气候季节起始日分析脚本
  4. 依据:《气候季节划分指标》标准(GB/T 41538-2022)
  5. - 常年法:基于历史多年平均气温,计算常年春/夏/秋/冬起始日
  6. - 当年法:基于当年气温数据,结合常年参考进行初判与二次判断
  7. """
  8. import csv
  9. import json
  10. from collections import defaultdict
  11. from datetime import datetime
  12. from typing import Optional
  13. # ---------------------------------------------------------------------------
  14. # 工具函数
  15. # ---------------------------------------------------------------------------
  16. def month_day_to_doy(month: int, day: int) -> int:
  17. """月份+日期 -> 一年中的第几天(1-based,按非闰年算)"""
  18. days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
  19. return sum(days_in_month[:month]) + day
  20. def doy_to_date(doy: int, year: int = 2023) -> str:
  21. """日序 -> YYYY-MM-DD"""
  22. days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
  23. m = 1
  24. while doy > days_in_month[m]:
  25. doy -= days_in_month[m]
  26. m += 1
  27. return f"{year}-{m:02d}-{doy:02d}"
  28. def day_diff(date1: str, date2: str) -> int:
  29. """两个 YYYY-MM-DD 字符串的天数差(date2 - date1)"""
  30. d1 = datetime.strptime(date1, "%Y-%m-%d")
  31. d2 = datetime.strptime(date2, "%Y-%m-%d")
  32. return (d2 - d1).days
  33. # ---------------------------------------------------------------------------
  34. # 1. 数据加载
  35. # ---------------------------------------------------------------------------
  36. def load_temperature_data(csv_path: str) -> dict[str, dict[str, float]]:
  37. """
  38. 读取 CSV,过滤掉 mean_temp 为空的行。
  39. 返回 {city: {date_str: mean_temp}}
  40. """
  41. data: dict[str, dict[str, float]] = defaultdict(dict)
  42. with open(csv_path, encoding="utf-8-sig") as f:
  43. reader = csv.reader(f)
  44. next(reader) # skip header
  45. for row in reader:
  46. if len(row) < 3 or not row[2].strip():
  47. continue
  48. data[row[1]][row[0]] = float(row[2])
  49. return dict(data)
  50. # ---------------------------------------------------------------------------
  51. # 2. 常年日平均气温序列(公式1)
  52. # ---------------------------------------------------------------------------
  53. def compute_clim_daily_avg(
  54. city_data: dict[str, float],
  55. years: list[str],
  56. ) -> list[float]:
  57. """
  58. 对选定年份逐日(日序 1~365)求平均,得常年日平均气温序列。
  59. 返回列表索引 j 对应日序 j+1(0-based)。
  60. """
  61. totals = [0.0] * 366
  62. counts = [0] * 366
  63. for year in years:
  64. yd = {k: v for k, v in city_data.items() if k.startswith(year)}
  65. for date_str, temp in yd.items():
  66. m = int(date_str[5:7])
  67. d = int(date_str[8:10])
  68. doy = month_day_to_doy(m, d)
  69. if doy == 60: # skip leap Feb 29
  70. continue
  71. totals[doy] += temp
  72. counts[doy] += 1
  73. return [
  74. round(totals[j] / counts[j], 2) if counts[j] > 0 else None
  75. for j in range(1, 366)
  76. ]
  77. # ---------------------------------------------------------------------------
  78. # 3. 5日滑动平均(公式2,首尾相接)
  79. # ---------------------------------------------------------------------------
  80. def moving_avg_5d(series: list[float]) -> list[float]:
  81. """
  82. 计算5日滑动平均序列。
  83. 边界处用相邻年份数据补齐(首尾相接),保留2位小数。
  84. 返回列表与输入等长,索引 j 对应日序 j+1。
  85. """
  86. n = len(series)
  87. # 前补2个、后补4个(需要4个来覆盖最后两天的右边界窗口)
  88. padded = series[-2:] + series + series[:4]
  89. avg = []
  90. for i in range(n):
  91. vals = [padded[i + 2], padded[i + 3], padded[i + 4],
  92. padded[i + 5], padded[i + 6]]
  93. non_none = [v for v in vals if v is not None]
  94. if non_none:
  95. avg.append(round(sum(non_none) / len(non_none), 2))
  96. else:
  97. avg.append(None)
  98. return avg
  99. # ---------------------------------------------------------------------------
  100. # 4. 找到首次连续W天满足条件的起始索引(0-based)
  101. # ---------------------------------------------------------------------------
  102. def find_season_start(
  103. avg_list: list[float],
  104. threshold: float,
  105. direction: str,
  106. search_start_doy: int = 1,
  107. ) -> Optional[int]:
  108. """
  109. 在序列中找到首次连续5天满足条件的起始日序。
  110. direction='above': >= threshold(春/夏)
  111. direction='below': < threshold (秋/冬)
  112. search_start_doy: 从该日序开始搜索(避免跨年边界误判),默认从1开始。
  113. 返回日序(1-based)或 None。
  114. """
  115. streak = 0
  116. for j, v in enumerate(avg_list):
  117. doy = j + 1 # 1-based day of year
  118. if doy < search_start_doy:
  119. streak = 0
  120. continue
  121. if v is None:
  122. streak = 0
  123. continue
  124. if direction == "above" and v >= threshold:
  125. streak += 1
  126. elif direction == "below" and v < threshold:
  127. streak += 1
  128. else:
  129. streak = 0
  130. if streak == 5:
  131. return doy - 4 # convert to 1-based day-of-year of first day
  132. return None
  133. # ---------------------------------------------------------------------------
  134. # 5. 常年气候季节分析
  135. # ---------------------------------------------------------------------------
  136. def analyze_climate_season(
  137. city_data: dict[str, float],
  138. hist_years: list[str],
  139. ) -> dict:
  140. daily_avg = compute_clim_daily_avg(city_data, hist_years)
  141. avg_5d = moving_avg_5d(daily_avg)
  142. # 秋季搜索从夏季起始日后开始,冬季搜索从秋季起始日后开始
  143. autumn_start = (climate_ref_summer_doy or 1) + 1 if False else None
  144. # 由于我们还没有计算 climate_ref,用保守估计:
  145. # 秋季从夏季日后30天开始搜索(roughly June+30 = July),冬季从秋季日后30天开始
  146. summer_doy_val = find_season_start(avg_5d, 22.0, "above")
  147. autumn_doy_val = find_season_start(avg_5d, 22.0, "below", search_start_doy=(summer_doy_val or 1) + 30)
  148. winter_doy_val = find_season_start(avg_5d, 10.0, "below", search_start_doy=(autumn_doy_val or 1) + 30)
  149. return {
  150. "daily_avg": daily_avg,
  151. "avg_5d": avg_5d,
  152. "spring_doy": find_season_start(avg_5d, 10.0, "above"),
  153. "summer_doy": summer_doy_val,
  154. "autumn_doy": autumn_doy_val,
  155. "winter_doy": winter_doy_val,
  156. }
  157. # ---------------------------------------------------------------------------
  158. # 6. 当年季节起始日分析(含初判 & 二次判断)
  159. # ---------------------------------------------------------------------------
  160. def compute_current_year_avg_5d(
  161. city_data: dict[str, float],
  162. target_year: str,
  163. ) -> tuple[list[str], list[float]]:
  164. """
  165. 计算目标年每日5日滑动平均。
  166. 返回 (sorted_dates, avg_5d_list),avg_5d_list 与 sorted_dates 一一对应。
  167. """
  168. yd = {k: v for k, v in city_data.items() if k.startswith(target_year)}
  169. sorted_dates = sorted(yd.keys())
  170. temps = [yd[d] for d in sorted_dates]
  171. n = len(temps)
  172. # 首尾相接补齐
  173. padded = temps[-2:] + temps + temps[:4]
  174. avg_5d = []
  175. for i in range(n):
  176. vals = [padded[i + 2], padded[i + 3], padded[i + 4],
  177. padded[i + 5], padded[i + 6]]
  178. non_none = [v for v in vals if v is not None]
  179. if non_none:
  180. avg_5d.append(round(sum(non_none) / len(non_none), 2))
  181. else:
  182. avg_5d.append(None)
  183. return sorted_dates, avg_5d
  184. def find_all_season_starts(
  185. avg_list: list[float],
  186. threshold: float,
  187. direction: str,
  188. ) -> list[int]:
  189. """
  190. 找出所有首次连续5日满足条件的起始日期(0-based 索引)。
  191. 用于收集初判及所有二次/多次候选。
  192. """
  193. candidates = []
  194. streak = 0
  195. for j, v in enumerate(avg_list):
  196. if v is None:
  197. streak = 0
  198. continue
  199. if direction == "above" and v >= threshold:
  200. streak += 1
  201. elif direction == "below" and v < threshold:
  202. streak += 1
  203. else:
  204. streak = 0
  205. if streak == 5:
  206. candidates.append(j - 4) # 0-based index of first day
  207. streak = 0 # 不重叠,继续搜索
  208. return candidates
  209. def analyze_current_year(
  210. city_data: dict[str, float],
  211. target_year: str,
  212. climate_info: dict,
  213. ) -> dict:
  214. sorted_dates, avg_5d_curr = compute_current_year_avg_5d(city_data, target_year)
  215. n = len(sorted_dates)
  216. spring_candidates = find_all_season_starts(avg_5d_curr, 10.0, "above")
  217. summer_candidates = find_all_season_starts(avg_5d_curr, 22.0, "above")
  218. # 初判
  219. initial_spring = sorted_dates[spring_candidates[0]] if spring_candidates else None
  220. initial_summer = sorted_dates[summer_candidates[0]] if summer_candidates else None
  221. # 二次/多次候选(初判之后的所有候选)
  222. secondary_spring = [sorted_dates[i] for i in spring_candidates[1:]]
  223. secondary_summer = [sorted_dates[i] for i in summer_candidates[1:]]
  224. # 与常年对比
  225. ref_spring_doy = climate_info["spring_doy"]
  226. ref_summer_doy = climate_info["summer_doy"]
  227. spring_diff_days: Optional[int] = None
  228. summer_diff_days: Optional[int] = None
  229. if initial_spring and ref_spring_doy:
  230. ref_date = doy_to_date(ref_spring_doy, int(target_year))
  231. spring_diff_days = day_diff(ref_date, initial_spring)
  232. if initial_summer and ref_summer_doy:
  233. ref_date = doy_to_date(ref_summer_doy, int(target_year))
  234. summer_diff_days = day_diff(ref_date, initial_summer)
  235. # 最终判定(按 6.1.2 规则)
  236. final_spring = initial_spring
  237. final_summer = initial_summer
  238. spring_note = ""
  239. summer_note = ""
  240. if initial_spring and ref_spring_doy:
  241. if spring_diff_days > 30:
  242. if secondary_spring:
  243. final_spring = secondary_spring[0]
  244. spring_note = "(二次判断)"
  245. else:
  246. spring_note = "(无二次候选,保留初判)"
  247. if initial_summer and ref_summer_doy:
  248. if summer_diff_days > 30:
  249. if secondary_summer:
  250. final_summer = secondary_summer[0]
  251. summer_note = "(二次判断)"
  252. else:
  253. summer_note = "(无二次候选,保留初判)"
  254. # 数据完整性提示
  255. warnings = []
  256. last_date = sorted_dates[-1] if sorted_dates else ""
  257. if initial_spring is None and last_date <= f"{target_year}-08-31":
  258. warnings.append("数据截至8月底,春季尚未完成判定")
  259. if initial_summer is None and last_date <= f"{target_year}-08-31":
  260. warnings.append("数据截至8月底,夏季尚未完成判定")
  261. return {
  262. "target_year": target_year,
  263. "initial_spring": initial_spring,
  264. "initial_summer": initial_summer,
  265. "secondary_spring": secondary_spring,
  266. "secondary_summer": secondary_summer,
  267. "final_spring": final_spring,
  268. "final_summer": final_summer,
  269. "spring_diff_days": spring_diff_days,
  270. "summer_diff_days": summer_diff_days,
  271. "spring_note": spring_note,
  272. "summer_note": summer_note,
  273. "warnings": warnings,
  274. }
  275. # ---------------------------------------------------------------------------
  276. # 7. 输出
  277. # ---------------------------------------------------------------------------
  278. def print_results(all_cities: dict, hist_years: list[str], target_year: str):
  279. print("=" * 70)
  280. print(f" 气候季节起始日分析 | 历史年: {', '.join(hist_years)} | 目标年: {target_year}")
  281. print("=" * 70)
  282. for city in sorted(all_cities):
  283. city_data = all_cities[city]
  284. # 常年
  285. climate = analyze_climate_season(city_data, hist_years)
  286. c_spring = doy_to_date(climate["spring_doy"], 2023) if climate["spring_doy"] else "--"
  287. c_summer = doy_to_date(climate["summer_doy"], 2023) if climate["summer_doy"] else "--"
  288. c_autumn = doy_to_date(climate["autumn_doy"], 2023) if climate["autumn_doy"] else "--"
  289. c_winter = doy_to_date(climate["winter_doy"], 2023) if climate["winter_doy"] else "--"
  290. # 当年
  291. current = analyze_current_year(city_data, target_year, climate)
  292. print(f"\n【{city}】")
  293. print(f" 常年参考({','.join(hist_years)} 平均):")
  294. print(f" 春季起始日: {c_spring} 夏季起始日: {c_summer}")
  295. print(f" 秋季起始日: {c_autumn} 冬季起始日: {c_winter}")
  296. init_s = current["initial_spring"] or "--"
  297. init_sm = current["initial_summer"] or "--"
  298. # 初判信息
  299. diff_s_str = ""
  300. if current["spring_diff_days"] is not None:
  301. if current["spring_diff_days"] < 0:
  302. diff_s_str = f" (比常年早{abs(current['spring_diff_days'])}天,触发二次判断)"
  303. elif current["spring_diff_days"] <= 30:
  304. diff_s_str = f" (比常年晚{current['spring_diff_days']}天,初判有效)"
  305. else:
  306. diff_s_str = f" (比常年晚{current['spring_diff_days']}天,需进一步判断)"
  307. else:
  308. diff_s_str = " (无常年参考)"
  309. diff_sm_str = ""
  310. if current["summer_diff_days"] is not None:
  311. if current["summer_diff_days"] < 0:
  312. diff_sm_str = f" (比常年早{abs(current['summer_diff_days'])}天,触发二次判断)"
  313. elif current["summer_diff_days"] <= 30:
  314. diff_sm_str = f" (比常年晚{current['summer_diff_days']}天,初判有效)"
  315. else:
  316. diff_sm_str = f" (比常年晚{current['summer_diff_days']}天,需进一步判断)"
  317. else:
  318. diff_sm_str = " (无常年参考)"
  319. print(f" 当年法判定({target_year}):")
  320. print(f" 初判春季起始日: {init_s}{diff_s_str}")
  321. sec_spring_list = current["secondary_spring"]
  322. if sec_spring_list:
  323. shown = ', '.join(sec_spring_list[:5])
  324. suffix = f' 等{len(sec_spring_list)}个' if len(sec_spring_list) > 5 else ''
  325. print(f" 二次候选春季: {shown}{suffix}")
  326. else:
  327. print(f" 二次候选春季: (无)")
  328. print(f" 初判夏季起始日: {init_sm}{diff_sm_str}")
  329. sec_summer_list = current["secondary_summer"]
  330. if sec_summer_list:
  331. shown = ', '.join(sec_summer_list[:5])
  332. suffix = f' 等{len(sec_summer_list)}个' if len(sec_summer_list) > 5 else ''
  333. print(f" 二次候选夏季: {shown}{suffix}")
  334. else:
  335. print(f" 二次候选夏季: (无)")
  336. print(f" >> 最终判定 春季起始日: 【{current['final_spring'] or '--'}】{current['spring_note']}")
  337. print(f" >> 最终判定 夏季起始日: 【{current['final_summer'] or '--'}】{current['summer_note']}")
  338. for w in current["warnings"]:
  339. print(f" 提示: {w}")
  340. print("\n" + "=" * 70)
  341. # ---------------------------------------------------------------------------
  342. # 8. 导出常年参考(JSON)
  343. # ---------------------------------------------------------------------------
  344. def export_climate_reference(
  345. all_cities: dict,
  346. hist_years: list[str],
  347. out_path: str,
  348. ):
  349. result = {}
  350. for city, city_data in all_cities.items():
  351. climate = analyze_climate_season(city_data, hist_years)
  352. result[city] = {
  353. "spring_doy": climate["spring_doy"],
  354. "summer_doy": climate["summer_doy"],
  355. "autumn_doy": climate["autumn_doy"],
  356. "winter_doy": climate["winter_doy"],
  357. }
  358. with open(out_path, "w", encoding="utf-8") as f:
  359. json.dump(result, f, ensure_ascii=False, indent=2)
  360. print(f"常年参考已导出至: {out_path}")
  361. # ---------------------------------------------------------------------------
  362. # 主程序
  363. # ---------------------------------------------------------------------------
  364. if __name__ == "__main__":
  365. CSV_PATH = "/home/proc/new_mage/china_city_temperature_2020_2026.csv"
  366. HIST_YEARS = ["2020", "2021", "2022", "2023", "2024", "2025"]
  367. TARGET_YEAR = "2026"
  368. CLIMATE_OUT = "/home/proc/new_mage/climate_reference.json"
  369. print("正在加载数据...")
  370. all_cities = load_temperature_data(CSV_PATH)
  371. print(f"已加载 {len(all_cities)} 个城市\n")
  372. for c in sorted(all_cities):
  373. print(f" {c}: {len(all_cities[c])} 条记录")
  374. export_climate_reference(all_cities, HIST_YEARS, CLIMATE_OUT)
  375. print()
  376. print_results(all_cities, HIST_YEARS, TARGET_YEAR)
  377. print("\n完成。")