rsplot 0.2.6__tar.gz → 0.2.7__tar.gz

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 (33) hide show
  1. {rsplot-0.2.6 → rsplot-0.2.7}/PKG-INFO +4 -1
  2. {rsplot-0.2.6 → rsplot-0.2.7}/README.md +3 -0
  3. {rsplot-0.2.6 → rsplot-0.2.7}/pyproject.toml +1 -1
  4. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/cli.py +194 -25
  5. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/plotting/overlay.py +3 -3
  6. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/results.py +24 -0
  7. {rsplot-0.2.6 → rsplot-0.2.7}/.gitignore +0 -0
  8. {rsplot-0.2.6 → rsplot-0.2.7}/LICENSE +0 -0
  9. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/__init__.py +0 -0
  10. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/__main__.py +0 -0
  11. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/config.py +0 -0
  12. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/fnr.py +0 -0
  13. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/geo/__init__.py +0 -0
  14. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/geo/boundaries.py +0 -0
  15. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/geo/gridding.py +0 -0
  16. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/plotting/__init__.py +0 -0
  17. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/plotting/colormaps.py +0 -0
  18. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/plotting/fnr.py +0 -0
  19. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/plotting/raster.py +0 -0
  20. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/plotting/station.py +0 -0
  21. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/plotting/styles.py +0 -0
  22. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/__init__.py +0 -0
  23. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/base.py +0 -0
  24. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/era5_wind.py +0 -0
  25. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/forecast.py +0 -0
  26. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/guokong.py +0 -0
  27. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/tropomi_hcho.py +0 -0
  28. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/tropomi_no2.py +0 -0
  29. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/tropomi_o3.py +0 -0
  30. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/readers/tropomi_o3pr.py +0 -0
  31. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/recent.py +0 -0
  32. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/tiles/__init__.py +0 -0
  33. {rsplot-0.2.6 → rsplot-0.2.7}/src/rsplot/tiles/tianditu.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rsplot
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: CLI tool for plotting Remote Sensing data, designed for Agent.
5
5
  Project-URL: Repository, https://git.lug.ustc.edu.cn/yaoyhu/rsplot
6
6
  Project-URL: GitHub, https://github.com/yaoyhu/rsplot
@@ -340,6 +340,9 @@ rsplot forecast 安徽 2026063000 --lead 24 --var O3 -o forecast_anhui_o3.png
340
340
  # Date-only forecast input automatically selects the latest initialized hour.
341
341
  rsplot forecast 合肥 20260630 --lead 72 --var PM2.5 -o forecast_hefei_pm25.png
342
342
 
343
+ # Overlay station observations on the forecast valid time.
344
+ rsplot forecast 合肥 2026041409 --lead 6 --var O3 --station-overlay -o forecast_station_hefei_o3.png
345
+
343
346
  # Plot national monitoring station observations.
344
347
  rsplot station 合肥 2026041415 --var O3 -o station_hefei_o3.png
345
348
 
@@ -113,6 +113,9 @@ rsplot forecast 安徽 2026063000 --lead 24 --var O3 -o forecast_anhui_o3.png
113
113
  # Date-only forecast input automatically selects the latest initialized hour.
114
114
  rsplot forecast 合肥 20260630 --lead 72 --var PM2.5 -o forecast_hefei_pm25.png
115
115
 
116
+ # Overlay station observations on the forecast valid time.
117
+ rsplot forecast 合肥 2026041409 --lead 6 --var O3 --station-overlay -o forecast_station_hefei_o3.png
118
+
116
119
  # Plot national monitoring station observations.
117
120
  rsplot station 合肥 2026041415 --var O3 -o station_hefei_o3.png
118
121
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rsplot"
3
- version = "0.2.6"
3
+ version = "0.2.7"
4
4
  description = "CLI tool for plotting Remote Sensing data, designed for Agent."
5
5
  authors = [
6
6
  { name = "Yaoyao Hu", email = "yaoyhu@mail.ustc.edu.cn" },
@@ -445,6 +445,21 @@ def raster(
445
445
  emit_result(result, output)
446
446
 
447
447
 
448
+ # ---------------------------------------------------------------------------
449
+ # Forecast variable defaults that pair naturally with station observations.
450
+ # Used when the user asks for a forecast/station overlay but doesn't specify
451
+ # --station-var explicitly.
452
+ # ---------------------------------------------------------------------------
453
+ _FORECAST_STATION_PAIR: dict[str, str] = {
454
+ "pm25": "PM2.5",
455
+ "pm10": "PM10",
456
+ "so2": "SO2",
457
+ "no2": "NO2",
458
+ "co": "CO",
459
+ "o3": "O3",
460
+ }
461
+
462
+
448
463
  @app.command()
449
464
  def forecast(
450
465
  region: str = typer.Argument(
@@ -476,6 +491,26 @@ def forecast(
476
491
  "-d",
477
492
  help="预报数据目录 (覆盖配置中的 forecast 路径)",
478
493
  ),
494
+ station_overlay: bool = typer.Option(
495
+ False,
496
+ "--station-overlay",
497
+ help="叠加同有效时次的国控站点散点",
498
+ ),
499
+ station_var: str | None = typer.Option(
500
+ None,
501
+ "--station-var",
502
+ help="站点变量名;默认按预报变量自动匹配 PM2.5/PM10/SO2/NO2/CO/O3",
503
+ ),
504
+ station_data_dir: str | None = typer.Option(
505
+ None,
506
+ "--station-data-dir",
507
+ help="国控站点数据目录 (覆盖配置中的 guokong 路径)",
508
+ ),
509
+ station_datetime: str | None = typer.Option(
510
+ None,
511
+ "--station-datetime",
512
+ help="站点时次 YYYYMMDDHH 或 YYYYMMDD;默认使用预报有效时次",
513
+ ),
479
514
  output: str | None = typer.Option(
480
515
  None,
481
516
  "--output",
@@ -502,16 +537,23 @@ def forecast(
502
537
  示例:
503
538
  rsplot forecast 安徽 2026063000 --lead 24 --var O3
504
539
  rsplot forecast 合肥 20260630 --lead 72 --var PM2.5
540
+ rsplot forecast 合肥 2026041409 --lead 6 --var O3 --station-overlay
505
541
  """
506
542
  from pathlib import Path
507
543
 
508
- from rsplot.geo.boundaries import resolve_region
544
+ from rsplot.config import (
545
+ STATION_VAR_LABELS,
546
+ STATION_VAR_META,
547
+ get_exceedance_threshold,
548
+ )
549
+ from rsplot.geo.boundaries import _load_city, resolve_region
509
550
  from rsplot.geo.gridding import (
510
551
  grid_coverage_in_region,
511
552
  mask_to_region,
512
553
  smooth_grid,
513
554
  )
514
555
  from rsplot.plotting.colormaps import STATION_CONCENTRATION_CMAP
556
+ from rsplot.plotting.overlay import plot_overlay
515
557
  from rsplot.plotting.raster import plot_raster
516
558
  from rsplot.plotting.styles import setup_fonts
517
559
  from rsplot.readers.forecast import (
@@ -519,6 +561,7 @@ def forecast(
519
561
  get_forecast_var_info,
520
562
  read_forecast,
521
563
  )
564
+ from rsplot.readers.guokong import mask_stations_to_region, read_stations
522
565
  from rsplot.results import build_forecast_result, emit_result
523
566
 
524
567
  # --- 1. Load config ---
@@ -644,42 +687,159 @@ def forecast(
644
687
  raise typer.Exit(1)
645
688
  console.print(f" → 色标范围: {use_vmin:.1f} ~ {use_vmax:.1f}")
646
689
 
647
- # --- 6. Plot ---
690
+ # --- 6. Optional station overlay ---
691
+ station_data = None
692
+ station_resolved_dt = None
693
+ station_threshold = None
694
+ station_unit = ""
695
+ station_colorbar_label = ""
696
+ use_station_overlay = (
697
+ station_overlay
698
+ or station_var is not None
699
+ or station_data_dir is not None
700
+ or station_datetime is not None
701
+ )
702
+ if use_station_overlay:
703
+ use_station_var = station_var or _FORECAST_STATION_PAIR[var_info.name]
704
+ if use_station_var not in STATION_VAR_META:
705
+ available = ", ".join(STATION_VAR_META)
706
+ console.print(
707
+ f"[red]未知站点变量 '{use_station_var}'[/red]。"
708
+ f"可用: {available}"
709
+ )
710
+ raise typer.Exit(1)
711
+
712
+ try:
713
+ use_station_data_dir = (
714
+ station_data_dir
715
+ if station_data_dir is not None
716
+ else cfg.get_data_dir("guokong")
717
+ )
718
+ except ValueError as e:
719
+ console.print(f"[red]{e}[/red]")
720
+ raise typer.Exit(1) from e
721
+
722
+ use_station_datetime = station_datetime or forecast_data.valid_datetime
723
+ console.print(
724
+ f"\n[bold]读取站点数据:[/bold] {use_station_data_dir}"
725
+ )
726
+ console.print(
727
+ f" 时间: {use_station_datetime}, 变量: {use_station_var}"
728
+ )
729
+ try:
730
+ station_data, station_resolved_dt = read_stations(
731
+ use_station_data_dir,
732
+ use_station_datetime,
733
+ use_station_var,
734
+ extent=read_extent,
735
+ )
736
+ except (FileNotFoundError, KeyError, ValueError) as e:
737
+ console.print(f"[red]{e}[/red]")
738
+ raise typer.Exit(1) from e
739
+ station_data = mask_stations_to_region(station_data, info.geometry)
740
+ if station_resolved_dt != use_station_datetime:
741
+ console.print(
742
+ f" → 自动选择最新站点时次: [cyan]{station_resolved_dt}[/cyan]"
743
+ )
744
+ console.print(
745
+ f" → [green]{station_data.n_valid}[/green] 有效站点 "
746
+ f"(共 {station_data.n_stations} 站)"
747
+ )
748
+
749
+ station_threshold = get_exceedance_threshold(use_station_var)
750
+ if station_threshold is not None:
751
+ n_exceed = int(
752
+ np.sum(
753
+ station_data.values[np.isfinite(station_data.values)]
754
+ > station_threshold
755
+ )
756
+ )
757
+ console.print(
758
+ f" → 超标阈值: {station_threshold}, "
759
+ f"超标站点: [red]{n_exceed}[/red]"
760
+ )
761
+
762
+ station_unit = STATION_VAR_META[use_station_var][0]
763
+ station_display_var = STATION_VAR_LABELS.get(
764
+ use_station_var, use_station_var
765
+ )
766
+ station_colorbar_label = (
767
+ f"{station_display_var} ({station_unit})"
768
+ if station_unit
769
+ else station_display_var
770
+ )
771
+
772
+ # --- 7. Plot ---
648
773
  if output is None:
774
+ station_suffix = (
775
+ f"_station{station_data.var_name}"
776
+ if station_data is not None
777
+ else ""
778
+ )
649
779
  output = (
650
780
  f"/tmp/rsplot_forecast_{info.name}_{forecast_data.variable}_"
651
- f"{forecast_data.init_datetime}_f{forecast_data.lead_hour}.png"
781
+ f"{forecast_data.init_datetime}_f{forecast_data.lead_hour}"
782
+ f"{station_suffix}.png"
652
783
  )
653
784
  Path(output).parent.mkdir(parents=True, exist_ok=True)
654
785
 
655
786
  if title is None:
656
- title = (
657
- f"Forecast {forecast_data.label} - {info.name} "
658
- f"(valid {forecast_data.valid_datetime}, "
659
- f"+{forecast_data.lead_hour}h)"
660
- )
787
+ if station_data is None:
788
+ title = (
789
+ f"Forecast {forecast_data.label} - {info.name} "
790
+ f"(valid {forecast_data.valid_datetime}, "
791
+ f"+{forecast_data.lead_hour}h)"
792
+ )
793
+ else:
794
+ title = (
795
+ f"Forecast {forecast_data.label} + Station "
796
+ f"{station_data.var_name} - {info.name} "
797
+ f"(valid {forecast_data.valid_datetime}, "
798
+ f"+{forecast_data.lead_hour}h)"
799
+ )
661
800
 
662
801
  console.print("\n[bold]绘图中...[/bold]")
663
- plot_raster(
664
- forecast_data.lon,
665
- forecast_data.lat,
666
- grid_masked,
667
- info,
668
- dpi=use_dpi,
669
- cmap=use_cmap,
670
- vmin=use_vmin,
671
- vmax=use_vmax,
672
- basemap=use_basemap,
673
- tianditu_key=cfg.tianditu_key,
674
- hcho_cmap_file=cfg.hcho_cmap_file,
675
- title=title,
676
- output=output,
677
- colorbar_label=var_info.colorbar_label,
678
- )
802
+ if station_data is None:
803
+ plot_raster(
804
+ forecast_data.lon,
805
+ forecast_data.lat,
806
+ grid_masked,
807
+ info,
808
+ dpi=use_dpi,
809
+ cmap=use_cmap,
810
+ vmin=use_vmin,
811
+ vmax=use_vmax,
812
+ basemap=use_basemap,
813
+ tianditu_key=cfg.tianditu_key,
814
+ hcho_cmap_file=cfg.hcho_cmap_file,
815
+ title=title,
816
+ output=output,
817
+ colorbar_label=var_info.colorbar_label,
818
+ )
819
+ else:
820
+ plot_overlay(
821
+ forecast_data.lon,
822
+ forecast_data.lat,
823
+ grid_masked,
824
+ station_data,
825
+ info,
826
+ raster_cmap=use_cmap,
827
+ raster_vmin=use_vmin,
828
+ raster_vmax=use_vmax,
829
+ raster_label=var_info.colorbar_label,
830
+ station_threshold=station_threshold,
831
+ station_label=station_colorbar_label,
832
+ dpi=use_dpi,
833
+ basemap=use_basemap,
834
+ tianditu_key=cfg.tianditu_key,
835
+ hcho_cmap_file=cfg.hcho_cmap_file,
836
+ title=title,
837
+ output=output,
838
+ )
679
839
 
680
840
  console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
681
841
 
682
- # --- 7. Emit structured result for downstream agents ---
842
+ # --- 8. Emit structured result for downstream agents ---
683
843
  result = build_forecast_result(
684
844
  region=info,
685
845
  data=forecast_data,
@@ -689,6 +849,15 @@ def forecast(
689
849
  vmin=use_vmin,
690
850
  vmax=use_vmax,
691
851
  output=output,
852
+ station_data=station_data,
853
+ station_datetime=station_resolved_dt,
854
+ station_threshold=station_threshold,
855
+ station_unit=station_unit,
856
+ cities_gdf=(
857
+ _load_city(cfg.require_geojson_dir())
858
+ if station_data is not None
859
+ else None
860
+ ),
692
861
  )
693
862
  emit_result(result, output)
694
863
 
@@ -1,8 +1,8 @@
1
1
  """Combined raster + station overlay plotting.
2
2
 
3
- Satellite raster as the background colored field, 国控 station observations
4
- on top as colored dots. Each layer keeps its own colorbar so the two
5
- quantity scales stay independent.
3
+ A gridded raster layer (satellite or forecast) is drawn as the background
4
+ colored field, with 国控 station observations on top as colored dots. Each
5
+ layer keeps its own colorbar so the two quantity scales stay independent.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -201,6 +201,11 @@ def build_forecast_result(
201
201
  vmin: float,
202
202
  vmax: float,
203
203
  output: str,
204
+ station_data: StationData | None = None,
205
+ station_datetime: str | None = None,
206
+ station_threshold: float | None = None,
207
+ station_unit: str = "",
208
+ cities_gdf: gpd.GeoDataFrame | None = None,
204
209
  ) -> dict[str, Any]:
205
210
  """Build the JSON sidecar payload for a forecast run."""
206
211
  from rsplot.geo.gridding import grid_coverage_in_region
@@ -211,6 +216,11 @@ def build_forecast_result(
211
216
 
212
217
  result: dict[str, Any] = {
213
218
  "command": "forecast",
219
+ "mode": (
220
+ "forecast_station_overlay"
221
+ if station_data is not None
222
+ else "forecast"
223
+ ),
214
224
  "image": output,
215
225
  "region": {
216
226
  "name": region.name,
@@ -239,6 +249,20 @@ def build_forecast_result(
239
249
  },
240
250
  }
241
251
 
252
+ if station_data is not None:
253
+ station_block = build_station_result(
254
+ region=region,
255
+ data=station_data,
256
+ datetime_str=station_datetime or data.valid_datetime,
257
+ threshold=station_threshold,
258
+ unit=station_unit,
259
+ cities_gdf=cities_gdf,
260
+ output=output,
261
+ )
262
+ for key in ("command", "image", "region"):
263
+ station_block.pop(key, None)
264
+ result["station"] = station_block
265
+
242
266
  sub_gdf = region.sub_boundary_gdf
243
267
  if sub_gdf is not None and len(sub_gdf) > 0:
244
268
  result["spatial_summary"] = _grid_per_city_stats(LON, LAT, grid, sub_gdf)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes