33def _capture_study_context(target: dict) -> list:
35 @brief Embed small study control-plane files with an individually archived member.
36 @param[in] target Value supplied through the `target` argument.
37 @return Result produced by this operation.
39 if target[
"artifact_type"] !=
"study-case":
41 study_root = Path(target[
"study_path"])
43 study_root /
"study.yml",
44 study_root /
"cluster.yml",
45 study_root /
"study_manifest.json",
46 study_root /
"scheduler" /
"case_index.tsv",
47 study_root /
"scheduler" /
"submission.json",
50 for path
in candidates:
51 if not path.is_file()
or path.stat().st_size > 5 * 1024 * 1024:
54 "path": path.relative_to(study_root).as_posix(),
55 "mode": int(path.stat().st_mode & 0o7777),
56 "content_base64": base64.b64encode(path.read_bytes()).decode(
"ascii"),
61def _capture_parameter_summary(root: str) -> dict:
63 @brief Capture the few case values that identify what a run actually solved.
65 @details `storage show` has to answer "which run was this?" months later without
66 restoring anything, and an id plus a label does not. These are read from
67 the run's own configuration snapshot, so they describe the run rather than
68 whatever the editable workspace says now.
69 @param[in] root Artifact root directory.
70 @return Parameter summary, empty when no readable case snapshot exists.
72 for relative
in ((
"config",
"case.yml"), (
"cases",
"case_0000",
"config",
"case.yml")):
73 case_path = os.path.join(root, *relative)
74 if os.path.isfile(case_path):
79 with open(case_path,
"r", encoding=
"utf-8")
as stream:
80 case = yaml.safe_load(stream)
or {}
81 except (OSError, ValueError):
83 if not isinstance(case, dict):
85 properties = case.get(
"properties")
or {}
86 scaling = properties.get(
"scaling")
or {}
87 fluid = properties.get(
"fluid")
or {}
88 grid = case.get(
"grid")
or {}
89 run_control = case.get(
"run_control")
or {}
91 "title": case.get(
"title"),
92 "grid_mode": grid.get(
"mode"),
93 "grid_dimensions":
None,
94 "blocks": ((case.get(
"models")
or {}).get(
"domain")
or {}).get(
"blocks"),
95 "start_step": run_control.get(
"start_step"),
96 "total_steps": run_control.get(
"total_steps"),
97 "dt_physical": run_control.get(
"dt_physical"),
98 "length_ref": scaling.get(
"length_ref"),
99 "velocity_ref": scaling.get(
"velocity_ref"),
100 "density": fluid.get(
"density"),
101 "viscosity": fluid.get(
"viscosity"),
104 programmatic = grid.get(
"programmatic_settings")
105 if isinstance(programmatic, dict):
106 dims = [programmatic.get(key)
for key
in (
"im",
"jm",
"km")]
107 if all(isinstance(value, int)
for value
in dims):
108 summary[
"grid_dimensions"] = dims
110 summary[
"reynolds"] = (
111 float(fluid[
"density"]) * float(scaling[
"velocity_ref"])
112 * float(scaling[
"length_ref"]) / float(fluid[
"viscosity"])
114 except (KeyError, TypeError, ValueError, ZeroDivisionError):
115 summary[
"reynolds"] =
None
116 return {key: value
for key, value
in summary.items()
if value
is not None}
119def _capture_workspace_assets(target: dict) -> list:
121 @brief List the asset ids a workspace archive carries.
122 @param[in] target Local artifact target.
123 @return Sorted asset ids, empty for non-workspace artifacts.
125 if target[
"artifact_type"] !=
"workspace":
127 objects_root = os.path.join(target[
"root_path"],
"assets",
"objects")
128 if not os.path.isdir(objects_root):
131 for kind
in sorted(os.listdir(objects_root)):
132 kind_root = os.path.join(objects_root, kind)
133 if os.path.isdir(kind_root):
135 name
for name
in sorted(os.listdir(kind_root))
136 if os.path.isdir(os.path.join(kind_root, name))
141def _capture_run_assets(root: str) -> list:
143 @brief Record reusable asset references carried by run-local input snapshots.
144 @param[in] root Run or study artifact root.
145 @return Portable asset references for the remote archive catalog.
148 archive_root = Path(os.path.abspath(root))
149 for runtime_root_text
in _artifact_runtime_roots(root):
150 runtime_root = Path(runtime_root_text)
151 lock_path = runtime_root /
"inputs" /
"assets.lock.yml"
152 if not lock_path.is_file():
155 payload = yaml.safe_load(lock_path.read_text(encoding=
"utf-8"))
or {}
156 except (OSError, ValueError, TypeError):
158 assets = payload.get(
"assets")
if isinstance(payload, dict)
else None
159 if not isinstance(assets, dict):
161 runtime_prefix = runtime_root.relative_to(archive_root).as_posix()
162 for kind, reference
in sorted(assets.items()):
163 if not isinstance(reference, dict):
165 files = reference.get(
"files")
or reference.get(
"exposed")
or []
166 normalized_files = []
168 if not isinstance(item, dict)
or not isinstance(item.get(
"path"), str):
170 run_path = item[
"path"]
172 f
"{runtime_prefix}/{run_path}" if runtime_prefix !=
"." else run_path
174 normalized_files.append({
176 "archived_path": archived_path,
177 "bytes": item.get(
"bytes"),
178 "sha256": item.get(
"sha256"),
182 "asset_id": reference.get(
"asset_id"),
183 "provider": reference.get(
"provider"),
184 "provider_spec_sha256": reference.get(
"provider_spec_sha256"),
185 "files": normalized_files,
190def _config_fingerprints(root: str) -> dict:
192 @brief Hash small canonical YAML inputs stored under an artifact.
193 @param[in] root Value supplied through the `root` argument.
194 @return Result produced by this operation.
197 root_path = Path(root)
198 candidates = set(root_path.glob(
"config/*.yml"))
199 candidates.update(root_path.glob(
"base_configs/*.yml"))
200 candidates.update(root_path.glob(
"cases/case_*/config/*.yml"))
201 for path
in sorted(candidates):
202 result[path.relative_to(root).as_posix()] = _sha256_file(str(path))
203 for name
in (
"study.yml",
"cluster.yml"):
204 path = root_path / name
206 result[name] = _sha256_file(str(path))
233def _restore_study_context(manifest: dict, case_destination: str) ->
None:
235 @brief Recreate missing study control-plane files around a restored member.
236 @param[in] manifest Value supplied through the `manifest` argument.
237 @param[in] case_destination Value supplied through the `case_destination` argument.
239 if manifest.get(
"artifact_type") !=
"study-case":
241 study_root = Path(case_destination).parent.parent
242 for item
in manifest.get(
"study_context", []):
243 relative = item.get(
"path")
244 if not isinstance(relative, str):
246 destination = study_root.joinpath(*relative.split(
"/"))
247 if destination.exists():
249 destination.parent.mkdir(parents=
True, exist_ok=
True)
250 destination.write_bytes(base64.b64decode(item.get(
"content_base64",
"")))
252 destination.chmod(int(item.get(
"mode", 0o644)))
257def _rebase_restored_text_paths(root: str, replacements: list) -> list:
259 @brief Rebase known generated text artifacts after an explicit relocated restore.
260 @param[in] root Value supplied through the `root` argument.
261 @param[in] replacements Value supplied through the `replacements` argument.
262 @return Result produced by this operation.
265 allowed_suffixes = {
".control",
".run",
".sbatch",
".json",
".tsv",
".yml",
".yaml"}
266 for path
in Path(root).rglob(
"*"):
267 if not path.is_file()
or path.is_symlink()
or path.stat().st_size > 32 * 1024 * 1024:
269 if path.suffix.lower()
not in allowed_suffixes:
272 content = path.read_text(encoding=
"utf-8")
273 except (OSError, UnicodeDecodeError):
276 for old, new
in replacements:
277 if old
and new
and old != new:
278 updated = updated.replace(old, new)
279 if updated != content:
280 path.write_text(updated, encoding=
"utf-8")
281 changed.append(str(path))