__init__.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. """Analysis layer (pipeline step 7).
  2. Reads ONLY the evaluation netCDF files in ``<work_dir>/evaluations/`` and writes
  3. artifacts to ``<work_dir>/analysis/``. Because analyses are pure functions of the
  4. schema-locked datasets, a rerun is cheap and can only overwrite regeneratable
  5. output -- never models or evaluations.
  6. See ``ai/ANALYSIS_PLAN.md`` for the scientific definitions behind each analysis.
  7. Adding an analysis:
  8. 1. create ``analysis/plots/<name>.py`` with a ``@register``-decorated function
  9. taking an :class:`~analysis.context.AnalysisContext`;
  10. 2. import it in ``analysis/plots/__init__.py``.
  11. """
  12. import pathlib as pl
  13. from typing import Any, Dict, Optional
  14. import xarray as xr
  15. from evaluation import load_evaluation
  16. from util.ui_logger import PipelineLogger
  17. EVAL_SUBDIR = "evaluations"
  18. ANALYSIS_SUBDIR = "analysis"
  19. __all__ = [
  20. "ANALYSIS_SUBDIR",
  21. "EVAL_SUBDIR",
  22. "get_analyses",
  23. "load_evaluations",
  24. "run_all",
  25. ]
  26. def get_analyses() -> Dict[str, Any]:
  27. """Every registered analysis, keyed by name.
  28. Importing ``analysis.plots`` is what runs the ``@register`` decorators, and
  29. it is done lazily (here and in :func:`run_all`) so that submodules can import
  30. from this package without forming an import cycle.
  31. """
  32. from analysis import plots as _plots # noqa: F401 (import for registration)
  33. from analysis.registry import ANALYSES
  34. return dict(ANALYSES)
  35. def load_evaluations(
  36. eval_dir: pl.Path, log: PipelineLogger
  37. ) -> Dict[str, xr.Dataset]:
  38. """Load every ``*.nc`` in ``eval_dir``, keyed by file stem."""
  39. datasets: Dict[str, xr.Dataset] = {}
  40. for nc in sorted(eval_dir.glob("*.nc")):
  41. try:
  42. ds = load_evaluation(str(nc))
  43. except Exception as exc: # noqa: BLE001 - report and skip a bad file
  44. log.error(f"Could not load evaluation '{nc.name}': {exc}")
  45. continue
  46. datasets[nc.stem] = ds
  47. log.info(f"Loaded evaluation '{nc.stem}' with dims {dict(ds.sizes)}.")
  48. return datasets
  49. def run_all(
  50. work_dir: pl.Path,
  51. log: PipelineLogger,
  52. config: Optional[Dict[str, Any]] = None,
  53. progress: Optional[Any] = None,
  54. ) -> None:
  55. """Run every registered analysis over ``work_dir``'s evaluations.
  56. Analyses are independent: one failing is reported and does not prevent the
  57. rest from running. Analyses declaring ``requires_noise`` are skipped with an
  58. explanatory message when no noise sweep is present.
  59. """
  60. # Lazy imports: keeps this module free of submodule dependencies at import
  61. # time, so `analysis.context` etc. can import from the package cleanly.
  62. from analysis import plots as _plots # noqa: F401 (import for registration)
  63. from analysis.context import AnalysisContext
  64. from analysis.registry import ANALYSES
  65. eval_dir = work_dir / EVAL_SUBDIR
  66. out_dir = work_dir / ANALYSIS_SUBDIR
  67. if not eval_dir.is_dir():
  68. log.error(
  69. f"No evaluations directory at {eval_dir}. Run an evaluation scenario "
  70. "first (or point the working directory at one that has evaluations)."
  71. )
  72. return
  73. datasets = load_evaluations(eval_dir, log)
  74. if not datasets:
  75. log.error(f"No evaluation .nc files found in {eval_dir}; nothing to analyze.")
  76. return
  77. out_dir.mkdir(parents=True, exist_ok=True)
  78. ctx = AnalysisContext(datasets, out_dir, log, config)
  79. try:
  80. if not ANALYSES:
  81. log.error("No analyses are registered; nothing to do.")
  82. return
  83. if progress is not None:
  84. progress.update(total=len(ANALYSES), advance=0)
  85. for spec in ANALYSES.values():
  86. if spec.requires_noise and not ctx.has_noise:
  87. log.info(
  88. f"Skipping '{spec.name}': needs a noise sweep, but no "
  89. "multi-noise-level evaluation was found."
  90. )
  91. if progress is not None:
  92. progress.update(advance=1)
  93. continue
  94. marker = f" [{', '.join(spec.tags)}]" if spec.tags else ""
  95. log.info(f"Running analysis: {spec.name}{marker} — {spec.title}")
  96. try:
  97. spec.fn(ctx)
  98. except NotImplementedError:
  99. log.info(f"'{spec.name}' is scaffolded but not implemented yet.")
  100. except Exception as exc: # noqa: BLE001 - keep other analyses running
  101. log.error(f"Analysis '{spec.name}' failed: {exc}")
  102. log.file_logger.error(f"Analysis '{spec.name}' failed", exc_info=True)
  103. if progress is not None:
  104. progress.update(advance=1)
  105. log.info(f"Analysis artifacts written to {out_dir}/.")
  106. finally:
  107. for ds in datasets.values():
  108. ds.close()