openkarhydro 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. openkarhydro/__init__.py +33 -0
  2. openkarhydro/__main__.py +6 -0
  3. openkarhydro/__version__.py +1 -0
  4. openkarhydro/cli.py +489 -0
  5. openkarhydro/config.py +532 -0
  6. openkarhydro/data/__init__.py +0 -0
  7. openkarhydro/data/example_data.py +38 -0
  8. openkarhydro/data/soil_templates.py +30 -0
  9. openkarhydro/data/veg_templates.py +96 -0
  10. openkarhydro/forcing.py +425 -0
  11. openkarhydro/geometry/__init__.py +0 -0
  12. openkarhydro/geometry/pv_geometry.py +128 -0
  13. openkarhydro/io/__init__.py +0 -0
  14. openkarhydro/io/load_climate.py +130 -0
  15. openkarhydro/io/load_parameter_maps.py +322 -0
  16. openkarhydro/io/load_topography.py +318 -0
  17. openkarhydro/io/nwm_output.py +553 -0
  18. openkarhydro/io/postprocess.py +189 -0
  19. openkarhydro/io/state_io.py +146 -0
  20. openkarhydro/io/tbl_parser.py +281 -0
  21. openkarhydro/model.py +379 -0
  22. openkarhydro/panel/__init__.py +0 -0
  23. openkarhydro/panel/pv_bridge.py +90 -0
  24. openkarhydro/panel/pv_coordinator.py +245 -0
  25. openkarhydro/panel/pv_subgrid.py +354 -0
  26. openkarhydro/parallel/__init__.py +18 -0
  27. openkarhydro/parallel/config.py +19 -0
  28. openkarhydro/parallel/coordinator.py +676 -0
  29. openkarhydro/parallel/decompose.py +115 -0
  30. openkarhydro/parallel/halo.py +154 -0
  31. openkarhydro/parallel/shmem.py +159 -0
  32. openkarhydro/parallel/worker.py +301 -0
  33. openkarhydro/processes/__init__.py +0 -0
  34. openkarhydro/processes/canopy_hydrology.py +109 -0
  35. openkarhydro/processes/carbon_flux_veg.py +213 -0
  36. openkarhydro/processes/channel_routing.py +907 -0
  37. openkarhydro/processes/connectivity.py +213 -0
  38. openkarhydro/processes/energy_balance.py +152 -0
  39. openkarhydro/processes/evapotranspiration.py +178 -0
  40. openkarhydro/processes/groundwater.py +554 -0
  41. openkarhydro/processes/irradiance.py +90 -0
  42. openkarhydro/processes/params.py +112 -0
  43. openkarhydro/processes/phenology_main.py +99 -0
  44. openkarhydro/processes/radiation.py +153 -0
  45. openkarhydro/processes/rain_distribution.py +83 -0
  46. openkarhydro/processes/resistance_stomata_ballberry.py +215 -0
  47. openkarhydro/processes/runoff.py +684 -0
  48. openkarhydro/processes/runoff_surface.py +350 -0
  49. openkarhydro/processes/soil_hydraulic_property.py +59 -0
  50. openkarhydro/processes/soil_water_diffusion.py +350 -0
  51. openkarhydro/processes/soil_water_transpiration.py +147 -0
  52. openkarhydro/processes/vegetation.py +107 -0
  53. openkarhydro/results.py +47 -0
  54. openkarhydro/solver/__init__.py +349 -0
  55. openkarhydro/solver/balance.py +36 -0
  56. openkarhydro/solver/biochem.py +106 -0
  57. openkarhydro/solver/context.py +180 -0
  58. openkarhydro/solver/energy.py +118 -0
  59. openkarhydro/solver/groundwater.py +182 -0
  60. openkarhydro/solver/helpers.py +75 -0
  61. openkarhydro/solver/lsm_water.py +234 -0
  62. openkarhydro/solver/output.py +162 -0
  63. openkarhydro/solver/parallel.py +146 -0
  64. openkarhydro/solver/progress.py +164 -0
  65. openkarhydro/solver/pv.py +3 -0
  66. openkarhydro/solver/routing.py +226 -0
  67. openkarhydro/solver/step.py +355 -0
  68. openkarhydro/utils/__init__.py +0 -0
  69. openkarhydro/utils/array_utils.py +21 -0
  70. openkarhydro/utils/unit_conversion.py +19 -0
  71. openkarhydro/utils/validators.py +15 -0
  72. openkarhydro-0.1.0.dist-info/METADATA +75 -0
  73. openkarhydro-0.1.0.dist-info/RECORD +77 -0
  74. openkarhydro-0.1.0.dist-info/WHEEL +5 -0
  75. openkarhydro-0.1.0.dist-info/entry_points.txt +2 -0
  76. openkarhydro-0.1.0.dist-info/licenses/LICENSE +674 -0
  77. openkarhydro-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,33 @@
1
+ """OpenKarHydro — Solar Farm Hydrological Model
2
+
3
+ 光伏电站水文生态过程模拟的开源 Python 实现。
4
+ """
5
+
6
+ from openkarhydro.__version__ import __version__
7
+ from openkarhydro.model import SolarFarmModel
8
+ from openkarhydro.config import SimConfig, GridConfig, PanelConfig
9
+ from openkarhydro.config import SoilParams, VegParams, SiteConfig, TimeConfig
10
+ from openkarhydro.config import DistributedConfig
11
+ from openkarhydro.forcing import ForcingData
12
+ from openkarhydro.geometry.pv_geometry import (
13
+ PanelGeometry,
14
+ UniformPanelGeometry,
15
+ ArrayPanelGeometry,
16
+ )
17
+
18
+ __all__ = [
19
+ "__version__",
20
+ "SolarFarmModel",
21
+ "SimConfig",
22
+ "GridConfig",
23
+ "PanelConfig",
24
+ "SoilParams",
25
+ "VegParams",
26
+ "SiteConfig",
27
+ "TimeConfig",
28
+ "DistributedConfig",
29
+ "ForcingData",
30
+ "PanelGeometry",
31
+ "UniformPanelGeometry",
32
+ "ArrayPanelGeometry",
33
+ ]
@@ -0,0 +1,6 @@
1
+ """CLI 入口: python -m openkarhydro run/init/plot"""
2
+
3
+ from openkarhydro.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
openkarhydro/cli.py ADDED
@@ -0,0 +1,489 @@
1
+ """OpenKarHydro CLI"""
2
+
3
+ from pathlib import Path
4
+ import click
5
+ import numpy as np
6
+
7
+
8
+ def _get_config_dir(config_path: str) -> Path:
9
+ """获取 config.yaml 所在目录"""
10
+ return Path(config_path).resolve().parent
11
+
12
+
13
+ # ═══════════════════════════════════════════════════════════════
14
+ # init
15
+ # ═══════════════════════════════════════════════════════════════
16
+
17
+ CONFIG_TEMPLATE = """# ============================================================================
18
+ # OpenKarHydro 模型配置
19
+ # ============================================================================
20
+ # 所有模型参数和文件路径集中于此。修改参数无需改动 Python 代码。
21
+ #
22
+ # 运行命令: openkarhydro run config.yaml
23
+ # ============================================================================
24
+
25
+
26
+ # ============================================================================
27
+ # INPUT — 输入文件路径
28
+ # ============================================================================
29
+ # 路径相对于 config.yaml 所在目录。CLI 选项 (--dem/--pv/--forcing) 优先级更高。
30
+ input:
31
+ forcing: "inputs/forcing.csv" # [必需] 逐日气象数据 (CSV)
32
+ dem: "inputs/dem.tif" # [必需] 数字高程模型 (GeoTIFF)
33
+ pv_layout: "inputs/pv_layout.geojson" # [可选] PV 面板布局文件
34
+ soil_shrub: "" # [可选] 自定义非 PV 区土壤参数
35
+ soil_grass: "" # [可选] 自定义 PV 区土壤参数
36
+ veg_shrub: "" # [可选] 自定义非 PV 区植被参数
37
+ veg_grass: "" # [可选] 自定义 PV 区植被参数
38
+
39
+
40
+ # ============================================================================
41
+ # OUTPUT — 输出目录与格式
42
+ # ============================================================================
43
+ output:
44
+ dir: "outputs" # 结果输出目录
45
+ out_dt: 2592000 # 输出间隔(s), 默认30天 (out_day>0时被覆盖)
46
+ out_day: 1 # 每月第1天输出 (1-28, 0=按out_dt间隔)
47
+ geotiff: true # 导出 GeoTIFF
48
+ netcdf: true # 导出 NetCDF
49
+ timeseries: true # 导出时间序列 CSV
50
+
51
+
52
+ # ============================================================================
53
+ # SITE — 站点地理与环境参数
54
+ # ============================================================================
55
+ site:
56
+ name: "my_site" # 站点名称
57
+ latitude: 38.1 # 纬度 [°N](北正南负)
58
+ longitude: 106.2 # 经度 [°E](东正西负)
59
+ elevation: 1350 # 海拔 [m]
60
+ wind_speed: 2.0 # 年平均风速 [m/s]
61
+ obs_height: 2.0 # 气象观测高度 [m]
62
+ root_depth: 600 # 根系层深度 [mm]
63
+
64
+
65
+ # ============================================================================
66
+ # GRID — 空间网格参数
67
+ # ============================================================================
68
+ grid:
69
+ length: 100 # x 方向网格数 [—]
70
+ width: 100 # y 方向网格数 [—]
71
+ depth: 10 # 土壤剖面总深度 [cm]
72
+ dz: 10 # 垂向网格步长 [cm]
73
+ dx: 100 # x 方向水平分辨率 [cm]
74
+ dy: 100 # y 方向水平分辨率 [cm]
75
+
76
+
77
+ # ============================================================================
78
+ # TIME — 模拟时间参数
79
+ # ============================================================================
80
+ time:
81
+ start_date: "2023-01-01" # 模拟开始日期
82
+ end_date: "2023-12-31" # 模拟结束日期
83
+ pv_start_date: "2025-01-01" # PV 投运日期(不设则无 PV)
84
+ pv_end_date: "2025-07-01" # PV 运维开始 (不设则建设期长度为0)
85
+ rst_dt: 15768000 # 重启间隔 (s), 15768000≈半年(182.5d×86400)
86
+ rst_day: 0 # 每月固定日重启 (1-28), 0=用rst_dt间隔
87
+ growing_start: 91 # 生长季开始 [DOY](91=4月1日)
88
+ growing_end: 273 # 生长季结束 [DOY](273=9月30日)
89
+
90
+
91
+ # ============================================================================
92
+ # PANEL — 光伏面板参数
93
+ # ============================================================================
94
+ panel:
95
+ length: 2.0 # 面板长度 [m]
96
+ width: 1.0 # 面板宽度 [m]
97
+ spacing_area: 0.3 # 面板面积占比 [—]
98
+ tilt: 37.2 # 面板倾角 [°]
99
+ shading_angle: 0.384 # 遮阴角 [rad]
100
+ dz: 60.0 # 面板子网格垂向步长 [cm]
101
+
102
+
103
+ # ============================================================================
104
+ # SOIL / VEG — 土壤与植被参数
105
+ # ============================================================================
106
+ soil_type: loess_shrub # 非 PV 区土壤模板名
107
+ veg_type: shrub # 非 PV 区植被模板名
108
+ # 自定义参数文件: inputs/soil_shrub.yaml, veg_shrub.yaml
109
+
110
+
111
+ # ============================================================================
112
+ # DISTRIBUTED — 分布式参数(可选)
113
+ # ============================================================================
114
+ # 未配置时自动使用 soil_shrub/soil_grass 两套参数按 PV 区分。
115
+ # 配置后支持逐格独立土壤/植被参数(需与 DEM 同 CRS、同网格尺寸)。
116
+ distributed:
117
+ soil_map: "" # 土壤类型图 (GeoTIFF),整数值对应 soil_types
118
+ veg_map: "" # 植被类型图 (GeoTIFF),整数值对应 veg_types
119
+ soil_types: {} # {1: "loess_shrub", 2: "silt_loam"}
120
+ veg_types: {} # {1: "shrub", 2: "grass"}
121
+
122
+
123
+ # ============================================================================
124
+ # RICHARDS — 隐式 Richards 求解器
125
+ # ============================================================================
126
+ richards:
127
+ scheme: "implicit" # 求解方案 (目前仅支持 implicit)
128
+ num_iter: 3 # 子步数 (2~10, 大雨自动增至6)
129
+ bottom_boundary: "mmf" # 底边界: mmf(地下水耦合) | free_drain | zero
130
+ drain_slope: 1.0 # 排水坡度 (free_drain时用)
131
+
132
+
133
+ # ============================================================================
134
+ # GROUNDWATER — 侧向地下水流
135
+ # ============================================================================
136
+ groundwater:
137
+ enabled: true # 启用 MMF 8向 Darcy 侧向流 + QRF
138
+ klat_factor: 0.3 # 水平/垂向渗透率比
139
+ f_depth: 300.0 # 导水率衰减深度 [cm]
140
+ spec_yield: 0.2 # 给水度
141
+ update_interval: 5 # 侧向流更新间隔 [d]
142
+
143
+
144
+ # ============================================================================
145
+ # 模型开关
146
+ # ============================================================================
147
+ has_pv: true # 激活光伏面板 [true/false]
148
+ """
149
+
150
+
151
+ def _write_example_param(path, label):
152
+ """写入土壤/植被参数示例文件"""
153
+ if path.exists():
154
+ return
155
+ path.write_text(f"# {label}\n#\n"
156
+ f"# 将此文件重命名为 soil_shrub.yaml 即可生效。\n"
157
+ f"# 查看内置模板: openkarhydro.data.soil_templates.SOIL_TEMPLATES\n")
158
+
159
+
160
+ @click.command()
161
+ @click.argument("project_dir", default="my_project")
162
+ def init(project_dir):
163
+ """创建新项目
164
+
165
+ 生成 config.yaml 配置文件和 inputs/ 输入目录。
166
+ 用户需将 DEM、气象数据、PV 布局等文件放入 inputs/ 目录。
167
+ """
168
+ d = Path(project_dir)
169
+ d.mkdir(parents=True, exist_ok=True)
170
+
171
+ # config.yaml
172
+ config = d / "config.yaml"
173
+ if not config.exists():
174
+ config.write_text(CONFIG_TEMPLATE)
175
+ click.echo(f" 📄 {config.name}")
176
+ else:
177
+ click.echo(f" ⏭️ {config.name} 已存在,跳过")
178
+
179
+ # inputs/ 目录及示例参数文件
180
+ inputs_dir = d / "inputs"
181
+ inputs_dir.mkdir(exist_ok=True)
182
+ click.echo(f" 📁 {inputs_dir.name}/")
183
+
184
+ # 生成土壤/植被参数示例文件
185
+ _write_example_param(inputs_dir / "soil_shrub.yaml.example",
186
+ "预置土壤参数模板: loess_shrub (注释中的完整参数列表)")
187
+ _write_example_param(inputs_dir / "veg_shrub.yaml.example",
188
+ "预置植被参数模板: shrub (注释中的完整参数列表)")
189
+
190
+ click.echo(f"\n✅ 项目已创建: {d.resolve()}")
191
+ click.echo(" 编辑 config.yaml 中的 input 段指定输入文件路径,")
192
+ click.echo(" 然后执行: openkarhydro run config.yaml")
193
+ click.echo("")
194
+ click.echo(" 也可用 CLI 选项覆盖路径:")
195
+ click.echo(" openkarhydro run config.yaml --dem xxx.tif --forcing xxx.csv")
196
+
197
+
198
+ # ═══════════════════════════════════════════════════════════════
199
+ # run
200
+ # ═══════════════════════════════════════════════════════════════
201
+
202
+ @click.command()
203
+ @click.argument("config_path", default="config.yaml")
204
+ @click.option("--dem", default=None, help="DEM GeoTIFF 路径")
205
+ @click.option("--pv", default=None, help="PV 布局文件路径")
206
+ @click.option("--forcing", default=None, help="气象 CSV 路径")
207
+ @click.option("--output", "-o", default=None, help="输出目录")
208
+ @click.option("--years", "-y", default=None, type=int, help="模拟年数")
209
+ @click.option("--resume", default=None, help="从检查点续算(.npz 路径)")
210
+ @click.option("--parallel", "use_parallel", flag_value=True, default=None,
211
+ help="强制启用并行")
212
+ @click.option("--no-parallel", "use_parallel", flag_value=False,
213
+ help="强制串行")
214
+ @click.option("--n-tiles-x", default=0, type=int, help="X 方向瓦片数 (0=自动)")
215
+ @click.option("--n-tiles-y", default=0, type=int, help="Y 方向瓦片数 (0=自动)")
216
+ @click.option("--n-threads", default=0, type=int, help="每瓦片线程数 (0=自动)")
217
+ def run(config_path, dem, pv, forcing, output, years, resume, use_parallel, n_tiles_x, n_tiles_y, n_threads):
218
+ """运行模拟
219
+
220
+ 从 config.yaml 和 inputs/ 目录加载数据,执行三阶段模拟
221
+ (建设前→建设期→运营期),导出结果。
222
+ """
223
+ # 强制行缓冲(解决管道/重定向下输出被缓冲的问题)
224
+ import sys as _sys
225
+ if hasattr(_sys.stdout, 'reconfigure'):
226
+ _sys.stdout.reconfigure(line_buffering=True)
227
+
228
+ from openkarhydro.config import SimConfig
229
+ from openkarhydro.forcing import ForcingData
230
+ from openkarhydro.model import SolarFarmModel
231
+
232
+ click.echo("=" * 60)
233
+ click.echo("OpenKarHydro — 光伏电站水文模拟")
234
+ click.echo("=" * 60)
235
+
236
+ # ── 0. 加载原始 YAML(获取路径配置) ──
237
+ cfg_path = Path(config_path)
238
+ if not cfg_path.exists():
239
+ click.echo(f" ❌ 未找到: {cfg_path}")
240
+ return
241
+ project_dir = cfg_path.parent
242
+ import yaml
243
+ with open(cfg_path) as f:
244
+ raw_cfg = yaml.safe_load(f) or {}
245
+
246
+ in_paths = raw_cfg.get("input", {})
247
+ out_cfg = raw_cfg.get("output", {})
248
+
249
+ # 输入文件路径(CLI 选项 > config.yaml > 默认值)
250
+ def _resolve(path_str):
251
+ return str(project_dir / path_str) if path_str else None
252
+
253
+ forcing_file = (forcing or _resolve(in_paths.get("forcing")) or
254
+ str(project_dir / "inputs" / "forcing.csv"))
255
+ dem_file = (dem or _resolve(in_paths.get("dem")))
256
+ pv_file = (pv or _resolve(in_paths.get("pv_layout")))
257
+
258
+ # 输出目录(CLI 选项 > config.yaml > 默认值)
259
+ out_dir = (output or _resolve(out_cfg.get("dir")) or
260
+ str(project_dir / "outputs"))
261
+ # ── 1. 加载配置 ──
262
+ click.echo("\n[1/4] 加载配置...")
263
+ config = SimConfig.from_yaml(str(cfg_path), inputs_dir=project_dir / "inputs")
264
+ click.echo(f" 网格: {config.grid.Nx}×{config.grid.Ny}×{config.grid.Nz}")
265
+ pv_info = f"PV 投运: {config.time.pv_start_date}" if config.time.pv_start_date else "无 PV"
266
+ click.echo(f" 时间: {config.time.start_date} → {config.time.end_date} ({pv_info})")
267
+
268
+ # ── 2. 准备输入数据 ──
269
+ click.echo("\n[2/4] 加载输入数据...")
270
+
271
+ # 气象
272
+ if not Path(forcing_file).exists():
273
+ click.echo(f" ❌ 未找到气象数据: {forcing_file}")
274
+ click.echo(" 请将逐日气象 CSV 放入 input.forcing 指定的路径")
275
+ click.echo(" 格式: date, precip, tmax, tmin [, solar_radiation]")
276
+ return
277
+ forcing = ForcingData.load(forcing_file)
278
+ click.echo(f" 🌤️ 气象: {Path(forcing_file).name} ({forcing.precipitation.shape[1]}天)")
279
+
280
+ # 地形
281
+ topo = _load_topography(config, project_dir, dem_file, pv_file)
282
+ if topo is None:
283
+ return
284
+ click.echo(f" 🗺️ 地形: {topo.dem.shape[0]}×{topo.dem.shape[1]}")
285
+ if hasattr(topo, "pv_index") and topo.pv_index is not None and topo.pv_index.sum() > 0:
286
+ click.echo(f" ☀️ PV {topo.pv_index.sum()} 块 "
287
+ f"({100*topo.pv_index.sum()/topo.pv_index.size:.1f}%)")
288
+
289
+ # ── 3. 运行 ──
290
+ click.echo("\n[3/4] 运行模拟...")
291
+ # 并行配置: --parallel 强制开, --no-parallel 强制关, 不传跟随 config.yaml
292
+ if use_parallel is not None:
293
+ config.parallel.enabled = use_parallel
294
+ if n_tiles_x > 0:
295
+ config.parallel.n_tiles_x = n_tiles_x
296
+ if n_tiles_y > 0:
297
+ config.parallel.n_tiles_y = n_tiles_y
298
+ if n_threads > 0:
299
+ config.parallel.n_threads = n_threads
300
+
301
+ mode_str = "🚀 并行" if config.parallel.enabled else "🧵 串行"
302
+ click.echo(f" {mode_str} (配置: {config.parallel.n_tiles_x or "auto"}×{config.parallel.n_tiles_y or "auto"} 瓦片)")
303
+ model = SolarFarmModel(config, forcing, topography=topo)
304
+
305
+ import time
306
+ t0 = time.time()
307
+ results = model.run(
308
+ resume_from=resume,
309
+ output_dir=out_dir,
310
+ verbose=True,
311
+ parallel=config.parallel.enabled,
312
+ )
313
+ elapsed = time.time() - t0
314
+
315
+ # ── 4. 完成 ──
316
+ click.echo(f"\n[4/4] 完成!({elapsed:.1f}s)")
317
+ click.echo(f" 输出目录: {Path(out_dir).resolve()}")
318
+ click.echo(f" 最终 SMC: {results.state_variables['x'][1:-1,1:-1,1].mean():.4f}")
319
+
320
+
321
+ def _load_topography(config, project_dir, dem_path, pv_path):
322
+ """加载地形数据,返回 TopographyData"""
323
+ from openkarhydro.io.load_topography import (
324
+ load_dem_geotiff, load_pv_layout, load_topography_from_arrays,
325
+ )
326
+ inputs_dir = project_dir / "inputs"
327
+ dem_file = dem_path or (str(inputs_dir / "dem.tif") if (inputs_dir / "dem.tif").exists() else None)
328
+ pv_file = pv_path or _find_pv_file(inputs_dir)
329
+
330
+ if dem_file and Path(dem_file).exists():
331
+ dem, crs, transform, dem_nodata = load_dem_geotiff(dem_file)
332
+ L, W = dem.shape
333
+ else:
334
+ click.echo(f" ❌ 未找到 DEM: {dem_file or '无文件'}")
335
+ click.echo(" 请将 DEM GeoTIFF 放入 inputs/dem.tif")
336
+ return None
337
+
338
+ pv_index = _load_pv_index(pv_file, crs, transform, (L, W))
339
+
340
+ # NODATA 需转换到 cm(与 DEM 单位一致)
341
+ nodata_cm = dem_nodata * 100.0 if dem_nodata is not None else None
342
+ if nodata_cm is not None:
343
+ mask = (dem != nodata_cm)
344
+ else:
345
+ mask = np.ones(dem.shape, dtype=bool)
346
+ return load_topography_from_arrays(
347
+ dem, pv_index=pv_index, mask=mask, crs=crs, transform=transform,
348
+ nodata=dem_nodata,
349
+ )
350
+
351
+
352
+ def _find_pv_file(inputs_dir):
353
+ """在 inputs/ 目录下查找 PV 布局文件"""
354
+ if not inputs_dir.exists():
355
+ return None
356
+ for ext in [".tif", ".tiff", ".geojson", ".shp", ".npy", ".mat", ".csv"]:
357
+ files = list(inputs_dir.glob(f"pv_layout*{ext}"))
358
+ if files:
359
+ return str(files[0])
360
+ return None
361
+
362
+
363
+ def _load_pv_index(path, crs, transform, grid_shape):
364
+ """加载 PV 索引"""
365
+ if path is None:
366
+ return np.zeros(grid_shape, dtype=int)
367
+ try:
368
+ from openkarhydro.io.load_topography import load_pv_layout
369
+ return load_pv_layout(path, crs=crs, transform=transform, grid_shape=grid_shape)
370
+ except Exception as e:
371
+ click.echo(f" ⚠️ PV 布局加载失败: {e}")
372
+ return np.zeros(grid_shape, dtype=int)
373
+
374
+
375
+ # ═══════════════════════════════════════════════════════════════
376
+ # postprocess — SQLite + spatial_stats 生成
377
+ # ═══════════════════════════════════════════════════════════════
378
+
379
+ @click.command()
380
+ @click.argument("output_dir", default="outputs")
381
+ @click.option("--db", "-d", default=None,
382
+ help="SQLite 输出路径(默认 {output_dir}/results.sqlite)")
383
+ def postprocess(output_dir, db):
384
+ """后处理: 从 NC/CSV 生成 SQLite(时间序列 + 空间统计)
385
+
386
+ 读取 {output_dir} 目录中的 timeseries.csv 和 LDASOUT*.nc,
387
+ 写入 results.sqlite(daily/monthly/annual + spatial_stats 表)。
388
+
389
+ QGIS 中直接打开 LDASOUT*.nc 即可查看空间结果。
390
+ """
391
+ from openkarhydro.io.postprocess import postprocess as _run_postprocess
392
+ _run_postprocess(output_dir, sqlite_path=db)
393
+
394
+
395
+ # ═══════════════════════════════════════════════════════════════
396
+ # plot
397
+ # ═══════════════════════════════════════════════════════════════
398
+
399
+ @click.command()
400
+ @click.argument("input_path")
401
+ @click.option("--output", "-o", default=None)
402
+ @click.option("--var", "-v", default="all",
403
+ help="变量名(x/B/LAI/DEM 等),默认 all")
404
+ def plot(input_path, output, var):
405
+ """可视化 .npz 检查点或 .csv 时间序列"""
406
+ import matplotlib.pyplot as plt
407
+ p = Path(input_path)
408
+
409
+ if p.suffix == ".csv":
410
+ import pandas as pd
411
+ df = pd.read_csv(str(p))
412
+ numeric_cols = df.select_dtypes(include="number").columns
413
+ if var != "all":
414
+ numeric_cols = [c for c in numeric_cols if var in c]
415
+ fig, axes = plt.subplots(
416
+ len(numeric_cols[:6]), 1,
417
+ figsize=(10, 2 * len(numeric_cols[:6])),
418
+ sharex=True,
419
+ )
420
+ if len(numeric_cols[:6]) == 1:
421
+ axes = [axes]
422
+ for ax, col in zip(axes, numeric_cols[:6]):
423
+ ax.plot(df.index, df[col])
424
+ ax.set_ylabel(col)
425
+ ax.grid(True, alpha=0.3)
426
+ axes[-1].set_xlabel("Day")
427
+ fig.suptitle(p.stem, fontsize=12)
428
+ fig.tight_layout()
429
+
430
+ elif p.suffix == ".npz":
431
+ data = np.load(p, allow_pickle=True)
432
+ skip_keys = {"_crs", "_transform", "_pv_index"}
433
+ plot_keys = [k for k in data.keys()
434
+ if not k.startswith("_") and k not in skip_keys]
435
+ if var != "all" and var in data:
436
+ plot_keys = [var]
437
+ n_plots = min(len(plot_keys), 6)
438
+ fig, axes = plt.subplots(2, 3, figsize=(12, 8))
439
+ axes = axes.ravel()
440
+ for idx, k in enumerate(plot_keys[:6]):
441
+ v = data[k]
442
+ ax = axes[idx]
443
+ if v.ndim == 1 and v.size > 1:
444
+ ax.plot(v.ravel(), marker=".")
445
+ ax.set_title(k)
446
+ elif v.ndim == 2:
447
+ im = ax.imshow(v, cmap="YlOrRd", aspect="auto")
448
+ plt.colorbar(im, ax=ax)
449
+ ax.set_title(k)
450
+ elif v.ndim == 3:
451
+ if k == "B" and v.shape[-1] == 3:
452
+ im = ax.imshow(v[1:-1, 1:-1, :].sum(axis=2),
453
+ cmap="YlGn", aspect="auto")
454
+ plt.colorbar(im, ax=ax)
455
+ ax.set_title("Biomass (总)")
456
+ else:
457
+ im = ax.imshow(v[1:-1, 1:-1, 1], cmap="YlOrRd", aspect="auto")
458
+ plt.colorbar(im, ax=ax)
459
+ ax.set_title(f"{k} (层1)")
460
+ else:
461
+ ax.text(0.5, 0.5, f"{k}: shape={v.shape}", ha="center")
462
+ for idx in range(len(plot_keys[:6]), 6):
463
+ fig.delaxes(axes[idx])
464
+ fig.suptitle(p.stem, fontsize=12)
465
+ fig.tight_layout()
466
+ else:
467
+ click.echo(f"不支持: {p.suffix}")
468
+ return
469
+
470
+ if output:
471
+ plt.savefig(output, dpi=150)
472
+ plt.show()
473
+
474
+
475
+ # ═══════════════════════════════════════════════════════════════
476
+ # CLI group
477
+ # ═══════════════════════════════════════════════════════════════
478
+
479
+ @click.group()
480
+ def cli():
481
+ """OpenKarHydro — 光伏电站水文模拟"""
482
+
483
+
484
+ cli.add_command(init)
485
+ cli.add_command(run)
486
+ cli.add_command(postprocess)
487
+ cli.add_command(plot)
488
+
489
+