swatplus-builder 0.4.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 (253) hide show
  1. swatplus_builder/__init__.py +26 -0
  2. swatplus_builder/artifacts/__init__.py +34 -0
  3. swatplus_builder/artifacts/hashing.py +49 -0
  4. swatplus_builder/artifacts/models.py +128 -0
  5. swatplus_builder/artifacts/store.py +198 -0
  6. swatplus_builder/autoresearch/__init__.py +47 -0
  7. swatplus_builder/autoresearch/loop.py +411 -0
  8. swatplus_builder/autoresearch/surrogate.py +557 -0
  9. swatplus_builder/calibration/__init__.py +52 -0
  10. swatplus_builder/calibration/bridge_diagnostics.py +339 -0
  11. swatplus_builder/calibration/calibrator.py +1172 -0
  12. swatplus_builder/calibration/diagnostic_calibrator.py +1025 -0
  13. swatplus_builder/calibration/forward.py +472 -0
  14. swatplus_builder/calibration/locked_benchmark.py +1433 -0
  15. swatplus_builder/calibration/nwis.py +61 -0
  16. swatplus_builder/calibration/pyswatplus_runtime.py +93 -0
  17. swatplus_builder/calibration/real_engine.py +467 -0
  18. swatplus_builder/calibration/report.py +476 -0
  19. swatplus_builder/calibration/sensitivity_screen.py +126 -0
  20. swatplus_builder/calibration/spotpy_adapter.py +144 -0
  21. swatplus_builder/cli.py +1751 -0
  22. swatplus_builder/config.py +72 -0
  23. swatplus_builder/db/__init__.py +1 -0
  24. swatplus_builder/db/mock_datasets.py +470 -0
  25. swatplus_builder/db/project.py +275 -0
  26. swatplus_builder/db/schema.py +367 -0
  27. swatplus_builder/db/seed.py +132 -0
  28. swatplus_builder/db/writer.py +415 -0
  29. swatplus_builder/diagnostics.py +822 -0
  30. swatplus_builder/editor/__init__.py +5 -0
  31. swatplus_builder/editor/api.py +726 -0
  32. swatplus_builder/editor/vendored/Pipfile +13 -0
  33. swatplus_builder/editor/vendored/actions/__init__.py +0 -0
  34. swatplus_builder/editor/vendored/actions/create_databases.py +119 -0
  35. swatplus_builder/editor/vendored/actions/get_swatplus_check.py +902 -0
  36. swatplus_builder/editor/vendored/actions/import_export_data.py +249 -0
  37. swatplus_builder/editor/vendored/actions/import_gis.py +1644 -0
  38. swatplus_builder/editor/vendored/actions/import_gis_legacy.py +2007 -0
  39. swatplus_builder/editor/vendored/actions/import_weather.py +1466 -0
  40. swatplus_builder/editor/vendored/actions/load_scenarios.py +97 -0
  41. swatplus_builder/editor/vendored/actions/read_output.py +432 -0
  42. swatplus_builder/editor/vendored/actions/read_output_legacy.py +350 -0
  43. swatplus_builder/editor/vendored/actions/reimport_gis.py +100 -0
  44. swatplus_builder/editor/vendored/actions/run_all.py +105 -0
  45. swatplus_builder/editor/vendored/actions/setup_project.py +154 -0
  46. swatplus_builder/editor/vendored/actions/update_datasets.py +485 -0
  47. swatplus_builder/editor/vendored/actions/update_project.py +1132 -0
  48. swatplus_builder/editor/vendored/actions/write_files.py +1098 -0
  49. swatplus_builder/editor/vendored/database/__init__.py +0 -0
  50. swatplus_builder/editor/vendored/database/datasets/__init__.py +0 -0
  51. swatplus_builder/editor/vendored/database/datasets/base.py +8 -0
  52. swatplus_builder/editor/vendored/database/datasets/basin.py +77 -0
  53. swatplus_builder/editor/vendored/database/datasets/change.py +10 -0
  54. swatplus_builder/editor/vendored/database/datasets/climate.py +29 -0
  55. swatplus_builder/editor/vendored/database/datasets/decision_table.py +41 -0
  56. swatplus_builder/editor/vendored/database/datasets/definitions.py +88 -0
  57. swatplus_builder/editor/vendored/database/datasets/hru_parm_db.py +165 -0
  58. swatplus_builder/editor/vendored/database/datasets/init.py +21 -0
  59. swatplus_builder/editor/vendored/database/datasets/lum.py +70 -0
  60. swatplus_builder/editor/vendored/database/datasets/ops.py +61 -0
  61. swatplus_builder/editor/vendored/database/datasets/setup.py +732 -0
  62. swatplus_builder/editor/vendored/database/datasets/soils.py +41 -0
  63. swatplus_builder/editor/vendored/database/datasets/structural.py +78 -0
  64. swatplus_builder/editor/vendored/database/lib.py +95 -0
  65. swatplus_builder/editor/vendored/database/project/__init__.py +0 -0
  66. swatplus_builder/editor/vendored/database/project/aquifer.py +80 -0
  67. swatplus_builder/editor/vendored/database/project/base.py +8 -0
  68. swatplus_builder/editor/vendored/database/project/basin.py +78 -0
  69. swatplus_builder/editor/vendored/database/project/change.py +147 -0
  70. swatplus_builder/editor/vendored/database/project/channel.py +146 -0
  71. swatplus_builder/editor/vendored/database/project/climate.py +110 -0
  72. swatplus_builder/editor/vendored/database/project/config.py +79 -0
  73. swatplus_builder/editor/vendored/database/project/connect.py +148 -0
  74. swatplus_builder/editor/vendored/database/project/decision_table.py +41 -0
  75. swatplus_builder/editor/vendored/database/project/dr.py +93 -0
  76. swatplus_builder/editor/vendored/database/project/exco.py +93 -0
  77. swatplus_builder/editor/vendored/database/project/gis.py +125 -0
  78. swatplus_builder/editor/vendored/database/project/gwflow.py +160 -0
  79. swatplus_builder/editor/vendored/database/project/hru.py +52 -0
  80. swatplus_builder/editor/vendored/database/project/hru_parm_db.py +172 -0
  81. swatplus_builder/editor/vendored/database/project/hydrology.py +62 -0
  82. swatplus_builder/editor/vendored/database/project/init.py +240 -0
  83. swatplus_builder/editor/vendored/database/project/link.py +21 -0
  84. swatplus_builder/editor/vendored/database/project/lum.py +74 -0
  85. swatplus_builder/editor/vendored/database/project/ops.py +61 -0
  86. swatplus_builder/editor/vendored/database/project/recall.py +35 -0
  87. swatplus_builder/editor/vendored/database/project/regions.py +134 -0
  88. swatplus_builder/editor/vendored/database/project/reservoir.py +105 -0
  89. swatplus_builder/editor/vendored/database/project/routing_unit.py +57 -0
  90. swatplus_builder/editor/vendored/database/project/salts.py +467 -0
  91. swatplus_builder/editor/vendored/database/project/setup.py +307 -0
  92. swatplus_builder/editor/vendored/database/project/simulation.py +114 -0
  93. swatplus_builder/editor/vendored/database/project/soils.py +53 -0
  94. swatplus_builder/editor/vendored/database/project/structural.py +78 -0
  95. swatplus_builder/editor/vendored/database/project/water_rights.py +49 -0
  96. swatplus_builder/editor/vendored/database/soils.py +207 -0
  97. swatplus_builder/editor/vendored/database/vardefs.py +50 -0
  98. swatplus_builder/editor/vendored/database/wgn.py +139 -0
  99. swatplus_builder/editor/vendored/fileio/__init__.py +0 -0
  100. swatplus_builder/editor/vendored/fileio/aquifer.py +124 -0
  101. swatplus_builder/editor/vendored/fileio/base.py +497 -0
  102. swatplus_builder/editor/vendored/fileio/basin.py +55 -0
  103. swatplus_builder/editor/vendored/fileio/change.py +405 -0
  104. swatplus_builder/editor/vendored/fileio/channel.py +178 -0
  105. swatplus_builder/editor/vendored/fileio/climate.py +261 -0
  106. swatplus_builder/editor/vendored/fileio/config.py +350 -0
  107. swatplus_builder/editor/vendored/fileio/connect.py +329 -0
  108. swatplus_builder/editor/vendored/fileio/decision_table.py +241 -0
  109. swatplus_builder/editor/vendored/fileio/dr.py +51 -0
  110. swatplus_builder/editor/vendored/fileio/exco.py +195 -0
  111. swatplus_builder/editor/vendored/fileio/gwflow.py +717 -0
  112. swatplus_builder/editor/vendored/fileio/hru.py +109 -0
  113. swatplus_builder/editor/vendored/fileio/hru_parm_db.py +177 -0
  114. swatplus_builder/editor/vendored/fileio/hydrology.py +100 -0
  115. swatplus_builder/editor/vendored/fileio/init.py +272 -0
  116. swatplus_builder/editor/vendored/fileio/lum.py +373 -0
  117. swatplus_builder/editor/vendored/fileio/ops.py +259 -0
  118. swatplus_builder/editor/vendored/fileio/recall.py +199 -0
  119. swatplus_builder/editor/vendored/fileio/regions.py +132 -0
  120. swatplus_builder/editor/vendored/fileio/reservoir.py +223 -0
  121. swatplus_builder/editor/vendored/fileio/routing_unit.py +136 -0
  122. swatplus_builder/editor/vendored/fileio/salts.py +1152 -0
  123. swatplus_builder/editor/vendored/fileio/simulation.py +284 -0
  124. swatplus_builder/editor/vendored/fileio/soils.py +114 -0
  125. swatplus_builder/editor/vendored/fileio/structural.py +243 -0
  126. swatplus_builder/editor/vendored/fileio/water_rights.py +170 -0
  127. swatplus_builder/editor/vendored/get-pip.py +32992 -0
  128. swatplus_builder/editor/vendored/helpers/executable_api.py +19 -0
  129. swatplus_builder/editor/vendored/helpers/table_mapper.py +186 -0
  130. swatplus_builder/editor/vendored/helpers/utils.py +193 -0
  131. swatplus_builder/editor/vendored/python-build-linux.sh +3 -0
  132. swatplus_builder/editor/vendored/python-build-mac.sh +3 -0
  133. swatplus_builder/editor/vendored/python-build-windows.bat +2 -0
  134. swatplus_builder/editor/vendored/rest/aquifer.py +340 -0
  135. swatplus_builder/editor/vendored/rest/auto_complete.py +261 -0
  136. swatplus_builder/editor/vendored/rest/basin.py +23 -0
  137. swatplus_builder/editor/vendored/rest/change.py +362 -0
  138. swatplus_builder/editor/vendored/rest/channel.py +461 -0
  139. swatplus_builder/editor/vendored/rest/climate.py +457 -0
  140. swatplus_builder/editor/vendored/rest/config.py +17 -0
  141. swatplus_builder/editor/vendored/rest/decision_table.py +296 -0
  142. swatplus_builder/editor/vendored/rest/defaults.py +608 -0
  143. swatplus_builder/editor/vendored/rest/definitions.py +60 -0
  144. swatplus_builder/editor/vendored/rest/gwflow.py +565 -0
  145. swatplus_builder/editor/vendored/rest/hru.py +306 -0
  146. swatplus_builder/editor/vendored/rest/hru_lte.py +212 -0
  147. swatplus_builder/editor/vendored/rest/hru_parm_db.py +336 -0
  148. swatplus_builder/editor/vendored/rest/hydrology.py +110 -0
  149. swatplus_builder/editor/vendored/rest/init.py +478 -0
  150. swatplus_builder/editor/vendored/rest/lum.py +504 -0
  151. swatplus_builder/editor/vendored/rest/ops.py +252 -0
  152. swatplus_builder/editor/vendored/rest/recall.py +331 -0
  153. swatplus_builder/editor/vendored/rest/regions.py +132 -0
  154. swatplus_builder/editor/vendored/rest/reservoir.py +723 -0
  155. swatplus_builder/editor/vendored/rest/routing_unit.py +248 -0
  156. swatplus_builder/editor/vendored/rest/salts.py +871 -0
  157. swatplus_builder/editor/vendored/rest/setup.py +458 -0
  158. swatplus_builder/editor/vendored/rest/soils.py +119 -0
  159. swatplus_builder/editor/vendored/rest/structural.py +212 -0
  160. swatplus_builder/editor/vendored/rest/water_rights.py +106 -0
  161. swatplus_builder/editor/vendored/swatplus_api.py +206 -0
  162. swatplus_builder/editor/vendored/swatplus_rest_api.py +73 -0
  163. swatplus_builder/errors.py +43 -0
  164. swatplus_builder/full_mode/__init__.py +0 -0
  165. swatplus_builder/full_mode/parameter_bridge.py +476 -0
  166. swatplus_builder/full_mode/routing_fixes.py +303 -0
  167. swatplus_builder/full_mode/topology_converter.py +367 -0
  168. swatplus_builder/full_mode/verify_conversion.py +246 -0
  169. swatplus_builder/full_mode/warmup.py +182 -0
  170. swatplus_builder/full_mode/water_balance_gate.py +354 -0
  171. swatplus_builder/gis/__init__.py +17 -0
  172. swatplus_builder/gis/complexity.py +130 -0
  173. swatplus_builder/gis/delineation.py +1172 -0
  174. swatplus_builder/gis/hru.py +910 -0
  175. swatplus_builder/gis/landuse.py +178 -0
  176. swatplus_builder/gis/nldi_fallback.py +72 -0
  177. swatplus_builder/gis/overlay_repair.py +286 -0
  178. swatplus_builder/gis/soil.py +574 -0
  179. swatplus_builder/gis/tables.py +653 -0
  180. swatplus_builder/gis/terrain.py +61 -0
  181. swatplus_builder/gis/topology.py +84 -0
  182. swatplus_builder/gis/validate.py +286 -0
  183. swatplus_builder/mcp/__init__.py +5 -0
  184. swatplus_builder/mcp/server.py +513 -0
  185. swatplus_builder/orchestrate.py +280 -0
  186. swatplus_builder/output/__init__.py +35 -0
  187. swatplus_builder/output/et_diagnostics.py +344 -0
  188. swatplus_builder/output/eval.py +682 -0
  189. swatplus_builder/output/mass_diagnostics.py +279 -0
  190. swatplus_builder/output/mass_trace.py +2496 -0
  191. swatplus_builder/output/metadata.py +85 -0
  192. swatplus_builder/output/metrics.py +279 -0
  193. swatplus_builder/output/plots/__init__.py +35 -0
  194. swatplus_builder/output/plots/fdc.py +50 -0
  195. swatplus_builder/output/plots/hydrograph.py +62 -0
  196. swatplus_builder/output/plots/residuals.py +51 -0
  197. swatplus_builder/output/plots/scatter.py +55 -0
  198. swatplus_builder/output/plots/seasonal.py +55 -0
  199. swatplus_builder/output/plots/soil.py +78 -0
  200. swatplus_builder/output/plots/spatial.py +107 -0
  201. swatplus_builder/output/plots/style.py +32 -0
  202. swatplus_builder/output/plots/utils.py +117 -0
  203. swatplus_builder/output/plots/wrapper.py +175 -0
  204. swatplus_builder/output/reader.py +305 -0
  205. swatplus_builder/output/realism.py +423 -0
  206. swatplus_builder/output/summary.py +202 -0
  207. swatplus_builder/output/volume_diagnostics.py +3109 -0
  208. swatplus_builder/output/weather_forcing.py +352 -0
  209. swatplus_builder/params/__init__.py +37 -0
  210. swatplus_builder/params/governance.py +219 -0
  211. swatplus_builder/params/registry.py +452 -0
  212. swatplus_builder/ref/__init__.py +38 -0
  213. swatplus_builder/ref/bootstrap.py +308 -0
  214. swatplus_builder/ref/catalog.py +99 -0
  215. swatplus_builder/run/__init__.py +18 -0
  216. swatplus_builder/run/swatplus.py +712 -0
  217. swatplus_builder/sensitivity.py +217 -0
  218. swatplus_builder/skills/__init__.py +17 -0
  219. swatplus_builder/skills/swatplus_playbook/README.md +21 -0
  220. swatplus_builder/skills/swatplus_playbook/__init__.py +13 -0
  221. swatplus_builder/skills/swatplus_playbook/rules.py +64 -0
  222. swatplus_builder/skills/swatplus_playbook/schemas.py +54 -0
  223. swatplus_builder/skills/swatplus_playbook/update.py +41 -0
  224. swatplus_builder/soil/__init__.py +53 -0
  225. swatplus_builder/soil/builder.py +157 -0
  226. swatplus_builder/soil/gnatsgo.py +763 -0
  227. swatplus_builder/soil/models.py +36 -0
  228. swatplus_builder/soil/params.py +330 -0
  229. swatplus_builder/soil/pc.py +242 -0
  230. swatplus_builder/soil/plot.py +167 -0
  231. swatplus_builder/soil/sda.py +277 -0
  232. swatplus_builder/soil/soilgrids.py +114 -0
  233. swatplus_builder/soil/writer.py +260 -0
  234. swatplus_builder/tools/__init__.py +18 -0
  235. swatplus_builder/tools/agent.py +159 -0
  236. swatplus_builder/types.py +518 -0
  237. swatplus_builder/validation/__init__.py +6 -0
  238. swatplus_builder/validation/runner.py +403 -0
  239. swatplus_builder/weather/__init__.py +29 -0
  240. swatplus_builder/weather/daymet.py +311 -0
  241. swatplus_builder/weather/gridmet.py +510 -0
  242. swatplus_builder/weather/synthetic.py +253 -0
  243. swatplus_builder/weather/wgn.py +33 -0
  244. swatplus_builder/weather/writer.py +369 -0
  245. swatplus_builder/workflows/__init__.py +5 -0
  246. swatplus_builder/workflows/contracts.py +108 -0
  247. swatplus_builder/workflows/full_build.py +343 -0
  248. swatplus_builder/workflows/usgs_e2e.py +2251 -0
  249. swatplus_builder-0.4.0.dist-info/METADATA +429 -0
  250. swatplus_builder-0.4.0.dist-info/RECORD +253 -0
  251. swatplus_builder-0.4.0.dist-info/WHEEL +4 -0
  252. swatplus_builder-0.4.0.dist-info/entry_points.txt +3 -0
  253. swatplus_builder-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,26 @@
1
+ """swatplus_builder — headless, agent-friendly generator for SWAT+ project inputs.
2
+
3
+ Public surface is intentionally tiny. See :mod:`swatplus_builder.tools` for the four
4
+ agent-facing functions, or :mod:`swatplus_builder.cli` for the Typer CLI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ __version__ = "0.4.0"
14
+
15
+ __all__ = ["__version__"]
16
+
17
+
18
+ def _ensure_writable_matplotlib_config_dir() -> None:
19
+ if os.environ.get("MPLCONFIGDIR"):
20
+ return
21
+ cfg = Path(tempfile.gettempdir()) / "swatplus_builder_mplconfig"
22
+ cfg.mkdir(parents=True, exist_ok=True)
23
+ os.environ["MPLCONFIGDIR"] = str(cfg)
24
+
25
+
26
+ _ensure_writable_matplotlib_config_dir()
@@ -0,0 +1,34 @@
1
+ """Typed run-artifact schemas and hashing helpers.
2
+
3
+ Phase 3B introduces a canonical artifact contract under ``runs/<content_hash>/``.
4
+ This package defines the typed JSON payloads and deterministic hash utilities
5
+ that power content-addressed caching.
6
+ """
7
+
8
+ from .models import (
9
+ ArtifactMetadata,
10
+ ArtifactQuery,
11
+ ArtifactMetrics,
12
+ ArtifactProvenance,
13
+ ArtifactRecord,
14
+ ArtifactSummary,
15
+ OutletMetadata,
16
+ RunConfig,
17
+ )
18
+ from .hashing import canonical_config_json, compute_content_hash
19
+ from .store import ArtifactStore, LocalArtifactStore
20
+
21
+ __all__ = [
22
+ "ArtifactMetadata",
23
+ "ArtifactQuery",
24
+ "ArtifactMetrics",
25
+ "ArtifactProvenance",
26
+ "ArtifactRecord",
27
+ "ArtifactSummary",
28
+ "ArtifactStore",
29
+ "LocalArtifactStore",
30
+ "OutletMetadata",
31
+ "RunConfig",
32
+ "canonical_config_json",
33
+ "compute_content_hash",
34
+ ]
@@ -0,0 +1,49 @@
1
+ """Deterministic content-hash utilities for run artifacts.
2
+
3
+ Content hash contract (Roadmap Appendix A):
4
+
5
+ SHA256(canonical_json(config) || engine_version || builder_git_sha)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+
13
+ from .models import RunConfig
14
+
15
+
16
+ def canonical_config_json(config: RunConfig | dict[str, object]) -> bytes:
17
+ """Return canonical UTF-8 JSON bytes for content hashing.
18
+
19
+ Canonicalization rules:
20
+ - sorted keys
21
+ - no extra whitespace
22
+ - UTF-8 encoding
23
+ """
24
+ payload: dict[str, object]
25
+ if isinstance(config, RunConfig):
26
+ payload = config.model_dump(mode="json")
27
+ else:
28
+ payload = RunConfig.model_validate(config).model_dump(mode="json")
29
+ txt = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
30
+ return txt.encode("utf-8")
31
+
32
+
33
+ def compute_content_hash(
34
+ config: RunConfig | dict[str, object],
35
+ *,
36
+ engine_version: str,
37
+ builder_git_sha: str,
38
+ ) -> str:
39
+ """Compute the Phase 3B content hash.
40
+
41
+ Failure modes:
42
+ - Raises `pydantic.ValidationError` when `config` is invalid.
43
+ """
44
+ h = hashlib.sha256()
45
+ h.update(canonical_config_json(config))
46
+ h.update(engine_version.encode("utf-8"))
47
+ h.update(builder_git_sha.encode("utf-8"))
48
+ return h.hexdigest()
49
+
@@ -0,0 +1,128 @@
1
+ """Pydantic schemas for Phase 3B run artifacts.
2
+
3
+ These models correspond to the JSON files defined in `ROADMAP.md` Appendix A:
4
+ `config.json`, `metadata.json`, `metrics.json`, and `provenance.json`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import date
10
+ from typing import Literal, TypeAlias
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ JsonScalar: TypeAlias = str | int | float | bool | None
15
+
16
+
17
+ class ParameterValue(BaseModel):
18
+ """One calibration parameter assignment within `config.parameters`."""
19
+
20
+ value: float = Field(..., description="Numeric parameter value.")
21
+ scope: Literal["global", "hru", "subbasin", "channel"] = Field(
22
+ ..., description="Application scope for this parameter value."
23
+ )
24
+
25
+
26
+ class RunConfig(BaseModel):
27
+ """Artifact `config.json` model used for content hashing."""
28
+
29
+ basin_id: str = Field(..., min_length=1)
30
+ bbox: tuple[float, float, float, float] | None = Field(
31
+ default=None,
32
+ description="Bounding box [minx, miny, maxx, maxy] in lon/lat.",
33
+ )
34
+ simulation_start: date = Field(...)
35
+ simulation_end: date = Field(...)
36
+ parameters: dict[str, ParameterValue] = Field(default_factory=dict)
37
+ options: dict[str, JsonScalar] = Field(default_factory=dict)
38
+
39
+
40
+ class OutletMetadata(BaseModel):
41
+ """Outlet selection metadata in `metadata.json`."""
42
+
43
+ gis_id: int | None = Field(default=None)
44
+ auto_detected: bool = Field(default=False)
45
+ reason: str | None = Field(default=None)
46
+
47
+
48
+ class ArtifactMetadata(BaseModel):
49
+ """Artifact `metadata.json` model (provenance, non-hash inputs)."""
50
+
51
+ run_id: str | None = Field(default=None, description="Content hash / run identifier.")
52
+ timestamp_utc: str = Field(..., description="UTC ISO-8601 timestamp.")
53
+ engine_version: str | None = Field(default=None)
54
+ builder_version: str | None = Field(default=None)
55
+ git_sha: str | None = Field(default=None)
56
+ outlet: OutletMetadata | None = Field(default=None)
57
+ soil_mode: Literal["high_fidelity", "fallback", "synthetic"] | None = Field(default=None)
58
+ pct_fallback_soils: float | None = Field(default=None, ge=0.0, le=1.0)
59
+ weather_coverage: dict[str, float] = Field(default_factory=dict)
60
+ n_subbasins: int | None = Field(default=None, ge=0)
61
+ n_hrus: int | None = Field(default=None, ge=0)
62
+ runtime_seconds: float | None = Field(default=None, ge=0.0)
63
+
64
+
65
+ class MetricsPeriod(BaseModel):
66
+ """Evaluation period included in `metrics.json`."""
67
+
68
+ start: date = Field(...)
69
+ end: date = Field(...)
70
+
71
+
72
+ class ArtifactMetrics(BaseModel):
73
+ """Artifact `metrics.json` model."""
74
+
75
+ outlet_id: int | None = Field(default=None)
76
+ period: MetricsPeriod | None = Field(default=None)
77
+ nse: float | None = Field(default=None)
78
+ log_nse: float | None = Field(default=None)
79
+ kge: float | None = Field(default=None)
80
+ pbias: float | None = Field(default=None)
81
+ bfi_observed: float | None = Field(default=None)
82
+ bfi_simulated: float | None = Field(default=None)
83
+ peak_flow_error_pct: float | None = Field(default=None)
84
+
85
+
86
+ class AgentContext(BaseModel):
87
+ """Agent context fields in `provenance.json`."""
88
+
89
+ agent_id: str | None = Field(default=None)
90
+ experiment_id: str | None = Field(default=None)
91
+
92
+
93
+ class ArtifactProvenance(BaseModel):
94
+ """Artifact `provenance.json` model."""
95
+
96
+ parent_run: str | None = Field(default=None)
97
+ proposal_source: str | None = Field(default=None)
98
+ agent_context: AgentContext | None = Field(default=None)
99
+
100
+
101
+ class ArtifactRecord(BaseModel):
102
+ """In-memory representation of one artifact record."""
103
+
104
+ content_hash: str = Field(..., min_length=8)
105
+ config: RunConfig
106
+ metadata: ArtifactMetadata
107
+ metrics: ArtifactMetrics | None = Field(default=None)
108
+ provenance: ArtifactProvenance | None = Field(default=None)
109
+
110
+
111
+ class ArtifactSummary(BaseModel):
112
+ """Lightweight listing payload used by `ArtifactStore.query()`."""
113
+
114
+ content_hash: str = Field(..., min_length=8)
115
+ basin_id: str = Field(..., min_length=1)
116
+ simulation_start: date = Field(...)
117
+ simulation_end: date = Field(...)
118
+ soil_mode: Literal["high_fidelity", "fallback", "synthetic"] | None = Field(default=None)
119
+ nse: float | None = Field(default=None)
120
+ parent_run: str | None = Field(default=None)
121
+
122
+
123
+ class ArtifactQuery(BaseModel):
124
+ """Optional filters for artifact query/list operations."""
125
+
126
+ basin_id: str | None = Field(default=None)
127
+ soil_mode: Literal["high_fidelity", "fallback", "synthetic"] | None = Field(default=None)
128
+ nse_min: float | None = Field(default=None)
@@ -0,0 +1,198 @@
1
+ """Local run-artifact storage (Phase 3B.2).
2
+
3
+ Directory contract:
4
+
5
+ <root>/runs/<content_hash>/
6
+ config.json
7
+ metadata.json
8
+ metrics.json (optional)
9
+ provenance.json (optional)
10
+ timeseries.parquet (optional)
11
+ plots/* (optional)
12
+ logs/* (optional)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import shutil
19
+ from abc import ABC, abstractmethod
20
+ from pathlib import Path
21
+ from typing import Iterable
22
+
23
+ from .models import (
24
+ ArtifactMetadata,
25
+ ArtifactMetrics,
26
+ ArtifactProvenance,
27
+ ArtifactQuery,
28
+ ArtifactRecord,
29
+ ArtifactSummary,
30
+ RunConfig,
31
+ )
32
+
33
+
34
+ class ArtifactStore(ABC):
35
+ """Abstract artifact store backend."""
36
+
37
+ @abstractmethod
38
+ def write(
39
+ self,
40
+ record: ArtifactRecord,
41
+ *,
42
+ timeseries_parquet: Path | None = None,
43
+ plot_files: Iterable[Path] = (),
44
+ log_files: Iterable[Path] = (),
45
+ ) -> Path:
46
+ """Persist one artifact record and optional payload files."""
47
+
48
+ @abstractmethod
49
+ def read(self, content_hash: str) -> ArtifactRecord:
50
+ """Read one artifact record by hash."""
51
+
52
+ @abstractmethod
53
+ def exists(self, content_hash: str) -> bool:
54
+ """Return true when artifact directory exists."""
55
+
56
+ @abstractmethod
57
+ def query(self, filters: ArtifactQuery | None = None) -> list[ArtifactSummary]:
58
+ """List artifact summaries, optionally filtered."""
59
+
60
+ @abstractmethod
61
+ def lineage(self, content_hash: str) -> list[str]:
62
+ """Return parent chain starting at `content_hash`."""
63
+
64
+
65
+ class LocalArtifactStore(ArtifactStore):
66
+ """Filesystem artifact store rooted at `<root>/runs`."""
67
+
68
+ def __init__(self, root: Path | str):
69
+ self.root = Path(root).expanduser().resolve()
70
+ self.runs_dir = self.root / "runs"
71
+ self.runs_dir.mkdir(parents=True, exist_ok=True)
72
+
73
+ def write(
74
+ self,
75
+ record: ArtifactRecord,
76
+ *,
77
+ timeseries_parquet: Path | None = None,
78
+ plot_files: Iterable[Path] = (),
79
+ log_files: Iterable[Path] = (),
80
+ ) -> Path:
81
+ run_dir = self._run_dir(record.content_hash)
82
+ run_dir.mkdir(parents=True, exist_ok=True)
83
+
84
+ self._write_json(run_dir / "config.json", record.config.model_dump(mode="json"))
85
+ self._write_json(run_dir / "metadata.json", record.metadata.model_dump(mode="json"))
86
+ if record.metrics is not None:
87
+ self._write_json(run_dir / "metrics.json", record.metrics.model_dump(mode="json"))
88
+ if record.provenance is not None:
89
+ self._write_json(run_dir / "provenance.json", record.provenance.model_dump(mode="json"))
90
+
91
+ if timeseries_parquet is not None:
92
+ dst = run_dir / "timeseries.parquet"
93
+ dst.parent.mkdir(parents=True, exist_ok=True)
94
+ shutil.copy2(timeseries_parquet, dst)
95
+
96
+ self._copy_many(plot_files, run_dir / "plots")
97
+ self._copy_many(log_files, run_dir / "logs")
98
+ return run_dir
99
+
100
+ def read(self, content_hash: str) -> ArtifactRecord:
101
+ run_dir = self._run_dir(content_hash)
102
+ config = RunConfig.model_validate(self._read_json(run_dir / "config.json"))
103
+ metadata = ArtifactMetadata.model_validate(self._read_json(run_dir / "metadata.json"))
104
+
105
+ metrics_path = run_dir / "metrics.json"
106
+ prov_path = run_dir / "provenance.json"
107
+ metrics = (
108
+ ArtifactMetrics.model_validate(self._read_json(metrics_path))
109
+ if metrics_path.exists()
110
+ else None
111
+ )
112
+ provenance = (
113
+ ArtifactProvenance.model_validate(self._read_json(prov_path))
114
+ if prov_path.exists()
115
+ else None
116
+ )
117
+ return ArtifactRecord(
118
+ content_hash=content_hash,
119
+ config=config,
120
+ metadata=metadata,
121
+ metrics=metrics,
122
+ provenance=provenance,
123
+ )
124
+
125
+ def exists(self, content_hash: str) -> bool:
126
+ return self._run_dir(content_hash).is_dir()
127
+
128
+ def query(self, filters: ArtifactQuery | None = None) -> list[ArtifactSummary]:
129
+ q = filters or ArtifactQuery()
130
+ out: list[ArtifactSummary] = []
131
+ for run_dir in sorted(self.runs_dir.iterdir()):
132
+ if not run_dir.is_dir():
133
+ continue
134
+ try:
135
+ record = self.read(run_dir.name)
136
+ except Exception:
137
+ continue
138
+ summary = ArtifactSummary(
139
+ content_hash=record.content_hash,
140
+ basin_id=record.config.basin_id,
141
+ simulation_start=record.config.simulation_start,
142
+ simulation_end=record.config.simulation_end,
143
+ soil_mode=record.metadata.soil_mode,
144
+ nse=record.metrics.nse if record.metrics is not None else None,
145
+ parent_run=record.provenance.parent_run if record.provenance is not None else None,
146
+ )
147
+ if not self._match(summary, q):
148
+ continue
149
+ out.append(summary)
150
+ return out
151
+
152
+ def lineage(self, content_hash: str) -> list[str]:
153
+ chain: list[str] = []
154
+ seen: set[str] = set()
155
+ current = content_hash
156
+ while current and current not in seen and self.exists(current):
157
+ seen.add(current)
158
+ chain.append(current)
159
+ record = self.read(current)
160
+ parent = record.provenance.parent_run if record.provenance is not None else None
161
+ if not parent:
162
+ break
163
+ current = parent
164
+ return chain
165
+
166
+ def _run_dir(self, content_hash: str) -> Path:
167
+ return self.runs_dir / content_hash
168
+
169
+ @staticmethod
170
+ def _write_json(path: Path, payload: dict[str, object]) -> None:
171
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
172
+
173
+ @staticmethod
174
+ def _read_json(path: Path) -> dict[str, object]:
175
+ return json.loads(path.read_text(encoding="utf-8"))
176
+
177
+ @staticmethod
178
+ def _copy_many(files: Iterable[Path], out_dir: Path) -> None:
179
+ copied = False
180
+ for src in files:
181
+ if not src.exists():
182
+ continue
183
+ if not copied:
184
+ out_dir.mkdir(parents=True, exist_ok=True)
185
+ copied = True
186
+ shutil.copy2(src, out_dir / src.name)
187
+
188
+ @staticmethod
189
+ def _match(summary: ArtifactSummary, q: ArtifactQuery) -> bool:
190
+ if q.basin_id is not None and summary.basin_id != q.basin_id:
191
+ return False
192
+ if q.soil_mode is not None and summary.soil_mode != q.soil_mode:
193
+ return False
194
+ if q.nse_min is not None:
195
+ if summary.nse is None or summary.nse < q.nse_min:
196
+ return False
197
+ return True
198
+
@@ -0,0 +1,47 @@
1
+ """Autoresearch loop primitives (Phase 3D)."""
2
+
3
+ from .loop import (
4
+ LoopIterationResult,
5
+ LoopRequest,
6
+ LoopResult,
7
+ LoopStoppingCriteria,
8
+ SurrogatePrediction,
9
+ run_autoresearch_loop,
10
+ )
11
+ from .surrogate import (
12
+ HoldoutEvaluationCase,
13
+ HoldoutEvaluationReport,
14
+ HoldoutEvaluationRequest,
15
+ RoutingDecision,
16
+ SurrogateEnsemble,
17
+ SurrogateMember,
18
+ SurrogatePredictionEstimate,
19
+ SurrogateTrainingRequest,
20
+ decide_routing_path,
21
+ evaluate_surrogate_holdout,
22
+ make_loop_surrogate_predictor,
23
+ predict_with_surrogate,
24
+ train_surrogate_ensemble,
25
+ )
26
+
27
+ __all__ = [
28
+ "LoopIterationResult",
29
+ "LoopRequest",
30
+ "LoopResult",
31
+ "LoopStoppingCriteria",
32
+ "SurrogatePrediction",
33
+ "run_autoresearch_loop",
34
+ "HoldoutEvaluationCase",
35
+ "HoldoutEvaluationReport",
36
+ "HoldoutEvaluationRequest",
37
+ "RoutingDecision",
38
+ "SurrogateEnsemble",
39
+ "SurrogateMember",
40
+ "SurrogatePredictionEstimate",
41
+ "SurrogateTrainingRequest",
42
+ "decide_routing_path",
43
+ "evaluate_surrogate_holdout",
44
+ "make_loop_surrogate_predictor",
45
+ "predict_with_surrogate",
46
+ "train_surrogate_ensemble",
47
+ ]