teehr 0.5.1.dev10__tar.gz → 0.5.3__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 (141) hide show
  1. {teehr-0.5.1.dev10 → teehr-0.5.3}/PKG-INFO +2 -2
  2. {teehr-0.5.1.dev10 → teehr-0.5.3}/pyproject.toml +2 -2
  3. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/__init__.py +1 -1
  4. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/fetch.py +2 -4
  5. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/metrics.py +33 -1
  6. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/nwm/grid_utils.py +7 -1
  7. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/nwm/nwm_grids.py +2 -1
  8. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/nwm/nwm_points.py +2 -1
  9. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/nwm/point_utils.py +18 -3
  10. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/loading/s3/clone_from_s3.py +23 -6
  11. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/loading/timeseries.py +1 -11
  12. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/metrics/deterministic_funcs.py +221 -11
  13. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/metrics/probabilistic_funcs.py +86 -0
  14. teehr-0.5.3/src/teehr/models/calculated_fields/row_level.py +938 -0
  15. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/calculated_fields/timeseries_aware.py +5 -4
  16. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/metrics/basemodels.py +13 -2
  17. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/metrics/bootstrap_models.py +0 -9
  18. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/metrics/deterministic_models.py +217 -0
  19. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/metrics/metric_attributes.py +84 -1
  20. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/metrics/probabilistic_models.py +36 -0
  21. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/querying/metric_format.py +9 -0
  22. teehr-0.5.1.dev10/src/teehr/models/calculated_fields/row_level.py +0 -478
  23. {teehr-0.5.1.dev10 → teehr-0.5.3}/LICENSE.md +0 -0
  24. {teehr-0.5.1.dev10 → teehr-0.5.3}/README.md +0 -0
  25. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/const.py +0 -0
  26. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/__init__.py +0 -0
  27. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/evaluation.py +0 -0
  28. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/generate.py +0 -0
  29. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/attribute_table.py +0 -0
  30. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/base_table.py +0 -0
  31. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/configuration_table.py +0 -0
  32. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/domain_table.py +0 -0
  33. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/joined_timeseries_table.py +0 -0
  34. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/location_attribute_table.py +0 -0
  35. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/location_crosswalk_table.py +0 -0
  36. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/location_table.py +0 -0
  37. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/primary_timeseries_table.py +0 -0
  38. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/secondary_timeseries_table.py +0 -0
  39. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/timeseries_table.py +0 -0
  40. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/unit_table.py +0 -0
  41. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/tables/variable_table.py +0 -0
  42. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/evaluation/utils.py +0 -0
  43. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/example_data/__init__.py +0 -0
  44. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/example_data/ensemble_example_data.py +0 -0
  45. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/example_data/nwm_gridded_example_data.py +0 -0
  46. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/example_data/nwm_streamflow_example_data.py +0 -0
  47. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/example_data/two_locations.py +0 -0
  48. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/example_data/v0_3_test_study.py +0 -0
  49. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/01-evaluation_setup.ipynb +0 -0
  50. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/02-table_queries.ipynb +0 -0
  51. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/03-metric_queries.ipynb +0 -0
  52. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/04-table_filters.ipynb +0 -0
  53. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/05-2_site_setup.ipynb +0 -0
  54. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/06-2_site_query.ipynb +0 -0
  55. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/07-2_site_plotting.ipynb +0 -0
  56. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/08-sql.ipynb +0 -0
  57. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/09-ensemble_metric_queries.ipynb +0 -0
  58. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/__init__.py +0 -0
  59. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/clone_from_s3.ipynb +0 -0
  60. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/load_netcdf.ipynb +0 -0
  61. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/ngen_example.py +0 -0
  62. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/partition_by_reference_time.py +0 -0
  63. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/setup_ensemble_example.py +0 -0
  64. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/setup_evaluation.py +0 -0
  65. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/setup_nwm_grid_example.py +0 -0
  66. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/setup_nwm_streamflow_example.py +0 -0
  67. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/examples/two_site_debug_script.py +0 -0
  68. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/__init__.py +0 -0
  69. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/const.py +0 -0
  70. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/nwm/__init__.py +0 -0
  71. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/nwm/retrospective_grids.py +0 -0
  72. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/nwm/retrospective_points.py +0 -0
  73. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/readme.md +0 -0
  74. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/usgs/__init__.py +0 -0
  75. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/usgs/usgs.py +0 -0
  76. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/fetching/utils.py +0 -0
  77. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/generate/utils.py +0 -0
  78. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/loading/__init__.py +0 -0
  79. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/loading/location_attributes.py +0 -0
  80. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/loading/location_crosswalks.py +0 -0
  81. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/loading/locations.py +0 -0
  82. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/loading/readme.md +0 -0
  83. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/loading/utils.py +0 -0
  84. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/metrics/__init__.py +0 -0
  85. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/metrics/bootstrap_funcs.py +0 -0
  86. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/metrics/gumboot_bootstrap.py +0 -0
  87. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/metrics/readme.md +0 -0
  88. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/metrics/signature_funcs.py +0 -0
  89. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/__init__.py +0 -0
  90. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/calculated_fields/base.py +0 -0
  91. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/__init__.py +0 -0
  92. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/nwm12_grid.py +0 -0
  93. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/nwm12_point.py +0 -0
  94. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/nwm20_grid.py +0 -0
  95. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/nwm20_point.py +0 -0
  96. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/nwm22_grid.py +0 -0
  97. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/nwm22_point.py +0 -0
  98. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/nwm30_grid.py +0 -0
  99. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/nwm30_point.py +0 -0
  100. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/fetching/utils.py +0 -0
  101. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/filters.py +0 -0
  102. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/generate/base.py +0 -0
  103. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/generate/timeseries_generator_models.py +0 -0
  104. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/metrics/__init__.py +0 -0
  105. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/metrics/signature_models.py +0 -0
  106. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/pandera_dataframe_schemas.py +0 -0
  107. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/pydantic_table_models.py +0 -0
  108. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/readme.md +0 -0
  109. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/str_enum.py +0 -0
  110. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/models/table_enums.py +0 -0
  111. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/querying/__init__.py +0 -0
  112. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/querying/filter_format.py +0 -0
  113. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/querying/readme.md +0 -0
  114. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/querying/utils.py +0 -0
  115. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/__init__.py +0 -0
  116. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/cache/readme.md +0 -0
  117. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/attributes/_readme.md +0 -0
  118. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/attributes/attributes.csv +0 -0
  119. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/configurations/_readme.md +0 -0
  120. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/configurations/configurations.csv +0 -0
  121. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/joined_timeseries/_readme.md +0 -0
  122. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/location_attributes/_readme.md +0 -0
  123. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/location_crosswalks/_readme.md +0 -0
  124. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/locations/_readme.md +0 -0
  125. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/primary_timeseries/_readme.md +0 -0
  126. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/secondary_timeseries/_readme.md +0 -0
  127. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/units/_readme.md +0 -0
  128. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/units/units.csv +0 -0
  129. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/variables/_readme.md +0 -0
  130. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/dataset/variables/variables.csv +0 -0
  131. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/gitignore_template +0 -0
  132. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/readme.md +0 -0
  133. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/scripts/__init__.py +0 -0
  134. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/template/scripts/user_defined_fields.py +0 -0
  135. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/utilities/__init__.py +0 -0
  136. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/utilities/generate_weights.py +0 -0
  137. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/utils/remove_spark_jars.py +0 -0
  138. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/utils/s3path.py +0 -0
  139. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/utils/utils.py +0 -0
  140. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/visualization/__init__.py +0 -0
  141. {teehr-0.5.1.dev10 → teehr-0.5.3}/src/teehr/visualization/dataframe_accessor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teehr
3
- Version: 0.5.1.dev10
3
+ Version: 0.5.3
4
4
  Summary: Tools for Exploratory Evaluation in Hydrologic Research
5
5
  License: GNU v3
6
6
  License-File: LICENSE.md
@@ -34,7 +34,7 @@ Requires-Dist: psutil (>=7.0.0,<8)
34
34
  Requires-Dist: pyarrow (>=15.0.0,<21)
35
35
  Requires-Dist: pydantic (>=2.4.2,<3)
36
36
  Requires-Dist: pyiceberg (>=0.9.1,<1)
37
- Requires-Dist: pyspark[pandas-on-spark] (>=4,<5)
37
+ Requires-Dist: pyspark[pandas-on-spark] (==4.0.1)
38
38
  Requires-Dist: rasterio (>=1.3.0,<2)
39
39
  Requires-Dist: rioxarray (>=0.15.0,<1)
40
40
  Requires-Dist: s3fs (>=2023.10.0,<2026)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "teehr"
3
- version = "0.5.1dev10"
3
+ version = "0.5.3"
4
4
  description = "Tools for Exploratory Evaluation in Hydrologic Research"
5
5
  authors = [
6
6
  "RTI International",
@@ -29,7 +29,7 @@ h5py = ">=3.12.1,<4"
29
29
  pyarrow = ">=15.0.0,<21"
30
30
  httpx = ">=0.25.1,<1"
31
31
  pandas = ">=2.2.0,<3"
32
- pyspark = {extras = ["pandas-on-spark"], version = ">=4,<5"}
32
+ pyspark = {extras = ["pandas-on-spark"], version = "4.0.1"}
33
33
  dataretrieval = ">=1.0.9,<2"
34
34
  numba = ">=0.60.0,<1"
35
35
  arch = ">=7.0.0,<8"
@@ -1,7 +1,7 @@
1
1
  """Initialize the TEEHR package."""
2
2
  import warnings
3
3
 
4
- __version__ = "0.5.1dev10"
4
+ __version__ = "0.5.3"
5
5
 
6
6
  with warnings.catch_warnings():
7
7
  warnings.simplefilter("ignore", UserWarning)
@@ -911,8 +911,7 @@ class Fetch:
911
911
  ),
912
912
  timeseries_type=timeseries_type,
913
913
  write_mode=write_mode,
914
- drop_duplicates=drop_duplicates,
915
- drop_overlapping_assimilation_values=drop_overlapping_assimilation_values # noqa
914
+ drop_duplicates=drop_duplicates
916
915
  )
917
916
 
918
917
  def nwm_operational_grids(
@@ -1215,6 +1214,5 @@ class Fetch:
1215
1214
  in_path=Path(self.nwm_cache_dir),
1216
1215
  timeseries_type=timeseries_type,
1217
1216
  write_mode=write_mode,
1218
- drop_duplicates=drop_duplicates,
1219
- drop_overlapping_assimilation_values=drop_overlapping_assimilation_values # noqa
1217
+ drop_duplicates=drop_duplicates
1220
1218
  )
@@ -239,11 +239,34 @@ class Metrics:
239
239
  """
240
240
  for model in include_metrics:
241
241
  if model.reference_configuration is not None:
242
+ """
242
243
  self.df = self._calculate_metric_skill_score(
243
244
  model.output_field_name,
244
245
  model.reference_configuration,
245
246
  group_by
246
247
  )
248
+ """
249
+ # 1) get the original cols ahead of skill score join
250
+ original_cols = self.df.columns
251
+ # 2) calculate skill score sdf
252
+ sdf = self._calculate_metric_skill_score(
253
+ model.output_field_name,
254
+ model.reference_configuration,
255
+ group_by
256
+ )
257
+ # 3) remove original metric column from skill score sdf
258
+ sdf = sdf.drop(model.output_field_name)
259
+ # 3) get join columns
260
+ join_cols = parse_fields_to_list(group_by)
261
+ # 4) join returned table back to self.df, trim
262
+ self.df = self.df.join(
263
+ sdf,
264
+ on=join_cols,
265
+ how="left"
266
+ ).select(
267
+ *original_cols,
268
+ F.col(f"{model.output_field_name}_skill_score")
269
+ )
247
270
 
248
271
  if model.unpack_results:
249
272
  self.df = model.unpack_function(
@@ -292,11 +315,20 @@ class Metrics:
292
315
  temp_col = f"{config}_{metric_field}_skill"
293
316
  pivot_sdf = pivot_sdf.withColumn(
294
317
  temp_col,
295
- 1 - F.col(config) / F.col(reference_configuration)
318
+ 1 - F.try_divide(F.col(config), F.col(reference_configuration))
296
319
  ).withColumn(
297
320
  "configuration_name",
298
321
  F.lit(config)
299
322
  )
323
+ # warn user if try_divide results in nulls (division by zero)
324
+ null_count = pivot_sdf.filter(F.col(temp_col).isNull()).count()
325
+ if null_count > 0:
326
+ logger.warning(
327
+ f"Division by zero encountered when calculating skill "
328
+ f"score for configuration '{config}' relative to "
329
+ f"reference configuration '{reference_configuration}'. "
330
+ f"{null_count} null values were produced."
331
+ )
300
332
  # Join skill score values from the pivot table.
301
333
  join_cols = group_by_strings + ["configuration_name"]
302
334
  sdf = sdf.join(
@@ -183,7 +183,8 @@ def fetch_and_format_nwm_grids(
183
183
  overwrite_output: bool,
184
184
  location_id_prefix: Union[str, None],
185
185
  variable_mapper: Dict[str, Dict[str, str]],
186
- timeseries_type: TimeseriesTypeEnum
186
+ timeseries_type: TimeseriesTypeEnum,
187
+ drop_overlapping_assimilation_values: bool
187
188
  ):
188
189
  """Compute weighted average, grouping by reference time.
189
190
 
@@ -243,6 +244,11 @@ def fetch_and_format_nwm_grids(
243
244
  Path(output_parquet_dir), f"{ref_time_str}.parquet"
244
245
  )
245
246
  z_hour_df.sort_values([LOCATION_ID, VALUE_TIME], inplace=True)
247
+
248
+ if drop_overlapping_assimilation_values and "assim" in nwm_configuration_name:
249
+ # Set reference_time to NaT for assimilation values
250
+ z_hour_df.loc[:, REFERENCE_TIME] = pd.NaT
251
+
246
252
  write_timeseries_parquet_file(
247
253
  filepath=parquet_filepath,
248
254
  overwrite_output=overwrite_output,
@@ -382,5 +382,6 @@ def nwm_grids_to_parquet(
382
382
  overwrite_output=overwrite_output,
383
383
  location_id_prefix=location_id_prefix,
384
384
  variable_mapper=variable_mapper,
385
- timeseries_type=timeseries_type
385
+ timeseries_type=timeseries_type,
386
+ drop_overlapping_assimilation_values=drop_overlapping_assimilation_values
386
387
  )
@@ -348,5 +348,6 @@ def nwm_to_parquet(
348
348
  overwrite_output,
349
349
  nwm_version,
350
350
  variable_mapper,
351
- timeseries_type
351
+ timeseries_type,
352
+ drop_overlapping_assimilation_values
352
353
  )
@@ -102,7 +102,8 @@ def process_chunk_of_files(
102
102
  overwrite_output: bool,
103
103
  nwm_version: str,
104
104
  variable_mapper: Dict[str, Dict[str, str]],
105
- timeseries_type: TimeseriesTypeEnum
105
+ timeseries_type: TimeseriesTypeEnum,
106
+ drop_overlapping_assimilation_values: bool
106
107
  ):
107
108
  """Assemble a table for a chunk of NWM files."""
108
109
  location_ids = np.array(location_ids).astype(int)
@@ -156,6 +157,12 @@ def process_chunk_of_files(
156
157
  end = f"{end_json[1]}T{end_json[3][1:3]}F{end_json[6][1:]}"
157
158
  filename = f"{start}_{end}.parquet"
158
159
 
160
+ if drop_overlapping_assimilation_values and "assim" in configuration:
161
+ # Set reference_time to NaT for assimilation values
162
+ df_output = output_table.to_pandas()
163
+ df_output.loc[:, REFERENCE_TIME] = pd.NaT
164
+ output_table = pa.Table.from_pandas(df_output, schema=schema)
165
+
159
166
  write_timeseries_parquet_file(
160
167
  Path(output_parquet_dir, filename),
161
168
  overwrite_output,
@@ -176,7 +183,8 @@ def fetch_and_format_nwm_points(
176
183
  overwrite_output: bool,
177
184
  nwm_version: str,
178
185
  variable_mapper: Dict[str, Dict[str, str]],
179
- timeseries_type: TimeseriesTypeEnum
186
+ timeseries_type: TimeseriesTypeEnum,
187
+ drop_overlapping_assimilation_values: bool
180
188
  ):
181
189
  """Fetch NWM point data and save as parquet files.
182
190
 
@@ -211,6 +219,12 @@ def fetch_and_format_nwm_points(
211
219
  they already exist. True = overwrite; False = fail.
212
220
  nwm_version : str
213
221
  Specified NWM version.
222
+ variable_mapper : Dict[str, Dict[str, str]]
223
+ A mapping dictionary for variable names and units.
224
+ timeseries_type : TimeseriesTypeEnum
225
+ The type of timeseries being processed.
226
+ drop_overlapping_assimilation_values : bool
227
+ Whether to drop assimilation values that overlap in value_time.
214
228
  """
215
229
  output_parquet_dir = Path(output_parquet_dir)
216
230
  if not output_parquet_dir.exists():
@@ -241,5 +255,6 @@ def fetch_and_format_nwm_points(
241
255
  overwrite_output,
242
256
  nwm_version,
243
257
  variable_mapper,
244
- timeseries_type
258
+ timeseries_type,
259
+ drop_overlapping_assimilation_values
245
260
  )
@@ -49,9 +49,22 @@ def subset_the_table(
49
49
  """Subset the dataset based on location and start/end time."""
50
50
  if table.name == "locations" and primary_location_ids is not None:
51
51
  sdf_in = sdf_in.filter(sdf_in.id.isin(primary_location_ids))
52
- elif table.name == "location_attributes" and primary_location_ids is not None:
52
+ # warn user if any primary_location_ids were excluded
53
+ available_ids = set(
54
+ sdf_in.select("id").rdd.flatMap(
55
+ lambda x: x).collect()
56
+ )
57
+ missing_ids = set(primary_location_ids) - available_ids
58
+ if len(missing_ids) > 0:
59
+ logger.warning(
60
+ "The following primary_location_ids were not found in the "
61
+ f"locations table and will be excluded: {missing_ids}"
62
+ )
63
+ elif table.name == "location_attributes" and \
64
+ primary_location_ids is not None:
53
65
  sdf_in = sdf_in.filter(sdf_in.location_id.isin(primary_location_ids))
54
- elif table.name == "location_crosswalks" and primary_location_ids is not None:
66
+ elif table.name == "location_crosswalks" and \
67
+ primary_location_ids is not None:
55
68
  sdf_in = sdf_in.filter(
56
69
  sdf_in.primary_location_id.isin(primary_location_ids)
57
70
  )
@@ -64,7 +77,8 @@ def subset_the_table(
64
77
  if primary_location_ids is not None:
65
78
  secondary_ids = (
66
79
  ev.location_crosswalks.to_sdf()
67
- .select("secondary_location_id").rdd.flatMap(lambda x: x).collect()
80
+ .select("secondary_location_id").rdd.flatMap(
81
+ lambda x: x).collect()
68
82
  )
69
83
  sdf_in = sdf_in.filter(sdf_in.location_id.isin(secondary_ids))
70
84
  elif table.name == "joined_timeseries":
@@ -119,7 +133,6 @@ def clone_from_s3(
119
133
 
120
134
  Note: future version will allow subsetting the tables to clone.
121
135
  """
122
-
123
136
  # Make the Evaluation directories
124
137
  logger.info(f"Creating directories for evaluation: {evaluation_name}")
125
138
  Path(ev.cache_dir).mkdir()
@@ -174,7 +187,10 @@ def clone_from_s3(
174
187
  logger.debug(f"Making directory {table.dir}")
175
188
  Path(table.dir).mkdir()
176
189
 
177
- logger.debug(f"Cloning {table.name} from {s3_dataset_path}/{table.name}/ to {table.dir}")
190
+ logger.debug(
191
+ f"Cloning {table.name} from {s3_dataset_path}/{table.name}/ to "
192
+ f"{table.dir}"
193
+ )
178
194
 
179
195
  sdf_in = table._read_files(
180
196
  path=f"{s3_dataset_path}/{table.name}/",
@@ -200,7 +216,8 @@ def clone_from_s3(
200
216
  dest = f"{ev.scripts_dir}/user_defined_fields.py"
201
217
  logger.debug(f"Copying from {source}/ to {dest}")
202
218
 
203
- # ToDo: there is a permission issue that prevents copying the entire directory.
219
+ # ToDo: there is a permission issue that prevents copying the entire
220
+ # directory.
204
221
  # This works for now.
205
222
  with fsspec.open(source, 'r', anon=True) as file:
206
223
  with open(dest, 'w') as f:
@@ -247,8 +247,7 @@ def validate_and_insert_timeseries(
247
247
  timeseries_type: str,
248
248
  pattern: str = "**/*.parquet",
249
249
  write_mode: TableWriteEnum = "append",
250
- drop_duplicates: bool = True,
251
- drop_overlapping_assimilation_values: bool = False
250
+ drop_duplicates: bool = True
252
251
  ):
253
252
  """Validate and insert primary timeseries data.
254
253
 
@@ -272,12 +271,6 @@ def validate_and_insert_timeseries(
272
271
  drop_duplicates : bool, optional (default: True)
273
272
  Whether to drop duplicates in the dataframe before writing
274
273
  to the table.
275
- drop_overlapping_assimilation_values: Optional[bool] = True
276
- Whether to drop overlapping assimilation values. Default is True.
277
- If True, values that overlap in value_time are dropped, keeping those with
278
- the most recent reference_time. In this case, all reference_time values
279
- are set to None. If False, overlapping values are kept and reference_time
280
- is retained.
281
274
  """ # noqa
282
275
  in_path = Path(in_path)
283
276
  logger.info(f"Validating and inserting timeseries data from {in_path}")
@@ -292,9 +285,6 @@ def validate_and_insert_timeseries(
292
285
  # Read the converted files to Spark DataFrame
293
286
  df = table._read_files(in_path, pattern)
294
287
 
295
- if drop_overlapping_assimilation_values:
296
- df = df.withColumn("reference_time", lit(None))
297
-
298
288
  # Validate using the _validate() method
299
289
  validated_df = table._validate(
300
290
  df=df,
@@ -18,7 +18,8 @@ def _transform(
18
18
  p: pd.Series,
19
19
  s: pd.Series,
20
20
  model: MetricsBasemodel,
21
- t: Optional[pd.Series] = None
21
+ t: Optional[pd.Series] = None,
22
+ threshold_series: Optional[pd.Series] = None
22
23
  ) -> tuple:
23
24
  """Apply timeseries transform for metrics calculations."""
24
25
  # Apply transform
@@ -31,25 +32,37 @@ def _transform(
31
32
  )
32
33
  p = p + EPSILON
33
34
  s = s + EPSILON
35
+ if threshold_series is not None:
36
+ threshold_series = threshold_series + EPSILON
34
37
  logger.debug("Applying log transform")
35
38
  p = np.log(p)
36
39
  s = np.log(s)
40
+ if threshold_series is not None:
41
+ threshold_series = np.log(threshold_series)
37
42
  case TransformEnum.sqrt:
38
43
  logger.debug("Applying square root transform")
39
44
  p = np.sqrt(p)
40
45
  s = np.sqrt(s)
46
+ if threshold_series is not None:
47
+ threshold_series = np.sqrt(threshold_series)
41
48
  case TransformEnum.square:
42
49
  logger.debug("Applying square transform")
43
50
  p = np.square(p)
44
51
  s = np.square(s)
52
+ if threshold_series is not None:
53
+ threshold_series = np.square(threshold_series)
45
54
  case TransformEnum.cube:
46
55
  logger.debug("Applying cube transform")
47
56
  p = np.power(p, 3)
48
57
  s = np.power(s, 3)
58
+ if threshold_series is not None:
59
+ threshold_series = np.power(threshold_series, 3)
49
60
  case TransformEnum.exp:
50
61
  logger.debug("Applying exponential transform")
51
62
  p = np.exp(p)
52
63
  s = np.exp(s)
64
+ if threshold_series is not None:
65
+ threshold_series = np.exp(threshold_series)
53
66
  case TransformEnum.inv:
54
67
  if model.add_epsilon:
55
68
  logger.debug(
@@ -57,13 +70,19 @@ def _transform(
57
70
  )
58
71
  p = p + EPSILON
59
72
  s = s + EPSILON
73
+ if threshold_series is not None:
74
+ threshold_series = threshold_series + EPSILON
60
75
  logger.debug("Applying inverse transform")
61
76
  p = 1.0 / p
62
77
  s = 1.0 / s
78
+ if threshold_series is not None:
79
+ threshold_series = 1.0 / threshold_series
63
80
  case TransformEnum.abs:
64
81
  logger.debug("Applying absolute value transform")
65
82
  p = np.abs(p)
66
83
  s = np.abs(s)
84
+ if threshold_series is not None:
85
+ threshold_series = np.abs(threshold_series)
67
86
  case _:
68
87
  raise ValueError(
69
88
  f"Unsupported transform: {model.transform}"
@@ -71,24 +90,41 @@ def _transform(
71
90
  else:
72
91
  logger.debug("No transform specified, using original values")
73
92
 
74
- # Remove invalid values and align series
75
- if t is not None:
76
- if isinstance(t, pd.Series):
93
+ # Remove invalid values and align series if transform applied
94
+ if model.transform is not None:
95
+ logger.debug("Removing invalid values and aligning series")
96
+ if (t is not None) and (threshold_series is not None):
97
+ valid_mask = np.isfinite(p) & np.isfinite(s)
98
+ p = p[valid_mask]
99
+ s = s[valid_mask]
100
+ t = t[valid_mask]
101
+ threshold_series = threshold_series[valid_mask]
102
+ elif t is not None:
77
103
  valid_mask = np.isfinite(p) & np.isfinite(s)
78
104
  p = p[valid_mask]
79
105
  s = s[valid_mask]
80
106
  t = t[valid_mask]
107
+ elif threshold_series is not None:
108
+ valid_mask = np.isfinite(p) & np.isfinite(s)
109
+ p = p[valid_mask]
110
+ s = s[valid_mask]
111
+ threshold_series = threshold_series[valid_mask]
81
112
  else:
82
- raise TypeError(
83
- "t must be a pandas Series, not {type(t)}"
113
+ valid_mask = np.isfinite(p) & np.isfinite(s)
114
+ p = p[valid_mask]
115
+ s = s[valid_mask]
116
+ logger.debug(
117
+ f"Removed {len(valid_mask) - np.sum(valid_mask)} invalid entries"
118
+ " from the transformed input series"
84
119
  )
85
- else:
86
- valid_mask = np.isfinite(p) & np.isfinite(s)
87
- p = p[valid_mask]
88
- s = s[valid_mask]
89
120
 
90
- if t is not None:
121
+ # return results
122
+ if (t is not None) and (threshold_series is not None):
123
+ return p, s, t, threshold_series
124
+ elif t is not None:
91
125
  return p, s, t
126
+ elif threshold_series is not None:
127
+ return p, s, threshold_series
92
128
  else:
93
129
  return p, s
94
130
 
@@ -659,3 +695,177 @@ def max_value_timedelta(model: MetricsBasemodel) -> Callable:
659
695
  return td.total_seconds()
660
696
 
661
697
  return max_value_timedelta_inner
698
+
699
+
700
+ # Categorical Metrics
701
+ def _validate_threshold_field(threshold_series: pd.Series) -> float:
702
+ """Validate threshold input field."""
703
+ unique_thresholds = threshold_series.unique()
704
+ if len(unique_thresholds) != 1:
705
+ raise ValueError(
706
+ "Threshold field must contain a single unique value for each"
707
+ " population grouping."
708
+ )
709
+ threshold = unique_thresholds[0]
710
+ return threshold
711
+
712
+
713
+ def confusion_matrix(model: MetricsBasemodel) -> Callable:
714
+ """Create the confusion_matrix metric function.
715
+
716
+ Returns counts of TP, TN, FP, FN as a dictionary.
717
+
718
+ :math:`TP=\\sum((prim>=threshold_{prim})\\ and\\ (sec>=threshold_{sec}))`
719
+ :math:`TN=\\sum((prim<threshold_{prim})\\ and\\ (sec<threshold_{sec}))`
720
+ :math:`FP=\\sum((prim<threshold_{prim})\\ and\\ (sec>=threshold_{sec}))`
721
+ :math:`FN=\\sum((prim>=threshold_{prim})\\ and\\ (sec<threshold_{sec}))`
722
+ """ # noqa
723
+ logger.debug("Building the confusion_matrix metric function")
724
+
725
+ def confusion_matrix_inner(p: pd.Series,
726
+ s: pd.Series,
727
+ threshold_series: pd.Series) -> dict:
728
+ """Confusion matrix counts."""
729
+ p, s, threshold_series = _transform(p,
730
+ s,
731
+ model,
732
+ None,
733
+ threshold_series)
734
+
735
+ threshold = _validate_threshold_field(threshold_series)
736
+
737
+ tp = np.sum((p >= threshold) & (s >= threshold))
738
+ tn = np.sum((p < threshold) & (s < threshold))
739
+ fp = np.sum((p < threshold) & (s >= threshold))
740
+ fn = np.sum((p >= threshold) & (s < threshold))
741
+ result = {"TP": tp, "TN": tn, "FP": fp, "FN": fn}
742
+
743
+ return result
744
+
745
+ return confusion_matrix_inner
746
+
747
+
748
+ def false_alarm_ratio(model: MetricsBasemodel) -> Callable:
749
+ """Create the false_alarm_ratio metric function.
750
+
751
+ :math:`FAR=\\frac{FP}{(TP+FP)}`
752
+ """ # noqa
753
+ logger.debug("Building the false_alarm_ratio metric function")
754
+
755
+ def false_alarm_ratio_inner(p: pd.Series,
756
+ s: pd.Series,
757
+ threshold_series: pd.Series) -> float:
758
+ """Calculate False alarm ratio."""
759
+ p, s, threshold_series = _transform(p,
760
+ s,
761
+ model,
762
+ None,
763
+ threshold_series)
764
+
765
+ threshold = _validate_threshold_field(threshold_series)
766
+
767
+ tp = np.sum((p >= threshold) & (s >= threshold))
768
+ fp = np.sum((p < threshold) & (s >= threshold))
769
+ if (tp + fp) == 0:
770
+ result = np.nan
771
+ else:
772
+ result = fp / (tp + fp)
773
+
774
+ return result
775
+
776
+ return false_alarm_ratio_inner
777
+
778
+
779
+ def probability_of_detection(model: MetricsBasemodel) -> Callable:
780
+ """Create the probability_of_detection metric function.
781
+
782
+ :math:`POD=\\frac{TP}{(TP+FN)}`
783
+ """ # noqa
784
+ logger.debug("Building the probability_of_detection metric function")
785
+
786
+ def probability_of_detection_inner(p: pd.Series,
787
+ s: pd.Series,
788
+ threshold_series: pd.Series) -> float:
789
+ """Probability of detection."""
790
+ p, s, threshold_series = _transform(p,
791
+ s,
792
+ model,
793
+ None,
794
+ threshold_series)
795
+
796
+ threshold = _validate_threshold_field(threshold_series)
797
+
798
+ tp = np.sum((p >= threshold) & (s >= threshold))
799
+ fn = np.sum((p >= threshold) & (s < threshold))
800
+ if (tp + fn) == 0:
801
+ result = np.nan
802
+ else:
803
+ result = tp / (tp + fn)
804
+
805
+ return result
806
+
807
+ return probability_of_detection_inner
808
+
809
+
810
+ def probability_of_false_detection(model: MetricsBasemodel) -> Callable:
811
+ """Create the probability_of_false_detection metric function.
812
+
813
+ :math:`POFD=\\frac{FP}{(FP+TN)}`
814
+ """ # noqa
815
+ logger.debug("Building the probability_of_false_detection metric function")
816
+
817
+ def probability_of_false_detection_inner(p: pd.Series,
818
+ s: pd.Series,
819
+ threshold_series: pd.Series
820
+ ) -> float:
821
+ """Probability of false detection."""
822
+ p, s, threshold_series = _transform(p,
823
+ s,
824
+ model,
825
+ None,
826
+ threshold_series)
827
+
828
+ threshold = _validate_threshold_field(threshold_series)
829
+
830
+ fp = np.sum((p < threshold) & (s >= threshold))
831
+ tn = np.sum((p < threshold) & (s < threshold))
832
+ if (fp + tn) == 0:
833
+ result = np.nan
834
+ else:
835
+ result = fp / (fp + tn)
836
+
837
+ return result
838
+
839
+ return probability_of_false_detection_inner
840
+
841
+
842
+ def critical_success_index(model: MetricsBasemodel) -> Callable:
843
+ """Create the critical_success_index metric function.
844
+
845
+ :math:`CSI=\\frac{TP}{(TP+FP+FN)}`
846
+ """ # noqa
847
+ logger.debug("Building the critical_success_index metric function")
848
+
849
+ def critical_success_index_inner(p: pd.Series,
850
+ s: pd.Series,
851
+ threshold_series: pd.Series) -> float:
852
+ """Critical success index."""
853
+ p, s, threshold_series = _transform(p,
854
+ s,
855
+ model,
856
+ None,
857
+ threshold_series)
858
+
859
+ threshold = _validate_threshold_field(threshold_series)
860
+
861
+ tp = np.sum((p >= threshold) & (s >= threshold))
862
+ fp = np.sum((p < threshold) & (s >= threshold))
863
+ fn = np.sum((p >= threshold) & (s < threshold))
864
+ if (tp + fp + fn) == 0:
865
+ result = np.nan
866
+ else:
867
+ result = tp / (tp + fp + fn)
868
+
869
+ return result
870
+
871
+ return critical_success_index_inner