diff --git a/workflows/exaconstit-calibrate/ARCHITECTURE.md b/workflows/exaconstit-calibrate/ARCHITECTURE.md new file mode 100644 index 0000000..5d733fc --- /dev/null +++ b/workflows/exaconstit-calibrate/ARCHITECTURE.md @@ -0,0 +1,262 @@ +# workflow_common — Architecture + +Read this if you are new to the codebase and want a one-document +overview before diving into individual modules. The goal here is not +to replace the in-source docstrings (those are the source of truth) +but to show how the pieces fit together. + +## What problem this package solves + +A scientific workflow — a parameter sweep, an optimization, a UQ +campaign — ends up doing the same handful of things no matter what +simulation code sits underneath: + +1. Render per-case input files from a master template. +2. Lay out working directories in a predictable way. +3. Launch simulations on some hardware (laptop, cluster, HPC). +4. Collect results, deal with failures, survive an allocation timeout. + +Historically these concerns were tangled together inside a single +driver script. `workflow_common` separates them so that each one is +independently reusable and independently testable — and so the same +driver can swap in a different simulation code by supplying a +different template and a different path pattern, rather than being +rewritten. + +## One-picture overview + +``` + +-------------------------+ + | Driver / user code | + | (optimizer, sweep, ...)| + +-------------------------+ + | | | | + v v v v + +------------+ +------+ +------+ +--------+ + | templates | |paths | |manif.| |sentinel| + +------------+ +------+ +------+ +--------+ + | + | submits SimJobSpec + v + +------------------------+ + | JobBackend Protocol | + +------------------------+ + / \ + / \ + +------------------+ +------------------+ + | LocalBackend | | FluxBackend | + | (subprocess + | | (flux.job. | + | ThreadPool) | | FluxExecutor) | + +------------------+ +------------------+ + | | + v v + local subprocesses Flux-submitted jobs +``` + +The driver is the only code that knows the user's optimization +algorithm. Everything below the driver is problem-agnostic. + +## Module responsibilities, one line each + +| Module | Responsibility | +| --- | --- | +| `_fs` | Directory context manager, atomic text writes | +| `logging_utils` | Stdlib `logging` setup + shims for old call sites | +| `templates` | `%%key%%` substitution for input-file rendering | +| `paths` | Case → working-directory and logical → output-file resolution | +| `manifest` | JSONL event log for crash-safe case state tracking | +| `sentinel` | Per-case `.done` file as authoritative completion marker | +| `platform_detect` | Hostname-based detection of Spectrum-MPI machines | +| `backends.base` | `SimJobSpec` / `JobResult` / `JobBackend` Protocol | +| `backends.local` | Run sims as local subprocesses | +| `backends.flux_backend` | Run sims via `flux.job.FluxExecutor` | + +## The two most important interfaces + +Almost everything in the package hangs off two small abstractions. +If you internalize these, the rest is mechanical. + +### `PathResolver` + +```python +class PathResolver(Protocol): + def working_dir(self, ctx: CaseContext) -> Path: ... + def output_file(self, logical_name: str, ctx: CaseContext) -> Path: ... +``` + +Given a `CaseContext` (a (generation, gene, obj) triple plus extras), +a resolver answers: + +- "Where does this case live on disk?" +- "Where will its `avg_stress.txt` appear after the sim runs?" + +`TemplatePathResolver` is the supplied implementation: layouts are +described by Python format-string patterns. Users with exotic needs +can write their own class satisfying the Protocol. + +### `JobBackend` + +```python +class JobBackend(Protocol): + def stream_batch(self, specs: Sequence[SimJobSpec]) -> Iterator[JobResult]: ... + def submit_batch(self, specs: Sequence[SimJobSpec]) -> List[JobResult]: ... + def submit_one(self, spec: SimJobSpec) -> JobResult: ... +``` + +Given a batch of `SimJobSpec`s, a backend runs them and produces +`JobResult`s. Callers do not need to know whether the backend uses +subprocesses, Flux, SLURM, or something not yet written. Concrete +backends inherit from `BaseBackend` and only need to implement +`stream_batch`; the other two methods fall out for free. + +## How a case flows through the system + +The case lifecycle is the clearest way to see how the modules +cooperate. For each case, in order: + +1. **Path resolution.** Driver builds a `CaseContext` and asks the + `PathResolver` for the working directory. It creates the directory + and renders `options.toml` (or whatever) from the master template + using `render_template_file`. +2. **Manifest: SUBMITTED.** Driver records a `ManifestEntry(state= + SUBMITTED)` with the case key and working dir. If the driver is + killed between this write and a terminal state, restart logic + will later promote this entry to `INTERRUPTED`. +3. **Backend handoff.** Driver builds a `SimJobSpec` pointing at the + simulation binary and hands it to the backend's `stream_batch`. + The backend launches the job (subprocess or Flux) and, later, + yields a `JobResult`. +4. **Output validation.** Driver calls `validate_outputs` to confirm + the required files are present and non-empty. A clean `rc=0` with + missing files is still a workflow-level failure. +5. **Sentinel write.** Driver calls `write_sentinel` with a `Sentinel` + object describing what happened. The write is atomic + (tempfile + rename). After this point, the case is considered + done regardless of what happens next. +6. **Manifest: COMPLETED / FAILED.** Driver records the terminal + `ManifestEntry`. This is the last step on purpose: a crash between + steps 5 and 6 is safely recoverable (sentinel tells the truth), + but a crash in the opposite order would leave the manifest + claiming work was done that was never validated. + +``` + CaseContext build SimJobSpec JobResult + | | | + | render template | stream_batch | validate outputs + v v v + options.toml backend launches +-----------+ + flux / subprocess | Sentinel | step 5 + | write | (FIRST) + +-----------+ + | + v + +-----------+ + | Manifest | step 6 + | record | (SECOND) + +-----------+ +``` + +## Why two state files (manifest and sentinel)? + +A common question. Both record case state; why not unify them? + +They answer different questions: + +- **The manifest** answers "what is the global state of this run?" + It is append-only (JSONL), holds every transition for every case, + and is the source of truth for restart planning. +- **The sentinel** answers "is the work in this specific directory + finished?" It is a single file in the case directory, written + atomically, and is the source of truth for per-case skip logic. + +The sentinel is locally inspectable. An analyst looking at one +output directory knows at a glance whether it is safe to post-process +the files inside. They do not need to parse the manifest, they do +not need to know which run this directory came from — the `.done` +file tells them. + +The manifest is globally inspectable. The driver needs to know, on +restart, which cases are done so it can skip them, and which cases +were submitted but never finished so it can retry them. A pile of +sentinels cannot tell you about cases that were planned but never +launched. + +Keeping both means each layer is small, simple, and can be reasoned +about in isolation. + +## Restart model + +Optimization runs routinely outlive their HPC allocations. The +restart story is: + +1. On startup, driver calls `Manifest.load()`. This replays the + snapshot + JSONL log to rebuild the in-memory state. +2. Driver calls `Manifest.mark_submitted_as_interrupted()`. Anything + stuck in SUBMITTED is assumed to have been killed alongside the + old allocation. +3. For each case in the new plan, driver checks + `is_case_complete(working_dir)`. A present sentinel means the + case is done (regardless of what the manifest says) and can be + skipped. No sentinel means the case either never ran, was + interrupted, or failed without producing a sentinel, and should + be submitted. + +There is deliberately no Flux-state recovery. When the allocation +dies, the Flux instance dies with it, and its jobids are +unrecoverable. The restart machinery is entirely filesystem-driven. + +## Non-goals + +Things `workflow_common` deliberately does not do: + +- **Parse simulation input files.** Master templates are text; we + substitute `%%key%%` placeholders and nothing else. The framework + never knows whether the file is TOML, XML, INI, or a shell script. +- **Know about physical quantities.** Strain rates, yield stresses, + stress-strain curves — all of that is user code. The framework + only carries opaque parameter dicts from the driver into the + template renderer. +- **Optimization.** No GA, no nearest-neighbor sampling, no + surrogate models. Those live in the driver. The framework's job + is to make driving the simulation reproducible and restartable. +- **Auto-starting Flux.** You start a Flux instance yourself (via + `flux start` inside an allocation); the backend connects to + whatever is already running. + +## Adding a new backend + +If you need to support a new execution environment (e.g. a SLURM +step backend, or a remote SSH backend for tiny shared clusters): + +1. Subclass `BaseBackend` (from `backends.base`). +2. Implement `stream_batch(specs) -> Iterator[JobResult]`. The + `submit_batch` and `submit_one` methods come for free. +3. Use `SimJobSpec` fields for all inputs; do not introduce new + fields unless absolutely necessary. If you do need new fields, + put them inside `SimJobSpec.extra` so core callers remain + backend-agnostic. +4. Return `JobResult` with a meaningful `JobOutcome`. + +No Protocol inheritance declaration required — Python's structural +typing means any class with the right method signatures is accepted +wherever `JobBackend` is expected. + +## Adding a new path layout + +If `TemplatePathResolver`'s format-string approach does not fit your +needs (e.g. you want paths looked up from a database, or computed +from some hash of the parameter values): + +1. Write a class with `working_dir(self, ctx)` and + `output_file(self, logical_name, ctx)` methods. +2. Hand an instance to the driver where it expects a `PathResolver`. + +That's it. Again, no inheritance declaration needed. + +## Where to look next + +- `tests/demo_workflow.py` — runnable end-to-end example, no HPC + required. Read it top to bottom and you will see every module in + use. +- Each module's header docstring — the "why" for that module. +- The function-level docstrings — the "what" and the "how". diff --git a/workflows/exaconstit-calibrate/AUDIT_PLAN.md b/workflows/exaconstit-calibrate/AUDIT_PLAN.md new file mode 100644 index 0000000..5163c4b --- /dev/null +++ b/workflows/exaconstit-calibrate/AUDIT_PLAN.md @@ -0,0 +1,1643 @@ +# Audit Findings & Action Plan — `exaconstit-calibrate` + +Date: 2026-04-23 +Scope: systematic review of path handling, subprocess launches, default +values, and validation coverage across `workflow_common`, `workflows`, +and the backends. + +## Bug-class meta-pattern + +Three families of bugs surfaced in recent debugging, all variants of the +same meta-pattern: **a signature that accepts values it cannot honor, or +a handoff where two sides each think they own the same step.** + +1. **Silent-default-disables-feature.** A default value makes an + adjacent feature quietly inert. Example: `LocalBackend` defaulted + `mpi_launcher=None`, so `SimCase.num_tasks=4` silently produced + single-rank runs. The user never sees a warning. + +2. **Redundant handoff / double-compose.** Two layers each do the same + composition (path join, validation, substitution). Idempotent on + absolute inputs, so tests on tmp-dir absolute paths never see it; + surfaces only in production on relative user paths. Example: + `problem.py::_handle_backend_result` pre-joined `working_dir / p` + before calling `validate_outputs` which ALSO joined against + `working_dir`, producing `//...` on relative + workspaces. + +3. **API-drift / invented kwarg.** Example code against a docstring + mental model of the API instead of the real signature. Example: + `TemplateTarget(substitute=False)` was invented before that field + existed; `configure_logging(restart=...)` used the old ExaConstit + logger's kwarg name. + +All three share one root cause: **tests exercise happy-path absolute +tmp-dir inputs only.** Production users have relative `WORKSPACE` +paths, unusual kwarg combinations, and shapes the tests never cover. + +## Findings + +### Finding 1 — Silent escape via `TemplateTarget.dest` (FIXED) + +**Severity:** Medium — data can land outside the case dir. + +**Mechanism:** `CaseTemplater.render` joined `layout.working_dir / +target.dest` without validating `dest`. On POSIX, `Path("/abs") / +"/absolute"` returns `/absolute`, silently dropping the working-dir +prefix. An unvalidated `dest="../escape/foo.toml"` escapes the case +dir. An unvalidated `dest="{gene}.toml"` produces a literal `{gene}.toml` +filename. + +**Fix:** `_validate_case_relative_dest(dest, field_name)` helper in +`case_setup.py`, called from `TemplateTarget.__post_init__`. Rejects +absolute paths, `..` parent refs, `{...}` placeholder syntax, and +non-string / empty values. + +**Status:** Fixed. + +### Finding 2 — Same silent escape via `TemplatePropertyWriter.dest` and `DelimitedPropertyWriter.dest` (FIXED) + +**Severity:** Medium — same mechanism as #1, different classes. + +**Mechanism:** `TemplatePropertyWriter` and `DelimitedPropertyWriter` +both have `dest: str` fields that feed directly into `layout.working_dir +/ self.dest` inside `write()`. Same validation gap as #1. + +**Fix:** Same `_validate_case_relative_dest` helper, wired in via +`__post_init__` on both classes. `CallablePropertyWriter.dest_hint` is +cosmetic (logging only) and intentionally left unvalidated — the user's +callable does the real writing. + +**Status:** Fixed. + +### Finding 3 — `skip_completed` reuses stale results after config change (NOT FIXED) + +**Severity:** **High** — silent wrong-answer on restart. + +**Mechanism:** `Problem.skip_completed=True` (the default) uses +`is_case_complete(layout.working_dir)` to decide whether to re-run a +case. If a sentinel is present, the cached result is loaded. The +sentinel file does NOT fingerprint: + +- The gene vector that produced the result +- The `param_names` ordering +- The `ProblemConfig.binary` or `binary_args` +- `SimCase.case_data` (boundary conditions, strain rate, ...) + +So a user who: + +1. Runs generation 0 to completion +2. Realizes their `param_names` order was wrong, edits the driver +3. Resumes with `--resume-from checkpoint_gen_0.pkl` + +will get their new gene-vector interpretation evaluated against the +OLD sentinel-cached results. Objective values are silently wrong for +every case that didn't need re-running. No error, no warning. + +**Design questions for the fix:** + +1. **What counts as "the config changed"?** At minimum: the gene + vector itself (hash the numpy array); `param_names` (hash the + tuple); `ProblemConfig.binary` path + `binary_args` tuple; the + full `SimCase.case_data` dict. Omit `num_tasks`, `duration_s`, + and other resource fields — they don't affect the *physics* of the + simulation, only its execution. + +2. **Storage:** add `config_fingerprint: Optional[str]` to + `Sentinel`. Missing (old sentinel) → treat as untrusted, force + re-run with a one-line log message. Mismatched → force re-run with + a LOUD warning that includes the diff. Matched → skip as today. + +3. **Fingerprint canonicalization:** `hashlib.sha256(json.dumps({...}, + sort_keys=True, default=_canonicalize_value).encode()).hexdigest()`. + The `_canonicalize_value` helper handles numpy arrays (convert to + lists), Path objects (str), and tuples (list, so JSON stable). + +4. **Backward compatibility:** existing on-disk sentinels don't have + the fingerprint. Treat missing-fingerprint as "force re-run" — the + "safe" direction. Log once per restart: "N sentinels lack a config + fingerprint; re-running those cases for safety. This is expected + after a framework upgrade." + +5. **Opt-out:** some users will legitimately want to skip the + fingerprint check (they know their edits were cosmetic). Add + `ProblemConfig.fingerprint_policy: Literal["check", "force", + "skip"] = "check"`. `"force"` ignores the fingerprint (current + behavior). `"skip"` — cases without a fingerprint still skip + rather than re-run. Default `"check"` is the safe behavior. + +**Test plan:** at least five new tests: + +- Matching fingerprint → skip as expected +- Missing fingerprint (old sentinel) → re-run with log +- Mismatched fingerprint → re-run with warning +- `fingerprint_policy="force"` → skip regardless +- `fingerprint_policy="skip"` → skip when missing, warn-and-re-run + when mismatched + +**Estimated effort:** 200 lines of code, 5 test cases, plus an update +to `MIGRATION.md` explaining the new sentinel field and its +backward-compatibility path. + +**Status:** Not fixed. Highest-priority follow-up item. + +### Finding 4 — `clear_outputs` could delete files outside the case dir (FIXED) + +**Severity:** Medium — data-destruction hazard if a user misconfigures +`output_file_patterns` with an absolute path. + +**Mechanism:** `CaseLayout.clear_outputs()` iterates known outputs and +`unlink()`s each. If a user's `TemplatePathResolver` has an +`output_file_patterns` entry whose rendered path is absolute (or +escapes via `..`), `clear_outputs_on_rerun=True` would delete that +file on every restart. + +**Fix:** Clamp-to-workdir safety gate in `results.py::CaseLayout.clear_outputs`. +Resolve both the target path and the working dir (with symlinks) and +refuse to delete anything whose resolved path isn't `relative_to(wd)`. +Log the refusal at WARNING level with the offending path so the +misconfiguration is visible. + +**Status:** Fixed. + +### Finding 5 — `pickle.load` on `resume_from` path (DOCSTRING WARNING) + +**Severity:** Low — requires an attacker with write access to the +checkpoint workspace. + +**Mechanism:** `_load_checkpoint` calls `pickle.load` on a user-supplied +path. A malicious pickle executes arbitrary Python during unpickling. +On shared HPC scratch filesystems this is a real risk vector. + +**Fix:** Docstring warning on `_load_checkpoint` explaining the risk +and suggesting JSON+npz as an alternative exchange format. Did not +switch the serialization format — that's a bigger change and the risk +profile is low for the typical single-user workspace. + +**Status:** Warned. Possible follow-up: optional JSON+npz checkpoint +format for cross-user / cross-host exchange. + +### Finding 6 — FluxBackend passed raw (possibly relative) paths to Flux (FIXED) + +**Severity:** Low — version-dependent Flux behavior, would manifest +as "job runs in unexpected cwd" or "stdout lands nowhere obvious" on +relative-workspace runs. + +**Mechanism:** `FluxBackend._build_jobspec` set `js.cwd = str(spec.working_dir)` +and `js.stdout = spec.stdout` using the RAW user strings. Flux's +interpretation of a relative `cwd` depends on the broker version; +`LocalBackend` uses `resolved_stdout()` (absolute) consistently. + +**Fix:** Both `js.cwd` and `js.stdout/stderr` now use resolved +absolute paths at the boundary. Matches LocalBackend's behavior. + +**Status:** Fixed. + +### Finding 7 — Zero test coverage of relative-workspace roots (ACTION ITEM) + +**Severity:** Meta — this is the reason findings 3 and the prior +double-join bug reached production. + +**Mechanism:** Every existing integration test uses `tmp_path` (an +absolute pytest-fixture path) as the workspace. Users naturally write +`WORKSPACE = Path("./calibration_run")` in their drivers. The double-join +bug only surfaced when a join chain produced a relative path that was +then re-joined. + +**Action item:** parameterize the main end-to-end integration test +over `workspace_style` in `{"absolute", "relative"}`. The relative +variant uses `monkeypatch.chdir(tmp_path)` then `Path("calibration_run")` +as the workspace. Expected to shake out any remaining double-join or +relative-vs-absolute confusion. + +**Estimated effort:** 30-50 lines of test code, applied to +`test_integration.py` and `test_nsga3_driver.py`. No framework +changes. + +**Status:** Not done. Medium-priority follow-up. + +## Bugs confirmed NOT present after audit + +Documented for future maintainers so the same ground isn't re-covered: + +- No duplicate template-substitution passes. `{working_dir}` format + key is injected only during `output_file()` rendering; the + `working_dir_pattern` render raises cleanly on self-reference. +- No SQL concatenation in `archive.py`. All queries use parameterized + bindings. +- `LocalBackend._run_one` is the only subprocess-launch site. Already + hardened with the `mpi_launcher` check and the running-jobs lock. +- Backends don't duplicate `validate_outputs` / `write_sentinel`. + `Problem` is the single authority for both. +- `StressStrainExtractor.strain_rate=None` fails loudly when combined + with `strain_source="time_rate"`. Safe default. +- `FluxBackend.spectrum_mpi=None` triggers `is_spectrum_machine()` + auto-detection. Safe default. +- Path joins in `paths.py:355`, `sentinel.py:325`, + `backends/base.py:257/267` all gate on `.is_absolute()` correctly. +- `CaseTemplater` with `targets=[]` is a safe no-op (already tested). +- `_fs.atomic_write_text` uses tempfile+rename, crash-safe. + +## Action-item summary + +| # | Item | Priority | Effort | Status | +|---|------|----------|--------|--------| +| 3 | Sentinel config fingerprint for `skip_completed` safety | **High** | ~200 lines code + 5 tests + MIGRATION.md | **Open** | +| 7 | Parameterize integration tests over absolute/relative workspace | Medium | ~50 lines | **Done** | +| 5 | JSON+npz optional checkpoint format for cross-user exchange | Low | ~100 lines + 2 tests | Open | +| 8 | `output_file_patterns` bare-relative auto-prepend symmetry | Medium | ~10 lines + 2 tests | **Done** | +| 9 | Restore `logbook1_stats.log` / `logbook2_solutions.log` writes | Medium | ~100 lines + 3 tests | **Done** | +| — | `inspect_archive` CLI for SQL-free archive viewing | Medium | ~400 lines + 10 tests | **Done** | +| — | Cross-gen `--pareto-only` + `--top` | Medium | ~150 lines + 5 tests | **Done** | +| — | Scale work: vectorized ranking + `load_all_genes` batch | Medium | ~80 lines + bench | **Done** | +| — | `--gens-best` convergence view + `--limit` cap | Medium | ~180 lines + 6 tests | **Done** | +| — | `l2_norm` column + `--pareto-only` implies `--genes` | Low | ~60 lines + 6 tests | **Done** | +| — | `--resume-latest` / integer `--resume-from N` | Medium | ~80 lines in example | **Done** | +| — | Relax `Problem.__init__` for resume orphan case | Medium | ~15 lines + 2 tests | **Done** | +| — | Diagnostic resume logging (`resume path:`, before/after discard) | Low | ~25 lines | **Done** | +| — | `delete_run` + `prune_empty_runs` API + CLI | Medium | ~150 lines + 12 tests | **Done** | +| — | `regenerate_logbook_files` CLI for lost `.log` recovery | Low | ~120 lines + 6 tests | **Done** | +| — | Binary-BLOB gene-vector storage for >100k records | Low | Schema migration | Future | +| — | Run full regression after fixes; add 6-8 tests | Medium | ~100 lines of test code | **Done** | + +## Things for future consideration + +- **Linter or runtime check for API-drift in examples.** The + `test_example_imports.py` guard catches module-level import breakage + but not kwarg drift inside function bodies. Consider a + `main(dry_run=True)` path that constructs every framework object + without actually running simulations, and have the guard call it. +- **`ExaConstit_Problems.py` "partially successful" simulation + behavior.** Old code zero-padded the result tail when + `error_strain > 0.01`. `ZeroPadPartialHandler` was discussed but + never implemented. Flagged in MIGRATION troubleshooting. +- **Migration from `required_outputs` toward reader-owned validation.** + The two pre-flight checks overlap. Consider deprecating + `ProblemConfig.required_outputs` once every user has moved to + `TextTableSpec(required=True)`. +- **`FluxBackend.poll_stats` coverage.** Two error-handling branches + marked `# pragma: no cover`. Not a correctness issue; CI coverage + gap only. + +### Finding 8 — `output_file_patterns` bare-relative asymmetry (FIXED) + +**Severity:** Medium — silent-wrong-path bug that manifested as +"reader found no files" at runtime. + +**Mechanism:** `TemplatePathResolver.output_file()` rendered patterns +via `str.format_map` and returned `Path(rendered)` directly. Patterns +that included `{working_dir}/` got the correct per-case path; +patterns written as "obviously relative to the case dir" without +that prefix (e.g. `"results/avg_stress.txt"`) got rendered as bare +relative paths that `path.exists()` then resolved against the +driver's cwd — missing the case dir entirely. The asymmetry vs +`working_dir_pattern` (which auto-prepends `root`) was not +documented clearly enough, and a user writing what seemed like the +obvious config got silently wrong behavior. + +**Fix:** `output_file()` now auto-prepends `working_dir` when the +rendered pattern is (a) not absolute AND (b) doesn't contain the +`{working_dir}` marker. Patterns that explicitly use the marker +are unchanged; absolute patterns are honored exactly as before. +Symmetry with `working_dir_pattern`'s root-prepend behavior +restored. + +**Test coverage:** two new tests in `test_paths.py` +(`test_output_file_auto_prepends_working_dir_for_bare_relative_pattern` +and `test_output_file_bare_relative_matches_working_dir_regardless_of_root_type`). + +**Status:** Fixed. + +## Session 2 deltas + +Between the initial audit and this second pass, the following +additional work was completed: + +- **Column-name convention aligned to ExaConstit.** All framework + defaults, docstring examples, fixtures, and the fake binary now + use the ``# Time Volume Sxx/Syy/Szz/Sxy/Sxz/Syz`` header + format from + ``ExaConstit/src/postprocessing/postprocessing_file_manager.hpp`` + ``GetVolumeAverageHeader``. `StressStrainExtractor` defaults are + `stress_column="Szz"`, `def_grad_column="F33"`, `time_column="Time"`; + `common_time_range` and `interpolate_to` default `time_column` + is `"Time"`. Non-ExaConstit users override explicitly. + +- **Pandas engine auto-switch for commented files.** The C engine + was mishandling ExaConstit's "indented `#` header + indented + data rows" shape, raising a misleading + `EmptyDataError: No columns to parse from file`. `TextTableReader` + now switches to the python engine whenever `spec.comment` is + set, and wraps `EmptyDataError` in a `ValueError` that includes + the first 512 bytes of the file so users can see what pandas + actually saw. + +- **`strain_source` renamed.** `"f11_minus_one"` → `"axial_minus_one"`, + `"log_f11"` → `"log_axial"`. The old labels stay accepted via a + normalization shim in `extract()` that emits a `DeprecationWarning` + naming the new label. The internal local variable `F11` was also + renamed to `axial_stretch` so the code reads correctly when + `def_grad_column="F33"` (ExaConstit z-axis default). + +- **Finding 7 closed.** `test_integration.py` now has a + `@pytest.mark.parametrize("workspace_style", ["absolute", "relative"])` + variant on the happy-path pipeline. The relative branch uses + `monkeypatch.chdir(tmp_path)` + `Path("wf_rel")` as the + workspace root, exactly matching how users in production + wrote `WORKSPACE = Path("./calibration_run")`. + +- **Finding 3 still not addressed.** The sentinel config + fingerprint for safe `skip_completed` restart is the remaining + silent-wrong-answer hazard. Detailed design is in this document; + needs a dedicated session. + +Test count progression across the two audit passes: +- Start of audit: 287 +- After audit fixes (Findings 1, 2, 4, 6): 290 +- After Finding 8 (bare-relative auto-prepend): 290 +- After column-name sweep + ExaConstit-first defaults: 291 +- After reader C-engine fix: 291 +- After `strain_source` rename: 292 +- After Finding 7 parameterization: 294 + +## Session 3 deltas + +### Finding 9 — Logbook text files dropped during refactor (FIXED) + +**Severity:** Medium — observability regression vs pre-refactor +driver. Not a correctness bug, but "the run is producing zero +user-visible output about its own progress" is a serious quality +regression that hid the health of multi-day runs. + +**Mechanism:** The pre-refactor `ExaConstit_NSGA3.py` wrote two +tab-delimited text files every generation: +- `logbook1_stats.log` — avg/std/min/max fitness per generation, + plus ND / GD / HV for multi-objective runs +- `logbook2_solutions.log` — per-individual gene vector + fitness + +The refactor preserved the in-memory DEAP `Logbook` objects and +even pickled them into the checkpoint, but the file writes were +dropped. Users had no way to watch the optimization's progress +mid-run short of inspecting the pickle or the SQLite archive. + +**Fix:** Added `_LogbookWriter` helper that writes both files using +DEAP's `logbook.stream` delta mechanic — each call emits only +records added since the previous access. Calls are inserted after +every `logbook.record(...)` site in `run_nsga3`. Resume-from- +checkpoint truncates both files and replays the loaded logbooks +from scratch (using DEAP's `buffindex` cursor field, verified by +reading `deap.tools.Logbook.stream`'s source) so no gaps or +duplicates result. + +Two new `RunConfig` fields: +- `log_dir: Optional[Path] = None` — default behavior picks + checkpoint-adjacent or cwd +- `write_logbook_files: bool = True` — opt-out for tests / + silent workflows; in-memory logbooks still populated regardless + +Three new tests in `test_nsga3_driver.py` cover the happy path, +the opt-out, and the resume rewrite. + +**Status:** Fixed. + +### New deliverable — `inspect_archive` CLI + +Not a bug — a usability gap flagged alongside Finding 9. The +SQLite archive at `archive.db` is the authoritative per-run record +but users couldn't inspect it without a SQLite client. Added +`workflows/optimization/inspect_archive.py` — a standalone CLI +with three views (`--runs`, `--gens`, `--genes`), three formats +(`table` / `csv` / `json`), and `--pareto-only` / `--run` / `--gen` +filters. Expands gene vectors into named columns using the run's +stored `param_names` + `objective_labels`. + +Ten tests in `test_inspect_archive.py` cover every view × every +format × error paths. + +Invocation examples: +``` +python -m workflows.optimization.inspect_archive ./wf --runs +python -m workflows.optimization.inspect_archive ./wf --gens +python -m workflows.optimization.inspect_archive ./wf --genes \ + --gen 10 --pareto-only --format csv > front.csv +``` + +### Test count through session 3 + +- End of session 2: 294 +- +3 logbook writer tests: 297 +- +10 inspect CLI tests: 307 + +## Session 4 deltas — Scale work + +User flagged that real calibrations run at 40k–50k total simulations +(100 gens × 60–80 pop × ~8 objectives per gene, or 500 gens × 100 pop). +Two categories of work addressed. + +### Performance on large archives + +Baseline profile of `inspect_archive --genes --pareto-only` on a +50k-record archive was 2.1 seconds. Two changes dropped this to +~1.2 s without adding any new dependencies: + +1. **`ArchiveDB.load_all_genes(run_id)`** — one SQL query instead of + one per generation. Eliminates ~500 round-trips on a 500-gen + archive. Saved ~150 ms of pure query overhead. +2. **Vectorized top-N ranking** — the three separate Python loops + (`_top_n_by_l2`, `_top_n_by_objective`, `_finite_fitness`) were + replaced with one `(N, M)` numpy fitness matrix and `np.argsort` + with non-finite values masked to `+inf`. Saved ~700 ms on 50k + rows; dropped the 450,000 `math.isfinite` calls in the profile. + +Remaining cost is dominated by JSON decode in +`sqlite3 → GeneRecord` hydration (~0.67s of the remaining 1.2s). +Further reduction would require either a faster JSON library +(orjson — adds a dependency) or a schema migration to store +`gene_vector` / `fitness` as a binary BLOB. Neither fits under the +"tuning" bucket, so the JSON cost was left alone. Filed for a +future dedicated turn if scaling above ~100k records becomes +common. + +Benchmark table (8 objectives, 6 params, Python 3.12): + +| Scale | --runs | --gens | --gens-best | --pareto-only | +|----------------------|--------|--------|-------------|---------------| +| 8000 records | 4 ms | 6 ms | 156 ms | 228 ms | +| 50000 records | 3 ms | 5 ms | 1043 ms | 1316 ms | + +### New views + +- **`--gens-best`** (convergence view). Per-generation row with + running-best fitness on each objective, current L2 champion's + birth generation, and cumulative `n_seen`. Designed to answer + "is the GA still improving?" at a glance — if + `champion_birth_gen` holds the same value for many rows, the + run has plateaued. +- **`--limit N`** cap on the raw `--genes` dump (default 200). + Prevents accidentally flooding the terminal when a generation + has a huge population. `--limit 0` disables. Ignored for + `--pareto-only` (already bounded). Stderr notice when + truncation happens so the row count isn't mysteriously round. + +Six new tests bring total to 327. + +## Session 5 deltas — Resume UX, observability, housekeeping + +Four distinct threads of work in this session. All driven by +Robert hitting real usability issues during his first multi-day +calibration run. Each thread ended up teaching us something +worth keeping documented. + +### UX thread: the `--resume-latest` / `--resume-from N` flow + +Robert typed `python nsga3_calibration.py --resume-from 16` to pick +up from generation 16 and got a terse `FileNotFoundError: '16'`. +The example's argparse had `type=Path`, which dutifully wrapped the +string "16" as a Path object that didn't exist on disk. + +Shipped a three-behavior `--resume-from` accepting any of: +- a plain integer like `15` → looks up + `checkpoint_dir/checkpoint_gen_15.pkl` +- a full path to a pickle → used verbatim +- nothing (absent) → fresh run + +Plus a separate `--resume-latest` flag that scans for the +highest-numbered `checkpoint_gen_*.pkl` in the configured +directory — the "just keep going from wherever we left off" +ergonomic default that shouldn't require a number. + +A cosmetic `Path.relative_to()` call in the display line blew up +when the checkpoint path was relative and cwd was absolute. Wrapped +in try/except with a fallback to the unresolved path. Minor but +would have bounced users with perfectly valid invocations. + +### Usability thread: archive_run_id mismatch on resume + +Robert's first successful `--resume-from ` immediately died +with `ValueError: checkpoint archive_run_id='3155cb92-...' differs +from Problem.archive_run_id='679fde72-...'`. Root cause: the +example unconditionally called `archive.start_run(...)` on every +invocation, generating a fresh UUID on resume that conflicted with +the one baked into the pickle. + +Two fixes, one in the library and one in the example: + +1. **Library `Problem.__init__` relaxed** to accept + `archive=..., archive_run_id=None` — the orphan case that + lets resume work cleanly. The deferred check moved to + `_archive_case`, which raises `RuntimeError` at first write + if `archive_run_id` is still None by then. Same invariant, + enforced at the real failure point. +2. **Example now gates** `start_run` on `resume_pickle_path is None`. + On resume it leaves `archive_run_id=None`; the library reads + the pickled UUID and assigns it to `problem.archive_run_id` + before any writes happen. The existing `discard_from_generation` + call in the resume path then cleans stale post-crash rows. + +The mismatch ValueError itself got upgraded to name both UUIDs +and quote the correct driver pattern verbatim: + +``` +archive_run_id mismatch on resume: + checkpoint's run_id: '3155cb92-...' + Problem's run_id: '679fde72-...' + +Fix: in your driver, only call start_run() when NOT resuming: + if args.resume_from is None: + problem.archive_run_id = archive.start_run(...) +``` + +### Observability thread: diagnostic resume logging + +Robert reported "resume starts from gen 0, logs wiped, DB wiped" +and we spent time guessing where the bug was before realizing +the symptoms all described the library taking the fresh path +instead of the resume path. The example driver was passing +`resume_from=args.resume_from` to `RunConfig` when it should have +been passing `resume_pickle_path` (which is what the resolver +block populated). `--resume-latest` only sets `args.resume_latest`, +not `args.resume_from`, so the library saw `None` and took fresh. + +Fixed the line. Ship diagnostic logging so the **next** time a +resume misbehaves it's one-glance diagnosable: + +``` +resume path: loading checkpoint from ... +resume: last completed gen=15, pop_library has 16 entries, + logbook1 has 16 records, logbook2 has 320 records, + pickled archive_run_id=3155cb92-... +resume: next generation to run is 16 +archive resume: run=3155cb92-... has gens [0..15] before discard; + keeping gens <= 15, dropping >= 16 +archive resume: after discard, run=3155cb92-... has gens [0..15] +``` + +The two-line before/after discard lines specifically address the +"my archive got wiped" class of report — confirm at a glance that +the framework is only touching the current run's post-crash rows. + +### Housekeeping thread: archive cleanup tooling + +Multiple debug sessions left Robert with 4+ empty aborted run +rows cluttering his archive. He deleted them manually because no +API existed. That's wrong. + +Two new `ArchiveDB` methods: + +- **`delete_run(run_id) -> int`** — single-run deletion. Schema + CASCADE handles dependent rows in `generations` / `genes` / + `case_outputs`. Raises `KeyError` on unknown IDs so typos + surface rather than silently no-op. Returns the parent-row + delete count (1 on success). +- **`prune_empty_runs(*, min_age_minutes=60.0, dry_run=False)`** — + bulk cleanup. "Empty" means no `generations` AND no + `case_outputs` rows; a partial crash mid-gen-0 that wrote some + case outputs doesn't qualify because those are real simulation + artifacts. Two safety gates protect in-progress runs: completed + runs (those with `end_run` called) are always eligible; still- + running runs must be older than `min_age_minutes` (default 60) + before being touched. Pass 0 to override. + +CLI surface on `inspect_archive`: + +- `--clean-empty-runs` — wraps `prune_empty_runs` +- `--dry-run` — preview without deleting; prints candidates on + stderr +- `--age-minutes N` — override the age gate + +Zero-delete path prints a helper line pointing at +`--age-minutes 0` so users don't have to `--help` to find the +escape hatch. + +### Logbook recovery tool + +Separate deliverable: `workflows/optimization/regenerate_logbook_files` +CLI. When the `.log` files on disk have been lost (truncated by +a misconfigured run, accidentally deleted), the pickle still +carries the full DEAP `Logbook` history. This tool reads the +pickle and reuses the driver's own `_LogbookWriter.rewrite_from` +to regenerate the `.log` files byte-identically to a live run. +Defaults output dir to the pickle's parent directory (where the +live driver would have put them); `--output-dir` overrides. + +### `l2_norm` column on the cross-gen view + +Smaller cosmetic change flagged by Robert mid-session. The cross-gen +`--pareto-only` view ranks genes by L2 norm but didn't display the +value. Added `l2_norm` column at the end of every row — L2-block +rows read monotonically, per-objective rows let users spot narrow +specialists vs balanced-and-specialist winners at a glance. + +Also: `--pareto-only` alone (without `--genes`) silently fell +through to the default `--gens` view. Fixed — it now implies +`--genes`. Combining `--pareto-only` with `--runs` / `--gens` / +`--gens-best` / `--clean-empty-runs` is now an explicit error +rather than a silent flag-drop. + +### Test count through session 5 + +- End of session 4: 327 +- Cross-gen view + L2 column + pareto-only implies genes: 333 +- Resume UX fixes + mismatch error + orphan archive case: 336 +- `delete_run`: 339 +- `regenerate_logbook_files`: 345 +- `prune_empty_runs` + CLI: 357 + +## Session 6 deltas — SimCase API consolidation + +One focused thread: collapse `SimCase.template_values` and +`SimCase.context_extra` into a single `SimCase.case_data` field. + +### Why the split was a usability rough edge + +Robert asked how to provide per-case constants to a property +writer (e.g. temperature-dependent elastic constants). The answer +was conceptually simple — "put them in `template_values` and read +them off `sim_case.template_values` inside the writer" — but only +because `template_values` happened to be visible to the writer +through SimCase passthrough. Meanwhile, `context_extra` was the +field for "things the path resolver needs," visible to the writer +in a different way (`layout.ctx.extra`). The semantic was: + +- `template_values` — read by the templater; ALSO visible to the + writer via `sim_case.template_values` +- `context_extra` — read by the path resolver; ALSO visible to + the writer via `layout.ctx.extra` + +Two fields, both functionally per-case constants, distinguished +only by which downstream consumer reads them. This forced the +user to remember the decision tree: + +1. Will it appear as `%%key%%` in a template? → `template_values` +2. Will it appear as `{key}` in a path pattern? → `context_extra` +3. Both? → `template_values` works, but path resolver doesn't + see it… + +The decision tree itself is the smell. Same data, two boxes, +arbitrary partition. + +### What changed + +`SimCase.case_data` replaces both fields. The single mapping +flows to all three consumers: + +- the **templater** for `%%key%%` substitution +- the **path resolver** for `{key}` substitution +- the **property writer** via `sim_case.case_data` + +One dict, three readers. No decision tree. + +The path resolver is still strict about missing keys (it raises +`KeyError` with a helpful "available: [...]" message), so typos +still surface — the merge doesn't sacrifice that safety net. + +### Files touched + +- `workflow_common/problem.py` — field rename, docstring rewrite + (Fields section, Example block, Problem class docstring example + using `case_data=`). Two `extra=dict(sim_case.context_extra)` + call sites changed to `extra=dict(sim_case.case_data)`. The + templater-values build site simplifies because `ctx.extra` is + now populated from the same source — kept the existing + `setdefault` loop for defensive clarity but it's a no-op in + practice now. +- `workflow_common/case_setup.py` — `CallablePropertyWriter` + docstring example updated to read from `sim_case.case_data`. +- `workflow_common/MIGRATION.md` — Step 3 ("DEP_UNOPT") rewritten + to introduce `case_data` as the single home with a brief + historical note about the previous two-field design. +- `workflow_common/ARCHITECTURE.md` — SimCase prose updated. +- `examples/nsga3_calibration.py` — large per-case-constants + comment block rewritten (was a 3-row table, now a unified + description). Two prose comments fixed. Two SimCase definitions + updated. write_properties docstring updated. +- `workflows/optimization/nsga3_driver.py` — two docstring + examples updated. +- `tests/test_problem.py` — appended new behavioral test + `test_case_data_visible_to_templater_path_resolver_and_writer` + that pins the merged-field design end-to-end. The test runs a + real Problem with a working_dir_pattern that includes + `{rve_name}` and a templater target that references + `%%temperature_k%%`, then asserts: (1) the case dir lands at + the expected path (proves resolver consumed case_data), (2) + the rendered file content carries the substituted value + (proves templater consumed case_data), (3) the writer's + capture-dict shows both fields read off `sim_case.case_data` + (proves writer consumed case_data). If anyone ever re-splits + the field, this test fails because all three flow paths + exercise from one dict. +- `tests/test_case_setup.py` — + `test_callable_property_writer_receives_sim_case` updated to + use `case_data` and assert on it. +- `AUDIT_PLAN.md` — Finding 3's design references updated to + `case_data`. + +No backward-compat alias was added. Robert is the only user; +hard rename keeps the API surface clean for incoming users +who'll never have seen the old names. + +### Test count through session 6 + +- End of session 5: 357 +- + new behavioral test pinning case_data flow: 358 + +## Session 6 deltas — addendum: case_data design clarifications + +After the rename landed, two follow-on improvements based on +Robert's feedback that the example was steering users wrong: + +### Per-case constants live IN case_data, not in the writer + +The illustrative example in `examples/nsga3_calibration.py` had +shown a Python `ELASTIC_TABLE = {temp: {c11, c12, c44}}` lookup +table inside `write_properties`, with the writer indexing by +`sim_case.case_data["temperature_k"]`. That's the wrong split: +it splits per-case data between two places (the SimCase +definition and the writer's source code), forcing users who +add a new case to edit two files. + +The corrected guidance: **anything that varies per case goes +directly into that SimCase's `case_data` dict**. The writer +just reads the keys. No Python lookup tables, no logic that +maps cases to values. + +```python +sim_cases = [ + SimCase(case_data={ + "temperature_k": 298.0, + "c11": 168.4, "c12": 121.4, "c44": 75.4, + # ... loading + path-pattern fields ... + }, label="cold"), + SimCase(case_data={ + "temperature_k": 600.0, + "c11": 156.0, "c12": 117.0, "c44": 72.0, + }, label="hot"), +] +``` + +The example's illustrative comment block, the SimCase docstring +in `problem.py`, and the matching MIGRATION.md walkthrough all +got rewritten to show this pattern. Every reference to the +old "lookup table inside the writer" pattern was removed. + +### Templater is template-driven, not data-driven + +Robert flagged that case_data shouldn't fail when it carries +keys the master template doesn't reference. Verified the +existing behavior — `render_template`'s `strict=True` mode +already only complains about template `%%key%%` placeholders +that have NO matching value in the supplied dict; extra keys +in the dict are silently ignored. So Ask 2 was already met +at the mechanism level. + +What WASN'T great: the error message from +`UnresolvedPlaceholderError` says "available keys: [...]" but +doesn't tell the user that the missing value should be added +to `case_data` specifically. From the user's point of view +they see "%%c11%% has no value" and have to figure out where +to add c11. The right place is the SimCase's `case_data` dict +— the framework knows this; the template machinery doesn't. + +Fix: `Problem._dispatch_one_case` now wraps `templater.render` +in a try/except that catches `UnresolvedPlaceholderError`, +preserves the original message (key name + available-keys +list), and appends a one-line hint pointing the user at +`SimCase(label=..., case_data=...)` for the specific case. + +This puts the "what to fix" hint exactly where the +case-specific context lives, without coupling the generic +`render_template` machinery to the Problem layer's +abstractions. + +### Test count through session 6 addendum + +- Start of addendum: 358 (after the rename) +- + extra-keys-in-case_data-don't-fail test: 359 +- + missing-template-key-helpful-error test: 360 + +## Session 7 deltas — Plotting example, public selection API + +Two threads in this session, both driven by Robert's request for +"a way to plot the optimized cases vs the experimental data": + +### Promoting inspect_archive's selection logic to a public API + +The `inspect_archive` CLI had several internal helpers +(`_pareto_history_rows`, `_dedup_on_gene_vector`, `_pick_run`, +`_collect_all_genes`, `_resolve_archive_path`) that contained the +exact logic any post-run analysis tool would want: locate the +archive, pick a run, top-N rank by L2 / per-objective. Robert's +plotting ask would have meant re-implementing that, which is the +worst kind of duplication — same math in two places that drift +apart over time. + +Fix: promote five helpers to public API on +`workflows.optimization.inspect_archive`: + +- `RankedGene` — new public dataclass carrying a `GeneRecord` plus + rank/category/score metadata. The `GeneRecord` keeps the archive + lookup keys (birth_gen/birth_gene); the metadata lets a consumer + know why this gene was selected and color/label accordingly. +- `select_top_genes(genes, *, objective_labels, top_n, + categories=("l2","per_objective"), dedup=True)` — the headline + selector. Returns `Dict[str, List[RankedGene]]` keyed by category + name. The CLI's `_pareto_history_rows` was rewritten to call this + and just format the row dicts on top. +- `pick_run(archive, run_id=None, *, latest_if_none=True)` — picks + a run; raises `ValueError` rather than the CLI's `SystemExit(1)`, + so library callers can catch it cleanly. +- `dedup_on_gene_vector(genes)` — drops duplicate gene-vectors + (caused by NSGA-III elitism carrying winners across generations). + First-sighting-wins ordering preserved. +- `collect_all_genes(archive, run_id)` — public name for + `archive.load_all_genes`. +- `resolve_archive_path(path, default_name)` — public alias of + the existing `_resolve_archive_path` (which already had a clean + injectable signature for testability). + +The CLI now calls into these. Tests pin the new public contracts +independently of the CLI behavior tests. + +### `examples/plot_solutions.py` — interactive plotter + +Single-file example: ~600 lines including docstrings, importable +from notebooks AND runnable as `python examples/plot_solutions.py +calibration_run --top 10 ...`. Composes the public selection API +with `ArchiveDB.load_case_outputs` and `StressStrainExtractor` to +produce: + +1. **Headline figure** — one subplot per SimCase, top-N simulated + stress-strain curves overlaid on the experimental reference. + Curves are colored by rank using a red→blue colormap so rank 0 + is visually obvious. +2. **Slider** below the subplots — sets the visible rank threshold + K. Ranks < K are full opacity; ranks ≥ K fade to 7% opacity. + Lets users interactively narrow focus from "show me the top 10" + to "show me the top 3" without re-running. +3. **Click-to-show parameter panel** — clicking any simulated curve + prints that gene's parameter values + fitness + birth coordinates + in a monospace text strip below the slider. Uses matplotlib's + `pick_event` with a 5-pixel tolerance. +4. **Pareto-front side plot** (`--pareto i,j`) — 2-D scatter for the + chosen objective pair with the L2-closest gene highlighted in + red. Reuses `workflow_common.postprocess.plot_pareto_front`. + +Three ranking modes: `l2` (default — closest to utopian origin), +`objective` (best on a single named objective), `last-gen` (every +individual in the final generation, no ranking). The objective +specifier accepts both indices and label strings. + +The plotter reads exclusively from the SQLite archive — gene +records via `load_all_genes`, simulation outputs via +`load_case_outputs`. The on-disk case directories don't need to +still exist; the archive carries everything needed to reconstruct +stress-strain curves. This was specifically by design — the +archive's `case_outputs` table was added in an earlier session +precisely so post-run analysis would survive workspace cleanup. + +### Test count through session 7 + +- End of session 6: 360 +- Promotion: select_top_genes / pick_run / dedup / collect / resolve + contract tests: 372 +- Plot solutions integration tests (mode selection, sim_case probe, + CLI error handling): 382 + +## Session 8 — Plotter polish + Pareto interactivity + +Robert hit several bugs in the post-run plotter and asked for the +Pareto plot to gain the same interactive affordances as the headline +overlay. + +### Bug fixes in `examples/plot_solutions.py` + +* **`--objective N` no longer silently runs L2 ranking.** + Previously `--mode l2` was the default and `--objective` had no + effect unless the user also explicitly passed `--mode objective`. + This was the root cause of "changing --objective shows the same + plot." `main()` now auto-promotes `--mode l2` → `"objective"` when + `--objective` is supplied. `--objective` with `--mode last-gen` is + reported as an incompatible combination (last-gen mode has no + ranking step, so the flag would have nothing to apply to). + +* **`--no-overlay` flag added.** Previously the headline overlay was + unconditional; passing `--pareto` produced two figures whether the + user wanted both or just the Pareto. Now `--no-overlay --pareto i,j` + produces only the Pareto plot. `--no-overlay` alone reports an + error rather than silently exiting with no figures. + +* **`top_n` honored on the Pareto plot.** The previous implementation + scattered every finite-fitness gene regardless of `--top`. Now + `top_n > 0` restricts the scatter to the N lowest-L2 genes (using + `select_top_genes` for consistency with the inspect-archive + CLI's `--pareto-only` table). `top_n=0` (default) plots every + rank-0 gene — the actual Pareto front — with gene-vector dedup so + elitism-carried duplicates don't pile on. + +* **Slider skip when `n_total == 1`.** A single-solution overlay used + to trigger a matplotlib "identical low/high xlim" warning when + building the slider with `valmin=valmax=1`. The slider would also + be functionless. Now the slider is skipped and its axes hidden + when there's nothing to toggle. + +### Pareto interactivity + +The Pareto figure is now a 2-column layout: scatter on the left, +response inset on the right. + +* **Clickable points.** Each scatter point has `picker=5`. Click + fires a `pick_event` that updates a parameter panel below the + plots (gene vector, fitness, birth coordinates, L2 norm) AND + redraws the response inset. +* **Response inset.** The right axes shows the clicked gene's + simulated stress-strain curves (one line per SimCase, colored by + `tab10`) overlaid with the experimental references (dashed, + same color per SimCase). Initially populated with the L2 + winner's response. +* **L2-winner ring** stays — same red ring on the L2-closest point, + with the colorbar of L2 norms making "balance vs specialization" + legible at a glance. +* **Archive-first experimental data** — the inset reuses + `_resolve_experimental_for_case` so users get the right + reference curve per SimCase without supplying CSV paths. + +### Slope plotting (already in session 7) + +The overlay plot has both stress-strain and slope-strain rows. +Slope is computed via `np.gradient(stress, strain)` for both +simulated and experimental curves so the values match what the +slope objective scored against. Toggleable via `show_slopes=False`. + +### Test count through session 8 + +* Start of session: 390 +* Pareto top_n / inset / picker / experimental tests: +5 → 395 +* CLI auto-promote / no-overlay / l2-vs-objective title tests: +5 → 400 +* Archive-first experimental + slope helper + overlay no-CSV: +3 → 403 + +Total: **403 passing** (33 driver + 370 non-driver). + +## Session 8 addendum — Backward compat for archives without `experiments` + +Robert hit `sqlite3.OperationalError: no such table: experiments` +running the plotter against an archive created before the +`experiments` table was added (older code, real .db file on his +machine). He had supplied `--experimental` CSV paths as the fallback, +but the load_experiment call crashed before the fallback was reached. + +Root cause: `ArchiveDB.open()` only runs the schema-creation script +on writable opens (a read-only SQLite connection can't modify the +schema, by design). So a read-only open of a pre-experiments-table +archive never gets the `IF NOT EXISTS` table-creation statement +executed; the table is genuinely absent, and the first SELECT +against it raises `OperationalError`. + +Fix: `load_experiment` and `list_experiments` now check for the +table's existence via a small `_has_table(name)` helper before +querying. Missing → return `None` / `[]` respectively, matching the +contract those methods already had for "no row found." The plotter's +`_resolve_experimental_for_case` therefore reaches its CSV-fallback +branch cleanly when the archive is too old to have stored +experimental data. + +Writable opens are unaffected: they run the full schema script on +every open, so missing tables get created on first write. A user +who opens a pre-experiments archive in write mode and calls +`record_experiment` gets the table created on the fly with no +manual migration step required. + +### Test count through session 8 addendum + +- Start: 403 +- Archive-level missing-table tests (load returns None, + list returns [], writable open creates table): +3 → 406 +- Plot-level tests reproducing Robert's command line + (resolve falls back to CSV, returns None without fallback, + full main() flow): +3 → 409 + +Total: **409 passing** (33 driver + 376 non-driver). + +## Session 9 — Optimization windowing, generic strain, plotter polish + +Robert's feedback covered four threads: + +1. Slope plots needed semi-log y because elastic-vs-plastic slopes + span orders of magnitude and the optimized curves were + visually invisible compressed against the elastic spike. +2. Optimized curves weren't appearing on slope axes (turned out to + be the same compression issue — the lines were drawn but + indistinguishable from the x-axis at linear scale). +3. The old framework's `minmax_strain` field was documented in + `case_data` but unwired anywhere — optimizer was scoring against + the full curve, often dominated by elastic regime instead of + the plastic regime users actually care about. +4. `StressStrainExtractor`'s field names baked def-grad-specific + assumptions that mismatched users with sims that already write + strain measures (Lagrange/Euler) directly. + +### Extractor refactor (workflow_common/objectives.py) + +* `def_grad_output` → `strain_source_output` +* `def_grad_column` → `strain_source_column` +* `axial_minus_one` → `biot` (it IS Biot strain in 1D; the old + name was inscrutable) +* `log_axial` → `log` +* New `direct` strain source: reads the column verbatim, no + transformation. Use case: sim already wrote + `avg_lagrangian_strain.txt` and the user wants `E33` straight. +* New `window: Optional[Tuple[float, float]]` field — crops + ``(strain, stress)`` to a strain interval. Compares against + ``|strain|`` so the same value works for tension and compression + without sign-handling at the call site. +* Legacy aliases (`f11_minus_one`, `log_f11`) and their + `DeprecationWarning` machinery removed entirely. No deprecation + period — Robert is the only user. + +### Optimization windowing wired through + +`StressStrainExtractor.window` is the single point of windowing. +Both `StressStrainObjective` and any custom evaluator that uses an +extractor get cropping for free, since they consume the +extractor's output. The example `nsga3_calibration.py` reads +`sim_case.case_data["minmax_strain"]` and passes it to the +extractor as `window=`. The custom `_StdNormalizedStress/Slope` +evaluators dropped their `desired_strain` parameter — that was +half-windowing (upper bound only) and is now subsumed by the +extractor's `window`. + +### Archive: minmax_strain column on experiments + +Added `minmax_strain TEXT` column storing JSON `[lo, hi]` so +plotter can shade the optimized region against the full curve. +Both sides may be null for unbounded. + +* `record_experiment(..., minmax_strain=)` — new optional kwarg. +* `load_experiment_window(run_id, sim_case_idx)` — new method + returning the stored `(lo, hi)` tuple. Separate from + `load_experiment` so callers that only need the window + (e.g. plotter shading) skip the DataFrame deserialize. +* Backward compat for archives missing the column: + `load_experiment_window` returns None gracefully via a new + `_has_column` helper. +* Forward compat for archives missing the column on writable + open: a new `_migrate_add_missing_columns` runs after the + schema script and `ALTER TABLE`s the column in. Idempotent + (guarded by `_has_column`) so reopens of fresh archives + don't raise "duplicate column." + +### Driver wiring + +`_record_experiments_from_problem` now reads +`SimCase.case_data["minmax_strain"]`, defensively unpacks it as a +2-tuple of floats/Nones, and passes it to `record_experiment`. +Bad input (e.g. a string) logs a warning and drops the window — +the experiment still gets recorded so plotting keeps working. + +### Plotter (examples/plot_solutions.py) + +* Slope axes now use `set_yscale("symlog", linthresh=...)` with + `linthresh = 0.001 * max(|slope|)`. Resolves both the "elastic + spike compresses everything" issue and noise-induced sign-flip + brittleness in one shot. +* Window shading: `axvspan` on both stress and slope axes for + each SimCase whose archive entry has a non-None window. + Translucent green (alpha=0.08), zorder=0 so it sits behind + curves. Mirrors to negative-strain region for compression + loadings (sign detected from experimental or first sim curve). +* `per_case_window` collected alongside `per_case_curves` and + `per_case_exp` inside the same `with ArchiveDB(...)` block so + there's only one connection lifecycle to manage. + +### Tests added + +* Extractor: `direct` strain, window cropping (tension), window + cropping (compression via abs), exclude-all error, inverted + bounds error. (5) +* Archive: window persistence, unbounded sides, missing-record + returns None, missing-column returns None, writable open + migrates, migration idempotent on fresh archive. (6) +* Driver: case_data minmax_strain plumbed to archive (with + multiple SimCase variants), malformed minmax_strain handled + gracefully. (2) +* Plot: optimized curves present on slope axes, slope yscale is + symlog, window shading drawn when archive has it, no shading + when window is None. (4) + +Existing tests updated where API names changed: `test_objectives` +log/biot renames + drop legacy-alias test, `test_example_imports` +drop `desired_strain` arg, `_make_result_set` helper accepts a +custom output name for direct-strain tests. + +### Test count through session 9 + +- Start: 409 +- Extractor (direct strain + window): +5 → 414 +- Archive (minmax_strain column + migration): +6 → 420 +- Driver (case_data plumbing): +2 → 422 +- Plot (slope curves + symlog + shading): +4 → 426 + +Note: also dropped the legacy-aliases test on the old field +names, so net delta to test_objectives was +5 minus 1 = +4. Net +total **425 passing** (35 driver + 390 non-driver). + +## Session 10 — Pareto interactivity fixes, archived extractor configs + +Robert reported the new Pareto figure was clean but had three real +issues plus one usability nit: + +1. Bug — clicking a Pareto point only plotted "1 of 2 SimCases" in + the inset response. The same SimCase failed regardless of which + Pareto pair was selected, so the bug wasn't in pair-selection + logic. +2. The headline overlay still showed alongside `--pareto`. Robert + wanted bare `--pareto` to mean "only the Pareto." +3. The L2 winner ring used full-objective L2, not the projected + pair's L2 — surprising when the user picks 2 of N objectives + and expects the "balanced winner ON THIS PROJECTION." +4. Coloring by full L2 made it hard to see whether a Pareto front + was a true tradeoff curve. Different metrics highlight + different aspects (proximity to Y=0 plane, X=0 plane, + asymmetry between specialists and compromises). + +### Root cause for the SimCase-disappearing bug + +The plotter's click handler built a default +`StressStrainExtractor()` for every SimCase. Robert's calibration +used `strain_source="time_rate"` with case-specific `strain_rate`; +the default uses `strain_source="biot"` against `F33`. For ANY +SimCase whose archived `case_outputs` shape didn't fit the default +Biot-strain expectations (or whose curve was indistinguishable +from noise after default extraction), `_extract_curve` returned +None silently — and the SimCase vanished from the inset. + +The architectural fix is to archive the extractor config the +optimizer actually used. The plotter consults the archive first, +so a user is guaranteed to see plots that match what the optimizer +scored against — not whatever the plotter's default would produce. + +### Archive: extractor_config column on experiments + +* New `extractor_config TEXT` column on the `experiments` table. + JSON-encoded dict from `StressStrainExtractor.to_dict()`. +* `record_experiment(..., extractor_config=)` accepts the dict. +* `load_extractor_config(run_id, sim_case_idx)` returns the dict + or None (missing column / row / null value all fold to None). +* Migration entry added to `_migrate_add_missing_columns` so old + archives gain the column on writable open. + +### Extractor JSON contract + +* `StressStrainExtractor.to_dict()` — serializes all 8 fields, + converting `window: tuple` to `[lo, hi]` for JSON safety. +* `StressStrainExtractor.from_dict(d)` — tolerant of missing keys + (defaults fill in) and extra keys (silently ignored), so + forward-compat works in both directions: an older archive on + newer code OR a newer archive on older code both load. + +### Driver wiring + +`_record_experiments_from_problem` now also reads +`evaluator.extractor.to_dict()` if the evaluator exposes +`.extractor`. Custom evaluators without that attribute (or whose +extractor doesn't have `to_dict`) get a warning and the experiment +is recorded with extractor_config=NULL. Plot-time fallback to a +default extractor still works in that case. + +### Plotter resolver + +New `_resolve_extractor_for_case(archive, run_id, sc_idx, factory=)`: + + archive's stored config → user-supplied factory → default + +The window field is **stripped** from any archived extractor +before plot-time use: the plotter shows full curves with the +window shaded as an overlay, so cropping at extraction would hide +exactly the context users want to see. + +Both `plot_top_solutions_overlay` and the Pareto plotter use the +resolver. The Pareto plotter also hoists resolution outside the +gene loop (extractors are per-SimCase, not per-gene, so building +them per-gene was wasted work). + +### Pareto subset L2 + color modes + +The L2 winner ring now lands on the gene with minimum +**subset L2** (sqrt(x² + y²) on the projected pair) — answering +"best balanced compromise on what I'm looking at" rather than +"globally best across all objectives." + +New `--color-by` flag with five options: + +* `subset_l2` (default) — distance from utopia in the projected + plane. Iso-curves are circles. Uniform color along the front + signals a true tradeoff curve. +* `full_l2` — distance across all objectives. Catches points + that look balanced on the projection but are bad on the + hidden objectives. +* `x` / `y` — single axis. Iso-curves are vertical/horizontal + lines. Reveals proximity to Y=0 / X=0 plane. +* `asymmetry` — `|x - y| / (x + y)`. Zero = perfectly balanced + on the pair. Highlights specialists vs compromises. + +### CLI inversion + +* `--no-overlay` removed. +* `--overlay` added — explicit opt-in to keep the overlay alongside + `--pareto`. +* Bare `--pareto i,j` → only Pareto figure produced. +* Bare command (no `--pareto`) → still shows overlay (backward + compat with the "user just wants to look at results" workflow). + +### Tests added + +* Archive (5): extractor_config persists, missing record returns + None, null record returns None, old archive without column + returns None, writable open auto-migrates. +* Objectives (4): JSON round-trip with tuple→tuple, missing-keys + tolerance, extra-keys tolerance, window=None round-trip. +* Driver (2): extractor config from evaluator flows through to + archive, evaluators without `.extractor` skip cleanly. +* Plot (12): subset-L2 winner ring (not full-L2), color_by exact + values for subset_l2 and full_l2, x/y/asymmetry render OK, + invalid color_by raises, Pareto inset shows all SimCases on + initial draw, archived extractor used when present (with + custom column names that would fail the default), bare + `--pareto` skips overlay, `--pareto --overlay` shows both, + no-pareto-no-overlay still shows overlay. + +The autouse `_agg_backend` fixture now also runs `plt.close("all")` +on test teardown — without that, the figure-leak warning escalated +once test count exceeded matplotlib's 20-figure threshold. + +### Test count through session 10 + +- Start: 425 +- Archive (extractor_config column + migration): +5 → 430 +- Objectives (to_dict / from_dict round-trip): +4 → 434 +- Driver (extractor config plumbing): +2 → 436 +- Plot (subset L2 + color modes + click-handler + extractor + resolver + CLI inversion): +12, minus 2 dropped `--no-overlay` + tests → **net +10** → **446** + +Total: **446 passing** (37 driver + 409 non-driver). + +## Session 11 — Save the simulation curve too + +Robert's response to session 10 had two parts: (1) the diagnosis +of "only 1 of 2 SimCases plotted" was incomplete (the same +default-extractor logic worked for the overlay so something else +was going on, and the architectural fix is still right but my +narrative was wrong), and (2) more substantively, the framework +should save the SIMULATION-side (independent, dependent) curve +alongside the experimental data. Storing only the experimental +side and reconstructing the sim side after the fact loses +information and makes re-analysis harder than it needs to be. + +Robert called out three motivations: + +* Direct retrieval beats reconstruction. With the sim curve + persisted, post-run analyses pull it back as data, not as + something rebuilt by re-running the extraction pipeline (which + could drift if extractor logic ever changes). +* Re-running analyses with different metrics (RMSE → MAE, + different windowing, different weighting) becomes a query, not + a re-extraction. Especially valuable when deciding to evaluate + parameter sets against new criteria after the fact without + re-running the whole optimization. +* Bayesian / surrogate model training. Mapping + ``gene_vector → response_curve`` lets surrogate-driven search + speed up future optimizations; that requires the curves to be + available and stable. + +Critical detail: store the FULL extraction range, not the +windowed range. The optimizer's window crops which region the +metric was computed over, but downstream analyses might want +data outside that region. Strip the window before extraction +when archiving. + +### Schema: case_curves table + +New table keyed on ``(run_id, birth_gen, birth_gene, sim_case_idx)`` +matching the case_outputs key shape. Columns: + +* ``independent_blob`` — pickled np.ndarray (typically strain). +* ``dependent_blob`` — pickled np.ndarray (typically stress). +* ``independent_label`` / ``dependent_label`` — TEXT hints; the + framework defaults to "strain" / "stress" but the columns let + future extractor types (thermal, creep) carry their own labels. +* FK cascade on ``runs(run_id)``. + +Why a separate table rather than a column on case_outputs: +case_outputs holds raw simulator output (avg_stress.txt etc.), +which is an INPUT to extraction. The extracted curve is the +comparison target the optimizer scored against. Different +conceptual layer; different table. + +### Archive API + +* ``record_case_curve(run_id, *, birth_gen, birth_gene, sim_case_idx, independent, dependent, independent_label=None, dependent_label=None)``. + Validates 1-D shapes and matching length. +* ``load_case_curve(run_id, *, birth_gen, birth_gene, sim_case_idx)`` + returns ``(ind, dep, ind_label, dep_label)`` or None. Tolerates + missing table on read-only opens of older archives. +* ``discard_from_generation`` extended to also delete case_curves + rows past the resume point. +* ``delete_run`` cascades automatically via FK. + +### Problem-level wiring + +``Problem._archive_case`` now calls a new +``_archive_case_curve(ctx, rs)`` after writing case_outputs. +Logic: + +* Cache the extractor per ``sim_case_idx`` on the Problem + instance (lazy, populated on first hit). The extractor is + found by walking ``objective_specs`` for the first one with + ``spec.sim_case == sim_case_idx`` AND a usable + ``.extractor`` attribute. +* Strip the window from the cached extractor via the + ``to_dict`` / ``from_dict`` round-trip so the archived curve + covers the full range. Falls back to using the original + extractor if to_dict isn't available — better windowed than + nothing. +* Run the extractor, archive the result. Failures log at debug + (not warning) since the evaluator's own failure handler is + the primary path for reporting extraction problems; this is + a bonus archive write. + +Custom evaluators without an ``.extractor`` attribute leave +case_curves empty for that SimCase. The plotter handles that +by falling back to ``case_outputs`` + extraction. + +### Plotter + +New ``_load_or_extract_curve`` helper: + + archived case_curve → re-extract from case_outputs → None + +Both ``plot_top_solutions_overlay`` and the Pareto plotter +route through this helper. Old archives without case_curves +hit path 2 and continue working unchanged. + +### Tests added + +* Archive (7): record/load round-trip, shape validation, missing + record returns None, missing table returns None on read-only, + collision-replace, FK cascade on delete, discard_from_generation. +* Driver (2): full-range curves archived per (gene, sim_case) + with window stripped, custom evaluators without extractor + skip cleanly. +* Plot (1): plotter prefers archived curves over re-extraction. + +### Test count through session 11 + +- Start: 446 +- Archive (case_curves CRUD + cascade + discard): +7 → 453 +- Driver (curves archived from Problem hook): +2 → 455 +- Plot (archived-curve preference): +1 → 456 + +Total: **456 passing** (39 driver + 417 non-driver). + +## Session 12 — Sign correction for compressive loading + +Robert reported the plotter rendering a compressive case with +positive strain alongside negative stress — strain axis flipped +relative to the stress axis. Root cause: the example calibration +script applied ``abs()`` to ``case_data["strain_rate"]`` before +constructing the extractor, so the extractor's +``strain = strain_rate * time`` produced positive strain even on +runs the user had configured as compression (signed strain rate). +Stress (Szz) was naturally negative. The two axes ended up +mirrored relative to each other. + +### Three layers of fix + +**1. Example fix (preventive).** Drop ``abs()`` on the strain +rate so it carries sign through to the extractor unchanged. +Evaluators that score on magnitudes (the example's +``_StdNormalized*Evaluator`` family) already apply ``np.abs`` +themselves before computing RMSE, so the sign change doesn't +affect optimization scores. The fix only changes what gets +passed to the extractor for archiving and plotting. + +**2. Post-processing fix (Robert's specific ask).** This is +where users notice the bug — plots, not run logs. The plotter's +``_load_or_extract_curve`` helper now accepts an +``experimental_reference`` kwarg ``(exp_ind, exp_dep)``; when +supplied, both the loaded simulated arrays are sign-matched +against the experimental columns via +``match_sign_to_reference``. Both ``plot_top_solutions_overlay`` +and the Pareto plotter resolve experimental data BEFORE the +gene-curve loop and thread it through, so the sign-correction +sits at the single curve-loading point. Old archives written +before the save-off fix get corrected at plot time without any +upgrade path needed. + +**3. Save-off fix (defensive, archive-side).** ``Problem. +_archive_case_curve`` now also runs the same correction before +calling ``record_case_curve``. The same evaluator that supplied +the extractor must also supply ``.experimental`` and the +matching column-name attributes (``experimental_strain_col`` / +``experimental_stress_col``, defaulting to ``"strain"`` / +``"stress"``). Resolved together via a new +``_resolve_case_curve_state(sim_case_idx) -> +Tuple[extractor, exp_df]`` so both come from the same evaluator +choice — keeps them consistent. Result: archived ``case_curves`` +rows are correctly-signed for any downstream consumer +(surrogate-model trainers, alternative post-processing scripts), +not just the bundled plotter. + +### The helper itself + +Originally drafted with a "compute dominant signs of both arrays, +multiply by -1 if they disagree" approach. Robert pointed out +``np.copysign`` is the right primitive — simpler and conveys +intent more clearly. Final form: + +```python +def match_sign_to_reference(sim, reference): + if sim.size == 0 or reference.size == 0: + return sim + ref_dominant = float(reference[int(np.argmax(np.abs(reference)))]) + if ref_dominant == 0.0: + return sim + return np.copysign(sim, ref_dominant) +``` + +Picking a single representative sign from the reference (its +max-magnitude value) sidesteps numerical noise around zero — +a Voce stress curve's max-magnitude point is at peak load, +unambiguous in sign. Then ``np.copysign(sim, scalar)`` forces +every element of ``sim`` to that sign while preserving +magnitude. For monotonic loading (the framework's target) this +is correct; for cyclic loading where the curve genuinely +crosses zero this would over-correct, but the framework +doesn't currently target cyclic fits. + +The helper is exported from ``workflow_common`` so external +code can apply the same correction. + +### Tests added + +* Objectives unit (7): compression flip, already-aligned no-op, + unaligned lengths handled, empty sim, empty ref, all-zero ref, + noise-near-zero robustness. +* Driver (2): sign-correction at save-off given a compression + experimental reference, no correction when experimental data + is absent. +* Plot (2): sign-correction at plot time given wrong-signed + archived curves, no correction when experimental data is + absent. + +### Test count through session 12 + +- Start: 456 +- Objectives helper: +7 → 463 +- Driver save-off: +2 → 465 +- Plot post-processing: +2 → 467 + +Total: **467 passing** (41 driver + 426 non-driver). + +## Session 13 — Use the smoothing module, in the right direction + +Robert called out two related issues with the example evaluators +``_StdNormalizedStressEvaluator`` / ``_StdNormalizedSlopeEvaluator`` +in ``nsga3_calibration.py``: + +1. **Wrong direction.** They were interpolating sim onto exp's + strain grid (``np.interp(target_strain=exp, sim_strain, + sim_stress)``). The original ExaConstit code interpolated + experimental data onto the simulation's grid — which is the + right way: the optimizer is computing error at sim's own + sample points. + +2. **Bypassing the framework.** Used raw ``np.interp`` + instead of the framework's smoothing module + (``PchipSmoother``, ``ArcLengthSmoother``, + ``auto_smoother``). The smoothing module exists exactly for + this — picks PCHIP for monotonic data, ``ArcLengthSmoother`` + for non-monotonic, and the ``sample_at`` method gives + "evaluate at these target x's" semantics with PCHIP's + no-overshoot guarantee. + +### Evaluator rewrite + +Both ``_StdNormalized{Stress,Slope}Evaluator.evaluate`` now: + +* Mask SIM (not exp) to the common strain range — sim's sample + points become the comparison grid. +* ``PchipSmoother(strict_monotonic=False).sample_at(exp_strain, + exp_stress, sim_strain_in)`` brings exp onto sim's grid via + PCHIP. ``strict_monotonic=False`` accepts mild noise in + exp_strain (auto-sort); it doesn't handle genuinely + non-monotonic data, which the framework's ``ArcLengthSmoother`` + is for. Mechanical-test exp data is cleaned and clipped before + calibration anyway, per Robert. +* RMSE / std computed on the sim-grid-aligned arrays. + +### Sign-correction sites updated + +The post-processing plotter and the archive-time +``Problem._archive_case_curve`` previously used a +``match_sign_to_reference`` helper that wrapped ``np.copysign`` +with a parametric ``np.interp`` resample. Robert pointed out +that's the wrong tool — the framework already has +``PchipSmoother`` for the same job. Both sites updated to: + +```python +smoother = PchipSmoother(strict_monotonic=False) +exp_dep_at_sim = smoother.sample_at( + np.abs(exp_ind), exp_dep, np.abs(sim_ind), +).y +sim_dep = np.copysign(sim_dep, exp_dep_at_sim) +sim_ind = np.copysign(sim_ind, exp_ind[-1]) # scalar from last point +``` + +Element-wise ``np.copysign`` on the dependent axis preserves +cyclic structure (each sim point inherits the sign of the +corresponding exp point at the same strain magnitude). The +independent axis uses a scalar sign drawn from exp's last +strain value — unambiguous for monotonic loading; cyclic data +ending back at zero would degenerate, but the framework +targets monotonic loading near-term. + +The thin ``match_sign_to_reference`` helper has been removed — +``np.copysign`` doesn't need a wrapper. + +### Tests changed + +* Removed: 7 ``match_sign_to_reference`` unit tests (helper gone). +* Added: 2 evaluator-direction tests pinning the + PchipSmoother-based score against a hand-computed + expected value, ensuring the rewrite uses exp→sim alignment + and not sim→exp. +* The 2 existing driver tests (sign-corrected curves at + save-off) and 2 plot tests (sign-corrected curves at plot + time) continue to exercise the new PCHIP-aligned path — + they pass unchanged. + +### Test count through session 13 + +- Start: 467 +- Removed match_sign helper tests: -7 → 460 +- Added evaluator-direction tests: +2 → 462 + +Total: **462 passing** (41 driver + 421 non-driver). + +## Session 14 — Slope plot truncation + +Robert's report: with slope data like ``[strain=-1e-6..-0.12, slope=-128, -40, +210, +128, ..., 0.7]`` the slope subplot only shows "the very start" — the rest of the curve appears unplotted. Confirmed with and without symlog. Only ``abs(slope)`` made the rest visible, which Robert explicitly didn't want. + +### Diagnosis + +Matplotlib was actually plotting all 133 points. Verified with standalone repro on Robert's exact arrays — every point made it onto the canvas. The problem was **visualization**, not data: an elastic spike (~+210) and a brief start-of-test numerical-noise spike (~-128) dominated the auto-scaled y-axis. The plastic-regime bulk (slope ≈ 0.7-3, the actual response of interest) got compressed into a hairline near the chart edge. To Robert's eye, that hairline read as "only the start was plotted." ``abs(slope)`` happened to fix it because the negative spike's contribution to ylim disappeared, doubling the bulk's relative real estate — but at the cost of losing sign information. + +Old code (``examples/plot_solutions.py``): + +```python +max_abs = float(np.max(np.abs(concat))) +linthresh = max(max_abs * 0.001, 1e-9) +slope_ax.set_yscale("symlog", linthresh=linthresh) +``` + +Two failure modes baked in: ``max(|slope|)`` lets a single outlier dictate ``linthresh``, and there's no explicit ``set_ylim`` so matplotlib's auto-scale also sizes against the spike. + +### Fix + +Drive both the y-axis range AND ``linthresh`` from robust percentiles of ``|slope|``, not extremes: + +```python +finite = concat[np.isfinite(concat)] +abs_finite = np.abs(finite) +nonzero = abs_finite[abs_finite > 0] +# 90th percentile: captures the bulk Voce response, excludes +# spikes (typically ≤5% of points). 1.5x for headroom. +p90 = float(np.percentile(abs_finite, 90)) +bulk_extent = max(p90 * 1.5, 1.0) +slope_ax.set_ylim(-bulk_extent, bulk_extent) +# Linthresh from 5th percentile of nonzero |slope| — keeps the +# bulk in the LOG region, not the linear band. +linthresh = max(float(np.percentile(nonzero, 5)) * 0.5, 1e-9) +slope_ax.set_yscale("symlog", linthresh=linthresh) +``` + +For Robert's data: +- ``p5(|slope|)`` ≈ 0.77, ``p50`` ≈ 1.43, ``p90`` ≈ 2.54, ``max`` ≈ 210.9 +- Old: ``linthresh = 0.21``, ylim auto ≈ ``(-323, +530)`` — bulk visually compressed. +- New: ``linthresh = 0.38``, ylim = ``(-3.81, +3.81)`` — bulk fills the chart; spikes extend off the top and bottom edges, still visible as line segments leaving the visible region. + +Crucially the spike data is **not** clipped from the line — matplotlib draws the line in full and just renders the portions outside ``ylim`` past the chart edge. Tested explicitly via ``line.get_ydata().max() > 100`` and ``< -50`` after plotting. + +### Tests + +Added ``test_plot_overlay_slope_ylim_robust_against_outliers``: +- Builds a sim curve with the failure pattern (elastic + noise spikes plus smooth bulk) +- Asserts symmetric ylim with ``abs_extent < 50`` (bulk-scale, not spike-scale) +- Asserts plotted y-data still includes the spike values (range, not data, was the fix) + +### Test count through session 14 + +- Start: 462 +- Added robust-ylim slope test: +1 → 463 + +Total: **463 passing**. diff --git a/workflows/exaconstit-calibrate/LICENSE b/workflows/exaconstit-calibrate/LICENSE new file mode 100644 index 0000000..e1db481 --- /dev/null +++ b/workflows/exaconstit-calibrate/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2024, Lawrence Livermore National Security, LLC. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/workflows/exaconstit-calibrate/MANIFEST.in b/workflows/exaconstit-calibrate/MANIFEST.in new file mode 100644 index 0000000..50b490d --- /dev/null +++ b/workflows/exaconstit-calibrate/MANIFEST.in @@ -0,0 +1,14 @@ +# Extra files to include in the sdist. The wheel is unaffected +# (wheel contents come from setuptools.packages.find + package_data). +# +# These files matter to people installing from source but not to +# people installing from the wheel: +# - examples/ is the runnable side-by-side port of the pre-refactor +# script. Users pulling from source want it; wheel users will +# typically read it on GitHub instead. +# - RELEASING.md is only useful to maintainers cutting new releases. + +recursive-include examples *.py *.md +include RELEASING.md +include README.md +include LICENSE diff --git a/workflows/exaconstit-calibrate/README.md b/workflows/exaconstit-calibrate/README.md new file mode 100644 index 0000000..55232bc --- /dev/null +++ b/workflows/exaconstit-calibrate/README.md @@ -0,0 +1,399 @@ +# exaconstit-calibrate + +Calibration infrastructure for [ExaConstit](https://github.com/LLNL/ExaConstit): +material-parameter fitting via (U-)NSGA-III on the +[rcarson3/deap](https://github.com/rcarson3/deap) fork, plus a +code-agnostic framework for driving external simulation codes from +Python workflows. + +**Current scope:** NSGA-III-based crystal-plasticity parameter +calibration (replaces `ExaConstit_NSGA3.py` + friends). + +**Planned scope:** Macroscale yield-surface data generation and Barlat +surface fitting. The framework layer is code-agnostic and already +supports arbitrary drivers; only the yld-specific driver is pending. + +**Out of scope:** Full additive-manufacturing challenge-problem +workflows (those live under `workflows/Stage3/` in ExaConstit and +are a separate pipeline). + +The package ships two importable names: + +- `workflow_common` — the code-agnostic framework (Problem + orchestrator, ArchiveDB, result readers, backends). No DEAP + dependency. +- `workflows.optimization` — the NSGA-III driver. Requires the + DEAP fork. Named `workflows` to preserve import paths from + migrated pre-refactor drivers. + +## Installation + +Requires **Python 3.12 or newer**. Most HPC sites are standardizing +on 3.12+; older versions are not supported because the codebase +uses post-3.10 typing idioms (`X | Y`, PEP 604 unions, etc.). + +### Core framework only + +```bash +pip install exaconstit-calibrate +``` + +This installs `workflow_common` and `workflows` with `numpy`, +`pandas`, and `scipy` only. No DEAP, no matplotlib, no flux-core. +Suitable for users who want the Problem orchestrator, archive, +and result readers but are plugging in their own optimizer or +using a different plotting stack. `import workflows.optimization` +still works but driver functions raise `ModuleNotFoundError` on +first use (they are lazily imported). + +### With the NSGA-III driver (typical user setup) + +```bash +pip install "exaconstit-calibrate[nsga3,plot]" +``` + +Adds the DEAP fork and matplotlib. This is what you want for +material-parameter-calibration workflows and for running the +`run_nsga3` driver. + +### Development install + +```bash +git clone https://github.com/LLNL/ExaConstit.git +cd ExaConstit/workflows # or wherever this package lives +pip install -e ".[test]" +python -m pytest tests/ -q +``` + +`-e` gives you an editable install so your checkout is what gets +imported. `.[test]` pulls in pytest, matplotlib, and the DEAP fork. +Expected output: `254 passed` in 30-45 seconds. + +### HPC install (LLNL, ...) + +```bash +pip install "exaconstit-calibrate[nsga3,plot,flux]" +``` + +The `flux` extra pulls `flux-python` for the `FluxBackend`. This +will only succeed on systems where `flux-core` is already +installed (that is, inside a Flux allocation on an HPC system). +On a developer laptop you almost certainly do not want this +extra. + +## Quick start + +```python +from pathlib import Path +import numpy as np +import pandas as pd +from workflow_common import ( + CaseTemplater, TemplateTarget, + TemplatePathResolver, TemplatePropertyWriter, + TextTableReader, TextTableSpec, + Problem, ProblemConfig, SimCase, ObjectiveSpec, + StressStrainExtractor, StressStrainObjective, + LocalBackend, Manifest, ArchiveDB, + load_experimental_csv, +) +from workflows.optimization import Bounds, RunConfig, run_nsga3 + +# ... build templater / writer / resolver / reader / evaluator ... + +archive = ArchiveDB("opt.db") +archive.open() +run_id = archive.start_run(seed=42, param_names=["yield", "hardening"]) + +problem = Problem( + config=ProblemConfig(binary=Path("/path/to/mechanics"), ...), + param_names=["yield", "hardening"], + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(evaluator, sim_case=0)], + templater=templater, + property_writer=writer, + resolver=resolver, + backend=LocalBackend(max_workers=4), + reader=reader, + manifest=Manifest("wf/manifest.jsonl"), + archive=archive, + archive_run_id=run_id, +) + +result = run_nsga3( + problem, + bounds=Bounds(lower=np.array([100., 1000.]), + upper=np.array([500., 3000.])), + config=RunConfig( + n_generations=50, population_size=60, + unsga3=True, seed=42, + checkpoint_dir=Path("ck"), + cleanup_keep_generations=2, # keep inode count bounded + ), +) + +archive.close() +print("Pareto size:", len(result.pareto_front)) +``` + +See `workflow_common/MIGRATION.md` for a full walk-through of +porting an existing `ExaConstit_NSGA3.py` driver to this package, +and `workflow_common/ARCHITECTURE.md` for the design-rationale +overview, layering diagram, and decision table. + +For a runnable side-by-side example that mirrors the old script +top-to-bottom — same parameter bounds, same two-experiment +topology, same GA defaults — with inline comments quoting the +pre-refactor code next to each new-framework equivalent, see +`examples/nsga3_calibration.py`. It's the "show, don't tell" +counterpart to the migration guide. + +## Watching a run in progress + +During a run, the driver writes two human-readable text files +(tab-delimited, matches the pre-refactor driver's format exactly): + +- `logbook1_stats.log` — per-generation avg / std / min / max + fitness, plus ND / GD / HV for multi-objective runs +- `logbook2_solutions.log` — one row per individual per + generation with gene vector and fitness + +These land in `RunConfig.log_dir`, which defaults to the +`checkpoint_dir` if set, otherwise `cwd`. `less`, `tail -f`, and +`grep` all work. Turn them off with `write_logbook_files=False`. + +If you enabled archiving (`ArchiveDB`), a `archive.db` SQLite +file sits alongside. You don't need a SQLite client to read it — +there's a CLI: + +``` +python -m workflows.optimization.inspect_archive ./run_dir --runs +python -m workflows.optimization.inspect_archive ./run_dir --gens +python -m workflows.optimization.inspect_archive ./run_dir --gens-best +python -m workflows.optimization.inspect_archive ./run_dir \ + --genes --pareto-only --top 5 --format csv > best.csv +python -m workflows.optimization.inspect_archive ./run_dir \ + --clean-empty-runs --dry-run +``` + +Views: + +- `--runs` — every run in the archive +- `--gens` — per-generation summary stats (default) +- `--gens-best` — convergence view. Running best fitness per + objective, plus the generation the current L2 champion was born + in. Plateau detection: if `champion_birth_gen` holds steady for + many rows, the GA is stuck +- `--genes` — individual gene records; capped at 200 rows by + default (`--limit N` to override, `--limit 0` for no cap). Pair + with `--pareto-only` for top-N-across-history (top 3 by L2 norm + plus top 3 per objective, with an explicit `l2_norm` column so + every row is self-verifying), or with `--gen N --pareto-only` + for the rank-0 set at one specific generation. Passing + `--pareto-only` by itself implies `--genes` +- `--clean-empty-runs` — prune runs that have no generations and + no case outputs (typical residue of aborted debugging sessions). + Pair with `--dry-run` to preview; `--age-minutes 0` overrides + the default 60-minute safety gate that protects in-progress + runs + +Formats: `table` (default), `csv`, `json`. Filters: `--run RUN_ID`, +`--gen GEN_IDX`, `--pareto-only`, `--top N`, `--limit N`. Gene +vectors and fitness tuples are expanded into named columns using +the run's stored `param_names` and `objective_labels`, so the +output has meaningful headers (`yield_stress`, `hardening`, +`rmse`) rather than cryptic positional indices. + +Scale: on a 50 000-record archive (500 gens × 100 pop × 8 +objectives), `--runs` / `--gens` are near-instant; `--gens-best` +and `--pareto-only` run in about a second, dominated by SQLite +JSON decode. + +## Resuming a run + +Two ways to pick up from the last checkpoint: + +``` +python nsga3_calibration.py --resume-latest +python nsga3_calibration.py --resume-from 15 # gen number +python nsga3_calibration.py --resume-from path/to/checkpoint_gen_15.pkl +``` + +`--resume-latest` picks the highest-numbered pickle in the +checkpoint directory — the common case after a crash. +`--resume-from N` looks up `checkpoint_gen_N.pkl` in the +configured `checkpoint_dir`; giving a path uses it verbatim. + +On resume the example driver deliberately does NOT call +`archive.start_run()`, leaving `Problem.archive_run_id=None` so +the library adopts the UUID stored in the pickle. Calling +`start_run` on resume generates a fresh UUID that doesn't match +the pickle, which the library rejects with a diagnostic error +pointing at the fix. Stale archive rows from a pre-crash partial +generation are cleaned automatically via +`discard_from_generation`. + +## Recovering blown-away logs + +If the `.log` files have been lost or truncated but the pickle is +still around: + +``` +python -m workflows.optimization.regenerate_logbook_files \ + calibration_run/checkpoint_files/checkpoint_gen_15.pkl +``` + +Writes fresh `logbook1_stats.log` + `logbook2_solutions.log` next +to the pickle (or elsewhere with `--output-dir`). Output is +byte-identical to what a live run would have produced, because +it reuses the driver's own `_LogbookWriter`. + +## Plotting solutions vs experimental data + +Once a calibration finishes, point the example plotter at the +workspace: + +``` +python examples/plot_solutions.py calibration_run --top 10 +``` + +The plotter reads everything from the SQLite archive — gene +records, simulation outputs, AND the experimental reference +DataFrames (the driver records these at run start so you don't +have to keep CSVs around or worry about CSV-to-SimCase ordering +post-run). The on-disk case directories are not required. + +The headline figure is a grid of subplots, one column per SimCase, +with two rows: stress-strain on top, slope-strain on the bottom +(slope = `np.gradient(stress, strain)`, computed identically for +sim and exp so it matches what the slope objective scored). +Below the subplots: + +- a slider that fades curves below a chosen rank — narrow focus + from N to K without re-running; +- a click panel that prints the gene's parameters and fitness + when you click a curve. + +Selection modes: + +- `--objective 0` (or `--objective stress_rmse_0`) ranks by a single + objective. Pass `--objective` and the plotter picks `--mode + objective` automatically. +- `--mode last-gen` plots every individual in the final generation + with no ranking. +- L2 norm is the default — closest-to-utopian-origin across all + objectives. + +Pareto front: + +- `--pareto 0,1` produces a 2-D scatter of two objectives. Points + are colored by L2 norm; the L2 winner is ringed in red. **Every + point is clickable** — click reveals the gene's parameters AND + draws its stress-strain response in an inset axes overlaid against + the experimental references. With `--top N` the scatter is + restricted to the N lowest-L2 genes; without it, every rank-0 + gene from the run is shown. +- `--no-overlay` skips the headline overlay so you can request only + the Pareto. + +Headless / batch: + +``` +python examples/plot_solutions.py calibration_run \ + --top 10 --pareto 0,1 \ + --save overlay.png --save-pareto pareto.png --no-show +``` + +The plotter reuses the selection logic from `inspect_archive` +(the public `select_top_genes` API) so the L2 / per-objective / +dedup behavior matches what `inspect_archive --pareto-only` would +show. + +If for any reason the archive doesn't carry experimental data +(older archives written before the experiments table existed), +fall back to passing CSVs explicitly: + +``` +python examples/plot_solutions.py calibration_run \ + --top 10 --experimental experiments/exp1.txt experiments/exp2.txt +``` + +## Package layout + +``` +exaconstit-calibrate/ +├── pyproject.toml +├── README.md +├── LICENSE +├── workflow_common/ Framework package +│ ├── __init__.py Public API +│ ├── ARCHITECTURE.md Layering, decision table, FAQ +│ ├── MIGRATION.md Step-by-step port guide +│ ├── _fs.py Atomic writes, cd context manager +│ ├── logging_utils.py Stdlib-logging wrapper +│ ├── templates.py %%key%% substitution +│ ├── paths.py PathResolver, TemplatePathResolver +│ ├── manifest.py JSONL run manifest +│ ├── sentinel.py Per-case atomic .done markers +│ ├── platform_detect.py HPC-specific quirks +│ ├── results.py CaseLayout, ResultReader, TextTableReader +│ ├── smoothing.py PCHIP / arc-length / legacy smoothers +│ ├── case_setup.py CaseTemplater + PropertyWriter +│ ├── objectives.py Extractor, evaluator, failure handlers +│ ├── problem.py Problem orchestrator +│ ├── postprocess.py BestSol, plotting, checkpoint loader +│ ├── archive.py SQLite archive (ArchiveDB) +│ └── backends/ LocalBackend + FluxBackend +│ +├── workflows/ +│ └── optimization/ (U-)NSGA-III driver on DEAP fork +│ +├── examples/ +│ ├── README.md How to adapt and run the example +│ └── nsga3_calibration.py Side-by-side port of ExaConstit_NSGA3.py +│ +└── tests/ 255 tests +``` + +## Testing + +After an editable install: + +```bash +python -m pytest tests/ -q # full suite (~30-45s) +python -m pytest tests/test_archive.py -v # one module +python -m pytest tests/ -k "determin" # keyword filter +``` + +The test suite uses a fake Python "simulation binary" defined in +`tests/conftest.py`; no ExaConstit build is required. + +## A note on the package name + +`exaconstit-calibrate` captures the current and planned scope — +crystal-plasticity parameter calibration today, macroscale +yield-surface fitting tomorrow — without overreaching into other +ExaConstit workflow territory (AM challenge-problem orchestration, +etc.). The importable names (`workflow_common`, `workflows`) are +deliberately kept generic and unchanged from the pre-refactor +code so migrated drivers don't need import-path rewrites. + +## License + +BSD-3-Clause (matches ExaConstit). See `LICENSE`. + +## For maintainers + +Cutting a new release? See `RELEASING.md` for the build-and-publish +workflow. Most of the time the whole sequence is: + +```bash +# edit version in pyproject.toml, then: +python -m pytest tests/ -q +rm -rf dist build *.egg-info +python -m build +git tag -a v -m "Release v" +``` + +but there are failure modes (wheel sanity check, stale build dirs, +PyPI immutability) worth being aware of before publishing. The +guide covers all of them. diff --git a/workflows/exaconstit-calibrate/RELEASING.md b/workflows/exaconstit-calibrate/RELEASING.md new file mode 100644 index 0000000..149a4c5 --- /dev/null +++ b/workflows/exaconstit-calibrate/RELEASING.md @@ -0,0 +1,337 @@ +# Releasing exaconstit-calibrate + +This is a maintainer guide for cutting new versions of the package. +End users installing the package do not need to read this. + +## TL;DR + +```bash +# 0. One-time: install the build front-end (see Prerequisites) +pip install --upgrade build + +# 1. Bump the version in pyproject.toml +# 2. Run the full test suite +python -m pytest tests/ -q + +# 3. Build the artifacts +rm -rf dist build *.egg-info +python -m build + +# 4. Sanity-check the wheel in a clean venv +python -m venv /tmp/releasecheck +/tmp/releasecheck/bin/pip install "dist/exaconstit_calibrate--py3-none-any.whl[test]" +cp -r tests /tmp/releasecheck/ +/tmp/releasecheck/bin/python -m pytest /tmp/releasecheck/tests -q + +# 5. (Optional) tag and publish +git tag -a v -m "Release v" +git push origin v +# For PyPI: python -m twine upload dist/* +``` + +If any step fails, do not publish. Fix the failure and start over. + +## Prerequisites + +A one-time setup on your development machine: + +```bash +pip install --upgrade build # PEP 517 front-end +pip install --upgrade twine # only if you publish to a package index +``` + +Both are stdlib-external but they are the standard Python Packaging +Authority tools and should be considered mandatory for any Python +release work. `build` replaces the old `python setup.py bdist_wheel` +invocation that PEP 517 deprecated. + +You also need Python 3.12 or newer (same floor as the package itself +— building a wheel that requires `>=3.12` on an older Python would +silently produce metadata that's wrong for the interpreter running +the tests). + +## Step 1 — Decide on a version number + +The version lives in exactly one place: the `version = "X.Y.Z"` line +near the top of `pyproject.toml`. Everything else (wheel filename, +sdist filename, metadata, `importlib.metadata.version(...)`) derives +from that single string. + +We follow [semantic versioning](https://semver.org/): + +- **PATCH (0.1.0 → 0.1.1)** — bug fixes, no API changes. Users + can pip-upgrade without reading release notes. +- **MINOR (0.1.0 → 0.2.0)** — new features, backward-compatible. + Users can pip-upgrade but might want to read release notes. +- **MAJOR (0.1.0 → 1.0.0)** — breaking changes. Users need to + update their code. Reserve this for genuine API rewrites. + +Pre-1.0 versions have a softer contract: 0.X minor bumps can break +API if you call it out in the release notes. After 1.0, minor bumps +must not break API. + +Example edit: + +```toml +# pyproject.toml +[project] +name = "exaconstit-calibrate" +version = "0.2.0" # was 0.1.0 +``` + +Do NOT hardcode the version anywhere else. No `__version__` string +in `workflow_common/__init__.py`, no VERSION file. If you need +the version at runtime, use `importlib.metadata`: + +```python +from importlib.metadata import version +v = version("exaconstit-calibrate") +``` + +## Step 2 — Run the tests + +Before building anything, prove the code works. From the project +root: + +```bash +python -m pytest tests/ -q +``` + +Expected output: `254 passed` (as of v0.1.0; the number grows as +tests are added). If any test fails, do not build a release — +fix the failure and re-run. + +The test suite uses the fake simulation binary in +`tests/conftest.py`; you do not need ExaConstit or any HPC tool +installed to validate a release candidate. + +## Step 3 — Clean the build directory + +Stale artifacts from a previous build can cause subtle wrong-file +inclusions in the new build. Always wipe before building: + +```bash +rm -rf dist build *.egg-info +``` + +`dist/` holds the final artifacts (wheel and sdist). `build/` is +setuptools' scratch area. `*.egg-info` is editable-install metadata +that should regenerate cleanly each time. + +Also confirm the `build` front-end is installed in the environment +you plan to invoke it from: + +```bash +python -c "import build; print('build available')" || \ + pip install --upgrade build +``` + +`build` is a maintainer tool, not a runtime dependency of the +package, so it is intentionally absent from +`[project.optional-dependencies]`. If you see "No module named +'build'", install it explicitly — it will not come in via +`pip install .[test]`. + +## Step 4 — Build the wheel and sdist + +```bash +python -m build +``` + +This produces two files in `dist/`: + +- **`exaconstit_calibrate--py3-none-any.whl`** — the wheel. + Pure-Python, universal (py3, no platform tag). This is what + `pip install` uses by default. +- **`exaconstit_calibrate-.tar.gz`** — the sdist (source + distribution). Pip falls back to this if no wheel is available; + PyPI requires both. + +The names use underscores (`exaconstit_calibrate`) not hyphens +even though the distribution name in `pyproject.toml` uses hyphens +(`exaconstit-calibrate`). That's a packaging convention, not a +mistake: distribution names are normalized to underscores for +filenames. + +Expect to see a lot of "adding 'workflow_common/...'" lines scroll +past. The build ends with: + +``` +Successfully built exaconstit_calibrate-.tar.gz and +exaconstit_calibrate--py3-none-any.whl +``` + +If the build fails, the most common causes (in order of how often +you will hit them): + +- **setuptools too old.** We require `>=77.0` for PEP 639 license + expressions. Upgrade: `pip install --upgrade setuptools`. +- **Python too old.** `>=3.12` per the `requires-python` field. +- **Syntax error in pyproject.toml.** TOML is picky; a misplaced + comma or missing quote will produce a cryptic error from + setuptools. Validate with `python -c "import tomllib; tomllib.load(open('pyproject.toml','rb'))"`. + +## Step 5 — Sanity-check the wheel + +**Never publish a wheel you have not test-installed.** The build +succeeding only proves the build tool was happy; it doesn't prove +the wheel is importable, has the right files, or passes tests. + +Do this in a fresh venv, not your development environment: + +```bash +python -m venv /tmp/releasecheck +/tmp/releasecheck/bin/pip install \ + "dist/exaconstit_calibrate--py3-none-any.whl[test]" + +# The wheel doesn't include the tests directory, so copy it over: +cp -r tests /tmp/releasecheck/ + +# Run the test suite against the installed package: +cd /tmp/releasecheck +./bin/python -m pytest tests -q +``` + +Expected: `254 passed` (or however many tests exist in this +version). If any test fails, the wheel is broken — do not publish. + +Common things this test catches that the source-tree test did not: + +- A Markdown file was forgotten in `[tool.setuptools.package-data]` + and doesn't ship in the wheel. +- A helper module was added but its import path is wrong for an + installed layout. +- The lazy `__getattr__` in `workflows/optimization/__init__.py` + was accidentally replaced with an eager import, breaking the + base-install path. + +While you're here, also verify the documentation files made it +into the wheel: + +```bash +./bin/python -c " +import workflow_common, pathlib +d = pathlib.Path(workflow_common.__file__).parent +for name in ('ARCHITECTURE.md', 'MIGRATION.md'): + p = d / name + print(name, 'present:', p.is_file(), 'size:', p.stat().st_size if p.is_file() else '(missing!)') +" +``` + +Both files should report sizes > 0. + +## Step 6 — (Optional) Git tag the release + +If the project is under version control (it is — this is in the +ExaConstit repo), mark the release with an annotated tag: + +```bash +git add pyproject.toml +git commit -m "Release v" +git tag -a v -m "Release v" +git push origin main +git push origin v +``` + +The `-a` makes it an annotated tag (has its own commit-like object +and a message) rather than a lightweight tag. Annotated tags are +what GitHub uses to create Releases. + +## Step 7 — (Optional) Publish to a package index + +### To a GitHub Release + +The low-friction option. Go to +https://github.com/LLNL/ExaConstit/releases, click "Draft a new +release", pick the tag from step 6, and attach both artifacts from +`dist/` as release assets. Users install with: + +```bash +pip install https://github.com/LLNL/ExaConstit/releases/download/v/exaconstit_calibrate--py3-none-any.whl +``` + +### To PyPI (public) + +If LLNL decides to publish on PyPI: + +```bash +python -m twine check dist/* # metadata sanity check +python -m twine upload dist/* # prompts for credentials +``` + +You will need a PyPI account with ownership of the +`exaconstit-calibrate` name. The first upload reserves the name; +subsequent uploads append new versions. PyPI does not allow +deleting or overwriting a published version — if you upload a +broken 0.2.0, you must release 0.2.1 to fix it. + +### To an internal / HPC index + +LLNL and other HPC sites often run an internal devpi or Nexus index. +`twine upload` accepts a `--repository-url` to target a non-PyPI +index; credentials go in `~/.pypirc`. Ask your site admin for the +URL and auth method. + +## Step 8 — Announce + +If there's a changelog (`CHANGELOG.md`, release notes section in +README, etc.), update it. Post-release is also a good moment to +bump the version in `pyproject.toml` to `0.X.Y+dev` or `0.X.(Y+1).dev0` +so main-branch installs are obviously not release builds. That's a +convention, not a requirement. + +## Troubleshooting + +### "No module named 'deap'" during `pytest` in the release check + +The `[test]` extra should pull DEAP from the rcarson3 fork. If it +didn't, check `pyproject.toml` under `[project.optional-dependencies]` +— the `test` list must include +`"deap @ git+https://github.com/rcarson3/deap.git"`. + +### "error in egg-info command" during `python -m build` + +Setuptools is too old. Upgrade it: `pip install --upgrade setuptools`. +Version `>=77.0` is needed for the PEP 639 SPDX license expression. + +### Wheel contains unexpected files / missing expected files + +`[tool.setuptools.packages.find]` controls which packages ship. +`[tool.setuptools.package-data]` controls non-`.py` files (Markdown +docs, config files, etc.). Edit both and rebuild. + +To inspect the wheel contents without installing: + +```bash +python -c " +import zipfile +with zipfile.ZipFile('dist/exaconstit_calibrate--py3-none-any.whl') as z: + for n in z.namelist(): + print(n) +" +``` + +### `pip install` falls back to building from sdist instead of using the wheel + +Your Python version or platform doesn't match the wheel's tags. +Since our wheel is `py3-none-any` (pure Python, any platform), this +usually means the user's Python is older than `requires-python`. +Check the `Requires-Python: >=3.12` line in the wheel's METADATA. + +### A published version has a critical bug + +You cannot overwrite or delete a published version on PyPI. Release +a patch version (`0.2.0` → `0.2.1`) with the fix. Document the known +bug in the GitHub Release notes for `0.2.0` so users know to skip +that version. + +## Reference + +- [Python Packaging User Guide](https://packaging.python.org/) + — the canonical docs. Start here for anything not covered above. +- [PEP 517](https://peps.python.org/pep-0517/) — the build-system + standard that `python -m build` implements. +- [PEP 639](https://peps.python.org/pep-0639/) — SPDX license + expressions in pyproject.toml. +- [Semantic Versioning](https://semver.org/) — the version-number + contract. diff --git a/workflows/exaconstit-calibrate/examples/README.md b/workflows/exaconstit-calibrate/examples/README.md new file mode 100644 index 0000000..1105d88 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/README.md @@ -0,0 +1,132 @@ +# Examples + +## `nsga3_calibration.py` + +Drop-in replacement for the pre-refactor +`ExaConstit_NSGA3.py` + `ExaConstit_Problems.py` + `normal_map.py` ++ `ExaConstit_Logger.py` set. Runs the real ExaConstit `mechanics` +binary against the same two-experiment topology, the same parameter +bounds, and the same GA defaults as the original. + +Every section in the file has a commented-out block quoting the +relevant slice of the old script, followed by its new equivalent. +Useful for mapping old code mentally onto new code in one sitting. + +### Running it + +For Slurm users, start with `run_nsga3_slurm.sh`, +`nsga3_slurm_helpers.sh`, and `SLURM_RUN_GUIDE.md`. The Slurm script +is the small file users edit. The helper contains the longer shell +functions and should stay in the same directory unless +`HELPER_SCRIPT` points to its full path. + +1. Install the package with the NSGA-III extra: + + ```bash + pip install "exaconstit-calibrate[nsga3,plot]" + ``` + +2. Make sure the example lives inside an ExaConstit checkout. The + script auto-discovers: + - the enclosing ExaConstit repo root + - `test/data` inputs such as `voce_quats.ori`, `grains.txt`, + and `state_cp_voce.txt` + - a built `mechanics` binary in common locations such as + `build_cpu/bin/mechanics` + + Override discovery only if your layout differs: + - `EXACONSTIT_ROOT=/path/to/ExaConstit` + - `EXACONSTIT_MECHANICS=/path/to/mechanics` + +3. Pick a backend: + - `--backend flux` is the default and is the intended HPC path. + Flux reads the per-case `SimCase` resource fields directly + (`num_nodes`, `num_tasks`, `cores_per_task`, `gpus_per_task`, + `duration_s`). + - `--backend local` is mainly for workstation/debug use. The + shipped example cases request `num_tasks=4`, so local mode is + not a drop-in replacement unless you either: + - reduce the cases to `num_tasks=1` for serial debugging, or + - adapt the backend construction in the example to provide an + MPI launcher such as `mpirun`/`srun` for multi-rank local runs + +4. Run: + + ```bash + python nsga3_calibration.py --backend flux + ``` + + For a Flux allocation started from Slurm, a typical launch looks like: + + ```bash + srun -n 1 --pty --mpi=none --mpibind=off flux start \ + python nsga3_calibration.py --backend flux + ``` + + For a serial local debug run, first lower the example's + `SimCase.num_tasks` values to `1`, then run: + + ```bash + python nsga3_calibration.py --backend local + ``` + + Optional flags: + - `--no-archive` — disable the SQLite archive + rolling cleanup + (most literal reproduction of the old script's behavior) + - `--resume-from PATH/checkpoint_gen_N.pkl` — resume a previous run + - `--resume-latest` — resume from the highest checkpoint in the run dir + - `--seed N` — override the RNG seed + +### Paths And Templates + +- `examples/template_options.toml` is repo-portable: the example + fills in `%%properties_file%%`, `%%state_vars_file%%`, + `%%grain_file%%`, and the other case-specific placeholders at render + time. +- The example script's local package root is + `ExaConstit/workflows/exaconstit-calibrate`, while the ExaConstit + repo root is the higher-level `ExaConstit` directory. The code uses + separate names for those on purpose: `PACKAGE_ROOT` versus + `EXACONSTIT_ROOT`. + +### Resource Configuration + +- Backend choice is separate from resource description. The resource + shape lives on each `SimCase`. +- Typical CPU-only Flux case: + `num_nodes=1, num_tasks=4, cores_per_task=1, gpus_per_task=0` +- Typical GPU Flux case with one rank per GPU: + `num_nodes=1, num_tasks=4, cores_per_task=1, gpus_per_task=1` +- Hybrid MPI+threads case: + `num_nodes=1, num_tasks=4, cores_per_task=7, gpus_per_task=1` + which requests 28 CPU cores plus 4 GPUs for that one simulation. + +### Things worth knowing before you port your own driver + +- **Normalization.** The built-in `StressStrainObjective` returns + plain RMSE. The old script divided by `np.std(exp)`. The example + includes two small custom evaluator classes + (`_StdNormalizedStressEvaluator`, `_StdNormalizedSlopeEvaluator`) + that reproduce the old normalization exactly. Copy those into + your own driver or replace with whatever metric you prefer. + +- **Population size.** The old script derived `NPOP` from the + reference-point count. The new `RunConfig` does the same when + `population_size=None` (default). You get `NPOP = 288` for + `NOBJ=4, p=10` either way. + +- **Case-directory naming.** The example uses + `gen_{generation}/gene_{gene}_obj_{obj}` to exactly match the + pre-refactor layout, so a mix of old + new runs in the same + workspace doesn't collide. + +- **SimCase vs ObjectiveSpec.** One SimCase per experiment (two + in this example). Two ObjectiveSpecs per SimCase (stress and + slope). That's the `NOBJ = NEXP * 2` pattern, preserved exactly + but with the sim-vs-evaluator split that the framework makes + explicit. + +For the conceptual walkthrough that this file is the +"show, don't tell" counterpart to, read +`workflow_common/MIGRATION.md` — especially the step-by-step +section 4-8 for the Problem construction. diff --git a/workflows/exaconstit-calibrate/examples/SLURM_RUN_GUIDE.md b/workflows/exaconstit-calibrate/examples/SLURM_RUN_GUIDE.md new file mode 100644 index 0000000..fc49b4e --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/SLURM_RUN_GUIDE.md @@ -0,0 +1,379 @@ +# Slurm Run Guide For `nsga3_calibration.py` + +This guide is for running the NSGA-III calibration example on a Slurm +cluster that starts a Flux instance inside the Slurm job. + +The short version: + +```bash +cd ExaConstit/workflows/exaconstit-calibrate/examples +sbatch run_nsga3_slurm.sh +``` + +Before you submit, edit the top of `run_nsga3_slurm.sh`. + +Keep these two files together in the same directory: + +- `run_nsga3_slurm.sh` +- `nsga3_slurm_helpers.sh` + +Most users should edit only `run_nsga3_slurm.sh`. The helper file holds +the longer shell functions so the job script stays short and harder to +break by accident. + +If you move `nsga3_slurm_helpers.sh` somewhere else, set +`HELPER_SCRIPT` in `run_nsga3_slurm.sh` to the full path: + +```sh +HELPER_SCRIPT="/usr/workspace/myname/scripts/nsga3_slurm_helpers.sh" +``` + +## What To Edit First + +Open `run_nsga3_slurm.sh` and edit the `#SBATCH` block: + +```sh +#SBATCH -A wbronze +#SBATCH -N 2 +#SBATCH -n 224 +#SBATCH -t 01:00:00 +#SBATCH -p pdebug +#SBATCH -J exaconstit_nsga3 +#SBATCH -o exaconstit_nsga3.%j.out +``` + +Meaning: + +- `-A` is your bank, project, or allocation account. +- `-N` is the number of nodes. +- `-n` is the total Slurm task count. A common choice is nodes times + CPU cores per node. +- `-t` is the wall-clock time limit. +- `-p` is the queue or partition. +- `-J` is the job name shown by Slurm. +- `-o` is the output log file. `%j` expands to the Slurm job id. + +Concrete examples: + +```sh +# Short debug job on 2 nodes: +#SBATCH -A wbronze +#SBATCH -N 2 +#SBATCH -n 224 +#SBATCH -t 01:00:00 +#SBATCH -p pdebug +#SBATCH -J exaconstit_debug +#SBATCH -o exaconstit_debug.%j.out +``` + +```sh +# Longer production-style job on 4 nodes: +#SBATCH -A your_bank_name +#SBATCH -N 4 +#SBATCH -n 448 +#SBATCH -t 08:00:00 +#SBATCH -p pbatch +#SBATCH -J exaconstit_calibration +#SBATCH -o exaconstit_calibration.%j.out +``` + +Then check the `User settings` block: + +```sh +PYTHON="/usr/tce/packages/python/python-3.12.2/bin/python" +FLUX_PYTHONPATH="/usr/lib64/flux/python3.12" +ACTION="all" +INSTALL_PACKAGE="0" +TOP_N="10" +``` + +Most users only need to change `PYTHON`, `ACTION`, and maybe +`INSTALL_PACKAGE`. + +Important shell syntax rule: do not add spaces around `=`. + +Correct: + +```sh +ACTION="all" +``` + +Incorrect: + +```sh +ACTION = "all" +``` + +More examples: + +```sh +# Use python3 from your loaded environment: +PYTHON="python3" +``` + +```sh +# Use a different Flux Python module directory: +FLUX_PYTHONPATH="/usr/lib64/flux/python3.11" +``` + +```sh +# Show 25 best solutions instead of 10: +TOP_N="25" +``` + +## Mechanics Binary + +The example tries to find the ExaConstit checkout and the `mechanics` +binary automatically. It looks for common paths like: + +```text +ExaConstit/build_cpu/bin/mechanics +ExaConstit/build/bin/mechanics +ExaConstit/build_hip/bin/mechanics +ExaConstit/build_cuda/bin/mechanics +``` + +If your build is somewhere else, set this in `run_nsga3_slurm.sh`: + +```sh +EXACONSTIT_MECHANICS="/full/path/to/mechanics" +``` + +If the script cannot find the ExaConstit checkout, set: + +```sh +EXACONSTIT_ROOT="/full/path/to/ExaConstit" +``` + +## Running A New Calibration + +Set: + +```sh +ACTION="run" +``` + +Then submit: + +```bash +sbatch run_nsga3_slurm.sh +``` + +This runs: + +```bash +srun -n 1 --mpi=none --mpibind=off flux start \ + python nsga3_calibration.py --backend flux +``` + +The driver writes its run directory under: + +```text +examples/calibration_run +``` + +Important files: + +- `calibration_run/calibration.db` is the SQLite archive. +- `calibration_run/checkpoint_files/checkpoint_gen_N.pkl` are restart files. +- `calibration_run/checkpoint_files/logbook1_stats.log` has generation stats. +- `calibration_run/checkpoint_files/logbook2_solutions.log` has individuals. + +## Running And Post-Processing In One Job + +Set: + +```sh +ACTION="all" +``` + +This runs the calibration first. If the calibration finishes, the script +then prints archive summaries and writes plots under: + +```text +examples/postprocess +``` + +This is the recommended first mode because it gives you useful outputs +without needing to remember separate commands. + +## Resuming A Run + +The calibration driver writes a checkpoint every generation. + +To resume from the newest checkpoint: + +```sh +ACTION="resume-latest" +``` + +To resume from a generation number: + +```sh +ACTION="resume-from" +CHECKPOINT_GEN="15" +CHECKPOINT_PATH="" +``` + +To resume from an explicit checkpoint path: + +```sh +ACTION="resume-from" +CHECKPOINT_GEN="" +CHECKPOINT_PATH="calibration_run/checkpoint_files/checkpoint_gen_15.pkl" +``` + +Only set one of `CHECKPOINT_GEN` or `CHECKPOINT_PATH`. + +## Inspecting Results Without Running More Simulations + +Set: + +```sh +ACTION="inspect" +``` + +This runs archive inspection commands only: + +```bash +python -m workflows.optimization.inspect_archive calibration_run --runs +python -m workflows.optimization.inspect_archive calibration_run --gens-best +python -m workflows.optimization.inspect_archive calibration_run \ + --genes --pareto-only --top 10 +``` + +What these mean: + +- `--runs` lists runs stored in the archive. +- `--gens-best` shows convergence by generation. +- `--genes --pareto-only --top 10` shows the best solutions across the + whole run, not just the last generation. + +The script also writes: + +```text +postprocess/top_solutions.csv +``` + +## Making Plots Without Running More Simulations + +Set: + +```sh +ACTION="plots" +``` + +This writes several figures under: + +```text +examples/postprocess +``` + +Useful plotting commands shown by the script: + +```bash +python plot_solutions.py calibration_run \ + --top 10 \ + --save postprocess/top_10_balanced_l2_overlay.png \ + --no-show +``` + +This plots the top 10 most balanced solutions across all objectives. + +```bash +python plot_solutions.py calibration_run \ + --top 10 --objective stress_1 \ + --save postprocess/top_10_stress_1_overlay.png \ + --no-show +``` + +This ranks by one objective only. Available objective labels in this +example are: + +- `stress_1` +- `slope_1` +- `stress_2` +- `slope_2` + +You can also use integer objective indices: + +- `0` means `stress_1` +- `1` means `slope_1` +- `2` means `stress_2` +- `3` means `slope_2` + +## Pareto Plots + +Pareto plots show tradeoffs between two objectives. + +Stress-vs-slope for experiment 1: + +```bash +python plot_solutions.py calibration_run \ + --top 10 --pareto 0,1 \ + --save-pareto postprocess/pareto_stress_1_vs_slope_1.png \ + --no-show +``` + +Stress objective from experiment 1 vs stress objective from experiment 2: + +```bash +python plot_solutions.py calibration_run \ + --top 10 --pareto 0,2 \ + --save-pareto postprocess/pareto_stress_1_vs_stress_2.png \ + --no-show +``` + +To make both a Pareto plot and the stress-strain overlay in one command, +add `--overlay`: + +```bash +python plot_solutions.py calibration_run \ + --top 10 --pareto 0,1 --overlay \ + --save postprocess/top_10_overlay.png \ + --save-pareto postprocess/pareto_0_1.png \ + --no-show +``` + +## Common Problems + +### The job says it cannot import `flux` + +The Flux Python module is not visible to your Python interpreter. Check: + +```sh +FLUX_PYTHONPATH="/usr/lib64/flux/python3.12" +``` + +That path must match your site and Python version. + +### The job says it cannot find `mechanics` + +Set: + +```sh +EXACONSTIT_MECHANICS="/full/path/to/mechanics" +``` + +### I want a quick test, not a full production run + +Use the `pdebug` partition, a short time limit, and `ACTION="run"` or +`ACTION="all"`. For a very small local test, edit `nsga3_calibration.py` +so each `SimCase` has `num_tasks=1`, then run with `--backend local`. + +### The run stopped before all generations + +That can be normal. The NSGA-III driver has early stopping criteria. Look +at: + +```bash +python -m workflows.optimization.inspect_archive calibration_run --gens-best +``` + +and: + +```text +calibration_run/checkpoint_files/logbook1_stats.log +``` + +to see whether it converged or stopped due to a failure. diff --git a/workflows/exaconstit-calibrate/examples/experiments/expt_strain_rate_1m1.txt b/workflows/exaconstit-calibrate/examples/experiments/expt_strain_rate_1m1.txt new file mode 100644 index 0000000..357812d --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/experiments/expt_strain_rate_1m1.txt @@ -0,0 +1,62 @@ +-5.00E-06 -6.43E-04 +-2.06E-05 -2.65E-03 +-6.95E-05 -8.93E-03 +-2.22E-04 -2.85E-02 +-4.61E-04 -4.03E-02 +-6.47E-04 -4.23E-02 +-8.42E-04 -4.35E-02 +-1.04E-03 -4.44E-02 +-1.26E-03 -4.53E-02 +-1.48E-03 -4.61E-02 +-1.82E-03 -4.73E-02 +-2.18E-03 -4.85E-02 +-2.55E-03 -4.97E-02 +-2.94E-03 -5.09E-02 +-3.35E-03 -5.21E-02 +-3.78E-03 -5.34E-02 +-4.22E-03 -5.47E-02 +-4.68E-03 -5.60E-02 +-5.16E-03 -5.74E-02 +-5.67E-03 -5.88E-02 +-6.19E-03 -6.03E-02 +-7.01E-03 -6.26E-02 +-7.87E-03 -6.50E-02 +-8.77E-03 -6.74E-02 +-9.70E-03 -6.99E-02 +-1.07E-02 -7.25E-02 +-1.17E-02 -7.51E-02 +-1.28E-02 -7.79E-02 +-1.39E-02 -8.07E-02 +-1.50E-02 -8.36E-02 +-1.63E-02 -8.66E-02 +-1.75E-02 -8.97E-02 +-1.89E-02 -9.29E-02 +-2.03E-02 -9.62E-02 +-2.24E-02 -1.01E-01 +-2.47E-02 -1.06E-01 +-2.71E-02 -1.12E-01 +-2.97E-02 -1.17E-01 +-3.23E-02 -1.22E-01 +-3.51E-02 -1.28E-01 +-3.80E-02 -1.33E-01 +-4.11E-02 -1.39E-01 +-4.43E-02 -1.45E-01 +-4.77E-02 -1.51E-01 +-5.12E-02 -1.57E-01 +-5.50E-02 -1.63E-01 +-5.89E-02 -1.69E-01 +-6.31E-02 -1.75E-01 +-6.74E-02 -1.81E-01 +-7.21E-02 -1.87E-01 +-7.69E-02 -1.93E-01 +-8.20E-02 -1.99E-01 +-8.75E-02 -2.06E-01 +-9.32E-02 -2.12E-01 +-9.92E-02 -2.18E-01 +-1.06E-01 -2.24E-01 +-1.12E-01 -2.30E-01 +-1.20E-01 -2.36E-01 +-1.27E-01 -2.42E-01 +-1.35E-01 -2.48E-01 +-1.44E-01 -2.53E-01 +-1.46E-01 -2.55E-01 \ No newline at end of file diff --git a/workflows/exaconstit-calibrate/examples/experiments/expt_strain_rate_1m3.txt b/workflows/exaconstit-calibrate/examples/experiments/expt_strain_rate_1m3.txt new file mode 100644 index 0000000..7bd137e --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/experiments/expt_strain_rate_1m3.txt @@ -0,0 +1,60 @@ +-5.00E-05 -6.43E-03 +-2.06E-04 -2.64E-02 +-4.51E-04 -3.71E-02 +-6.42E-04 -3.88E-02 +-8.41E-04 -3.99E-02 +-1.05E-03 -4.07E-02 +-1.26E-03 -4.15E-02 +-1.49E-03 -4.23E-02 +-1.73E-03 -4.30E-02 +-2.09E-03 -4.41E-02 +-2.48E-03 -4.53E-02 +-2.88E-03 -4.64E-02 +-3.29E-03 -4.76E-02 +-3.73E-03 -4.87E-02 +-4.18E-03 -5.00E-02 +-4.65E-03 -5.12E-02 +-5.15E-03 -5.25E-02 +-5.66E-03 -5.39E-02 +-6.20E-03 -5.53E-02 +-6.76E-03 -5.67E-02 +-7.35E-03 -5.82E-02 +-7.96E-03 -5.97E-02 +-8.59E-03 -6.13E-02 +-9.26E-03 -6.29E-02 +-9.95E-03 -6.46E-02 +-1.07E-02 -6.64E-02 +-1.18E-02 -6.91E-02 +-1.30E-02 -7.19E-02 +-1.42E-02 -7.47E-02 +-1.55E-02 -7.77E-02 +-1.69E-02 -8.07E-02 +-1.83E-02 -8.39E-02 +-1.98E-02 -8.71E-02 +-2.13E-02 -9.04E-02 +-2.38E-02 -9.54E-02 +-2.63E-02 -1.01E-01 +-2.90E-02 -1.06E-01 +-3.18E-02 -1.11E-01 +-3.48E-02 -1.16E-01 +-3.79E-02 -1.22E-01 +-4.12E-02 -1.28E-01 +-4.46E-02 -1.33E-01 +-4.82E-02 -1.39E-01 +-5.20E-02 -1.45E-01 +-5.60E-02 -1.50E-01 +-6.03E-02 -1.56E-01 +-6.47E-02 -1.62E-01 +-6.94E-02 -1.68E-01 +-7.43E-02 -1.74E-01 +-7.95E-02 -1.80E-01 +-8.51E-02 -1.85E-01 +-9.09E-02 -1.91E-01 +-9.70E-02 -1.97E-01 +-1.04E-01 -2.03E-01 +-1.10E-01 -2.09E-01 +-1.18E-01 -2.14E-01 +-1.26E-01 -2.20E-01 +-1.34E-01 -2.25E-01 +-1.43E-01 -2.31E-01 +-1.46E-01 -2.33E-01 \ No newline at end of file diff --git a/workflows/exaconstit-calibrate/examples/master_options.toml b/workflows/exaconstit-calibrate/examples/master_options.toml new file mode 100644 index 0000000..d4af28e --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/master_options.toml @@ -0,0 +1,138 @@ +#The below show all of the options available and their default values +#Although, it should be noted that the BCs options have no default values +#and require you to input ones that are appropriate for your problem. +#Also while the below is indented to make things easier to read the parser doesn't care. +#More information on TOML files can be found at: https://en.wikipedia.org/wiki/TOML +#and https://github.com/toml-lang/toml/blob/master/README.md +Version = "0.6.0" +[Properties] + # A base temperature that all models will initially run at + temperature = %%temperature_k%% + #The below informs us about the material properties to use + [Properties.Matl_Props] + floc = "%%properties_file%%" + num_props = 17 + #These options tell inform the program about the state variables + [Properties.State_Vars] + floc = "%%state_vars_file%%" + num_vars = 24 + #These options are only used in xtal plasticity problems + [Properties.Grain] + # Tells us where the orientations are located for either a UMAT or + # ExaCMech problem. -1 indicates that it goes at the end of the state + # variable file. + # If ExaCMech is used the loc value will be overriden with values that are + # consistent with the library's expected location + ori_state_var_loc = 9 + ori_stride = 4 + #The following options are available for orientation type: euler, quat/quaternion, or custom. + #If one of these options is not provided the program will exit early. + ori_type = "quat" + num_grains = 500 + ori_floc = "%%ori_file%%" + # If auto generating a mesh a grain file is needed that associates a given + # element to a grain. If you are using a mesh file this information should + # already be embedded in the mesh using something akin to the MFEM v1.0 mesh + # file element attributes, and therefore this option is ignored. + grain_floc = "%%grain_file%%" +[BCs] + # Required - essential BC ids for the whole boundary + essential_ids = [1, 4] + # Required = component combo (free = 0, x = 1, y = 2, z = 3, xy = 4, yz = 5, xz = 6, xyz = 7) + # Note: ExaConstit v0.5.0 and earlier had xyz set to -1. This change was broken in v0.6.0 + # These numbers tell us which degrees of freedom are constrained for the given + # list of attributes provided within essential_ids + # Negative values of the below signify that for a given essential BC id that + # we want to use a constant velocity gradient rather than directly supplying the + # velocity values. + essential_comps = [3, 3] + #Vector of vals to be applied for each attribute + #The length of this should be #ids * dim of problem + essential_vals = %%essential_vals%% +[Model] + #This option tells us to run using a UMAT or exacmech + mech_type = "exacmech" + #This tells us that our model is a crystal plasticity problem + cp = true + [Model.ExaCMech] + #Need to specify the xtal type + #currently only FCC is supported + xtal_type = "fcc" + # Required - the slip kinetics and hardening form that we're going to be using + # The choices are either PowerVoce, PowerVoceNL, or MTSDD + # HCP is only available with MTSDD + slip_type = "powervoce" + +# Options related to our time steps +# For the time options if all three or some combination of the following tables +# [Auto, Fixed, and Custom] are provided the priority of which one goes +# 1. Custom +# 2. Auto +# 3. Fixed +# +# Note: For fixed and auto time steppings the final simulation step is satified if +# abs(t_final - t_current) < abs(1e-3 * dt_current) +# Generally, the simulation driver will try to satisfy this to even tighter bounds +# but that is not always possible. +[Time] + [Time.Auto] + dt_start = %%dt_min%% + dt_min = %%dt_min%% + dt_max = %%dt_max%% + dt_scale = %%dt_scale%% + t_final = %%t_final%% +#Our visualizations options +[Visualizations] + #The stride that we want to use for when to take save off data for visualizations + steps = 1 + visit = false + conduit = false + paraview = false + additional_avgs = true +[Solvers] + # Option for how our assembly operation is conducted. Possible choices are + # FULL, PA, EA + # Full assembly fully assembles the stiffness matrix + # Partial assembly is completely matrix free and only performs the action of + # the stiffness matrix. + # Element assembly only assembles the elemental contributions to the stiffness + # matrix in order to perform the actions of the overall matrix. + assembly = "EA" + #Option for what our runtime is set to. Possible choices are CPU, OPENMP, or CUDA + rtmodel = "CPU" + #Options for our nonlinear solver + #The number of iterations should probably be low + #Some problems might have difficulty converging so you might need to relax + #the default tolerances + [Solvers.NR] + iter = 25 + rel_tol = 5e-5 + abs_tol = 5e-10 + #Options for our iterative linear solver + #A lot of times the iterative solver converges fairly quickly to a solved value + #However, the solvers could at worst take DOFs iterations to converge. In most of these + #solid mechanics problems that almost never occcurs unless the mesh is incredibly coarse. + [Solvers.Krylov] + iter = 1000 + rel_tol = 1e-7 + abs_tol = 1e-27 + #The following Krylov solvers are available GMRES, PCG, and MINRES + #If one of these options is not used the program will exit early. + solver = "CG" + preconditioner = "JACOBI" +[Mesh] + #Serial refinement level + ref_ser = 1 + #Parallel refinement level + ref_par = 0 + #The polynomial refinement/order of our shape functions + p_refinement = %%p_refinement%% + #Possible values here are cubit, auto, or other + #If one of these is not provided the program will exit early + type = "auto" + #The below shows the necessary options needed to automatically generate a mesh + [Mesh.Auto] + #The mesh length is needed + length = %%mesh_lengths%% + #The number of cuts along an edge of the mesh are also needed + ncuts = %%mesh_cuts%% diff --git a/workflows/exaconstit-calibrate/examples/nsga3_calibration.py b/workflows/exaconstit-calibrate/examples/nsga3_calibration.py new file mode 100644 index 0000000..746875b --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/nsga3_calibration.py @@ -0,0 +1,1616 @@ +""" +ExaConstit NSGA-III calibration — side-by-side with the pre-refactor script. + +This file is a drop-in replacement for the pre-refactor +``ExaConstit_NSGA3.py`` + ``ExaConstit_Problems.py`` + ``normal_map.py`` + +``ExaConstit_Logger.py`` set. It runs the real ``mechanics`` binary +against the same two-experiment topology, the same parameter bounds, +the same per-case resource allocation, and the same GA knobs as the +original. + +How to read this file +--------------------- +The whole thing is a single ``main()``. Each top-level section starts +with a commented block showing the relevant slice of the OLD script, +followed by the NEW equivalent. The OLD blocks are quoted verbatim +from the pre-refactor sources so you can grep for them and verify +the line-for-line mapping. + +Numerical defaults match the old script exactly. Running both against +the same seed, same experimental data, and same binary should produce +trajectories that agree to within float roundoff. + +How to run this file +-------------------- +1. Ensure the example lives inside an ExaConstit checkout. By default + it auto-discovers: + + * the repo root by walking upward from this file, + * ``test/data`` inputs like ``voce_quats.ori`` and ``grains.txt``, + * a built ``mechanics`` binary in common locations such as + ``build_cpu/bin/mechanics``. + + Override discovery with ``EXACONSTIT_ROOT=/path/to/ExaConstit`` or + ``EXACONSTIT_MECHANICS=/path/to/mechanics`` if your layout differs. + +2. Install the package with the NSGA-III extra: + + pip install "exaconstit-calibrate[nsga3,plot]" + +3. Run: + + python nsga3_calibration.py + + Or, to run without filesystem cleanup and without the archive + (closer to the old script's default behavior): + + python nsga3_calibration.py --no-archive + +For the conceptual overview of each framework component this script +uses, see ``workflow_common/MIGRATION.md``. This file is the +"show me working code" counterpart to that prose walkthrough. +""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import os +from pathlib import Path +import sys +from typing import Any, Sequence + +import numpy as np +import pandas as pd + +EXAMPLE_DIR = Path(__file__).resolve().parent +PACKAGE_ROOT = EXAMPLE_DIR.parent +if str(PACKAGE_ROOT) not in sys.path: + # Make the example runnable directly from a source checkout + # without requiring an editable install first. + sys.path.insert(0, str(PACKAGE_ROOT)) + + +def _find_exaconstit_root(start: Path) -> Path: + """Find the surrounding ExaConstit checkout. + + Preference order: + 1. ``EXACONSTIT_ROOT`` environment variable. + 2. Walk upward from this example until we find ``test/data`` and + ``workflows`` together, which identifies the repo root in a + normal checkout. + 3. Fall back to the expected ``.../ExaConstit`` ancestor relative + to the example file. + """ + env_root = os.environ.get("EXACONSTIT_ROOT") + if env_root: + return Path(env_root).expanduser().resolve() + + for candidate in (start, *start.parents): + if (candidate / "test" / "data").is_dir() and ( + candidate / "workflows" + ).is_dir(): + return candidate.resolve() + + return start.parents[2].resolve() + + +def _first_existing(paths: Sequence[Path]) -> Path | None: + for path in paths: + if path.is_file(): + return path + return None + + +def _mechanics_candidates(exaconstit_root: Path) -> list[Path]: + candidates: list[Path] = [] + env_mechanics = os.environ.get("EXACONSTIT_MECHANICS") + if env_mechanics: + candidates.append(Path(env_mechanics).expanduser()) + + for build_dir in ( + "build_cpu", + "build", + "build_hip", + "build_cuda", + "build_gpu", + "build_debug", + "build_release", + ): + candidates.append(exaconstit_root / build_dir / "bin" / "mechanics") + return candidates + + +@dataclass(frozen=True) +class ParameterSpec: + """One optimized material parameter. + + Edit the PARAMS table in main(), not three separate lists. Bounds, + names, log output, checkpoint metadata, and write_properties() all + derive from this single table. + """ + + name: str + lower: float + upper: float + units: str = "" + description: str = "" + + +from workflow_common import ( + ArchiveDB, + CallablePropertyWriter, + CaseTemplater, + HAS_FLUX, + InfinityFailureHandler, + LocalBackend, + Manifest, + ObjectiveSpec, + PchipSmoother, + Problem, + ProblemConfig, + SimCase, + StressStrainExtractor, + TemplatePathResolver, + TemplateTarget, + TextTableReader, + TextTableSpec, + configure_logging, +) + + +# ================================================================ +# CONFIGURATION — repo-relative defaults for the shipped example. +# Everything below this block should work in a normal ExaConstit checkout. +# ================================================================ +# +# USER EDIT MAP +# ------------- +# If you are new to this workflow, start here. Most users only need +# to edit the items in this block and the matching sections called +# out below. +# +# 1. New experiment data: +# * Put your stress-strain text/CSV files under examples/experiments +# or point EXPERIMENT_FILES at their full paths. +# * Add one matching SimCase in the ``sim_cases = [...]`` section +# for each experiment. One SimCase means one simulation setup: +# strain rate, final strain, time stepping, mesh, orientation, +# and CPU/GPU resource request. +# * In the OBJECTIVES section, set ``stress_column`` to the stress +# component matching your loading direction: Sxx for x, Syy for y, +# Szz for z. +# +# 2. New orientations, grains, or state variables: +# * Point ORIENTATION_FILES, GRAIN_FILE, and STATE_VARS_FILE at the +# files you want copied/referenced by each case. +# * If different experiments use different orientations, keep one +# ORIENTATION_FILES entry per experiment and set each SimCase's +# ``ori_file`` to the filename that should appear inside the +# case directory. +# +# 3. New material model or new ExaConstit options: +# * Edit examples/template_options.toml. That is the options.toml +# template rendered into every case directory. +# * Edit ``write_properties()`` if your material model expects a +# different properties-file order or extra material constants. +# The property order must match the active ExaCMech material +# shortcut/model selected in template_options.toml. +# +# 4. New fitted parameters: +# * Edit the PARAMS table in main(). Bounds and parameter names are +# derived from that one table. +# * Then update ``write_properties()`` so each fitted parameter is +# written into the correct material-model slot. The order still +# matters when ExaConstit reads properties.txt. +# +# 5. Backend and hardware: +# * Pick ``--backend flux`` for HPC Flux runs or ``--backend local`` +# for small/debug runs. +# * CPU/GPU shape is controlled on each SimCase with num_nodes, +# num_tasks, cores_per_task, gpus_per_task, and duration_s. +# Changing only the Slurm header does not change what each +# individual simulation requests from Flux. + +EXACONSTIT_ROOT = _find_exaconstit_root(EXAMPLE_DIR) +TEST_DATA_DIR = EXACONSTIT_ROOT / "test" / "data" +MECHANICS_BINARY = ( + _first_existing(_mechanics_candidates(EXACONSTIT_ROOT)) + or (EXACONSTIT_ROOT / "build_cpu" / "bin" / "mechanics") +) +STATE_VARS_FILE = TEST_DATA_DIR / "state_cp_voce.txt" +GRAIN_FILE = TEST_DATA_DIR / "grains.txt" + +# Template options TOML with ``%%key%%`` placeholders. The keys +# substituted in per-case come from SimCase.template_values +# (see the ``sim_cases = [...]`` section). A minimal viable master +# file references: +# +# temperature_k = %%temperature_k%% +# orientation_file = "%%ori_file%%" +# t_final = %%t_final%% +# dt_min = %%dt_min%% +# dt_max = %%dt_max%% +# dt_scale = %%dt_scale%% +# essential_vals = %%essential_vals%% +# mesh_lengths = %%mesh_lengths%% +# mesh_cuts = %%mesh_cuts%% +# p_refinement = %%p_refinement%% +# properties_file = "%%properties_file%%" +# state_vars_file = "%%state_vars_file%%" +# grain_file = "%%grain_file%%" +# +# Note: ``strain_rate`` remains in SimCase.case_data because the +# objective extractor uses it to convert time to strain. The mechanics +# input currently receives direct velocity values via +# ``essential_vals``; if you switch to velocity-gradient BCs, map +# ``%%strain_rate%%`` into that block in template_options.toml. +MASTER_TOML = EXAMPLE_DIR / "template_options.toml" + +# One stress-strain CSV per experiment. Two-column format: +# strain, stress. Whitespace or comma-separated both work. +EXPERIMENT_FILES = [ + EXAMPLE_DIR / "experiments" / "expt_strain_rate_1m3.txt", + EXAMPLE_DIR / "experiments" / "expt_strain_rate_1m1.txt", +] + +# Orientation files (one per experiment) copied into each case dir. +# Matches the old ``ori_file=["voce_quats.ori", "voce_quats.ori"]``. +ORIENTATION_FILES = [ + TEST_DATA_DIR / "voce_quats.ori", + TEST_DATA_DIR / "voce_quats.ori", +] + +WORKSPACE = EXAMPLE_DIR / "calibration_run" + +# ================================================================ + + +def main(argv: Sequence[str] | None = None) -> None: + from workflows.optimization import ( + Bounds, + RunConfig, + build_reference_points, + derive_population_size, + run_nsga3, + ) + + parser = argparse.ArgumentParser( + description="NSGA-III calibration of ExaConstit parameters", + ) + parser.add_argument( + "--backend", + choices=("flux", "local"), + default="flux", + help=( + "Execution backend. 'flux' submits each case into the " + "current Flux instance. 'local' runs cases as ordinary " + "subprocesses on the current host; for this example, " + "local mode is mainly for serial debugging unless you also " + "adapt the resource settings and/or provide an MPI launcher." + ), + ) + parser.add_argument( + "--resume-from", default=None, + help=( + "Resume from an existing checkpoint. Accepts either: " + "(a) a path to a checkpoint pickle, e.g. " + "'calibration_run/checkpoint_files/checkpoint_gen_15.pkl'; " + "or (b) a plain integer like '15' meaning " + "'checkpoint_gen_15.pkl inside the run's checkpoint dir'. " + "Mutually exclusive with --resume-latest." + ), + ) + parser.add_argument( + "--resume-latest", action="store_true", + help=( + "Resume from the highest-numbered checkpoint pickle in " + "the run's checkpoint directory. Convenient after a " + "crash when you just want to pick back up wherever " + "the last good generation landed." + ), + ) + parser.add_argument( + "--no-archive", action="store_true", + help="Disable the SQLite archive + rolling cleanup. " + "Closest match to the pre-refactor script's disk behavior.", + ) + parser.add_argument( + "--seed", type=int, default=42, + help="Random seed for reproducibility (default: 42).", + ) + args = parser.parse_args(argv) + + required_files = [ + ("mechanics binary", MECHANICS_BINARY), + ("master options template", MASTER_TOML), + ("state vars file", STATE_VARS_FILE), + ("grain file", GRAIN_FILE), + ] + required_files.extend( + (f"experiment file #{i + 1}", path) + for i, path in enumerate(EXPERIMENT_FILES) + ) + required_files.extend( + (f"orientation file #{i + 1}", path) + for i, path in enumerate(ORIENTATION_FILES) + ) + missing = [(label, path) for label, path in required_files if not path.is_file()] + if missing: + details = "\n".join( + f" - {label}: {path}" for label, path in missing + ) + parser.error( + "required example inputs were not found:\n" + f"{details}\n" + "Set EXACONSTIT_ROOT to your checkout root or " + "EXACONSTIT_MECHANICS to an explicit mechanics binary path " + "if your build lives elsewhere." + ) + + # ============================================================ + # RESOLVE --resume-from / --resume-latest + # ============================================================ + # + # We let --resume-from accept either a checkpoint path or a + # generation number, because the user's mental model is "pick + # up from gen N" — they shouldn't have to remember the exact + # checkpoint filename convention. --resume-latest is a separate + # flag for the very common "just keep going from wherever we + # left off" case. + # + # The end product is ``resume_pickle_path``: either a Path + # pointing at the pickle to load, or None for "fresh run". + # This is what the library's RunConfig.resume_from wants. + + if args.resume_from is not None and args.resume_latest: + parser.error( + "--resume-from and --resume-latest are mutually exclusive" + ) + + CHECKPOINT_DIR = WORKSPACE / "checkpoint_files" + + resume_pickle_path: Path | None = None + if args.resume_latest: + # Find the highest-numbered pickle in the checkpoint dir. + # Pattern is fixed by the library as "checkpoint_gen_{N}.pkl". + if not CHECKPOINT_DIR.is_dir(): + parser.error( + f"--resume-latest: checkpoint directory not found at " + f"{CHECKPOINT_DIR}. Is this a fresh workspace?" + ) + candidates = list(CHECKPOINT_DIR.glob("checkpoint_gen_*.pkl")) + if not candidates: + parser.error( + f"--resume-latest: no checkpoint files under " + f"{CHECKPOINT_DIR}. Nothing to resume from." + ) + # Extract the integer part and pick the max. Robust against + # non-matching filenames — ``int(...)`` on a bad stem raises + # and we skip that candidate, preserving the behavior for + # any user's stray logs or backups in the same dir. + def _gen_idx_of(p: Path) -> int: + try: + return int(p.stem.split("_")[-1]) + except ValueError: + return -1 + resume_pickle_path = max(candidates, key=_gen_idx_of) + # Display-only line. Try to shorten to a cwd-relative path + # since that's nicer to read, but fall back to the full + # string if the checkpoint dir and cwd have no ancestor + # relationship (happens when the user runs from elsewhere, + # or when CHECKPOINT_DIR is itself a relative path that + # argparse hasn't resolved). + try: + display_path = resume_pickle_path.resolve().relative_to( + Path.cwd().resolve() + ) + except ValueError: + display_path = resume_pickle_path + print( + f"--resume-latest: picked {display_path} " + f"(gen {_gen_idx_of(resume_pickle_path)})" + ) + elif args.resume_from is not None: + # Try integer first; if it parses, it's a generation number. + # Otherwise treat as a path. + raw = str(args.resume_from) + try: + gen_idx = int(raw) + except ValueError: + # Not an integer → treat as path. + resume_pickle_path = Path(raw) + else: + # Integer → look up the canonical filename. We do this + # eagerly so the user gets a clear "no such checkpoint" + # error right here, not deep inside run_nsga3's pickle + # load. + resume_pickle_path = ( + CHECKPOINT_DIR / f"checkpoint_gen_{gen_idx}.pkl" + ) + if not resume_pickle_path.is_file(): + parser.error( + f"--resume-from: no checkpoint at {resume_pickle_path}" + ) + + # ============================================================ + # LOGGING + # ============================================================ + # + # OLD (ExaConstit_NSGA3.py): + # + # initialize_ExaProb_log( + # glob_loglvl="info", + # filename="logbook3_ExaProb.log", + # restart=restart, + # ) + # + # NEW: configure_logging wraps stdlib logging. Inside framework + # modules, loggers come from get_logger(__name__). + + WORKSPACE.mkdir(parents=True, exist_ok=True) + configure_logging( + level="info", + logfile=WORKSPACE / "logbook3_ExaProb.log", + append=(resume_pickle_path is not None), + ) + + # ============================================================ + # PARAMETER BOUNDS — Bounds replaces BOUND_LOW / BOUND_UP / NDIM + # ============================================================ + # + # OLD (ExaConstit_NSGA3.py): + # + # IND_LOW = [150, 100, 50, 1500, 1e-5, 1e-3, 1e-4, 1e-5, 1e-6] + # IND_UP = [200, 150, 100, 2500, 1e-3, 1e-1, 1e-2, 1e-3, 1e-4] + # BOUND_LOW = IND_LOW; BOUND_UP = IND_UP + # NDIM = len(BOUND_LOW) + # + # NEW: edit one PARAMS table. Bounds, names, archive metadata, + # log output, and write_properties() all derive from this table. + # + # IMPORTANT USER CONTRACT: + # PARAMS defines the optimized-parameter vector. The order here is + # the gene order: + # + # gene[0] -> PARAMS[0].name + # gene[1] -> PARAMS[1].name + # ... + # + # To add a fitted parameter, add one ParameterSpec below and then + # use that name in write_properties(). Example: to fit the Voce + # saturation strength, add + # + # ParameterSpec("crss_sat", 80.0e-3, 180.0e-3, "GPa", + # "CRSS saturation strength") + # + # and replace the fixed crss_sat value in write_properties() with + # gene_dict["crss_sat"]. + + PARAMS = [ + ParameterSpec( + name="mprime", + lower=0.01e0, + upper=0.05e0, + description="rate sensitivity exponent for power-law slip", + ), + ParameterSpec( + name="h0", + lower=200.0e-3, + upper=500.0e-3, + units="GPa", + description="Voce hardening coefficient", + ), + ParameterSpec( + name="crss0", + lower=10.0e-3, + upper=20.0e-3, + units="GPa", + description="initial critical resolved shear stress", + ), + ] + + param_names = [spec.name for spec in PARAMS] + bounds = Bounds( + lower=np.array([spec.lower for spec in PARAMS]), + upper=np.array([spec.upper for spec in PARAMS]), + ) + assert len(param_names) == bounds.n_params + + # ============================================================ + # SIMCASES — one per experiment, with FULL per-experiment dict + # ============================================================ + # + # OLD (ExaConstit_NSGA3.py, all the per-experiment parallel arrays): + # + # ncpus = [4, 4] + # ngpus = [0, 0] + # nnodes = [1, 1] + # temperature_k = [DEP_UNOPT[0][0], DEP_UNOPT[1][0]] + # ori_file = ["voce_quats.ori", "voce_quats.ori"] + # strain_rate = [-1.0e-3, -1.0e-1] + # desired_strain = [-0.1301, -0.1001] + # timeout = [6 * 60, 6 * 60] + # t_final = [120.0, 1.0] + # dt_min = [0.001, 0.00001] + # dt_max = [1.0, 0.01] + # dt_scale = [0.125, 0.125] + # minmax_strain = [None, None] + # essential_vals = [[0.0, 0.0, 0.0, 0.0, 0.0, -0.001], + # [0.0, 0.0, 0.0, 0.0, 0.0, -0.001]] + # mesh_lengths = [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]] + # mesh_cuts = [[1, 1, 1], [1, 1, 1]] + # p_refinement = [1, 1] + # + # test_dataframe = { + # "experiments": exper_input_files, + # "temp_k": temperature_k, "strain_rate": strain_rate, + # "desired_strain": desired_strain, "ori_file": ori_file, + # "t_final": t_final, "dt_min": dt_min, "dt_max": dt_max, + # "dt_scale": dt_scale, "essential_vals": essential_vals, + # "mesh_lengths": mesh_lengths, "mesh_cuts": mesh_cuts, + # "p_refinement": p_refinement, "timeout": timeout, + # "minmax_strain": minmax_strain, + # } + # test_dataframe = pd.DataFrame(data=test_dataframe) + # + # NEW: one SimCase per row of the old DataFrame. Per-experiment + # overlay (the old `test_dataframe` columns minus the experiment + # file itself) goes in SimCase.case_data and gets substituted + # into template_options.toml at render time. Per-experiment resource + # requests (ncpus/ngpus/nnodes/timeout) go in the SimCase's + # resource-override fields and flow through to the backend's + # SimJobSpec. + # + # PLAIN-LANGUAGE VERSION: + # One SimCase is one experiment you are trying to match. If you + # measured the same material at two strain rates, use two SimCases. + # If you measured tension and compression, use two SimCases. If + # you measured three temperatures, use three SimCases. + # + # To add an experiment, copy one SimCase block and change: + # * label: a short name that will appear in logs + # * EXPERIMENT_FILES above: add the measured curve file + # * strain_rate: signed strain rate for the experiment + # * desired_strain and minmax_strain: comparison strain range + # * t_final/dt_min/dt_max/dt_scale: simulation time stepping + # * essential_vals: imposed boundary values in options.toml + # * ori_file / ORIENTATION_FILES if orientation changes + # * mesh_lengths, mesh_cuts, p_refinement if the RVE changes + # * num_tasks, cores_per_task, gpus_per_task if resource needs + # change for that case + + # ============================================================ + # PER-CASE CONSTANTS — what to put on each SimCase + # ============================================================ + # + # SimCase.case_data is the single mapping for everything that's + # specific to one experiment but NOT part of the gene vector + # being optimized. Whatever you put here flows to all three + # framework components that need per-case context: + # + # * the TEMPLATER substitutes %%key%% references in + # template_options.toml (and any other rendered file) + # * the PATH RESOLVER substitutes {key} references in + # working_dir_pattern and output_file_patterns + # * the PROPERTY WRITER reads sim_case.case_data inside the + # CallablePropertyWriter callable + # + # One dict, three readers. No need to remember which field a + # particular variable belongs in. + # + # Common cases: + # * "different elastic constants per temperature" — put the + # constants directly in case_data as "c11", "c12", and + # "c44". The first shipped SimCase below does this explicitly; + # the second omits them and therefore uses write_properties() + # defaults. + # * "case-specific orientation file" — put the filename in + # case_data["ori_file"] so it lands in options.toml AND + # the property writer can read it. + # * "case-specific RVE name in the path" — put it in + # case_data["rve_name"] and use {rve_name} in + # working_dir_pattern. + # + # Per-case RESOURCE overrides (num_nodes / num_tasks / + # gpus_per_task / duration_s / binary / binary_args) are + # separate top-level fields on SimCase — see step 4a below. + # Resources are consumed by the backend, not by templates or + # paths, which is why they have dedicated fields rather than + # living inside case_data. + # + # NOTE: TemplatePropertyWriter ALSO has an `extra_values=` field, + # but those are constants applied to EVERY case (not per-case); + # for per-case property-writer constants, read them out of + # sim_case.case_data inside your CallablePropertyWriter callable. + # + # ILLUSTRATIVE EXAMPLE — not used in this run, but the pattern + # to imitate when constants need to vary per simulation case + # (elastic constants, lattice parameters, anisotropy ratios, + # whatever else is gene-independent but case-dependent): + # + # PUT EVERY VARYING CONSTANT DIRECTLY IN case_data. No Python + # lookup tables, no logic in the writer to map cases to values. + # The data lives next to the SimCase that uses it; modifying + # it later is one place to edit: + # + # sim_cases = [ + # SimCase( + # label="exp_298K", + # case_data={ + # # Loading + path-pattern fields: + # "strain_rate": -1.0e-3, + # "temperature_k": 298.0, + # "ori_file": "voce_298k.ori", + # "rve_name": "grain_32", + # # Per-case material constants — elastic moduli + # # at this case's temperature, lattice params, + # # whatever else varies. Just dictionary entries: + # "c11": 168.4, + # "c12": 121.4, + # "c44": 75.4, + # }, + # ), + # SimCase( + # label="exp_600K", + # case_data={ + # "strain_rate": -1.0e-3, + # "temperature_k": 600.0, + # "ori_file": "voce_600k.ori", + # "rve_name": "grain_32", + # # Different elastic constants for this temperature. + # # Same KEYS, different VALUES — that's all: + # "c11": 156.0, + # "c12": 117.0, + # "c44": 72.0, + # }, + # ), + # ] + # + # CONSUMING THIS DATA — three options, pick whichever fits your + # sim code's input format. All three see the same case_data; + # the framework wires it to all three readers automatically: + # + # OPTION 1 — let the templater put the constants in options.toml. + # If your template_options.toml has %%c11%%, %%c12%%, %%c44%% + # placeholders, the templater fills them from case_data. Zero + # writer code; nothing to change in the writer at all. + # + # OPTION 2 — let the writer pull from case_data and write a + # separate properties.txt. Useful if your sim code reads + # material constants from a dedicated properties file rather + # than options.toml. The writer reads case_data by key: + # + # def write_properties(case_dir, gene, names, sim_case): + # path = case_dir / "properties.txt" + # data = sim_case.case_data + # lines = [ + # f"c11 = {data['c11']}", + # f"c12 = {data['c12']}", + # f"c44 = {data['c44']}", + # # ... gene-derived values follow ... + # ] + # path.write_text("\n".join(lines) + "\n") + # return path + # + # OPTION 3 — split: some constants in options.toml via + # templater (Option 1), some in properties.txt via writer + # (Option 2). Same case_data dict, different consumers. The + # framework doesn't impose a particular layout. + # + # ABOUT EXTRA KEYS: case_data may contain keys not referenced + # by any template or path pattern. Those are silently ignored + # by the templater (validated at template-load time, not at + # data-load time) — the templater only complains when its + # template has a %%key%% with no matching case_data entry, + # which is exactly the typo-detection users want. + + # RESOURCE SHAPE PER CASE + # ----------------------- + # These four fields tell the backend how much hardware ONE + # simulation case should consume: + # + # num_nodes - nodes requested for this case + # num_tasks - MPI ranks for this case + # cores_per_task - CPU cores per MPI rank + # gpus_per_task - GPUs per MPI rank + # + # Examples: + # + # CPU-only, 4 MPI ranks on 1 node: + # num_nodes=1, num_tasks=4, cores_per_task=1, gpus_per_task=0 + # + # GPU run, 1 rank per GPU on a 4-GPU node: + # num_nodes=1, num_tasks=4, cores_per_task=1, gpus_per_task=1 + # + # Hybrid MPI+threads, 4 ranks each with 7 CPU cores and 1 GPU: + # num_nodes=1, num_tasks=4, cores_per_task=7, gpus_per_task=1 + # + # FluxBackend consumes these fields directly when building the + # jobspec. LocalBackend only launches local subprocesses, so + # ``num_tasks > 1`` requires wiring in an MPI launcher there. + # The shipped example keeps the old 4-rank CPU-only shape. + sim_cases = [ + SimCase( + label="exp1_quasi_static", + case_data={ + # Loading condition / boundary conditions. + "strain_rate": -1.0e-3, + "desired_strain": -0.1301, + "temperature_k": 298.0, + "ori_file": "voce_quats.ori", # filename IN case dir + # Example of per-case material constants. These are + # the same cubic elastic constants used as defaults + # in write_properties(), so adding them here verifies + # the case_data path without changing the shipped + # numerical behavior. To calibrate or compare + # experiments at different temperatures, put the + # temperature-specific elastic constants on each + # SimCase. + "c11": 168.4e0, + "c12": 121.4e0, + "c44": 75.2e0, + # Time stepping. + "t_final": 120.0, + "dt_min": 0.001, + "dt_max": 1.0, + "dt_scale": 0.125, + # Essential (Dirichlet) BC values for each dof. + # For this template, the six values are ordered like + # the six velocity/strain-rate components expected by + # the ExaConstit options file. The last entry controls + # the z-direction loading used by this example, so it + # matches strain_rate for uniaxial z compression. + # Negative means compression; positive means tension. + "essential_vals": [0.0, 0.0, 0.0, 0.0, 0.0, -0.001], + # Mesh controls. + "mesh_lengths": [1.0, 1.0, 1.0], + "mesh_cuts": [5, 5, 5], + "p_refinement": 1, + # Reference to the per-case properties file written + # by CallablePropertyWriter below. + "properties_file": "properties.txt", + # Repo-local support files resolved from this example's + # location so users can run from any working directory. + "state_vars_file": str(STATE_VARS_FILE), + "grain_file": str(GRAIN_FILE), + # Optimization window. (lo, hi) absolute-value strain + # bounds; the extractor crops both stress and slope + # comparisons to this interval. The elastic regime + # in metals is typically below ~0.002 strain — set + # lo above that to skip elastic, leaving the optimizer + # focused on the plastic portion the gene parameters + # actually control. Use None on either side for "no + # bound." None for the whole field disables windowing. + "minmax_strain": (-0.01, -0.13), + }, + # Per-case resource request. This example keeps the old + # CPU-only 4-rank shape: + # 1 node + # 4 MPI ranks + # 1 CPU core per rank + # 0 GPUs per rank + # + # To move to GPUs under Flux, typically change only + # ``gpus_per_task`` (and, if needed, ``cores_per_task``). + num_nodes=1, + num_tasks=4, # ncpus[0] + cores_per_task=1, + gpus_per_task=0, # ngpus[0] + duration_s=6 * 60, # timeout[0] (seconds) + # Per-SimCase args for the binary's CLI. + binary_args=("-opt", "options.toml"), + ), + SimCase( + label="exp2_high_rate", + case_data={ + "strain_rate": -1.0e-1, + "desired_strain": -0.1001, + "temperature_k": 298.0, + "ori_file": "voce_quats.ori", + "t_final": 1.0, + "dt_min": 0.00001, + "dt_max": 0.01, + "dt_scale": 0.125, + # Same convention as exp1: the last entry imposes + # z-direction compression at the faster rate. + "essential_vals": [0.0, 0.0, 0.0, 0.0, 0.0, -0.1], + "mesh_lengths": [1.0, 1.0, 1.0], + "mesh_cuts": [5, 5, 5], + "p_refinement": 1, + "properties_file": "properties.txt", + "state_vars_file": str(STATE_VARS_FILE), + "grain_file": str(GRAIN_FILE), + "minmax_strain": (-0.01, -0.1), + }, + num_nodes=1, + num_tasks=4, + cores_per_task=1, + gpus_per_task=0, + duration_s=6 * 60, + binary_args=("-opt", "options.toml"), + ), + ] + + # ============================================================ + # OBJECTIVES — stress + slope per experiment (2 * NEXP) + # ============================================================ + # + # OLD (ExaConstit_Problems.py::evaluate): + # + # f[iobj * 2] = RMSE(sim_stress, exp_stress) / np.std(exp_stress) + # f[iobj * 2 + 1] = RMSE(sim_slope, exp_slope) / np.std(exp_slope) + # + # NEW: one ObjectiveSpec per objective value, each pointing at + # its SimCase by index. Custom evaluator classes defined at the + # bottom of this file reproduce the OLD std-normalized RMSE + # exactly. The framework's built-in StressStrainObjective returns + # plain RMSE without normalization — that's a judgment call the + # framework doesn't make silently. + # + # Choosing the stress/strain comparison: + # * stress_column must match the loading direction in the + # simulation output: Sxx for x loading, Syy for y loading, + # Szz for z loading. + # * strain_rate should keep the experimental sign convention. + # This example uses negative strain for compression. + # * minmax_strain in each SimCase crops the comparison window. + # Use it to ignore elastic transients, machine seating, noisy + # tails, or strain ranges where the experiment is unreliable. + # * Some evaluators score using absolute strain/stress magnitudes + # so tension/compression signs do not dominate RMSE. Plots and + # physical interpretation still need the correct sign. + + objective_specs = [] + for i, (exp_file, sim_case) in enumerate(zip(EXPERIMENT_FILES, sim_cases)): + exp_df = _load_experimental_csv(exp_file) + # Pass strain_rate signed so the extractor's + # strain = strain_rate * time produces signed strain + # matching the loading direction (negative for compression, + # positive for tension). Stripping the sign here would + # flip the strain axis relative to the stress axis on + # compression cases, leaving plotted curves looking like + # tension responses with negative stress — wrong sign on + # the strain side, mismatched against experimental data. + # Evaluators that score on magnitudes still work because + # they apply np.abs themselves before computing RMSE. + strain_rate = float(sim_case.case_data["strain_rate"]) + + # Optional optimization window. case_data["minmax_strain"] is + # a (lo, hi) pair or None: lo=None or hi=None means "no + # bound on that side." If both bounds are missing the + # extractor sees window=None and returns the full curve. + # Anything real, including upper bounds derived from + # "desired_strain" if that's all the user wants, can go + # here. + minmax = sim_case.case_data.get("minmax_strain") + if minmax is not None and (minmax[0] is not None or minmax[1] is not None): + # Substitute 0 for missing lo and a huge number for + # missing hi — both are absolute-value bounds, so 0 means + # "include from origin" and 1e9 means "no upper limit". + lo = 0.0 if minmax[0] is None else float(abs(minmax[0])) + hi = 1e9 if minmax[1] is None else float(abs(minmax[1])) + window = (lo, hi) + else: + # Fall back to "desired_strain" as the upper bound when + # minmax_strain isn't set — this preserves the old + # convention where users only declared the maximum strain + # they cared about. + window = (0.0, abs(sim_case.case_data["desired_strain"])) + + # Build one extractor per SimCase. The window crops the + # extracted (strain, stress) arrays before any objective + # sees them, so both stress and slope evaluators below + # automatically respect the user's chosen interval. + extractor = StressStrainExtractor( + stress_output="avg_stress", + stress_column="Szz", # z-axis load — override for x/y loading + strain_source="time_rate", + strain_rate=strain_rate, + time_column="Time", + window=window, + ) + + stress_eval = _StdNormalizedStressEvaluator( + experimental=exp_df, extractor=extractor, + ) + slope_eval = _StdNormalizedSlopeEvaluator( + experimental=exp_df, extractor=extractor, + ) + objective_specs.extend([ + ObjectiveSpec(stress_eval, sim_case=i, label=f"stress_{i + 1}"), + ObjectiveSpec(slope_eval, sim_case=i, label=f"slope_{i + 1}"), + ]) + + n_obj = len(objective_specs) + + # ============================================================ + # PROPERTY WRITER — CallablePropertyWriter matching the old pattern + # ============================================================ + # + # OLD: a user-supplied Python function wrote a per-case properties + # file. The ExaProb class invoked that callable during each + # evaluation, right before running mechanics. + # + # NEW: CallablePropertyWriter is the direct analogue. Define your + # write function with signature + # ``(case_dir, gene, param_names, sim_case) -> Path`` and hand it + # to the writer. Everything else about the framework is unchanged. + # + # The writer's callable receives the SimCase for this evaluation, + # so per-experiment logic (different orientation files, temp- + # dependent derived values, etc.) works naturally. + # + # This example writer is intentionally minimal. It is enough to + # exercise the workflow and reproduce the old quick-start behavior, + # but it is not a complete material-model authoring interface. + # For production calibration work, port or write a material + # generator that names each physical parameter explicitly, similar + # to the older Matgen-style scripts. That keeps units, model + # assumptions, and parameter ordering visible to the mechanics + # user instead of hiding them in a list of numbers. + + def write_properties(case_dir: Path, gene: Sequence[float], + names: Sequence[str], sim_case: SimCase) -> Path: + """Write material-model properties for one case. + + Produces a `properties.txt` file whose format matches what + the old `ExaConstit_Problems.py` wrote. Adapt the body to + whatever format your mechanics binary actually reads. The + framework does not care about the file's internal format, + only that the file exists and is referenced correctly in + options.toml. ExaConstit does care: the numeric order here + must match the active material model selected in + template_options.toml. + + ``sim_case`` lets this callable behave differently per + experiment. Read per-case constants out of + ``sim_case.case_data`` (set up the SimCase) — typical + examples include ``temperature_k`` for a temp-dependent + elastic-constant lookup, ``ori_file`` for case-specific + orientation files, or any other field you put in the + SimCase's case_data dict. See the SimCase definitions + above for the conventions; the comment block before + ``sim_cases = [...]`` explains how case_data flows to the + templater, path resolver, and property writer. + """ + path = case_dir / "properties.txt" + # Zip gene values with their names so the output is both + # machine-readable AND human-auditable for debugging. + gene_dict = dict(zip(names, gene)) + + # ------------------------------------------------------------------ + # Voce material properties for this example + # ------------------------------------------------------------------ + # + # This section mirrors the older Matgen-style scripts: assign + # each physical quantity to a named Python variable first, then + # write the final numeric list in the exact order ExaConstit + # expects for the selected material model. + # + # Per-simulation constants: + # SimCase.case_data is available here as ``case_data``. To make + # a constant vary by experiment, add the key to each SimCase and + # read it with ``case_data.get("name", default)`` below. + # + # Example for temperature-dependent elastic constants: + # + # In each SimCase.case_data, add: + # "c11": 168.4, "c12": 121.4, "c44": 75.2 + # + # Then the c11/c12/c44 assignments below automatically use + # the case-specific values. If the keys are absent, the + # defaults shown here are used. + # + # Example for a temperature-driven reference energy: + # + # simulation_temperature_k = case_data["temperature_k"] + # reference_temperature_k = simulation_temperature_k + # + # This example keeps the historical 300 K reference energy so + # it reproduces the old quick-start behavior. + case_data = sim_case.case_data + + # Basic thermo-physical constants. + density = 8.920e-6 + heat_capacity_cv = 0.003435984 + tolerance = 1.0e-10 + + # Cubic elastic constants. Put c11/c12/c44 in SimCase.case_data + # to vary these by experiment, temperature, orientation set, or + # any other case-specific condition. + c11 = float(case_data.get("c11", 168.4e0)) + c12 = float(case_data.get("c12", 121.4e0)) + c44 = float(case_data.get("c44", 75.2e0)) + + # Voce hardening and power-law slip parameters. + # Values pulled from gene_dict are fitted by NSGA-III. The + # remaining values are fixed model constants for this example. + mu = (c11 - c12) / 2.0 + nu = c44 + voigt_shear = 0.2 * (2.0 * mu + 3.0 * nu) + reuss_shear = (mu * nu) / (nu + 3.0 * (mu - nu) * 0.2) + # Shear modulus calculation if not available in literature + shear_modulus = (voigt_shear + reuss_shear) / 2.0 + mprime = float(gene_dict["mprime"]) + gdot0 = 1.0e0 + h0 = float(gene_dict["h0"]) + crss0 = float(gene_dict["crss0"]) + crss_sat = 122.4e-3 + crss_sat_scaling_exponent = 0.0 + crss_sat_scaling_coefficient = 5.0e9 + hardening_initial = crss0 + + # Equation-of-state style constants used by the current model + # input. reference_internal_energy is tied to a reference + # temperature, not necessarily the SimCase's loading temperature. + gruneisen_parameter = 0.0 + reference_temperature_k = 300.0 + reference_internal_energy = -heat_capacity_cv * reference_temperature_k + + # IMPORTANT: this order is the file format. Keep it aligned + # with the active ExaCMech material shortcut/model in + # template_options.toml. The names above are for humans; the + # file written below remains one numeric value per line. + properties = [ + density, + heat_capacity_cv, + tolerance, + c11, + c12, + c44, + shear_modulus, + mprime, + gdot0, + h0, + crss0, + crss_sat, + crss_sat_scaling_exponent, + crss_sat_scaling_coefficient, + hardening_initial, + gruneisen_parameter, + reference_internal_energy, + ] + path.write_text( + "\n".join(f"{value:.12g}" for value in properties) + "\n" + ) + return path + + property_writer = CallablePropertyWriter( + func=write_properties, dest_hint="properties.txt", + ) + + # ============================================================ + # TEMPLATER — renders options.toml per case + copies ori file + # ============================================================ + # + # The CaseTemplater renders text files with %%key%% substitution. + # TWO targets here: + # + # 1. template_options.toml -> options.toml, with ALL the + # SimCase.case_data substituted. + # 2. voce_quats.ori -> voce_quats.ori (plain copy, no + # substitution needed since .ori files are static). + # + # The orientation file's source path is per-SimCase, so we build + # the target list dynamically per case. The ``TemplateTarget`` + # per_case_source mapping lets a single TemplateTarget resolve + # differently per SimCase's index. See the framework's + # CaseTemplater docstring for alternative patterns. + + # For maximum clarity in a short example, we use a single + # templater with one global target (the options.toml render) + # and copy the ori files in as part of each SimCase's + # case_data by passing the source path — the framework + # resolves it naturally because %%ori_file%% is a string in + # template_options.toml. + # + # In production you typically have orientation files that + # differ per SimCase; the simplest way is to place them in a + # known location OUTSIDE the case dir and reference by absolute + # path in template_options.toml, so no copy is needed. The + # example uses that pattern: each SimCase sets + # ``ori_file = "voce_quats.ori"`` as a filename IN the case dir, + # and a second TemplateTarget copies it in. + templater_targets = [ + TemplateTarget(source=MASTER_TOML, dest="options.toml"), + ] + # Only include the ori-file copy if the user actually pointed + # at a real file — matches how the old code handled optional + # orientation inputs. + for i, ori_path in enumerate(ORIENTATION_FILES): + if ori_path.is_file(): + templater_targets.append( + TemplateTarget( + source=ori_path, + dest="voce_quats.ori", + substitute=False, # binary-safe copy, no %%key%% pass + ) + ) + break # single global copy is fine if both experiments share it + templater = CaseTemplater(templater_targets) + + # ============================================================ + # PATH RESOLVER — where each case's working dir and outputs live + # ============================================================ + # + # The PathResolver maps a (generation, gene, obj) CaseContext onto + # a directory and a set of output filenames. Two concerns: + # + # 1. ``working_dir_pattern`` — template for the case's directory. + # Placeholders available: {generation}, {gene}, {obj}, plus + # anything in SimCase.case_data. The old code used + # "gen_{gen}/gene_{gene}_obj_{obj}", which we preserve so a + # mixed old+new workspace doesn't collide. + # + # 2. ``output_file_patterns`` — logical names -> template paths. + # These are the files you want to read BACK after the sim. + # Each key becomes a valid argument to reader.read() later, + # and each {working_dir} inside the pattern is expanded via + # the working_dir_pattern above. + # + # Think of output_file_patterns as "which output files does the + # reader care about, and where does the binary write them?" The + # filenames can differ from the binary's hard-coded output names + # via a simple rename step; here we match the old names directly. + + resolver = TemplatePathResolver( + working_dir_pattern="gen_{generation}/gene_{gene}_obj_{obj}", + output_file_patterns={ + "avg_stress": "{working_dir}/results/options/avg_stress_global.txt", + "avg_def_grad": "{working_dir}/results/options/avg_def_grad_global.txt", + }, + root=WORKSPACE, + ) + + # ============================================================ + # RESULT READER — which output columns matter, how to parse them + # ============================================================ + # + # TextTableReader is built for whitespace-separated numeric output + # like ExaConstit's avg_stress.txt. One TextTableSpec per logical + # output name (the keys match PathResolver.output_file_patterns). + # Each spec declares: + # + # columns - name each column in disk order; the resulting + # DataFrame uses these as .columns. This is where you + # say "column Szz" vs the old hardcoded [:, 2] + # indexing in ExaConstit_Problems.py. + # required - if True (default) a missing or empty file fails + # the case; if False the reader silently skips it + # and the evaluator handles the missing DataFrame. + # + # The extractor above picks from these DataFrames by column name. + # Adjust column order to match your binary's output format exactly, + # or use `comment="#"` / `delimiter=","` / etc. for non-default + # formats (see TextTableSpec docstring for the full option set). + + reader = TextTableReader({ + # avg_stress_global.txt: Time + Volume + 6 Cauchy-stress + # components. ExaConstit writes its volume-averaged output + # files with a ``# Time Volume ...`` header prefix followed + # by calc-type-specific columns. For stress the columns are + # Sxx/Syy/Szz/Sxy/Sxz/Syz (capital S, x/y/z axis indexing — + # Load direction here is z so stress_column="Szz" above. + # See ExaConstit/src/postprocessing/postprocessing_file_manager.hpp + # GetVolumeAverageHeader for the authoritative column lists. + "avg_stress": TextTableSpec(columns=[ + "Time", "Volume", + "Sxx", "Syy", "Szz", "Sxy", "Sxz", "Syz", + ]), + # avg_def_grad_global.txt: Time + Volume + 9 F-components. + # Written by mechanics but optional for calibration; we keep + # it around for post-processing plots. + "avg_def_grad": TextTableSpec( + columns=[ + "Time", "Volume", + "F11", "F12", "F13", + "F21", "F22", "F23", + "F31", "F32", "F33", + ], + required=False, + ), + }) + + # ============================================================ + # ARCHIVE + CLEANUP (opt-in, new feature) + # ============================================================ + + archive = None + archive_run_id = None + if not args.no_archive: + archive = ArchiveDB(WORKSPACE / "calibration.db") + archive.open() + if resume_pickle_path is None: + # Fresh run — create a new archive row and get back its + # UUID. That UUID is threaded into the Problem below so + # gene records write to the right row. + archive_run_id = archive.start_run( + seed=args.seed, + param_names=param_names, + objective_labels=[spec.label for spec in objective_specs], + config={ + "mechanics_binary": str(MECHANICS_BINARY), + "master_toml": str(MASTER_TOML), + "experiment_files": [str(p) for p in EXPERIMENT_FILES], + }, + ) + else: + # Resume — do NOT call start_run(). A fresh UUID here + # would conflict with the one baked into the pickle, + # and the library would refuse the mismatch with a + # ValueError. Leave archive_run_id = None; the library + # reads the pickled UUID out of the checkpoint and + # assigns it to Problem.archive_run_id on our behalf. + # The existing archive row keeps receiving gene records + # under its original UUID, uninterrupted. + pass + + # ============================================================ + # BACKEND + # ============================================================ + # + # OLD: normal_map.map_custom ran cases sequentially via subprocess. + # + # NEW: pluggable backend. LocalBackend for workstation use; + # FluxBackend for HPC. The per-SimCase resource fields above + # (num_nodes, num_tasks, cores_per_task, gpus_per_task, duration_s) + # flow through to whichever backend you pick. + # + # FluxBackend: + # * turns each SimCase into a Flux jobspec + # * requests the CPU/GPU shape encoded on that SimCase + # * handles MPI launch internally via Flux's job shell + # + # LocalBackend: + # * starts local subprocesses on the current host + # * is useful for serial debugging or small workstation runs + # * does NOT magically create MPI ranks by itself; multi-rank + # cases need an explicit MPI launcher in the backend config + # + # NOTE on mpi_launcher: ExaConstit's ``mechanics`` binary uses + # MPI internally, so any SimCase with ``num_tasks > 1`` must run + # under an MPI launcher. LocalBackend will refuse at submit time + # if ``num_tasks > 1`` and ``mpi_launcher`` is not set — prevents + # the failure mode where a four-rank request silently ran + # single-rank. Set this to "mpirun", "srun", "jsrun", or + # whichever launcher is on your PATH. If your launcher uses a + # non-standard ntasks flag (jsrun wants ``--nrs``, lrun wants + # ``-T``) pass ``mpi_launcher_ntasks_flag`` too. + + if args.backend == "flux": + if not HAS_FLUX: + parser.error( + "--backend=flux requested, but this Python interpreter " + "cannot import Flux's bindings. Use the same interpreter " + "for both install and run, and ensure the Flux module is " + "visible (for example via " + "PYTHONPATH=/usr/lib64/flux/python3.12 on this system)." + ) + from workflow_common.backends.flux_backend import FluxBackend + + backend = FluxBackend() + else: + # Debug-oriented default. The SimCases in this example request + # 4 MPI ranks, so local mode assumes ``mpirun`` is available + # on PATH and uses it to launch those ranks. For a simpler + # serial debug run, change each SimCase to ``num_tasks=1`` and + # set mpi_launcher=None here. If your system uses ``srun`` or + # another launcher instead of ``mpirun``, replace the string + # below with that command. + backend = LocalBackend(max_workers=4, mpi_launcher="mpirun") + + # On a Flux-managed HPC allocation: + # + # from workflow_common import FluxBackend + # backend = FluxBackend() + # + # (The per-case resource fields on each SimCase drive Flux's + # job submission; no global resource config is needed. For + # example, a SimCase with ``num_tasks=4, cores_per_task=7, + # gpus_per_task=1`` requests 4 MPI ranks, 28 CPU cores total, + # and 4 GPUs for that one simulation. Flux handles MPI rank + # launching internally; no mpi_launcher argument is involved + # for FluxBackend.) + + # The driver automatically attaches a ProgressReporter per + # generation, rendering a line like: + # + # gen 3/100 | 47/288 (16.3%) | running 4/4 | cores 16/16 | elapsed 2m14s | ETA 11m42s + # + # Interactive runs get in-place updates; captured-stdout runs + # (HPC job logs) get one line per update. Turn off with + # ``RunConfig(show_progress=False)`` if you want silent mode. + + # ============================================================ + # ASSEMBLE THE PROBLEM + # ============================================================ + # + # ProblemConfig now holds only the FALLBACK resource settings. + # If any SimCase leaves a resource field as None, the value + # here is used. In this example all fields are per-case, so + # ProblemConfig's resource numbers don't actually govern + # anything — they're shown for completeness. + + problem = Problem( + config=ProblemConfig( + binary=MECHANICS_BINARY, + binary_args=("-opt", "options.toml"), + num_nodes=1, num_tasks=4, + cores_per_task=1, gpus_per_task=0, + duration_s=3600, + stdout="stdout.log", stderr="stderr.log", + required_outputs=("results/options/avg_stress_global.txt",), + ), + param_names=param_names, + sim_cases=sim_cases, + objective_specs=objective_specs, + templater=templater, + property_writer=property_writer, + resolver=resolver, + backend=backend, + reader=reader, + failure_handler=InfinityFailureHandler(), + manifest=Manifest(WORKSPACE / "manifest.jsonl"), + archive=archive, + archive_run_id=archive_run_id, + ) + + # ============================================================ + # RUN CONFIG — every knob is 1:1 with the old globals + # ============================================================ + # + # OLD -> NEW mapping: + # + # NGEN = 100 -> n_generations=100 + # UNSGA3 = True -> unsga3=True + # p = [10, 0] -> ref_dirs_partitions=(10, 0) + # scaling = [1, 0] -> ref_dirs_scaling=(1.0, 0.0) + # seed = -> seed=args.seed + # mat_eta = 30.0 -> mate_eta=30.0 + # mut_eta = 20.0 -> mut_eta=20.0 + # indpb = 1.0 / NDIM -> mut_indpb=None (default = 1/n_params) + # fail_limit = 10 -> fail_limit=10 + # Imin = round(NGEN / 2) -> imin_fraction=0.5 + # stop_limit = 5 -> stop_limit=5 + # checkpoint_freq = 1 -> checkpoint_freq=1 + # + # cleanup_keep_generations=2 is NEW and requires the archive. + + ref_dirs_partitions = (3, 0) + ref_dirs_scaling = (1.0, 0.0) + + # Compute NPOP the same way the driver will, using the framework's + # public helpers. This replaces a hand-rolled P/H/NPOP calculation + # that's easy to get wrong for the two-hyperplane case (p_inner > 0). + _ref_points, h_count = build_reference_points( + n_obj, ref_dirs_partitions, ref_dirs_scaling, + ) + npop = derive_population_size(n_obj, h_count) + + # Pull n_generations out up front so the summary print and the + # RunConfig below share one source of truth. Same story for any + # other knob you want to surface at the top of the log. + n_generations = 30 + + print(f"\nNumber of experiments = {len(sim_cases)}") + print(f"Number of objectives = {n_obj}") + print(f"Number of parameters = {bounds.n_params}") + print(f"Number of generations = {n_generations}") + print(f"Number of reference points H = {h_count}") + print(f"Population size NPOP = {npop}") + print(f"Total simulation runs = " + f"{npop * len(sim_cases) * n_generations} " + f"(pop x n_sim_cases x gens)\n") + + config = RunConfig( + n_generations=n_generations, + unsga3=True, + ref_dirs_partitions=ref_dirs_partitions, + ref_dirs_scaling=ref_dirs_scaling, + seed=args.seed, + mate_eta=30.0, + mut_eta=20.0, + mut_indpb=None, # = 1/n_params, old default + fail_limit=10, + imin_fraction=0.5, # Imin = NGEN / 2 + stop_limit=5, + checkpoint_dir=WORKSPACE / "checkpoint_files", + checkpoint_freq=1, + resume_from=resume_pickle_path, + track_hypervolume=True, + cleanup_keep_generations=2 if not args.no_archive else None, + # log_dir defaults to checkpoint_dir if set (here, the + # checkpoint_files dir above); override if you want logs + # somewhere else. Two files are written per generation: + # * logbook1_stats.log — aggregate fitness stats (avg / + # std / min / max) plus ND / GD / HV for multi-objective + # * logbook2_solutions.log — every individual's gene + # vector + fitness + # Both are DEAP-style tab-delimited text — greppable, + # less-able, and parseable by the pre-refactor driver's + # downstream tooling. Set write_logbook_files=False to + # skip them. + ) + + # ============================================================ + # GO + # ============================================================ + # + # TIP: for a CLI peek at the SQLite archive during or after + # the run (if --archive is on), try: + # + # python -m workflows.optimization.inspect_archive \ + # ./calibration_run --gens + # + # Add --genes --gen N --pareto-only to see just the Pareto + # front at a specific generation. --format csv pipes into + # Excel / LibreOffice Calc. --format json is friendly to + # notebook post-processing. + + try: + result = run_nsga3(problem, bounds, config) + finally: + if archive is not None: + archive.close() + + # ============================================================ + # QUICK POST-RUN SUMMARY + # ============================================================ + + print(f"\nRun complete. Generations run: {result.generations_run}") + print(f"Final pop size: {len(result.final_pop)}") + print(f"Pareto front size: {len(result.pareto_front)}") + if result.stopped_early: + print("Stopped early via ND == NPOP criterion.") + + from workflow_common.postprocess import best_solution_eudist + fits = np.array([ind.fitness.values for ind in result.final_pop]) + best_idx = best_solution_eudist(fits, nsmallest=1)[0] + best_gene = result.final_pop[best_idx] + print(f"\nBest gene fitness: {best_gene.fitness.values}") + print("Best gene parameters:") + for name, val in zip(param_names, list(best_gene)): + print(f" {name:10s} = {val:.6g}") + + +# ============================================================================ +# Custom evaluators that reproduce the OLD std-normalized RMSE +# ============================================================================ +# +# The pre-refactor ExaProb computed: +# +# f[iobj * 2] = RMSE(sim_stress, exp_stress) / np.std(exp_stress) +# f[iobj * 2 + 1] = RMSE(sim_slope, exp_slope) / np.std(exp_slope) +# +# where slope = np.diff(stress) / np.diff(strain). The framework's +# built-in StressStrainObjective does plain RMSE without normalization; +# these two classes reproduce the old normalization exactly. If you +# prefer a different normalization (the old ExaConstit_Problems.py has +# commented alternatives with std / IQR / min-max / mean denominators), +# change the `denom = ...` line below and leave everything else alone. +# +# An evaluator is any object with an ``evaluate(results, ctx) -> float`` +# method. No base class to inherit from; duck typing suffices. + + +class _StdNormalizedStressEvaluator: + """RMSE(sim_stress, exp_stress) / std(exp_stress). + + Brings the experimental curve onto the simulation's strain + grid via the framework's PCHIP smoother (the right tool: it + can't overshoot, so a smoothed exp curve will not introduce + stress values that never appeared in the source data). This + is the direction the original ExaConstit calibration code + used and it's the one that matters for scoring: the + optimizer is computing sim error at sim's own sample points, + not at noisy raw exp points. + + The extractor is responsible for cropping the simulated + curve to the user's optimization window (via its ``window`` + field). This evaluator just compares whatever the extractor + returns against the experimental reference, restricted to + the common strain range. + """ + + def __init__( + self, + experimental: pd.DataFrame, + extractor: StressStrainExtractor, + ): + self.experimental = experimental + self.extractor = extractor + + def evaluate(self, results: Any, ctx: Any) -> float: + sim_strain, sim_stress = self.extractor.extract(results) + sim_strain = np.abs(sim_strain) + sim_stress = np.abs(sim_stress) + exp_strain = np.abs(self.experimental.iloc[:, 0].to_numpy()) + exp_stress = np.abs(self.experimental.iloc[:, 1].to_numpy()) + + # Mask SIM (not exp) to the common strain range — we want + # sim's sample points as the comparison grid since the + # optimizer is implicitly fitting at sim's resolution. + lo = max(float(sim_strain.min()), float(exp_strain.min())) + hi = min(float(sim_strain.max()), float(exp_strain.max())) + sim_mask = (sim_strain >= lo) & (sim_strain <= hi) + if sim_mask.sum() < 2: + return float("inf") + sim_strain_in = sim_strain[sim_mask] + sim_stress_in = sim_stress[sim_mask] + + # Bring exp onto sim's grid using PCHIP. ``strict_monotonic + # =False`` accepts mild noise in exp_strain (a tiny backward + # step from sampling jitter) by sorting; it does NOT handle + # genuinely non-monotonic data. Mechanical-test exp data is + # cleaned and clipped before reaching this code, per the + # standard calibration workflow. + smoother = PchipSmoother(strict_monotonic=False) + exp_at_sim = smoother.sample_at( + exp_strain, exp_stress, sim_strain_in, + ).y + + denom = float(np.std(exp_at_sim)) + if denom <= 0: + return float("inf") + residual = sim_stress_in - exp_at_sim + return float(np.sqrt(np.mean(residual ** 2)) / denom) + + +class _StdNormalizedSlopeEvaluator: + """RMSE(sim_slope, exp_slope) / std(exp_slope). + + Same architecture as the stress evaluator: brings exp onto + sim's strain grid via PCHIP smoothing, then compares + finite-difference slopes on that common grid. + """ + + def __init__( + self, + experimental: pd.DataFrame, + extractor: StressStrainExtractor, + ): + self.experimental = experimental + self.extractor = extractor + + def evaluate(self, results: Any, ctx: Any) -> float: + sim_strain, sim_stress = self.extractor.extract(results) + sim_strain = np.abs(sim_strain) + sim_stress = np.abs(sim_stress) + exp_strain = np.abs(self.experimental.iloc[:, 0].to_numpy()) + exp_stress = np.abs(self.experimental.iloc[:, 1].to_numpy()) + + lo = max(float(sim_strain.min()), float(exp_strain.min())) + hi = min(float(sim_strain.max()), float(exp_strain.max())) + sim_mask = (sim_strain >= lo) & (sim_strain <= hi) + if sim_mask.sum() < 3: + return float("inf") + sim_strain_in = sim_strain[sim_mask] + sim_stress_in = sim_stress[sim_mask] + + smoother = PchipSmoother(strict_monotonic=False) + exp_at_sim = smoother.sample_at( + exp_strain, exp_stress, sim_strain_in, + ).y + + # Slopes via finite difference on the common grid. + # ``np.diff`` shrinks length by one; using sim's strain + # spacing is correct because exp has now been sampled at + # exactly those points. + diff_strain = np.diff(sim_strain_in) + sim_slope = np.diff(sim_stress_in) / diff_strain + exp_slope = np.diff(exp_at_sim) / diff_strain + + denom = float(np.std(exp_slope)) + if denom <= 0: + return float("inf") + residual = sim_slope - exp_slope + return float(np.sqrt(np.mean(residual ** 2)) / denom) + +def _load_experimental_csv(path: Path) -> pd.DataFrame: + """Load a two-column (strain, stress) file. Any whitespace separator.""" + arr = np.loadtxt(path, dtype=float, ndmin=2) + return pd.DataFrame({"strain": arr[:, 0], "stress": arr[:, 1]}) + + +if __name__ == "__main__": + main() diff --git a/workflows/exaconstit-calibrate/examples/nsga3_slurm_helpers.sh b/workflows/exaconstit-calibrate/examples/nsga3_slurm_helpers.sh new file mode 100755 index 0000000..2d4dff9 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/nsga3_slurm_helpers.sh @@ -0,0 +1,260 @@ +#!/bin/sh +# +# Helper functions for run_nsga3_slurm.sh. +# +# Most users should not edit this file. It exists so the Slurm job +# script can stay short and easy to edit. +# +# Required location: +# Keep this file in the same directory as run_nsga3_slurm.sh. +# +# Alternative location: +# If you store this helper somewhere else, set HELPER_SCRIPT in +# run_nsga3_slurm.sh to this file's full path. + +# exa_nsga3_slurm_tasks_text +# -------------------------- +# Returns a human-readable Slurm task count. Some interactive/exclusive +# allocations are created by node count and do not export SLURM_NTASKS; +# that is not an error for this workflow because the launch command +# below explicitly starts Flux with ``srun -n 1``. +exa_nsga3_slurm_tasks_text() +{ + if [ -n "${SLURM_NTASKS:-}" ]; then + printf "%s" "${SLURM_NTASKS}" + else + printf "not set (common in interactive/exclusive allocations)" + fi +} + +# exa_nsga3_slurm_cpu_text +# ------------------------ +# Reports whichever Slurm CPU-count variable is available. These are +# useful sanity checks in interactive jobs where SLURM_NTASKS is unset. +exa_nsga3_slurm_cpu_text() +{ + if [ -n "${SLURM_CPUS_ON_NODE:-}" ]; then + printf "%s (SLURM_CPUS_ON_NODE)" "${SLURM_CPUS_ON_NODE}" + elif [ -n "${SLURM_JOB_CPUS_PER_NODE:-}" ]; then + printf "%s (SLURM_JOB_CPUS_PER_NODE)" "${SLURM_JOB_CPUS_PER_NODE}" + else + printf "unknown" + fi +} + +# exa_nsga3_setup +# ---------------- +# Sets paths, exports PYTHONPATH so Python can import this package and +# Flux, moves into the package directory, and optionally runs pip install. +exa_nsga3_setup() +{ + PACKAGE_ROOT=$(CDPATH= cd "${SCRIPT_DIR}/.." && pwd) + + export PYTHONPATH="${FLUX_PYTHONPATH}:${PACKAGE_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" + export MPLBACKEND=Agg + + if [ -n "${EXACONSTIT_ROOT}" ]; then + export EXACONSTIT_ROOT + fi + + if [ -n "${EXACONSTIT_MECHANICS}" ]; then + export EXACONSTIT_MECHANICS + fi + + echo "=== ExaConstit NSGA-III Slurm job ===" + echo "date: $(date)" + echo "host: $(hostname)" + echo "job id: ${SLURM_JOB_ID:-not-running-under-slurm}" + echo "nodes: ${SLURM_JOB_NUM_NODES:-unknown}" + echo "slurm tasks: $(exa_nsga3_slurm_tasks_text)" + echo "slurm cpus/node: $(exa_nsga3_slurm_cpu_text)" + echo "flux launcher: srun -n 1 starts one Flux broker inside this allocation" + echo "python: ${PYTHON}" + echo "package root: ${PACKAGE_ROOT}" + echo "examples dir: ${SCRIPT_DIR}" + echo "workspace: ${SCRIPT_DIR}/${WORKSPACE}" + echo "postprocess dir: ${SCRIPT_DIR}/${POST_DIR}" + echo "action: ${ACTION}" + echo + + cd "${PACKAGE_ROOT}" + + if [ "${INSTALL_PACKAGE}" = "1" ]; then + echo "=== Installing editable package ===" + "${PYTHON}" -m pip install -e ".[test,nsga3,plot]" + echo + fi + + cd "${SCRIPT_DIR}" +} + +# exa_nsga3_calibration_args +# -------------------------- +# Converts ACTION and checkpoint settings into command-line arguments +# understood by nsga3_calibration.py. +exa_nsga3_calibration_args() +{ + CALIBRATION_ARGS="--backend flux" + + case "${ACTION}" in + run|all) + ;; + resume-latest) + CALIBRATION_ARGS="${CALIBRATION_ARGS} --resume-latest" + ;; + resume-from) + if [ -n "${CHECKPOINT_PATH}" ]; then + CALIBRATION_ARGS="${CALIBRATION_ARGS} --resume-from ${CHECKPOINT_PATH}" + elif [ -n "${CHECKPOINT_GEN}" ]; then + CALIBRATION_ARGS="${CALIBRATION_ARGS} --resume-from ${CHECKPOINT_GEN}" + else + echo "ERROR: ACTION=\"resume-from\" requires CHECKPOINT_GEN or CHECKPOINT_PATH." + exit 2 + fi + ;; + inspect|plots|postprocess) + ;; + *) + echo "ERROR: unknown ACTION=\"${ACTION}\"" + echo "Allowed actions:" + echo " run" + echo " resume-latest" + echo " resume-from" + echo " inspect" + echo " plots" + echo " postprocess" + echo " all" + exit 2 + ;; + esac +} + +# exa_nsga3_run_calibration +# ------------------------- +# Starts one Flux instance inside the Slurm job and runs the Python +# NSGA-III calibration driver inside that Flux instance. +exa_nsga3_run_calibration() +{ + echo "=== Starting calibration ===" + echo "command:" + echo " srun -n 1 --mpi=none --mpibind=off flux start ${PYTHON} nsga3_calibration.py ${CALIBRATION_ARGS}" + echo + + srun -n 1 --mpi=none --mpibind=off \ + flux start "${PYTHON}" nsga3_calibration.py ${CALIBRATION_ARGS} + + echo +} + +# exa_nsga3_inspect_results +# ------------------------- +# Prints easy-to-read archive summaries and writes a CSV table of the +# best solutions across the whole run. +exa_nsga3_inspect_results() +{ + echo "=== Inspecting archive tables ===" + mkdir -p "${POST_DIR}" + + echo + echo "--- Runs in the archive ---" + "${PYTHON}" -m workflows.optimization.inspect_archive "${WORKSPACE}" --runs + + echo + echo "--- Per-generation convergence summary ---" + "${PYTHON}" -m workflows.optimization.inspect_archive "${WORKSPACE}" --gens-best + + echo + echo "--- Best solutions across the whole run ---" + "${PYTHON}" -m workflows.optimization.inspect_archive \ + "${WORKSPACE}" --genes --pareto-only --top "${TOP_N}" + + echo + echo "--- Writing CSV table of best solutions ---" + "${PYTHON}" -m workflows.optimization.inspect_archive \ + "${WORKSPACE}" --genes --pareto-only --top "${TOP_N}" --format csv \ + > "${POST_DIR}/top_solutions.csv" + echo "wrote: ${SCRIPT_DIR}/${POST_DIR}/top_solutions.csv" + echo +} + +# exa_nsga3_make_plots +# -------------------- +# Writes PNG plots for balanced best solutions, per-objective best +# solutions, and a few common Pareto tradeoff views. +exa_nsga3_make_plots() +{ + echo "=== Making solution plots ===" + mkdir -p "${POST_DIR}" + + "${PYTHON}" plot_solutions.py "${WORKSPACE}" \ + --top "${TOP_N}" \ + --save "${POST_DIR}/top_${TOP_N}_balanced_l2_overlay.png" \ + --no-show + + "${PYTHON}" plot_solutions.py "${WORKSPACE}" \ + --top "${TOP_N}" --objective stress_1 \ + --save "${POST_DIR}/top_${TOP_N}_stress_1_overlay.png" \ + --no-show + + "${PYTHON}" plot_solutions.py "${WORKSPACE}" \ + --top "${TOP_N}" --objective slope_1 \ + --save "${POST_DIR}/top_${TOP_N}_slope_1_overlay.png" \ + --no-show + + "${PYTHON}" plot_solutions.py "${WORKSPACE}" \ + --top "${TOP_N}" --objective stress_2 \ + --save "${POST_DIR}/top_${TOP_N}_stress_2_overlay.png" \ + --no-show + + "${PYTHON}" plot_solutions.py "${WORKSPACE}" \ + --top "${TOP_N}" --objective slope_2 \ + --save "${POST_DIR}/top_${TOP_N}_slope_2_overlay.png" \ + --no-show + + "${PYTHON}" plot_solutions.py "${WORKSPACE}" \ + --top "${TOP_N}" --pareto 0,1 \ + --save-pareto "${POST_DIR}/pareto_stress_1_vs_slope_1.png" \ + --no-show + + "${PYTHON}" plot_solutions.py "${WORKSPACE}" \ + --top "${TOP_N}" --pareto 0,2 \ + --save-pareto "${POST_DIR}/pareto_stress_1_vs_stress_2.png" \ + --no-show + + echo "wrote plots under: ${SCRIPT_DIR}/${POST_DIR}" + echo +} + +# exa_nsga3_main +# -------------- +# Main dispatcher called by run_nsga3_slurm.sh after it loads this +# helper. It runs the action selected in the user-edited Slurm script. +exa_nsga3_main() +{ + exa_nsga3_setup + exa_nsga3_calibration_args + + case "${ACTION}" in + run|resume-latest|resume-from) + exa_nsga3_run_calibration + ;; + all) + exa_nsga3_run_calibration + exa_nsga3_inspect_results + exa_nsga3_make_plots + ;; + inspect) + exa_nsga3_inspect_results + ;; + plots) + exa_nsga3_make_plots + ;; + postprocess) + exa_nsga3_inspect_results + exa_nsga3_make_plots + ;; + esac + + echo "=== Done ===" + echo "date: $(date)" +} diff --git a/workflows/exaconstit-calibrate/examples/options.toml b/workflows/exaconstit-calibrate/examples/options.toml new file mode 100644 index 0000000..462dcd3 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/options.toml @@ -0,0 +1,815 @@ +# ExaConstit Configuration File +# This file controls all aspects of your solid mechanics simulation. +# Lines starting with '#' are comments and will be ignored by the program. +# The file uses TOML format where options are organized in sections [Section] and subsections [[Section.Subsection]] + +# Version of ExaConstit this configuration is designed for +# This helps ensure compatibility - use the version shown when you run 'exaconstit --version' +Version = "0.9.0" + +# Base name for the simulation output directory +# This creates a subdirectory to organize all outputs from this simulation +# For example, if basename = "tensile_test", all outputs go in a "tensile_test" folder +# If not specified, defaults to the name of this configuration file (without .toml extension) +basename = "multi_material_test" + +# ===================================== +# MULTI-MATERIAL SETUP (Optional) +# ===================================== +# For simulations with multiple materials, you need to tell the code how to assign +# materials to different parts of your mesh + +# Maps grain IDs / existing mesh element attributes to material region numbers +# This file should have two columns: element attributes / Grain IDs -> material_region_number +# Example content: +# 1 1 +# 2 1 +# 3 2 +# This would assign element attributes 1-2 to material 1, element 3 to material 2 +region_mapping_file = "region_mapping.txt" + +# For automatically generated meshes with multiple materials +# This file assigns each element to a grain (crystal orientation) +# Format: one integer per line, each representing a grain ID +# Line 1 = grain ID for element 1, Line 2 = grain ID for element 2, etc. +grain_file = "grains.txt" + +# ===================================== +# MATERIAL DEFINITIONS +# ===================================== +# Define properties for each material in your simulation +# Use [[Materials]] to define multiple materials (note the double brackets) +# Each material must have a unique region_id starting from 0 + +[[Materials]] + # User-friendly name for this material (for output files and identification) + material_name = "aluminum_alloy" + + # Which region of the mesh uses this material (starts at 1, must be sequential: 1, 2, 3...) + # This corresponds to the material numbers in your region_mapping_file + region_id = 1 + + # Initial temperature in Kelvin (affects material properties) + # Room temperature ≈ 298K (25°C or 77°F) + temperature = 298.0 + + # Material model type - tells the code which physics engine to use: + # - "umat" = User Material subroutine (custom material behavior) + # - "exacmech" = ExaCMech crystal plasticity library (for metals) + # - "" = Empty string if defined in Model section below + mech_type = "exacmech" + + # ===== Material Properties ===== + [Materials.Properties] + # Option 1: Read properties from a file + floc = "props.txt" # Path to properties file + num_props = 20 # Number of property values to read + + # Option 2: Define properties directly here (choose either floc OR values) + # values = [210000.0, 0.3, 450.0, ...] # [Young's modulus, Poisson's ratio, yield stress, ...] + + # ===== Internal State Variables ===== + # These track the material's history (plastic strain, damage, etc.) + [Materials.State_Vars] + # Option 1: Read initial values from file + floc = "state.txt" # Path to state variables file + num_vars = 30 # Number of state variables + + # Option 2: Define initial values directly (usually zeros for virgin material) + # values = [0.0, 0.0, 0.0, ...] # Initial values (often all zeros) + + # ===== Crystal/Grain Information (for crystal plasticity only) ===== + [Materials.Grain] + # Where orientations are stored in the state variable array + # Use -1 to append at the end (recommended for UMATs) + # Positive number = specific location in state variable array + # Value ignored for any ExaCMech model + ori_state_var_loc = -1 + + # If using custom orientation types we need to know the stride length values + # Value ignored for any ExaCMech model as always quaternions + ori_stride = 4 + + # How crystal orientations are represented: + # - "quat" or "quaternion" = 4 values per orientation (recommended for accuracy) + # - "euler" = 3 Euler angles in degrees (Bunge convention: Z-X-Z) + # - "custom" = User-defined format + # Value ignored for any ExaCMech model as always quaternions + ori_type = "quat" + + # Total number of unique crystal orientations in your simulation + # For single crystal: num_grains = 1 + # For polycrystal: num_grains = number of crystals + num_grains = 100 + + # File containing crystal orientations + # Format depends on ori_type: + # - Quaternions: 4 values per line (q0 q1 q2 q3) + # - Euler angles: 3 values per line in degrees (phi1 Phi phi2) + orientation_file = "orientations.txt" + + # Maps elements to grains (which orientation each element uses) + # Only needed for auto-generated meshes + # For mesh files, this info should be in element attributes + grain_file = "grain_map.txt" + + # ===== Material Model Configuration ===== + [Materials.Model] + # Must match the mech_type above (or define it here if not set above) + mech_type = "exacmech" + + # Is this a crystal plasticity model? + # - true = considers crystal orientation effects (required for ExaCMech) + # - false = isotropic material (same properties in all directions) + crystal_plasticity = true + + # ===== ExaCMech-Specific Settings ===== + [Materials.Model.ExaCMech] + # Model name from ExaCMech library + # This combines crystal structure + hardening law + kinetics + # Common options: + # FCC metals (aluminum, copper, nickel): + # - "evptn_FCC_A" = Linear Voce hardening + power law slip kinetics + # - "evptn_FCC_AH" = Nonlinear Voce hardening + power law slip kinetics + # - "evptn_FCC_B" = Kocks-Meckings single source DD model + MTS-like and phonon drag slip kinetics (temperature dependent) + # BCC metals (iron, tungsten): + # - "evptn_BCC_A" = Linear Voce hardening + power law slip kinetics + # - "evptn_BCC_B" = Kocks-Meckings single source DD model + MTS-like and phonon drag slip kinetics (temperature dependent) + # HCP metals (titanium, magnesium): + # - "evptn_HCP_A" = Kocks-Meckings single source DD model + MTS-like and phonon drag slip kinetics (temperature dependent) + shortcut = "evptn_FCC_A" + +# ===== Second Material Example (UMAT) ===== +[[Materials]] + material_name = "custom_polymer" + region_id = 2 + temperature = 298.0 + mech_type = "umat" + + [Materials.Properties] + num_props = 2 + # Direct property definition - useful for simple materials + # For polymer: [Young's modulus, Poisson's ratio] + values = [3000.0, 0.35] + + [Materials.State_Vars] + num_vars = 5 + # Initial state (zeros for undeformed material) + values = [0.0, 0.0, 0.0, 0.0, 0.0] + + [Materials.Model] + mech_type = "umat" + # UMATs are typically isotropic + crystal_plasticity = false + + [Materials.Model.UMAT] + # Path to compiled UMAT library (.so or .dll file) + # Can be absolute path or relative to working directory + library_path = "./my_umat.so" + + # Name of the UMAT function in the library + # Must match the function name in your Fortran/C code + function_name = "my_polymer_model" + + # When to load the library: + # - "persistent" = load once at start, keep in memory (fastest) + # - "lazy_load" = load when first needed (saves memory) + # - "load_on_setup" = reload for each setup (for debugging) + load_strategy = "persistent" + + # Allow loading external libraries? + # Set false for security if using pre-installed UMATs only + enable_dynamic_loading = true + + # Directories to search for the UMAT library + # Searched in order if library_path is just a filename + search_paths = ["./", "./umats/", "/shared/umat_libs/"] + +# ===================================== +# BOUNDARY CONDITIONS +# ===================================== +# Define how your model is loaded and constrained +# BCs can change over time using the time_info section + +[BCs] + # ===== Time-Dependent BC Control ===== + [BCs.time_info] + # How to specify when BCs change: + # Option 1: Based on cycle number (recommended for multi-step loading) + cycle_dependent = true + # Change BCs at cycle 100 and 200 + # A value of 1 is always required + cycles = [1, 100, 200] + + # Option 2: Based on simulation time (alternative method) + # Currently ignored but will be coming in a future release + # time_dependent = true + # times = [0.1, 0.2] # Change BCs at time 0.1 and 0.2 + + # Note: For all of the below BCs if we have changing BCs over several cycles then + # we need to have multiple [[BCs.velocity_bcs]] and [[BCs.velocity_gradient_bcs]] + + # ===== Velocity Boundary Conditions ===== + # Apply specific velocities to nodes/surfaces + # ( think constant engineering strain rate BCs ) + [[BCs.velocity_bcs]] + # Which boundary markers/node sets to apply this BC to + # These numbers come from your mesh file's boundary markers + essential_ids = [1, 2, 3] + + # Which velocity components to constrain for each boundary marker + # This uses a binary encoding: + # 0 = no constraints (free) + # 1 = constrain X velocity only + # 2 = constrain Y velocity only + # 3 = constrain Z velocity only + # 4 = constrain X and Y velocities + # 5 = constrain Y and Z velocities + # 6 = constrain X and Z velocities + # 7 = constrain all velocities (X, Y, and Z) + # Example: [3, 1, 2] means: + # - Boundary 1: fix Z velocity only + # - Boundary 2: fix X velocity only + # - Boundary 3: fix Y velocity only + essential_comps = [3, 1, 2] + + # Velocity values for each constrained component + # Format: [vx, vy, vz] for each boundary marker + # Order matches essential_ids, components match essential_comps + # Example below: all velocities are zero (fixed boundaries) + essential_vals = [0.0, 0.0, 0.0, # Boundary 1 velocities + 0.0, 0.0, 0.0, # Boundary 2 velocities + 0.0, 0.0, 0.0] # Boundary 3 velocities + + # ===== Velocity Gradient Boundary Conditions ===== + # Apply a velocity gradient to a boundary + # ( think constant true strain rate BCs ) + [[BCs.velocity_gradient_bcs]] + # Boundary markers for velocity gradient BC + essential_ids = [4] + + # Which components to constrain (same encoding as above) + # Typically use 7 (all components) for velocity gradient + essential_comps = [7] + + # Velocity gradient tensor (3x3 matrix) + # This defines the velocity: v = L·(x - origin) + # Each row is [L11, L12, L13; L21, L22, L23; L31, L32, L33] + # Example: uniaxial tension in Z at strain rate 0.001/s + velocity_gradient = [[0.0, 0.0, 0.0], # dVx/dx, dVx/dy, dVx/dz + [0.0, 0.0, 0.0], # dVy/dx, dVy/dy, dVy/dz + [0.0, 0.0, 0.001]] # dVz/dx, dVz/dy, dVz/dz + + # Reference point for velocity gradient (default: origin) + # Velocities are: v = L·(x - origin) + # Currently this is assummed constant over all time steps + # but in future this could change over time + origin = [0.0, 0.0, 0.0] + + # ================================================================= + # EXPERIMENTAL: Monotonic Z-Direction Loading Boundary Condition + # ================================================================= + # Enables a specialized boundary condition for uniaxial monotonic loading + # in the z-direction, commonly used for material characterization and + # constitutive model validation. + # + # BEHAVIOR: + # - Applies pure tension or compression in the z-direction + # - Automatically constrains lateral surfaces to prevent shear deformation + # - Maintains kinematic compatibility to avoid artificial stress concentrations + # - Produces clean uniaxial stress states ideal for comparing with + # experimental stress-strain curves + # + # TYPICAL USE CASES: + # - Single crystal tensile/compression testing + # - Polycrystal aggregate response validation + # - Material parameter calibration + # - Crystal plasticity model verification + # + # REQUIREMENTS & LIMITATIONS: + # - Best performance with auto_time_stepping = true + # - May cause convergence issues or crashes with fixed time stepping + # - Recommended for simple geometries (rectangular domains) + # - Not suitable for complex loading paths or multiaxial conditions + # + # WARNING: This is an experimental feature under active development. + # Verify results against known analytical solutions before + # using for production simulations. + # + expt_mono_def_flag = false + +# ===================================== +# TIME STEPPING CONTROL +# ===================================== +# How the simulation advances through time +# Choose ONE of: Custom, Auto, or Fixed + +[Time] + # Which time stepping method to use: + # - "custom" = user-defined time steps from file (most control) + # - "auto" = adaptive time stepping (recommended for efficiency) + # - "fixed" = constant time step (simplest) + # Time stepping priority (highest to lowest): + # 1. Custom (if [Time.Custom] exists) + # 2. Auto (if [Time.Auto] exists) + # 3. Fixed (if [Time.Fixed] exists or as default) + time_type = "custom" + + # ===== Restart Options (Optional) ===== + # Continue from a previous simulation + # Currently ignored but will be coming in a near-future release + # restart = true + # restart_time = 0.5 # Time to restart from + # restart_cycle = 100 # Cycle number to restart from + + # ===== Automatic Time Stepping ===== + # Adjusts time step based on convergence behavior + [Time.Auto] + # Starting time step size + # Smaller = more accurate but slower + # Typical range: 0.001 to 1.0 + dt_start = 0.1 + + # Minimum allowed time step + # Simulation fails if it needs smaller than this + # Too small = may never finish, too large = may not converge + dt_min = 0.001 + + # Maximum allowed time step + # Prevents steps from getting too large + # 1e9 = effectively no limit + dt_max = 1.0 + + # Reduction factor when convergence is difficult + # If solver struggles, multiply dt by this factor + # Must be between 0 and 1 (0.25 = reduce to 25%) + dt_scale = 0.25 + + # Target end time for simulation + t_final = 1.0 + + # ===== Fixed Time Stepping ===== + # Same time step throughout simulation + [Time.Fixed] + # Time step size (constant throughout) + dt = 0.01 + + # Simulation end time + t_final = 1.0 + + # ===== Custom Time Stepping ===== + # Full control over every time step + [Time.Custom] + # Total number of time steps to take + nsteps = 1000 + + # File containing time step sizes + # Format: one number per line (dt for each step) + # Line 1 = dt for step 1, Line 2 = dt for step 2, etc. + floc = "custom_dt.txt" + +# ===================================== +# SOLVER SETTINGS +# ===================================== +# Controls how the equations are solved numerically + +[Solvers] + # ===== Matrix Assembly Strategy ===== + # How to build and store the stiffness matrix: + # - "FULL" = build complete matrix (most memory, best for CPU-only problems) + # - "PA" = partial assembly (least memory, best for higher-order p-refinement elements) + # - "EA" = element assembly (less memory, best for large problems run on GPUs) + # GPU requires PA or EA, CPU works with all options + assembly = "FULL" + + # ===== Execution Model ===== + # Where to run computations: + # - "CPU" = single processor core (simple, good for debugging or CPU only problems) + # These later options require RAJA/MFEM/ExaConstit to have been compiled with support for these + # - "OPENMP" = multiple CPU cores (faster for medium problems) + # - "GPU" = graphics card (fastest for large problems) + rtmodel = "CPU" + + # ===== Integration Scheme ===== + # How to handle material incompressibility: + # - "FULL" = standard integration (general purpose) + # - "BBAR" = B-bar method (for nearly incompressible materials) + # Use BBAR is really useful if your material has Poisson's ratio > 0.45 + integ_model = "FULL" + + # ===== Linear Solver Settings ===== + # Solves Ax=b at each Newton iteration + [Solvers.Krylov] + # Maximum iterations before giving up + # Increase if solver fails to converge + # EA/PA may need 1000-5000 iterations + iter = 200 + + # Relative tolerance for convergence + # Stop when: ||residual|| < rel_tol * ||initial residual|| + # Smaller = more accurate but slower + rel_tol = 1e-10 + + # Absolute tolerance for convergence + # Stop when: ||residual|| < abs_tol + # Prevents over-solving when residual is already tiny + abs_tol = 1e-30 + + # Linear solver algorithm: + # - "CG" = Conjugate Gradient (fastest for well-behaved symmetric problems) + # - "GMRES" = General solver (works for any problem, reliable but uses more memory) + # - "MINRES" = Minimal Residual (for symmetric problems when CG fails) + # - "BiCGSTAB" = Memory-efficient alternative to GMRES (faster but less robust) + # Use CG for typical material models, GMRES if unsure or have convergence issues + solver = "GMRES" + + # Preconditioner to accelerate convergence: + # With multi-material systems you might find better convergence + # properties by testing out different preconditioners + # EA/PA will automatically run with JACOBI as the full matrix is + # not constructed + # - "JACOBI" = diagonal scaling (simple/fast, works everywhere but slow convergence) + # - "AMG" = Algebraic MultiGrid (fewer iterations but expensive setup, can fail on some problems) + # - "ILU" = Incomplete factorization (good middle-ground, useful for multi-material systems) + # - "L1GS" = advanced smoother (can help with multi-material systems with contrasting properties) + # - "CHEBYSHEV" = polynomial smoother (good for problems with multiple material scales) + # Try ILU / L1GS / CHEBYSHEV if JACOBI convergence is too slow + preconditioner = "JACOBI" + + # Output verbosity (0 = quiet, 1+ = show iterations) + print_level = 0 + + # ===== Nonlinear Solver Settings ===== + # Solves the overall nonlinear problem + [Solvers.NR] + # Maximum Newton-Raphson iterations per time step + # Increase if "failed to converge" errors occur + iter = 25 + + # Relative tolerance for equilibrium + # Stop when: force imbalance < rel_tol * applied force + rel_tol = 1e-5 + + # Absolute tolerance for equilibrium + # Stop when: force imbalance < abs_tol + abs_tol = 1e-10 + + # Nonlinear solver type: + # - "NR" = standard Newton-Raphson (usually sufficient) + # - "NRLS" = Newton with line search (for difficult convergence) + nl_solver = "NR" + +# ===================================== +# VISUALIZATION OUTPUT +# ===================================== +# Controls what visualization files are created for post-processing +# These files can be opened in ParaView, VisIt, or other tools + +[Visualizations] + # ===== Output Formats ===== + # Enable the formats you need (multiple can be true) + + # VisIt format (.visit files + data) + # Good for: VisIt software, time series data + visit = false + + # ParaView format (.pvtu/.vtu files) + # Good for: ParaView software, widely supported + # Recommended for most users + paraview = true + + # ADIOS2 format (high-performance I/O) + # Good for: very large simulations, supercomputers + adios2 = false + + # ===== Output Control ===== + # How often to write visualization files + # 1 = every time step (lots of files!) + # 10 = every 10th time step + # 100 = every 100th time step + output_frequency = 10 + + # ===== IMPORTANT: Visualization Output Location ===== + # This path is RELATIVE to the main output directory structure! + # Actual location will be: + # [PostProcessing.volume_averages.output_directory]/[basename]/[floc] + # + # Example: With output_directory = "./results", basename = "test", floc = "viz/": + # Actual path: ./results/test/viz/ + # + # Default "visualizations/" means files go to: + # [output_directory]/[basename]/visualizations/ + # + # Note: This is different from volume averages which go directly in: + # [output_directory]/[basename]/ + floc = "visualizations/" + +# ===================================== +# POST-PROCESSING OPTIONS +# ===================================== +# Analysis and data extraction from your simulation + +[PostProcessing] + # ===== Volume Averaging ===== + # Computes average quantities over the entire domain or by material + # Useful for: stress-strain curves, homogenized properties + [PostProcessing.volume_averages] + # Master switch for volume averaging + enabled = true + + # Which quantities to average: + # Each creates a separate output file with time history + + # Stress tensor (6 components: σxx, σyy, σzz, σxy, σyz, σxz) + stress = true + + # Deformation gradient tensor F (9 components) + # F relates current to reference configuration + def_grad = true + + # Euler strain tensor (6 components) + # Sometimes referred to as "true strain" or "logarithmic strain" in the 1D sense + # Note: "logarithmic strain" is a completely different measure when we move to 3D tensors + euler_strain = true + + # Integrated plastic work across entire volume + # Note: this is the only quantity that is the integrated quantity over the + # volume rather than a volume average value + # Useful for finding equivalent yield points across multiple + # loading directions + plastic_work = true + + # Equivalent plastic strain (scalar value) + # Single value representing accumulated plastic deformation + # Common measures: von Mises equivalent strain + # Useful for: failure prediction, hardening evolution + # Only available for ExaCMech models + eq_pl_strain = false + + # Elastic strain tensor (6 components) + # Only available for ExaCMech models + elastic_strain = false + + # How often to compute averages + # Must be multiple of visualization output_frequency + output_frequency = 1 + + # ===== OUTPUT DIRECTORY STRUCTURE ===== + # This setting determines the base directory for ALL simulation outputs + # The actual output structure will be: + # [output_directory]/[basename]/ <- Main simulation folder + # [output_directory]/[basename]/avg_*.txt <- Volume average files + # [output_directory]/[basename]/visualizations/ <- Visualization files + # [output_directory]/[basename]/restart/ <- Restart files (if enabled) + # + # Example: If output_directory = "./results" and basename = "tensile_test": + # ./results/tensile_test/ <- All outputs go here + # ./results/tensile_test/avg_stress_global.txt <- Note: filename NOT affected by basename + # ./results/tensile_test/avg_stress_region_aluminum_alloy_1.txt + # ./results/tensile_test/visualizations/solution_000010.vtu + # + # IMPORTANT: The basename only affects the subdirectory name, NOT the output filenames + # The actual filenames are controlled by the *_fname options below + output_directory = "./results/" + + # ===== MULTI-MATERIAL OUTPUT FILES ===== + # For simulations with multiple materials, EACH quantity generates: + # 1. A global average file: avg_[quantity]_global.txt + # - Contains volume-weighted average over ENTIRE domain + # - Useful for overall material response + # + # 2. Per-material files: avg_[quantity]_region_[material_name]_[region_id].txt + # - Contains average over that material region only + # - Useful for individual material behavior + # + # Example with two materials (aluminum and polymer): + # avg_stress_global.txt <- Combined response + # avg_stress_region_aluminum_alloy_1.txt <- Aluminum only (region 1) + # avg_stress_region_custom_polymer_2.txt <- Polymer only (region 2) + # + # File format (all files): + # Column 1: Time + # Column 2: Volume (total volume averaged over) + # Columns 3+: Component values + # Data is space-delimited, one row per time step + + # ===== Output File Names ===== + # These define the base names for averaged quantity files + # These are optional but the base names are provided down below + # Actual filenames will have _global or _region_[name]_[id] appended + + # Stress components file + # Columns: time, volume, σxx, σyy, σzz, σxy, σyz, σxz + # Creates: avg_stress_global.txt, avg_stress_region_*.txt + avg_stress_fname = "avg_stress.txt" + + # Deformation gradient file + # Columns: time, volume, F11, F12, F13, F21, F22, F23, F31, F32, F33 + # Creates: avg_def_grad_global.txt, avg_def_grad_region_*.txt + avg_def_grad_fname = "avg_def_grad.txt" + + # Euler strain file + # Columns: time, volume, εxx, εyy, εzz, εxy, εyz, εxz + # Creates: avg_euler_strain_global.txt, avg_euler_strain_region_*.txt + avg_euler_strain_fname = "avg_euler_strain.txt" + + # Plastic work file + # Columns: time, volume, plastic_work + # Creates: avg_pl_work_global.txt, avg_pl_work_region_*.txt + avg_pl_work_fname = "avg_pl_work.txt" + + # Equivalent plastic strain file + # Columns: time, volume, equivalent_plastic_strain + # Creates: avg_eq_pl_strain_global.txt, avg_eq_pl_strain_region_*.txt + avg_eq_pl_strain_fname = "avg_eq_pl_strain.txt" + + # Elastic strain file (ExaCMech only) + # Columns: time, volume, εe_xx, εe_yy, εe_zz, εe_xy, εe_yz, εe_xz + # Creates: avg_elastic_strain_global.txt, avg_elastic_strain_region_*.txt + avg_elastic_strain_fname = "avg_elastic_strain.txt" + + # ===== Crystal Orientation Analysis (Light-Up) ===== + # Tracks which crystals are favorably oriented for slip across all crystal systems + # Supports cubic, hexagonal, trigonal, rhombohedral, tetragonal, orthorhombic, monoclinic, triclinic + # Useful for: texture evolution, identifying active grains, powder diffraction simulation + [[PostProcessing.light_up]] + # Enable this analysis + enabled = true + + # Which material to analyze (must match Materials.material_name) + material_name = "aluminum_alloy" + + # Crystal system type - determines symmetry operations and lattice parameter requirements + # Supported values: 'CUBIC', 'HEXAGONAL', 'TRIGONAL', 'RHOMBOHEDRAL', + # 'TETRAGONAL', 'ORTHORHOMBIC', 'MONOCLINIC', 'TRICLINIC' + laue_type = 'CUBIC' + + # Crystal directions to monitor [h,k,l] + # These are Miller indices in crystal coordinates + # Examples: [1,1,1] = octahedral planes, [1,0,0] = cube faces, [1,1,0] = cube edges + # For hexagonal: [1,0,0] = basal, [0,0,1] = c-axis, [1,1,0] = prismatic + hkl_directions = [[1, 1, 1], [1, 0, 0], [1, 1, 0]] + + # Angular tolerance in radians + # Grains within this angle of target direction are considered "in-fiber" + # 0.0873 radians ≈ 5 degrees, 0.1745 radians ≈ 10 degrees + distance_tolerance = 0.0873 + + # Sample direction in lab coordinates [x,y,z] + # Used as reference for orientation analysis and lattice strain calculations + # [0,0,1] = Z direction (typical loading direction) + # [1,0,0] = X direction, [0,1,0] = Y direction + sample_direction = [0.0, 0.0, 1.0] + + # Crystal lattice parameters - requirements vary by crystal system: + # CUBIC: [a] (lattice parameter in Angstroms) + # HEXAGONAL: [a, c] (basal and c-axis parameters in Angstroms) + # TRIGONAL: [a, c] (basal and c-axis parameters in Angstroms) + # RHOMBOHEDRAL: [a, alpha] (lattice parameter in Angstroms, angle in radians) + # TETRAGONAL: [a, c] (basal and c-axis parameters in Angstroms) + # ORTHORHOMBIC: [a, b, c] (three lattice parameters in Angstroms) + # MONOCLINIC: [a, b, c, beta] (three lattice parameters in Angstroms, monoclinic angle in radians) + # TRICLINIC: [a, b, c, alpha, beta, gamma] (three lattice parameters in Angstroms, three angles in radians) + lattice_parameters = [3.6] # Cubic aluminum: a = 3.6 Angstroms + + # Base name for output files + # Creates files like: lattice_avg_directional_stiffness.txt, lattice_avg_dpeff.txt, lattice_avg_strains.txt... + # File naming automatically includes region number as well as quantity related to it + lattice_basename = "lattice_avg_" + + # Example: Hexagonal crystal system (e.g., titanium, zinc) + # [[PostProcessing.light_up]] + # enabled = true + # material_name = "titanium_alloy" + # laue_type = 'HEXAGONAL' + # hkl_directions = [[1, 0, 0], [0, 0, 1], [1, 1, 0]] # basal, c-axis, prismatic + # distance_tolerance = 0.0873 + # sample_direction = [0.0, 0.0, 1.0] + # lattice_parameters = [2.95, 4.68] # a = 2.95 Å, c = 4.68 Å for Ti + # lattice_basename = "ti_lattice_" + + # Example: Rhombohedral crystal system (e.g., some ceramics, bismuth) + # [[PostProcessing.light_up]] + # enabled = true + # material_name = "rhombohedral_ceramic" + # laue_type = 'RHOMBOHEDRAL' + # hkl_directions = [[1, 1, 1], [1, 0, 0], [1, 1, 0]] + # distance_tolerance = 0.0873 + # sample_direction = [0.0, 0.0, 1.0] + # lattice_parameters = [4.75, 1.0472] # a = 4.75 Å, alpha = 60° = 1.0472 radians + # lattice_basename = "rhombo_lattice_" + + # ===== Field Projections ===== + # Projects integration point data to nodes for visualization + [PostProcessing.projections] + # Field projection configuration for post-processing and visualization + # Format: "field_key" -> "Display Name in Visualization Tools" + # All fields listed below are available by default (except "all_state") + # + # ================================================================= + # GEOMETRIC FIELDS + # Basic geometric properties computed from finite element mesh + # ================================================================= + # "centroid" -> "Centroid" # Element centroid coordinates + # "volume" -> "Volume" # Element volume (or area in 2D) + # + # ================================================================= + # STRESS FIELDS + # Fundamental stress measures for solid mechanics analysis + # ================================================================= + # "cauchy" -> "Cauchy Stress" # Cauchy stress tensor (σ) + # "von_mises" -> "Von Mises Stress" # Von Mises equivalent stress (√(3/2 s:s)) + # "hydro" -> "Hydrostatic Stress" # Hydrostatic pressure (tr(σ)/3) + # + # ================================================================= + # GENERAL STATE VARIABLES + # Material state quantities from constitutive models + # ================================================================= + # "all_state" -> "All State Variables" # Complete state variable vector + # # (WARNING: Can be very large for complex models) + # + # ================================================================= + # EXACMECH CRYSTAL PLASTICITY FIELDS + # Specialized quantities for crystalline material modeling + # Available when using ExaCMech constitutive models + # ================================================================= + # "dpeff" -> "Equivalent Plastic Strain Rate" # √(2/3 ε̇ᵖ:ε̇ᵖ) + # "eps" -> "Equivalent Plastic Strain" # ∫√(2/3 ε̇ᵖ:ε̇ᵖ) dt + # "xtal_ori" -> "Crystal Orientations" # Crystal orientation tensors/quaternions + # "elastic_strain" -> "Elastic Strains" # Elastic strain tensor components in sample frame + # "hardness" -> "Hardness" # Crystal hardness parameters + # "shear_rate" -> "Shearing Rate" # Shear rate on slip systems + # + # ================================================================= + # CONFIGURATION + # ================================================================= + # + # Automatic field selection based on constitutive model + # Controls whether to automatically enable all fields compatible with your material model + auto_enable_compatible = true + # true -> Automatically project all fields supported by the active constitutive model + # (Geometric fields + Stress fields + model-specific state variables) + # Ignores 'enabled_projections' list when true + # Recommended for exploratory analysis and model validation + # false -> Use only fields specified in 'enabled_projections' list below + # Recommended for production runs or when targeting specific outputs + # Reduces computational overhead and output file size + # + # Manual field selection (only used when auto_enable_compatible = false) + # Specify which fields to project for visualization and analysis + # Leave empty ([]) to disable all projections + # Example configurations: + # enabled_projections = ["cauchy", "von_mises", "eps"] # Basic stress + plasticity + # enabled_projections = ["volume", "cauchy", "dpeff", "xtal_ori"] # Geometry + crystal fields + # enabled_projections = ["all_state"] # Everything (large output) + enabled_projections = [] + +# ===================================== +# MESH SETTINGS +# ===================================== +# Defines the geometry and discretization of your model + +[Mesh] + # Mesh source: + # - "file" = load from mesh file (most common) + # - "auto" = generate simple box mesh (for testing or bringing over voxelized data) + type = "file" + + # ===== File-Based Mesh ===== + # Path to mesh file (MFEM format .mesh or .mesh.gz) + # Can use absolute or relative paths + floc = "../../data/my_model.mesh" + + # ===== Mesh Refinement ===== + # Subdivide elements for higher accuracy + + # Serial refinement (before domain decomposition) + # 0 = no refinement, 1 = split each element into 8 (linear hex) (3D) + ref_ser = 0 + + # Parallel refinement (after domain decomposition) + # Use for better load balancing on many processors + ref_par = 0 + + # ===== Polynomial Order ===== + # Shape function order (higher = more accurate but expensive) + # 1 = linear elements (8-node hex, 4-node tet) + # 2 = quadratic elements (20-node hex, 10-node tet) + # 3+ = high-order elements (research use) + p_refinement = 1 + + # ===== Periodic Boundaries ===== + # Connect opposite faces for periodic simulations + # Used for: representative volume elements (RVEs) + # Currently ignored as we don't yet support PBCs yet + # periodicity = false + + # ===== Auto-Generated Mesh ===== + # Creates a simple box mesh (useful for testing) + [Mesh.Auto] + # Physical dimensions [X_length, Y_length, Z_length] + mxyz = [1.0, 1.0, 1.0] + + # Number of elements [X_elements, Y_elements, Z_elements] + # Total elements = product of these numbers + nxyz = [10, 10, 10] \ No newline at end of file diff --git a/workflows/exaconstit-calibrate/examples/parameter_creation.py b/workflows/exaconstit-calibrate/examples/parameter_creation.py new file mode 100644 index 0000000..ab340f4 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/parameter_creation.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any, Sequence + +import numpy as np +import pandas as pd + +from workflow_common import ( + ArchiveDB, + CallablePropertyWriter, + CaseTemplater, + InfinityFailureHandler, + LocalBackend, + Manifest, + ObjectiveSpec, + Problem, + ProblemConfig, + SimCase, + StressStrainExtractor, + TemplatePathResolver, + TemplateTarget, + TextTableReader, + TextTableSpec, + configure_logging, +) +from workflows.optimization import ( + Bounds, + RunConfig, + build_reference_points, + derive_population_size, + run_nsga3, +) + +def write_properties(case_dir: Path, gene: Sequence[float], + names: Sequence[str], sim_case: SimCase) -> Path: + """Write material-model properties for one case. + + Produces a `properties.txt` file whose format matches what + the old `ExaConstit_Problems.py` wrote. Adapt the body to + whatever format your mechanics binary actually reads — the + framework doesn't care about the format, only that the file + exists and is referenced correctly in options.toml. + """ + path = case_dir / "properties.txt" + # Zip gene values with their names so the output is both + # machine-readable AND human-auditable for debugging. + gene_dict = dict(zip(names, gene)) + lines = [ + "8.920e-6", + "0.003435984", + "1.0e-10", + "168.4e0", + "121.4e0", + "75.2e0", + "44.0e0", + f"{gene_dict['mprime']:.6g}", + "1.0e0", + f"{gene_dict['h0']:.6g}", + f"{gene_dict['crss0']:.6g}", + "122.4e-3", + "0.0", + "5.0e9", + f"{gene_dict['crss0']:.6g}", + "0.0", + "-1.0307952", + ] + path.write_text("\n".join(lines) + "\n") + return path \ No newline at end of file diff --git a/workflows/exaconstit-calibrate/examples/plot_solutions.py b/workflows/exaconstit-calibrate/examples/plot_solutions.py new file mode 100644 index 0000000..e556345 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/plot_solutions.py @@ -0,0 +1,1623 @@ +""" +Plot the top-N optimized solutions vs the experimental reference. + +Companion to ``nsga3_calibration.py``. Once a calibration finishes, +point this script at the workspace and it will: + +1. Find the SQLite archive automatically (resolves + ``calibration.db`` in the workspace, or any unique ``*.db`` file). +2. Pull the top-N gene records from the archive — ranked either by + L2 norm of the fitness tuple (the "balanced winner" — closest to + the utopian origin) or by a single objective. Reuses + :func:`workflows.optimization.inspect_archive.select_top_genes`, + which is the same selection logic that powers the inspect-archive + CLI's ``--pareto-only`` view. +3. For each selected gene and each SimCase, reconstruct the + stress-strain curve from the archived ``avg_stress`` / + ``avg_def_grad`` tables. The case directories on disk DO NOT need + to still exist; the archive carries everything required. +4. Draw an interactive matplotlib figure overlaying: + * the experimental reference curve (thick black); + * each selected gene's simulated curve (semi-transparent line, + red-to-blue gradient by rank); + * a slider that fades curves below a chosen rank, so users can + interactively narrow focus from N to K without re-running; + * a click panel that prints the gene's parameter values when + the user clicks a curve. +5. Optionally produce a 2-D Pareto-front scatter (``--pareto``) for + any pair of objectives, with the L2-closest gene highlighted. + +Usage examples +-------------- + + # Top 10 by L2 norm (the headline plot): + python examples/plot_solutions.py calibration_run \\ + --top 10 --experimental experiments/exp1.csv experiments/exp2.csv + + # Top 5 ranked by objective 0 (e.g. stress-RMSE for exp1): + python examples/plot_solutions.py calibration_run \\ + --top 5 --objective 0 \\ + --experimental experiments/exp1.csv experiments/exp2.csv + + # 2-D Pareto-front scatter for objectives 0 vs 2: + python examples/plot_solutions.py calibration_run \\ + --pareto 0,2 --experimental experiments/exp1.csv experiments/exp2.csv + + # Save without showing (headless / batch / CI): + python examples/plot_solutions.py calibration_run \\ + --top 10 --save out.png --no-show \\ + --experimental experiments/exp1.csv experiments/exp2.csv + +Programmatic use (from a notebook): + + from examples.plot_solutions import plot_top_solutions_overlay + plot_top_solutions_overlay( + "calibration_run", top_n=10, mode="l2", + experimental_paths=["experiments/exp1.csv", + "experiments/exp2.csv"], + ) + +Architectural note +------------------ +This module is deliberately a thin orchestrator over functionality +that already lives in the framework: + +* archive resolution + run selection + top-N ranking → + :mod:`workflows.optimization.inspect_archive` (its public selection + API: ``select_top_genes``, ``pick_run``, ``resolve_archive_path``, + ``collect_all_genes``). +* simulated stress-strain reconstruction → + :meth:`ArchiveDB.load_case_outputs` (reconstitutes the on-disk + output tables from the archive's blob storage). +* strain/stress extraction → :class:`StressStrainExtractor`. +* curve overlay + Pareto scatter → + :func:`workflow_common.postprocess.plot_stress_strain_overlay` + and :func:`plot_pareto_front`. + +The only NEW logic in this file is the matplotlib interactivity +glue (Slider for opacity, pick events for click-to-show parameters) +and the per-SimCase subplot layout. Everything else is composed. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import ( + Callable, Dict, List, Optional, Sequence, Tuple, Union, +) + +import numpy as np + +from workflow_common import ( + ArchiveDB, + GeneRecord, + PchipSmoother, + StressStrainExtractor, + load_experimental_csv, +) +from workflow_common.postprocess import plot_pareto_front +from workflow_common.results import CaseResultSet +from workflows.optimization.inspect_archive import ( + RankedGene, + collect_all_genes, + pick_run, + resolve_archive_path, + select_top_genes, +) + + +# --- Helpers ------------------------------------------------------------- + + +def _resolve_objective_index( + objective: Union[int, str], objective_labels: Sequence[str], +) -> int: + """Map an int-or-name objective specifier to an integer index. + + Names take precedence (so ``--objective stress_1`` works); a + bare numeric string falls through to int parse. Raises + :class:`ValueError` with the available labels listed if neither + matches. + """ + if isinstance(objective, str): + if objective in objective_labels: + return list(objective_labels).index(objective) + try: + idx = int(objective) + except ValueError: + raise ValueError( + f"objective {objective!r} not found. Available: " + f"{list(objective_labels)}" + ) + if not 0 <= idx < len(objective_labels): + raise ValueError( + f"objective index {idx} out of range " + f"[0, {len(objective_labels)})" + ) + return idx + if not 0 <= objective < len(objective_labels): + raise ValueError( + f"objective index {objective} out of range " + f"[0, {len(objective_labels)})" + ) + return objective + + +def _ranked_genes_for_mode( + archive: ArchiveDB, + run_id: str, + *, + objective_labels: Sequence[str], + top_n: int, + mode: str, + objective: Optional[Union[int, str]], +) -> List[RankedGene]: + """Pick the genes to plot using inspect_archive's selection logic. + + The three modes map to ``select_top_genes`` outputs: + + * ``"l2"`` — return the ``"l2"`` category list directly. + * ``"objective"`` — pick a single per-objective category by + label/index. + * ``"last-gen"`` — bypass ``select_top_genes`` and return every + gene from the highest gen_idx (no ranking; sorted by pop_idx + so the order matches what a user would see in + ``inspect_archive --gen ``). + """ + if mode == "last-gen": + all_genes = collect_all_genes(archive, run_id) + if not all_genes: + return [] + last_gen = max(g.gen_idx for g in all_genes) + last = sorted( + (g for g in all_genes if g.gen_idx == last_gen), + key=lambda g: g.pop_idx, + ) + l2_full = np.array([ + float(np.linalg.norm(g.fitness)) for g in last + ]) + if top_n <= 0 or top_n > len(last): + take = len(last) + else: + take = top_n + return [ + RankedGene( + gene=g, rank=i, category=f"gen={last_gen}", + score=float(g.pop_idx), l2_norm=float(l2_full[i]), + ) + for i, g in enumerate(last[:take]) + ] + + all_genes = collect_all_genes(archive, run_id) + if not all_genes: + return [] + + if mode == "l2": + selection = select_top_genes( + all_genes, + objective_labels=objective_labels, + top_n=top_n, + categories=("l2",), + ) + return selection.get("l2", []) + + if mode == "objective": + if objective is None: + raise ValueError("mode='objective' requires --objective") + idx = _resolve_objective_index(objective, objective_labels) + label = objective_labels[idx] + selection = select_top_genes( + all_genes, + objective_labels=objective_labels, + top_n=top_n, + categories=("per_objective",), + ) + return selection.get(f"obj:{label}", []) + + raise ValueError( + f"mode={mode!r} not recognized. Use 'l2', 'objective', or 'last-gen'." + ) + + +def _discover_sim_case_count( + archive: ArchiveDB, run_id: str, sample_gene: GeneRecord, + *, max_check: int = 16, +) -> int: + """Probe how many SimCases were archived for a sample gene. + + The archive doesn't store an explicit "n_sim_cases" field — it's + a derived property of which sim_case_idx values have rows under + a given ``(run_id, birth_gen, birth_gene)`` triple. We probe + incrementally until ``load_case_outputs`` returns ``None``. + + ``max_check`` caps the probe to avoid pathological loops if some + archive becomes sparse; 16 covers any reasonable calibration + setup (most have 1-4 SimCases). + """ + count = 0 + for idx in range(max_check): + outputs = archive.load_case_outputs( + run_id, + birth_gen=sample_gene.birth_gen, + birth_gene=sample_gene.birth_gene, + sim_case_idx=idx, + ) + if outputs is None: + break + count += 1 + return count + + +def _extract_curve( + case_result: CaseResultSet, extractor: StressStrainExtractor, + *, rank: int, sim_case_idx: int, +) -> Optional[Tuple[np.ndarray, np.ndarray]]: + """Run an extractor against one CaseResultSet, swallow + warn on failure. + + Bad data shouldn't crash the whole plot; print a one-line warning + and let the caller skip the curve. + """ + try: + strain, stress = extractor.extract(case_result) + return np.asarray(strain), np.asarray(stress) + except Exception as e: # noqa: BLE001 + print( + f"warning: skipping rank={rank} sim_case={sim_case_idx} " + f"(extractor failed: {e})", + file=sys.stderr, + ) + return None + + +def _load_or_extract_curve( + archive: ArchiveDB, + run_id: str, + *, + birth_gen: int, + birth_gene: int, + sim_case_idx: int, + extractor: StressStrainExtractor, + rank_label: int, + experimental_reference: Optional[Tuple[np.ndarray, np.ndarray]] = None, +) -> Optional[Tuple[np.ndarray, np.ndarray]]: + """Get an extracted curve for one (gene, sim_case). + + Preference order: + + 1. Archived curve from the ``case_curves`` table. This is the + FULL-range curve the framework stored at run time (window + stripped before extraction). Faster than re-extracting and + guaranteed to match what the optimizer scored against, + modulo the optimizer's own windowing on top. + 2. Re-extract from raw ``case_outputs`` using the supplied + extractor. Used for archives written before the + ``case_curves`` table existed, or for cases where the + run-time extraction failed but the raw outputs are + still useful. + + If ``experimental_reference`` is supplied as ``(exp_ind, + exp_dep)``, both the loaded ``ind`` and ``dep`` arrays are + sign-matched against their respective exp axes via + :func:`workflow_common.objectives.match_sign_to_reference`. + This catches the case where an extractor accidentally + absolute-values its output (a sign-stripped strain rate, an + evaluator that calls ``np.abs`` on its arrays before scoring + RMSE) — without correction, the plot shows the simulated curve + flipped relative to the experimental reference, which is + confusing. The correction is purely cosmetic; it doesn't + re-write the archive. + + Returns ``(independent, dependent)`` arrays or ``None`` if + neither path produces data. + """ + archived = archive.load_case_curve( + run_id, + birth_gen=birth_gen, birth_gene=birth_gene, + sim_case_idx=sim_case_idx, + ) + if archived is not None: + ind, dep, _ind_label, _dep_label = archived + else: + outputs = archive.load_case_outputs( + run_id, + birth_gen=birth_gen, birth_gene=birth_gene, + sim_case_idx=sim_case_idx, + ) + if outputs is None: + return None + pair = _extract_curve( + outputs, extractor, + rank=rank_label, sim_case_idx=sim_case_idx, + ) + if pair is None: + return None + ind, dep = pair + if experimental_reference is not None: + exp_ind = np.asarray(experimental_reference[0], dtype=float) + exp_dep = np.asarray(experimental_reference[1], dtype=float) + ind = np.asarray(ind, dtype=float) + dep = np.asarray(dep, dtype=float) + try: + # Dependent axis: bring exp_dep onto sim's grid via + # PCHIP, then element-wise np.copysign so each sim + # point inherits the sign of the experimental + # response at the same strain magnitude. ``np.abs`` + # on both x-arrays handles the cross-sign case + # (sim positive from absolute-valued extractor, exp + # negative from compression test) — the smoother + # works on monotonic |strain|, sign comes back via + # copysign. + smoother = PchipSmoother(strict_monotonic=False) + exp_dep_at_sim = smoother.sample_at( + np.abs(exp_ind), exp_dep, np.abs(ind), + ).y + dep = np.copysign(dep, exp_dep_at_sim) + # Independent axis: a scalar sign drawn from the + # experimental's last strain value gives the test + # direction unambiguously. (For cyclic data ending + # back at zero this fails; the framework targets + # monotonic loading.) + if exp_ind.size > 0: + ind = np.copysign(ind, exp_ind[-1]) + except (ValueError, RuntimeError) as e: + # Degenerate exp data (e.g. single point, all + # duplicates) → leave the curve as-is rather than + # crashing the plot. + print( + f"warning: sim/exp sign-match for sim_case={sim_case_idx} " + f"skipped — {e}", file=sys.stderr, + ) + return ind, dep + + +def _format_param_panel( + rg: RankedGene, param_names: Sequence[str], + objective_labels: Sequence[str], +) -> str: + """Pretty-print a gene's parameters for the click panel. + + Format is dense (monospace, line per param) since the panel sits + in a tight bottom strip of the figure. Includes fitness values + too because users almost always want to see "what trade-off did + this winner make?" alongside the parameters. + """ + lines = [ + f"rank {rg.rank} category={rg.category} " + f"score={rg.score:.4g} L2={rg.l2_norm:.4g}", + ] + for name, val in zip(param_names, rg.gene.gene_vector): + lines.append(f" {name} = {val:.6g}") + lines.append( + " fitness: " + + ", ".join( + f"{lbl}={v:.4g}" + for lbl, v in zip(objective_labels, rg.gene.fitness) + ) + ) + lines.append( + f" birth: gen={rg.gene.birth_gen}, " + f"gene={rg.gene.birth_gene}" + ) + return "\n".join(lines) + + +def _format_gene_panel( + g: GeneRecord, param_names: Sequence[str], + objective_labels: Sequence[str], + *, header: str = "", +) -> str: + """Pretty-print a plain GeneRecord for the Pareto-click panel. + + Same shape as :func:`_format_param_panel` but works directly on + a ``GeneRecord`` (no rank/category metadata, since Pareto-front + points aren't ranked relative to each other in the same way the + overlay's top-N list is). The optional ``header`` line lets the + caller annotate which point was clicked (e.g. its scatter + index or "L2 winner"). + """ + lines: List[str] = [] + if header: + lines.append(header) + lines.append( + f"L2={float(np.linalg.norm(g.fitness)):.4g} " + f"birth: gen={g.birth_gen}, gene={g.birth_gene}" + ) + for name, val in zip(param_names, g.gene_vector): + lines.append(f" {name} = {val:.6g}") + lines.append( + " fitness: " + + ", ".join( + f"{lbl}={v:.4g}" + for lbl, v in zip(objective_labels, g.fitness) + ) + ) + return "\n".join(lines) + + +def _resolve_experimental_for_case( + archive: ArchiveDB, + run_id: str, + sim_case_idx: int, + csv_fallback: Optional[Sequence[Optional[Union[str, Path]]]], +) -> Optional[Tuple[np.ndarray, np.ndarray, Optional[str]]]: + """Get experimental ``(strain, stress, label)`` for one SimCase. + + Resolution order: + + 1. Archive's ``experiments`` table — written by the driver at + run start. This is the "no manual ordering" path: the + SimCase index is the key, so users don't have to remember + which CSV maps to which experiment. + 2. CSV path at ``csv_fallback[sim_case_idx]`` — for archives + written before this feature existed, or for users who want + to override with different reference data post-hoc. + 3. ``None`` — no experimental overlay for this SimCase. + + Returns ``(strain, stress, label)`` on success, ``None`` if no + source resolved. + """ + # Archive first. + result = archive.load_experiment(run_id, sim_case_idx) + if result is not None: + label, df = result + if {"strain", "stress"}.issubset(df.columns): + return ( + df["strain"].to_numpy(), df["stress"].to_numpy(), label, + ) + cols = list(df.columns) + return ( + df[cols[0]].to_numpy(), df[cols[1]].to_numpy(), label, + ) + # CSV fallback. + if (csv_fallback is not None + and sim_case_idx < len(csv_fallback) + and csv_fallback[sim_case_idx] is not None): + path = Path(csv_fallback[sim_case_idx]) + try: + df = load_experimental_csv(path) + if {"strain", "stress"}.issubset(df.columns): + return ( + df["strain"].to_numpy(), df["stress"].to_numpy(), + str(path.name), + ) + cols = list(df.columns) + return ( + df[cols[0]].to_numpy(), df[cols[1]].to_numpy(), + str(path.name), + ) + except Exception as e: # noqa: BLE001 + print( + f"warning: could not load CSV {path} for SimCase " + f"{sim_case_idx}: {e}", + file=sys.stderr, + ) + return None + + +def _resolve_extractor_for_case( + archive: ArchiveDB, + run_id: str, + sim_case_idx: int, + *, + factory: Optional[Callable[[int], StressStrainExtractor]] = None, +) -> StressStrainExtractor: + """Pick a StressStrainExtractor for the given SimCase. + + Resolution order: + + 1. Archive's stored extractor config (written by the driver + at run start). This is the source of truth: it captures + the EXACT settings the optimizer used to score curves, + including ``strain_source``, ``strain_rate``, column-name + overrides, etc. Without this, a default-built extractor + can silently produce different curves from what the + optimizer saw. + 2. ``factory(sim_case_idx)`` if the caller supplied one. + Lets a Python caller of plot_top_solutions_overlay / + plot_pareto_front_with_l2_winner pass per-SimCase + extractor configs explicitly. + 3. ``StressStrainExtractor()`` default (ExaConstit z-axis + Biot-strain conventions). The "no idea what they used, + try defaults" fallback for archives written before + extractor configs were stored. + + The window field is STRIPPED from any archived extractor + before return: the plotter wants full-curve visibility (so + the user sees what's going on outside the optimization + region) and shades the window separately via + ``case_data["minmax_strain"]`` from the archive's + experiments.minmax_strain column. + """ + cfg = archive.load_extractor_config(run_id, sim_case_idx) + if cfg is not None: + try: + ext = StressStrainExtractor.from_dict(cfg) + # Plotter wants full curves with the window shaded as + # an overlay rather than data cropped at extraction. + if ext.window is not None: + ext = StressStrainExtractor.from_dict({ + **cfg, "window": None, + }) + return ext + except Exception as e: # noqa: BLE001 + print( + f"warning: archived extractor config for SimCase " + f"{sim_case_idx} failed to load ({e}); falling back", + file=sys.stderr, + ) + if factory is not None: + return factory(sim_case_idx) + return StressStrainExtractor() + + +def _slope_of(strain: np.ndarray, stress: np.ndarray) -> np.ndarray: + """Numerical slope dStress/dStrain via finite difference. + + Uses ``np.gradient`` so endpoints are handled with one-sided + differences and the slope array has the same length as the + inputs. This matches what ``StressStrainObjective``'s + slope-extraction does internally — deliberately so, because + if the optimizer scored a slope objective, the slope values + we plot here must be derived the same way to be comparable. + """ + return np.gradient(np.asarray(stress, dtype=float), + np.asarray(strain, dtype=float)) + + +def _lazy_matplotlib(): + """Soft dep on matplotlib with a useful error if missing.""" + try: + import matplotlib.pyplot as plt + from matplotlib.widgets import Slider + return plt, Slider + except ImportError as e: + raise RuntimeError( + "matplotlib is required for plot_solutions; install via " + "`pip install -e \".[plot]\"` or `pip install matplotlib`." + ) from e + + +# --- Public plotting functions ------------------------------------------- + + +def plot_top_solutions_overlay( + workspace: Union[str, Path], + *, + top_n: int = 10, + mode: str = "l2", + objective: Optional[Union[int, str]] = None, + run_id: Optional[str] = None, + experimental_paths: Optional[Sequence[Optional[Union[str, Path]]]] = None, + extractor_factory: Optional[ + Callable[[int], StressStrainExtractor] + ] = None, + show_slopes: bool = True, + save: Optional[Union[str, Path]] = None, + show: bool = True, +): + """Build the headline interactive figure. + + Layout: a 2-row × N-SimCase-column grid of subplots. + + * Top row — stress-strain. Top-N simulated curves overlaid on + the experimental reference (thick black). Curves colored by + rank with a coolwarm-reversed colormap so rank 0 is red. + * Bottom row — slope-strain (dStress/dStrain). Same overlay, + same colors. Computed via ``np.gradient`` for both sim and + exp so the curves correspond to what a slope objective + would have scored. Hidden if ``show_slopes=False`` or if + no experimental data is available. + + Below the subplots: + + * a slider that fades curves below a chosen rank, so users can + narrow visual focus from N to K interactively; + * a click panel that prints the gene's parameter values when + the user clicks a curve. + + Experimental data resolution (per SimCase): + + 1. The archive's ``experiments`` table (written by the driver + at run start). This is the canonical source — keyed by + ``sim_case_idx``, so users don't have to remember CSV + ordering. + 2. ``experimental_paths[sim_case_idx]`` if archive doesn't + have it. Lets archives written before the experiments + table existed still produce useful plots. + 3. None — the experimental overlay is omitted for that case. + + Args: + workspace: Workspace dir, or direct ``.db`` archive path. + top_n: Number of solutions to plot. ``0`` or negative shows + all available. + mode: ``"l2"`` (default), ``"objective"``, or ``"last-gen"``. + objective: Required for ``mode='objective'``; integer index + or label string. + run_id: Optional explicit run UUID (default: latest). + experimental_paths: Per-SimCase CSV fallbacks; only used + when the archive doesn't carry experimental data for + a given SimCase. + extractor_factory: ``f(sim_case_idx) -> StressStrainExtractor``; + lets per-SimCase extractor configs match what was used + at run time. Default: vanilla + ``StressStrainExtractor()`` for every case. + show_slopes: Include the slope-strain row. Default True. + save: Path to write the figure to (PNG / PDF / SVG). + show: Whether to call ``plt.show()`` blocking. Set False + for headless / batch. + + Returns: + The matplotlib ``Figure``. + """ + plt, Slider = _lazy_matplotlib() + + db_path = resolve_archive_path(Path(workspace), "calibration.db") + with ArchiveDB(db_path, readonly=True) as archive: + meta = pick_run(archive, run_id, latest_if_none=True) + run_id = meta.run_id + + ranked = _ranked_genes_for_mode( + archive, run_id, + objective_labels=meta.objective_labels, + top_n=top_n, mode=mode, objective=objective, + ) + if not ranked: + raise RuntimeError( + f"no plottable solutions in run {run_id} " + f"(mode={mode!r}). Either no genes have finite " + f"fitness, or the run hasn't completed gen 0." + ) + + n_sim_cases = _discover_sim_case_count( + archive, run_id, sample_gene=ranked[0].gene, + ) + if n_sim_cases == 0: + raise RuntimeError( + "best gene has no archived case outputs. " + "Was archiving disabled at run time?" + ) + + # Per-(sim_case, rank) (strain, stress) for simulated curves. + per_case_curves: List[ + List[Tuple[RankedGene, np.ndarray, np.ndarray]] + ] = [] + # Per-sim_case experimental (strain, stress, label) or None. + per_case_exp: List[ + Optional[Tuple[np.ndarray, np.ndarray, Optional[str]]] + ] = [] + # Per-sim_case (lo, hi) optimization window or None. Either + # side of the tuple may itself be None for "unbounded on + # that side." Plotter shades the corresponding strain region + # so users can see what range the optimizer scored against. + per_case_window: List[ + Optional[Tuple[Optional[float], Optional[float]]] + ] = [] + + for sc_idx in range(n_sim_cases): + extractor = _resolve_extractor_for_case( + archive, run_id, sc_idx, factory=extractor_factory, + ) + # Resolve experimental data FIRST so we can sign-match + # the simulated curves against it as we load them. This + # corrects archived data where an extractor accidentally + # absolute-values its output (e.g. a sign-stripped + # strain rate producing positive strain on a compression + # run): without correction the simulated curve plots + # mirrored relative to the experimental reference. + # The correction is applied in :func:`_load_or_extract_curve`. + exp_resolved = _resolve_experimental_for_case( + archive, run_id, sc_idx, experimental_paths, + ) + exp_reference: Optional[Tuple[np.ndarray, np.ndarray]] = None + if exp_resolved is not None: + exp_strain, exp_stress, _exp_label = exp_resolved + exp_reference = (exp_strain, exp_stress) + sc_curves: List[ + Tuple[RankedGene, np.ndarray, np.ndarray] + ] = [] + for rg in ranked: + pair = _load_or_extract_curve( + archive, run_id, + birth_gen=rg.gene.birth_gen, + birth_gene=rg.gene.birth_gene, + sim_case_idx=sc_idx, + extractor=extractor, + rank_label=rg.rank, + experimental_reference=exp_reference, + ) + if pair is None: + continue + sc_curves.append((rg, pair[0], pair[1])) + per_case_curves.append(sc_curves) + per_case_exp.append(exp_resolved) + # Pull the optimization window the optimizer was + # constrained to. Returns None on archives that pre-date + # the minmax_strain column (the read path tolerates that + # gracefully) and on SimCases that didn't supply one. + per_case_window.append( + archive.load_experiment_window(run_id, sc_idx) + ) + + # --- Lay out the figure ---------------------------------------- + n_cols = max(n_sim_cases, 1) + # Row layout: stress (top), slope (middle, optional), spacer, + # slider, panel. + has_slopes = show_slopes + if has_slopes: + # 5 stress + spacer + 5 slope + spacer + slider + panel. + height_ratios = [1] * 5 + [0.15] + [1] * 5 + [0.2, 0.5, 0.7] + fig = plt.figure(figsize=(6 * n_cols, 10)) + else: + height_ratios = [1] * 7 + [0.2, 0.5, 0.7] + fig = plt.figure(figsize=(6 * n_cols, 7.5)) + + gs = fig.add_gridspec( + nrows=len(height_ratios), ncols=n_cols, + height_ratios=height_ratios, + hspace=0.35, wspace=0.25, + ) + if has_slopes: + stress_axes = [fig.add_subplot(gs[0:5, c]) for c in range(n_cols)] + slope_axes = [fig.add_subplot(gs[6:11, c]) for c in range(n_cols)] + slider_ax = fig.add_subplot(gs[12, :]) + panel_ax = fig.add_subplot(gs[13, :]) + else: + stress_axes = [fig.add_subplot(gs[0:7, c]) for c in range(n_cols)] + slope_axes = [] + slider_ax = fig.add_subplot(gs[8, :]) + panel_ax = fig.add_subplot(gs[9, :]) + + panel_ax.axis("off") + panel_text = panel_ax.text( + 0.01, 0.95, + "click any curve to see its parameter values", + transform=panel_ax.transAxes, + va="top", ha="left", family="monospace", fontsize=9, + ) + + n_total = len(ranked) + cmap = plt.get_cmap("coolwarm_r") + line_to_rg: Dict[object, RankedGene] = {} + # line_groups[rank] = list of every Line2D for that rank across + # both stress and slope rows. Slider toggles them all together. + line_groups: List[List[object]] = [[] for _ in range(n_total)] + + for sc_idx in range(n_cols): + stress_ax = stress_axes[sc_idx] + slope_ax = slope_axes[sc_idx] if has_slopes else None + curves = per_case_curves[sc_idx] + exp_data = per_case_exp[sc_idx] + + for rg, strain, stress in curves: + color = cmap(rg.rank / max(n_total - 1, 1)) + line, = stress_ax.plot( + strain, stress, + color=color, alpha=0.7, linewidth=1.4, + picker=5, + label=f"rank {rg.rank}", + ) + line_to_rg[line] = rg + line_groups[rg.rank].append(line) + + if slope_ax is not None: + slope = _slope_of(strain, stress) + slope_line, = slope_ax.plot( + strain, slope, + color=color, alpha=0.7, linewidth=1.4, + picker=5, + label=f"rank {rg.rank}", + ) + line_to_rg[slope_line] = rg + line_groups[rg.rank].append(slope_line) + + # Experimental overlay — same source for both rows. + if exp_data is not None: + ex, ey, exp_label = exp_data + stress_ax.plot( + ex, ey, "k-", linewidth=2.5, label="experimental", + zorder=10, + ) + if slope_ax is not None: + exp_slope = _slope_of(ex, ey) + slope_ax.plot( + ex, exp_slope, "k-", linewidth=2.5, + label="experimental", zorder=10, + ) + + # Per-SimCase title using the experimental label if available. + title = f"SimCase {sc_idx}" + if exp_data is not None and exp_data[2]: + title += f" — {exp_data[2]}" + stress_ax.set_xlabel("Strain") + stress_ax.set_ylabel("Stress") + stress_ax.set_title(title) + stress_ax.grid(True, alpha=0.3) + + if slope_ax is not None: + slope_ax.set_xlabel("Strain") + slope_ax.set_ylabel("dStress/dStrain") + slope_ax.set_title(f"SimCase {sc_idx} — slope") + slope_ax.grid(True, alpha=0.3) + # Stress-strain slopes mix orders of magnitude AND + # signs: the elastic regime hits 10^5 MPa, plastic is + # 10^0 - 10^2, and the very first time steps often + # produce brief negative numerical artifacts when dt + # is tiny. Auto-scaling against ``max(|slope|)`` + # symmetrically lets those artifacts dictate the + # y-axis on BOTH sides — the majority-positive bulk + # gets squashed because matplotlib reserves equal + # vertical real estate for an all-but-empty negative + # half. + # + # The right rule: per-side decision. The side where + # the data actually lives gets its full range so the + # elastic spike stays visible. The other side, which + # is mostly outliers/numerical noise, gets clipped to + # the bulk's 90th-percentile envelope so it doesn't + # eat half the chart for a handful of points. + # + # When the data is genuinely two-sided (cyclic + # loading, repeated load reversals), neither side is + # a "minority" and both get the bulk clip — preserves + # symmetric behavior for the case where it's right. + all_slopes = [] + for _rg, s_strain, s_stress in curves: + all_slopes.append(_slope_of(s_strain, s_stress)) + if exp_data is not None: + all_slopes.append(_slope_of(exp_data[0], exp_data[1])) + if all_slopes: + concat = np.concatenate(all_slopes) + finite = concat[np.isfinite(concat)] + if finite.size: + abs_finite = np.abs(finite) + nonzero = abs_finite[abs_finite > 0] + # Bulk envelope — used to clip the minority + # side. 90th percentile catches the majority + # of the smooth response while excluding the + # top ~5% (typically elastic spikes or + # start-of-test artifacts). + bulk_clip = min( + 0, + 1.0, + ) + positives = finite[finite > 0] + negatives = finite[finite < 0] + n_pos, n_neg = positives.size, negatives.size + n_total = n_pos + n_neg + # 10% threshold: if one side has fewer than + # 10% of all signed points, treat it as the + # minority. Empirically this catches start- + # of-test numerical noise without misfiring + # on real two-sided responses. + MINORITY = 0.10 + if n_total == 0: + upper, lower = 1.0, -1.0 + elif n_neg == 0: + # All non-negative: full positive range, + # zero floor (nothing to show below). + upper = float(positives.max()) * 1.05 + lower = 0.0 + elif n_pos == 0: + upper = 0.0 + lower = float(negatives.min()) * 1.05 + elif n_neg / n_total < MINORITY: + # Mostly positive: full positive range, + # clip the negative tail to bulk envelope + # so the plastic bulk doesn't get crushed + # by a couple of dt-too-small noise dips. + upper = max( + float(positives.max()) * 1.05, + bulk_clip, + ) + lower = -bulk_clip + elif n_pos / n_total < MINORITY: + upper = bulk_clip + lower = min( + float(negatives.min()) * 1.05, + -bulk_clip, + ) + else: + # Genuinely two-sided: clip both ends to + # the bulk envelope; outliers extend off + # the chart visibly on whichever side + # they appeared. + upper = bulk_clip + lower = -bulk_clip + slope_ax.set_ylim(lower, upper) + # linthresh from the small end of the + # distribution — keeps the bulk in the LOG + # region rather than the LINEAR band, where + # log spacing actually helps with the + # multi-decade variation that the bulk + # exhibits. + if nonzero.size: + linthresh = max( + float(np.percentile(nonzero, 5)) * 0.5, + 1e-9, + ) + else: + linthresh = 1e-9 + slope_ax.set_yscale("symlog", linthresh=linthresh) + else: + # All non-finite slope values; keep a sensible + # default so the axis renders rather than + # crashing on the symlog setup. + slope_ax.set_yscale("symlog", linthresh=1.0) + + # Optimization-window shading. Shows the strain region the + # optimizer was constrained to via case_data["minmax_strain"]. + # Drawn under everything else (low zorder) so curves stay + # legible. None on either side means "unbounded on that + # side" — extend the shading to the axis edge there. + win = per_case_window[sc_idx] if sc_idx < len(per_case_window) else None + if win is not None and (win[0] is not None or win[1] is not None): + for ax_target in [stress_ax] + ([slope_ax] if slope_ax is not None else []): + xlo, xhi = ax_target.get_xlim() + lo = abs(win[0]) if win[0] is not None else xlo + hi = abs(win[1]) if win[1] is not None else xhi + # If the curves are negative-strain (compression), + # mirror the window onto the negative side so the + # shading lines up. We pick the sign of the + # experimental data if available, else of the first + # simulated curve. + ref = exp_data[0] if exp_data is not None else ( + curves[0][1] if curves else None + ) + if ref is not None and len(ref) > 0 and float(np.median(ref)) < 0: + lo, hi = -hi, -lo + ax_target.axvspan( + lo, hi, color="tab:green", alpha=0.08, zorder=0, + label="optimization window" if ax_target is stress_ax else None, + ) + + # --- Slider -------------------------------------------------------- + # The slider only makes sense when there are at least 2 ranks to + # toggle between. With n_total=1 matplotlib would raise an + # "identical low/high xlim" UserWarning trying to build a slider + # with valmin=valmax=1; the slider would also be functionless + # for the user. Hide the slider axes and skip widget creation. + slider = None + if n_total >= 2: + slider = Slider( + ax=slider_ax, + label="show top K", + valmin=1, valmax=n_total, valinit=n_total, valstep=1, + ) + + def _apply_visibility(k: int) -> None: + for r in range(n_total): + alpha = 0.7 if r < k else 0.07 + for line in line_groups[r]: + line.set_alpha(alpha) + fig.canvas.draw_idle() + + slider.on_changed(lambda val: _apply_visibility(int(val))) + else: + slider_ax.axis("off") + + # --- Click handler ------------------------------------------------- + def _on_pick(event): + rg = line_to_rg.get(event.artist) + if rg is None: + return + panel_text.set_text(_format_param_panel( + rg, meta.param_names, meta.objective_labels, + )) + fig.canvas.draw_idle() + + fig.canvas.mpl_connect("pick_event", _on_pick) + + label_for_title = ranked[0].category if ranked else "unranked" + fig.suptitle( + f"Top {n_total} solutions | ranked by {label_for_title} " + f"| run {run_id[:8]}", + fontsize=11, + ) + + if save is not None: + fig.savefig(str(save), dpi=120, bbox_inches="tight") + print(f"saved figure to {save}", file=sys.stderr) + if show: + plt.show() + fig._plot_solutions_slider = slider # type: ignore[attr-defined] + return fig + + +def plot_pareto_front_with_l2_winner( + workspace: Union[str, Path], + *, + objective_pair: Tuple[int, int] = (0, 1), + top_n: int = 0, + color_by: str = "subset_l2", + run_id: Optional[str] = None, + experimental_paths: Optional[Sequence[Optional[Union[str, Path]]]] = None, + extractor_factory: Optional[ + Callable[[int], StressStrainExtractor] + ] = None, + save: Optional[Union[str, Path]] = None, + show: bool = True, +): + """2-D Pareto-front scatter, clickable, with response-curve inset. + + Layout: a two-column figure. + + * Left column — the 2-D scatter of fitness pairs. Points are + colored by L2 norm (smaller = darker), the L2-closest gene + is marked in red, every point has ``picker=5`` so a click + fires a pick event. + * Right column — an inset axes that initially shows the + L2-closest gene's stress-strain response. When a user clicks + any scatter point, the inset redraws to show that gene's + simulated curves (one line per SimCase) overlaid against + the experimental references. A monospace text panel below + the inset shows the clicked gene's parameters and fitness. + + Selection: by default plots EVERY rank-0 (Pareto-front) gene + so the picture is the actual front. Pass ``top_n > 0`` to + restrict the scatter to the ``top_n`` lowest-L2 genes — useful + when the front has hundreds of points and the user only wants + to see the best balanced trade-offs. + + Experimental data is pulled archive-first via + :func:`_resolve_experimental_for_case` so users never need to + keep CSVs around once a run has completed. + + Args: + workspace: Workspace directory or direct ``.db`` path. + objective_pair: ``(x_idx, y_idx)`` objective indices to scatter. + top_n: If positive, restrict to the ``top_n`` lowest-L2 genes. + ``0`` (default) shows every rank-0 gene. + run_id: Optional explicit run UUID. + experimental_paths: CSV fallbacks per SimCase, used only when + the archive doesn't have experimental data. + extractor_factory: Per-SimCase extractor configs. + save: Path to write the figure. + show: Whether to ``plt.show()`` blocking. + + Returns: + The matplotlib ``Figure``. + """ + plt, _ = _lazy_matplotlib() + + db_path = resolve_archive_path(Path(workspace), "calibration.db") + + # Pre-load EVERYTHING the click handler will need: gene records, + # per-gene case_outputs for all SimCases, experimental refs. The + # alternative — keeping the DB connection open across user + # interaction — risks half-open connections and SQLite locking + # surprises. A few MB of in-memory DataFrames is the right + # tradeoff for a post-run analysis tool. + with ArchiveDB(db_path, readonly=True) as archive: + meta = pick_run(archive, run_id, latest_if_none=True) + run_id = meta.run_id + + all_genes = collect_all_genes(archive, run_id) + finite = [ + g for g in all_genes + if all(np.isfinite(v) for v in g.fitness) + ] + if not finite: + raise RuntimeError("no finite-fitness genes to plot.") + + a_idx, b_idx = objective_pair + n_obj = len(finite[0].fitness) + if not (0 <= a_idx < n_obj and 0 <= b_idx < n_obj): + raise ValueError( + f"objective_pair {objective_pair} out of range for " + f"{n_obj} objectives." + ) + + # Default selection: every rank-0 gene (the actual Pareto + # front). The archive stores rank in GeneRecord.rank, which + # is what selNSGA3 assigns — rank 0 is non-dominated. + if top_n is not None and top_n > 0: + # Top-N by L2 norm, dedup'd via shared logic with + # inspect_archive so the same genes appear in the + # `--pareto-only` table and on this plot. + selection = select_top_genes( + finite, + objective_labels=meta.objective_labels, + top_n=top_n, + categories=("l2",), + ) + chosen = [rg.gene for rg in selection.get("l2", [])] + mode_note = f"top-{top_n} by L2 norm" + else: + chosen = [ + g for g in finite + if g.rank is not None and g.rank == 0 + ] + # Dedup on gene-vector — elitism keeps the same + # solution across generations, and we don't want N + # copies of the same point sitting on top of each + # other in the scatter. + from workflows.optimization.inspect_archive import ( + dedup_on_gene_vector, + ) + chosen = dedup_on_gene_vector(chosen) + mode_note = "rank-0 Pareto front" + + if not chosen: + raise RuntimeError( + "no genes selected for Pareto plot. With top_n=0 " + "the run had no rank-0 genes (rank may not have " + "been recorded — check archive's gene table)." + ) + + # Discover SimCases via the first chosen gene. + n_sim_cases = _discover_sim_case_count( + archive, run_id, sample_gene=chosen[0], + ) + + # Resolve per-SimCase extractors ONCE — they don't vary + # across genes, so building them inside the gene loop is + # wasted work. Archive-stored configs are preferred over + # the user-supplied factory and the default fallback. + per_case_extractor: Dict[int, StressStrainExtractor] = { + sc_idx: _resolve_extractor_for_case( + archive, run_id, sc_idx, factory=extractor_factory, + ) + for sc_idx in range(n_sim_cases) + } + + # Per-SimCase experimental references — loaded BEFORE the + # gene loop so each loaded simulated curve can be + # sign-matched against its experimental reference at load + # time. Without sign-matching, archived curves where the + # extractor accidentally absolute-valued its output appear + # mirrored in the inset relative to the experimental + # reference, which is confusing. + per_case_exp: List[ + Optional[Tuple[np.ndarray, np.ndarray, Optional[str]]] + ] = [ + _resolve_experimental_for_case( + archive, run_id, sc_idx, experimental_paths, + ) + for sc_idx in range(n_sim_cases) + ] + per_case_exp_reference: Dict[ + int, Optional[Tuple[np.ndarray, np.ndarray]] + ] = {} + for sc_idx, exp_resolved in enumerate(per_case_exp): + if exp_resolved is None: + per_case_exp_reference[sc_idx] = None + else: + exp_strain, exp_stress, _exp_label = exp_resolved + per_case_exp_reference[sc_idx] = (exp_strain, exp_stress) + + # Pre-load per-gene case curves for the inset. Same shape + # as the overlay's per_case_curves, but indexed by clicked + # gene index rather than rank. + gene_to_curves: Dict[ + int, List[Tuple[int, np.ndarray, np.ndarray]] + ] = {} + for g_idx, g in enumerate(chosen): + sc_curves: List[Tuple[int, np.ndarray, np.ndarray]] = [] + for sc_idx in range(n_sim_cases): + pair = _load_or_extract_curve( + archive, run_id, + birth_gen=g.birth_gen, birth_gene=g.birth_gene, + sim_case_idx=sc_idx, + extractor=per_case_extractor[sc_idx], + rank_label=g_idx, + experimental_reference=per_case_exp_reference[sc_idx], + ) + if pair is None: + continue + sc_curves.append((sc_idx, pair[0], pair[1])) + gene_to_curves[g_idx] = sc_curves + + # --- Build the figure ---------------------------------------------- + fig = plt.figure(figsize=(13, 7)) + gs = fig.add_gridspec( + nrows=10, ncols=2, + height_ratios=[1] * 7 + [0.2, 0.5, 0.7], + width_ratios=[1.0, 1.1], + hspace=0.35, wspace=0.25, + ) + scatter_ax = fig.add_subplot(gs[0:7, 0]) + inset_ax = fig.add_subplot(gs[0:7, 1]) + panel_ax = fig.add_subplot(gs[9, :]) + panel_ax.axis("off") + panel_text = panel_ax.text( + 0.01, 0.95, + "click any scatter point to see its parameters and response", + transform=panel_ax.transAxes, + va="top", ha="left", family="monospace", fontsize=9, + ) + + # --- Scatter ------------------------------------------------------- + fits = np.array([g.fitness for g in chosen]) + pop_fit_2d = fits[:, [a_idx, b_idx]] + + # Two L2 norms are useful, depending on the question being asked: + # subset_l2: distance from utopian origin in the projected pair + # (a,b). This is what users typically want for "the best + # balanced point ON THIS PROJECTION" — the Pareto winner of + # what they're actually looking at. + # full_l2: distance across all objectives. Useful as a + # secondary signal showing how a point ranks globally vs + # just on the displayed pair. + # The L2 winner ring uses subset_l2 by default — Robert's ask: + # "the L2 circle should really be based on the objectives + # chosen and that should be what's most useful." + x_vals = pop_fit_2d[:, 0] + y_vals = pop_fit_2d[:, 1] + subset_l2 = np.sqrt(x_vals ** 2 + y_vals ** 2) + full_l2 = np.linalg.norm(fits, axis=1) + best_local_idx = int(np.argmin(subset_l2)) + + # Pick the color metric. Each mode highlights a different aspect + # of where points sit relative to the X=0 / Y=0 planes: + # - subset_l2: iso-curves are circles centered at origin in + # the projected pair. Points along a true Pareto front sit + # along an iso-curve at constant distance, so a uniform + # color along the front is the visual signal that the + # front is genuinely tradeoff-shaped here. + # - full_l2: same idea but with all objectives folded in. + # A point that looks balanced on (a,b) but is bad on the + # hidden objectives shows up as light here. + # - x: just the projected X value. Iso-curves are vertical + # lines. Reveals proximity to the Y=0 plane (how + # specialized-on-Y a point is — small X means good on + # X regardless of Y). + # - y: symmetric to x. Iso-curves are horizontal lines. + # Shows proximity to the X=0 plane. + # - asymmetry: |x - y| / (x + y). Zero means perfectly + # balanced on the pair, larger means more lopsided. Lets + # users see at a glance which points are specialists vs + # compromises. + color_metric_options = ("subset_l2", "full_l2", "x", "y", "asymmetry") + if color_by not in color_metric_options: + raise ValueError( + f"unknown color_by={color_by!r}; valid options: " + f"{color_metric_options}" + ) + if color_by == "subset_l2": + color_values = subset_l2 + cbar_label = ( + f"L2 of ({meta.objective_labels[a_idx]}, " + f"{meta.objective_labels[b_idx]})" + ) + elif color_by == "full_l2": + color_values = full_l2 + cbar_label = "L2 (all objectives)" + elif color_by == "x": + color_values = x_vals + cbar_label = meta.objective_labels[a_idx] + elif color_by == "y": + color_values = y_vals + cbar_label = meta.objective_labels[b_idx] + else: # asymmetry + # Guard against division by zero — x+y=0 only if both are + # zero (utopia point), which is a degenerate edge case. + denom = x_vals + y_vals + with np.errstate(divide="ignore", invalid="ignore"): + asym = np.where(denom > 0, np.abs(x_vals - y_vals) / denom, 0.0) + color_values = asym + cbar_label = ( + f"|{meta.objective_labels[a_idx]} - " + f"{meta.objective_labels[b_idx]}| / sum" + ) + + sc = scatter_ax.scatter( + pop_fit_2d[:, 0], pop_fit_2d[:, 1], + c=color_values, cmap="viridis", + s=40, picker=5, edgecolors="black", linewidths=0.4, + ) + # Mark the subset-L2 winner with a red ring on top. This is the + # gene most balanced on the displayed projection — answering + # "which point is the best compromise on what I'm looking at?" + scatter_ax.scatter( + pop_fit_2d[best_local_idx, 0], pop_fit_2d[best_local_idx, 1], + s=160, facecolors="none", edgecolors="red", linewidths=2.0, + label="L2 winner (subset)", zorder=5, + ) + # Utopian origin marker — same convention as plot_pareto_front + # in workflow_common.postprocess. + scatter_ax.scatter( + [0], [0], c="black", marker="+", s=80, label="utopia", + zorder=4, + ) + cbar = fig.colorbar(sc, ax=scatter_ax, shrink=0.85, pad=0.02) + cbar.set_label(cbar_label) + scatter_ax.set_xlabel(meta.objective_labels[a_idx]) + scatter_ax.set_ylabel(meta.objective_labels[b_idx]) + scatter_ax.set_title( + f"Pareto: {meta.objective_labels[a_idx]} vs " + f"{meta.objective_labels[b_idx]}\n" + f"({mode_note}, color={color_by})", + fontsize=10, + ) + scatter_ax.legend(loc="upper right", fontsize=8) + scatter_ax.grid(True, alpha=0.3) + + # --- Inset: response curve for the clicked gene -------------------- + inset_ax.set_xlabel("Strain") + inset_ax.set_ylabel("Stress") + inset_ax.set_title("Response of clicked gene", fontsize=10) + inset_ax.grid(True, alpha=0.3) + + def _draw_response(g_idx: int) -> None: + """Redraw the inset showing gene g_idx's sim curves vs experimental.""" + inset_ax.clear() + inset_ax.set_xlabel("Strain") + inset_ax.set_ylabel("Stress") + inset_ax.grid(True, alpha=0.3) + # Deterministic per-SimCase coloring. + sc_cmap = plt.get_cmap("tab10") + sim_curves = gene_to_curves.get(g_idx, []) + for sc_idx, strain, stress in sim_curves: + color = sc_cmap(sc_idx % 10) + inset_ax.plot( + strain, stress, + color=color, linewidth=1.6, + label=f"sim sc={sc_idx}", + ) + # Experimental reference (if any) for this SimCase. + if (sc_idx < len(per_case_exp) + and per_case_exp[sc_idx] is not None): + ex, ey, _exp_label = per_case_exp[sc_idx] + inset_ax.plot( + ex, ey, + color=color, linestyle="--", linewidth=2.0, + alpha=0.8, + label=f"exp sc={sc_idx}", + ) + gene = chosen[g_idx] + is_winner = g_idx == best_local_idx + title_extra = " (L2 winner)" if is_winner else "" + inset_ax.set_title( + f"Response — gene g{gene.birth_gen}.{gene.birth_gene}" + f"{title_extra}", + fontsize=10, + ) + if sim_curves: + inset_ax.legend(loc="best", fontsize=8) + + # Initialize the inset with the L2 winner's response. + _draw_response(best_local_idx) + panel_text.set_text(_format_gene_panel( + chosen[best_local_idx], meta.param_names, meta.objective_labels, + header=f"L2 winner — {len(chosen)} points on plot", + )) + + # --- Click handler ------------------------------------------------- + def _on_pick(event): + # PathCollection (scatter) pick events arrive with `ind` — + # an array of indices into the collection's data. A single + # click usually returns one index, but a dense cluster can + # return several; pick the first. + if event.artist is not sc: + return + if not hasattr(event, "ind") or len(event.ind) == 0: + return + g_idx = int(event.ind[0]) + _draw_response(g_idx) + panel_text.set_text(_format_gene_panel( + chosen[g_idx], meta.param_names, meta.objective_labels, + header=f"clicked point #{g_idx} of {len(chosen)}", + )) + fig.canvas.draw_idle() + + fig.canvas.mpl_connect("pick_event", _on_pick) + + fig.suptitle( + f"Pareto front (run {run_id[:8]}) — click points for details", + fontsize=11, + ) + + if save is not None: + fig.savefig(str(save), dpi=120, bbox_inches="tight") + print(f"saved figure to {save}", file=sys.stderr) + if show: + plt.show() + return fig + + +# --- CLI ----------------------------------------------------------------- + + +def _parse_pareto_pair(s: str) -> Tuple[int, int]: + """Parse ``--pareto 0,1`` into a tuple of ints with friendly errors.""" + try: + a, b = (int(x.strip()) for x in s.split(",")) + except (ValueError, AttributeError): + raise argparse.ArgumentTypeError( + f"--pareto must be 'i,j' (e.g. '0,1'); got {s!r}" + ) + return (a, b) + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description=( + "Plot top-N optimized solutions vs experimental " + "data from a calibration archive." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " Top 10 by L2 norm:\n" + " python examples/plot_solutions.py calibration_run \\\n" + " --top 10 --experimental experiments/exp1.csv \\\n" + " experiments/exp2.csv\n\n" + " Top 5 by objective 0:\n" + " python examples/plot_solutions.py calibration_run \\\n" + " --top 5 --objective 0 \\\n" + " --experimental experiments/exp1.csv\n\n" + " Pareto front for objectives 0 vs 2:\n" + " python examples/plot_solutions.py calibration_run \\\n" + " --pareto 0,2\n" + ), + ) + p.add_argument( + "workspace", type=Path, + help="Calibration workspace dir, or direct .db file path.", + ) + p.add_argument( + "--top", dest="top_n", type=int, default=10, + help="Number of top solutions to plot (default: 10). " + "Pass 0 or negative for all available.", + ) + p.add_argument( + "--mode", dest="mode", + choices=("l2", "objective", "last-gen"), default="l2", + help="Ranking mode (default: l2).", + ) + p.add_argument( + "--objective", dest="objective", default=None, + help="For --mode=objective: integer index or label name.", + ) + p.add_argument( + "--run-id", dest="run_id", default=None, + help="Archive run UUID (default: latest run).", + ) + p.add_argument( + "--experimental", dest="experimental", + nargs="*", type=Path, default=None, + help="CSV paths, one per SimCase (in SimCase order). " + "If fewer paths than SimCases are given, later cases " + "show no experimental overlay.", + ) + p.add_argument( + "--pareto", dest="pareto", + type=_parse_pareto_pair, default=None, + help="Produce a Pareto-front scatter for objectives i,j " + "(e.g. '0,1'). The subset-L2 winner is highlighted, all " + "points are clickable to reveal parameters and the " + "stress-strain response of every SimCase. Restricted to " + "--top N points if --top is set; otherwise plots every " + "rank-0 gene. NOTE: passing --pareto suppresses the " + "headline overlay by default — pass --overlay to also " + "plot the overlay.", + ) + p.add_argument( + "--overlay", dest="overlay", action="store_true", default=None, + help="Force the headline stress-strain overlay figure on " + "even when --pareto is given. Without this flag, " + "passing --pareto suppresses the overlay (the bare " + "command without --pareto still shows the overlay by " + "default).", + ) + p.add_argument( + "--color-by", dest="color_by", + choices=("subset_l2", "full_l2", "x", "y", "asymmetry"), + default="subset_l2", + help="Pareto-scatter color metric. 'subset_l2' (default) " + "colors by L2 distance in the projected (X,Y) plane — " + "iso-curves are circles, uniform color along the " + "front signals a true tradeoff curve. 'full_l2' uses " + "all objectives. 'x'/'y' color by a single axis " + "(showing proximity to the Y=0/X=0 plane). 'asymmetry' " + "highlights points that are specialists on one axis " + "vs balanced compromises.", + ) + p.add_argument( + "--save", dest="save", type=Path, default=None, + help="Save the headline figure to this path. Use with " + "--no-show for headless/CI usage.", + ) + p.add_argument( + "--save-pareto", dest="save_pareto", + type=Path, default=None, + help="Save the Pareto-front figure to this path.", + ) + p.add_argument( + "--no-show", dest="show", action="store_false", default=True, + help="Don't open a window (useful with --save in batch).", + ) + return p + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = _build_parser().parse_args(argv) + + # Auto-promote mode: if the user passed --objective without + # --mode, they almost certainly meant `--mode objective`. The + # alternative — silently using L2 ranking with the objective + # flag ignored — was the source of a real user-reported bug + # ("changing --objective shows the same plot"). So when + # --objective is supplied AND --mode is still the default + # "l2", flip mode to "objective". Users who really want L2 + # ranking with an objective set (a strange combination) can + # pass --mode l2 explicitly... and they'd see the same L2 + # plot they get without --objective, which is consistent. + # + # last-gen mode is incompatible with --objective (the former + # ignores ranking entirely). Error rather than silently + # picking one over the other. + if args.objective is not None: + if args.mode == "last-gen": + print( + "error: --objective is not compatible with " + "--mode last-gen (last-gen has no ranking step).", + file=sys.stderr, + ) + return 1 + if args.mode == "l2": + args.mode = "objective" + + # Resolve overlay default. Robert's report: passing --pareto + # alone should suppress the overlay, since users asking for a + # Pareto plot usually want only that. The --overlay flag is the + # explicit opt-in to also draw the overlay alongside Pareto. + # When --pareto is NOT given, the overlay is the headline + # figure and shows by default (the "user just runs the script + # to look at their results" path). + # + # args.overlay is None when neither flag was set; True when + # --overlay was passed. Resolve to a concrete bool here. + if args.overlay is True: + show_overlay = True + elif args.pareto is not None: + show_overlay = False + else: + show_overlay = True + + try: + if show_overlay: + plot_top_solutions_overlay( + args.workspace, + top_n=args.top_n, + mode=args.mode, + objective=args.objective, + run_id=args.run_id, + experimental_paths=args.experimental, + save=args.save, + show=args.show, + ) + if args.pareto is not None: + plot_pareto_front_with_l2_winner( + args.workspace, + objective_pair=args.pareto, + top_n=args.top_n, + color_by=args.color_by, + run_id=args.run_id, + experimental_paths=args.experimental, + save=args.save_pareto, + show=args.show, + ) + # No-op runs (neither overlay nor pareto) shouldn't happen + # given the resolution above, but guard for paranoia: the + # only way to reach this is --pareto unset AND --overlay + # explicitly false (which the new CLI doesn't expose), or + # the resolution logic above getting confused. + if not show_overlay and args.pareto is None: + print( + "error: nothing to plot. Pass --pareto i,j or " + "remove conflicting flags.", + file=sys.stderr, + ) + return 1 + except (ValueError, RuntimeError, FileNotFoundError) as e: + print(f"error: {e}", file=sys.stderr) + return 1 + except SystemExit as e: + return int(e.code) if e.code is not None else 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workflows/exaconstit-calibrate/examples/run_nsga3_slurm.sh b/workflows/exaconstit-calibrate/examples/run_nsga3_slurm.sh new file mode 100755 index 0000000..8f372e3 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/run_nsga3_slurm.sh @@ -0,0 +1,178 @@ +#!/bin/sh +# +# Slurm job script for the ExaConstit NSGA-III calibration example. +# +# Most users should only edit: +# 1. The #SBATCH lines. +# 2. The SIMPLE SETTINGS block. +# +# Submit with: +# +# sbatch run_nsga3_slurm.sh +# +# Keep this file in the same directory as nsga3_slurm_helpers.sh unless +# you set HELPER_SCRIPT below to the full path of that helper file. + +# ---------------------------------------------------------------------- +# 1. SLURM SETTINGS +# ---------------------------------------------------------------------- +# +# These lines ask Slurm for compute resources. +# +# Examples: +# +# One short debug job on two nodes: +# #SBATCH -A your_bank_name +# #SBATCH -N 2 +# #SBATCH -n 224 +# #SBATCH -t 01:00:00 +# #SBATCH -p pdebug +# +# A longer job on four nodes: +# #SBATCH -A your_bank_name +# #SBATCH -N 4 +# #SBATCH -n 448 +# #SBATCH -t 08:00:00 +# #SBATCH -p pbatch +# +# Edit the active lines below. Do not put quotes around these values. +# +#SBATCH -A your_bank_name +#SBATCH -N 2 +#SBATCH -n 224 +#SBATCH -t 01:00:00 +#SBATCH -p pdebug +#SBATCH -J exaconstit_nsga3 +#SBATCH -o exaconstit_nsga3.%j.out + +# ---------------------------------------------------------------------- +# 2. SIMPLE SETTINGS +# ---------------------------------------------------------------------- +# +# Change the text to the right of each equals sign. +# Do not add spaces around the equals sign. +# +# Correct: +# ACTION="all" +# +# Incorrect: +# ACTION = "all" + +# Python to use for this workflow. +# +# Example for this LLNL setup: +# PYTHON="/usr/tce/packages/python/python-3.12.2/bin/python" +# +# Example if your environment provides python on PATH: +# PYTHON="python3" +PYTHON="/usr/tce/packages/python/python-3.12.2/bin/python" + +# Where Flux's Python module is installed. +# +# Example for this LLNL setup: +# FLUX_PYTHONPATH="/usr/lib64/flux/python3.12" +# +# Example for a different Python version: +# FLUX_PYTHONPATH="/usr/lib64/flux/python3.11" +FLUX_PYTHONPATH="/usr/lib64/flux/python3.12" + +# What should this job do? +# +# Choose exactly one: +# ACTION="run" starts a new calibration run +# ACTION="resume-latest" resumes from the newest checkpoint +# ACTION="resume-from" resumes from CHECKPOINT_GEN or CHECKPOINT_PATH +# ACTION="inspect" prints tables from an existing run +# ACTION="plots" makes plots from an existing run +# ACTION="postprocess" does inspect and plots, but no new simulations +# ACTION="all" runs calibration, then inspect, then plots +ACTION="run" + +# Resume settings. These only matter when ACTION="resume-from". +# +# Resume from generation 15: +# CHECKPOINT_GEN="15" +# CHECKPOINT_PATH="" +# +# Resume from an exact checkpoint file: +# CHECKPOINT_GEN="" +# CHECKPOINT_PATH="calibration_run/checkpoint_files/checkpoint_gen_15.pkl" +CHECKPOINT_GEN="" +CHECKPOINT_PATH="" + +# Set this to "1" the first time you run, or after Python dependencies +# changed. Set it to "0" for normal runs so you do not spend allocation +# time reinstalling the package. +# +# Example first-time setup: +# INSTALL_PACKAGE="1" +# +# Example normal production run: +# INSTALL_PACKAGE="0" +INSTALL_PACKAGE="0" + +# Where nsga3_calibration.py writes case directories, checkpoints, +# logs, and the SQLite archive. +# +# Most users can leave this as-is. +WORKSPACE="calibration_run" + +# Where post-processing tables and figures should be written. +# +# Most users can leave this as-is. +POST_DIR="postprocess" + +# Number of best solutions to print or plot. +# +# Example: +# TOP_N="5" +# TOP_N="10" +# TOP_N="25" +TOP_N="10" + +# Optional path overrides. +# +# Most users should leave these blank because nsga3_calibration.py can +# find the ExaConstit checkout and common mechanics build directories. +# +# Example if your ExaConstit checkout is somewhere unusual: +# EXACONSTIT_ROOT="/usr/workspace/myname/ExaConstit" +# +# Example if mechanics is in a custom build directory: +# EXACONSTIT_MECHANICS="/usr/workspace/myname/ExaConstit/build_cpu/bin/mechanics" +# For most people, you just need to provide the ExaConstit binary location +EXACONSTIT_ROOT="" +EXACONSTIT_MECHANICS="" + +# Helper script with the actual shell functions. +# +# If nsga3_slurm_helpers.sh is in this same directory, leave this blank. +# +# If you keep the helper somewhere else, use the full path: +# HELPER_SCRIPT="/usr/workspace/myname/scripts/nsga3_slurm_helpers.sh" +HELPER_SCRIPT="" + +# ---------------------------------------------------------------------- +# 3. DO NOT EDIT BELOW THIS LINE FOR NORMAL USE +# ---------------------------------------------------------------------- + +set -eu + +SCRIPT_DIR=$(CDPATH= cd "$(dirname "$0")" && pwd) + +if [ -z "${HELPER_SCRIPT}" ]; then + HELPER_SCRIPT="${SCRIPT_DIR}/nsga3_slurm_helpers.sh" +fi + +if [ ! -f "${HELPER_SCRIPT}" ]; then + echo "ERROR: helper script not found:" + echo " ${HELPER_SCRIPT}" + echo + echo "Keep nsga3_slurm_helpers.sh next to run_nsga3_slurm.sh, or set" + echo "HELPER_SCRIPT to the helper's full path." + exit 2 +fi + +# Load helper functions, then run the workflow selected by ACTION. +. "${HELPER_SCRIPT}" +exa_nsga3_main diff --git a/workflows/exaconstit-calibrate/examples/template_options.toml b/workflows/exaconstit-calibrate/examples/template_options.toml new file mode 100644 index 0000000..192dcb9 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/template_options.toml @@ -0,0 +1,170 @@ +# ExaConstit NSGA-III calibration template. +# +# This file uses the newer ExaConstit options layout from examples/options.toml, +# but keeps only the pieces needed by examples/nsga3_calibration.py. +# +# The calibration driver renders this file once per simulation case. +# Values written with double-percent placeholder syntax are filled +# from SimCase.case_data in +# nsga3_calibration.py before mechanics runs. + +Version = "0.9.0" + +# Keep this fixed at "options" so output files land under +# results/options/, matching the reader paths configured by +# nsga3_calibration.py. +basename = "options" + +# Auto-generated meshes use this grain map. For file-based meshes this +# information may already live in the mesh element attributes. +grain_file = "%%grain_file%%" + +# ===================================== +# MATERIAL DEFINITIONS +# ===================================== + +[[Materials]] + material_name = "voce_fcc" + region_id = 1 + temperature = %%temperature_k%% + mech_type = "exacmech" + + [Materials.Properties] + floc = "%%properties_file%%" + num_props = 17 + + [Materials.State_Vars] + floc = "%%state_vars_file%%" + num_vars = 24 + + [Materials.Grain] + # Matches the legacy voce calibration setup. + ori_state_var_loc = 9 + ori_stride = 4 + ori_type = "quat" + num_grains = 500 + orientation_file = "%%ori_file%%" + grain_file = "%%grain_file%%" + + [Materials.Model] + mech_type = "exacmech" + crystal_plasticity = true + + [Materials.Model.ExaCMech] + # Old layout: + # xtal_type = "fcc" + # slip_type = "powervoce" + # + # New 0.9-style shortcut name: + # evptn_FCC_A = FCC + linear Voce hardening + power-law slip. + shortcut = "evptn_FCC_A" + +# ===================================== +# BOUNDARY CONDITIONS +# ===================================== + +[BCs] + [BCs.time_info] + cycle_dependent = true + cycles = [1] + + # This preserves the existing calibration behavior: the driver + # supplies per-experiment boundary values through %%essential_vals%%. + [[BCs.velocity_bcs]] + essential_ids = [1, 4] + essential_comps = [3, 3] + essential_vals = %%essential_vals%% + + # Keep the newer experimental monotonic deformation helper off by + # default. The calibration example controls loading explicitly via + # velocity_bcs above. + expt_mono_def_flag = false + +# ===================================== +# TIME STEPPING CONTROL +# ===================================== + +[Time] + time_type = "auto" + + [Time.Auto] + dt_start = %%dt_min%% + dt_min = %%dt_min%% + dt_max = %%dt_max%% + dt_scale = %%dt_scale%% + t_final = %%t_final%% + +# ===================================== +# SOLVER SETTINGS +# ===================================== + +[Solvers] + assembly = "EA" + rtmodel = "CPU" + integ_model = "FULL" + + [Solvers.Krylov] + iter = 1000 + rel_tol = 1e-7 + abs_tol = 1e-27 + solver = "CG" + preconditioner = "JACOBI" + print_level = 0 + + [Solvers.NR] + iter = 25 + rel_tol = 5e-5 + abs_tol = 5e-10 + nl_solver = "NR" + +# ===================================== +# VISUALIZATION OUTPUT +# ===================================== + +[Visualizations] + visit = false + paraview = false + adios2 = false + output_frequency = 1 + floc = "visualizations/" + +# ===================================== +# POST-PROCESSING OPTIONS +# ===================================== + +[PostProcessing] + [PostProcessing.volume_averages] + enabled = true + stress = true + def_grad = true + euler_strain = true + plastic_work = true + eq_pl_strain = true + elastic_strain = true + output_frequency = 1 + output_directory = "./results/" + + avg_stress_fname = "avg_stress.txt" + avg_def_grad_fname = "avg_def_grad.txt" + avg_euler_strain_fname = "avg_euler_strain.txt" + avg_pl_work_fname = "avg_pl_work.txt" + avg_eq_pl_strain_fname = "avg_eq_pl_strain.txt" + avg_elastic_strain_fname = "avg_elastic_strain.txt" + + [PostProcessing.projections] + auto_enable_compatible = false + enabled_projections = [] + +# ===================================== +# MESH SETTINGS +# ===================================== + +[Mesh] + type = "auto" + ref_ser = 1 + ref_par = 0 + p_refinement = %%p_refinement%% + + [Mesh.Auto] + mxyz = %%mesh_lengths%% + nxyz = %%mesh_cuts%% diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_def_grad_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_def_grad_global.txt new file mode 100644 index 0000000..f9b5354 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_def_grad_global.txt @@ -0,0 +1,70 @@ + # Time Volume F11 F12 F13 F21 F22 F23 F31 F32 F33 + 5.00000000e-05 1.00000156e+00 9.99998350e-01 -1.84962184e-08 -5.66846862e-08 -8.91705016e-09 9.99998214e-01 -1.23795590e-07 -1.77937979e-07 2.55892896e-07 1.00000500e+00 + 2.06250000e-04 1.00000645e+00 9.99993194e-01 -7.62951971e-08 -2.33847635e-07 -3.67826043e-08 9.99992631e-01 -5.10652195e-07 -7.33968306e-07 1.05554536e-06 1.00002063e+00 + 6.94531250e-04 1.00002172e+00 9.99977084e-01 -2.56900365e-07 -7.87709412e-07 -1.23860238e-07 9.99975187e-01 -1.71953401e-06 -2.47131056e-06 3.55433392e-06 1.00006945e+00 + 2.22041016e-03 1.00006925e+00 9.99926658e-01 -7.73969082e-07 -2.77847366e-06 -3.70965413e-07 9.99920582e-01 -5.61775210e-06 -7.61207543e-06 1.15160494e-05 1.00022204e+00 + 4.60459595e-03 1.00009813e+00 9.99831231e-01 -2.78751469e-06 -1.73479784e-05 -4.03063251e-06 9.99806582e-01 -2.46207938e-05 -1.68848315e-06 3.58581525e-05 1.00046046e+00 + 6.46724110e-03 1.00010295e+00 9.99751214e-01 -6.14409726e-06 -2.70720587e-05 -8.50983666e-06 9.99705304e-01 -4.12131435e-05 5.26669821e-06 6.00831507e-05 1.00064673e+00 + 8.40749646e-03 1.00010584e+00 9.99667387e-01 -9.94364227e-06 -3.26621132e-05 -1.31620644e-05 9.99598209e-01 -5.58271783e-05 9.76063365e-06 8.35287520e-05 1.00084075e+00 + 1.04285958e-02 1.00010819e+00 9.99580472e-01 -1.34912718e-05 -3.55480452e-05 -1.76362744e-05 9.99485649e-01 -6.82051668e-05 1.29261268e-05 1.06184612e-04 1.00104286e+00 + 1.25339076e-02 1.00011031e+00 9.99490356e-01 -1.68056413e-05 -3.64573871e-05 -2.21636897e-05 9.99367725e-01 -7.81778058e-05 1.50306766e-05 1.27670681e-04 1.00125339e+00 + 1.47269407e-02 1.00011233e+00 9.99396824e-01 -2.00170646e-05 -3.57932403e-05 -2.68164382e-05 9.99244439e-01 -8.65142301e-05 1.61421739e-05 1.48588804e-04 1.00147270e+00 + 1.81535550e-02 1.00011532e+00 9.99251094e-01 -2.46886149e-05 -3.26732709e-05 -3.38661956e-05 9.99051358e-01 -9.71081725e-05 1.61007241e-05 1.79357447e-04 1.00181536e+00 + 2.17229449e-02 1.00011828e+00 9.99099425e-01 -2.95678349e-05 -2.89451155e-05 -4.13043649e-05 9.98850148e-01 -1.06855010e-04 1.54883263e-05 2.09973353e-04 1.00217230e+00 + 2.54410593e-02 1.00012127e+00 9.98941812e-01 -3.44906386e-05 -2.47997009e-05 -4.91171295e-05 9.98640296e-01 -1.16218679e-04 1.44316814e-05 2.40828452e-04 1.00254411e+00 + 2.93140952e-02 1.00012432e+00 9.98778093e-01 -3.93691292e-05 -2.05374196e-05 -5.73252134e-05 9.98421402e-01 -1.25306087e-04 1.31215347e-05 2.72119591e-04 1.00293141e+00 + 3.33485076e-02 1.00012744e+00 9.98608115e-01 -4.41314659e-05 -1.64970889e-05 -6.58840222e-05 9.98193016e-01 -1.34254071e-04 1.16888603e-05 3.03985330e-04 1.00333486e+00 + 3.75510205e-02 1.00013065e+00 9.98431497e-01 -4.88366889e-05 -1.26134168e-05 -7.47090691e-05 9.97954897e-01 -1.43435317e-04 1.00415440e-05 3.36881576e-04 1.00375511e+00 + 4.19286381e-02 1.00013395e+00 9.98247900e-01 -5.35998796e-05 -8.67130430e-06 -8.37813401e-05 9.97706732e-01 -1.52887119e-04 7.77828894e-06 3.70764997e-04 1.00419287e+00 + 4.64886564e-02 1.00013736e+00 9.98056993e-01 -5.85487696e-05 -4.77215069e-06 -9.31139680e-05 9.97448170e-01 -1.62596251e-04 4.71029267e-06 4.05703624e-04 1.00464887e+00 + 5.12386755e-02 1.00014089e+00 9.97858465e-01 -6.38067259e-05 -8.66597080e-07 -1.02683090e-04 9.97178814e-01 -1.72532196e-04 7.57670566e-07 4.41779027e-04 1.00512388e+00 + 5.61866121e-02 1.00014454e+00 9.97652036e-01 -6.94623975e-05 3.20431424e-06 -1.12436472e-04 9.96898207e-01 -1.82616767e-04 -4.07142368e-06 4.78802865e-04 1.00561867e+00 + 6.13407126e-02 1.00014832e+00 9.97437385e-01 -7.54279991e-05 7.36368938e-06 -1.22335209e-04 9.96605903e-01 -1.92824523e-04 -9.63971249e-06 5.16679729e-04 1.00613408e+00 + 6.67095674e-02 1.00015223e+00 9.97214210e-01 -8.17285911e-05 1.14029162e-05 -1.32315535e-04 9.96301410e-01 -2.03153279e-04 -1.57299138e-05 5.55472172e-04 1.00667097e+00 + 7.23021245e-02 1.00015628e+00 9.96982178e-01 -8.84432763e-05 1.53819474e-05 -1.42379879e-04 9.95984234e-01 -2.13553828e-04 -2.24094459e-05 5.95102530e-04 1.00723023e+00 + 7.81277047e-02 1.00016048e+00 9.96740891e-01 -9.56077521e-05 1.92549531e-05 -1.52547591e-04 9.95653913e-01 -2.23996200e-04 -2.96229587e-05 6.35628811e-04 1.00781278e+00 + 8.41960175e-02 1.00016483e+00 9.96489839e-01 -1.03216583e-04 2.30373399e-05 -1.62880863e-04 9.95310069e-01 -2.34588650e-04 -3.73538013e-05 6.77222474e-04 1.00841962e+00 + 9.05171766e-02 1.00016934e+00 9.96228495e-01 -1.11219010e-04 2.68172824e-05 -1.73407937e-04 9.94952301e-01 -2.45454155e-04 -4.56684375e-05 7.20050230e-04 1.00905173e+00 + 1.00393988e-01 1.00017658e+00 9.95820669e-01 -1.23836306e-04 3.22481862e-05 -1.89376671e-04 9.94394167e-01 -2.62068219e-04 -5.90023125e-05 7.86152925e-04 1.01003942e+00 + 1.10682333e-01 1.00018407e+00 9.95396554e-01 -1.36987588e-04 3.73908541e-05 -2.05480338e-04 9.93813591e-01 -2.78885529e-04 -7.31060587e-05 8.54201342e-04 1.01106826e+00 + 1.21399359e-01 1.00019182e+00 9.94955661e-01 -1.50667769e-04 4.21206147e-05 -2.21687055e-04 9.93209577e-01 -2.95970347e-04 -8.78265491e-05 9.24383834e-04 1.01213996e+00 + 1.32562927e-01 1.00019984e+00 9.94497399e-01 -1.64955716e-04 4.64689827e-05 -2.38064231e-04 9.92581177e-01 -3.13407000e-04 -1.03345563e-04 9.96722968e-04 1.01325632e+00 + 1.44191645e-01 1.00020813e+00 9.94021075e-01 -1.79930319e-04 5.05501326e-05 -2.54658986e-04 9.91927493e-01 -3.31356885e-04 -1.19810764e-04 1.07124763e-03 1.01441920e+00 + 1.56304892e-01 1.00021672e+00 9.93526040e-01 -1.95666426e-04 5.44921723e-05 -2.71436295e-04 9.91247526e-01 -3.49836582e-04 -1.37265068e-04 1.14786651e-03 1.01563053e+00 + 1.68922858e-01 1.00022560e+00 9.93011647e-01 -2.12201589e-04 5.82787669e-05 -2.88524182e-04 9.90540220e-01 -3.68743345e-04 -1.55498964e-04 1.22666291e-03 1.01689233e+00 + 1.82066573e-01 1.00023479e+00 9.92477148e-01 -2.29530093e-04 6.19303455e-05 -3.05969185e-04 9.89804567e-01 -3.88111875e-04 -1.74260087e-04 1.30795026e-03 1.01820671e+00 + 1.95757942e-01 1.00024429e+00 9.91921872e-01 -2.47660909e-04 6.54249816e-05 -3.23803663e-04 9.89039417e-01 -4.07995611e-04 -1.93597931e-04 1.39179474e-03 1.01957585e+00 + 2.10019785e-01 1.00025412e+00 9.91345186e-01 -2.66645939e-04 6.87408902e-05 -3.42000702e-04 9.88243524e-01 -4.28438562e-04 -2.13820021e-04 1.47822065e-03 1.02100204e+00 + 2.24875872e-01 1.00026429e+00 9.90746315e-01 -2.86502523e-04 7.18531705e-05 -3.60522923e-04 9.87415724e-01 -4.49407244e-04 -2.35128096e-04 1.56722767e-03 1.02248766e+00 + 2.40350962e-01 1.00027479e+00 9.90124494e-01 -3.07259538e-04 7.47357212e-05 -3.79329678e-04 9.86554781e-01 -4.70920914e-04 -2.57617802e-04 1.65888019e-03 1.02403518e+00 + 2.56470847e-01 1.00028566e+00 9.89478853e-01 -3.28904874e-04 7.73289205e-05 -3.98387239e-04 9.85659503e-01 -4.92851333e-04 -2.81233310e-04 1.75311505e-03 1.02564717e+00 + 2.73262395e-01 1.00029689e+00 9.88808495e-01 -3.51426710e-04 7.95891478e-05 -4.17649810e-04 9.84728658e-01 -5.15227551e-04 -3.05984626e-04 1.85007302e-03 1.02732634e+00 + 2.90753590e-01 1.00030849e+00 9.88112465e-01 -3.74819243e-04 8.14990535e-05 -4.37060637e-04 9.83761003e-01 -5.38122704e-04 -3.31990950e-04 1.95004267e-03 1.02907547e+00 + 3.08973585e-01 1.00032048e+00 9.87389805e-01 -3.99106530e-04 8.30745643e-05 -4.56585032e-04 9.82755236e-01 -5.61609102e-04 -3.59319924e-04 2.05333953e-03 1.03089748e+00 + 3.27952746e-01 1.00033288e+00 9.86639532e-01 -4.24429379e-04 8.43426788e-05 -4.76219795e-04 9.81710009e-01 -5.85619335e-04 -3.88004003e-04 2.15999815e-03 1.03279541e+00 + 3.47722706e-01 1.00034568e+00 9.85860592e-01 -4.50907428e-04 8.52769164e-05 -4.96030523e-04 9.80623977e-01 -6.10187086e-04 -4.18184587e-04 2.27007355e-03 1.03477242e+00 + 3.68316415e-01 1.00035891e+00 9.85051949e-01 -4.78590557e-04 8.58712253e-05 -5.15961220e-04 9.79495711e-01 -6.35274899e-04 -4.50201861e-04 2.38352978e-03 1.03683181e+00 + 3.89768194e-01 1.00037257e+00 9.84212618e-01 -5.07521181e-04 8.60299403e-05 -5.35849019e-04 9.78323659e-01 -6.60897056e-04 -4.84229640e-04 2.50018789e-03 1.03897701e+00 + 4.12113798e-01 1.00038668e+00 9.83341653e-01 -5.37729958e-04 8.55503152e-05 -5.55520564e-04 9.77106162e-01 -6.87034888e-04 -5.20170233e-04 2.61990689e-03 1.04121159e+00 + 4.35390468e-01 1.00040126e+00 9.82438076e-01 -5.69242539e-04 8.42968525e-05 -5.74878715e-04 9.75841523e-01 -7.13515322e-04 -5.57938119e-04 2.74243285e-03 1.04353928e+00 + 4.59637000e-01 1.00041631e+00 9.81500843e-01 -6.02083775e-04 8.21360492e-05 -5.93863176e-04 9.74528040e-01 -7.40200462e-04 -5.97617967e-04 2.86761346e-03 1.04596396e+00 + 4.84893803e-01 1.00043187e+00 9.80528856e-01 -6.36292220e-04 7.91032383e-05 -6.12445296e-04 9.73163991e-01 -7.67208828e-04 -6.39539193e-04 2.99583970e-03 1.04848967e+00 + 5.11202974e-01 1.00044793e+00 9.79521022e-01 -6.71892512e-04 7.53101403e-05 -6.30516821e-04 9.71747574e-01 -7.94677393e-04 -6.83905833e-04 3.12752360e-03 1.05112061e+00 + 5.38608360e-01 1.00046452e+00 9.78476237e-01 -7.08907363e-04 7.08113511e-05 -6.47989016e-04 9.70276939e-01 -8.22517601e-04 -7.30819899e-04 3.26254584e-03 1.05386118e+00 + 5.67155637e-01 1.00048166e+00 9.77393378e-01 -7.47370730e-04 6.56323127e-05 -6.64799978e-04 9.68750175e-01 -8.50615779e-04 -7.80313941e-04 3.40054401e-03 1.05671595e+00 + 5.96892384e-01 1.00049936e+00 9.76271331e-01 -7.87379799e-04 5.95184961e-05 -6.80908616e-04 9.67165302e-01 -8.79045710e-04 -8.32187254e-04 3.54116993e-03 1.05968966e+00 + 6.27868162e-01 1.00051766e+00 9.75108950e-01 -8.28993638e-04 5.22634895e-05 -6.96273117e-04 9.65520292e-01 -9.07802050e-04 -8.86526141e-04 3.68381703e-03 1.06278728e+00 + 6.60134598e-01 1.00053657e+00 9.73905027e-01 -8.72298656e-04 4.38578544e-05 -7.10793830e-04 9.63813115e-01 -9.36878465e-04 -9.43749332e-04 3.82774360e-03 1.06601397e+00 + 6.93745468e-01 1.00055611e+00 9.72658348e-01 -9.17361037e-04 3.45466602e-05 -7.24268785e-04 9.62041683e-01 -9.66014864e-04 -1.00421765e-03 3.97223618e-03 1.06937511e+00 + 7.28756791e-01 1.00057632e+00 9.71367647e-01 -9.64361159e-04 2.40335546e-05 -7.36597318e-04 9.60203908e-01 -9.94873638e-04 -1.06829173e-03 4.11668420e-03 1.07287630e+00 + 7.65226920e-01 1.00059722e+00 9.70031816e-01 -1.01333379e-03 1.21468611e-05 -7.47476869e-04 9.58297484e-01 -1.02327507e-03 -1.13632680e-03 4.26078321e-03 1.07652337e+00 + 8.03216637e-01 1.00061884e+00 9.68649705e-01 -1.06422197e-03 -1.37168203e-06 -7.56443357e-04 9.56320075e-01 -1.05108603e-03 -1.20777190e-03 4.40400145e-03 1.08032241e+00 + 8.42789259e-01 1.00064122e+00 9.67220184e-01 -1.11688812e-03 -1.60316789e-05 -7.63158388e-04 9.54269264e-01 -1.07807270e-03 -1.28193196e-03 4.54619186e-03 1.08427974e+00 + 8.84010740e-01 1.00066439e+00 9.65741931e-01 -1.17132391e-03 -3.21587952e-05 -7.67404000e-04 9.52142794e-01 -1.10415877e-03 -1.35836093e-03 4.68663976e-03 1.08840196e+00 + 9.26949783e-01 1.00068840e+00 9.64213599e-01 -1.22753082e-03 -4.98263349e-05 -7.69096293e-04 9.49938364e-01 -1.12904327e-03 -1.43802551e-03 4.82501186e-03 1.09269595e+00 + 9.71677953e-01 1.00071327e+00 9.62633984e-01 -1.28484284e-03 -6.84232571e-05 -7.67803623e-04 9.47653464e-01 -1.15304488e-03 -1.52200539e-03 4.96188638e-03 1.09716886e+00 + 1.01826980e+00 1.00073907e+00 9.61002476e-01 -1.34251678e-03 -8.74462880e-05 -7.63282000e-04 9.45284969e-01 -1.17615920e-03 -1.61097450e-03 5.09650796e-03 1.10182814e+00 + 1.06680297e+00 1.00076583e+00 9.59317939e-01 -1.40080612e-03 -1.06790146e-04 -7.55200854e-04 9.42830253e-01 -1.19865534e-03 -1.70468118e-03 5.22761202e-03 1.10668157e+00 + 1.11735835e+00 1.00079361e+00 9.57579486e-01 -1.45932417e-03 -1.26476397e-04 -7.43543283e-04 9.40286396e-01 -1.22045888e-03 -1.80396036e-03 5.35415003e-03 1.11173723e+00 + 1.17002021e+00 1.00082246e+00 9.55786084e-01 -1.51811558e-03 -1.47181890e-04 -7.28906672e-04 9.37650607e-01 -1.24166244e-03 -1.90889736e-03 5.47673172e-03 1.11700354e+00 + 1.20000000e+00 1.00083445e+00 9.54770799e-01 -1.55109051e-03 -1.59154169e-04 -7.19984587e-04 9.36154545e-01 -1.25326611e-03 -1.96915227e-03 5.54413827e-03 1.12000159e+00 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_def_grad_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_def_grad_region_default_1.txt new file mode 100644 index 0000000..f9b5354 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_def_grad_region_default_1.txt @@ -0,0 +1,70 @@ + # Time Volume F11 F12 F13 F21 F22 F23 F31 F32 F33 + 5.00000000e-05 1.00000156e+00 9.99998350e-01 -1.84962184e-08 -5.66846862e-08 -8.91705016e-09 9.99998214e-01 -1.23795590e-07 -1.77937979e-07 2.55892896e-07 1.00000500e+00 + 2.06250000e-04 1.00000645e+00 9.99993194e-01 -7.62951971e-08 -2.33847635e-07 -3.67826043e-08 9.99992631e-01 -5.10652195e-07 -7.33968306e-07 1.05554536e-06 1.00002063e+00 + 6.94531250e-04 1.00002172e+00 9.99977084e-01 -2.56900365e-07 -7.87709412e-07 -1.23860238e-07 9.99975187e-01 -1.71953401e-06 -2.47131056e-06 3.55433392e-06 1.00006945e+00 + 2.22041016e-03 1.00006925e+00 9.99926658e-01 -7.73969082e-07 -2.77847366e-06 -3.70965413e-07 9.99920582e-01 -5.61775210e-06 -7.61207543e-06 1.15160494e-05 1.00022204e+00 + 4.60459595e-03 1.00009813e+00 9.99831231e-01 -2.78751469e-06 -1.73479784e-05 -4.03063251e-06 9.99806582e-01 -2.46207938e-05 -1.68848315e-06 3.58581525e-05 1.00046046e+00 + 6.46724110e-03 1.00010295e+00 9.99751214e-01 -6.14409726e-06 -2.70720587e-05 -8.50983666e-06 9.99705304e-01 -4.12131435e-05 5.26669821e-06 6.00831507e-05 1.00064673e+00 + 8.40749646e-03 1.00010584e+00 9.99667387e-01 -9.94364227e-06 -3.26621132e-05 -1.31620644e-05 9.99598209e-01 -5.58271783e-05 9.76063365e-06 8.35287520e-05 1.00084075e+00 + 1.04285958e-02 1.00010819e+00 9.99580472e-01 -1.34912718e-05 -3.55480452e-05 -1.76362744e-05 9.99485649e-01 -6.82051668e-05 1.29261268e-05 1.06184612e-04 1.00104286e+00 + 1.25339076e-02 1.00011031e+00 9.99490356e-01 -1.68056413e-05 -3.64573871e-05 -2.21636897e-05 9.99367725e-01 -7.81778058e-05 1.50306766e-05 1.27670681e-04 1.00125339e+00 + 1.47269407e-02 1.00011233e+00 9.99396824e-01 -2.00170646e-05 -3.57932403e-05 -2.68164382e-05 9.99244439e-01 -8.65142301e-05 1.61421739e-05 1.48588804e-04 1.00147270e+00 + 1.81535550e-02 1.00011532e+00 9.99251094e-01 -2.46886149e-05 -3.26732709e-05 -3.38661956e-05 9.99051358e-01 -9.71081725e-05 1.61007241e-05 1.79357447e-04 1.00181536e+00 + 2.17229449e-02 1.00011828e+00 9.99099425e-01 -2.95678349e-05 -2.89451155e-05 -4.13043649e-05 9.98850148e-01 -1.06855010e-04 1.54883263e-05 2.09973353e-04 1.00217230e+00 + 2.54410593e-02 1.00012127e+00 9.98941812e-01 -3.44906386e-05 -2.47997009e-05 -4.91171295e-05 9.98640296e-01 -1.16218679e-04 1.44316814e-05 2.40828452e-04 1.00254411e+00 + 2.93140952e-02 1.00012432e+00 9.98778093e-01 -3.93691292e-05 -2.05374196e-05 -5.73252134e-05 9.98421402e-01 -1.25306087e-04 1.31215347e-05 2.72119591e-04 1.00293141e+00 + 3.33485076e-02 1.00012744e+00 9.98608115e-01 -4.41314659e-05 -1.64970889e-05 -6.58840222e-05 9.98193016e-01 -1.34254071e-04 1.16888603e-05 3.03985330e-04 1.00333486e+00 + 3.75510205e-02 1.00013065e+00 9.98431497e-01 -4.88366889e-05 -1.26134168e-05 -7.47090691e-05 9.97954897e-01 -1.43435317e-04 1.00415440e-05 3.36881576e-04 1.00375511e+00 + 4.19286381e-02 1.00013395e+00 9.98247900e-01 -5.35998796e-05 -8.67130430e-06 -8.37813401e-05 9.97706732e-01 -1.52887119e-04 7.77828894e-06 3.70764997e-04 1.00419287e+00 + 4.64886564e-02 1.00013736e+00 9.98056993e-01 -5.85487696e-05 -4.77215069e-06 -9.31139680e-05 9.97448170e-01 -1.62596251e-04 4.71029267e-06 4.05703624e-04 1.00464887e+00 + 5.12386755e-02 1.00014089e+00 9.97858465e-01 -6.38067259e-05 -8.66597080e-07 -1.02683090e-04 9.97178814e-01 -1.72532196e-04 7.57670566e-07 4.41779027e-04 1.00512388e+00 + 5.61866121e-02 1.00014454e+00 9.97652036e-01 -6.94623975e-05 3.20431424e-06 -1.12436472e-04 9.96898207e-01 -1.82616767e-04 -4.07142368e-06 4.78802865e-04 1.00561867e+00 + 6.13407126e-02 1.00014832e+00 9.97437385e-01 -7.54279991e-05 7.36368938e-06 -1.22335209e-04 9.96605903e-01 -1.92824523e-04 -9.63971249e-06 5.16679729e-04 1.00613408e+00 + 6.67095674e-02 1.00015223e+00 9.97214210e-01 -8.17285911e-05 1.14029162e-05 -1.32315535e-04 9.96301410e-01 -2.03153279e-04 -1.57299138e-05 5.55472172e-04 1.00667097e+00 + 7.23021245e-02 1.00015628e+00 9.96982178e-01 -8.84432763e-05 1.53819474e-05 -1.42379879e-04 9.95984234e-01 -2.13553828e-04 -2.24094459e-05 5.95102530e-04 1.00723023e+00 + 7.81277047e-02 1.00016048e+00 9.96740891e-01 -9.56077521e-05 1.92549531e-05 -1.52547591e-04 9.95653913e-01 -2.23996200e-04 -2.96229587e-05 6.35628811e-04 1.00781278e+00 + 8.41960175e-02 1.00016483e+00 9.96489839e-01 -1.03216583e-04 2.30373399e-05 -1.62880863e-04 9.95310069e-01 -2.34588650e-04 -3.73538013e-05 6.77222474e-04 1.00841962e+00 + 9.05171766e-02 1.00016934e+00 9.96228495e-01 -1.11219010e-04 2.68172824e-05 -1.73407937e-04 9.94952301e-01 -2.45454155e-04 -4.56684375e-05 7.20050230e-04 1.00905173e+00 + 1.00393988e-01 1.00017658e+00 9.95820669e-01 -1.23836306e-04 3.22481862e-05 -1.89376671e-04 9.94394167e-01 -2.62068219e-04 -5.90023125e-05 7.86152925e-04 1.01003942e+00 + 1.10682333e-01 1.00018407e+00 9.95396554e-01 -1.36987588e-04 3.73908541e-05 -2.05480338e-04 9.93813591e-01 -2.78885529e-04 -7.31060587e-05 8.54201342e-04 1.01106826e+00 + 1.21399359e-01 1.00019182e+00 9.94955661e-01 -1.50667769e-04 4.21206147e-05 -2.21687055e-04 9.93209577e-01 -2.95970347e-04 -8.78265491e-05 9.24383834e-04 1.01213996e+00 + 1.32562927e-01 1.00019984e+00 9.94497399e-01 -1.64955716e-04 4.64689827e-05 -2.38064231e-04 9.92581177e-01 -3.13407000e-04 -1.03345563e-04 9.96722968e-04 1.01325632e+00 + 1.44191645e-01 1.00020813e+00 9.94021075e-01 -1.79930319e-04 5.05501326e-05 -2.54658986e-04 9.91927493e-01 -3.31356885e-04 -1.19810764e-04 1.07124763e-03 1.01441920e+00 + 1.56304892e-01 1.00021672e+00 9.93526040e-01 -1.95666426e-04 5.44921723e-05 -2.71436295e-04 9.91247526e-01 -3.49836582e-04 -1.37265068e-04 1.14786651e-03 1.01563053e+00 + 1.68922858e-01 1.00022560e+00 9.93011647e-01 -2.12201589e-04 5.82787669e-05 -2.88524182e-04 9.90540220e-01 -3.68743345e-04 -1.55498964e-04 1.22666291e-03 1.01689233e+00 + 1.82066573e-01 1.00023479e+00 9.92477148e-01 -2.29530093e-04 6.19303455e-05 -3.05969185e-04 9.89804567e-01 -3.88111875e-04 -1.74260087e-04 1.30795026e-03 1.01820671e+00 + 1.95757942e-01 1.00024429e+00 9.91921872e-01 -2.47660909e-04 6.54249816e-05 -3.23803663e-04 9.89039417e-01 -4.07995611e-04 -1.93597931e-04 1.39179474e-03 1.01957585e+00 + 2.10019785e-01 1.00025412e+00 9.91345186e-01 -2.66645939e-04 6.87408902e-05 -3.42000702e-04 9.88243524e-01 -4.28438562e-04 -2.13820021e-04 1.47822065e-03 1.02100204e+00 + 2.24875872e-01 1.00026429e+00 9.90746315e-01 -2.86502523e-04 7.18531705e-05 -3.60522923e-04 9.87415724e-01 -4.49407244e-04 -2.35128096e-04 1.56722767e-03 1.02248766e+00 + 2.40350962e-01 1.00027479e+00 9.90124494e-01 -3.07259538e-04 7.47357212e-05 -3.79329678e-04 9.86554781e-01 -4.70920914e-04 -2.57617802e-04 1.65888019e-03 1.02403518e+00 + 2.56470847e-01 1.00028566e+00 9.89478853e-01 -3.28904874e-04 7.73289205e-05 -3.98387239e-04 9.85659503e-01 -4.92851333e-04 -2.81233310e-04 1.75311505e-03 1.02564717e+00 + 2.73262395e-01 1.00029689e+00 9.88808495e-01 -3.51426710e-04 7.95891478e-05 -4.17649810e-04 9.84728658e-01 -5.15227551e-04 -3.05984626e-04 1.85007302e-03 1.02732634e+00 + 2.90753590e-01 1.00030849e+00 9.88112465e-01 -3.74819243e-04 8.14990535e-05 -4.37060637e-04 9.83761003e-01 -5.38122704e-04 -3.31990950e-04 1.95004267e-03 1.02907547e+00 + 3.08973585e-01 1.00032048e+00 9.87389805e-01 -3.99106530e-04 8.30745643e-05 -4.56585032e-04 9.82755236e-01 -5.61609102e-04 -3.59319924e-04 2.05333953e-03 1.03089748e+00 + 3.27952746e-01 1.00033288e+00 9.86639532e-01 -4.24429379e-04 8.43426788e-05 -4.76219795e-04 9.81710009e-01 -5.85619335e-04 -3.88004003e-04 2.15999815e-03 1.03279541e+00 + 3.47722706e-01 1.00034568e+00 9.85860592e-01 -4.50907428e-04 8.52769164e-05 -4.96030523e-04 9.80623977e-01 -6.10187086e-04 -4.18184587e-04 2.27007355e-03 1.03477242e+00 + 3.68316415e-01 1.00035891e+00 9.85051949e-01 -4.78590557e-04 8.58712253e-05 -5.15961220e-04 9.79495711e-01 -6.35274899e-04 -4.50201861e-04 2.38352978e-03 1.03683181e+00 + 3.89768194e-01 1.00037257e+00 9.84212618e-01 -5.07521181e-04 8.60299403e-05 -5.35849019e-04 9.78323659e-01 -6.60897056e-04 -4.84229640e-04 2.50018789e-03 1.03897701e+00 + 4.12113798e-01 1.00038668e+00 9.83341653e-01 -5.37729958e-04 8.55503152e-05 -5.55520564e-04 9.77106162e-01 -6.87034888e-04 -5.20170233e-04 2.61990689e-03 1.04121159e+00 + 4.35390468e-01 1.00040126e+00 9.82438076e-01 -5.69242539e-04 8.42968525e-05 -5.74878715e-04 9.75841523e-01 -7.13515322e-04 -5.57938119e-04 2.74243285e-03 1.04353928e+00 + 4.59637000e-01 1.00041631e+00 9.81500843e-01 -6.02083775e-04 8.21360492e-05 -5.93863176e-04 9.74528040e-01 -7.40200462e-04 -5.97617967e-04 2.86761346e-03 1.04596396e+00 + 4.84893803e-01 1.00043187e+00 9.80528856e-01 -6.36292220e-04 7.91032383e-05 -6.12445296e-04 9.73163991e-01 -7.67208828e-04 -6.39539193e-04 2.99583970e-03 1.04848967e+00 + 5.11202974e-01 1.00044793e+00 9.79521022e-01 -6.71892512e-04 7.53101403e-05 -6.30516821e-04 9.71747574e-01 -7.94677393e-04 -6.83905833e-04 3.12752360e-03 1.05112061e+00 + 5.38608360e-01 1.00046452e+00 9.78476237e-01 -7.08907363e-04 7.08113511e-05 -6.47989016e-04 9.70276939e-01 -8.22517601e-04 -7.30819899e-04 3.26254584e-03 1.05386118e+00 + 5.67155637e-01 1.00048166e+00 9.77393378e-01 -7.47370730e-04 6.56323127e-05 -6.64799978e-04 9.68750175e-01 -8.50615779e-04 -7.80313941e-04 3.40054401e-03 1.05671595e+00 + 5.96892384e-01 1.00049936e+00 9.76271331e-01 -7.87379799e-04 5.95184961e-05 -6.80908616e-04 9.67165302e-01 -8.79045710e-04 -8.32187254e-04 3.54116993e-03 1.05968966e+00 + 6.27868162e-01 1.00051766e+00 9.75108950e-01 -8.28993638e-04 5.22634895e-05 -6.96273117e-04 9.65520292e-01 -9.07802050e-04 -8.86526141e-04 3.68381703e-03 1.06278728e+00 + 6.60134598e-01 1.00053657e+00 9.73905027e-01 -8.72298656e-04 4.38578544e-05 -7.10793830e-04 9.63813115e-01 -9.36878465e-04 -9.43749332e-04 3.82774360e-03 1.06601397e+00 + 6.93745468e-01 1.00055611e+00 9.72658348e-01 -9.17361037e-04 3.45466602e-05 -7.24268785e-04 9.62041683e-01 -9.66014864e-04 -1.00421765e-03 3.97223618e-03 1.06937511e+00 + 7.28756791e-01 1.00057632e+00 9.71367647e-01 -9.64361159e-04 2.40335546e-05 -7.36597318e-04 9.60203908e-01 -9.94873638e-04 -1.06829173e-03 4.11668420e-03 1.07287630e+00 + 7.65226920e-01 1.00059722e+00 9.70031816e-01 -1.01333379e-03 1.21468611e-05 -7.47476869e-04 9.58297484e-01 -1.02327507e-03 -1.13632680e-03 4.26078321e-03 1.07652337e+00 + 8.03216637e-01 1.00061884e+00 9.68649705e-01 -1.06422197e-03 -1.37168203e-06 -7.56443357e-04 9.56320075e-01 -1.05108603e-03 -1.20777190e-03 4.40400145e-03 1.08032241e+00 + 8.42789259e-01 1.00064122e+00 9.67220184e-01 -1.11688812e-03 -1.60316789e-05 -7.63158388e-04 9.54269264e-01 -1.07807270e-03 -1.28193196e-03 4.54619186e-03 1.08427974e+00 + 8.84010740e-01 1.00066439e+00 9.65741931e-01 -1.17132391e-03 -3.21587952e-05 -7.67404000e-04 9.52142794e-01 -1.10415877e-03 -1.35836093e-03 4.68663976e-03 1.08840196e+00 + 9.26949783e-01 1.00068840e+00 9.64213599e-01 -1.22753082e-03 -4.98263349e-05 -7.69096293e-04 9.49938364e-01 -1.12904327e-03 -1.43802551e-03 4.82501186e-03 1.09269595e+00 + 9.71677953e-01 1.00071327e+00 9.62633984e-01 -1.28484284e-03 -6.84232571e-05 -7.67803623e-04 9.47653464e-01 -1.15304488e-03 -1.52200539e-03 4.96188638e-03 1.09716886e+00 + 1.01826980e+00 1.00073907e+00 9.61002476e-01 -1.34251678e-03 -8.74462880e-05 -7.63282000e-04 9.45284969e-01 -1.17615920e-03 -1.61097450e-03 5.09650796e-03 1.10182814e+00 + 1.06680297e+00 1.00076583e+00 9.59317939e-01 -1.40080612e-03 -1.06790146e-04 -7.55200854e-04 9.42830253e-01 -1.19865534e-03 -1.70468118e-03 5.22761202e-03 1.10668157e+00 + 1.11735835e+00 1.00079361e+00 9.57579486e-01 -1.45932417e-03 -1.26476397e-04 -7.43543283e-04 9.40286396e-01 -1.22045888e-03 -1.80396036e-03 5.35415003e-03 1.11173723e+00 + 1.17002021e+00 1.00082246e+00 9.55786084e-01 -1.51811558e-03 -1.47181890e-04 -7.28906672e-04 9.37650607e-01 -1.24166244e-03 -1.90889736e-03 5.47673172e-03 1.11700354e+00 + 1.20000000e+00 1.00083445e+00 9.54770799e-01 -1.55109051e-03 -1.59154169e-04 -7.19984587e-04 9.36154545e-01 -1.25326611e-03 -1.96915227e-03 5.54413827e-03 1.12000159e+00 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_elastic_strain_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_elastic_strain_global.txt new file mode 100644 index 0000000..5d9b14d --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_elastic_strain_global.txt @@ -0,0 +1,70 @@ + # Time Volume Ee11 Ee22 Ee33 Ee23 Ee13 Ee12 + 5.00000000e-05 1.00000156e+00 -6.52013073e-08 -5.97936310e-08 7.28502963e-07 3.45182917e-08 -3.57852082e-08 6.12148327e-10 + 2.06250000e-04 1.00000645e+00 -2.68957051e-07 -2.46650053e-07 3.00503720e-06 1.42384596e-07 -1.47612881e-07 2.52686095e-09 + 6.94531250e-04 1.00002172e+00 -9.05709931e-07 -8.30589312e-07 1.01188403e-05 4.79434009e-07 -4.97063589e-07 8.52741736e-09 + 2.22041016e-03 1.00006925e+00 -2.91225111e-06 -2.64889063e-06 3.22580272e-05 1.53065829e-06 -1.58227518e-06 2.62430111e-08 + 4.60459595e-03 1.00009813e+00 -3.97076821e-06 -5.01054952e-06 4.54759638e-05 2.43543480e-06 -2.32007153e-06 -5.48978424e-07 + 6.46724110e-03 1.00010295e+00 -3.93860268e-06 -5.36296780e-06 4.73212657e-05 2.57872457e-06 -2.35800859e-06 -9.26377382e-07 + 8.40749646e-03 1.00010584e+00 -3.97496267e-06 -5.39462816e-06 4.83007254e-05 2.61158945e-06 -2.37888128e-06 -1.13719379e-06 + 1.04285958e-02 1.00010819e+00 -4.05124642e-06 -5.38559335e-06 4.90854332e-05 2.60748018e-06 -2.41106814e-06 -1.27637843e-06 + 1.25339076e-02 1.00011031e+00 -4.13745727e-06 -5.37296772e-06 4.98009255e-05 2.59475395e-06 -2.44829089e-06 -1.39809418e-06 + 1.47269407e-02 1.00011233e+00 -4.23992464e-06 -5.35766896e-06 5.04884952e-05 2.58310242e-06 -2.48594454e-06 -1.51061843e-06 + 1.81535550e-02 1.00011532e+00 -4.41146324e-06 -5.33576067e-06 5.15140000e-05 2.56595128e-06 -2.55701787e-06 -1.65738911e-06 + 2.17229449e-02 1.00011828e+00 -4.57796769e-06 -5.32089312e-06 5.25581503e-05 2.54992556e-06 -2.64561914e-06 -1.79300897e-06 + 2.54410593e-02 1.00012127e+00 -4.73933113e-06 -5.30902712e-06 5.36379171e-05 2.53006779e-06 -2.74562321e-06 -1.92409375e-06 + 2.93140952e-02 1.00012432e+00 -4.89803093e-06 -5.30988734e-06 5.47540971e-05 2.50736021e-06 -2.84920865e-06 -2.05368859e-06 + 3.33485076e-02 1.00012744e+00 -5.05152954e-06 -5.33242791e-06 5.59110155e-05 2.48636148e-06 -2.94891290e-06 -2.17996544e-06 + 3.75510205e-02 1.00013065e+00 -5.19197970e-06 -5.38208898e-06 5.71136216e-05 2.46865091e-06 -3.04449601e-06 -2.30679572e-06 + 4.19286381e-02 1.00013395e+00 -5.32749661e-06 -5.45140627e-06 5.83623310e-05 2.45571277e-06 -3.13720758e-06 -2.43621406e-06 + 4.64886564e-02 1.00013736e+00 -5.46017167e-06 -5.53634801e-06 5.96603472e-05 2.44644077e-06 -3.23025982e-06 -2.56405701e-06 + 5.12386755e-02 1.00014089e+00 -5.59387302e-06 -5.63739263e-06 6.10076953e-05 2.43893842e-06 -3.32617874e-06 -2.68697055e-06 + 5.61866121e-02 1.00014454e+00 -5.73385182e-06 -5.74874615e-06 6.24086297e-05 2.43370473e-06 -3.42713299e-06 -2.80142179e-06 + 6.13407126e-02 1.00014832e+00 -5.87798238e-06 -5.87148445e-06 6.38648901e-05 2.43074917e-06 -3.53389034e-06 -2.91162757e-06 + 6.67095674e-02 1.00015223e+00 -6.01956587e-06 -6.01042812e-06 6.53773719e-05 2.42785445e-06 -3.64697345e-06 -3.01850026e-06 + 7.23021245e-02 1.00015628e+00 -6.16158872e-06 -6.16437060e-06 6.69439195e-05 2.42581408e-06 -3.76292995e-06 -3.12308441e-06 + 7.81277047e-02 1.00016048e+00 -6.30776886e-06 -6.33207041e-06 6.85655002e-05 2.42532320e-06 -3.87918606e-06 -3.22522159e-06 + 8.41960175e-02 1.00016483e+00 -6.45788900e-06 -6.51154316e-06 7.02454703e-05 2.42439527e-06 -3.99638215e-06 -3.32606018e-06 + 9.05171766e-02 1.00016934e+00 -6.61616744e-06 -6.69766342e-06 7.19874050e-05 2.42183742e-06 -4.11296261e-06 -3.42642018e-06 + 1.00393988e-01 1.00017658e+00 -6.86691525e-06 -6.98759658e-06 7.46834668e-05 2.42313687e-06 -4.28746784e-06 -3.57297489e-06 + 1.10682333e-01 1.00018407e+00 -7.13005577e-06 -7.29181441e-06 7.74622889e-05 2.43150666e-06 -4.46271825e-06 -3.71909046e-06 + 1.21399359e-01 1.00019182e+00 -7.40071529e-06 -7.61122370e-06 8.03242505e-05 2.44579697e-06 -4.63848697e-06 -3.87009415e-06 + 1.32562927e-01 1.00019984e+00 -7.68007824e-06 -7.94379900e-06 8.32689786e-05 2.46307545e-06 -4.81659330e-06 -4.02662794e-06 + 1.44191645e-01 1.00020813e+00 -7.96838850e-06 -8.28604890e-06 8.62960357e-05 2.48250147e-06 -4.99675667e-06 -4.18552378e-06 + 1.56304892e-01 1.00021672e+00 -8.26782005e-06 -8.63729487e-06 8.94054571e-05 2.50365329e-06 -5.17903750e-06 -4.34592454e-06 + 1.68922858e-01 1.00022560e+00 -8.57760543e-06 -9.00220377e-06 9.25993540e-05 2.52661673e-06 -5.36527099e-06 -4.50716767e-06 + 1.82066573e-01 1.00023479e+00 -8.89731634e-06 -9.38110931e-06 9.58795180e-05 2.55014502e-06 -5.55585070e-06 -4.66988337e-06 + 1.95757942e-01 1.00024429e+00 -9.22698743e-06 -9.77181672e-06 9.92467108e-05 2.57465836e-06 -5.74965404e-06 -4.83516060e-06 + 2.10019785e-01 1.00025412e+00 -9.56441475e-06 -1.01731968e-05 1.02699369e-04 2.60158433e-06 -5.94470664e-06 -5.00632529e-06 + 2.24875872e-01 1.00026429e+00 -9.90921003e-06 -1.05865815e-05 1.06238235e-04 2.63065311e-06 -6.14039279e-06 -5.18281267e-06 + 2.40350962e-01 1.00027479e+00 -1.02680897e-05 -1.10112361e-05 1.09867366e-04 2.66310519e-06 -6.33950773e-06 -5.36188242e-06 + 2.56470847e-01 1.00028566e+00 -1.06386208e-05 -1.14494785e-05 1.13584060e-04 2.69593869e-06 -6.54281044e-06 -5.54328646e-06 + 2.73262395e-01 1.00029689e+00 -1.10192911e-05 -1.19021044e-05 1.17386303e-04 2.72737418e-06 -6.75136135e-06 -5.72674164e-06 + 2.90753590e-01 1.00030849e+00 -1.14087042e-05 -1.23705585e-05 1.21273005e-04 2.75754718e-06 -6.96514256e-06 -5.91263311e-06 + 3.08973585e-01 1.00032048e+00 -1.18073277e-05 -1.28553467e-05 1.25242946e-04 2.78748640e-06 -7.18329691e-06 -6.10021788e-06 + 3.27952746e-01 1.00033288e+00 -1.22162960e-05 -1.33561070e-05 1.29294772e-04 2.81656372e-06 -7.40543363e-06 -6.28980404e-06 + 3.47722706e-01 1.00034568e+00 -1.26352549e-05 -1.38720025e-05 1.33427550e-04 2.84409260e-06 -7.63161753e-06 -6.48214744e-06 + 3.68316415e-01 1.00035891e+00 -1.30616029e-05 -1.44047163e-05 1.37639307e-04 2.87179434e-06 -7.86126034e-06 -6.67685886e-06 + 3.89768194e-01 1.00037257e+00 -1.34929014e-05 -1.49553139e-05 1.41927577e-04 2.90066331e-06 -8.09203044e-06 -6.87248474e-06 + 4.12113798e-01 1.00038668e+00 -1.39303489e-05 -1.55247210e-05 1.46289126e-04 2.92989226e-06 -8.32323143e-06 -7.06850171e-06 + 4.35390468e-01 1.00040126e+00 -1.43763679e-05 -1.61118598e-05 1.50720362e-04 2.95774279e-06 -8.55416736e-06 -7.26605016e-06 + 4.59637000e-01 1.00041631e+00 -1.48337517e-05 -1.67157898e-05 1.55217045e-04 2.98538900e-06 -8.78339744e-06 -7.46610366e-06 + 4.84893803e-01 1.00043187e+00 -1.53026365e-05 -1.73346415e-05 1.59775619e-04 3.01413818e-06 -9.00988011e-06 -7.66914984e-06 + 5.11202974e-01 1.00044793e+00 -1.57811209e-05 -1.79681681e-05 1.64393778e-04 3.04379125e-06 -9.23674227e-06 -7.87337174e-06 + 5.38608360e-01 1.00046452e+00 -1.62673184e-05 -1.86162493e-05 1.69068577e-04 3.07309395e-06 -9.46468047e-06 -8.07684503e-06 + 5.67155637e-01 1.00048166e+00 -1.67601935e-05 -1.92781446e-05 1.73796058e-04 3.09995271e-06 -9.69154255e-06 -8.28026806e-06 + 5.96892384e-01 1.00049936e+00 -1.72610746e-05 -1.99530704e-05 1.78569697e-04 3.12484836e-06 -9.91410426e-06 -8.48442595e-06 + 6.27868162e-01 1.00051766e+00 -1.77684813e-05 -2.06424745e-05 1.83383391e-04 3.14863529e-06 -1.01296972e-05 -8.69034434e-06 + 6.60134598e-01 1.00053657e+00 -1.82801663e-05 -2.13467144e-05 1.88231626e-04 3.17297229e-06 -1.03384731e-05 -8.89739925e-06 + 6.93745468e-01 1.00055611e+00 -1.87957068e-05 -2.20650686e-05 1.93108162e-04 3.19985787e-06 -1.05402343e-05 -9.10404549e-06 + 7.28756791e-01 1.00057632e+00 -1.93125283e-05 -2.27980968e-05 1.98002290e-04 3.22831832e-06 -1.07343079e-05 -9.30970943e-06 + 7.65226920e-01 1.00059722e+00 -1.98296633e-05 -2.35444854e-05 2.02907345e-04 3.25420190e-06 -1.09207311e-05 -9.51287014e-06 + 8.03216637e-01 1.00061884e+00 -2.03489836e-05 -2.42996311e-05 2.07820331e-04 3.27683872e-06 -1.11063556e-05 -9.70974864e-06 + 8.42789259e-01 1.00064122e+00 -2.08652143e-05 -2.50721241e-05 2.12737291e-04 3.29511289e-06 -1.12962495e-05 -9.89818401e-06 + 8.84010740e-01 1.00066439e+00 -2.13735081e-05 -2.58589221e-05 2.17650907e-04 3.31229075e-06 -1.14782428e-05 -1.00800696e-05 + 9.26949783e-01 1.00068840e+00 -2.18751499e-05 -2.66539593e-05 2.22552054e-04 3.32928744e-06 -1.16449055e-05 -1.02533782e-05 + 9.71677953e-01 1.00071327e+00 -2.23680159e-05 -2.74540657e-05 2.27435271e-04 3.34078650e-06 -1.17955189e-05 -1.04158891e-05 + 1.01826980e+00 1.00073907e+00 -2.28468808e-05 -2.82540412e-05 2.32291225e-04 3.35153705e-06 -1.19316959e-05 -1.05634402e-05 + 1.06680297e+00 1.00076583e+00 -2.33209479e-05 -2.90458218e-05 2.37120193e-04 3.37939420e-06 -1.20569176e-05 -1.06974281e-05 + 1.11735835e+00 1.00079361e+00 -2.37948264e-05 -2.98294887e-05 2.41905898e-04 3.42098560e-06 -1.21715363e-05 -1.08153447e-05 + 1.17002021e+00 1.00082246e+00 -2.42663437e-05 -3.06040361e-05 2.46630037e-04 3.46749877e-06 -1.22687975e-05 -1.09233698e-05 + 1.20000000e+00 1.00083445e+00 -2.45324797e-05 -3.10324592e-05 2.49236362e-04 3.49265813e-06 -1.23160767e-05 -1.09794436e-05 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_elastic_strain_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_elastic_strain_region_default_1.txt new file mode 100644 index 0000000..5d9b14d --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_elastic_strain_region_default_1.txt @@ -0,0 +1,70 @@ + # Time Volume Ee11 Ee22 Ee33 Ee23 Ee13 Ee12 + 5.00000000e-05 1.00000156e+00 -6.52013073e-08 -5.97936310e-08 7.28502963e-07 3.45182917e-08 -3.57852082e-08 6.12148327e-10 + 2.06250000e-04 1.00000645e+00 -2.68957051e-07 -2.46650053e-07 3.00503720e-06 1.42384596e-07 -1.47612881e-07 2.52686095e-09 + 6.94531250e-04 1.00002172e+00 -9.05709931e-07 -8.30589312e-07 1.01188403e-05 4.79434009e-07 -4.97063589e-07 8.52741736e-09 + 2.22041016e-03 1.00006925e+00 -2.91225111e-06 -2.64889063e-06 3.22580272e-05 1.53065829e-06 -1.58227518e-06 2.62430111e-08 + 4.60459595e-03 1.00009813e+00 -3.97076821e-06 -5.01054952e-06 4.54759638e-05 2.43543480e-06 -2.32007153e-06 -5.48978424e-07 + 6.46724110e-03 1.00010295e+00 -3.93860268e-06 -5.36296780e-06 4.73212657e-05 2.57872457e-06 -2.35800859e-06 -9.26377382e-07 + 8.40749646e-03 1.00010584e+00 -3.97496267e-06 -5.39462816e-06 4.83007254e-05 2.61158945e-06 -2.37888128e-06 -1.13719379e-06 + 1.04285958e-02 1.00010819e+00 -4.05124642e-06 -5.38559335e-06 4.90854332e-05 2.60748018e-06 -2.41106814e-06 -1.27637843e-06 + 1.25339076e-02 1.00011031e+00 -4.13745727e-06 -5.37296772e-06 4.98009255e-05 2.59475395e-06 -2.44829089e-06 -1.39809418e-06 + 1.47269407e-02 1.00011233e+00 -4.23992464e-06 -5.35766896e-06 5.04884952e-05 2.58310242e-06 -2.48594454e-06 -1.51061843e-06 + 1.81535550e-02 1.00011532e+00 -4.41146324e-06 -5.33576067e-06 5.15140000e-05 2.56595128e-06 -2.55701787e-06 -1.65738911e-06 + 2.17229449e-02 1.00011828e+00 -4.57796769e-06 -5.32089312e-06 5.25581503e-05 2.54992556e-06 -2.64561914e-06 -1.79300897e-06 + 2.54410593e-02 1.00012127e+00 -4.73933113e-06 -5.30902712e-06 5.36379171e-05 2.53006779e-06 -2.74562321e-06 -1.92409375e-06 + 2.93140952e-02 1.00012432e+00 -4.89803093e-06 -5.30988734e-06 5.47540971e-05 2.50736021e-06 -2.84920865e-06 -2.05368859e-06 + 3.33485076e-02 1.00012744e+00 -5.05152954e-06 -5.33242791e-06 5.59110155e-05 2.48636148e-06 -2.94891290e-06 -2.17996544e-06 + 3.75510205e-02 1.00013065e+00 -5.19197970e-06 -5.38208898e-06 5.71136216e-05 2.46865091e-06 -3.04449601e-06 -2.30679572e-06 + 4.19286381e-02 1.00013395e+00 -5.32749661e-06 -5.45140627e-06 5.83623310e-05 2.45571277e-06 -3.13720758e-06 -2.43621406e-06 + 4.64886564e-02 1.00013736e+00 -5.46017167e-06 -5.53634801e-06 5.96603472e-05 2.44644077e-06 -3.23025982e-06 -2.56405701e-06 + 5.12386755e-02 1.00014089e+00 -5.59387302e-06 -5.63739263e-06 6.10076953e-05 2.43893842e-06 -3.32617874e-06 -2.68697055e-06 + 5.61866121e-02 1.00014454e+00 -5.73385182e-06 -5.74874615e-06 6.24086297e-05 2.43370473e-06 -3.42713299e-06 -2.80142179e-06 + 6.13407126e-02 1.00014832e+00 -5.87798238e-06 -5.87148445e-06 6.38648901e-05 2.43074917e-06 -3.53389034e-06 -2.91162757e-06 + 6.67095674e-02 1.00015223e+00 -6.01956587e-06 -6.01042812e-06 6.53773719e-05 2.42785445e-06 -3.64697345e-06 -3.01850026e-06 + 7.23021245e-02 1.00015628e+00 -6.16158872e-06 -6.16437060e-06 6.69439195e-05 2.42581408e-06 -3.76292995e-06 -3.12308441e-06 + 7.81277047e-02 1.00016048e+00 -6.30776886e-06 -6.33207041e-06 6.85655002e-05 2.42532320e-06 -3.87918606e-06 -3.22522159e-06 + 8.41960175e-02 1.00016483e+00 -6.45788900e-06 -6.51154316e-06 7.02454703e-05 2.42439527e-06 -3.99638215e-06 -3.32606018e-06 + 9.05171766e-02 1.00016934e+00 -6.61616744e-06 -6.69766342e-06 7.19874050e-05 2.42183742e-06 -4.11296261e-06 -3.42642018e-06 + 1.00393988e-01 1.00017658e+00 -6.86691525e-06 -6.98759658e-06 7.46834668e-05 2.42313687e-06 -4.28746784e-06 -3.57297489e-06 + 1.10682333e-01 1.00018407e+00 -7.13005577e-06 -7.29181441e-06 7.74622889e-05 2.43150666e-06 -4.46271825e-06 -3.71909046e-06 + 1.21399359e-01 1.00019182e+00 -7.40071529e-06 -7.61122370e-06 8.03242505e-05 2.44579697e-06 -4.63848697e-06 -3.87009415e-06 + 1.32562927e-01 1.00019984e+00 -7.68007824e-06 -7.94379900e-06 8.32689786e-05 2.46307545e-06 -4.81659330e-06 -4.02662794e-06 + 1.44191645e-01 1.00020813e+00 -7.96838850e-06 -8.28604890e-06 8.62960357e-05 2.48250147e-06 -4.99675667e-06 -4.18552378e-06 + 1.56304892e-01 1.00021672e+00 -8.26782005e-06 -8.63729487e-06 8.94054571e-05 2.50365329e-06 -5.17903750e-06 -4.34592454e-06 + 1.68922858e-01 1.00022560e+00 -8.57760543e-06 -9.00220377e-06 9.25993540e-05 2.52661673e-06 -5.36527099e-06 -4.50716767e-06 + 1.82066573e-01 1.00023479e+00 -8.89731634e-06 -9.38110931e-06 9.58795180e-05 2.55014502e-06 -5.55585070e-06 -4.66988337e-06 + 1.95757942e-01 1.00024429e+00 -9.22698743e-06 -9.77181672e-06 9.92467108e-05 2.57465836e-06 -5.74965404e-06 -4.83516060e-06 + 2.10019785e-01 1.00025412e+00 -9.56441475e-06 -1.01731968e-05 1.02699369e-04 2.60158433e-06 -5.94470664e-06 -5.00632529e-06 + 2.24875872e-01 1.00026429e+00 -9.90921003e-06 -1.05865815e-05 1.06238235e-04 2.63065311e-06 -6.14039279e-06 -5.18281267e-06 + 2.40350962e-01 1.00027479e+00 -1.02680897e-05 -1.10112361e-05 1.09867366e-04 2.66310519e-06 -6.33950773e-06 -5.36188242e-06 + 2.56470847e-01 1.00028566e+00 -1.06386208e-05 -1.14494785e-05 1.13584060e-04 2.69593869e-06 -6.54281044e-06 -5.54328646e-06 + 2.73262395e-01 1.00029689e+00 -1.10192911e-05 -1.19021044e-05 1.17386303e-04 2.72737418e-06 -6.75136135e-06 -5.72674164e-06 + 2.90753590e-01 1.00030849e+00 -1.14087042e-05 -1.23705585e-05 1.21273005e-04 2.75754718e-06 -6.96514256e-06 -5.91263311e-06 + 3.08973585e-01 1.00032048e+00 -1.18073277e-05 -1.28553467e-05 1.25242946e-04 2.78748640e-06 -7.18329691e-06 -6.10021788e-06 + 3.27952746e-01 1.00033288e+00 -1.22162960e-05 -1.33561070e-05 1.29294772e-04 2.81656372e-06 -7.40543363e-06 -6.28980404e-06 + 3.47722706e-01 1.00034568e+00 -1.26352549e-05 -1.38720025e-05 1.33427550e-04 2.84409260e-06 -7.63161753e-06 -6.48214744e-06 + 3.68316415e-01 1.00035891e+00 -1.30616029e-05 -1.44047163e-05 1.37639307e-04 2.87179434e-06 -7.86126034e-06 -6.67685886e-06 + 3.89768194e-01 1.00037257e+00 -1.34929014e-05 -1.49553139e-05 1.41927577e-04 2.90066331e-06 -8.09203044e-06 -6.87248474e-06 + 4.12113798e-01 1.00038668e+00 -1.39303489e-05 -1.55247210e-05 1.46289126e-04 2.92989226e-06 -8.32323143e-06 -7.06850171e-06 + 4.35390468e-01 1.00040126e+00 -1.43763679e-05 -1.61118598e-05 1.50720362e-04 2.95774279e-06 -8.55416736e-06 -7.26605016e-06 + 4.59637000e-01 1.00041631e+00 -1.48337517e-05 -1.67157898e-05 1.55217045e-04 2.98538900e-06 -8.78339744e-06 -7.46610366e-06 + 4.84893803e-01 1.00043187e+00 -1.53026365e-05 -1.73346415e-05 1.59775619e-04 3.01413818e-06 -9.00988011e-06 -7.66914984e-06 + 5.11202974e-01 1.00044793e+00 -1.57811209e-05 -1.79681681e-05 1.64393778e-04 3.04379125e-06 -9.23674227e-06 -7.87337174e-06 + 5.38608360e-01 1.00046452e+00 -1.62673184e-05 -1.86162493e-05 1.69068577e-04 3.07309395e-06 -9.46468047e-06 -8.07684503e-06 + 5.67155637e-01 1.00048166e+00 -1.67601935e-05 -1.92781446e-05 1.73796058e-04 3.09995271e-06 -9.69154255e-06 -8.28026806e-06 + 5.96892384e-01 1.00049936e+00 -1.72610746e-05 -1.99530704e-05 1.78569697e-04 3.12484836e-06 -9.91410426e-06 -8.48442595e-06 + 6.27868162e-01 1.00051766e+00 -1.77684813e-05 -2.06424745e-05 1.83383391e-04 3.14863529e-06 -1.01296972e-05 -8.69034434e-06 + 6.60134598e-01 1.00053657e+00 -1.82801663e-05 -2.13467144e-05 1.88231626e-04 3.17297229e-06 -1.03384731e-05 -8.89739925e-06 + 6.93745468e-01 1.00055611e+00 -1.87957068e-05 -2.20650686e-05 1.93108162e-04 3.19985787e-06 -1.05402343e-05 -9.10404549e-06 + 7.28756791e-01 1.00057632e+00 -1.93125283e-05 -2.27980968e-05 1.98002290e-04 3.22831832e-06 -1.07343079e-05 -9.30970943e-06 + 7.65226920e-01 1.00059722e+00 -1.98296633e-05 -2.35444854e-05 2.02907345e-04 3.25420190e-06 -1.09207311e-05 -9.51287014e-06 + 8.03216637e-01 1.00061884e+00 -2.03489836e-05 -2.42996311e-05 2.07820331e-04 3.27683872e-06 -1.11063556e-05 -9.70974864e-06 + 8.42789259e-01 1.00064122e+00 -2.08652143e-05 -2.50721241e-05 2.12737291e-04 3.29511289e-06 -1.12962495e-05 -9.89818401e-06 + 8.84010740e-01 1.00066439e+00 -2.13735081e-05 -2.58589221e-05 2.17650907e-04 3.31229075e-06 -1.14782428e-05 -1.00800696e-05 + 9.26949783e-01 1.00068840e+00 -2.18751499e-05 -2.66539593e-05 2.22552054e-04 3.32928744e-06 -1.16449055e-05 -1.02533782e-05 + 9.71677953e-01 1.00071327e+00 -2.23680159e-05 -2.74540657e-05 2.27435271e-04 3.34078650e-06 -1.17955189e-05 -1.04158891e-05 + 1.01826980e+00 1.00073907e+00 -2.28468808e-05 -2.82540412e-05 2.32291225e-04 3.35153705e-06 -1.19316959e-05 -1.05634402e-05 + 1.06680297e+00 1.00076583e+00 -2.33209479e-05 -2.90458218e-05 2.37120193e-04 3.37939420e-06 -1.20569176e-05 -1.06974281e-05 + 1.11735835e+00 1.00079361e+00 -2.37948264e-05 -2.98294887e-05 2.41905898e-04 3.42098560e-06 -1.21715363e-05 -1.08153447e-05 + 1.17002021e+00 1.00082246e+00 -2.42663437e-05 -3.06040361e-05 2.46630037e-04 3.46749877e-06 -1.22687975e-05 -1.09233698e-05 + 1.20000000e+00 1.00083445e+00 -2.45324797e-05 -3.10324592e-05 2.49236362e-04 3.49265813e-06 -1.23160767e-05 -1.09794436e-05 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_eq_pl_strain_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_eq_pl_strain_global.txt new file mode 100644 index 0000000..4dd44a3 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_eq_pl_strain_global.txt @@ -0,0 +1,70 @@ + # Time Volume Equiv_Plastic_Stra + 5.00000000e-05 1.00000156e+00 0.00000000e+00 + 2.06250000e-04 1.00000645e+00 4.38622126e-57 + 6.94531250e-04 1.00002172e+00 3.17095312e-30 + 2.22041016e-03 1.00006925e+00 6.03269776e-07 + 4.60459595e-03 1.00009813e+00 1.71114334e-04 + 6.46724110e-03 1.00010295e+00 3.69825112e-04 + 8.40749646e-03 1.00010584e+00 5.80325020e-04 + 1.04285958e-02 1.00010819e+00 7.98940539e-04 + 1.25339076e-02 1.00011031e+00 1.02571369e-03 + 1.47269407e-02 1.00011233e+00 1.26106867e-03 + 1.81535550e-02 1.00011532e+00 1.62745955e-03 + 2.17229449e-02 1.00011828e+00 2.00797549e-03 + 2.54410593e-02 1.00012127e+00 2.40336026e-03 + 2.93140952e-02 1.00012432e+00 2.81425795e-03 + 3.33485076e-02 1.00012744e+00 3.24135201e-03 + 3.75510205e-02 1.00013065e+00 3.68540814e-03 + 4.19286381e-02 1.00013395e+00 4.14716651e-03 + 4.64886564e-02 1.00013736e+00 4.62738735e-03 + 5.12386755e-02 1.00014089e+00 5.12684736e-03 + 5.61866121e-02 1.00014454e+00 5.64632501e-03 + 6.13407126e-02 1.00014832e+00 6.18669912e-03 + 6.67095674e-02 1.00015223e+00 6.74885130e-03 + 7.23021245e-02 1.00015628e+00 7.33367665e-03 + 7.81277047e-02 1.00016048e+00 7.94208419e-03 + 8.41960175e-02 1.00016483e+00 8.57504947e-03 + 9.05171766e-02 1.00016934e+00 9.23360207e-03 + 1.00393988e-01 1.00017658e+00 1.02609318e-02 + 1.10682333e-01 1.00018407e+00 1.13294070e-02 + 1.21399359e-01 1.00019182e+00 1.24406791e-02 + 1.32562927e-01 1.00019984e+00 1.35964806e-02 + 1.44191645e-01 1.00020813e+00 1.47986375e-02 + 1.56304892e-01 1.00021672e+00 1.60489992e-02 + 1.68922858e-01 1.00022560e+00 1.73495018e-02 + 1.82066573e-01 1.00023479e+00 1.87021746e-02 + 1.95757942e-01 1.00024429e+00 2.01090552e-02 + 2.10019785e-01 1.00025412e+00 2.15722458e-02 + 2.24875872e-01 1.00026429e+00 2.30939421e-02 + 2.40350962e-01 1.00027479e+00 2.46764107e-02 + 2.56470847e-01 1.00028566e+00 2.63220429e-02 + 2.73262395e-01 1.00029689e+00 2.80332955e-02 + 2.90753590e-01 1.00030849e+00 2.98126717e-02 + 3.08973585e-01 1.00032048e+00 3.16627716e-02 + 3.27952746e-01 1.00033288e+00 3.35862965e-02 + 3.47722706e-01 1.00034568e+00 3.55860564e-02 + 3.68316415e-01 1.00035891e+00 3.76649095e-02 + 3.89768194e-01 1.00037257e+00 3.98257771e-02 + 4.12113798e-01 1.00038668e+00 4.20717141e-02 + 4.35390468e-01 1.00040126e+00 4.44059177e-02 + 4.59637000e-01 1.00041631e+00 4.68316482e-02 + 4.84893803e-01 1.00043187e+00 4.93522507e-02 + 5.11202974e-01 1.00044793e+00 5.19711845e-02 + 5.38608360e-01 1.00046452e+00 5.46919997e-02 + 5.67155637e-01 1.00048166e+00 5.75183670e-02 + 5.96892384e-01 1.00049936e+00 6.04540547e-02 + 6.27868162e-01 1.00051766e+00 6.35029488e-02 + 6.60134598e-01 1.00053657e+00 6.66690118e-02 + 6.93745468e-01 1.00055611e+00 6.99562731e-02 + 7.28756791e-01 1.00057632e+00 7.33689565e-02 + 7.65226920e-01 1.00059722e+00 7.69114060e-02 + 8.03216637e-01 1.00061884e+00 8.05880251e-02 + 8.42789259e-01 1.00064122e+00 8.44032998e-02 + 8.84010740e-01 1.00066439e+00 8.83619773e-02 + 9.26949783e-01 1.00068840e+00 9.24690430e-02 + 9.71677953e-01 1.00071327e+00 9.67294710e-02 + 1.01826980e+00 1.00073907e+00 1.01148174e-01 + 1.06680297e+00 1.00076583e+00 1.05730289e-01 + 1.11735835e+00 1.00079361e+00 1.10481071e-01 + 1.17002021e+00 1.00082246e+00 1.15405930e-01 + 1.20000000e+00 1.00083445e+00 1.18202002e-01 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_eq_pl_strain_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_eq_pl_strain_region_default_1.txt new file mode 100644 index 0000000..4dd44a3 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_eq_pl_strain_region_default_1.txt @@ -0,0 +1,70 @@ + # Time Volume Equiv_Plastic_Stra + 5.00000000e-05 1.00000156e+00 0.00000000e+00 + 2.06250000e-04 1.00000645e+00 4.38622126e-57 + 6.94531250e-04 1.00002172e+00 3.17095312e-30 + 2.22041016e-03 1.00006925e+00 6.03269776e-07 + 4.60459595e-03 1.00009813e+00 1.71114334e-04 + 6.46724110e-03 1.00010295e+00 3.69825112e-04 + 8.40749646e-03 1.00010584e+00 5.80325020e-04 + 1.04285958e-02 1.00010819e+00 7.98940539e-04 + 1.25339076e-02 1.00011031e+00 1.02571369e-03 + 1.47269407e-02 1.00011233e+00 1.26106867e-03 + 1.81535550e-02 1.00011532e+00 1.62745955e-03 + 2.17229449e-02 1.00011828e+00 2.00797549e-03 + 2.54410593e-02 1.00012127e+00 2.40336026e-03 + 2.93140952e-02 1.00012432e+00 2.81425795e-03 + 3.33485076e-02 1.00012744e+00 3.24135201e-03 + 3.75510205e-02 1.00013065e+00 3.68540814e-03 + 4.19286381e-02 1.00013395e+00 4.14716651e-03 + 4.64886564e-02 1.00013736e+00 4.62738735e-03 + 5.12386755e-02 1.00014089e+00 5.12684736e-03 + 5.61866121e-02 1.00014454e+00 5.64632501e-03 + 6.13407126e-02 1.00014832e+00 6.18669912e-03 + 6.67095674e-02 1.00015223e+00 6.74885130e-03 + 7.23021245e-02 1.00015628e+00 7.33367665e-03 + 7.81277047e-02 1.00016048e+00 7.94208419e-03 + 8.41960175e-02 1.00016483e+00 8.57504947e-03 + 9.05171766e-02 1.00016934e+00 9.23360207e-03 + 1.00393988e-01 1.00017658e+00 1.02609318e-02 + 1.10682333e-01 1.00018407e+00 1.13294070e-02 + 1.21399359e-01 1.00019182e+00 1.24406791e-02 + 1.32562927e-01 1.00019984e+00 1.35964806e-02 + 1.44191645e-01 1.00020813e+00 1.47986375e-02 + 1.56304892e-01 1.00021672e+00 1.60489992e-02 + 1.68922858e-01 1.00022560e+00 1.73495018e-02 + 1.82066573e-01 1.00023479e+00 1.87021746e-02 + 1.95757942e-01 1.00024429e+00 2.01090552e-02 + 2.10019785e-01 1.00025412e+00 2.15722458e-02 + 2.24875872e-01 1.00026429e+00 2.30939421e-02 + 2.40350962e-01 1.00027479e+00 2.46764107e-02 + 2.56470847e-01 1.00028566e+00 2.63220429e-02 + 2.73262395e-01 1.00029689e+00 2.80332955e-02 + 2.90753590e-01 1.00030849e+00 2.98126717e-02 + 3.08973585e-01 1.00032048e+00 3.16627716e-02 + 3.27952746e-01 1.00033288e+00 3.35862965e-02 + 3.47722706e-01 1.00034568e+00 3.55860564e-02 + 3.68316415e-01 1.00035891e+00 3.76649095e-02 + 3.89768194e-01 1.00037257e+00 3.98257771e-02 + 4.12113798e-01 1.00038668e+00 4.20717141e-02 + 4.35390468e-01 1.00040126e+00 4.44059177e-02 + 4.59637000e-01 1.00041631e+00 4.68316482e-02 + 4.84893803e-01 1.00043187e+00 4.93522507e-02 + 5.11202974e-01 1.00044793e+00 5.19711845e-02 + 5.38608360e-01 1.00046452e+00 5.46919997e-02 + 5.67155637e-01 1.00048166e+00 5.75183670e-02 + 5.96892384e-01 1.00049936e+00 6.04540547e-02 + 6.27868162e-01 1.00051766e+00 6.35029488e-02 + 6.60134598e-01 1.00053657e+00 6.66690118e-02 + 6.93745468e-01 1.00055611e+00 6.99562731e-02 + 7.28756791e-01 1.00057632e+00 7.33689565e-02 + 7.65226920e-01 1.00059722e+00 7.69114060e-02 + 8.03216637e-01 1.00061884e+00 8.05880251e-02 + 8.42789259e-01 1.00064122e+00 8.44032998e-02 + 8.84010740e-01 1.00066439e+00 8.83619773e-02 + 9.26949783e-01 1.00068840e+00 9.24690430e-02 + 9.71677953e-01 1.00071327e+00 9.67294710e-02 + 1.01826980e+00 1.00073907e+00 1.01148174e-01 + 1.06680297e+00 1.00076583e+00 1.05730289e-01 + 1.11735835e+00 1.00079361e+00 1.10481071e-01 + 1.17002021e+00 1.00082246e+00 1.15405930e-01 + 1.20000000e+00 1.00083445e+00 1.18202002e-01 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_global copy.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_global copy.txt new file mode 100644 index 0000000..a460180 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_global copy.txt @@ -0,0 +1,63 @@ + # Time Volume E11 E22 E33 E23 E13 E12 + 5.00000000e-05 9.99998436e-01 1.64988818e-06 1.78646132e-06 -5.00003741e-06 -6.60483513e-08 1.17311770e-07 1.37067237e-08 + 2.06250000e-04 9.99993550e-01 6.80584388e-06 7.36920606e-06 -2.06256366e-05 -2.72449396e-07 4.83917359e-07 5.65401921e-08 + 6.94531250e-04 9.99978280e-01 2.29187441e-05 2.48158022e-05 -6.94603445e-05 -9.17452130e-07 1.62962140e-06 1.90394360e-07 + 2.22041016e-03 9.99930760e-01 7.33679176e-05 7.94443440e-05 -2.22114816e-04 -2.94974797e-06 5.19644768e-06 5.72486145e-07 + 4.60459595e-03 9.99901977e-01 1.68899474e-04 1.93577899e-04 -4.60776811e-04 -5.62016524e-06 9.51691877e-06 3.40286583e-06 + 6.46724110e-03 9.99897218e-01 2.49034998e-04 2.94976224e-04 -6.47350115e-04 -9.43738927e-06 1.08910941e-05 7.32238953e-06 + 8.40749646e-03 9.99894393e-01 3.33020444e-04 4.02223178e-04 -8.41808299e-04 -1.38512416e-05 1.14259134e-05 1.15458055e-05 + 1.04285958e-02 9.99892111e-01 4.20132868e-04 5.14976013e-04 -1.04448956e-03 -1.89887617e-05 1.12687120e-05 1.55545433e-05 + 1.25339076e-02 9.99890059e-01 5.10493631e-04 6.33135323e-04 -1.25574700e-03 -2.47468438e-05 1.06479357e-05 1.94729447e-05 + 1.47269407e-02 9.99888107e-01 6.04317507e-04 7.56709443e-04 -1.47594923e-03 -3.10461514e-05 9.72456781e-06 2.34016351e-05 + 1.81535550e-02 9.99885307e-01 7.50623904e-04 9.50343344e-04 -1.82030633e-03 -4.11720510e-05 8.12818229e-06 2.92586016e-05 + 2.17229449e-02 9.99882544e-01 9.02984872e-04 1.15224820e-03 -2.17938938e-03 -5.16890002e-05 6.50433978e-06 3.54070415e-05 + 2.54410593e-02 9.99879766e-01 1.06142886e-03 1.36294017e-03 -2.55384462e-03 -6.25278123e-05 4.88199771e-06 4.17902193e-05 + 2.93140952e-02 9.99876947e-01 1.22611402e-03 1.58285412e-03 -2.94434813e-03 -7.37498519e-05 3.30297126e-06 4.83740453e-05 + 3.33485076e-02 9.99874073e-01 1.39722209e-03 1.81244119e-03 -3.35160724e-03 -8.53593441e-05 1.88331987e-06 5.51113877e-05 + 3.75510205e-02 9.99871132e-01 1.57513141e-03 2.05198244e-03 -3.77636215e-03 -9.73939919e-05 6.36400555e-07 6.19716223e-05 + 4.19286381e-02 9.99868113e-01 1.76021473e-03 2.30179665e-03 -4.21938758e-03 -1.09818030e-04 -3.43886591e-07 6.89880816e-05 + 4.64886564e-02 9.99865009e-01 1.95280823e-03 2.56227204e-03 -4.68149461e-03 -1.22696171e-04 -8.96585763e-07 7.62346446e-05 + 5.12386755e-02 9.99861816e-01 2.15324870e-03 2.83382651e-03 -5.16353251e-03 -1.36095081e-04 -1.03170550e-06 8.37700287e-05 + 5.61866121e-02 9.99858527e-01 2.36187170e-03 3.11691298e-03 -5.66639064e-03 -1.49958012e-04 -8.38338243e-07 9.15489732e-05 + 6.13407126e-02 9.99855139e-01 2.57902489e-03 3.41200460e-03 -6.19100061e-03 -1.64277955e-04 -3.54069895e-07 9.95780080e-05 + 6.93939948e-02 9.99850095e-01 2.91807251e-03 3.87360640e-03 -7.01234073e-03 -1.86392962e-04 1.01381140e-06 1.12080610e-04 + 7.77828304e-02 9.99844925e-01 3.27097771e-03 4.35482036e-03 -7.87003237e-03 -2.09153135e-04 3.03434565e-06 1.25151440e-04 + 8.65212008e-02 9.99839626e-01 3.63858934e-03 4.85619407e-03 -8.76577907e-03 -2.32669795e-04 5.57308718e-06 1.38829441e-04 + 9.56236699e-02 9.99834196e-01 4.02150860e-03 5.37858388e-03 -9.70137257e-03 -2.57031680e-04 8.55485420e-06 1.53101792e-04 + 1.05105409e-01 9.99828631e-01 4.42036746e-03 5.92287659e-03 -1.06786982e-02 -2.82433020e-04 1.19628991e-05 1.67866111e-04 + 1.14982220e-01 9.99822932e-01 4.83597567e-03 6.48984672e-03 -1.16997402e-02 -3.08936084e-04 1.57264823e-05 1.83044054e-04 + 1.25270565e-01 9.99817099e-01 5.26899354e-03 7.08048558e-03 -1.27665878e-02 -3.36626964e-04 1.97977643e-05 1.98585132e-04 + 1.35987591e-01 9.99811134e-01 5.72008443e-03 7.69584987e-03 -1.38814419e-02 -3.65563514e-04 2.41527029e-05 2.14568980e-04 + 1.47151159e-01 9.99805040e-01 6.19000965e-03 8.33697112e-03 -1.50466218e-02 -3.95791634e-04 2.87826854e-05 2.31152569e-04 + 1.58779877e-01 9.99798818e-01 6.67960559e-03 9.00488170e-03 -1.62645731e-02 -4.27329217e-04 3.36501161e-05 2.48444507e-04 + 1.70893124e-01 9.99792474e-01 7.18973148e-03 9.70067025e-03 -1.75378754e-02 -4.60200587e-04 3.87621057e-05 2.66431295e-04 + 1.83511090e-01 9.99786013e-01 7.72119068e-03 1.04255620e-02 -1.88692512e-02 -4.94424703e-04 4.41296469e-05 2.85143647e-04 + 1.96654805e-01 9.99779441e-01 8.27487587e-03 1.11807787e-02 -2.02615754e-02 -5.30070592e-04 4.97622477e-05 3.04583437e-04 + 2.17191859e-01 9.99770658e-01 9.14052827e-03 1.23618305e-02 -2.24483359e-02 -5.85899906e-04 5.87336957e-05 3.34698897e-04 + 2.38584623e-01 9.99761924e-01 1.00425782e-02 1.35922824e-02 -2.47409155e-02 -6.44319477e-04 6.82660459e-05 3.65677486e-04 + 2.60868753e-01 9.99753272e-01 1.09826043e-02 1.48741634e-02 -2.71451152e-02 -7.05674509e-04 7.84127870e-05 3.97610463e-04 + 2.84081388e-01 9.99744740e-01 1.19620886e-02 1.62097493e-02 -2.96671157e-02 -7.70095129e-04 8.94242339e-05 4.30511962e-04 + 3.08261217e-01 9.99736370e-01 1.29828278e-02 1.76011636e-02 -3.23135085e-02 -8.37696053e-04 1.01368787e-04 4.64301283e-04 + 3.33448538e-01 9.99728205e-01 1.40466883e-02 1.90506239e-02 -3.50913290e-02 -9.08567571e-04 1.14191813e-04 4.98979890e-04 + 3.59685331e-01 9.99720297e-01 1.51555983e-02 2.05604580e-02 -3.80080941e-02 -9.82882301e-04 1.27973567e-04 5.34590395e-04 + 3.87015323e-01 9.99712700e-01 1.63115028e-02 2.21331555e-02 -4.10718423e-02 -1.06084988e-03 1.42687637e-04 5.71048348e-04 + 4.15484065e-01 9.99705477e-01 1.75164741e-02 2.37712644e-02 -4.42911791e-02 -1.14270992e-03 1.58132558e-04 6.08178201e-04 + 4.45139005e-01 9.99698696e-01 1.87723549e-02 2.54777486e-02 -4.76753252e-02 -1.22865889e-03 1.73966824e-04 6.45881378e-04 + 4.76029568e-01 9.99692430e-01 2.00812334e-02 2.72555314e-02 -5.12341718e-02 -1.31886051e-03 1.89968202e-04 6.83836572e-04 + 5.08207237e-01 9.99686759e-01 2.14452422e-02 2.91077037e-02 -5.49783412e-02 -1.41360923e-03 2.05970334e-04 7.21734191e-04 + 5.41725642e-01 9.99681773e-01 2.28666025e-02 3.10374870e-02 -5.89192546e-02 -1.51328920e-03 2.21846498e-04 7.59612616e-04 + 5.76640647e-01 9.99677572e-01 2.43479107e-02 3.30479570e-02 -6.30692067e-02 -1.61824184e-03 2.37723039e-04 7.97706137e-04 + 6.13010445e-01 9.99674261e-01 2.58917450e-02 3.51424307e-02 -6.74414484e-02 -1.72890072e-03 2.53935670e-04 8.35860337e-04 + 6.50895651e-01 9.99671957e-01 2.75001860e-02 3.73249453e-02 -7.20502796e-02 -1.84571937e-03 2.70855369e-04 8.73982930e-04 + 6.90359406e-01 9.99670788e-01 2.91759086e-02 3.95992216e-02 -7.69111493e-02 -1.96884604e-03 2.88330664e-04 9.11820773e-04 + 7.31467486e-01 9.99670896e-01 3.09217671e-02 4.19690457e-02 -8.20407785e-02 -2.09898477e-03 3.05954472e-04 9.49383694e-04 + 7.74288401e-01 9.99672435e-01 3.27404562e-02 4.44386288e-02 -8.74572896e-02 -2.23704369e-03 3.23690280e-04 9.86765134e-04 + 8.18893522e-01 9.99675573e-01 3.46351496e-02 4.70120353e-02 -9.31803448e-02 -2.38329866e-03 3.41327084e-04 1.02396078e-03 + 8.65357189e-01 9.99680496e-01 3.66095806e-02 4.96930832e-02 -9.92313184e-02 -2.53831923e-03 3.59326351e-04 1.06067748e-03 + 9.13756842e-01 9.99687406e-01 3.86670828e-02 5.24862761e-02 -1.05633476e-01 -2.70252104e-03 3.77724962e-04 1.09671465e-03 + 9.64173148e-01 9.99696526e-01 4.08112833e-02 5.53961765e-02 -1.12412186e-01 -2.87607898e-03 3.96697658e-04 1.13177909e-03 + 1.01669013e+00 9.99708102e-01 4.30462325e-02 5.84272366e-02 -1.19595170e-01 -3.05960203e-03 4.17271122e-04 1.16431871e-03 + 1.07139533e+00 9.99722402e-01 4.53773375e-02 6.15829157e-02 -1.27212756e-01 -3.25333324e-03 4.40311978e-04 1.19235937e-03 + 1.12837990e+00 9.99739724e-01 4.78105586e-02 6.48665343e-02 -1.35298198e-01 -3.45757488e-03 4.66432959e-04 1.21602455e-03 + 1.18773883e+00 9.99760394e-01 5.03515297e-02 6.82821450e-02 -1.43888019e-01 -3.67227977e-03 4.94381086e-04 1.23560940e-03 + 1.20000000e+00 9.99759023e-01 5.08749042e-02 6.89841082e-02 -1.45684030e-01 -3.71659097e-03 5.00064806e-04 1.23935776e-03 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_global.txt new file mode 100644 index 0000000..5da2d63 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_global.txt @@ -0,0 +1,70 @@ + # Time Volume E11 E22 E33 E23 E13 E12 + 5.00000000e-05 1.00000156e+00 -1.64987386e-06 -1.78644680e-06 4.99996259e-06 6.60489760e-08 -1.17310942e-07 -1.37067125e-08 + 2.06250000e-04 1.00000645e+00 -6.80567452e-06 -7.36903992e-06 2.06243634e-05 2.72452079e-07 -4.83901329e-07 -5.65402321e-08 + 6.94531250e-04 1.00002172e+00 -2.29170130e-05 -2.48141246e-05 6.94459068e-05 9.17462289e-07 -1.62943467e-06 -1.90395397e-07 + 2.22041016e-03 1.00006925e+00 -7.33503261e-05 -7.94272245e-05 2.21967260e-04 2.94980399e-06 -5.19446856e-06 -5.72612240e-07 + 4.60459595e-03 1.00009813e+00 -1.68812021e-04 -1.93473291e-04 4.60142773e-04 5.62627098e-06 -9.51151943e-06 -3.41084185e-06 + 6.46724110e-03 1.00010295e+00 -2.48878741e-04 -2.94824866e-04 6.46099195e-04 9.45377475e-06 -1.08886313e-05 -7.33255158e-06 + 8.40749646e-03 1.00010584e+00 -3.32779773e-04 -4.02030070e-04 8.39693377e-04 1.38848305e-05 -1.14289046e-05 -1.15648074e-05 + 1.04285958e-02 1.00010819e+00 -4.19792901e-04 -5.14743202e-04 1.04123413e-03 1.90424059e-05 -1.12815085e-05 -1.55843789e-05 + 1.25339076e-02 1.00011031e+00 -5.10034573e-04 -6.32868791e-04 1.25104238e-03 2.48202492e-05 -1.06769358e-05 -1.95164207e-05 + 1.47269407e-02 1.00011233e+00 -6.03722672e-04 -7.56410373e-04 1.46945165e-03 3.11346796e-05 -9.78322728e-06 -2.34624687e-05 + 1.81535550e-02 1.00011532e+00 -7.49749610e-04 -9.49982071e-04 1.81042852e-03 4.12620011e-05 -8.23780007e-06 -2.93494904e-05 + 2.17229449e-02 1.00011828e+00 -9.01794636e-04 -1.15182362e-03 2.16524049e-03 5.17430142e-05 -6.67568858e-06 -3.55422511e-05 + 2.54410593e-02 1.00012127e+00 -1.05987183e-03 -1.36246357e-03 2.53443295e-03 6.25424325e-05 -5.12920050e-06 -4.19523679e-05 + 2.93140952e-02 1.00012432e+00 -1.22415325e-03 -1.58232133e-03 2.91857147e-03 7.37056757e-05 -3.65293901e-06 -4.85468887e-05 + 3.33485076e-02 1.00012744e+00 -1.39479991e-03 -1.81186674e-03 3.31824235e-03 8.52342734e-05 -2.35017674e-06 -5.52680712e-05 + 3.75510205e-02 1.00013065e+00 -1.57220560e-03 -2.05136186e-03 3.73405340e-03 9.71713419e-05 -1.23437065e-06 -6.21038768e-05 + 4.19286381e-02 1.00013395e+00 -1.75672120e-03 -2.30114377e-03 4.16663445e-03 1.09477481e-04 -3.99120510e-07 -6.91034628e-05 + 4.64886564e-02 1.00013736e+00 -1.94869165e-03 -2.56158783e-03 4.61663805e-03 1.22194178e-04 1.08288760e-08 -7.63386823e-05 + 5.12386755e-02 1.00014089e+00 -2.14844224e-03 -2.83311982e-03 5.08474013e-03 1.35378338e-04 -1.99053655e-08 -8.38608359e-05 + 5.61866121e-02 1.00014454e+00 -2.35627013e-03 -3.11622744e-03 5.57164073e-03 1.48975529e-04 -4.08495423e-07 -9.16896645e-05 + 6.13407126e-02 1.00014832e+00 -2.57251101e-03 -3.41139085e-03 6.07806462e-03 1.62951588e-04 -1.12468231e-06 -9.97626983e-05 + 6.67095674e-02 1.00015223e+00 -2.79748808e-03 -3.71913846e-03 6.60476195e-03 1.77339898e-04 -2.16330771e-06 -1.08061775e-04 + 7.23021245e-02 1.00015628e+00 -3.03155481e-03 -4.04000415e-03 7.15250901e-03 1.92126989e-04 -3.52830598e-06 -1.16629588e-04 + 7.81277047e-02 1.00016048e+00 -3.27513057e-03 -4.37449280e-03 7.72210878e-03 2.07357729e-04 -5.21461864e-06 -1.25495637e-04 + 8.41960175e-02 1.00016483e+00 -3.52875181e-03 -4.72303011e-03 8.31439172e-03 2.23065438e-04 -7.20619744e-06 -1.34690489e-04 + 9.05171766e-02 1.00016934e+00 -3.79297412e-03 -5.08606342e-03 8.93021640e-03 2.39274052e-04 -9.49266217e-06 -1.44204800e-04 + 1.00393988e-01 1.00017658e+00 -4.20570853e-03 -5.65319501e-03 9.89012715e-03 2.64397013e-04 -1.34744940e-05 -1.58920414e-04 + 1.10682333e-01 1.00018407e+00 -4.63546601e-03 -6.24414279e-03 1.08870420e-02 2.90436163e-04 -1.79869903e-05 -1.74029150e-04 + 1.21399359e-01 1.00019182e+00 -5.08280826e-03 -6.86004632e-03 1.19222615e-02 3.17457565e-04 -2.30142035e-05 -1.89516956e-04 + 1.32562927e-01 1.00019984e+00 -5.54840486e-03 -7.50200843e-03 1.29971214e-02 3.45434766e-04 -2.86303191e-05 -2.05463478e-04 + 1.44191645e-01 1.00020813e+00 -6.03303614e-03 -8.17109425e-03 1.41129932e-02 3.74307158e-04 -3.48520603e-05 -2.21938901e-04 + 1.56304892e-01 1.00021672e+00 -6.53744375e-03 -8.86848625e-03 1.52712835e-02 4.04025594e-04 -4.16374267e-05 -2.38970207e-04 + 1.68922858e-01 1.00022560e+00 -7.06237576e-03 -9.59544035e-03 1.64734340e-02 4.34687054e-04 -4.88891360e-05 -2.56649212e-04 + 1.82066573e-01 1.00023479e+00 -7.60869260e-03 -1.03531820e-02 1.77209214e-02 4.66436817e-04 -5.64706471e-05 -2.75004811e-04 + 1.95757942e-01 1.00024429e+00 -8.17718209e-03 -1.11430991e-02 1.90152571e-02 4.99287695e-04 -6.44165823e-05 -2.94066905e-04 + 2.10019785e-01 1.00025412e+00 -8.76860319e-03 -1.19667006e-02 2.03579865e-02 5.33236664e-04 -7.28899109e-05 -3.13857860e-04 + 2.24875872e-01 1.00026429e+00 -9.38387080e-03 -1.28254315e-02 2.17506886e-02 5.68306306e-04 -8.20021324e-05 -3.34377499e-04 + 2.40350962e-01 1.00027479e+00 -1.00238994e-02 -1.37208358e-02 2.31949751e-02 6.04526527e-04 -9.18121604e-05 -3.55630525e-04 + 2.56470847e-01 1.00028566e+00 -1.06897240e-02 -1.46544384e-02 2.46924893e-02 6.41934801e-04 -1.02319367e-04 -3.77604517e-04 + 2.73262395e-01 1.00029689e+00 -1.13824207e-02 -1.56278303e-02 2.62449049e-02 6.80595074e-04 -1.13547817e-04 -4.00281506e-04 + 2.90753590e-01 1.00030849e+00 -1.21031382e-02 -1.66426420e-02 2.78539249e-02 7.20624864e-04 -1.25562959e-04 -4.23641530e-04 + 3.08973585e-01 1.00032048e+00 -1.28530455e-02 -1.77005996e-02 2.95212797e-02 7.62156619e-04 -1.38388340e-04 -4.47692314e-04 + 3.27952746e-01 1.00033288e+00 -1.36333532e-02 -1.88035096e-02 3.12487258e-02 8.05249365e-04 -1.52024568e-04 -4.72520240e-04 + 3.47722706e-01 1.00034568e+00 -1.44453625e-02 -1.99532113e-02 3.30380438e-02 8.49924313e-04 -1.66551628e-04 -4.98238409e-04 + 3.68316415e-01 1.00035891e+00 -1.52903764e-02 -2.11516733e-02 3.48910355e-02 8.96191479e-04 -1.82138398e-04 -5.24861314e-04 + 3.89768194e-01 1.00037257e+00 -1.61696644e-02 -2.24010362e-02 3.68095224e-02 9.43965067e-04 -1.98912978e-04 -5.52344138e-04 + 4.12113798e-01 1.00038668e+00 -1.70844764e-02 -2.37036027e-02 3.87953422e-02 9.93194768e-04 -2.16917875e-04 -5.80630541e-04 + 4.35390468e-01 1.00040126e+00 -1.80361178e-02 -2.50617673e-02 4.08503454e-02 1.04384657e-03 -2.36168593e-04 -6.09702300e-04 + 4.59637000e-01 1.00041631e+00 -1.90259861e-02 -2.64779878e-02 4.29763925e-02 1.09592034e-03 -2.56762846e-04 -6.39561246e-04 + 4.84893803e-01 1.00043187e+00 -2.00555637e-02 -2.79548014e-02 4.51753491e-02 1.14956913e-03 -2.78839642e-04 -6.70234963e-04 + 5.11202974e-01 1.00044793e+00 -2.11263526e-02 -2.94948934e-02 4.74490824e-02 1.20494992e-03 -3.02440038e-04 -7.01702437e-04 + 5.38608360e-01 1.00046452e+00 -2.22399003e-02 -3.11010774e-02 4.97994580e-02 1.26205860e-03 -3.27581144e-04 -7.33952836e-04 + 5.67155637e-01 1.00048166e+00 -2.33978001e-02 -3.27763041e-02 5.22283342e-02 1.32078026e-03 -3.54258892e-04 -7.66996936e-04 + 5.96892384e-01 1.00049936e+00 -2.46016757e-02 -3.45236880e-02 5.47375574e-02 1.38092067e-03 -3.82480964e-04 -8.00892442e-04 + 6.27868162e-01 1.00051766e+00 -2.58532165e-02 -3.63464800e-02 5.73289560e-02 1.44219234e-03 -4.12372908e-04 -8.35678162e-04 + 6.60134598e-01 1.00053657e+00 -2.71542174e-02 -3.82480332e-02 6.00043349e-02 1.50423981e-03 -4.44133306e-04 -8.71378920e-04 + 6.93745468e-01 1.00055611e+00 -2.85065219e-02 -4.02318675e-02 6.27654675e-02 1.56683741e-03 -4.77816460e-04 -9.07954412e-04 + 7.28756791e-01 1.00057632e+00 -2.99120735e-02 -4.23016169e-02 6.56140888e-02 1.62984389e-03 -5.13719216e-04 -9.45483976e-04 + 7.65226920e-01 1.00059722e+00 -3.13726896e-02 -4.44612770e-02 6.85518885e-02 1.69319959e-03 -5.52076385e-04 -9.83856842e-04 + 8.03216637e-01 1.00061884e+00 -3.28902796e-02 -4.67150137e-02 7.15805057e-02 1.75670850e-03 -5.92711648e-04 -1.02281939e-03 + 8.42789259e-01 1.00064122e+00 -3.44667818e-02 -4.90672272e-02 7.47015185e-02 1.82041357e-03 -6.35044814e-04 -1.06214020e-03 + 8.84010740e-01 1.00066439e+00 -3.61044008e-02 -5.15222907e-02 7.79164370e-02 1.88399687e-03 -6.78980238e-04 -1.10172445e-03 + 9.26949783e-01 1.00068840e+00 -3.78054283e-02 -5.40847905e-02 8.12266904e-02 1.94742995e-03 -7.25003233e-04 -1.14155906e-03 + 9.71677953e-01 1.00071327e+00 -3.95720579e-02 -5.67597287e-02 8.46336195e-02 2.01087349e-03 -7.73357152e-04 -1.18104980e-03 + 1.01826980e+00 1.00073907e+00 -4.14058779e-02 -5.95530309e-02 8.81384718e-02 2.07395921e-03 -8.24136115e-04 -1.21965053e-03 + 1.06680297e+00 1.00076583e+00 -4.33091289e-02 -6.24702637e-02 9.17423915e-02 2.13593840e-03 -8.77152457e-04 -1.25733406e-03 + 1.11735835e+00 1.00079361e+00 -4.52838344e-02 -6.55175729e-02 9.54464019e-02 2.19632148e-03 -9.32805269e-04 -1.29387689e-03 + 1.17002021e+00 1.00082246e+00 -4.73322611e-02 -6.87012101e-02 9.92513921e-02 2.25538803e-03 -9.91393636e-04 -1.32966177e-03 + 1.20000000e+00 1.00083445e+00 -4.84970434e-02 -7.05202032e-02 1.01393587e-01 2.28810468e-03 -1.02497986e-03 -1.34959621e-03 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_region_default_1.txt new file mode 100644 index 0000000..5da2d63 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_euler_strain_region_default_1.txt @@ -0,0 +1,70 @@ + # Time Volume E11 E22 E33 E23 E13 E12 + 5.00000000e-05 1.00000156e+00 -1.64987386e-06 -1.78644680e-06 4.99996259e-06 6.60489760e-08 -1.17310942e-07 -1.37067125e-08 + 2.06250000e-04 1.00000645e+00 -6.80567452e-06 -7.36903992e-06 2.06243634e-05 2.72452079e-07 -4.83901329e-07 -5.65402321e-08 + 6.94531250e-04 1.00002172e+00 -2.29170130e-05 -2.48141246e-05 6.94459068e-05 9.17462289e-07 -1.62943467e-06 -1.90395397e-07 + 2.22041016e-03 1.00006925e+00 -7.33503261e-05 -7.94272245e-05 2.21967260e-04 2.94980399e-06 -5.19446856e-06 -5.72612240e-07 + 4.60459595e-03 1.00009813e+00 -1.68812021e-04 -1.93473291e-04 4.60142773e-04 5.62627098e-06 -9.51151943e-06 -3.41084185e-06 + 6.46724110e-03 1.00010295e+00 -2.48878741e-04 -2.94824866e-04 6.46099195e-04 9.45377475e-06 -1.08886313e-05 -7.33255158e-06 + 8.40749646e-03 1.00010584e+00 -3.32779773e-04 -4.02030070e-04 8.39693377e-04 1.38848305e-05 -1.14289046e-05 -1.15648074e-05 + 1.04285958e-02 1.00010819e+00 -4.19792901e-04 -5.14743202e-04 1.04123413e-03 1.90424059e-05 -1.12815085e-05 -1.55843789e-05 + 1.25339076e-02 1.00011031e+00 -5.10034573e-04 -6.32868791e-04 1.25104238e-03 2.48202492e-05 -1.06769358e-05 -1.95164207e-05 + 1.47269407e-02 1.00011233e+00 -6.03722672e-04 -7.56410373e-04 1.46945165e-03 3.11346796e-05 -9.78322728e-06 -2.34624687e-05 + 1.81535550e-02 1.00011532e+00 -7.49749610e-04 -9.49982071e-04 1.81042852e-03 4.12620011e-05 -8.23780007e-06 -2.93494904e-05 + 2.17229449e-02 1.00011828e+00 -9.01794636e-04 -1.15182362e-03 2.16524049e-03 5.17430142e-05 -6.67568858e-06 -3.55422511e-05 + 2.54410593e-02 1.00012127e+00 -1.05987183e-03 -1.36246357e-03 2.53443295e-03 6.25424325e-05 -5.12920050e-06 -4.19523679e-05 + 2.93140952e-02 1.00012432e+00 -1.22415325e-03 -1.58232133e-03 2.91857147e-03 7.37056757e-05 -3.65293901e-06 -4.85468887e-05 + 3.33485076e-02 1.00012744e+00 -1.39479991e-03 -1.81186674e-03 3.31824235e-03 8.52342734e-05 -2.35017674e-06 -5.52680712e-05 + 3.75510205e-02 1.00013065e+00 -1.57220560e-03 -2.05136186e-03 3.73405340e-03 9.71713419e-05 -1.23437065e-06 -6.21038768e-05 + 4.19286381e-02 1.00013395e+00 -1.75672120e-03 -2.30114377e-03 4.16663445e-03 1.09477481e-04 -3.99120510e-07 -6.91034628e-05 + 4.64886564e-02 1.00013736e+00 -1.94869165e-03 -2.56158783e-03 4.61663805e-03 1.22194178e-04 1.08288760e-08 -7.63386823e-05 + 5.12386755e-02 1.00014089e+00 -2.14844224e-03 -2.83311982e-03 5.08474013e-03 1.35378338e-04 -1.99053655e-08 -8.38608359e-05 + 5.61866121e-02 1.00014454e+00 -2.35627013e-03 -3.11622744e-03 5.57164073e-03 1.48975529e-04 -4.08495423e-07 -9.16896645e-05 + 6.13407126e-02 1.00014832e+00 -2.57251101e-03 -3.41139085e-03 6.07806462e-03 1.62951588e-04 -1.12468231e-06 -9.97626983e-05 + 6.67095674e-02 1.00015223e+00 -2.79748808e-03 -3.71913846e-03 6.60476195e-03 1.77339898e-04 -2.16330771e-06 -1.08061775e-04 + 7.23021245e-02 1.00015628e+00 -3.03155481e-03 -4.04000415e-03 7.15250901e-03 1.92126989e-04 -3.52830598e-06 -1.16629588e-04 + 7.81277047e-02 1.00016048e+00 -3.27513057e-03 -4.37449280e-03 7.72210878e-03 2.07357729e-04 -5.21461864e-06 -1.25495637e-04 + 8.41960175e-02 1.00016483e+00 -3.52875181e-03 -4.72303011e-03 8.31439172e-03 2.23065438e-04 -7.20619744e-06 -1.34690489e-04 + 9.05171766e-02 1.00016934e+00 -3.79297412e-03 -5.08606342e-03 8.93021640e-03 2.39274052e-04 -9.49266217e-06 -1.44204800e-04 + 1.00393988e-01 1.00017658e+00 -4.20570853e-03 -5.65319501e-03 9.89012715e-03 2.64397013e-04 -1.34744940e-05 -1.58920414e-04 + 1.10682333e-01 1.00018407e+00 -4.63546601e-03 -6.24414279e-03 1.08870420e-02 2.90436163e-04 -1.79869903e-05 -1.74029150e-04 + 1.21399359e-01 1.00019182e+00 -5.08280826e-03 -6.86004632e-03 1.19222615e-02 3.17457565e-04 -2.30142035e-05 -1.89516956e-04 + 1.32562927e-01 1.00019984e+00 -5.54840486e-03 -7.50200843e-03 1.29971214e-02 3.45434766e-04 -2.86303191e-05 -2.05463478e-04 + 1.44191645e-01 1.00020813e+00 -6.03303614e-03 -8.17109425e-03 1.41129932e-02 3.74307158e-04 -3.48520603e-05 -2.21938901e-04 + 1.56304892e-01 1.00021672e+00 -6.53744375e-03 -8.86848625e-03 1.52712835e-02 4.04025594e-04 -4.16374267e-05 -2.38970207e-04 + 1.68922858e-01 1.00022560e+00 -7.06237576e-03 -9.59544035e-03 1.64734340e-02 4.34687054e-04 -4.88891360e-05 -2.56649212e-04 + 1.82066573e-01 1.00023479e+00 -7.60869260e-03 -1.03531820e-02 1.77209214e-02 4.66436817e-04 -5.64706471e-05 -2.75004811e-04 + 1.95757942e-01 1.00024429e+00 -8.17718209e-03 -1.11430991e-02 1.90152571e-02 4.99287695e-04 -6.44165823e-05 -2.94066905e-04 + 2.10019785e-01 1.00025412e+00 -8.76860319e-03 -1.19667006e-02 2.03579865e-02 5.33236664e-04 -7.28899109e-05 -3.13857860e-04 + 2.24875872e-01 1.00026429e+00 -9.38387080e-03 -1.28254315e-02 2.17506886e-02 5.68306306e-04 -8.20021324e-05 -3.34377499e-04 + 2.40350962e-01 1.00027479e+00 -1.00238994e-02 -1.37208358e-02 2.31949751e-02 6.04526527e-04 -9.18121604e-05 -3.55630525e-04 + 2.56470847e-01 1.00028566e+00 -1.06897240e-02 -1.46544384e-02 2.46924893e-02 6.41934801e-04 -1.02319367e-04 -3.77604517e-04 + 2.73262395e-01 1.00029689e+00 -1.13824207e-02 -1.56278303e-02 2.62449049e-02 6.80595074e-04 -1.13547817e-04 -4.00281506e-04 + 2.90753590e-01 1.00030849e+00 -1.21031382e-02 -1.66426420e-02 2.78539249e-02 7.20624864e-04 -1.25562959e-04 -4.23641530e-04 + 3.08973585e-01 1.00032048e+00 -1.28530455e-02 -1.77005996e-02 2.95212797e-02 7.62156619e-04 -1.38388340e-04 -4.47692314e-04 + 3.27952746e-01 1.00033288e+00 -1.36333532e-02 -1.88035096e-02 3.12487258e-02 8.05249365e-04 -1.52024568e-04 -4.72520240e-04 + 3.47722706e-01 1.00034568e+00 -1.44453625e-02 -1.99532113e-02 3.30380438e-02 8.49924313e-04 -1.66551628e-04 -4.98238409e-04 + 3.68316415e-01 1.00035891e+00 -1.52903764e-02 -2.11516733e-02 3.48910355e-02 8.96191479e-04 -1.82138398e-04 -5.24861314e-04 + 3.89768194e-01 1.00037257e+00 -1.61696644e-02 -2.24010362e-02 3.68095224e-02 9.43965067e-04 -1.98912978e-04 -5.52344138e-04 + 4.12113798e-01 1.00038668e+00 -1.70844764e-02 -2.37036027e-02 3.87953422e-02 9.93194768e-04 -2.16917875e-04 -5.80630541e-04 + 4.35390468e-01 1.00040126e+00 -1.80361178e-02 -2.50617673e-02 4.08503454e-02 1.04384657e-03 -2.36168593e-04 -6.09702300e-04 + 4.59637000e-01 1.00041631e+00 -1.90259861e-02 -2.64779878e-02 4.29763925e-02 1.09592034e-03 -2.56762846e-04 -6.39561246e-04 + 4.84893803e-01 1.00043187e+00 -2.00555637e-02 -2.79548014e-02 4.51753491e-02 1.14956913e-03 -2.78839642e-04 -6.70234963e-04 + 5.11202974e-01 1.00044793e+00 -2.11263526e-02 -2.94948934e-02 4.74490824e-02 1.20494992e-03 -3.02440038e-04 -7.01702437e-04 + 5.38608360e-01 1.00046452e+00 -2.22399003e-02 -3.11010774e-02 4.97994580e-02 1.26205860e-03 -3.27581144e-04 -7.33952836e-04 + 5.67155637e-01 1.00048166e+00 -2.33978001e-02 -3.27763041e-02 5.22283342e-02 1.32078026e-03 -3.54258892e-04 -7.66996936e-04 + 5.96892384e-01 1.00049936e+00 -2.46016757e-02 -3.45236880e-02 5.47375574e-02 1.38092067e-03 -3.82480964e-04 -8.00892442e-04 + 6.27868162e-01 1.00051766e+00 -2.58532165e-02 -3.63464800e-02 5.73289560e-02 1.44219234e-03 -4.12372908e-04 -8.35678162e-04 + 6.60134598e-01 1.00053657e+00 -2.71542174e-02 -3.82480332e-02 6.00043349e-02 1.50423981e-03 -4.44133306e-04 -8.71378920e-04 + 6.93745468e-01 1.00055611e+00 -2.85065219e-02 -4.02318675e-02 6.27654675e-02 1.56683741e-03 -4.77816460e-04 -9.07954412e-04 + 7.28756791e-01 1.00057632e+00 -2.99120735e-02 -4.23016169e-02 6.56140888e-02 1.62984389e-03 -5.13719216e-04 -9.45483976e-04 + 7.65226920e-01 1.00059722e+00 -3.13726896e-02 -4.44612770e-02 6.85518885e-02 1.69319959e-03 -5.52076385e-04 -9.83856842e-04 + 8.03216637e-01 1.00061884e+00 -3.28902796e-02 -4.67150137e-02 7.15805057e-02 1.75670850e-03 -5.92711648e-04 -1.02281939e-03 + 8.42789259e-01 1.00064122e+00 -3.44667818e-02 -4.90672272e-02 7.47015185e-02 1.82041357e-03 -6.35044814e-04 -1.06214020e-03 + 8.84010740e-01 1.00066439e+00 -3.61044008e-02 -5.15222907e-02 7.79164370e-02 1.88399687e-03 -6.78980238e-04 -1.10172445e-03 + 9.26949783e-01 1.00068840e+00 -3.78054283e-02 -5.40847905e-02 8.12266904e-02 1.94742995e-03 -7.25003233e-04 -1.14155906e-03 + 9.71677953e-01 1.00071327e+00 -3.95720579e-02 -5.67597287e-02 8.46336195e-02 2.01087349e-03 -7.73357152e-04 -1.18104980e-03 + 1.01826980e+00 1.00073907e+00 -4.14058779e-02 -5.95530309e-02 8.81384718e-02 2.07395921e-03 -8.24136115e-04 -1.21965053e-03 + 1.06680297e+00 1.00076583e+00 -4.33091289e-02 -6.24702637e-02 9.17423915e-02 2.13593840e-03 -8.77152457e-04 -1.25733406e-03 + 1.11735835e+00 1.00079361e+00 -4.52838344e-02 -6.55175729e-02 9.54464019e-02 2.19632148e-03 -9.32805269e-04 -1.29387689e-03 + 1.17002021e+00 1.00082246e+00 -4.73322611e-02 -6.87012101e-02 9.92513921e-02 2.25538803e-03 -9.91393636e-04 -1.32966177e-03 + 1.20000000e+00 1.00083445e+00 -4.84970434e-02 -7.05202032e-02 1.01393587e-01 2.28810468e-03 -1.02497986e-03 -1.34959621e-03 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_pl_work_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_pl_work_global.txt new file mode 100644 index 0000000..2bffeb3 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_pl_work_global.txt @@ -0,0 +1,70 @@ + # Time Volume Plastic_Work + 5.00000000e-05 1.00000156e+00 0.00000000e+00 + 2.06250000e-04 1.00000645e+00 1.36358016e-59 + 6.94531250e-04 1.00002172e+00 3.31947133e-32 + 2.22041016e-03 1.00006925e+00 1.65894349e-08 + 4.60459595e-03 1.00009813e+00 5.37295074e-06 + 6.46724110e-03 1.00010295e+00 1.22050855e-05 + 8.40749646e-03 1.00010584e+00 1.98870797e-05 + 1.04285958e-02 1.00010819e+00 2.82045982e-05 + 1.25339076e-02 1.00011031e+00 3.71138635e-05 + 1.47269407e-02 1.00011233e+00 4.66091275e-05 + 1.81535550e-02 1.00011532e+00 6.18742258e-05 + 2.17229449e-02 1.00011828e+00 7.82083067e-05 + 2.54410593e-02 1.00012127e+00 9.56605448e-05 + 2.93140952e-02 1.00012432e+00 1.14293809e-04 + 3.33485076e-02 1.00012744e+00 1.34184498e-04 + 3.75510205e-02 1.00013065e+00 1.55411029e-04 + 4.19286381e-02 1.00013395e+00 1.78060390e-04 + 4.64886564e-02 1.00013736e+00 2.02229000e-04 + 5.12386755e-02 1.00014089e+00 2.28020237e-04 + 5.61866121e-02 1.00014454e+00 2.55548152e-04 + 6.13407126e-02 1.00014832e+00 2.84932617e-04 + 6.67095674e-02 1.00015223e+00 3.16303490e-04 + 7.23021245e-02 1.00015628e+00 3.49800419e-04 + 7.81277047e-02 1.00016048e+00 3.85573456e-04 + 8.41960175e-02 1.00016483e+00 4.23781920e-04 + 9.05171766e-02 1.00016934e+00 4.64597566e-04 + 1.00393988e-01 1.00017658e+00 5.30809645e-04 + 1.10682333e-01 1.00018407e+00 6.02390280e-04 + 1.21399359e-01 1.00019182e+00 6.79746012e-04 + 1.32562927e-01 1.00019984e+00 7.63311237e-04 + 1.44191645e-01 1.00020813e+00 8.53548598e-04 + 1.56304892e-01 1.00021672e+00 9.50955456e-04 + 1.68922858e-01 1.00022560e+00 1.05606226e-03 + 1.82066573e-01 1.00023479e+00 1.16943234e-03 + 1.95757942e-01 1.00024429e+00 1.29167122e-03 + 2.10019785e-01 1.00025412e+00 1.42342599e-03 + 2.24875872e-01 1.00026429e+00 1.56538397e-03 + 2.40350962e-01 1.00027479e+00 1.71827780e-03 + 2.56470847e-01 1.00028566e+00 1.88288013e-03 + 2.73262395e-01 1.00029689e+00 2.06001377e-03 + 2.90753590e-01 1.00030849e+00 2.25055382e-03 + 3.08973585e-01 1.00032048e+00 2.45542606e-03 + 3.27952746e-01 1.00033288e+00 2.67561119e-03 + 3.47722706e-01 1.00034568e+00 2.91214499e-03 + 3.68316415e-01 1.00035891e+00 3.16612580e-03 + 3.89768194e-01 1.00037257e+00 3.43871424e-03 + 4.12113798e-01 1.00038668e+00 3.73113237e-03 + 4.35390468e-01 1.00040126e+00 4.04466335e-03 + 4.59637000e-01 1.00041631e+00 4.38066009e-03 + 4.84893803e-01 1.00043187e+00 4.74054635e-03 + 5.11202974e-01 1.00044793e+00 5.12581667e-03 + 5.38608360e-01 1.00046452e+00 5.53803223e-03 + 5.67155637e-01 1.00048166e+00 5.97882811e-03 + 5.96892384e-01 1.00049936e+00 6.44991879e-03 + 6.27868162e-01 1.00051766e+00 6.95309001e-03 + 6.60134598e-01 1.00053657e+00 7.49019970e-03 + 6.93745468e-01 1.00055611e+00 8.06318622e-03 + 7.28756791e-01 1.00057632e+00 8.67405492e-03 + 7.65226920e-01 1.00059722e+00 9.32489196e-03 + 8.03216637e-01 1.00061884e+00 1.00178580e-02 + 8.42789259e-01 1.00064122e+00 1.07552005e-02 + 8.84010740e-01 1.00066439e+00 1.15392106e-02 + 9.26949783e-01 1.00068840e+00 1.23722583e-02 + 9.71677953e-01 1.00071327e+00 1.32567878e-02 + 1.01826980e+00 1.00073907e+00 1.41953143e-02 + 1.06680297e+00 1.00076583e+00 1.51904063e-02 + 1.11735835e+00 1.00079361e+00 1.62447144e-02 + 1.17002021e+00 1.00082246e+00 1.73609554e-02 + 1.20000000e+00 1.00083445e+00 1.80019490e-02 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_pl_work_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_pl_work_region_default_1.txt new file mode 100644 index 0000000..2bffeb3 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_pl_work_region_default_1.txt @@ -0,0 +1,70 @@ + # Time Volume Plastic_Work + 5.00000000e-05 1.00000156e+00 0.00000000e+00 + 2.06250000e-04 1.00000645e+00 1.36358016e-59 + 6.94531250e-04 1.00002172e+00 3.31947133e-32 + 2.22041016e-03 1.00006925e+00 1.65894349e-08 + 4.60459595e-03 1.00009813e+00 5.37295074e-06 + 6.46724110e-03 1.00010295e+00 1.22050855e-05 + 8.40749646e-03 1.00010584e+00 1.98870797e-05 + 1.04285958e-02 1.00010819e+00 2.82045982e-05 + 1.25339076e-02 1.00011031e+00 3.71138635e-05 + 1.47269407e-02 1.00011233e+00 4.66091275e-05 + 1.81535550e-02 1.00011532e+00 6.18742258e-05 + 2.17229449e-02 1.00011828e+00 7.82083067e-05 + 2.54410593e-02 1.00012127e+00 9.56605448e-05 + 2.93140952e-02 1.00012432e+00 1.14293809e-04 + 3.33485076e-02 1.00012744e+00 1.34184498e-04 + 3.75510205e-02 1.00013065e+00 1.55411029e-04 + 4.19286381e-02 1.00013395e+00 1.78060390e-04 + 4.64886564e-02 1.00013736e+00 2.02229000e-04 + 5.12386755e-02 1.00014089e+00 2.28020237e-04 + 5.61866121e-02 1.00014454e+00 2.55548152e-04 + 6.13407126e-02 1.00014832e+00 2.84932617e-04 + 6.67095674e-02 1.00015223e+00 3.16303490e-04 + 7.23021245e-02 1.00015628e+00 3.49800419e-04 + 7.81277047e-02 1.00016048e+00 3.85573456e-04 + 8.41960175e-02 1.00016483e+00 4.23781920e-04 + 9.05171766e-02 1.00016934e+00 4.64597566e-04 + 1.00393988e-01 1.00017658e+00 5.30809645e-04 + 1.10682333e-01 1.00018407e+00 6.02390280e-04 + 1.21399359e-01 1.00019182e+00 6.79746012e-04 + 1.32562927e-01 1.00019984e+00 7.63311237e-04 + 1.44191645e-01 1.00020813e+00 8.53548598e-04 + 1.56304892e-01 1.00021672e+00 9.50955456e-04 + 1.68922858e-01 1.00022560e+00 1.05606226e-03 + 1.82066573e-01 1.00023479e+00 1.16943234e-03 + 1.95757942e-01 1.00024429e+00 1.29167122e-03 + 2.10019785e-01 1.00025412e+00 1.42342599e-03 + 2.24875872e-01 1.00026429e+00 1.56538397e-03 + 2.40350962e-01 1.00027479e+00 1.71827780e-03 + 2.56470847e-01 1.00028566e+00 1.88288013e-03 + 2.73262395e-01 1.00029689e+00 2.06001377e-03 + 2.90753590e-01 1.00030849e+00 2.25055382e-03 + 3.08973585e-01 1.00032048e+00 2.45542606e-03 + 3.27952746e-01 1.00033288e+00 2.67561119e-03 + 3.47722706e-01 1.00034568e+00 2.91214499e-03 + 3.68316415e-01 1.00035891e+00 3.16612580e-03 + 3.89768194e-01 1.00037257e+00 3.43871424e-03 + 4.12113798e-01 1.00038668e+00 3.73113237e-03 + 4.35390468e-01 1.00040126e+00 4.04466335e-03 + 4.59637000e-01 1.00041631e+00 4.38066009e-03 + 4.84893803e-01 1.00043187e+00 4.74054635e-03 + 5.11202974e-01 1.00044793e+00 5.12581667e-03 + 5.38608360e-01 1.00046452e+00 5.53803223e-03 + 5.67155637e-01 1.00048166e+00 5.97882811e-03 + 5.96892384e-01 1.00049936e+00 6.44991879e-03 + 6.27868162e-01 1.00051766e+00 6.95309001e-03 + 6.60134598e-01 1.00053657e+00 7.49019970e-03 + 6.93745468e-01 1.00055611e+00 8.06318622e-03 + 7.28756791e-01 1.00057632e+00 8.67405492e-03 + 7.65226920e-01 1.00059722e+00 9.32489196e-03 + 8.03216637e-01 1.00061884e+00 1.00178580e-02 + 8.42789259e-01 1.00064122e+00 1.07552005e-02 + 8.84010740e-01 1.00066439e+00 1.15392106e-02 + 9.26949783e-01 1.00068840e+00 1.23722583e-02 + 9.71677953e-01 1.00071327e+00 1.32567878e-02 + 1.01826980e+00 1.00073907e+00 1.41953143e-02 + 1.06680297e+00 1.00076583e+00 1.51904063e-02 + 1.11735835e+00 1.00079361e+00 1.62447144e-02 + 1.17002021e+00 1.00082246e+00 1.73609554e-02 + 1.20000000e+00 1.00083445e+00 1.80019490e-02 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_global copy.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_global copy.txt new file mode 100644 index 0000000..3591ecc --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_global copy.txt @@ -0,0 +1,63 @@ + # Time Volume Sxx Syy Szz Sxy Sxz Syz + 5.00000000e-05 9.99998436e-01 -1.15438016e-14 -1.17951784e-14 -6.42983008e-04 4.06656919e-16 8.21398965e-16 8.86975495e-17 + 2.06250000e-04 9.99993550e-01 -4.25634510e-13 -4.29366326e-13 -2.65234750e-03 1.27395729e-14 -1.92240117e-14 3.04365975e-15 + 6.94531250e-04 9.99978280e-01 -1.36294679e-11 -1.37292789e-11 -8.93202744e-03 4.55870714e-13 -7.35919466e-13 1.03603043e-13 + 2.22041016e-03 9.99930760e-01 -7.07067300e-11 -2.80658159e-10 -2.84803206e-02 3.69802595e-11 1.18577358e-10 5.11152472e-11 + 4.60459595e-03 9.99901977e-01 -9.66715638e-10 3.94276702e-10 -4.03351500e-02 -4.63152398e-10 2.99332068e-10 -3.15202359e-12 + 6.46724110e-03 9.99897218e-01 -3.56309221e-10 -1.39719952e-09 -4.23034880e-02 -1.56516319e-11 -2.78544897e-10 5.76434479e-10 + 8.40749646e-03 9.99894393e-01 -2.78702908e-12 -2.14243516e-11 -4.34777055e-02 -1.32028160e-11 -4.90680293e-11 -4.09336434e-13 + 1.04285958e-02 9.99892111e-01 -1.35196247e-12 -1.28748833e-11 -4.44292269e-02 -3.98153471e-11 -1.58125351e-11 6.14934554e-13 + 1.25339076e-02 9.99890059e-01 -2.71489557e-12 -5.53343022e-12 -4.52877602e-02 -2.27315198e-11 -3.93802528e-12 5.26102204e-13 + 1.47269407e-02 9.99888107e-01 -1.23466878e-09 -1.32690472e-09 -4.61060975e-02 -2.05351296e-09 -1.56505680e-09 -1.71568762e-12 + 1.81535550e-02 9.99885307e-01 -6.51200170e-12 -5.04687393e-12 -4.72959467e-02 -2.46007635e-11 -9.73838949e-12 5.44202033e-13 + 2.17229449e-02 9.99882544e-01 -2.28413686e-12 -2.15040129e-12 -4.84740811e-02 -1.12793016e-11 -2.65352514e-12 -4.18627943e-13 + 2.54410593e-02 9.99879766e-01 1.29257591e-12 -9.80421311e-13 -4.96616380e-02 -5.05913767e-12 -2.54979330e-12 -6.99517279e-13 + 2.93140952e-02 9.99876947e-01 -2.23000299e-12 -4.80897131e-12 -5.08699810e-02 -1.24177098e-11 5.14419101e-12 7.41433851e-13 + 3.33485076e-02 9.99874073e-01 1.28936548e-13 -1.17013103e-12 -5.21047295e-02 -4.80590390e-12 4.71718792e-12 3.32816815e-13 + 3.75510205e-02 9.99871132e-01 1.17996715e-12 -1.02364533e-12 -5.33718859e-02 -2.07663173e-12 -1.28745352e-12 -8.83998085e-14 + 4.19286381e-02 9.99868113e-01 1.44124691e-12 -3.89201441e-13 -5.46759754e-02 -3.22302223e-12 -5.48559591e-12 -5.57536869e-13 + 4.64886564e-02 9.99865009e-01 1.14162126e-12 1.35286605e-13 -5.60199674e-02 -2.53976795e-12 -7.24327002e-12 -5.80135528e-13 + 5.12386755e-02 9.99861816e-01 -8.94573332e-14 -4.43000955e-13 -5.74069323e-02 -5.48139856e-12 -6.20037629e-12 -6.27442861e-13 + 5.61866121e-02 9.99858527e-01 -7.17610122e-13 -1.00227162e-12 -5.88390510e-02 -7.99044575e-12 -6.38930357e-12 -7.76033492e-13 + 6.13407126e-02 9.99855139e-01 -3.17630957e-11 -2.80103571e-11 -6.03189260e-02 -5.36661708e-10 -5.68974373e-10 -8.98187119e-11 + 6.93939948e-02 9.99850095e-01 -8.24340911e-12 -4.65761449e-12 -6.26047279e-02 -3.81105377e-11 -2.27794421e-11 -2.51902174e-12 + 7.77828304e-02 9.99844925e-01 -3.99831826e-12 -5.06428872e-12 -6.49599864e-02 -3.12235590e-11 -1.50785577e-11 -4.06052772e-13 + 8.65212008e-02 9.99839626e-01 1.87324601e-12 -8.66005887e-13 -6.73883290e-02 -1.47854438e-11 -1.14372534e-11 3.72549758e-12 + 9.56236699e-02 9.99834196e-01 1.44484325e-12 8.03844220e-14 -6.98924149e-02 -1.61902980e-11 -9.77868039e-12 9.97502044e-13 + 1.05105409e-01 9.99828631e-01 -1.58576119e-12 -2.03124015e-12 -7.24750488e-02 -1.46952884e-11 8.33502200e-12 2.26920153e-12 + 1.14982220e-01 9.99822932e-01 -4.75735164e-12 -3.55155196e-12 -7.51384622e-02 -1.31308036e-11 1.14578221e-11 1.01318920e-12 + 1.25270565e-01 9.99817099e-01 -1.22048513e-12 -2.99028039e-12 -7.78845126e-02 -4.44296288e-12 9.65977794e-12 1.14929077e-12 + 1.35987591e-01 9.99811134e-01 2.59308342e-12 -1.40365816e-12 -8.07150565e-02 4.33336089e-12 5.04847811e-12 1.19454071e-12 + 1.47151159e-01 9.99805040e-01 9.14514826e-13 -1.84491795e-12 -8.36319499e-02 8.59410349e-13 5.55081257e-12 5.30313868e-13 + 1.58779877e-01 9.99798818e-01 -4.05867171e-12 -3.52363513e-12 -8.66369691e-02 -8.69631251e-12 9.41391639e-12 9.30427933e-13 + 1.70893124e-01 9.99792474e-01 -8.30278872e-12 -6.77314180e-12 -8.97313996e-02 -2.51985076e-11 1.70541406e-12 5.31573589e-13 + 1.83511090e-01 9.99786013e-01 -6.64888465e-12 -2.66634075e-12 -9.29163379e-02 -2.04778561e-11 1.86724007e-12 8.82553620e-13 + 1.96654805e-01 9.99779441e-01 -4.59604053e-12 5.54291923e-10 -9.61930496e-02 1.38648077e-10 1.07364442e-10 4.24260016e-10 + 2.17191859e-01 9.99770658e-01 -5.99168688e-12 -2.37075427e-12 -1.01210864e-01 -6.16540018e-11 -8.91415768e-12 1.72713949e-11 + 2.38584623e-01 9.99761924e-01 9.38465087e-12 1.24131895e-11 -1.06334252e-01 -3.82503976e-11 -1.52763958e-11 1.36018426e-11 + 2.60868753e-01 9.99753272e-01 7.45421263e-11 5.69211682e-11 -1.11560755e-01 4.20674815e-11 -2.80599840e-11 2.72966720e-11 + 2.84081388e-01 9.99744740e-01 1.37757725e-10 1.12754450e-10 -1.16887429e-01 1.06239483e-10 -4.95918658e-11 4.21685083e-11 + 3.08261217e-01 9.99736370e-01 1.23779307e-10 1.20542616e-10 -1.22311650e-01 6.89742060e-11 -7.84941376e-11 4.39422104e-11 + 3.33448538e-01 9.99728205e-01 1.74445015e-10 1.54256857e-10 -1.27830074e-01 9.71045858e-11 -1.45697377e-10 3.57199603e-11 + 3.59685331e-01 9.99720297e-01 2.67048533e-10 2.24334090e-10 -1.33438560e-01 1.54506885e-10 -2.22887490e-10 3.48120149e-11 + 3.87015323e-01 9.99712700e-01 3.65455905e-10 2.97070790e-10 -1.39132382e-01 2.24147845e-10 -2.56661523e-10 3.67218062e-11 + 4.15484065e-01 9.99705477e-01 5.08314122e-10 3.76076344e-10 -1.44906315e-01 3.36866459e-10 -2.56606226e-10 5.54250903e-11 + 4.45139005e-01 9.99698696e-01 5.64578237e-10 3.77618418e-10 -1.50754282e-01 3.61318101e-10 -2.20669988e-10 6.04236300e-11 + 4.76029568e-01 9.99692430e-01 5.90907137e-10 3.80209324e-10 -1.56670442e-01 3.04717960e-10 -2.56388214e-10 5.13316844e-11 + 5.08207237e-01 9.99686759e-01 8.35630298e-10 4.87769821e-10 -1.62648170e-01 4.80501688e-10 -2.52644490e-10 7.88005222e-11 + 5.41725642e-01 9.99681773e-01 1.09252919e-09 5.78177628e-10 -1.68679946e-01 5.92928373e-10 -2.59147441e-10 7.57350624e-11 + 5.76640647e-01 9.99677572e-01 1.30659032e-09 5.70326423e-10 -1.74757286e-01 4.78627304e-10 -6.32036711e-10 8.45609250e-11 + 6.13010445e-01 9.99674261e-01 1.73841704e-09 6.76927192e-10 -1.80871901e-01 4.10267821e-10 -9.75258970e-10 6.37301658e-11 + 6.50895651e-01 9.99671957e-01 2.28292944e-09 1.09112961e-09 -1.87014791e-01 4.70396759e-10 -1.26450185e-09 3.97183263e-11 + 6.90359406e-01 9.99670788e-01 2.48997152e-09 1.27395201e-09 -1.93175992e-01 2.03342833e-10 -1.38864661e-09 1.09111558e-10 + 7.31467486e-01 9.99670896e-01 2.91660633e-09 1.23171218e-09 -1.99344529e-01 4.37139713e-10 -1.07603086e-09 2.57190231e-10 + 7.74288401e-01 9.99672435e-01 3.34640710e-09 1.00583608e-09 -2.05509453e-01 5.69943664e-10 -7.24407338e-10 3.10980227e-10 + 8.18893522e-01 9.99675573e-01 3.35013045e-09 3.01704158e-10 -2.11660412e-01 -8.90057492e-11 -9.00186682e-10 1.66989017e-10 + 8.65357189e-01 9.99680496e-01 3.76373860e-09 -5.25935413e-12 -2.17786085e-01 -7.43317470e-11 4.48234033e-10 1.76984365e-10 + 9.13756842e-01 9.99687406e-01 4.30185184e-09 2.08850140e-10 -2.23874873e-01 -4.46329298e-10 1.22435211e-09 7.00366921e-12 + 9.64173148e-01 9.99696526e-01 4.24628049e-09 3.26472751e-10 -2.29915721e-01 -7.71149947e-10 1.04224271e-09 1.26073705e-10 + 1.01669013e+00 9.99708102e-01 4.17877879e-09 1.02008877e-09 -2.35895180e-01 -9.44208807e-10 1.15778464e-09 -6.06397376e-10 + 1.07139533e+00 9.99722402e-01 4.82596228e-09 1.37826508e-09 -2.41799759e-01 -1.68927741e-09 -5.52407666e-11 -1.06312674e-09 + 1.12837990e+00 9.99739724e-01 4.51625765e-09 3.03989291e-10 -2.47615395e-01 -4.08484851e-09 5.26023389e-10 -1.48085234e-09 + 1.18773883e+00 9.99760394e-01 5.48781979e-09 -1.82564686e-09 -2.53330882e-01 -6.12255160e-09 5.98445815e-09 -2.34394203e-09 + 1.20000000e+00 9.99759023e-01 2.61685452e-11 -1.09238373e-11 -2.54504369e-01 3.85584170e-11 -7.33548595e-12 8.02673036e-12 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_global.txt new file mode 100644 index 0000000..b9e23fe --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_global.txt @@ -0,0 +1,70 @@ + # Time Volume Sxx Syy Szz Sxy Sxz Syz + 5.00000000e-05 1.00000156e+00 1.21038456e-14 1.21280482e-14 6.42975026e-04 2.31406498e-16 1.06522289e-15 -6.87555586e-17 + 2.06250000e-04 1.00000645e+00 4.26120026e-13 4.28838045e-13 2.65222940e-03 -1.34576235e-14 1.93526415e-14 -3.74845963e-15 + 6.94531250e-04 1.00002172e+00 1.36322112e-11 1.37220196e-11 8.93073348e-03 -4.63022685e-13 7.36986367e-13 -1.10480431e-13 + 2.22041016e-03 1.00006925e+00 -1.78469679e-11 1.65132296e-10 2.84676846e-02 4.84890041e-11 9.48052910e-11 -4.88204743e-12 + 4.60459595e-03 1.00009813e+00 1.04711135e-09 -2.64506171e-10 4.03244212e-02 1.70785901e-11 -4.62169345e-10 -4.53172180e-11 + 6.46724110e-03 1.00010295e+00 8.39996824e-10 1.74364922e-09 4.22940923e-02 3.27115109e-10 -2.00526810e-10 -5.43533441e-10 + 8.40749646e-03 1.00010584e+00 1.35728685e-12 -1.39083962e-11 4.34688223e-02 -1.72646669e-11 -2.97484721e-11 -5.71559651e-13 + 1.04285958e-02 1.00010819e+00 -1.22760553e-13 -4.97395092e-12 4.44204610e-02 -2.77716175e-11 -1.21135454e-11 -2.78669442e-14 + 1.25339076e-02 1.00011031e+00 -2.00403548e-13 -2.08893888e-12 4.52786760e-02 -6.07808187e-12 -6.33601826e-12 4.29126491e-13 + 1.47269407e-02 1.00011233e+00 3.94844564e-10 4.90950605e-11 4.60964482e-02 -1.70165106e-09 -1.27260465e-09 1.11661340e-10 + 1.81535550e-02 1.00011532e+00 1.08484414e-14 1.33524187e-12 4.72849483e-02 3.25554756e-12 -2.59471759e-12 2.78999575e-12 + 2.17229449e-02 1.00011828e+00 4.32292781e-14 2.21608782e-12 4.84611478e-02 8.50342310e-12 8.89583873e-13 1.10449259e-12 + 2.54410593e-02 1.00012127e+00 -2.60127590e-13 8.63295344e-13 4.96460722e-02 2.26480648e-12 1.21337866e-13 2.06257393e-13 + 2.93140952e-02 1.00012432e+00 -2.12444887e-12 -2.28776647e-12 5.08509268e-02 -2.08845160e-12 4.87247773e-12 1.30129661e-12 + 3.33485076e-02 1.00012744e+00 3.32259958e-13 2.20238994e-12 5.20812749e-02 2.80120628e-12 -2.16330838e-12 -1.19351614e-13 + 3.75510205e-02 1.00013065e+00 -8.71318379e-13 1.40840436e-12 5.33432568e-02 5.81115371e-13 -6.31504592e-13 2.42284876e-13 + 4.19286381e-02 1.00013395e+00 -6.72606475e-13 7.70841928e-13 5.46411212e-02 1.30786220e-12 1.20678747e-12 4.56345404e-13 + 4.64886564e-02 1.00013736e+00 -4.75536929e-13 3.72711767e-13 5.59778401e-02 -3.04707642e-13 1.09455955e-12 5.47644086e-13 + 5.12386755e-02 1.00014089e+00 1.37903962e-13 4.39966044e-13 5.73562615e-02 2.60650077e-12 7.63736922e-13 5.39684392e-13 + 5.61866121e-02 1.00014454e+00 4.53920929e-13 8.10661353e-13 5.87783729e-02 5.84868620e-12 2.15935386e-12 9.24963272e-13 + 6.13407126e-02 1.00014832e+00 4.93923323e-13 6.58039384e-13 6.02466982e-02 4.38204544e-12 -3.29281935e-13 8.12941419e-13 + 6.67095674e-02 1.00015223e+00 2.48031602e-13 9.47811757e-13 6.17632045e-02 2.31390998e-12 -2.17233660e-12 3.14153424e-13 + 7.23021245e-02 1.00015628e+00 -6.37458824e-13 5.52072192e-13 6.33297982e-02 -8.06972956e-14 -7.28901432e-13 -4.18027151e-14 + 7.81277047e-02 1.00016048e+00 -1.17325785e-12 -6.57479369e-14 6.49483954e-02 -2.47935430e-12 1.31694435e-12 -2.81458222e-13 + 8.41960175e-02 1.00016483e+00 -1.44291958e-12 2.27917538e-13 6.66210144e-02 -3.78919257e-12 2.14971341e-12 -2.55140701e-13 + 9.05171766e-02 1.00016934e+00 1.25799566e-10 1.46253908e-10 6.83494258e-02 3.67365402e-10 -1.18691711e-10 7.52520691e-11 + 1.00393988e-01 1.00017658e+00 -5.02263951e-12 1.12581685e-12 7.10182076e-02 -4.99190342e-12 8.80775110e-13 -2.07501101e-12 + 1.10682333e-01 1.00018407e+00 -7.14802860e-12 2.05556993e-12 7.37650406e-02 -1.41126833e-11 -3.62992144e-12 -2.82208188e-12 + 1.21399359e-01 1.00019182e+00 -5.69503270e-12 1.92601333e-13 7.65915851e-02 -1.48233656e-11 -9.92650862e-12 -2.83334123e-12 + 1.32562927e-01 1.00019984e+00 -1.06413954e-11 -1.94443697e-12 7.94993662e-02 -2.18817797e-11 -9.14138372e-12 -3.08204794e-12 + 1.44191645e-01 1.00020813e+00 -1.28122833e-11 -2.92915168e-12 8.24898901e-02 -3.10482778e-11 -1.95356608e-12 -2.39067432e-12 + 1.56304892e-01 1.00021672e+00 -1.69777216e-11 5.46669510e-13 8.55641000e-02 -3.41551399e-11 5.19353048e-13 -3.17095229e-12 + 1.68922858e-01 1.00022560e+00 -2.02036842e-11 -4.71708438e-12 8.87229742e-02 -4.31347555e-11 8.24805604e-12 -5.11311784e-12 + 1.82066573e-01 1.00023479e+00 -2.09145191e-11 -1.04014643e-11 9.19674957e-02 -5.09061125e-11 1.53002617e-11 -5.39250846e-12 + 1.95757942e-01 1.00024429e+00 -2.10569532e-11 -1.10130949e-11 9.52980708e-02 -5.36406337e-11 1.68185438e-11 -6.57584487e-12 + 2.10019785e-01 1.00025412e+00 -2.58136032e-11 -1.23966530e-11 9.87149279e-02 -6.42799152e-11 5.74784380e-12 -1.08682199e-11 + 2.24875872e-01 1.00026429e+00 -3.05152935e-11 -1.66768891e-11 1.02218321e-01 -7.99263271e-11 -5.88512917e-12 -8.74603943e-12 + 2.40350962e-01 1.00027479e+00 -3.55002371e-11 -1.87511779e-11 1.05808206e-01 -9.28076174e-11 -2.80176054e-12 -7.01710006e-12 + 2.56470847e-01 1.00028566e+00 -4.45961203e-11 -2.33714479e-11 1.09484581e-01 -1.11322883e-10 7.28651065e-12 -9.54285324e-12 + 2.73262395e-01 1.00029689e+00 -5.07805029e-11 -2.66928451e-11 1.13247025e-01 -1.22096923e-10 1.34772736e-11 -1.33208902e-11 + 2.90753590e-01 1.00030849e+00 -5.38036150e-11 -2.89353420e-11 1.17094873e-01 -1.26742014e-10 9.00504570e-12 -1.70708098e-11 + 3.08973585e-01 1.00032048e+00 -5.20335669e-11 -2.76733102e-11 1.21027296e-01 -1.25761518e-10 1.02661236e-11 -1.71569930e-11 + 3.27952746e-01 1.00033288e+00 -5.63288355e-11 -3.06735510e-11 1.25043107e-01 -1.41438261e-10 2.19191952e-11 -1.68587321e-11 + 3.47722706e-01 1.00034568e+00 -7.03698557e-11 -3.95746862e-11 1.29140948e-01 -1.78989605e-10 2.09001824e-11 -1.83062512e-11 + 3.68316415e-01 1.00035891e+00 -7.75732555e-11 -4.31857145e-11 1.33318983e-01 -2.31673375e-10 5.60846510e-12 -2.63515932e-11 + 3.89768194e-01 1.00037257e+00 -9.11499228e-11 -5.18226283e-11 1.37575096e-01 -2.94626694e-10 -2.61081483e-11 -3.20811286e-11 + 4.12113798e-01 1.00038668e+00 -1.10769861e-10 -6.55036204e-11 1.41907012e-01 -3.57360639e-10 -5.90262125e-12 -3.64316042e-11 + 4.35390468e-01 1.00040126e+00 -1.45364346e-10 -8.71814839e-11 1.46312241e-01 -4.88173776e-10 5.47777810e-11 -3.58664496e-11 + 4.59637000e-01 1.00041631e+00 -1.87518975e-10 -1.15399036e-10 1.50787729e-01 -6.50709975e-10 9.60853647e-11 -4.28906163e-11 + 4.84893803e-01 1.00043187e+00 -2.01648593e-10 -1.21483537e-10 1.55330068e-01 -7.42277035e-10 6.14761991e-11 -5.63225564e-11 + 5.11202974e-01 1.00044793e+00 -2.30152895e-10 -1.37035525e-10 1.59935509e-01 -8.35053015e-10 7.27899268e-13 -7.60034339e-11 + 5.38608360e-01 1.00046452e+00 -3.00850935e-10 -1.75508083e-10 1.64600152e-01 -1.00582792e-09 -4.28199118e-11 -1.00570917e-10 + 5.67155637e-01 1.00048166e+00 -3.65317276e-10 -2.08122992e-10 1.69319732e-01 -1.23986547e-09 -2.94835440e-11 -1.12768087e-10 + 5.96892384e-01 1.00049936e+00 -4.26591896e-10 -2.23634280e-10 1.74089466e-01 -1.50986599e-09 5.67494909e-11 -1.21468521e-10 + 6.27868162e-01 1.00051766e+00 -5.51376923e-10 -2.52811929e-10 1.78904390e-01 -1.93606700e-09 8.40882056e-11 -1.56495556e-10 + 6.60134598e-01 1.00053657e+00 -7.75007973e-10 -3.49601564e-10 1.83759342e-01 -2.60220479e-09 -2.12531385e-10 -2.77843063e-10 + 6.93745468e-01 1.00055611e+00 -8.78547554e-10 -6.09811713e-10 1.88648517e-01 -3.44733916e-09 -5.31175074e-10 -3.25042720e-10 + 7.28756791e-01 1.00057632e+00 -1.14975264e-09 -7.89429099e-10 1.93565999e-01 -4.50187086e-09 -6.67600383e-10 -4.58227993e-10 + 7.65226920e-01 1.00059722e+00 -1.46991266e-09 -9.27289963e-10 1.98505606e-01 -5.61848647e-09 -8.76845604e-10 -5.96092702e-10 + 8.03216637e-01 1.00061884e+00 -1.83116760e-09 -1.08114438e-09 2.03460653e-01 -6.85493932e-09 -1.00115275e-09 -8.38041525e-10 + 8.42789259e-01 1.00064122e+00 -2.01902139e-09 -1.32820734e-09 2.08423674e-01 -8.11487573e-09 -6.52368339e-10 -8.90495411e-10 + 8.84010740e-01 1.00066439e+00 -2.32466613e-09 -1.51269240e-09 2.13388167e-01 -9.68415804e-09 -3.84502118e-10 -9.95052511e-10 + 9.26949783e-01 1.00068840e+00 -2.85410331e-09 -2.01457768e-09 2.18346729e-01 -1.20032907e-08 -9.83694051e-10 -1.28924280e-09 + 9.71677953e-01 1.00071327e+00 -3.20105750e-09 -2.62595579e-09 2.23291645e-01 -1.35277206e-08 -1.98291837e-09 -1.60972310e-09 + 1.01826980e+00 1.00073907e+00 -4.26716380e-09 -3.60466209e-09 2.28214738e-01 -1.65799354e-08 -4.08881849e-09 -2.51502276e-09 + 1.06680297e+00 1.00076583e+00 -5.17573850e-09 -4.15913321e-09 2.33107911e-01 -1.98505250e-08 -4.08066685e-09 -3.18339055e-09 + 1.11735835e+00 1.00079361e+00 -6.57417063e-09 -4.75207583e-09 2.37962087e-01 -2.45439431e-08 -6.46099509e-09 -3.76923013e-09 + 1.17002021e+00 1.00082246e+00 -7.78733810e-09 -4.77147499e-09 2.42768679e-01 -2.80140243e-08 -7.87636859e-09 -4.51894000e-09 + 1.20000000e+00 1.00083445e+00 -6.99039894e-10 -4.27031977e-10 2.45424804e-01 -4.93423862e-09 -1.01815961e-09 -4.04024039e-10 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_region_default_1.txt new file mode 100644 index 0000000..b9e23fe --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m1/avg_stress_region_default_1.txt @@ -0,0 +1,70 @@ + # Time Volume Sxx Syy Szz Sxy Sxz Syz + 5.00000000e-05 1.00000156e+00 1.21038456e-14 1.21280482e-14 6.42975026e-04 2.31406498e-16 1.06522289e-15 -6.87555586e-17 + 2.06250000e-04 1.00000645e+00 4.26120026e-13 4.28838045e-13 2.65222940e-03 -1.34576235e-14 1.93526415e-14 -3.74845963e-15 + 6.94531250e-04 1.00002172e+00 1.36322112e-11 1.37220196e-11 8.93073348e-03 -4.63022685e-13 7.36986367e-13 -1.10480431e-13 + 2.22041016e-03 1.00006925e+00 -1.78469679e-11 1.65132296e-10 2.84676846e-02 4.84890041e-11 9.48052910e-11 -4.88204743e-12 + 4.60459595e-03 1.00009813e+00 1.04711135e-09 -2.64506171e-10 4.03244212e-02 1.70785901e-11 -4.62169345e-10 -4.53172180e-11 + 6.46724110e-03 1.00010295e+00 8.39996824e-10 1.74364922e-09 4.22940923e-02 3.27115109e-10 -2.00526810e-10 -5.43533441e-10 + 8.40749646e-03 1.00010584e+00 1.35728685e-12 -1.39083962e-11 4.34688223e-02 -1.72646669e-11 -2.97484721e-11 -5.71559651e-13 + 1.04285958e-02 1.00010819e+00 -1.22760553e-13 -4.97395092e-12 4.44204610e-02 -2.77716175e-11 -1.21135454e-11 -2.78669442e-14 + 1.25339076e-02 1.00011031e+00 -2.00403548e-13 -2.08893888e-12 4.52786760e-02 -6.07808187e-12 -6.33601826e-12 4.29126491e-13 + 1.47269407e-02 1.00011233e+00 3.94844564e-10 4.90950605e-11 4.60964482e-02 -1.70165106e-09 -1.27260465e-09 1.11661340e-10 + 1.81535550e-02 1.00011532e+00 1.08484414e-14 1.33524187e-12 4.72849483e-02 3.25554756e-12 -2.59471759e-12 2.78999575e-12 + 2.17229449e-02 1.00011828e+00 4.32292781e-14 2.21608782e-12 4.84611478e-02 8.50342310e-12 8.89583873e-13 1.10449259e-12 + 2.54410593e-02 1.00012127e+00 -2.60127590e-13 8.63295344e-13 4.96460722e-02 2.26480648e-12 1.21337866e-13 2.06257393e-13 + 2.93140952e-02 1.00012432e+00 -2.12444887e-12 -2.28776647e-12 5.08509268e-02 -2.08845160e-12 4.87247773e-12 1.30129661e-12 + 3.33485076e-02 1.00012744e+00 3.32259958e-13 2.20238994e-12 5.20812749e-02 2.80120628e-12 -2.16330838e-12 -1.19351614e-13 + 3.75510205e-02 1.00013065e+00 -8.71318379e-13 1.40840436e-12 5.33432568e-02 5.81115371e-13 -6.31504592e-13 2.42284876e-13 + 4.19286381e-02 1.00013395e+00 -6.72606475e-13 7.70841928e-13 5.46411212e-02 1.30786220e-12 1.20678747e-12 4.56345404e-13 + 4.64886564e-02 1.00013736e+00 -4.75536929e-13 3.72711767e-13 5.59778401e-02 -3.04707642e-13 1.09455955e-12 5.47644086e-13 + 5.12386755e-02 1.00014089e+00 1.37903962e-13 4.39966044e-13 5.73562615e-02 2.60650077e-12 7.63736922e-13 5.39684392e-13 + 5.61866121e-02 1.00014454e+00 4.53920929e-13 8.10661353e-13 5.87783729e-02 5.84868620e-12 2.15935386e-12 9.24963272e-13 + 6.13407126e-02 1.00014832e+00 4.93923323e-13 6.58039384e-13 6.02466982e-02 4.38204544e-12 -3.29281935e-13 8.12941419e-13 + 6.67095674e-02 1.00015223e+00 2.48031602e-13 9.47811757e-13 6.17632045e-02 2.31390998e-12 -2.17233660e-12 3.14153424e-13 + 7.23021245e-02 1.00015628e+00 -6.37458824e-13 5.52072192e-13 6.33297982e-02 -8.06972956e-14 -7.28901432e-13 -4.18027151e-14 + 7.81277047e-02 1.00016048e+00 -1.17325785e-12 -6.57479369e-14 6.49483954e-02 -2.47935430e-12 1.31694435e-12 -2.81458222e-13 + 8.41960175e-02 1.00016483e+00 -1.44291958e-12 2.27917538e-13 6.66210144e-02 -3.78919257e-12 2.14971341e-12 -2.55140701e-13 + 9.05171766e-02 1.00016934e+00 1.25799566e-10 1.46253908e-10 6.83494258e-02 3.67365402e-10 -1.18691711e-10 7.52520691e-11 + 1.00393988e-01 1.00017658e+00 -5.02263951e-12 1.12581685e-12 7.10182076e-02 -4.99190342e-12 8.80775110e-13 -2.07501101e-12 + 1.10682333e-01 1.00018407e+00 -7.14802860e-12 2.05556993e-12 7.37650406e-02 -1.41126833e-11 -3.62992144e-12 -2.82208188e-12 + 1.21399359e-01 1.00019182e+00 -5.69503270e-12 1.92601333e-13 7.65915851e-02 -1.48233656e-11 -9.92650862e-12 -2.83334123e-12 + 1.32562927e-01 1.00019984e+00 -1.06413954e-11 -1.94443697e-12 7.94993662e-02 -2.18817797e-11 -9.14138372e-12 -3.08204794e-12 + 1.44191645e-01 1.00020813e+00 -1.28122833e-11 -2.92915168e-12 8.24898901e-02 -3.10482778e-11 -1.95356608e-12 -2.39067432e-12 + 1.56304892e-01 1.00021672e+00 -1.69777216e-11 5.46669510e-13 8.55641000e-02 -3.41551399e-11 5.19353048e-13 -3.17095229e-12 + 1.68922858e-01 1.00022560e+00 -2.02036842e-11 -4.71708438e-12 8.87229742e-02 -4.31347555e-11 8.24805604e-12 -5.11311784e-12 + 1.82066573e-01 1.00023479e+00 -2.09145191e-11 -1.04014643e-11 9.19674957e-02 -5.09061125e-11 1.53002617e-11 -5.39250846e-12 + 1.95757942e-01 1.00024429e+00 -2.10569532e-11 -1.10130949e-11 9.52980708e-02 -5.36406337e-11 1.68185438e-11 -6.57584487e-12 + 2.10019785e-01 1.00025412e+00 -2.58136032e-11 -1.23966530e-11 9.87149279e-02 -6.42799152e-11 5.74784380e-12 -1.08682199e-11 + 2.24875872e-01 1.00026429e+00 -3.05152935e-11 -1.66768891e-11 1.02218321e-01 -7.99263271e-11 -5.88512917e-12 -8.74603943e-12 + 2.40350962e-01 1.00027479e+00 -3.55002371e-11 -1.87511779e-11 1.05808206e-01 -9.28076174e-11 -2.80176054e-12 -7.01710006e-12 + 2.56470847e-01 1.00028566e+00 -4.45961203e-11 -2.33714479e-11 1.09484581e-01 -1.11322883e-10 7.28651065e-12 -9.54285324e-12 + 2.73262395e-01 1.00029689e+00 -5.07805029e-11 -2.66928451e-11 1.13247025e-01 -1.22096923e-10 1.34772736e-11 -1.33208902e-11 + 2.90753590e-01 1.00030849e+00 -5.38036150e-11 -2.89353420e-11 1.17094873e-01 -1.26742014e-10 9.00504570e-12 -1.70708098e-11 + 3.08973585e-01 1.00032048e+00 -5.20335669e-11 -2.76733102e-11 1.21027296e-01 -1.25761518e-10 1.02661236e-11 -1.71569930e-11 + 3.27952746e-01 1.00033288e+00 -5.63288355e-11 -3.06735510e-11 1.25043107e-01 -1.41438261e-10 2.19191952e-11 -1.68587321e-11 + 3.47722706e-01 1.00034568e+00 -7.03698557e-11 -3.95746862e-11 1.29140948e-01 -1.78989605e-10 2.09001824e-11 -1.83062512e-11 + 3.68316415e-01 1.00035891e+00 -7.75732555e-11 -4.31857145e-11 1.33318983e-01 -2.31673375e-10 5.60846510e-12 -2.63515932e-11 + 3.89768194e-01 1.00037257e+00 -9.11499228e-11 -5.18226283e-11 1.37575096e-01 -2.94626694e-10 -2.61081483e-11 -3.20811286e-11 + 4.12113798e-01 1.00038668e+00 -1.10769861e-10 -6.55036204e-11 1.41907012e-01 -3.57360639e-10 -5.90262125e-12 -3.64316042e-11 + 4.35390468e-01 1.00040126e+00 -1.45364346e-10 -8.71814839e-11 1.46312241e-01 -4.88173776e-10 5.47777810e-11 -3.58664496e-11 + 4.59637000e-01 1.00041631e+00 -1.87518975e-10 -1.15399036e-10 1.50787729e-01 -6.50709975e-10 9.60853647e-11 -4.28906163e-11 + 4.84893803e-01 1.00043187e+00 -2.01648593e-10 -1.21483537e-10 1.55330068e-01 -7.42277035e-10 6.14761991e-11 -5.63225564e-11 + 5.11202974e-01 1.00044793e+00 -2.30152895e-10 -1.37035525e-10 1.59935509e-01 -8.35053015e-10 7.27899268e-13 -7.60034339e-11 + 5.38608360e-01 1.00046452e+00 -3.00850935e-10 -1.75508083e-10 1.64600152e-01 -1.00582792e-09 -4.28199118e-11 -1.00570917e-10 + 5.67155637e-01 1.00048166e+00 -3.65317276e-10 -2.08122992e-10 1.69319732e-01 -1.23986547e-09 -2.94835440e-11 -1.12768087e-10 + 5.96892384e-01 1.00049936e+00 -4.26591896e-10 -2.23634280e-10 1.74089466e-01 -1.50986599e-09 5.67494909e-11 -1.21468521e-10 + 6.27868162e-01 1.00051766e+00 -5.51376923e-10 -2.52811929e-10 1.78904390e-01 -1.93606700e-09 8.40882056e-11 -1.56495556e-10 + 6.60134598e-01 1.00053657e+00 -7.75007973e-10 -3.49601564e-10 1.83759342e-01 -2.60220479e-09 -2.12531385e-10 -2.77843063e-10 + 6.93745468e-01 1.00055611e+00 -8.78547554e-10 -6.09811713e-10 1.88648517e-01 -3.44733916e-09 -5.31175074e-10 -3.25042720e-10 + 7.28756791e-01 1.00057632e+00 -1.14975264e-09 -7.89429099e-10 1.93565999e-01 -4.50187086e-09 -6.67600383e-10 -4.58227993e-10 + 7.65226920e-01 1.00059722e+00 -1.46991266e-09 -9.27289963e-10 1.98505606e-01 -5.61848647e-09 -8.76845604e-10 -5.96092702e-10 + 8.03216637e-01 1.00061884e+00 -1.83116760e-09 -1.08114438e-09 2.03460653e-01 -6.85493932e-09 -1.00115275e-09 -8.38041525e-10 + 8.42789259e-01 1.00064122e+00 -2.01902139e-09 -1.32820734e-09 2.08423674e-01 -8.11487573e-09 -6.52368339e-10 -8.90495411e-10 + 8.84010740e-01 1.00066439e+00 -2.32466613e-09 -1.51269240e-09 2.13388167e-01 -9.68415804e-09 -3.84502118e-10 -9.95052511e-10 + 9.26949783e-01 1.00068840e+00 -2.85410331e-09 -2.01457768e-09 2.18346729e-01 -1.20032907e-08 -9.83694051e-10 -1.28924280e-09 + 9.71677953e-01 1.00071327e+00 -3.20105750e-09 -2.62595579e-09 2.23291645e-01 -1.35277206e-08 -1.98291837e-09 -1.60972310e-09 + 1.01826980e+00 1.00073907e+00 -4.26716380e-09 -3.60466209e-09 2.28214738e-01 -1.65799354e-08 -4.08881849e-09 -2.51502276e-09 + 1.06680297e+00 1.00076583e+00 -5.17573850e-09 -4.15913321e-09 2.33107911e-01 -1.98505250e-08 -4.08066685e-09 -3.18339055e-09 + 1.11735835e+00 1.00079361e+00 -6.57417063e-09 -4.75207583e-09 2.37962087e-01 -2.45439431e-08 -6.46099509e-09 -3.76923013e-09 + 1.17002021e+00 1.00082246e+00 -7.78733810e-09 -4.77147499e-09 2.42768679e-01 -2.80140243e-08 -7.87636859e-09 -4.51894000e-09 + 1.20000000e+00 1.00083445e+00 -6.99039894e-10 -4.27031977e-10 2.45424804e-01 -4.93423862e-09 -1.01815961e-09 -4.04024039e-10 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_def_grad_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_def_grad_global.txt new file mode 100644 index 0000000..8942ced --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_def_grad_global.txt @@ -0,0 +1,61 @@ + # Time Volume F11 F12 F13 F21 F22 F23 F31 F32 F33 + 5.00000000e-02 9.99984364e-01 1.00001650e+00 1.84982920e-07 5.66620597e-07 8.91682017e-08 1.00001787e+00 1.23796114e-06 1.77961123e-06 -2.55893657e-06 9.99950000e-01 + 2.06250000e-01 9.99935759e-01 1.00006819e+00 7.08975750e-07 2.62792447e-06 3.42953820e-07 1.00007384e+00 5.25944604e-06 7.02180271e-06 -1.07466402e-05 9.99793750e-01 + 4.50390625e-01 9.99909751e-01 1.00016716e+00 3.01526328e-06 1.78460623e-05 4.34670524e-06 1.00019312e+00 2.54847625e-05 -2.91102157e-08 -3.69871256e-05 9.99549610e-01 + 6.41125488e-01 9.99905645e-01 1.00024951e+00 6.61894342e-06 2.67718113e-05 9.03779694e-06 1.00029755e+00 4.22326845e-05 -6.61482335e-06 -6.18594136e-05 9.99358875e-01 + 8.39807638e-01 9.99903137e-01 1.00033553e+00 1.03818785e-05 3.15340367e-05 1.36450841e-05 1.00040792e+00 5.67418478e-05 -1.09181552e-05 -8.58909758e-05 9.99160194e-01 + 1.04676821e+00 9.99901078e-01 1.00042474e+00 1.38097550e-05 3.35723602e-05 1.81509646e-05 1.00052392e+00 6.85541935e-05 -1.38021838e-05 -1.08727213e-04 9.98953233e-01 + 1.26235214e+00 9.99899206e-01 1.00051727e+00 1.70494421e-05 3.35718206e-05 2.27657139e-05 1.00064548e+00 7.79603120e-05 -1.54694556e-05 -1.30380934e-04 9.98737650e-01 + 1.48691873e+00 9.99897410e-01 1.00061346e+00 2.01692140e-05 3.20357903e-05 2.74307274e-05 1.00077254e+00 8.61288759e-05 -1.60345087e-05 -1.51796556e-04 9.98513083e-01 + 1.72084227e+00 9.99895640e-01 1.00071359e+00 2.33040059e-05 2.97482888e-05 3.22377975e-05 1.00090514e+00 9.35922774e-05 -1.60129857e-05 -1.73218754e-04 9.98279160e-01 + 2.08634779e+00 9.99893048e-01 1.00086999e+00 2.82391937e-05 2.54123093e-05 3.99215427e-05 1.00111274e+00 1.03792872e-04 -1.49950641e-05 -2.05042089e-04 9.97913655e-01 + 2.46708271e+00 9.99890445e-01 1.00103259e+00 3.31814330e-05 2.06151321e-05 4.80440523e-05 1.00132962e+00 1.13622925e-04 -1.34964075e-05 -2.37187359e-04 9.97532920e-01 + 2.86368158e+00 9.99887810e-01 1.00120156e+00 3.80233325e-05 1.58041863e-05 5.65950083e-05 1.00155626e+00 1.23208345e-04 -1.17662286e-05 -2.69879566e-04 9.97136322e-01 + 3.27680541e+00 9.99885127e-01 1.00137720e+00 4.27791021e-05 1.11531480e-05 6.54972829e-05 1.00179304e+00 1.32915615e-04 -9.79505403e-06 -3.03535500e-04 9.96723199e-01 + 3.70714273e+00 9.99882382e-01 1.00155997e+00 4.75143003e-05 6.36539450e-06 7.46789969e-05 1.00204022e+00 1.42977445e-04 -7.14972408e-06 -3.38322208e-04 9.96292862e-01 + 4.15541077e+00 9.99879566e-01 1.00175020e+00 5.24039789e-05 1.46245442e-06 8.41695915e-05 1.00229819e+00 1.53342610e-04 -3.45448756e-06 -3.74285867e-04 9.95844595e-01 + 4.62235665e+00 9.99876673e-01 1.00194826e+00 5.76153279e-05 -3.61998846e-06 9.39326232e-05 1.00256740e+00 1.63924811e-04 1.36271291e-06 -4.11505721e-04 9.95377649e-01 + 5.10875860e+00 9.99873698e-01 1.00215448e+00 6.31792179e-05 -9.03371299e-06 1.03838676e-04 1.00284831e+00 1.74686008e-04 7.23915356e-06 -4.49801747e-04 9.94891248e-01 + 5.61542731e+00 9.99870636e-01 1.00236925e+00 6.91109181e-05 -1.47443325e-05 1.13929562e-04 1.00314140e+00 1.85634851e-04 1.40823240e-05 -4.89170728e-04 9.94384580e-01 + 6.14320721e+00 9.99867483e-01 1.00259295e+00 7.54585736e-05 -2.05709882e-05 1.24253162e-04 1.00344719e+00 1.96783084e-04 2.17294582e-05 -5.29657733e-04 9.93856801e-01 + 6.69297793e+00 9.99864237e-01 1.00282596e+00 8.23770178e-05 -2.65151397e-05 1.34796429e-04 1.00376625e+00 2.08102258e-04 3.02373477e-05 -5.71246741e-04 9.93307031e-01 + 7.26565578e+00 9.99860893e-01 1.00306879e+00 8.98551697e-05 -3.25553006e-05 1.45585674e-04 1.00409903e+00 2.19720593e-04 3.94773317e-05 -6.14217436e-04 9.92734354e-01 + 7.86219520e+00 9.99857450e-01 1.00332195e+00 9.78606823e-05 -3.87700081e-05 1.56707143e-04 1.00444604e+00 2.31746310e-04 4.94344894e-05 -6.58715980e-04 9.92137816e-01 + 8.48359042e+00 9.99853903e-01 1.00358586e+00 1.06348806e-04 -4.51639076e-05 1.68188044e-04 1.00480797e+00 2.44108334e-04 6.01087310e-05 -7.04798350e-04 9.91516422e-01 + 9.13087712e+00 9.99850251e-01 1.00386094e+00 1.15264928e-04 -5.16299082e-05 1.79995958e-04 1.00518548e+00 2.56808901e-04 7.14078436e-05 -7.52679355e-04 9.90869136e-01 + 9.80513409e+00 9.99846491e-01 1.00414778e+00 1.24583286e-04 -5.82164126e-05 1.92114403e-04 1.00557919e+00 2.69812982e-04 8.33074598e-05 -8.02405850e-04 9.90194881e-01 + 1.05074851e+01 9.99842622e-01 1.00444691e+00 1.34282481e-04 -6.49752362e-05 2.04503541e-04 1.00598977e+00 2.83108295e-04 9.58299959e-05 -8.54045707e-04 9.89492531e-01 + 1.16049086e+01 9.99837000e-01 1.00491499e+00 1.49429151e-04 -7.54447989e-05 2.23348064e-04 1.00663263e+00 3.03616548e-04 1.15672511e-04 -9.34745693e-04 9.88395110e-01 + 1.27480580e+01 9.99831265e-01 1.00540327e+00 1.65415056e-04 -8.64152438e-05 2.42660712e-04 1.00730374e+00 3.25080005e-04 1.36715064e-04 -1.01919181e-03 9.87251963e-01 + 1.39388387e+01 9.99825419e-01 1.00591275e+00 1.82431494e-04 -9.79421080e-05 2.62599520e-04 1.00800431e+00 3.47424597e-04 1.58934600e-04 -1.10731282e-03 9.86061185e-01 + 1.51792352e+01 9.99819468e-01 1.00644441e+00 2.00407412e-04 -1.10147440e-04 2.83190522e-04 1.00873568e+00 3.70532555e-04 1.82417285e-04 -1.19900347e-03 9.84820791e-01 + 1.64713149e+01 9.99813418e-01 1.00699916e+00 2.19290428e-04 -1.23043322e-04 3.04534938e-04 1.00949938e+00 3.94493910e-04 2.07202737e-04 -1.29444029e-03 9.83528714e-01 + 1.78172313e+01 9.99807275e-01 1.00757802e+00 2.39024299e-04 -1.36701658e-04 3.26670658e-04 1.01029694e+00 4.19318585e-04 2.33437032e-04 -1.39380976e-03 9.82182801e-01 + 1.92192275e+01 9.99801048e-01 1.00818212e+00 2.59543843e-04 -1.51188522e-04 3.49552663e-04 1.01112990e+00 4.45064061e-04 2.61263784e-04 -1.49739499e-03 9.80780809e-01 + 2.06796402e+01 9.99794747e-01 1.00881268e+00 2.80876822e-04 -1.66613699e-04 3.73232305e-04 1.01199987e+00 4.71859027e-04 2.90807404e-04 -1.60564603e-03 9.79320400e-01 + 2.29615351e+01 9.99786710e-01 1.00980088e+00 3.14334612e-04 -1.91705504e-04 4.09919626e-04 1.01336504e+00 5.14135167e-04 3.38927873e-04 -1.77669488e-03 9.77038511e-01 + 2.53385090e+01 9.99778825e-01 1.01083363e+00 3.49118974e-04 -2.18775172e-04 4.48018693e-04 1.01479331e+00 5.58360276e-04 3.91334880e-04 -1.95645771e-03 9.74661543e-01 + 2.78145234e+01 9.99771134e-01 1.01191343e+00 3.85136735e-04 -2.48080198e-04 4.87897874e-04 1.01628753e+00 6.04714511e-04 4.48314345e-04 -2.14560176e-03 9.72185536e-01 + 3.03937051e+01 9.99763684e-01 1.01304280e+00 4.22406148e-04 -2.79841327e-04 5.29648084e-04 1.01785082e+00 6.53341582e-04 5.10008990e-04 -2.34460567e-03 9.69606362e-01 + 3.30803527e+01 9.99756528e-01 1.01422425e+00 4.60775181e-04 -3.14187385e-04 5.73243267e-04 1.01948668e+00 7.04231773e-04 5.76475022e-04 -2.55360288e-03 9.66919722e-01 + 3.58789439e+01 9.99749723e-01 1.01546035e+00 5.00035848e-04 -3.51375836e-04 6.18644934e-04 1.02119887e+00 7.57658516e-04 6.47874970e-04 -2.77339759e-03 9.64121140e-01 + 3.87941431e+01 9.99743335e-01 1.01675358e+00 5.40091701e-04 -3.92081381e-04 6.66064860e-04 1.02299160e+00 8.13553747e-04 7.24186285e-04 -3.00442276e-03 9.61205949e-01 + 4.18308089e+01 9.99737432e-01 1.01810684e+00 5.80515421e-04 -4.36695463e-04 7.15420929e-04 1.02486906e+00 8.72361332e-04 8.05481586e-04 -3.24780879e-03 9.58169293e-01 + 4.49940025e+01 9.99732089e-01 1.01952335e+00 6.20891089e-04 -4.85467867e-04 7.66853113e-04 1.02683557e+00 9.34171084e-04 8.91959331e-04 -3.50433625e-03 9.55006110e-01 + 4.82889958e+01 9.99727392e-01 1.02100619e+00 6.60845408e-04 -5.38805201e-04 8.20407551e-04 1.02889603e+00 9.99245422e-04 9.83752446e-04 -3.77525089e-03 9.51711127e-01 + 5.17212805e+01 9.99723435e-01 1.02255869e+00 7.00264016e-04 -5.96954610e-04 8.76280887e-04 1.03105561e+00 1.06783183e-03 1.08086936e-03 -4.06172413e-03 9.48278853e-01 + 5.52965771e+01 9.99720318e-01 1.02418452e+00 7.39089479e-04 -6.60146928e-04 9.34478380e-04 1.03331965e+00 1.13994335e-03 1.18441931e-03 -4.36486603e-03 9.44703567e-01 + 5.90208444e+01 9.99718152e-01 1.02588689e+00 7.77028643e-04 -7.29508639e-04 9.94897989e-04 1.03569449e+00 1.21536374e-03 1.29680388e-03 -4.68562049e-03 9.40979311e-01 + 6.29002894e+01 9.99717059e-01 1.02766927e+00 8.13905598e-04 -8.05715808e-04 1.05775290e-03 1.03818688e+00 1.29382168e-03 1.41992010e-03 -5.02439897e-03 9.37099877e-01 + 6.69413780e+01 9.99717171e-01 1.02953655e+00 8.49871468e-04 -8.88906832e-04 1.12337503e-03 1.04080274e+00 1.37568572e-03 1.55325526e-03 -5.38285720e-03 9.33058799e-01 + 7.11508453e+01 9.99718632e-01 1.03149291e+00 8.85141477e-04 -9.79232455e-04 1.19164581e-03 1.04354945e+00 1.46131487e-03 1.69594115e-03 -5.76371076e-03 9.28849343e-01 + 7.55357071e+01 9.99721604e-01 1.03354315e+00 9.19674343e-04 -1.07615904e-03 1.26268031e-03 1.04643456e+00 1.55023978e-03 1.84776622e-03 -6.16873059e-03 9.24464492e-01 + 8.01032714e+01 9.99726262e-01 1.03569304e+00 9.53355596e-04 -1.17953241e-03 1.33704496e-03 1.04946553e+00 1.64249241e-03 2.00932452e-03 -6.59853083e-03 9.19896937e-01 + 8.48611510e+01 9.99732796e-01 1.03794788e+00 9.85506347e-04 -1.28996596e-03 1.41482619e-03 1.05265116e+00 1.73854505e-03 2.18119754e-03 -7.05448490e-03 9.15139066e-01 + 8.98172755e+01 9.99741417e-01 1.04031329e+00 1.01523030e-03 -1.40858835e-03 1.49557814e-03 1.05600097e+00 1.83816020e-03 2.36565805e-03 -7.53672090e-03 9.10182950e-01 + 9.49799051e+01 9.99752360e-01 1.04279632e+00 1.04188740e-03 -1.53564613e-03 1.57889382e-03 1.05952412e+00 1.94077088e-03 2.56413533e-03 -8.04599932e-03 9.05020327e-01 + 1.00357644e+02 9.99765879e-01 1.04540443e+00 1.06439987e-03 -1.67123281e-03 1.66320028e-03 1.06323046e+00 2.04703703e-03 2.77979383e-03 -8.58406368e-03 8.99642594e-01 + 1.05959456e+02 9.99782255e-01 1.04814666e+00 1.08172041e-03 -1.81480613e-03 1.74600716e-03 1.06712957e+00 2.15641487e-03 3.01385434e-03 -9.15192102e-03 8.94040785e-01 + 1.11794677e+02 9.99801800e-01 1.05103302e+00 1.09343125e-03 -1.96561201e-03 1.82707126e-03 1.07123155e+00 2.26809865e-03 3.26481031e-03 -9.75025138e-03 8.88205565e-01 + 1.17873031e+02 9.99824854e-01 1.05407296e+00 1.09847101e-03 -2.12199027e-03 1.90645318e-03 1.07554859e+00 2.38108335e-03 3.53299508e-03 -1.03798473e-02 8.82127207e-01 + 1.20000000e+02 9.99824800e-01 1.05514085e+00 1.09929381e-03 -2.17638012e-03 1.93348929e-03 1.07706464e+00 2.42027069e-03 3.62769437e-03 -1.06011240e-02 8.80000244e-01 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_def_grad_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_def_grad_region_default_1.txt new file mode 100644 index 0000000..8942ced --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_def_grad_region_default_1.txt @@ -0,0 +1,61 @@ + # Time Volume F11 F12 F13 F21 F22 F23 F31 F32 F33 + 5.00000000e-02 9.99984364e-01 1.00001650e+00 1.84982920e-07 5.66620597e-07 8.91682017e-08 1.00001787e+00 1.23796114e-06 1.77961123e-06 -2.55893657e-06 9.99950000e-01 + 2.06250000e-01 9.99935759e-01 1.00006819e+00 7.08975750e-07 2.62792447e-06 3.42953820e-07 1.00007384e+00 5.25944604e-06 7.02180271e-06 -1.07466402e-05 9.99793750e-01 + 4.50390625e-01 9.99909751e-01 1.00016716e+00 3.01526328e-06 1.78460623e-05 4.34670524e-06 1.00019312e+00 2.54847625e-05 -2.91102157e-08 -3.69871256e-05 9.99549610e-01 + 6.41125488e-01 9.99905645e-01 1.00024951e+00 6.61894342e-06 2.67718113e-05 9.03779694e-06 1.00029755e+00 4.22326845e-05 -6.61482335e-06 -6.18594136e-05 9.99358875e-01 + 8.39807638e-01 9.99903137e-01 1.00033553e+00 1.03818785e-05 3.15340367e-05 1.36450841e-05 1.00040792e+00 5.67418478e-05 -1.09181552e-05 -8.58909758e-05 9.99160194e-01 + 1.04676821e+00 9.99901078e-01 1.00042474e+00 1.38097550e-05 3.35723602e-05 1.81509646e-05 1.00052392e+00 6.85541935e-05 -1.38021838e-05 -1.08727213e-04 9.98953233e-01 + 1.26235214e+00 9.99899206e-01 1.00051727e+00 1.70494421e-05 3.35718206e-05 2.27657139e-05 1.00064548e+00 7.79603120e-05 -1.54694556e-05 -1.30380934e-04 9.98737650e-01 + 1.48691873e+00 9.99897410e-01 1.00061346e+00 2.01692140e-05 3.20357903e-05 2.74307274e-05 1.00077254e+00 8.61288759e-05 -1.60345087e-05 -1.51796556e-04 9.98513083e-01 + 1.72084227e+00 9.99895640e-01 1.00071359e+00 2.33040059e-05 2.97482888e-05 3.22377975e-05 1.00090514e+00 9.35922774e-05 -1.60129857e-05 -1.73218754e-04 9.98279160e-01 + 2.08634779e+00 9.99893048e-01 1.00086999e+00 2.82391937e-05 2.54123093e-05 3.99215427e-05 1.00111274e+00 1.03792872e-04 -1.49950641e-05 -2.05042089e-04 9.97913655e-01 + 2.46708271e+00 9.99890445e-01 1.00103259e+00 3.31814330e-05 2.06151321e-05 4.80440523e-05 1.00132962e+00 1.13622925e-04 -1.34964075e-05 -2.37187359e-04 9.97532920e-01 + 2.86368158e+00 9.99887810e-01 1.00120156e+00 3.80233325e-05 1.58041863e-05 5.65950083e-05 1.00155626e+00 1.23208345e-04 -1.17662286e-05 -2.69879566e-04 9.97136322e-01 + 3.27680541e+00 9.99885127e-01 1.00137720e+00 4.27791021e-05 1.11531480e-05 6.54972829e-05 1.00179304e+00 1.32915615e-04 -9.79505403e-06 -3.03535500e-04 9.96723199e-01 + 3.70714273e+00 9.99882382e-01 1.00155997e+00 4.75143003e-05 6.36539450e-06 7.46789969e-05 1.00204022e+00 1.42977445e-04 -7.14972408e-06 -3.38322208e-04 9.96292862e-01 + 4.15541077e+00 9.99879566e-01 1.00175020e+00 5.24039789e-05 1.46245442e-06 8.41695915e-05 1.00229819e+00 1.53342610e-04 -3.45448756e-06 -3.74285867e-04 9.95844595e-01 + 4.62235665e+00 9.99876673e-01 1.00194826e+00 5.76153279e-05 -3.61998846e-06 9.39326232e-05 1.00256740e+00 1.63924811e-04 1.36271291e-06 -4.11505721e-04 9.95377649e-01 + 5.10875860e+00 9.99873698e-01 1.00215448e+00 6.31792179e-05 -9.03371299e-06 1.03838676e-04 1.00284831e+00 1.74686008e-04 7.23915356e-06 -4.49801747e-04 9.94891248e-01 + 5.61542731e+00 9.99870636e-01 1.00236925e+00 6.91109181e-05 -1.47443325e-05 1.13929562e-04 1.00314140e+00 1.85634851e-04 1.40823240e-05 -4.89170728e-04 9.94384580e-01 + 6.14320721e+00 9.99867483e-01 1.00259295e+00 7.54585736e-05 -2.05709882e-05 1.24253162e-04 1.00344719e+00 1.96783084e-04 2.17294582e-05 -5.29657733e-04 9.93856801e-01 + 6.69297793e+00 9.99864237e-01 1.00282596e+00 8.23770178e-05 -2.65151397e-05 1.34796429e-04 1.00376625e+00 2.08102258e-04 3.02373477e-05 -5.71246741e-04 9.93307031e-01 + 7.26565578e+00 9.99860893e-01 1.00306879e+00 8.98551697e-05 -3.25553006e-05 1.45585674e-04 1.00409903e+00 2.19720593e-04 3.94773317e-05 -6.14217436e-04 9.92734354e-01 + 7.86219520e+00 9.99857450e-01 1.00332195e+00 9.78606823e-05 -3.87700081e-05 1.56707143e-04 1.00444604e+00 2.31746310e-04 4.94344894e-05 -6.58715980e-04 9.92137816e-01 + 8.48359042e+00 9.99853903e-01 1.00358586e+00 1.06348806e-04 -4.51639076e-05 1.68188044e-04 1.00480797e+00 2.44108334e-04 6.01087310e-05 -7.04798350e-04 9.91516422e-01 + 9.13087712e+00 9.99850251e-01 1.00386094e+00 1.15264928e-04 -5.16299082e-05 1.79995958e-04 1.00518548e+00 2.56808901e-04 7.14078436e-05 -7.52679355e-04 9.90869136e-01 + 9.80513409e+00 9.99846491e-01 1.00414778e+00 1.24583286e-04 -5.82164126e-05 1.92114403e-04 1.00557919e+00 2.69812982e-04 8.33074598e-05 -8.02405850e-04 9.90194881e-01 + 1.05074851e+01 9.99842622e-01 1.00444691e+00 1.34282481e-04 -6.49752362e-05 2.04503541e-04 1.00598977e+00 2.83108295e-04 9.58299959e-05 -8.54045707e-04 9.89492531e-01 + 1.16049086e+01 9.99837000e-01 1.00491499e+00 1.49429151e-04 -7.54447989e-05 2.23348064e-04 1.00663263e+00 3.03616548e-04 1.15672511e-04 -9.34745693e-04 9.88395110e-01 + 1.27480580e+01 9.99831265e-01 1.00540327e+00 1.65415056e-04 -8.64152438e-05 2.42660712e-04 1.00730374e+00 3.25080005e-04 1.36715064e-04 -1.01919181e-03 9.87251963e-01 + 1.39388387e+01 9.99825419e-01 1.00591275e+00 1.82431494e-04 -9.79421080e-05 2.62599520e-04 1.00800431e+00 3.47424597e-04 1.58934600e-04 -1.10731282e-03 9.86061185e-01 + 1.51792352e+01 9.99819468e-01 1.00644441e+00 2.00407412e-04 -1.10147440e-04 2.83190522e-04 1.00873568e+00 3.70532555e-04 1.82417285e-04 -1.19900347e-03 9.84820791e-01 + 1.64713149e+01 9.99813418e-01 1.00699916e+00 2.19290428e-04 -1.23043322e-04 3.04534938e-04 1.00949938e+00 3.94493910e-04 2.07202737e-04 -1.29444029e-03 9.83528714e-01 + 1.78172313e+01 9.99807275e-01 1.00757802e+00 2.39024299e-04 -1.36701658e-04 3.26670658e-04 1.01029694e+00 4.19318585e-04 2.33437032e-04 -1.39380976e-03 9.82182801e-01 + 1.92192275e+01 9.99801048e-01 1.00818212e+00 2.59543843e-04 -1.51188522e-04 3.49552663e-04 1.01112990e+00 4.45064061e-04 2.61263784e-04 -1.49739499e-03 9.80780809e-01 + 2.06796402e+01 9.99794747e-01 1.00881268e+00 2.80876822e-04 -1.66613699e-04 3.73232305e-04 1.01199987e+00 4.71859027e-04 2.90807404e-04 -1.60564603e-03 9.79320400e-01 + 2.29615351e+01 9.99786710e-01 1.00980088e+00 3.14334612e-04 -1.91705504e-04 4.09919626e-04 1.01336504e+00 5.14135167e-04 3.38927873e-04 -1.77669488e-03 9.77038511e-01 + 2.53385090e+01 9.99778825e-01 1.01083363e+00 3.49118974e-04 -2.18775172e-04 4.48018693e-04 1.01479331e+00 5.58360276e-04 3.91334880e-04 -1.95645771e-03 9.74661543e-01 + 2.78145234e+01 9.99771134e-01 1.01191343e+00 3.85136735e-04 -2.48080198e-04 4.87897874e-04 1.01628753e+00 6.04714511e-04 4.48314345e-04 -2.14560176e-03 9.72185536e-01 + 3.03937051e+01 9.99763684e-01 1.01304280e+00 4.22406148e-04 -2.79841327e-04 5.29648084e-04 1.01785082e+00 6.53341582e-04 5.10008990e-04 -2.34460567e-03 9.69606362e-01 + 3.30803527e+01 9.99756528e-01 1.01422425e+00 4.60775181e-04 -3.14187385e-04 5.73243267e-04 1.01948668e+00 7.04231773e-04 5.76475022e-04 -2.55360288e-03 9.66919722e-01 + 3.58789439e+01 9.99749723e-01 1.01546035e+00 5.00035848e-04 -3.51375836e-04 6.18644934e-04 1.02119887e+00 7.57658516e-04 6.47874970e-04 -2.77339759e-03 9.64121140e-01 + 3.87941431e+01 9.99743335e-01 1.01675358e+00 5.40091701e-04 -3.92081381e-04 6.66064860e-04 1.02299160e+00 8.13553747e-04 7.24186285e-04 -3.00442276e-03 9.61205949e-01 + 4.18308089e+01 9.99737432e-01 1.01810684e+00 5.80515421e-04 -4.36695463e-04 7.15420929e-04 1.02486906e+00 8.72361332e-04 8.05481586e-04 -3.24780879e-03 9.58169293e-01 + 4.49940025e+01 9.99732089e-01 1.01952335e+00 6.20891089e-04 -4.85467867e-04 7.66853113e-04 1.02683557e+00 9.34171084e-04 8.91959331e-04 -3.50433625e-03 9.55006110e-01 + 4.82889958e+01 9.99727392e-01 1.02100619e+00 6.60845408e-04 -5.38805201e-04 8.20407551e-04 1.02889603e+00 9.99245422e-04 9.83752446e-04 -3.77525089e-03 9.51711127e-01 + 5.17212805e+01 9.99723435e-01 1.02255869e+00 7.00264016e-04 -5.96954610e-04 8.76280887e-04 1.03105561e+00 1.06783183e-03 1.08086936e-03 -4.06172413e-03 9.48278853e-01 + 5.52965771e+01 9.99720318e-01 1.02418452e+00 7.39089479e-04 -6.60146928e-04 9.34478380e-04 1.03331965e+00 1.13994335e-03 1.18441931e-03 -4.36486603e-03 9.44703567e-01 + 5.90208444e+01 9.99718152e-01 1.02588689e+00 7.77028643e-04 -7.29508639e-04 9.94897989e-04 1.03569449e+00 1.21536374e-03 1.29680388e-03 -4.68562049e-03 9.40979311e-01 + 6.29002894e+01 9.99717059e-01 1.02766927e+00 8.13905598e-04 -8.05715808e-04 1.05775290e-03 1.03818688e+00 1.29382168e-03 1.41992010e-03 -5.02439897e-03 9.37099877e-01 + 6.69413780e+01 9.99717171e-01 1.02953655e+00 8.49871468e-04 -8.88906832e-04 1.12337503e-03 1.04080274e+00 1.37568572e-03 1.55325526e-03 -5.38285720e-03 9.33058799e-01 + 7.11508453e+01 9.99718632e-01 1.03149291e+00 8.85141477e-04 -9.79232455e-04 1.19164581e-03 1.04354945e+00 1.46131487e-03 1.69594115e-03 -5.76371076e-03 9.28849343e-01 + 7.55357071e+01 9.99721604e-01 1.03354315e+00 9.19674343e-04 -1.07615904e-03 1.26268031e-03 1.04643456e+00 1.55023978e-03 1.84776622e-03 -6.16873059e-03 9.24464492e-01 + 8.01032714e+01 9.99726262e-01 1.03569304e+00 9.53355596e-04 -1.17953241e-03 1.33704496e-03 1.04946553e+00 1.64249241e-03 2.00932452e-03 -6.59853083e-03 9.19896937e-01 + 8.48611510e+01 9.99732796e-01 1.03794788e+00 9.85506347e-04 -1.28996596e-03 1.41482619e-03 1.05265116e+00 1.73854505e-03 2.18119754e-03 -7.05448490e-03 9.15139066e-01 + 8.98172755e+01 9.99741417e-01 1.04031329e+00 1.01523030e-03 -1.40858835e-03 1.49557814e-03 1.05600097e+00 1.83816020e-03 2.36565805e-03 -7.53672090e-03 9.10182950e-01 + 9.49799051e+01 9.99752360e-01 1.04279632e+00 1.04188740e-03 -1.53564613e-03 1.57889382e-03 1.05952412e+00 1.94077088e-03 2.56413533e-03 -8.04599932e-03 9.05020327e-01 + 1.00357644e+02 9.99765879e-01 1.04540443e+00 1.06439987e-03 -1.67123281e-03 1.66320028e-03 1.06323046e+00 2.04703703e-03 2.77979383e-03 -8.58406368e-03 8.99642594e-01 + 1.05959456e+02 9.99782255e-01 1.04814666e+00 1.08172041e-03 -1.81480613e-03 1.74600716e-03 1.06712957e+00 2.15641487e-03 3.01385434e-03 -9.15192102e-03 8.94040785e-01 + 1.11794677e+02 9.99801800e-01 1.05103302e+00 1.09343125e-03 -1.96561201e-03 1.82707126e-03 1.07123155e+00 2.26809865e-03 3.26481031e-03 -9.75025138e-03 8.88205565e-01 + 1.17873031e+02 9.99824854e-01 1.05407296e+00 1.09847101e-03 -2.12199027e-03 1.90645318e-03 1.07554859e+00 2.38108335e-03 3.53299508e-03 -1.03798473e-02 8.82127207e-01 + 1.20000000e+02 9.99824800e-01 1.05514085e+00 1.09929381e-03 -2.17638012e-03 1.93348929e-03 1.07706464e+00 2.42027069e-03 3.62769437e-03 -1.06011240e-02 8.80000244e-01 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_elastic_strain_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_elastic_strain_global.txt new file mode 100644 index 0000000..4bf4fb2 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_elastic_strain_global.txt @@ -0,0 +1,61 @@ + # Time Volume Ee11 Ee22 Ee33 Ee23 Ee13 Ee12 + 5.00000000e-02 9.99984364e-01 6.52003825e-07 5.97931386e-07 -7.28543824e-06 -3.45217001e-07 3.57865458e-07 -6.10392343e-09 + 2.06250000e-01 9.99935759e-01 2.70919917e-06 2.45910974e-06 -2.99409069e-05 -1.42185673e-06 1.46827408e-06 -2.32850540e-08 + 4.50390625e-01 9.99909751e-01 3.60933531e-06 4.65625706e-06 -4.18236912e-05 -2.25534397e-06 2.12169909e-06 5.74713505e-07 + 6.41125488e-01 9.99905645e-01 3.58532871e-06 4.89965655e-06 -4.33697199e-05 -2.36220578e-06 2.15151641e-06 9.13068315e-07 + 8.39807638e-01 9.99903137e-01 3.63753731e-06 4.90831606e-06 -4.42289530e-05 -2.37865204e-06 2.17619019e-06 1.09052126e-06 + 1.04676821e+00 9.99901078e-01 3.71307652e-06 4.89393340e-06 -4.49373720e-05 -2.36569049e-06 2.21080822e-06 1.22020841e-06 + 1.26235214e+00 9.99899206e-01 3.80320636e-06 4.87649452e-06 -4.55872737e-05 -2.35081250e-06 2.24676952e-06 1.33873103e-06 + 1.48691873e+00 9.99897410e-01 3.90936724e-06 4.85737408e-06 -4.62230085e-05 -2.33628919e-06 2.28710170e-06 1.44405441e-06 + 1.72084227e+00 9.99895640e-01 4.01955208e-06 4.84212896e-06 -4.68665829e-05 -2.32240072e-06 2.33841758e-06 1.54041770e-06 + 2.08634779e+00 9.99893048e-01 4.17691793e-06 4.82123605e-06 -4.78534395e-05 -2.30084519e-06 2.43068773e-06 1.67739474e-06 + 2.46708271e+00 9.99890445e-01 4.32816925e-06 4.80664364e-06 -4.88731635e-05 -2.27505023e-06 2.53218899e-06 1.81025405e-06 + 2.86368158e+00 9.99887810e-01 4.47583771e-06 4.81028744e-06 -4.99272874e-05 -2.24917529e-06 2.63254547e-06 1.93993717e-06 + 3.27680541e+00 9.99885127e-01 4.60923468e-06 4.84216450e-06 -5.10227574e-05 -2.22564513e-06 2.72672417e-06 2.06884204e-06 + 3.70714273e+00 9.99882382e-01 4.73260496e-06 4.89720641e-06 -5.21605708e-05 -2.20412802e-06 2.81648724e-06 2.20119417e-06 + 4.15541077e+00 9.99879566e-01 4.85116415e-06 4.96876130e-06 -5.33448938e-05 -2.18765478e-06 2.90616352e-06 2.33123694e-06 + 4.62235665e+00 9.99876673e-01 4.96901405e-06 5.05688393e-06 -5.45764685e-05 -2.17469321e-06 2.99819949e-06 2.45354163e-06 + 5.10875860e+00 9.99873698e-01 5.09278798e-06 5.15388304e-06 -5.58595836e-05 -2.16466205e-06 3.09571218e-06 2.56618843e-06 + 5.61542731e+00 9.99870636e-01 5.21780176e-06 5.26426957e-06 -5.71971448e-05 -2.15718225e-06 3.20082141e-06 2.67275741e-06 + 6.14320721e+00 9.99867483e-01 5.33991714e-06 5.39159023e-06 -5.85877513e-05 -2.14890084e-06 3.31236577e-06 2.77764703e-06 + 6.69297793e+00 9.99864237e-01 5.46522227e-06 5.53309961e-06 -6.00301709e-05 -2.14158278e-06 3.42384773e-06 2.87944033e-06 + 7.26565578e+00 9.99860893e-01 5.59426370e-06 5.68783233e-06 -6.15288700e-05 -2.13621253e-06 3.53567894e-06 2.97703017e-06 + 7.86219520e+00 9.99857450e-01 5.72996730e-06 5.85001690e-06 -6.30872955e-05 -2.13222581e-06 3.64820948e-06 3.07152161e-06 + 8.48359042e+00 9.99853903e-01 5.87585390e-06 6.02107148e-06 -6.47029101e-05 -2.13221705e-06 3.76278190e-06 3.16448491e-06 + 9.13087712e+00 9.99850251e-01 6.03250474e-06 6.19911014e-06 -6.63750751e-05 -2.13481069e-06 3.87942287e-06 3.25699018e-06 + 9.80513409e+00 9.99846491e-01 6.19752318e-06 6.38517568e-06 -6.81053634e-05 -2.14017208e-06 3.99715135e-06 3.35171361e-06 + 1.05074851e+01 9.99842622e-01 6.37029307e-06 6.58025845e-06 -6.98953511e-05 -2.14798496e-06 4.11689020e-06 3.45014922e-06 + 1.16049086e+01 9.99837000e-01 6.64141596e-06 6.88169417e-06 -7.26598321e-05 -2.16307806e-06 4.30046587e-06 3.60376061e-06 + 1.27480580e+01 9.99831265e-01 6.92581177e-06 7.18990711e-06 -7.55090459e-05 -2.18155665e-06 4.48724688e-06 3.76203691e-06 + 1.39388387e+01 9.99825419e-01 7.21950060e-06 7.50960386e-06 -7.84455456e-05 -2.20283324e-06 4.67708716e-06 3.92296084e-06 + 1.51792352e+01 9.99819468e-01 7.52294893e-06 7.84279523e-06 -8.14714471e-05 -2.22922136e-06 4.87004847e-06 4.08640056e-06 + 1.64713149e+01 9.99813418e-01 7.84028172e-06 8.18670427e-06 -8.45900161e-05 -2.26037724e-06 5.06654551e-06 4.25232353e-06 + 1.78172313e+01 9.99807275e-01 8.16951285e-06 8.54439909e-06 -8.78013653e-05 -2.29592321e-06 5.26865439e-06 4.42137775e-06 + 1.92192275e+01 9.99801048e-01 8.50811154e-06 8.91739999e-06 -9.11055463e-05 -2.33320474e-06 5.47742721e-06 4.59443399e-06 + 2.06796402e+01 9.99794747e-01 8.85606549e-06 9.30423304e-06 -9.45025076e-05 -2.37043985e-06 5.69129608e-06 4.77255684e-06 + 2.29615351e+01 9.99786710e-01 9.39276142e-06 9.89123815e-06 -9.96988109e-05 -2.43252802e-06 6.01593018e-06 5.04716340e-06 + 2.53385090e+01 9.99778825e-01 9.94641688e-06 1.04905200e-05 -1.05002561e-04 -2.50570360e-06 6.34447883e-06 5.32722859e-06 + 2.78145234e+01 9.99771134e-01 1.05152441e-05 1.11055036e-05 -1.10405679e-04 -2.58576598e-06 6.67894424e-06 5.61460190e-06 + 3.03937051e+01 9.99763684e-01 1.10933205e-05 1.17367441e-05 -1.15905928e-04 -2.67425246e-06 7.02209093e-06 5.90814193e-06 + 3.30803527e+01 9.99756528e-01 1.16784322e-05 1.23800399e-05 -1.21503501e-04 -2.77475514e-06 7.37360988e-06 6.20587301e-06 + 3.58789439e+01 9.99749723e-01 1.22695526e-05 1.30340906e-05 -1.27195449e-04 -2.88832657e-06 7.73161934e-06 6.50752194e-06 + 3.87941431e+01 9.99743335e-01 1.28633599e-05 1.37006954e-05 -1.32972731e-04 -3.01141998e-06 8.09303691e-06 6.81191204e-06 + 4.18308089e+01 9.99737432e-01 1.34602952e-05 1.43784426e-05 -1.38822837e-04 -3.13792231e-06 8.45676776e-06 7.12083388e-06 + 4.49940025e+01 9.99732089e-01 1.40646528e-05 1.50661032e-05 -1.44742584e-04 -3.26570418e-06 8.82328157e-06 7.43390299e-06 + 4.82889958e+01 9.99727392e-01 1.46769655e-05 1.57656544e-05 -1.50728304e-04 -3.39322385e-06 9.19404292e-06 7.75020299e-06 + 5.17212805e+01 9.99723435e-01 1.52970740e-05 1.64730011e-05 -1.56774823e-04 -3.52335846e-06 9.56644717e-06 8.06398828e-06 + 5.52965771e+01 9.99720318e-01 1.59266916e-05 1.71852474e-05 -1.62874685e-04 -3.66016630e-06 9.94002955e-06 8.37282317e-06 + 5.90208444e+01 9.99718152e-01 1.65646582e-05 1.79060189e-05 -1.69013687e-04 -3.80458083e-06 1.03173698e-05 8.67565949e-06 + 6.29002894e+01 9.99717059e-01 1.72102965e-05 1.86351206e-05 -1.75181973e-04 -3.95696233e-06 1.06961571e-05 8.97468857e-06 + 6.69413780e+01 9.99717171e-01 1.78524393e-05 1.93774393e-05 -1.81371570e-04 -4.12242457e-06 1.10737605e-05 9.26983510e-06 + 7.11508453e+01 9.99718632e-01 1.84842965e-05 2.01327626e-05 -1.87567339e-04 -4.30598276e-06 1.14453829e-05 9.55750222e-06 + 7.55357071e+01 9.99721604e-01 1.91110865e-05 2.08966482e-05 -1.93757440e-04 -4.50100899e-06 1.18144157e-05 9.83398950e-06 + 8.01032714e+01 9.99726262e-01 1.97353444e-05 2.16691496e-05 -1.99933975e-04 -4.72034147e-06 1.21783212e-05 1.01013466e-05 + 8.48611510e+01 9.99732796e-01 2.03578597e-05 2.24645673e-05 -2.06075542e-04 -4.97059013e-06 1.25350620e-05 1.03631581e-05 + 8.98172755e+01 9.99741417e-01 2.09792973e-05 2.32716938e-05 -2.12169539e-04 -5.24835180e-06 1.28802572e-05 1.06112252e-05 + 9.49799051e+01 9.99752360e-01 2.15925564e-05 2.40963873e-05 -2.18210631e-04 -5.55622802e-06 1.32179025e-05 1.08463173e-05 + 1.00357644e+02 9.99765879e-01 2.21916416e-05 2.49394085e-05 -2.24181472e-04 -5.88887813e-06 1.35474984e-05 1.10674413e-05 + 1.05959456e+02 9.99782255e-01 2.27735551e-05 2.57971757e-05 -2.30074871e-04 -6.24634687e-06 1.38717431e-05 1.12646143e-05 + 1.11794677e+02 9.99801800e-01 2.33358084e-05 2.66529312e-05 -2.35883521e-04 -6.62773237e-06 1.41825609e-05 1.14567370e-05 + 1.17873031e+02 9.99824854e-01 2.38633226e-05 2.75054961e-05 -2.41600966e-04 -7.02035782e-06 1.44847908e-05 1.16546761e-05 + 1.20000000e+02 9.99824800e-01 2.40370555e-05 2.78006827e-05 -2.43574276e-04 -7.15659410e-06 1.45902015e-05 1.17221246e-05 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_elastic_strain_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_elastic_strain_region_default_1.txt new file mode 100644 index 0000000..4bf4fb2 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_elastic_strain_region_default_1.txt @@ -0,0 +1,61 @@ + # Time Volume Ee11 Ee22 Ee33 Ee23 Ee13 Ee12 + 5.00000000e-02 9.99984364e-01 6.52003825e-07 5.97931386e-07 -7.28543824e-06 -3.45217001e-07 3.57865458e-07 -6.10392343e-09 + 2.06250000e-01 9.99935759e-01 2.70919917e-06 2.45910974e-06 -2.99409069e-05 -1.42185673e-06 1.46827408e-06 -2.32850540e-08 + 4.50390625e-01 9.99909751e-01 3.60933531e-06 4.65625706e-06 -4.18236912e-05 -2.25534397e-06 2.12169909e-06 5.74713505e-07 + 6.41125488e-01 9.99905645e-01 3.58532871e-06 4.89965655e-06 -4.33697199e-05 -2.36220578e-06 2.15151641e-06 9.13068315e-07 + 8.39807638e-01 9.99903137e-01 3.63753731e-06 4.90831606e-06 -4.42289530e-05 -2.37865204e-06 2.17619019e-06 1.09052126e-06 + 1.04676821e+00 9.99901078e-01 3.71307652e-06 4.89393340e-06 -4.49373720e-05 -2.36569049e-06 2.21080822e-06 1.22020841e-06 + 1.26235214e+00 9.99899206e-01 3.80320636e-06 4.87649452e-06 -4.55872737e-05 -2.35081250e-06 2.24676952e-06 1.33873103e-06 + 1.48691873e+00 9.99897410e-01 3.90936724e-06 4.85737408e-06 -4.62230085e-05 -2.33628919e-06 2.28710170e-06 1.44405441e-06 + 1.72084227e+00 9.99895640e-01 4.01955208e-06 4.84212896e-06 -4.68665829e-05 -2.32240072e-06 2.33841758e-06 1.54041770e-06 + 2.08634779e+00 9.99893048e-01 4.17691793e-06 4.82123605e-06 -4.78534395e-05 -2.30084519e-06 2.43068773e-06 1.67739474e-06 + 2.46708271e+00 9.99890445e-01 4.32816925e-06 4.80664364e-06 -4.88731635e-05 -2.27505023e-06 2.53218899e-06 1.81025405e-06 + 2.86368158e+00 9.99887810e-01 4.47583771e-06 4.81028744e-06 -4.99272874e-05 -2.24917529e-06 2.63254547e-06 1.93993717e-06 + 3.27680541e+00 9.99885127e-01 4.60923468e-06 4.84216450e-06 -5.10227574e-05 -2.22564513e-06 2.72672417e-06 2.06884204e-06 + 3.70714273e+00 9.99882382e-01 4.73260496e-06 4.89720641e-06 -5.21605708e-05 -2.20412802e-06 2.81648724e-06 2.20119417e-06 + 4.15541077e+00 9.99879566e-01 4.85116415e-06 4.96876130e-06 -5.33448938e-05 -2.18765478e-06 2.90616352e-06 2.33123694e-06 + 4.62235665e+00 9.99876673e-01 4.96901405e-06 5.05688393e-06 -5.45764685e-05 -2.17469321e-06 2.99819949e-06 2.45354163e-06 + 5.10875860e+00 9.99873698e-01 5.09278798e-06 5.15388304e-06 -5.58595836e-05 -2.16466205e-06 3.09571218e-06 2.56618843e-06 + 5.61542731e+00 9.99870636e-01 5.21780176e-06 5.26426957e-06 -5.71971448e-05 -2.15718225e-06 3.20082141e-06 2.67275741e-06 + 6.14320721e+00 9.99867483e-01 5.33991714e-06 5.39159023e-06 -5.85877513e-05 -2.14890084e-06 3.31236577e-06 2.77764703e-06 + 6.69297793e+00 9.99864237e-01 5.46522227e-06 5.53309961e-06 -6.00301709e-05 -2.14158278e-06 3.42384773e-06 2.87944033e-06 + 7.26565578e+00 9.99860893e-01 5.59426370e-06 5.68783233e-06 -6.15288700e-05 -2.13621253e-06 3.53567894e-06 2.97703017e-06 + 7.86219520e+00 9.99857450e-01 5.72996730e-06 5.85001690e-06 -6.30872955e-05 -2.13222581e-06 3.64820948e-06 3.07152161e-06 + 8.48359042e+00 9.99853903e-01 5.87585390e-06 6.02107148e-06 -6.47029101e-05 -2.13221705e-06 3.76278190e-06 3.16448491e-06 + 9.13087712e+00 9.99850251e-01 6.03250474e-06 6.19911014e-06 -6.63750751e-05 -2.13481069e-06 3.87942287e-06 3.25699018e-06 + 9.80513409e+00 9.99846491e-01 6.19752318e-06 6.38517568e-06 -6.81053634e-05 -2.14017208e-06 3.99715135e-06 3.35171361e-06 + 1.05074851e+01 9.99842622e-01 6.37029307e-06 6.58025845e-06 -6.98953511e-05 -2.14798496e-06 4.11689020e-06 3.45014922e-06 + 1.16049086e+01 9.99837000e-01 6.64141596e-06 6.88169417e-06 -7.26598321e-05 -2.16307806e-06 4.30046587e-06 3.60376061e-06 + 1.27480580e+01 9.99831265e-01 6.92581177e-06 7.18990711e-06 -7.55090459e-05 -2.18155665e-06 4.48724688e-06 3.76203691e-06 + 1.39388387e+01 9.99825419e-01 7.21950060e-06 7.50960386e-06 -7.84455456e-05 -2.20283324e-06 4.67708716e-06 3.92296084e-06 + 1.51792352e+01 9.99819468e-01 7.52294893e-06 7.84279523e-06 -8.14714471e-05 -2.22922136e-06 4.87004847e-06 4.08640056e-06 + 1.64713149e+01 9.99813418e-01 7.84028172e-06 8.18670427e-06 -8.45900161e-05 -2.26037724e-06 5.06654551e-06 4.25232353e-06 + 1.78172313e+01 9.99807275e-01 8.16951285e-06 8.54439909e-06 -8.78013653e-05 -2.29592321e-06 5.26865439e-06 4.42137775e-06 + 1.92192275e+01 9.99801048e-01 8.50811154e-06 8.91739999e-06 -9.11055463e-05 -2.33320474e-06 5.47742721e-06 4.59443399e-06 + 2.06796402e+01 9.99794747e-01 8.85606549e-06 9.30423304e-06 -9.45025076e-05 -2.37043985e-06 5.69129608e-06 4.77255684e-06 + 2.29615351e+01 9.99786710e-01 9.39276142e-06 9.89123815e-06 -9.96988109e-05 -2.43252802e-06 6.01593018e-06 5.04716340e-06 + 2.53385090e+01 9.99778825e-01 9.94641688e-06 1.04905200e-05 -1.05002561e-04 -2.50570360e-06 6.34447883e-06 5.32722859e-06 + 2.78145234e+01 9.99771134e-01 1.05152441e-05 1.11055036e-05 -1.10405679e-04 -2.58576598e-06 6.67894424e-06 5.61460190e-06 + 3.03937051e+01 9.99763684e-01 1.10933205e-05 1.17367441e-05 -1.15905928e-04 -2.67425246e-06 7.02209093e-06 5.90814193e-06 + 3.30803527e+01 9.99756528e-01 1.16784322e-05 1.23800399e-05 -1.21503501e-04 -2.77475514e-06 7.37360988e-06 6.20587301e-06 + 3.58789439e+01 9.99749723e-01 1.22695526e-05 1.30340906e-05 -1.27195449e-04 -2.88832657e-06 7.73161934e-06 6.50752194e-06 + 3.87941431e+01 9.99743335e-01 1.28633599e-05 1.37006954e-05 -1.32972731e-04 -3.01141998e-06 8.09303691e-06 6.81191204e-06 + 4.18308089e+01 9.99737432e-01 1.34602952e-05 1.43784426e-05 -1.38822837e-04 -3.13792231e-06 8.45676776e-06 7.12083388e-06 + 4.49940025e+01 9.99732089e-01 1.40646528e-05 1.50661032e-05 -1.44742584e-04 -3.26570418e-06 8.82328157e-06 7.43390299e-06 + 4.82889958e+01 9.99727392e-01 1.46769655e-05 1.57656544e-05 -1.50728304e-04 -3.39322385e-06 9.19404292e-06 7.75020299e-06 + 5.17212805e+01 9.99723435e-01 1.52970740e-05 1.64730011e-05 -1.56774823e-04 -3.52335846e-06 9.56644717e-06 8.06398828e-06 + 5.52965771e+01 9.99720318e-01 1.59266916e-05 1.71852474e-05 -1.62874685e-04 -3.66016630e-06 9.94002955e-06 8.37282317e-06 + 5.90208444e+01 9.99718152e-01 1.65646582e-05 1.79060189e-05 -1.69013687e-04 -3.80458083e-06 1.03173698e-05 8.67565949e-06 + 6.29002894e+01 9.99717059e-01 1.72102965e-05 1.86351206e-05 -1.75181973e-04 -3.95696233e-06 1.06961571e-05 8.97468857e-06 + 6.69413780e+01 9.99717171e-01 1.78524393e-05 1.93774393e-05 -1.81371570e-04 -4.12242457e-06 1.10737605e-05 9.26983510e-06 + 7.11508453e+01 9.99718632e-01 1.84842965e-05 2.01327626e-05 -1.87567339e-04 -4.30598276e-06 1.14453829e-05 9.55750222e-06 + 7.55357071e+01 9.99721604e-01 1.91110865e-05 2.08966482e-05 -1.93757440e-04 -4.50100899e-06 1.18144157e-05 9.83398950e-06 + 8.01032714e+01 9.99726262e-01 1.97353444e-05 2.16691496e-05 -1.99933975e-04 -4.72034147e-06 1.21783212e-05 1.01013466e-05 + 8.48611510e+01 9.99732796e-01 2.03578597e-05 2.24645673e-05 -2.06075542e-04 -4.97059013e-06 1.25350620e-05 1.03631581e-05 + 8.98172755e+01 9.99741417e-01 2.09792973e-05 2.32716938e-05 -2.12169539e-04 -5.24835180e-06 1.28802572e-05 1.06112252e-05 + 9.49799051e+01 9.99752360e-01 2.15925564e-05 2.40963873e-05 -2.18210631e-04 -5.55622802e-06 1.32179025e-05 1.08463173e-05 + 1.00357644e+02 9.99765879e-01 2.21916416e-05 2.49394085e-05 -2.24181472e-04 -5.88887813e-06 1.35474984e-05 1.10674413e-05 + 1.05959456e+02 9.99782255e-01 2.27735551e-05 2.57971757e-05 -2.30074871e-04 -6.24634687e-06 1.38717431e-05 1.12646143e-05 + 1.11794677e+02 9.99801800e-01 2.33358084e-05 2.66529312e-05 -2.35883521e-04 -6.62773237e-06 1.41825609e-05 1.14567370e-05 + 1.17873031e+02 9.99824854e-01 2.38633226e-05 2.75054961e-05 -2.41600966e-04 -7.02035782e-06 1.44847908e-05 1.16546761e-05 + 1.20000000e+02 9.99824800e-01 2.40370555e-05 2.78006827e-05 -2.43574276e-04 -7.15659410e-06 1.45902015e-05 1.17221246e-05 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_eq_pl_strain_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_eq_pl_strain_global.txt new file mode 100644 index 0000000..ad41f13 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_eq_pl_strain_global.txt @@ -0,0 +1,61 @@ + # Time Volume Equiv_Plastic_Stra + 5.00000000e-02 9.99984364e-01 2.38346921e-35 + 2.06250000e-01 9.99935759e-01 8.02785799e-07 + 4.50390625e-01 9.99909751e-01 1.88612160e-04 + 6.41125488e-01 9.99905645e-01 3.93935005e-04 + 8.39807638e-01 9.99903137e-01 6.10005165e-04 + 1.04676821e+00 9.99901078e-01 8.34296422e-04 + 1.26235214e+00 9.99899206e-01 1.06702071e-03 + 1.48691873e+00 9.99897410e-01 1.30874481e-03 + 1.72084227e+00 9.99895640e-01 1.55999865e-03 + 2.08634779e+00 9.99893048e-01 1.95175158e-03 + 2.46708271e+00 9.99890445e-01 2.35910283e-03 + 2.86368158e+00 9.99887810e-01 2.78273070e-03 + 3.27680541e+00 9.99885127e-01 3.22343234e-03 + 3.70714273e+00 9.99882382e-01 3.68202967e-03 + 4.15541077e+00 9.99879566e-01 4.15933775e-03 + 4.62235665e+00 9.99876673e-01 4.65621794e-03 + 5.10875860e+00 9.99873698e-01 5.17351275e-03 + 5.61542731e+00 9.99870636e-01 5.71214057e-03 + 6.14320721e+00 9.99867483e-01 6.27304338e-03 + 6.69297793e+00 9.99864237e-01 6.85716713e-03 + 7.26565578e+00 9.99860893e-01 7.46556009e-03 + 7.86219520e+00 9.99857450e-01 8.09931462e-03 + 8.48359042e+00 9.99853903e-01 8.75956579e-03 + 9.13087712e+00 9.99850251e-01 9.44750278e-03 + 9.80513409e+00 9.99846491e-01 1.01643473e-02 + 1.05074851e+01 9.99842622e-01 1.09113654e-02 + 1.16049086e+01 9.99837000e-01 1.20794837e-02 + 1.27480580e+01 9.99831265e-01 1.32973426e-02 + 1.39388387e+01 9.99825419e-01 1.45672148e-02 + 1.51792352e+01 9.99819468e-01 1.58914572e-02 + 1.64713149e+01 9.99813418e-01 1.72725212e-02 + 1.78172313e+01 9.99807275e-01 1.87129816e-02 + 1.92192275e+01 9.99801048e-01 2.02155395e-02 + 2.06796402e+01 9.99794747e-01 2.17830576e-02 + 2.29615351e+01 9.99786710e-01 2.42382204e-02 + 2.53385090e+01 9.99778825e-01 2.68021792e-02 + 2.78145234e+01 9.99771134e-01 2.94802357e-02 + 3.03937051e+01 9.99763684e-01 3.22778956e-02 + 3.30803527e+01 9.99756528e-01 3.52008758e-02 + 3.58789439e+01 9.99749723e-01 3.82551495e-02 + 3.87941431e+01 9.99743335e-01 4.14470445e-02 + 4.18308089e+01 9.99737432e-01 4.47832927e-02 + 4.49940025e+01 9.99732089e-01 4.82709995e-02 + 4.82889958e+01 9.99727392e-01 5.19176004e-02 + 5.17212805e+01 9.99723435e-01 5.57309870e-02 + 5.52965771e+01 9.99720318e-01 5.97195559e-02 + 5.90208444e+01 9.99718152e-01 6.38919896e-02 + 6.29002894e+01 9.99717059e-01 6.82574300e-02 + 6.69413780e+01 9.99717171e-01 7.28256482e-02 + 7.11508453e+01 9.99718632e-01 7.76070611e-02 + 7.55357071e+01 9.99721604e-01 8.26127978e-02 + 8.01032714e+01 9.99726262e-01 8.78546055e-02 + 8.48611510e+01 9.99732796e-01 9.33450620e-02 + 8.98172755e+01 9.99741417e-01 9.90974038e-02 + 9.49799051e+01 9.99752360e-01 1.05125526e-01 + 1.00357644e+02 9.99765879e-01 1.11444465e-01 + 1.05959456e+02 9.99782255e-01 1.18070052e-01 + 1.11794677e+02 9.99801800e-01 1.25018596e-01 + 1.17873031e+02 9.99824854e-01 1.32308290e-01 + 1.20000000e+02 9.99824800e-01 1.34865446e-01 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_eq_pl_strain_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_eq_pl_strain_region_default_1.txt new file mode 100644 index 0000000..ad41f13 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_eq_pl_strain_region_default_1.txt @@ -0,0 +1,61 @@ + # Time Volume Equiv_Plastic_Stra + 5.00000000e-02 9.99984364e-01 2.38346921e-35 + 2.06250000e-01 9.99935759e-01 8.02785799e-07 + 4.50390625e-01 9.99909751e-01 1.88612160e-04 + 6.41125488e-01 9.99905645e-01 3.93935005e-04 + 8.39807638e-01 9.99903137e-01 6.10005165e-04 + 1.04676821e+00 9.99901078e-01 8.34296422e-04 + 1.26235214e+00 9.99899206e-01 1.06702071e-03 + 1.48691873e+00 9.99897410e-01 1.30874481e-03 + 1.72084227e+00 9.99895640e-01 1.55999865e-03 + 2.08634779e+00 9.99893048e-01 1.95175158e-03 + 2.46708271e+00 9.99890445e-01 2.35910283e-03 + 2.86368158e+00 9.99887810e-01 2.78273070e-03 + 3.27680541e+00 9.99885127e-01 3.22343234e-03 + 3.70714273e+00 9.99882382e-01 3.68202967e-03 + 4.15541077e+00 9.99879566e-01 4.15933775e-03 + 4.62235665e+00 9.99876673e-01 4.65621794e-03 + 5.10875860e+00 9.99873698e-01 5.17351275e-03 + 5.61542731e+00 9.99870636e-01 5.71214057e-03 + 6.14320721e+00 9.99867483e-01 6.27304338e-03 + 6.69297793e+00 9.99864237e-01 6.85716713e-03 + 7.26565578e+00 9.99860893e-01 7.46556009e-03 + 7.86219520e+00 9.99857450e-01 8.09931462e-03 + 8.48359042e+00 9.99853903e-01 8.75956579e-03 + 9.13087712e+00 9.99850251e-01 9.44750278e-03 + 9.80513409e+00 9.99846491e-01 1.01643473e-02 + 1.05074851e+01 9.99842622e-01 1.09113654e-02 + 1.16049086e+01 9.99837000e-01 1.20794837e-02 + 1.27480580e+01 9.99831265e-01 1.32973426e-02 + 1.39388387e+01 9.99825419e-01 1.45672148e-02 + 1.51792352e+01 9.99819468e-01 1.58914572e-02 + 1.64713149e+01 9.99813418e-01 1.72725212e-02 + 1.78172313e+01 9.99807275e-01 1.87129816e-02 + 1.92192275e+01 9.99801048e-01 2.02155395e-02 + 2.06796402e+01 9.99794747e-01 2.17830576e-02 + 2.29615351e+01 9.99786710e-01 2.42382204e-02 + 2.53385090e+01 9.99778825e-01 2.68021792e-02 + 2.78145234e+01 9.99771134e-01 2.94802357e-02 + 3.03937051e+01 9.99763684e-01 3.22778956e-02 + 3.30803527e+01 9.99756528e-01 3.52008758e-02 + 3.58789439e+01 9.99749723e-01 3.82551495e-02 + 3.87941431e+01 9.99743335e-01 4.14470445e-02 + 4.18308089e+01 9.99737432e-01 4.47832927e-02 + 4.49940025e+01 9.99732089e-01 4.82709995e-02 + 4.82889958e+01 9.99727392e-01 5.19176004e-02 + 5.17212805e+01 9.99723435e-01 5.57309870e-02 + 5.52965771e+01 9.99720318e-01 5.97195559e-02 + 5.90208444e+01 9.99718152e-01 6.38919896e-02 + 6.29002894e+01 9.99717059e-01 6.82574300e-02 + 6.69413780e+01 9.99717171e-01 7.28256482e-02 + 7.11508453e+01 9.99718632e-01 7.76070611e-02 + 7.55357071e+01 9.99721604e-01 8.26127978e-02 + 8.01032714e+01 9.99726262e-01 8.78546055e-02 + 8.48611510e+01 9.99732796e-01 9.33450620e-02 + 8.98172755e+01 9.99741417e-01 9.90974038e-02 + 9.49799051e+01 9.99752360e-01 1.05125526e-01 + 1.00357644e+02 9.99765879e-01 1.11444465e-01 + 1.05959456e+02 9.99782255e-01 1.18070052e-01 + 1.11794677e+02 9.99801800e-01 1.25018596e-01 + 1.17873031e+02 9.99824854e-01 1.32308290e-01 + 1.20000000e+02 9.99824800e-01 1.34865446e-01 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_euler_strain_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_euler_strain_global.txt new file mode 100644 index 0000000..a64e7a1 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_euler_strain_global.txt @@ -0,0 +1,61 @@ + # Time Volume E11 E22 E33 E23 E13 E12 + 5.00000000e-02 9.99984364e-01 1.64995260e-05 1.78652662e-05 -5.00037416e-05 -6.60455400e-07 1.17315494e-06 1.37067735e-07 + 2.06250000e-01 9.99935759e-01 6.81831552e-05 7.38358797e-05 -2.06313674e-04 -2.74302523e-06 4.82556533e-06 5.25840969e-07 + 4.50390625e-01 9.99909751e-01 1.67114223e-04 1.93066120e-04 -4.50694109e-04 -5.74338591e-06 8.91512251e-06 3.67910719e-06 + 6.41125488e-01 9.99905645e-01 2.49418816e-04 2.97416971e-04 -6.41740732e-04 -9.79405339e-06 1.00922046e-05 7.82238060e-06 + 8.39807638e-01 9.99903137e-01 3.35364741e-04 4.07675614e-04 -8.40864035e-04 -1.45395751e-05 1.03289721e-05 1.20009210e-05 + 1.04676821e+00 9.99901078e-01 4.24464275e-04 5.23512944e-04 -1.04841071e-03 -2.00326938e-05 9.91304052e-06 1.59588942e-05 + 1.26235214e+00 9.99899206e-01 5.16866211e-04 6.44862855e-04 -1.26474268e-03 -2.61352078e-05 9.08510034e-06 1.98745595e-05 + 1.48691873e+00 9.99897410e-01 6.12895364e-04 7.71649401e-04 -1.49023778e-03 -3.27346033e-05 8.03917636e-06 2.37525854e-05 + 1.72084227e+00 9.99895640e-01 7.12830367e-04 9.03919045e-04 -1.72529057e-03 -3.96867179e-05 6.90968015e-06 2.77059354e-05 + 2.08634779e+00 9.99893048e-01 8.68850725e-04 1.11089790e-03 -2.09289196e-03 -5.04513892e-05 5.25361093e-06 3.39819758e-05 + 2.46708271e+00 9.99890445e-01 1.03098817e-03 1.32699019e-03 -2.47624027e-03 -6.15544007e-05 3.60471768e-06 4.04723111e-05 + 2.86368158e+00 9.99887810e-01 1.19939383e-03 1.55265787e-03 -2.87602891e-03 -7.30447280e-05 2.06255419e-06 4.71175197e-05 + 3.27680541e+00 9.99885127e-01 1.37435878e-03 1.78825518e-03 -3.29298344e-03 -8.49461898e-05 7.19016288e-07 5.38855052e-05 + 3.70714273e+00 9.99882382e-01 1.55631783e-03 2.03402252e-03 -3.72786317e-03 -9.72245501e-05 -3.58335623e-07 6.07722644e-05 + 4.15541077e+00 9.99879566e-01 1.74560918e-03 2.29033212e-03 -4.18146301e-03 -1.09927620e-04 -9.70864815e-07 6.78785285e-05 + 4.62235665e+00 9.99876673e-01 1.94257150e-03 2.55758616e-03 -4.65461521e-03 -1.23137363e-04 -1.11505811e-06 7.52680545e-05 + 5.10875860e+00 9.99873698e-01 2.14753037e-03 2.83623352e-03 -5.14819124e-03 -1.36781890e-04 -8.99087116e-07 8.28905340e-05 + 5.61542731e+00 9.99870636e-01 2.36084818e-03 3.12671810e-03 -5.66310390e-03 -1.50854148e-04 -3.52348422e-07 9.07729375e-05 + 6.14320721e+00 9.99867483e-01 2.58288865e-03 3.42951791e-03 -6.20030949e-03 -1.65369558e-04 5.34574274e-07 9.89613843e-05 + 6.69297793e+00 9.99864237e-01 2.81400896e-03 3.74515381e-03 -6.76081011e-03 -1.80333213e-04 1.78912324e-06 1.07524313e-04 + 7.26565578e+00 9.99860893e-01 3.05470315e-03 4.07404576e-03 -7.34565612e-03 -1.95818779e-04 3.35751496e-06 1.16467258e-04 + 7.86219520e+00 9.99857450e-01 3.30545618e-03 4.41666188e-03 -7.95594869e-03 -2.11842981e-04 5.19227284e-06 1.25814693e-04 + 8.48359042e+00 9.99853903e-01 3.56663862e-03 4.77362509e-03 -8.59284267e-03 -2.28467865e-04 7.29059841e-06 1.35555583e-04 + 9.13087712e+00 9.99850251e-01 3.83867435e-03 5.14554611e-03 -9.25754947e-03 -2.45797662e-04 9.66020454e-06 1.45644509e-04 + 9.80513409e+00 9.99846491e-01 4.12208670e-03 5.53298047e-03 -9.95134013e-03 -2.63871496e-04 1.22639816e-05 1.56058087e-04 + 1.05074851e+01 9.99842622e-01 4.41739221e-03 5.93653634e-03 -1.06755487e-02 -2.82727358e-04 1.50863702e-05 1.66763481e-04 + 1.16049086e+01 9.99837000e-01 4.87894961e-03 6.56739892e-03 -1.18102194e-02 -3.12292240e-04 1.96700804e-05 1.83187326e-04 + 1.27480580e+01 9.99831265e-01 5.35974419e-03 7.22469992e-03 -1.29961972e-02 -3.43179281e-04 2.45849901e-05 2.00182185e-04 + 1.39388387e+01 9.99825419e-01 5.86066920e-03 7.90945908e-03 -1.42359828e-02 -3.75383432e-04 2.97893014e-05 2.17911422e-04 + 1.51792352e+01 9.99819468e-01 6.38258868e-03 8.62280664e-03 -1.55322150e-02 -4.08904722e-04 3.52613520e-05 2.36343873e-04 + 1.64713149e+01 9.99813418e-01 6.92628467e-03 9.36603755e-03 -1.68876793e-02 -4.43777145e-04 4.10120287e-05 2.55493794e-04 + 1.78172313e+01 9.99807275e-01 7.49265697e-03 1.01404168e-02 -1.83053178e-02 -4.80079813e-04 4.70745296e-05 2.75341821e-04 + 1.92192275e+01 9.99801048e-01 8.08268686e-03 1.09472202e-02 -1.97882397e-02 -5.17914856e-04 5.34832592e-05 2.95823297e-04 + 2.06796402e+01 9.99794747e-01 8.69743219e-03 1.17877418e-02 -2.13397328e-02 -5.57429906e-04 6.02400073e-05 3.16965014e-04 + 2.29615351e+01 9.99786710e-01 9.65852635e-03 1.31023636e-02 -2.37778947e-02 -6.19739249e-04 7.12129246e-05 3.49708794e-04 + 2.53385090e+01 9.99778825e-01 1.06599443e-02 1.44720699e-02 -2.63358935e-02 -6.85113483e-04 8.32290892e-05 3.83471227e-04 + 2.78145234e+01 9.99771134e-01 1.17036955e-02 1.58988646e-02 -2.90204689e-02 -7.53757608e-04 9.62828017e-05 4.18351531e-04 + 3.03937051e+01 9.99763684e-01 1.27918053e-02 1.73849073e-02 -3.18388177e-02 -8.25797081e-04 1.10312110e-04 4.54365115e-04 + 3.30803527e+01 9.99756528e-01 1.39261974e-02 1.89326348e-02 -3.47986323e-02 -9.01260997e-04 1.25256115e-04 4.91387720e-04 + 3.58789439e+01 9.99749723e-01 1.51088253e-02 2.05446332e-02 -3.79081429e-02 -9.80359175e-04 1.41036235e-04 5.29261363e-04 + 3.87941431e+01 9.99743335e-01 1.63415053e-02 2.22238035e-02 -4.11761640e-02 -1.06328967e-03 1.57254425e-04 5.67997988e-04 + 4.18308089e+01 9.99737432e-01 1.76263784e-02 2.39729262e-02 -4.46121455e-02 -1.15032116e-03 1.73701629e-04 6.07312775e-04 + 4.49940025e+01 9.99732089e-01 1.89658305e-02 2.57947542e-02 -4.82262305e-02 -1.24172768e-03 1.90300654e-04 6.47032284e-04 + 4.82889958e+01 9.99727392e-01 2.03620452e-02 2.76924574e-02 -5.20293177e-02 -1.33791303e-03 2.06849939e-04 6.86958773e-04 + 5.17212805e+01 9.99723435e-01 2.18173489e-02 2.96692848e-02 -5.60331315e-02 -1.43924155e-03 2.23162826e-04 7.27084226e-04 + 5.52965771e+01 9.99720318e-01 2.33343157e-02 3.17284652e-02 -6.02503008e-02 -1.54616404e-03 2.39607960e-04 7.67334305e-04 + 5.90208444e+01 9.99718152e-01 2.49149948e-02 3.38739707e-02 -6.46944449e-02 -1.65916223e-03 2.56705267e-04 8.07473767e-04 + 6.29002894e+01 9.99717059e-01 2.65615626e-02 3.61098959e-02 -6.93802691e-02 -1.77847633e-03 2.74949596e-04 8.47466105e-04 + 6.69413780e+01 9.99717171e-01 2.82774223e-02 3.84393740e-02 -7.43236738e-02 -1.90460528e-03 2.93901546e-04 8.87473390e-04 + 7.11508453e+01 9.99718632e-01 3.00651669e-02 4.08665978e-02 -7.95418798e-02 -2.03855423e-03 3.12922648e-04 9.27474204e-04 + 7.55357071e+01 9.99721604e-01 3.19278384e-02 4.33956327e-02 -8.50535658e-02 -2.18130482e-03 3.32078059e-04 9.67434664e-04 + 8.01032714e+01 9.99726262e-01 3.38691933e-02 4.60301522e-02 -9.08790086e-02 -2.33297964e-03 3.51619407e-04 1.00748364e-03 + 8.48611510e+01 9.99732796e-01 3.58924137e-02 4.87746716e-02 -9.70402612e-02 -2.49380407e-03 3.71338780e-04 1.04728100e-03 + 8.98172755e+01 9.99741417e-01 3.80007500e-02 5.16339692e-02 -1.03561348e-01 -2.66376775e-03 3.91557343e-04 1.08616217e-03 + 9.49799051e+01 9.99752360e-01 4.01985513e-02 5.46120891e-02 -1.10468497e-01 -2.84335204e-03 4.12645205e-04 1.12360658e-03 + 1.00357644e+02 9.99765879e-01 4.24902777e-02 5.77132522e-02 -1.17790379e-01 -3.03279936e-03 4.35913503e-04 1.15841702e-03 + 1.05959456e+02 9.99782255e-01 4.48815040e-02 6.09409949e-02 -1.25558405e-01 -3.23266267e-03 4.62033146e-04 1.18906121e-03 + 1.11794677e+02 9.99801800e-01 4.73782855e-02 6.42988075e-02 -1.33807048e-01 -3.44347656e-03 4.90407278e-04 1.21527890e-03 + 1.17873031e+02 9.99824854e-01 4.99858553e-02 6.77913579e-02 -1.42574226e-01 -3.66593545e-03 5.21915334e-04 1.23669046e-03 + 1.20000000e+02 9.99824800e-01 5.08965215e-02 6.90079394e-02 -1.45685097e-01 -3.74396453e-03 5.33117665e-04 1.24332915e-03 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_euler_strain_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_euler_strain_region_default_1.txt new file mode 100644 index 0000000..a64e7a1 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_euler_strain_region_default_1.txt @@ -0,0 +1,61 @@ + # Time Volume E11 E22 E33 E23 E13 E12 + 5.00000000e-02 9.99984364e-01 1.64995260e-05 1.78652662e-05 -5.00037416e-05 -6.60455400e-07 1.17315494e-06 1.37067735e-07 + 2.06250000e-01 9.99935759e-01 6.81831552e-05 7.38358797e-05 -2.06313674e-04 -2.74302523e-06 4.82556533e-06 5.25840969e-07 + 4.50390625e-01 9.99909751e-01 1.67114223e-04 1.93066120e-04 -4.50694109e-04 -5.74338591e-06 8.91512251e-06 3.67910719e-06 + 6.41125488e-01 9.99905645e-01 2.49418816e-04 2.97416971e-04 -6.41740732e-04 -9.79405339e-06 1.00922046e-05 7.82238060e-06 + 8.39807638e-01 9.99903137e-01 3.35364741e-04 4.07675614e-04 -8.40864035e-04 -1.45395751e-05 1.03289721e-05 1.20009210e-05 + 1.04676821e+00 9.99901078e-01 4.24464275e-04 5.23512944e-04 -1.04841071e-03 -2.00326938e-05 9.91304052e-06 1.59588942e-05 + 1.26235214e+00 9.99899206e-01 5.16866211e-04 6.44862855e-04 -1.26474268e-03 -2.61352078e-05 9.08510034e-06 1.98745595e-05 + 1.48691873e+00 9.99897410e-01 6.12895364e-04 7.71649401e-04 -1.49023778e-03 -3.27346033e-05 8.03917636e-06 2.37525854e-05 + 1.72084227e+00 9.99895640e-01 7.12830367e-04 9.03919045e-04 -1.72529057e-03 -3.96867179e-05 6.90968015e-06 2.77059354e-05 + 2.08634779e+00 9.99893048e-01 8.68850725e-04 1.11089790e-03 -2.09289196e-03 -5.04513892e-05 5.25361093e-06 3.39819758e-05 + 2.46708271e+00 9.99890445e-01 1.03098817e-03 1.32699019e-03 -2.47624027e-03 -6.15544007e-05 3.60471768e-06 4.04723111e-05 + 2.86368158e+00 9.99887810e-01 1.19939383e-03 1.55265787e-03 -2.87602891e-03 -7.30447280e-05 2.06255419e-06 4.71175197e-05 + 3.27680541e+00 9.99885127e-01 1.37435878e-03 1.78825518e-03 -3.29298344e-03 -8.49461898e-05 7.19016288e-07 5.38855052e-05 + 3.70714273e+00 9.99882382e-01 1.55631783e-03 2.03402252e-03 -3.72786317e-03 -9.72245501e-05 -3.58335623e-07 6.07722644e-05 + 4.15541077e+00 9.99879566e-01 1.74560918e-03 2.29033212e-03 -4.18146301e-03 -1.09927620e-04 -9.70864815e-07 6.78785285e-05 + 4.62235665e+00 9.99876673e-01 1.94257150e-03 2.55758616e-03 -4.65461521e-03 -1.23137363e-04 -1.11505811e-06 7.52680545e-05 + 5.10875860e+00 9.99873698e-01 2.14753037e-03 2.83623352e-03 -5.14819124e-03 -1.36781890e-04 -8.99087116e-07 8.28905340e-05 + 5.61542731e+00 9.99870636e-01 2.36084818e-03 3.12671810e-03 -5.66310390e-03 -1.50854148e-04 -3.52348422e-07 9.07729375e-05 + 6.14320721e+00 9.99867483e-01 2.58288865e-03 3.42951791e-03 -6.20030949e-03 -1.65369558e-04 5.34574274e-07 9.89613843e-05 + 6.69297793e+00 9.99864237e-01 2.81400896e-03 3.74515381e-03 -6.76081011e-03 -1.80333213e-04 1.78912324e-06 1.07524313e-04 + 7.26565578e+00 9.99860893e-01 3.05470315e-03 4.07404576e-03 -7.34565612e-03 -1.95818779e-04 3.35751496e-06 1.16467258e-04 + 7.86219520e+00 9.99857450e-01 3.30545618e-03 4.41666188e-03 -7.95594869e-03 -2.11842981e-04 5.19227284e-06 1.25814693e-04 + 8.48359042e+00 9.99853903e-01 3.56663862e-03 4.77362509e-03 -8.59284267e-03 -2.28467865e-04 7.29059841e-06 1.35555583e-04 + 9.13087712e+00 9.99850251e-01 3.83867435e-03 5.14554611e-03 -9.25754947e-03 -2.45797662e-04 9.66020454e-06 1.45644509e-04 + 9.80513409e+00 9.99846491e-01 4.12208670e-03 5.53298047e-03 -9.95134013e-03 -2.63871496e-04 1.22639816e-05 1.56058087e-04 + 1.05074851e+01 9.99842622e-01 4.41739221e-03 5.93653634e-03 -1.06755487e-02 -2.82727358e-04 1.50863702e-05 1.66763481e-04 + 1.16049086e+01 9.99837000e-01 4.87894961e-03 6.56739892e-03 -1.18102194e-02 -3.12292240e-04 1.96700804e-05 1.83187326e-04 + 1.27480580e+01 9.99831265e-01 5.35974419e-03 7.22469992e-03 -1.29961972e-02 -3.43179281e-04 2.45849901e-05 2.00182185e-04 + 1.39388387e+01 9.99825419e-01 5.86066920e-03 7.90945908e-03 -1.42359828e-02 -3.75383432e-04 2.97893014e-05 2.17911422e-04 + 1.51792352e+01 9.99819468e-01 6.38258868e-03 8.62280664e-03 -1.55322150e-02 -4.08904722e-04 3.52613520e-05 2.36343873e-04 + 1.64713149e+01 9.99813418e-01 6.92628467e-03 9.36603755e-03 -1.68876793e-02 -4.43777145e-04 4.10120287e-05 2.55493794e-04 + 1.78172313e+01 9.99807275e-01 7.49265697e-03 1.01404168e-02 -1.83053178e-02 -4.80079813e-04 4.70745296e-05 2.75341821e-04 + 1.92192275e+01 9.99801048e-01 8.08268686e-03 1.09472202e-02 -1.97882397e-02 -5.17914856e-04 5.34832592e-05 2.95823297e-04 + 2.06796402e+01 9.99794747e-01 8.69743219e-03 1.17877418e-02 -2.13397328e-02 -5.57429906e-04 6.02400073e-05 3.16965014e-04 + 2.29615351e+01 9.99786710e-01 9.65852635e-03 1.31023636e-02 -2.37778947e-02 -6.19739249e-04 7.12129246e-05 3.49708794e-04 + 2.53385090e+01 9.99778825e-01 1.06599443e-02 1.44720699e-02 -2.63358935e-02 -6.85113483e-04 8.32290892e-05 3.83471227e-04 + 2.78145234e+01 9.99771134e-01 1.17036955e-02 1.58988646e-02 -2.90204689e-02 -7.53757608e-04 9.62828017e-05 4.18351531e-04 + 3.03937051e+01 9.99763684e-01 1.27918053e-02 1.73849073e-02 -3.18388177e-02 -8.25797081e-04 1.10312110e-04 4.54365115e-04 + 3.30803527e+01 9.99756528e-01 1.39261974e-02 1.89326348e-02 -3.47986323e-02 -9.01260997e-04 1.25256115e-04 4.91387720e-04 + 3.58789439e+01 9.99749723e-01 1.51088253e-02 2.05446332e-02 -3.79081429e-02 -9.80359175e-04 1.41036235e-04 5.29261363e-04 + 3.87941431e+01 9.99743335e-01 1.63415053e-02 2.22238035e-02 -4.11761640e-02 -1.06328967e-03 1.57254425e-04 5.67997988e-04 + 4.18308089e+01 9.99737432e-01 1.76263784e-02 2.39729262e-02 -4.46121455e-02 -1.15032116e-03 1.73701629e-04 6.07312775e-04 + 4.49940025e+01 9.99732089e-01 1.89658305e-02 2.57947542e-02 -4.82262305e-02 -1.24172768e-03 1.90300654e-04 6.47032284e-04 + 4.82889958e+01 9.99727392e-01 2.03620452e-02 2.76924574e-02 -5.20293177e-02 -1.33791303e-03 2.06849939e-04 6.86958773e-04 + 5.17212805e+01 9.99723435e-01 2.18173489e-02 2.96692848e-02 -5.60331315e-02 -1.43924155e-03 2.23162826e-04 7.27084226e-04 + 5.52965771e+01 9.99720318e-01 2.33343157e-02 3.17284652e-02 -6.02503008e-02 -1.54616404e-03 2.39607960e-04 7.67334305e-04 + 5.90208444e+01 9.99718152e-01 2.49149948e-02 3.38739707e-02 -6.46944449e-02 -1.65916223e-03 2.56705267e-04 8.07473767e-04 + 6.29002894e+01 9.99717059e-01 2.65615626e-02 3.61098959e-02 -6.93802691e-02 -1.77847633e-03 2.74949596e-04 8.47466105e-04 + 6.69413780e+01 9.99717171e-01 2.82774223e-02 3.84393740e-02 -7.43236738e-02 -1.90460528e-03 2.93901546e-04 8.87473390e-04 + 7.11508453e+01 9.99718632e-01 3.00651669e-02 4.08665978e-02 -7.95418798e-02 -2.03855423e-03 3.12922648e-04 9.27474204e-04 + 7.55357071e+01 9.99721604e-01 3.19278384e-02 4.33956327e-02 -8.50535658e-02 -2.18130482e-03 3.32078059e-04 9.67434664e-04 + 8.01032714e+01 9.99726262e-01 3.38691933e-02 4.60301522e-02 -9.08790086e-02 -2.33297964e-03 3.51619407e-04 1.00748364e-03 + 8.48611510e+01 9.99732796e-01 3.58924137e-02 4.87746716e-02 -9.70402612e-02 -2.49380407e-03 3.71338780e-04 1.04728100e-03 + 8.98172755e+01 9.99741417e-01 3.80007500e-02 5.16339692e-02 -1.03561348e-01 -2.66376775e-03 3.91557343e-04 1.08616217e-03 + 9.49799051e+01 9.99752360e-01 4.01985513e-02 5.46120891e-02 -1.10468497e-01 -2.84335204e-03 4.12645205e-04 1.12360658e-03 + 1.00357644e+02 9.99765879e-01 4.24902777e-02 5.77132522e-02 -1.17790379e-01 -3.03279936e-03 4.35913503e-04 1.15841702e-03 + 1.05959456e+02 9.99782255e-01 4.48815040e-02 6.09409949e-02 -1.25558405e-01 -3.23266267e-03 4.62033146e-04 1.18906121e-03 + 1.11794677e+02 9.99801800e-01 4.73782855e-02 6.42988075e-02 -1.33807048e-01 -3.44347656e-03 4.90407278e-04 1.21527890e-03 + 1.17873031e+02 9.99824854e-01 4.99858553e-02 6.77913579e-02 -1.42574226e-01 -3.66593545e-03 5.21915334e-04 1.23669046e-03 + 1.20000000e+02 9.99824800e-01 5.08965215e-02 6.90079394e-02 -1.45685097e-01 -3.74396453e-03 5.33117665e-04 1.24332915e-03 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_pl_work_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_pl_work_global.txt new file mode 100644 index 0000000..4d54a34 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_pl_work_global.txt @@ -0,0 +1,61 @@ + # Time Volume Plastic_Work + 5.00000000e-02 9.99984364e-01 1.79635968e-37 + 2.06250000e-01 9.99935759e-01 2.01544672e-08 + 4.50390625e-01 9.99909751e-01 5.46566788e-06 + 6.41125488e-01 9.99905645e-01 1.20034931e-05 + 8.39807638e-01 9.99903137e-01 1.92938619e-05 + 1.04676821e+00 9.99901078e-01 2.71689233e-05 + 1.26235214e+00 9.99899206e-01 3.55964835e-05 + 1.48691873e+00 9.99897410e-01 4.45747585e-05 + 1.72084227e+00 9.99895640e-01 5.41203773e-05 + 2.08634779e+00 9.99893048e-01 6.94553337e-05 + 2.46708271e+00 9.99890445e-01 8.58616868e-05 + 2.86368158e+00 9.99887810e-01 1.03406869e-04 + 3.27680541e+00 9.99885127e-01 1.22164342e-04 + 3.70714273e+00 9.99882382e-01 1.42211750e-04 + 4.15541077e+00 9.99879566e-01 1.63637782e-04 + 4.62235665e+00 9.99876673e-01 1.86538012e-04 + 5.10875860e+00 9.99873698e-01 2.11018152e-04 + 5.61542731e+00 9.99870636e-01 2.37189991e-04 + 6.14320721e+00 9.99867483e-01 2.65175178e-04 + 6.69297793e+00 9.99864237e-01 2.95107501e-04 + 7.26565578e+00 9.99860893e-01 3.27127855e-04 + 7.86219520e+00 9.99857450e-01 3.61388187e-04 + 8.48359042e+00 9.99853903e-01 3.98052962e-04 + 9.13087712e+00 9.99850251e-01 4.37297326e-04 + 9.80513409e+00 9.99846491e-01 4.79310539e-04 + 1.05074851e+01 9.99842622e-01 5.24296950e-04 + 1.16049086e+01 9.99837000e-01 5.97543522e-04 + 1.27480580e+01 9.99831265e-01 6.77021164e-04 + 1.39388387e+01 9.99825419e-01 7.63226334e-04 + 1.51792352e+01 9.99819468e-01 8.56698090e-04 + 1.64713149e+01 9.99813418e-01 9.58019410e-04 + 1.78172313e+01 9.99807275e-01 1.06781389e-03 + 1.92192275e+01 9.99801048e-01 1.18675185e-03 + 2.06796402e+01 9.99794747e-01 1.31555213e-03 + 2.29615351e+01 9.99786710e-01 1.52859490e-03 + 2.53385090e+01 9.99778825e-01 1.76311397e-03 + 2.78145234e+01 9.99771134e-01 2.02084751e-03 + 3.03937051e+01 9.99763684e-01 2.30365528e-03 + 3.30803527e+01 9.99756528e-01 2.61353217e-03 + 3.58789439e+01 9.99749723e-01 2.95261775e-03 + 3.87941431e+01 9.99743335e-01 3.32320154e-03 + 4.18308089e+01 9.99737432e-01 3.72770751e-03 + 4.49940025e+01 9.99732089e-01 4.16870448e-03 + 4.82889958e+01 9.99727392e-01 4.64893196e-03 + 5.17212805e+01 9.99723435e-01 5.17131789e-03 + 5.52965771e+01 9.99720318e-01 5.73897968e-03 + 5.90208444e+01 9.99718152e-01 6.35522166e-03 + 6.29002894e+01 9.99717059e-01 7.02354560e-03 + 6.69413780e+01 9.99717171e-01 7.74769081e-03 + 7.11508453e+01 9.99718632e-01 8.53159448e-03 + 7.55357071e+01 9.99721604e-01 9.37942579e-03 + 8.01032714e+01 9.99726262e-01 1.02955630e-02 + 8.48611510e+01 9.99732796e-01 1.12846370e-02 + 8.98172755e+01 9.99741417e-01 1.23515215e-02 + 9.49799051e+01 9.99752360e-01 1.35014301e-02 + 1.00357644e+02 9.99765879e-01 1.47399305e-02 + 1.05959456e+02 9.99782255e-01 1.60729388e-02 + 1.11794677e+02 9.99801800e-01 1.75065822e-02 + 1.17873031e+02 9.99824854e-01 1.90473159e-02 + 1.20000000e+02 9.99824800e-01 1.95920945e-02 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_pl_work_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_pl_work_region_default_1.txt new file mode 100644 index 0000000..4d54a34 --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_pl_work_region_default_1.txt @@ -0,0 +1,61 @@ + # Time Volume Plastic_Work + 5.00000000e-02 9.99984364e-01 1.79635968e-37 + 2.06250000e-01 9.99935759e-01 2.01544672e-08 + 4.50390625e-01 9.99909751e-01 5.46566788e-06 + 6.41125488e-01 9.99905645e-01 1.20034931e-05 + 8.39807638e-01 9.99903137e-01 1.92938619e-05 + 1.04676821e+00 9.99901078e-01 2.71689233e-05 + 1.26235214e+00 9.99899206e-01 3.55964835e-05 + 1.48691873e+00 9.99897410e-01 4.45747585e-05 + 1.72084227e+00 9.99895640e-01 5.41203773e-05 + 2.08634779e+00 9.99893048e-01 6.94553337e-05 + 2.46708271e+00 9.99890445e-01 8.58616868e-05 + 2.86368158e+00 9.99887810e-01 1.03406869e-04 + 3.27680541e+00 9.99885127e-01 1.22164342e-04 + 3.70714273e+00 9.99882382e-01 1.42211750e-04 + 4.15541077e+00 9.99879566e-01 1.63637782e-04 + 4.62235665e+00 9.99876673e-01 1.86538012e-04 + 5.10875860e+00 9.99873698e-01 2.11018152e-04 + 5.61542731e+00 9.99870636e-01 2.37189991e-04 + 6.14320721e+00 9.99867483e-01 2.65175178e-04 + 6.69297793e+00 9.99864237e-01 2.95107501e-04 + 7.26565578e+00 9.99860893e-01 3.27127855e-04 + 7.86219520e+00 9.99857450e-01 3.61388187e-04 + 8.48359042e+00 9.99853903e-01 3.98052962e-04 + 9.13087712e+00 9.99850251e-01 4.37297326e-04 + 9.80513409e+00 9.99846491e-01 4.79310539e-04 + 1.05074851e+01 9.99842622e-01 5.24296950e-04 + 1.16049086e+01 9.99837000e-01 5.97543522e-04 + 1.27480580e+01 9.99831265e-01 6.77021164e-04 + 1.39388387e+01 9.99825419e-01 7.63226334e-04 + 1.51792352e+01 9.99819468e-01 8.56698090e-04 + 1.64713149e+01 9.99813418e-01 9.58019410e-04 + 1.78172313e+01 9.99807275e-01 1.06781389e-03 + 1.92192275e+01 9.99801048e-01 1.18675185e-03 + 2.06796402e+01 9.99794747e-01 1.31555213e-03 + 2.29615351e+01 9.99786710e-01 1.52859490e-03 + 2.53385090e+01 9.99778825e-01 1.76311397e-03 + 2.78145234e+01 9.99771134e-01 2.02084751e-03 + 3.03937051e+01 9.99763684e-01 2.30365528e-03 + 3.30803527e+01 9.99756528e-01 2.61353217e-03 + 3.58789439e+01 9.99749723e-01 2.95261775e-03 + 3.87941431e+01 9.99743335e-01 3.32320154e-03 + 4.18308089e+01 9.99737432e-01 3.72770751e-03 + 4.49940025e+01 9.99732089e-01 4.16870448e-03 + 4.82889958e+01 9.99727392e-01 4.64893196e-03 + 5.17212805e+01 9.99723435e-01 5.17131789e-03 + 5.52965771e+01 9.99720318e-01 5.73897968e-03 + 5.90208444e+01 9.99718152e-01 6.35522166e-03 + 6.29002894e+01 9.99717059e-01 7.02354560e-03 + 6.69413780e+01 9.99717171e-01 7.74769081e-03 + 7.11508453e+01 9.99718632e-01 8.53159448e-03 + 7.55357071e+01 9.99721604e-01 9.37942579e-03 + 8.01032714e+01 9.99726262e-01 1.02955630e-02 + 8.48611510e+01 9.99732796e-01 1.12846370e-02 + 8.98172755e+01 9.99741417e-01 1.23515215e-02 + 9.49799051e+01 9.99752360e-01 1.35014301e-02 + 1.00357644e+02 9.99765879e-01 1.47399305e-02 + 1.05959456e+02 9.99782255e-01 1.60729388e-02 + 1.11794677e+02 9.99801800e-01 1.75065822e-02 + 1.17873031e+02 9.99824854e-01 1.90473159e-02 + 1.20000000e+02 9.99824800e-01 1.95920945e-02 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_stress_global.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_stress_global.txt new file mode 100644 index 0000000..8eca5bc --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_stress_global.txt @@ -0,0 +1,61 @@ + # Time Volume Sxx Syy Szz Sxy Sxz Syz + 5.00000000e-02 9.99984364e-01 -1.17286159e-11 -1.19304241e-11 -6.43018930e-03 1.00328942e-13 -2.17874083e-14 7.09617246e-14 + 2.06250000e-01 9.99935759e-01 -1.31670047e-10 -5.21193558e-10 -2.64245624e-02 4.93367045e-11 1.26785759e-10 6.96794602e-11 + 4.50390625e-01 9.99909751e-01 -1.94003027e-09 5.99584187e-10 -3.71387594e-02 -7.15524599e-10 5.76057152e-10 -4.34355708e-10 + 6.41125488e-01 9.99905645e-01 -7.37240254e-10 -1.18796608e-09 -3.88392983e-02 -4.87786742e-11 -5.13311427e-10 1.98904186e-10 + 8.39807638e-01 9.99903137e-01 1.87417892e-14 -3.58427812e-11 -3.98838238e-02 -7.09257282e-11 -4.71687096e-11 6.90440160e-13 + 1.04676821e+00 9.99901078e-01 -5.91699105e-12 -2.06183075e-11 -4.07446049e-02 -6.31761049e-11 -4.75040286e-12 -2.97275779e-13 + 1.26235214e+00 9.99899206e-01 -2.20925937e-12 -3.73258914e-12 -4.15297061e-02 -1.61436408e-11 -5.67324342e-12 -1.70517899e-13 + 1.48691873e+00 9.99897410e-01 -2.17896218e-12 -2.97932324e-12 -4.22848079e-02 -1.20735804e-11 -1.28003166e-12 1.38029994e-13 + 1.72084227e+00 9.99895640e-01 -6.89812359e-10 -5.60776275e-10 -4.30303950e-02 -1.38265560e-09 -3.63552605e-10 -4.36119920e-11 + 2.08634779e+00 9.99893048e-01 2.21002118e-13 -1.25316600e-12 -4.41400204e-02 -7.93220449e-12 -4.46846373e-12 -1.33110330e-12 + 2.46708271e+00 9.99890445e-01 1.68828021e-12 -1.40362850e-12 -4.52577035e-02 -6.32225364e-12 -2.46245782e-12 -4.40985258e-13 + 2.86368158e+00 9.99887810e-01 -1.39776424e-12 -2.13732926e-12 -4.63925807e-02 -9.55895417e-12 5.99257288e-12 8.73918280e-13 + 3.27680541e+00 9.99885127e-01 7.98250464e-13 -1.17957844e-12 -4.75515736e-02 -2.78961266e-12 1.18580741e-12 2.33088054e-13 + 3.70714273e+00 9.99882382e-01 1.77132713e-12 -4.84099568e-13 -4.87409397e-02 -3.55522264e-12 -5.15533799e-12 -6.46977059e-13 + 4.15541077e+00 9.99879566e-01 1.76159291e-12 5.11906476e-13 -4.99642706e-02 -2.01453370e-12 -7.27217063e-12 -7.08641578e-13 + 4.62235665e+00 9.99876673e-01 -2.04605239e-14 -3.31793828e-13 -5.12249262e-02 -5.53037054e-12 -6.53180468e-12 -8.94027278e-13 + 5.10875860e+00 9.99873698e-01 -8.44477917e-13 -1.10263464e-12 -5.25252424e-02 -8.36504528e-12 -6.63104675e-12 -9.33028059e-13 + 5.61542731e+00 9.99870636e-01 -1.53134378e-12 -1.26531074e-12 -5.38679544e-02 -8.99067573e-12 -4.32678574e-12 -6.70212765e-13 + 6.14320721e+00 9.99867483e-01 -2.08900058e-12 -1.45400626e-12 -5.52552188e-02 -1.13938649e-11 -4.12250369e-12 -5.25344129e-13 + 6.69297793e+00 9.99864237e-01 -1.39112172e-12 -1.02025900e-12 -5.66886689e-02 -1.04169559e-11 -5.68993449e-12 -8.97989848e-14 + 7.26565578e+00 9.99860893e-01 -4.29781148e-14 -7.42368567e-13 -5.81705456e-02 -5.07064299e-12 -2.62252972e-12 4.81626045e-13 + 7.86219520e+00 9.99857450e-01 2.41572535e-13 -4.79388453e-13 -5.97028225e-02 -4.67576045e-12 -3.31190202e-12 8.33292106e-13 + 8.48359042e+00 9.99853903e-01 3.25356378e-13 -1.14870438e-13 -6.12872695e-02 -4.79068315e-12 -2.82100198e-12 1.44096315e-13 + 9.13087712e+00 9.99850251e-01 2.28509742e-13 -6.57441013e-13 -6.29259516e-02 -4.25444713e-12 1.65342557e-12 6.50833383e-13 + 9.80513409e+00 9.99846491e-01 -1.71934270e-12 -1.06178272e-12 -6.46206684e-02 -5.58448626e-12 4.94468003e-12 5.62488821e-13 + 1.05074851e+01 9.99842622e-01 -7.00428033e-11 1.58109694e-11 -6.63730805e-02 -1.86242102e-10 1.85696241e-10 8.61639787e-11 + 1.16049086e+01 9.99837000e-01 1.47367541e-12 -3.10068713e-12 -6.90793921e-02 8.33668133e-13 9.14918455e-12 1.88116001e-12 + 1.27480580e+01 9.99831265e-01 1.08421175e-12 -3.51651138e-12 -7.18666670e-02 -2.55644026e-12 5.77660122e-12 -2.13599800e-13 + 1.39388387e+01 9.99825419e-01 -6.12159934e-12 -7.37818146e-12 -7.47368079e-02 -1.50561034e-11 6.75330759e-12 -7.46131449e-13 + 1.51792352e+01 9.99819468e-01 -1.30420749e-11 -1.11497395e-11 -7.76910305e-02 -3.14989588e-11 4.82083847e-12 -1.59546114e-13 + 1.64713149e+01 9.99813418e-01 -9.42791593e-12 -3.69788044e-12 -8.07303410e-02 -1.94953774e-11 9.61689624e-12 2.85848761e-12 + 1.78172313e+01 9.99807275e-01 -9.00013358e-12 -4.99864711e-12 -8.38559249e-02 -2.23459563e-11 7.88442723e-12 4.90210025e-12 + 1.92192275e+01 9.99801048e-01 -8.70483291e-12 -2.51554705e-12 -8.70686865e-02 -2.40209269e-11 2.76077143e-12 4.28414893e-12 + 2.06796402e+01 9.99794747e-01 7.84451786e-11 8.01901394e-10 -9.03694378e-02 3.39892371e-10 -2.60719318e-11 5.42923357e-10 + 2.29615351e+01 9.99786710e-01 8.77176380e-11 7.09938653e-11 -9.54120787e-02 4.37145286e-11 1.31256018e-11 4.32982796e-11 + 2.53385090e+01 9.99778825e-01 9.91060940e-11 9.85591557e-11 -1.00548320e-01 4.29676758e-11 2.00240588e-12 6.02109969e-11 + 2.78145234e+01 9.99771134e-01 1.43048946e-10 1.23149298e-10 -1.05775544e-01 5.97915596e-11 -7.82846626e-11 3.87101228e-11 + 3.03937051e+01 9.99763684e-01 1.67614186e-10 1.31252038e-10 -1.11090209e-01 7.83870502e-11 -1.23036631e-10 3.08544861e-11 + 3.30803527e+01 9.99756528e-01 2.08156475e-10 1.72523338e-10 -1.16488028e-01 8.66937972e-11 -1.25607737e-10 3.06951550e-11 + 3.58789439e+01 9.99749723e-01 2.78907322e-10 2.55941455e-10 -1.21963986e-01 1.31478808e-10 -1.28706271e-10 3.99589020e-11 + 3.87941431e+01 9.99743335e-01 3.49633346e-10 2.70724070e-10 -1.27512579e-01 2.01401728e-10 -1.54499633e-10 1.70643596e-11 + 4.18308089e+01 9.99737432e-01 4.68634186e-10 3.37594960e-10 -1.33128321e-01 2.35719142e-10 -2.47509754e-10 -6.46898327e-12 + 4.49940025e+01 9.99732089e-01 5.07668365e-10 3.15035690e-10 -1.38805414e-01 1.72474078e-10 -2.16838676e-10 1.90684497e-11 + 4.82889958e+01 9.99727392e-01 7.35462985e-10 4.31647827e-10 -1.44537089e-01 2.84316929e-10 -1.67941482e-10 2.65865942e-11 + 5.17212805e+01 9.99723435e-01 1.09079124e-09 5.80868299e-10 -1.50315593e-01 4.23658774e-10 -1.93348712e-10 3.65591468e-11 + 5.52965771e+01 9.99720318e-01 1.46711857e-09 7.33191182e-10 -1.56132682e-01 4.24256616e-10 -5.98921237e-10 3.12932725e-12 + 5.90208444e+01 9.99718152e-01 1.89869192e-09 1.00389909e-09 -1.61979958e-01 2.76256676e-10 -1.00510360e-09 -4.61570026e-11 + 6.29002894e+01 9.99717059e-01 2.27284032e-09 1.28494451e-09 -1.67848590e-01 2.00673837e-10 -1.44117629e-09 -3.46606184e-11 + 6.69413780e+01 9.99717171e-01 2.72653954e-09 1.31252295e-09 -1.73728204e-01 2.74353395e-10 -1.15776538e-09 -2.79672505e-12 + 7.11508453e+01 9.99718632e-01 2.84397144e-09 1.09278902e-09 -1.79608945e-01 5.74771895e-10 -4.99280286e-10 1.20789071e-10 + 7.55357071e+01 9.99721604e-01 2.98811415e-09 7.52384967e-10 -1.85480230e-01 6.66017962e-10 -3.03313805e-11 1.67221152e-10 + 8.01032714e+01 9.99726262e-01 3.17104579e-09 4.13741525e-10 -1.91332020e-01 -5.99919753e-12 7.69955055e-11 -2.09739159e-11 + 8.48611510e+01 9.99732796e-01 3.70082532e-09 2.43160232e-10 -1.97153473e-01 -1.75985306e-10 1.29209882e-09 -3.37749489e-12 + 8.98172755e+01 9.99741417e-01 3.35223603e-09 6.98013714e-11 -2.02933702e-01 -7.47243616e-10 1.32135323e-09 -9.89102584e-12 + 9.49799051e+01 9.99752360e-01 4.12317873e-09 6.85692157e-10 -2.08660800e-01 -1.01521965e-09 1.18892048e-09 -3.79303115e-10 + 1.00357644e+02 9.99765879e-01 4.21540463e-09 1.46660215e-09 -2.14321372e-01 -1.55417307e-09 -7.79685188e-10 -6.86281249e-10 + 1.05959456e+02 9.99782255e-01 2.27736476e-09 -8.76402713e-10 -2.19901664e-01 -3.73640993e-09 5.66555364e-10 -7.43386901e-10 + 1.11794677e+02 9.99801800e-01 2.50998868e-09 -2.91972540e-09 -2.25390382e-01 -6.11149567e-09 3.17082810e-09 -2.17943610e-09 + 1.17873031e+02 9.99824854e-01 1.39777757e-09 -6.93034871e-09 -2.30776112e-01 -7.71429702e-09 6.58059316e-09 -2.39948168e-09 + 1.20000000e+02 9.99824800e-01 1.95726748e-10 -4.34564212e-11 -2.32630337e-01 7.87356588e-11 5.22192696e-11 8.14534180e-11 diff --git a/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_stress_region_default_1.txt b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_stress_region_default_1.txt new file mode 100644 index 0000000..8eca5bc --- /dev/null +++ b/workflows/exaconstit-calibrate/examples/voce_ea_1m3/avg_stress_region_default_1.txt @@ -0,0 +1,61 @@ + # Time Volume Sxx Syy Szz Sxy Sxz Syz + 5.00000000e-02 9.99984364e-01 -1.17286159e-11 -1.19304241e-11 -6.43018930e-03 1.00328942e-13 -2.17874083e-14 7.09617246e-14 + 2.06250000e-01 9.99935759e-01 -1.31670047e-10 -5.21193558e-10 -2.64245624e-02 4.93367045e-11 1.26785759e-10 6.96794602e-11 + 4.50390625e-01 9.99909751e-01 -1.94003027e-09 5.99584187e-10 -3.71387594e-02 -7.15524599e-10 5.76057152e-10 -4.34355708e-10 + 6.41125488e-01 9.99905645e-01 -7.37240254e-10 -1.18796608e-09 -3.88392983e-02 -4.87786742e-11 -5.13311427e-10 1.98904186e-10 + 8.39807638e-01 9.99903137e-01 1.87417892e-14 -3.58427812e-11 -3.98838238e-02 -7.09257282e-11 -4.71687096e-11 6.90440160e-13 + 1.04676821e+00 9.99901078e-01 -5.91699105e-12 -2.06183075e-11 -4.07446049e-02 -6.31761049e-11 -4.75040286e-12 -2.97275779e-13 + 1.26235214e+00 9.99899206e-01 -2.20925937e-12 -3.73258914e-12 -4.15297061e-02 -1.61436408e-11 -5.67324342e-12 -1.70517899e-13 + 1.48691873e+00 9.99897410e-01 -2.17896218e-12 -2.97932324e-12 -4.22848079e-02 -1.20735804e-11 -1.28003166e-12 1.38029994e-13 + 1.72084227e+00 9.99895640e-01 -6.89812359e-10 -5.60776275e-10 -4.30303950e-02 -1.38265560e-09 -3.63552605e-10 -4.36119920e-11 + 2.08634779e+00 9.99893048e-01 2.21002118e-13 -1.25316600e-12 -4.41400204e-02 -7.93220449e-12 -4.46846373e-12 -1.33110330e-12 + 2.46708271e+00 9.99890445e-01 1.68828021e-12 -1.40362850e-12 -4.52577035e-02 -6.32225364e-12 -2.46245782e-12 -4.40985258e-13 + 2.86368158e+00 9.99887810e-01 -1.39776424e-12 -2.13732926e-12 -4.63925807e-02 -9.55895417e-12 5.99257288e-12 8.73918280e-13 + 3.27680541e+00 9.99885127e-01 7.98250464e-13 -1.17957844e-12 -4.75515736e-02 -2.78961266e-12 1.18580741e-12 2.33088054e-13 + 3.70714273e+00 9.99882382e-01 1.77132713e-12 -4.84099568e-13 -4.87409397e-02 -3.55522264e-12 -5.15533799e-12 -6.46977059e-13 + 4.15541077e+00 9.99879566e-01 1.76159291e-12 5.11906476e-13 -4.99642706e-02 -2.01453370e-12 -7.27217063e-12 -7.08641578e-13 + 4.62235665e+00 9.99876673e-01 -2.04605239e-14 -3.31793828e-13 -5.12249262e-02 -5.53037054e-12 -6.53180468e-12 -8.94027278e-13 + 5.10875860e+00 9.99873698e-01 -8.44477917e-13 -1.10263464e-12 -5.25252424e-02 -8.36504528e-12 -6.63104675e-12 -9.33028059e-13 + 5.61542731e+00 9.99870636e-01 -1.53134378e-12 -1.26531074e-12 -5.38679544e-02 -8.99067573e-12 -4.32678574e-12 -6.70212765e-13 + 6.14320721e+00 9.99867483e-01 -2.08900058e-12 -1.45400626e-12 -5.52552188e-02 -1.13938649e-11 -4.12250369e-12 -5.25344129e-13 + 6.69297793e+00 9.99864237e-01 -1.39112172e-12 -1.02025900e-12 -5.66886689e-02 -1.04169559e-11 -5.68993449e-12 -8.97989848e-14 + 7.26565578e+00 9.99860893e-01 -4.29781148e-14 -7.42368567e-13 -5.81705456e-02 -5.07064299e-12 -2.62252972e-12 4.81626045e-13 + 7.86219520e+00 9.99857450e-01 2.41572535e-13 -4.79388453e-13 -5.97028225e-02 -4.67576045e-12 -3.31190202e-12 8.33292106e-13 + 8.48359042e+00 9.99853903e-01 3.25356378e-13 -1.14870438e-13 -6.12872695e-02 -4.79068315e-12 -2.82100198e-12 1.44096315e-13 + 9.13087712e+00 9.99850251e-01 2.28509742e-13 -6.57441013e-13 -6.29259516e-02 -4.25444713e-12 1.65342557e-12 6.50833383e-13 + 9.80513409e+00 9.99846491e-01 -1.71934270e-12 -1.06178272e-12 -6.46206684e-02 -5.58448626e-12 4.94468003e-12 5.62488821e-13 + 1.05074851e+01 9.99842622e-01 -7.00428033e-11 1.58109694e-11 -6.63730805e-02 -1.86242102e-10 1.85696241e-10 8.61639787e-11 + 1.16049086e+01 9.99837000e-01 1.47367541e-12 -3.10068713e-12 -6.90793921e-02 8.33668133e-13 9.14918455e-12 1.88116001e-12 + 1.27480580e+01 9.99831265e-01 1.08421175e-12 -3.51651138e-12 -7.18666670e-02 -2.55644026e-12 5.77660122e-12 -2.13599800e-13 + 1.39388387e+01 9.99825419e-01 -6.12159934e-12 -7.37818146e-12 -7.47368079e-02 -1.50561034e-11 6.75330759e-12 -7.46131449e-13 + 1.51792352e+01 9.99819468e-01 -1.30420749e-11 -1.11497395e-11 -7.76910305e-02 -3.14989588e-11 4.82083847e-12 -1.59546114e-13 + 1.64713149e+01 9.99813418e-01 -9.42791593e-12 -3.69788044e-12 -8.07303410e-02 -1.94953774e-11 9.61689624e-12 2.85848761e-12 + 1.78172313e+01 9.99807275e-01 -9.00013358e-12 -4.99864711e-12 -8.38559249e-02 -2.23459563e-11 7.88442723e-12 4.90210025e-12 + 1.92192275e+01 9.99801048e-01 -8.70483291e-12 -2.51554705e-12 -8.70686865e-02 -2.40209269e-11 2.76077143e-12 4.28414893e-12 + 2.06796402e+01 9.99794747e-01 7.84451786e-11 8.01901394e-10 -9.03694378e-02 3.39892371e-10 -2.60719318e-11 5.42923357e-10 + 2.29615351e+01 9.99786710e-01 8.77176380e-11 7.09938653e-11 -9.54120787e-02 4.37145286e-11 1.31256018e-11 4.32982796e-11 + 2.53385090e+01 9.99778825e-01 9.91060940e-11 9.85591557e-11 -1.00548320e-01 4.29676758e-11 2.00240588e-12 6.02109969e-11 + 2.78145234e+01 9.99771134e-01 1.43048946e-10 1.23149298e-10 -1.05775544e-01 5.97915596e-11 -7.82846626e-11 3.87101228e-11 + 3.03937051e+01 9.99763684e-01 1.67614186e-10 1.31252038e-10 -1.11090209e-01 7.83870502e-11 -1.23036631e-10 3.08544861e-11 + 3.30803527e+01 9.99756528e-01 2.08156475e-10 1.72523338e-10 -1.16488028e-01 8.66937972e-11 -1.25607737e-10 3.06951550e-11 + 3.58789439e+01 9.99749723e-01 2.78907322e-10 2.55941455e-10 -1.21963986e-01 1.31478808e-10 -1.28706271e-10 3.99589020e-11 + 3.87941431e+01 9.99743335e-01 3.49633346e-10 2.70724070e-10 -1.27512579e-01 2.01401728e-10 -1.54499633e-10 1.70643596e-11 + 4.18308089e+01 9.99737432e-01 4.68634186e-10 3.37594960e-10 -1.33128321e-01 2.35719142e-10 -2.47509754e-10 -6.46898327e-12 + 4.49940025e+01 9.99732089e-01 5.07668365e-10 3.15035690e-10 -1.38805414e-01 1.72474078e-10 -2.16838676e-10 1.90684497e-11 + 4.82889958e+01 9.99727392e-01 7.35462985e-10 4.31647827e-10 -1.44537089e-01 2.84316929e-10 -1.67941482e-10 2.65865942e-11 + 5.17212805e+01 9.99723435e-01 1.09079124e-09 5.80868299e-10 -1.50315593e-01 4.23658774e-10 -1.93348712e-10 3.65591468e-11 + 5.52965771e+01 9.99720318e-01 1.46711857e-09 7.33191182e-10 -1.56132682e-01 4.24256616e-10 -5.98921237e-10 3.12932725e-12 + 5.90208444e+01 9.99718152e-01 1.89869192e-09 1.00389909e-09 -1.61979958e-01 2.76256676e-10 -1.00510360e-09 -4.61570026e-11 + 6.29002894e+01 9.99717059e-01 2.27284032e-09 1.28494451e-09 -1.67848590e-01 2.00673837e-10 -1.44117629e-09 -3.46606184e-11 + 6.69413780e+01 9.99717171e-01 2.72653954e-09 1.31252295e-09 -1.73728204e-01 2.74353395e-10 -1.15776538e-09 -2.79672505e-12 + 7.11508453e+01 9.99718632e-01 2.84397144e-09 1.09278902e-09 -1.79608945e-01 5.74771895e-10 -4.99280286e-10 1.20789071e-10 + 7.55357071e+01 9.99721604e-01 2.98811415e-09 7.52384967e-10 -1.85480230e-01 6.66017962e-10 -3.03313805e-11 1.67221152e-10 + 8.01032714e+01 9.99726262e-01 3.17104579e-09 4.13741525e-10 -1.91332020e-01 -5.99919753e-12 7.69955055e-11 -2.09739159e-11 + 8.48611510e+01 9.99732796e-01 3.70082532e-09 2.43160232e-10 -1.97153473e-01 -1.75985306e-10 1.29209882e-09 -3.37749489e-12 + 8.98172755e+01 9.99741417e-01 3.35223603e-09 6.98013714e-11 -2.02933702e-01 -7.47243616e-10 1.32135323e-09 -9.89102584e-12 + 9.49799051e+01 9.99752360e-01 4.12317873e-09 6.85692157e-10 -2.08660800e-01 -1.01521965e-09 1.18892048e-09 -3.79303115e-10 + 1.00357644e+02 9.99765879e-01 4.21540463e-09 1.46660215e-09 -2.14321372e-01 -1.55417307e-09 -7.79685188e-10 -6.86281249e-10 + 1.05959456e+02 9.99782255e-01 2.27736476e-09 -8.76402713e-10 -2.19901664e-01 -3.73640993e-09 5.66555364e-10 -7.43386901e-10 + 1.11794677e+02 9.99801800e-01 2.50998868e-09 -2.91972540e-09 -2.25390382e-01 -6.11149567e-09 3.17082810e-09 -2.17943610e-09 + 1.17873031e+02 9.99824854e-01 1.39777757e-09 -6.93034871e-09 -2.30776112e-01 -7.71429702e-09 6.58059316e-09 -2.39948168e-09 + 1.20000000e+02 9.99824800e-01 1.95726748e-10 -4.34564212e-11 -2.32630337e-01 7.87356588e-11 5.22192696e-11 8.14534180e-11 diff --git a/workflows/exaconstit-calibrate/pyproject.toml b/workflows/exaconstit-calibrate/pyproject.toml new file mode 100644 index 0000000..e4bdd04 --- /dev/null +++ b/workflows/exaconstit-calibrate/pyproject.toml @@ -0,0 +1,134 @@ +[build-system] +requires = ["setuptools>=77.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "exaconstit-calibrate" +version = "0.1.0" +description = "Calibration infrastructure for ExaConstit: NSGA-III parameter fitting, yield-surface fitting (planned), and a code-agnostic framework for driving external simulation codes from Python." +readme = "README.md" +requires-python = ">=3.10" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +authors = [ + { name = "Lawrence Livermore National Laboratory" }, +] +keywords = [ + "ExaConstit", + "crystal plasticity", + "material parameter calibration", + "yield surface", + "NSGA-III", + "genetic algorithm", + "finite element", + "LLNL", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Physics", +] + +# Runtime dependencies required by the core framework. Kept minimal +# on purpose: numpy, pandas, scipy are the only hard requirements. +# SQLite is stdlib. DEAP, matplotlib, and flux-core are all optional. +dependencies = [ + "numpy>=1.21", + "pandas>=1.3", + "scipy>=1.7", +] + +[project.optional-dependencies] +# The NSGA-III driver needs the rcarson3 DEAP fork for the +# U-NSGA-III niching step. Installable from the fork's GitHub URL; +# users who pin the SHA can do so via direct-url installs instead. +nsga3 = [ + "deap @ git+https://github.com/rcarson3/deap.git", +] +# Soft dep for the postprocess plotting helpers. Framework data +# loading / analysis works fine without this. +plot = [ + "matplotlib>=3.5", +] +# Flux is an HPC scheduler; its Python bindings are available only +# on systems where flux-core is installed. Listing here makes the +# intent explicit even though "pip install" won't always succeed. +flux = [ + "flux-python>=0.50", +] +# Test suite dependencies. "pip install .[test]" is what CI runs. +test = [ + "pytest>=7", + "matplotlib>=3.5", + "deap @ git+https://github.com/rcarson3/deap.git", +] +# Catch-all for "install everything installable on a laptop". Drop +# the flux entry from this set since it fails on non-HPC dev +# machines. +all = [ + "matplotlib>=3.5", + "deap @ git+https://github.com/rcarson3/deap.git", +] + +[project.urls] +Homepage = "https://github.com/LLNL/ExaConstit" +Repository = "https://github.com/LLNL/ExaConstit" +Documentation = "https://github.com/LLNL/ExaConstit/tree/main/workflows" + +# --- Package discovery -------------------------------------------------- +# +# Two top-level packages ship together: +# +# workflow_common/ - the framework (code-agnostic; no DEAP dep) +# workflows/ - currently contains workflows.optimization with +# the NSGA-III driver. Uses the "workflows" +# top-level name because that's where the +# pre-refactor scripts live in the ExaConstit +# repo; keeping the name preserves import paths +# in migrated user code. +# +# The distribution name ("exaconstit-calibrate") and the importable +# names ("workflow_common", "workflows") are deliberately different: +# the distribution name captures scope (CP calibration, yield-surface +# fitting to come), while the importable names capture the role of +# each subpackage. Renaming the imports would break every migrated +# driver. + +[tool.setuptools.packages.find] +where = ["."] +include = ["workflow_common*", "workflows*"] +exclude = ["tests*"] + +# Include the two top-level Markdown docs inside the workflow_common +# package so pip-installed users can find them via +# importlib.resources.files("workflow_common") / "ARCHITECTURE.md". +[tool.setuptools.package-data] +workflow_common = ["*.md"] + +# --- Tooling ------------------------------------------------------------ + +[tool.pytest.ini_options] +minversion = "7.0" +# tests/conftest.py adds the repo root to sys.path so the in-tree +# workflow_common imports cleanly during development. No rootdir +# override needed beyond that. +testpaths = ["tests"] +addopts = [ + "-ra", + "--strict-markers", +] +# Warnings-as-errors for anything that looks like a real bug in +# framework code. DEAP's fork emits a few deprecation warnings from +# older numpy APIs; filter those. +filterwarnings = [ + "error", + "ignore::DeprecationWarning:deap.*", + "ignore::DeprecationWarning:pandas.*", + # SyntaxWarning from the rcarson3 DEAP fork's older regex patterns. + "ignore::SyntaxWarning", +] diff --git a/workflows/exaconstit-calibrate/test_script.sh b/workflows/exaconstit-calibrate/test_script.sh new file mode 100644 index 0000000..3605c4e --- /dev/null +++ b/workflows/exaconstit-calibrate/test_script.sh @@ -0,0 +1,6 @@ + PY=/usr/tce/packages/python/python-3.12.2/bin/python + export PYTHONPATH=/usr/lib64/flux/python3.12${PYTHONPATH:+:$PYTHONPATH} + + $PY -m pip install -e ".[test,nsga3,plot]" + cd examples + srun -n 1 --pty --mpi=none --mpibind=off flux start $PY nsga3_calibration.py --backend flux \ No newline at end of file diff --git a/workflows/exaconstit-calibrate/tests/conftest.py b/workflows/exaconstit-calibrate/tests/conftest.py new file mode 100644 index 0000000..a2c66a3 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/conftest.py @@ -0,0 +1,272 @@ +""" +Shared pytest fixtures for the workflow_common test suite. + +What's here +----------- +Fixtures that are reused across multiple test files. Two broad +categories: + +1. **Workspace fixtures** - provide throwaway directories for tests + to write into. pytest's built-in ``tmp_path`` handles the basics; + the ``workspace`` fixture below adds the repo path to sys.path + and configures logging so individual tests do not have to. + +2. **Fake simulation fixtures** - produce a small executable Python + script that mimics just enough of ExaConstit's output conventions + for the integration tests to exercise the full pipeline without + requiring the real simulation code to be installed. + +Why a separate "fake binary" instead of mocking the backend +----------------------------------------------------------- +Integration tests that mock out the backend only verify the Python +code paths. They cannot catch bugs in how environment variables are +set, how working directories are created, how output files are +written and closed, how subprocess stdio is captured, or how timeouts +interact with partially-written files. All of those have bitten real +ExaConstit workflows at least once. Exercising a real subprocess +with a stand-in binary catches those bugs; pure mocks do not. + +The fake binary is tiny - about 30 lines of Python with no imports +beyond the stdlib - so tests that use it stay fast (tens of +milliseconds each). +""" +from __future__ import annotations + +import os +import stat +import sys +import textwrap +from pathlib import Path +from typing import Callable + +import pytest + +# Make workflow_common importable without install when running the +# tests directly against a source checkout. The repository root is +# two levels up from this file (``tests/conftest.py``). When +# workflow_common is pip-installed (e.g. `pip install .[test]`) +# the repo-root path is harmless: importing still picks up the +# installed package because site-packages is earlier on sys.path +# than the insert at position 0 would place it... actually, to +# favor the INSTALLED package, append rather than insert. That way +# developers doing "pip install -e ." hit the editable install +# first, and users running pytest in a source checkout without +# install still get a working import chain via the appended path. +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.append(str(REPO_ROOT)) + + +# --- Fake simulation binary ---------------------------------------------- +# +# Small Python script that plays the role of ExaConstit for testing. +# It reads a handful of key=value pairs from options.toml via regex +# (not a real TOML parser; we don't want a dependency for tests), +# writes a plausible-looking avg_stress.txt, and optionally fails on +# demand via environment variables. This mirrors what the demo script +# uses but is exposed as a fixture for test files. + +FAKE_BINARY_SRC = textwrap.dedent( + r""" + #!/usr/bin/env python3 + '''Test stand-in for ExaConstit. + + Reads options.toml for strain_rate and yield_stress (both simple + numeric fields). Writes results//avg_stress.txt and + avg_def_grad.txt with synthetic stress-strain data that follows + a saturating exponential (Voce-like) response. + + Environment variables the test suite uses to control behavior: + FAKE_FAIL=1 -> exit 7 with no output + FAKE_SLEEP= -> sleep between reading and writing + FAKE_TRUNCATE=1 -> write partial avg_stress.txt then exit 0 + (simulates a kill-after-write) + FAKE_MISSING= -> skip writing the named output file + ''' + import math + import os + import pathlib + import re + import sys + import time + + options = pathlib.Path("options.toml") + if not options.exists(): + print("options.toml missing", file=sys.stderr) + sys.exit(2) + text = options.read_text() + + def _num(key, default): + m = re.search(rf"{key}\s*=\s*([-\d.eE+]+)", text) + return float(m.group(1)) if m else default + + strain_rate = _num("strain_rate", 1e-3) + yield_stress = _num("yield_stress", 200.0) + hardening = _num("hardening", 2000.0) + basename = "options" + m_base = re.search(r'basename\s*=\s*"([^"]+)"', text) + if m_base: + basename = m_base.group(1) + + time.sleep(float(os.environ.get("FAKE_SLEEP", "0.01"))) + if os.environ.get("FAKE_FAIL") == "1": + print("fake_sim: forced failure", file=sys.stderr) + sys.exit(7) + + results = pathlib.Path("results") / basename + results.mkdir(parents=True, exist_ok=True) + + skip = os.environ.get("FAKE_MISSING", "") + truncate = os.environ.get("FAKE_TRUNCATE") == "1" + + # Generate 50 time steps of saturating response. + # sigma(eps) = yield_stress + hardening * (1 - exp(-50*eps)) + # eps(t) = strain_rate * t, uniaxial along z (matches ExaConstit's + # z-axis convention; StressStrainExtractor defaults to Szz/F33). + n = 50 + t_max = 1.0 + + # ExaConstit's volume-averaged output files start with an indented + # commented header line, followed by indented whitespace-separated + # data rows. Format matches + # ExaConstit/src/postprocessing/postprocessing_file_manager.hpp + # :: GetVolumeAverageHeader. The indentation is load-bearing: + # pandas' C engine mis-handles the "indented # on line 1 + indented + # data rows" shape, which is why the framework reader switches to + # the python engine whenever ``comment`` is set. The fake binary + # emits the exact shape so that path is exercised end-to-end. + stress_header = ( + " # Time Volume " + "Sxx Syy Szz " + "Sxy Sxz Syz" + ) + defgrad_header = ( + " # Time Volume " + "F11 F12 F13 " + "F21 F22 F23 " + "F31 F32 F33" + ) + + rows_stress = [stress_header] + rows_F = [defgrad_header] + for i in range(n): + t = t_max * i / (n - 1) + eps = strain_rate * t + # Load along z so Szz is the active component; Sxx = Syy = 0. + szz = yield_stress + hardening * (1.0 - math.exp(-50.0 * eps)) + volume = 1.0 # unit cube, held constant for simplicity + rows_stress.append( + f" {t:.8e} {volume:.8e} " + f"{0.0:.8e} {0.0:.8e} {szz:.8e} " + f"{0.0:.8e} {0.0:.8e} {0.0:.8e}" + ) + # Deformation gradient: axial stretch along z so F33 = 1+eps; + # transverse (F11, F22) set to 1-0.5*eps for a 0.5 nominal + # Poisson effect; all off-diagonals zero. + axial = 1.0 + eps + lateral = 1.0 - 0.5 * eps + rows_F.append( + f" {t:.8e} {volume:.8e} " + f"{lateral:.8e} {0.0:.8e} {0.0:.8e} " + f"{0.0:.8e} {lateral:.8e} {0.0:.8e} " + f"{0.0:.8e} {0.0:.8e} {axial:.8e}" + ) + + if truncate: + # Drop last 10 data rows to simulate a killed write. Keep the + # header so the file still parses (truncation is about partial + # data, not a corrupt file). + rows_stress = rows_stress[:-10] + + if skip != "avg_stress": + (results / "avg_stress.txt").write_text("\n".join(rows_stress) + "\n") + if skip != "avg_def_grad": + (results / "avg_def_grad.txt").write_text("\n".join(rows_F) + "\n") + """ +).lstrip() + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + """A temporary working directory for one test. + + Wraps pytest's ``tmp_path`` so every test that needs a workspace + gets a fresh one, automatically cleaned up on teardown. + + Returns: + The workspace path. The directory exists and is empty. + """ + return tmp_path + + +@pytest.fixture +def fake_binary(workspace: Path) -> Path: + """Write the fake simulation script into the workspace and chmod +x. + + The script is written once per test. Tests that need to vary its + behavior control it through environment variables (``FAKE_FAIL``, + ``FAKE_SLEEP``, ``FAKE_TRUNCATE``, ``FAKE_MISSING``) rather than + by editing the script, so we never have to reason about source + interpolation inside a test. + + Args: + workspace: The per-test temporary directory. + + Returns: + Path to the executable script. + """ + path = workspace / "fake_sim.py" + path.write_text(FAKE_BINARY_SRC) + # Give everyone execute permission. The script lives under /tmp + # (or wherever pytest puts tmp_path) so permissive bits are fine. + path.chmod( + path.stat().st_mode + | stat.S_IEXEC + | stat.S_IXGRP + | stat.S_IXOTH + ) + return path + + +@pytest.fixture +def master_template(workspace: Path) -> Path: + """Write a minimal ExaConstit-like master template. + + The template exercises the ``%%key%%`` placeholder syntax and + includes fields the fake binary looks for (``strain_rate``, + ``yield_stress``, ``hardening``, ``basename``), plus one the + fake does not use (``temp_k``) so tests can verify that extra + keys pass through cleanly. + """ + content = textwrap.dedent( + """\ + [Problem] + name = "sim_%%gene%%_%%obj%%" + basename = "options" + strain_rate = %%strain_rate%% + yield_stress = %%yield_stress%% + hardening = %%hardening%% + temperature_k = %%temp_k%% + """ + ) + path = workspace / "master_options.toml" + path.write_text(content) + return path + + +@pytest.fixture(autouse=True) +def clean_fake_env(): + """Remove any FAKE_* env vars leaked in by prior tests. + + ``autouse=True`` means this runs before every test in the suite, + without requiring tests to ask for it. Prevents a test that sets + ``FAKE_FAIL`` from silently corrupting the next test if it + forgets to clean up. + """ + keys = [k for k in os.environ if k.startswith("FAKE_")] + for k in keys: + os.environ.pop(k, None) + yield + keys = [k for k in os.environ if k.startswith("FAKE_")] + for k in keys: + os.environ.pop(k, None) diff --git a/workflows/exaconstit-calibrate/tests/demo_workflow.py b/workflows/exaconstit-calibrate/tests/demo_workflow.py new file mode 100644 index 0000000..fd689e0 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/demo_workflow.py @@ -0,0 +1,536 @@ +""" +End-to-end smoke test / demo for workflow_common. + +Purpose +------- +This script exists to serve three audiences: + +1. **Developers testing changes to workflow_common**: the fastest way + to verify the plumbing still works after a modification is to run + this script on a desktop. It does not require flux, an HPC + allocation, or ExaConstit itself. +2. **New users learning the framework**: the code here is a concrete + minimal example of how the pieces (``TemplatePathResolver``, + ``SimJobSpec``, ``LocalBackend``, ``Manifest``, sentinels) fit + together. Read it alongside the module-level docstrings. +3. **CI**: the script's return code is 0 when everything worked, so + a continuous-integration job can run it as a regression check. + +What it does +------------ +The script creates a small temporary workspace under ``/tmp/wfc_demo`` +(or wherever ``--root`` points), writes a trivial "fake simulation" +Python script, and then runs a tiny 3-gene, 2-objective optimization +against that fake simulation. The fake sim reads its options.toml, +sleeps briefly, and writes an ``avg_stress.txt`` file with a made-up +stress-strain curve. No real physics, just enough plumbing to +exercise every part of the framework. + +Modes +----- +The ``--phase`` flag selects which scenario is exercised: + +* ``full``: happy path. All cases succeed. +* ``partial``: every case is poisoned to fail via the ``FAKE_FAIL`` + environment variable. Used to verify the failure path and the + manifest's FAILED state transitions. +* ``restart``: rerun after a ``partial`` phase. The restart logic + should see failed sentinels and either rerun or skip depending + on policy. (Current demo just rechecks sentinels; adjust to taste + when prototyping your own restart policy.) + +Usage +----- +Quickest verification:: + + python tests/demo_workflow.py --clean --phase full + +A restart cycle:: + + python tests/demo_workflow.py --clean --phase partial + python tests/demo_workflow.py --phase restart + +The ``--clean`` flag wipes the workspace before starting, so repeated +runs are deterministic. Omit it to inspect leftover state from a +previous run. + +Reading guide +------------- +If you are reading this file to learn the framework, start here and +follow the call chain in order: + +1. :func:`main` at the bottom - entry point. Shows how the pieces + are wired together: logging, path resolver, manifest, backend. +2. :func:`run_generation` - the heart of a real driver. Demonstrates + the sentinel-first-then-manifest ordering, the restart skip + check, and output validation. +3. :func:`build_specs_and_contexts` - how per-case input files get + rendered from a template and turned into executable specs. +4. The fake binary ``FAKE_BINARY_SRC`` - a stub "simulation" that + reads its options and writes a plausible output file. In a real + workflow this would be replaced by the actual mechanics code. + +Everything else in this file is plumbing to make the three functions +above self-contained and runnable on any machine. +""" +from __future__ import annotations + +import argparse +import os +import shutil +import stat +import sys +import textwrap +import time +from pathlib import Path + +# Make workflow_common importable without a package install, by putting +# the repository root on sys.path at runtime. This is the "script +# living next to the package" idiom and is fine for internal tools. +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) + +from workflow_common import ( # noqa: E402 + CaseContext, + CaseState, + LocalBackend, + Manifest, + ManifestEntry, + SimJobSpec, + TemplatePathResolver, + configure_logging, + get_logger, + render_template_file, + write_sentinel, +) +from workflow_common.backends.base import JobOutcome # noqa: E402 +from workflow_common.sentinel import ( # noqa: E402 + is_case_complete, + read_sentinel, + Sentinel, + validate_outputs, +) + +logger = get_logger("demo") + + +# --- Fixtures ------------------------------------------------------------- +# +# We generate both the fake simulation binary and the master template on +# the fly so the demo is fully self-contained - the repo does not need to +# carry these as extra data files. + +# Source code of the fake simulation. Written out to disk, chmod +x'd, +# and invoked like a real binary. Reads options.toml for a strain_rate, +# sleeps briefly to simulate work, and writes a dummy avg_stress.txt. +# Honors FAKE_FAIL=1 to force a nonzero rc, which is how the --phase +# partial mode exercises the failure path. +FAKE_BINARY_SRC = textwrap.dedent( + """\ + #!/usr/bin/env python3 + '''Fake simulation. Reads options.toml (we just grep for a value), + writes an avg_stress.txt, optionally fails depending on env vars.''' + import os, sys, time, pathlib, re + + opt_path = pathlib.Path("options.toml") + if not opt_path.exists(): + print("no options.toml", file=sys.stderr) + sys.exit(2) + + text = opt_path.read_text() + m = re.search(r'strain_rate\\s*=\\s*([-\\d.eE+]+)', text) + strain_rate = float(m.group(1)) if m else 1e-3 + + # Simulate a bit of work so parallel execution actually looks parallel. + time.sleep(float(os.environ.get('FAKE_SLEEP', '0.05'))) + + # Optional forced failure, controlled by the demo driver. + if os.environ.get('FAKE_FAIL') == '1': + print('simulated crash', file=sys.stderr) + sys.exit(7) + + out_dir = pathlib.Path('results/options') + out_dir.mkdir(parents=True, exist_ok=True) + rows = [] + for i in range(10): + t = 0.1 * i + s = 100.0 * (1 - 2 ** (-strain_rate * t * 10)) + rows.append(f'{s:.6f} {strain_rate*t:.6f}') + (out_dir / 'avg_stress.txt').write_text('\\n'.join(rows) + '\\n') + print('fake sim ok') + """ +) + + +# Master-template input "file". Uses the %%key%% placeholder syntax so +# the template renderer substitutes per-case values in. +MASTER_TEMPLATE = textwrap.dedent( + """\ + [Problem] + name = "case_%%gene%%_%%obj%%" + strain_rate = %%strain_rate%% + temperature_k = %%temp_k%% + """ +) + + +def prepare_fake_binary(root: Path) -> Path: + """Write the fake simulation script to disk and make it executable. + + Args: + root: Directory under which to write the script. Written as + ``/fake_sim.py``. + + Returns: + Path to the written script, ready to use as a + :attr:`SimJobSpec.binary`. + """ + bin_path = root / "fake_sim.py" + bin_path.write_text(FAKE_BINARY_SRC) + # chmod +x so the kernel can exec it via shebang. We preserve any + # existing mode bits and OR in the execute bits for all users; the + # script lives under /tmp so permissive perms are fine. + bin_path.chmod( + bin_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH + ) + return bin_path + + +# --- Core per-generation flow -------------------------------------------- + + +def build_specs_and_contexts( + genes, + generation: int, + resolver: TemplatePathResolver, + binary: Path, + master_template_path: Path, +): + """Render per-case inputs and build :class:`SimJobSpec` instances. + + For each gene and each objective, this function: + + 1. Builds a :class:`CaseContext` tagged with per-case parameters. + 2. Resolves the working directory via the supplied + :class:`TemplatePathResolver`. + 3. Renders the master template into an ``options.toml`` inside + that working directory. + 4. Constructs a :class:`SimJobSpec` pointing at the fake binary. + + The function returns pairs ``(ctx, spec)`` rather than just specs + because downstream code needs both: the context to drive the + manifest transitions, and the spec to hand to the backend. + + Args: + genes: A list of genes, where each gene is itself a list of + per-objective parameter dicts. For example, + ``genes[1][0]`` is the parameter dict for gene 1, + objective 0. + generation: The generation number for this batch. + resolver: Provides per-case working directories. + binary: Path to the simulation binary. + master_template_path: Template to render per case. + + Returns: + A list of ``(CaseContext, SimJobSpec)`` pairs, one per + objective of each gene. + """ + out = [] + for igene, gene in enumerate(genes): + for iobj, params in enumerate(gene): + ctx = CaseContext( + generation=generation, + gene=igene, + obj=iobj, + extra=dict(params), + ) + wd = resolver.working_dir(ctx) + wd.mkdir(parents=True, exist_ok=True) + + # Render options.toml with per-case parameter values. The + # values dict picks up the same per-case parameters used + # in the CaseContext.extra, plus the indices themselves. + values = dict(gene=igene, obj=iobj, **params) + render_template_file( + master_template_path, + wd / "options.toml", + values, + ) + + spec = SimJobSpec( + working_dir=wd, + binary=binary, + args=("-opt", "options.toml"), + num_nodes=1, + num_tasks=1, + duration_s=60, + stdout="stdout.log", + stderr="stderr.log", + tag=f"gen{generation}_g{igene}_o{iobj}", + ) + out.append((ctx, spec)) + return out + + +def run_generation( + *, + generation: int, + genes, + resolver: TemplatePathResolver, + binary: Path, + master_template_path: Path, + manifest: Manifest, + backend: LocalBackend, + required_outputs, +): + """Run one generation end-to-end with manifest and sentinel plumbing. + + This is the function that shows how a real driver should wire up + the machinery. Steps, in order: + + 1. Build all case contexts and specs for this generation. + 2. For each case: check if a sentinel already says it is complete. + If so, skip (and reflect that in the manifest). + 3. For each non-skipped case: record a SUBMITTED manifest entry, + then hand the spec to the backend. + 4. As results stream back, validate the outputs, write a sentinel + (sentinel first!), then record the terminal manifest entry. + + The sentinel-before-manifest ordering is deliberate: if a crash + happens between the two writes, the sentinel is already on disk, + so restart correctly treats the case as complete. The manifest + catches up on the next successful record. + + Args: + generation: Current generation number. + genes: Per-gene, per-objective parameter dicts. + resolver: Path resolver for this run. + binary: Simulation binary. + master_template_path: Input-file template. + manifest: The run's manifest (already loaded). + backend: The job backend to use. + required_outputs: List of output-file paths (relative to each + case's working directory) that must exist and be nonempty + for the case to be considered successful. + """ + all_items = build_specs_and_contexts( + genes, generation, resolver, binary, master_template_path + ) + + to_run = [] + skipped = 0 + for ctx, spec in all_items: + # Restart shortcut: a sentinel means this case was completed + # (successfully or not) on a previous run. We do not rerun it, + # but we do reflect the outcome in the manifest so the + # in-memory state matches disk. + if is_case_complete(spec.working_dir): + s = read_sentinel(spec.working_dir) + logger.info( + "skipping gen=%d gene=%d obj=%d (sentinel present, rc=%d)", + ctx.generation, ctx.gene, ctx.obj, s.rc if s else -1, + ) + manifest.record( + ManifestEntry( + generation=ctx.generation, + gene=ctx.gene, + obj=ctx.obj, + state=( + CaseState.COMPLETED + if (s and s.rc == 0) + else CaseState.FAILED + ), + rc=s.rc if s else None, + case_dir=str(spec.working_dir), + message="restored from sentinel", + ) + ) + skipped += 1 + continue + + # About to hand the case to the backend - record SUBMITTED + # before the handoff so restart has a record of "I was trying + # to run this when I died" even if the process is killed + # mid-submission. + manifest.record( + ManifestEntry( + generation=ctx.generation, + gene=ctx.gene, + obj=ctx.obj, + state=CaseState.SUBMITTED, + case_dir=str(spec.working_dir), + ) + ) + to_run.append((ctx, spec)) + + logger.info( + "gen %d: %d cases to run, %d restored from sentinel", + generation, len(to_run), skipped, + ) + + if not to_run: + return + + # We need to map results back to their contexts when they stream + # in from the backend. Using id(spec) is safe here because the + # spec objects persist for the entire loop and are never copied. + spec_to_ctx = {id(spec): ctx for ctx, spec in to_run} + specs = [spec for _, spec in to_run] + + for result in backend.stream_batch(specs): + ctx = spec_to_ctx[id(result.spec)] + + # Validate outputs regardless of rc. A zero rc with missing + # outputs is still a workflow-level failure - for example, a + # process that exited cleanly before writing its results. + ok, missing = validate_outputs(result.spec.working_dir, required_outputs) + if result.outcome == JobOutcome.OK and not ok: + logger.warning( + "gen=%d gene=%d obj=%d rc=0 but outputs missing: %s", + ctx.generation, ctx.gene, ctx.obj, missing, + ) + + terminal = ( + CaseState.COMPLETED + if (result.outcome == JobOutcome.OK and ok) + else CaseState.FAILED + ) + + # Sentinel first. If we crash between this write and the + # manifest record below, restart will see the sentinel and + # correctly treat the case as finished. + write_sentinel( + result.spec.working_dir, + Sentinel( + rc=result.rc, + wall_time_s=result.wall_time_s, + jobid=result.jobid, + output_files={p: str(p) for p in required_outputs}, + status="ok" if terminal == CaseState.COMPLETED else "bad", + message=result.error_message, + ), + ) + manifest.record( + ManifestEntry( + generation=ctx.generation, + gene=ctx.gene, + obj=ctx.obj, + state=terminal, + rc=result.rc, + jobid=result.jobid, + case_dir=str(result.spec.working_dir), + message=result.error_message, + ) + ) + logger.info( + "gen=%d gene=%d obj=%d -> %s rc=%d wall=%.2fs", + ctx.generation, ctx.gene, ctx.obj, + terminal.value, result.rc, result.wall_time_s, + ) + + +def main() -> int: + """Script entry point. + + Parses CLI arguments, prepares the workspace, runs one + generation, and exits with status 0 if every case completed + successfully or 1 if any case failed. Returns, rather than + directly calling ``sys.exit``, so tests can import and invoke + ``main()`` programmatically if desired. + + Returns: + 0 on success, 1 if any case failed. + """ + p = argparse.ArgumentParser(description="workflow_common demo") + p.add_argument("--root", type=Path, default=Path("/tmp/wfc_demo")) + p.add_argument("--clean", action="store_true", + help="wipe the workspace before starting") + p.add_argument("--workers", type=int, default=2, + help="number of concurrent simulations") + p.add_argument( + "--phase", + choices=("full", "partial", "restart"), + default="full", + help="'full' runs all cases normally; 'partial' poisons cases " + "to fail; 'restart' reruns and uses existing sentinels.", + ) + args = p.parse_args() + + if args.clean and args.root.exists(): + shutil.rmtree(args.root) + args.root.mkdir(parents=True, exist_ok=True) + + # stream=True (default) is helpful for demo output so you can see + # the progression live. On a real HPC run you would typically + # disable stream and write logs to a file. + configure_logging(level="info") + + # Layout setup: fake binary, master template, path resolver. + binary = prepare_fake_binary(args.root) + master_template_path = args.root / "master.toml" + master_template_path.write_text(MASTER_TEMPLATE) + + resolver = TemplatePathResolver( + working_dir_pattern="wf_files/gen_{generation}/gene_{gene}_obj_{obj}", + output_file_patterns={ + "avg_stress": "{working_dir}/results/options/avg_stress.txt", + }, + root=args.root, + ) + + # Load the manifest. On a fresh run this is a no-op; on restart + # it replays history and marks anything stuck in SUBMITTED as + # INTERRUPTED. The count is logged so the operator can see what + # the restart detected. + manifest = Manifest(args.root / "wf_files" / "manifest.jsonl") + manifest.load() + n_interrupted = manifest.mark_submitted_as_interrupted() + if n_interrupted: + logger.info("marked %d submitted-but-unknown cases as INTERRUPTED", + n_interrupted) + + backend = LocalBackend(max_workers=args.workers) + + # Tiny toy optimization: 3 genes, each with 2 objectives at + # different strain rates. Parameters are passed through the + # CaseContext.extra mapping and into the template. + genes = [ + [dict(strain_rate=1e-3, temp_k=298.0), dict(strain_rate=1e-1, temp_k=298.0)], + [dict(strain_rate=5e-4, temp_k=298.0), dict(strain_rate=5e-2, temp_k=298.0)], + [dict(strain_rate=2e-3, temp_k=298.0), dict(strain_rate=2e-1, temp_k=298.0)], + ] + + # 'partial' mode poisons the subprocess environment so every case + # fails. Use this to exercise the FAILED branch of the driver. + # The env var goes on os.environ so the child processes inherit it. + if args.phase == "partial": + os.environ["FAKE_FAIL"] = "1" + else: + os.environ.pop("FAKE_FAIL", None) + + required_outputs = ["results/options/avg_stress.txt"] + + run_generation( + generation=0, + genes=genes, + resolver=resolver, + binary=binary, + master_template_path=master_template_path, + manifest=manifest, + backend=backend, + required_outputs=required_outputs, + ) + + # Snapshot at end of generation so a later restart does not have + # to replay the entire JSONL log from the beginning. + manifest.snapshot() + + # Final tally, printed for human consumption. + n_ok = sum(1 for e in manifest.all_entries() if e.state == CaseState.COMPLETED) + n_bad = sum(1 for e in manifest.all_entries() if e.state == CaseState.FAILED) + logger.info("done. completed=%d failed=%d", n_ok, n_bad) + return 0 if n_bad == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/workflows/exaconstit-calibrate/tests/test_archive.py b/workflows/exaconstit-calibrate/tests/test_archive.py new file mode 100644 index 0000000..b08f0ba --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_archive.py @@ -0,0 +1,1215 @@ +""" +Unit tests for :mod:`workflow_common.archive`. + +What these prove +---------------- +1. **Round-trip correctness** — runs, generations, genes, and case + outputs written to the archive come back bit-identical via the + query methods. Pickled DataFrames round-trip exactly (dtype, + index, values). +2. **Key constraints** — duplicate run_ids raise, out-of-bound + gene records on record_generation raise at construction, + foreign-key cascades delete dependent rows. +3. **Resume discard** — ``discard_from_generation`` removes + exactly the rows with ``gen_idx >= K``, cascading into genes + and case_outputs. +4. **Read-only mode** — opening ``readonly=True`` rejects writes + and allows queries. +5. **WAL concurrent access** — a reader opened while a writer is + active sees committed generations without blocking the writer. +6. **Schema version check** — a DB stamped with a future schema + version raises a clear error at open. +""" +from __future__ import annotations + +import json +import sqlite3 +import threading +import time +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from workflow_common import ArchiveDB, GeneRecord +from workflow_common.archive import SCHEMA_VERSION +from workflow_common.paths import CaseContext +from workflow_common.results import CaseResultSet, TabularResult + + +# --- Helpers ------------------------------------------------------------ + + +def _make_case_result_set( + ctx: CaseContext, + *, + avg_stress: pd.DataFrame, + avg_def_grad: pd.DataFrame = None, +) -> CaseResultSet: + """Synthesize a CaseResultSet for archive tests.""" + tables = { + "avg_stress": TabularResult( + name="avg_stress", df=avg_stress, + source_path=Path("fake/avg_stress.txt"), + ), + } + if avg_def_grad is not None: + tables["avg_def_grad"] = TabularResult( + name="avg_def_grad", df=avg_def_grad, + source_path=Path("fake/avg_def_grad.txt"), + ) + return CaseResultSet(ctx=ctx, tables=tables) + + +def _voce_stress_df(y0=200.0, H=1900.0, k=48.0, n=20) -> pd.DataFrame: + t = np.linspace(0.0, 1.0, n) + strain = t + stress = y0 + H * (1 - np.exp(-k * strain)) + return pd.DataFrame({ + "Time": t, "Szz": stress, + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + + +def _example_gene_record(run_id, *, gen_idx, pop_idx, birth_gen, birth_gene, + fitness=(0.5,), rank=0): + return GeneRecord( + run_id=run_id, + gen_idx=gen_idx, + pop_idx=pop_idx, + birth_gen=birth_gen, + birth_gene=birth_gene, + gene_vector=np.array([1.0, 2.0, 3.0]), + fitness=fitness, + rank=rank, + ) + + +# --- Run-level round-trip --------------------------------------------- + + +def test_start_end_run_round_trip(tmp_path): + """A run written + ended comes back with matching fields.""" + db_path = tmp_path / "a.db" + with ArchiveDB(db_path) as a: + run_id = a.start_run( + seed=42, + param_names=["yield_stress", "hardening"], + objective_labels=["stress_rmse", "slope_rmse"], + config={"n_gens": 10}, + ) + assert run_id # non-empty + a.end_run(run_id) + + # Re-open and query. + with ArchiveDB(db_path, readonly=True) as a: + run = a.get_run(run_id) + assert run.seed == 42 + assert run.param_names == ["yield_stress", "hardening"] + assert run.objective_labels == ["stress_rmse", "slope_rmse"] + assert run.completed_at is not None + + +def test_start_run_generates_uuid_when_none(): + """No run_id -> UUID4 generated.""" + import tempfile + with tempfile.TemporaryDirectory() as d: + with ArchiveDB(Path(d) / "a.db") as a: + rid = a.start_run(seed=0, param_names=["x"]) + # Basic UUID shape: 5 hex groups separated by dashes. + assert rid.count("-") == 4 + + +def test_duplicate_run_id_raises(tmp_path): + with ArchiveDB(tmp_path / "a.db") as a: + a.start_run(run_id="explicit", seed=0, param_names=["x"]) + with pytest.raises(sqlite3.IntegrityError): + a.start_run(run_id="explicit", seed=1, param_names=["x"]) + + +def test_list_runs_order(tmp_path): + """list_runs returns runs oldest-first.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid1 = a.start_run(run_id="r1", seed=1, param_names=["x"]) + time.sleep(0.01) + rid2 = a.start_run(run_id="r2", seed=2, param_names=["x"]) + runs = a.list_runs() + assert [r.run_id for r in runs] == [rid1, rid2] + + +# --- Case-level round-trip -------------------------------------------- + + +def test_record_and_load_case_outputs(tmp_path): + """Pickled DataFrames round-trip bit-identical through the archive.""" + db = tmp_path / "a.db" + ctx = CaseContext(generation=0, gene=0, obj=0) + stress_df = _voce_stress_df() + dg_df = pd.DataFrame({"Time": [0.0, 1.0], "F33": [1.0, 1.5]}) + rs = _make_case_result_set(ctx, avg_stress=stress_df, avg_def_grad=dg_df) + + with ArchiveDB(db) as a: + rid = a.start_run(seed=0, param_names=["x", "y"]) + a.record_case_outputs( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, results=rs, + ) + + with ArchiveDB(db, readonly=True) as a: + loaded = a.load_case_outputs( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) + assert loaded is not None + pd.testing.assert_frame_equal( + loaded.df("avg_stress"), stress_df, + ) + pd.testing.assert_frame_equal( + loaded.df("avg_def_grad"), dg_df, + ) + + +def test_load_case_outputs_missing_returns_none(tmp_path): + """Unknown coordinates -> None (matches disk-based API).""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=0, param_names=["x"]) + got = a.load_case_outputs( + rid, birth_gen=99, birth_gene=99, sim_case_idx=0, + ) + assert got is None + + +def test_record_case_outputs_replaces_on_key_collision(tmp_path): + """INSERT OR REPLACE: re-running a case overwrites the old blob.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=0, param_names=["x"]) + ctx = CaseContext(0, 0, 0) + df1 = pd.DataFrame({"Time": [0.0], "Szz": [1.0]}) + df2 = pd.DataFrame({"Time": [0.0], "Szz": [99.0]}) + # Write once, then overwrite. + a.record_case_outputs( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + results=_make_case_result_set(ctx, avg_stress=df1), + ) + a.record_case_outputs( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + results=_make_case_result_set(ctx, avg_stress=df2), + ) + loaded = a.load_case_outputs( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) + assert loaded.df("avg_stress")["Szz"].tolist() == [99.0] + + +# --- Generation-level round-trip -------------------------------------- + + +def test_record_and_load_generation(tmp_path): + """Genes + stats round-trip via record_generation / load_genes.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=0, param_names=["x", "y", "z"]) + genes = [ + _example_gene_record( + rid, gen_idx=5, pop_idx=i, birth_gen=3, birth_gene=i, + fitness=(0.1 * i, 0.2 * i), + ) + for i in range(4) + ] + a.record_generation( + rid, gen_idx=5, genes=genes, stats={"min": [0.0, 0.0]}, + ) + + with ArchiveDB(tmp_path / "a.db", readonly=True) as a: + loaded = a.load_genes(rid, gen_idx=5) + assert len(loaded) == 4 + # Order preserved by pop_idx. + for i, g in enumerate(loaded): + assert g.pop_idx == i + assert g.birth_gen == 3 + assert g.birth_gene == i + np.testing.assert_array_equal( + g.gene_vector, [1.0, 2.0, 3.0], + ) + assert g.fitness == (0.1 * i, 0.2 * i) + + gens = a.list_generations(rid) + assert len(gens) == 1 + assert gens[0].gen_idx == 5 + assert gens[0].n_pop == 4 + assert gens[0].stats == {"min": [0.0, 0.0]} + + +def test_record_generation_rejects_mismatched_gen_idx(tmp_path): + """A gene record with gen_idx != outer must raise.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=0, param_names=["x"]) + bad = _example_gene_record( + rid, gen_idx=5, pop_idx=0, birth_gen=0, birth_gene=0, + ) + with pytest.raises(ValueError, match="gen_idx"): + a.record_generation(rid, gen_idx=6, genes=[bad]) + + +def test_record_generation_rejects_mismatched_run_id(tmp_path): + with ArchiveDB(tmp_path / "a.db") as a: + rid1 = a.start_run(run_id="r1", seed=0, param_names=["x"]) + rid2 = a.start_run(run_id="r2", seed=0, param_names=["x"]) + bad = _example_gene_record( + rid1, gen_idx=0, pop_idx=0, birth_gen=0, birth_gene=0, + ) + with pytest.raises(ValueError, match="run_id"): + a.record_generation(rid2, gen_idx=0, genes=[bad]) + + +def test_record_generation_replaces_partial_prior_write(tmp_path): + """A re-record of the same gen_idx replaces any partial rows. + + The specific scenario: a crash mid-generation left partial gene + rows in the archive. On resume the driver re-runs the + generation, calling record_generation with the full pop. The + stale partial rows must be gone afterwards. + """ + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=0, param_names=["x"]) + # Simulate a partial write: just 2 of 4 gene rows. + partial = [ + _example_gene_record( + rid, gen_idx=1, pop_idx=i, birth_gen=1, birth_gene=i, + ) + for i in range(2) + ] + a.record_generation(rid, gen_idx=1, genes=partial) + + # Full write on retry. + full = [ + _example_gene_record( + rid, gen_idx=1, pop_idx=i, birth_gen=1, birth_gene=i, + ) + for i in range(4) + ] + a.record_generation(rid, gen_idx=1, genes=full) + + loaded = a.load_genes(rid, gen_idx=1) + # Exactly 4; no leftover duplicates or missing rows. + assert [g.pop_idx for g in loaded] == [0, 1, 2, 3] + + +# --- Resume semantics ------------------------------------------------- + + +def test_discard_from_generation_cascades(tmp_path): + """discard_from_generation(K) removes gens K.. and cascades to genes+case_outputs.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=0, param_names=["x"]) + # Populate 3 generations of stuff. + for g in range(3): + genes = [ + _example_gene_record( + rid, gen_idx=g, pop_idx=0, birth_gen=g, birth_gene=0, + ), + ] + a.record_generation(rid, gen_idx=g, genes=genes) + # one case_output per gen too, keyed by birth_gen=g + ctx = CaseContext(generation=g, gene=0, obj=0) + a.record_case_outputs( + rid, birth_gen=g, birth_gene=0, sim_case_idx=0, + results=_make_case_result_set( + ctx, avg_stress=_voce_stress_df(y0=100 + g), + ), + ) + + # Discard gen 1.. + a.discard_from_generation(rid, gen_idx=1) + + # Gen 0 survives, gens 1..2 gone. load_genes returns []. + assert len(a.load_genes(rid, 0)) == 1 + assert len(a.load_genes(rid, 1)) == 0 + assert len(a.load_genes(rid, 2)) == 0 + # Cascaded case outputs: gen 0 still has its output, 1..2 don't. + assert a.load_case_outputs( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) is not None + assert a.load_case_outputs( + rid, birth_gen=1, birth_gene=0, sim_case_idx=0, + ) is None + assert a.load_case_outputs( + rid, birth_gen=2, birth_gene=0, sim_case_idx=0, + ) is None + + # generations table also cleaned. + assert [g.gen_idx for g in a.list_generations(rid)] == [0] + + +def test_delete_run_removes_run_and_all_dependents(tmp_path): + """delete_run wipes the target run entirely — row + generations + + genes + case_outputs — via schema CASCADE, leaving OTHER runs in + the same archive untouched. + + Exercises the "clean up the residue of a bad invocation" use + case: two runs in one archive, delete one, the other survives + in full. + """ + with ArchiveDB(tmp_path / "a.db") as a: + keep_id = a.start_run(seed=1, param_names=["x"]) + drop_id = a.start_run(seed=2, param_names=["x"]) + # Two generations in each run, plus one case_output per gene, + # so we can verify the cascade reaches every dependent table. + for rid in (keep_id, drop_id): + for g in range(2): + a.record_generation( + rid, gen_idx=g, + genes=[ + _example_gene_record( + rid, gen_idx=g, pop_idx=0, + birth_gen=g, birth_gene=0, + ), + ], + ) + ctx = CaseContext(generation=g, gene=0, obj=0) + a.record_case_outputs( + rid, birth_gen=g, birth_gene=0, sim_case_idx=0, + results=_make_case_result_set( + ctx, avg_stress=_voce_stress_df(y0=100 + g), + ), + ) + + # Delete one run. + n = a.delete_run(drop_id) + assert n == 1 # confirmation signal + + # drop_id must be gone from every table. + assert drop_id not in [r.run_id for r in a.list_runs()] + assert a.list_generations(drop_id) == [] + assert a.load_genes(drop_id, 0) == [] + assert a.load_case_outputs( + drop_id, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) is None + + # keep_id must be completely unaffected. + assert keep_id in [r.run_id for r in a.list_runs()] + kept_gens = [g.gen_idx for g in a.list_generations(keep_id)] + assert kept_gens == [0, 1] + assert len(a.load_genes(keep_id, 0)) == 1 + assert len(a.load_genes(keep_id, 1)) == 1 + assert a.load_case_outputs( + keep_id, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) is not None + + +def test_delete_run_raises_on_unknown_id(tmp_path): + """Typos and already-deleted IDs get a KeyError, not a silent no-op. + + Silent-no-op is a classic trap: caller thinks they cleaned up + and actually just misspelled the UUID. + """ + with ArchiveDB(tmp_path / "a.db") as a: + a.start_run(run_id="real-one", seed=0, param_names=["x"]) + with pytest.raises(KeyError, match="no run with run_id"): + a.delete_run("not-a-real-run-id") + # The real run is still there — we didn't partial-delete. + assert [r.run_id for r in a.list_runs()] == ["real-one"] + + +def test_delete_run_rejects_on_readonly(tmp_path): + """Read-only connections must refuse delete_run like every other mutation.""" + db = tmp_path / "a.db" + with ArchiveDB(db) as a: + a.start_run(run_id="only", seed=0, param_names=["x"]) + # Reopen read-only. + with ArchiveDB(db, readonly=True) as a: + with pytest.raises(RuntimeError, match="read-only"): + a.delete_run("only") + + +# --- prune_empty_runs -------------------------------------------------- + + +def test_prune_empty_runs_removes_completed_empty_runs(tmp_path): + """Runs that called end_run but never wrote a generation are always eligible.""" + with ArchiveDB(tmp_path / "a.db") as a: + # Three runs: the middle one is the only "real" one with + # a generation; the other two are empty-and-completed. + empty1 = a.start_run(seed=1, param_names=["x"]) + a.end_run(empty1) + + full = a.start_run(seed=2, param_names=["x"]) + a.record_generation( + full, gen_idx=0, + genes=[_example_gene_record( + full, gen_idx=0, pop_idx=0, birth_gen=0, birth_gene=0, + )], + ) + a.end_run(full) + + empty2 = a.start_run(seed=3, param_names=["x"]) + a.end_run(empty2) + + deleted = a.prune_empty_runs() + # Both empty runs, oldest first (started_at ASC). + assert deleted == [empty1, empty2] + + # Only the one real run survives. + survivors = [r.run_id for r in a.list_runs()] + assert survivors == [full] + + +def test_prune_empty_runs_preserves_young_empty_runs_by_default(tmp_path): + """A run that was just started and is empty must NOT be pruned — + it might be a live run still initializing. Default age gate of + 60 minutes protects it. + """ + with ArchiveDB(tmp_path / "a.db") as a: + # Young empty run — not completed, just started. + young = a.start_run(seed=1, param_names=["x"]) + # Another young run that's completed — eligible regardless of age. + completed = a.start_run(seed=2, param_names=["x"]) + a.end_run(completed) + + deleted = a.prune_empty_runs() + # Only the completed one got pruned; the live-looking young + # one stays. + assert deleted == [completed] + survivors = [r.run_id for r in a.list_runs()] + assert survivors == [young] + + +def test_prune_empty_runs_with_age_zero_prunes_young_runs_too(tmp_path): + """--age-minutes 0 disables the young-run protection. + + For the "I know my live run isn't running" case, the user can + override the safety default. + """ + with ArchiveDB(tmp_path / "a.db") as a: + young = a.start_run(seed=1, param_names=["x"]) + # Don't call end_run — simulates a crashed run that'll never + # flip completed_at. + + deleted = a.prune_empty_runs(min_age_minutes=0.0) + assert deleted == [young] + assert a.list_runs() == [] + + +def test_prune_empty_runs_dry_run_returns_list_without_deleting(tmp_path): + """dry_run=True shows what WOULD be deleted. Nothing is actually removed.""" + with ArchiveDB(tmp_path / "a.db") as a: + empty = a.start_run(seed=1, param_names=["x"]) + a.end_run(empty) + + preview = a.prune_empty_runs(dry_run=True) + assert preview == [empty] + # Run still there. + assert [r.run_id for r in a.list_runs()] == [empty] + + # Second dry-run gives same answer (no side effects). + assert a.prune_empty_runs(dry_run=True) == [empty] + + # Real run confirms the prediction. + assert a.prune_empty_runs() == [empty] + assert a.list_runs() == [] + + +def test_prune_empty_runs_preserves_run_with_case_outputs_only(tmp_path): + """A run with case_outputs but no generations represents a partial + crash mid-gen-0. Those case outputs are real simulation artifacts + — don't delete them even though ``generations`` is empty. + """ + from workflow_common.paths import CaseContext + + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["x"]) + # Case output written, but record_generation never called. + ctx = CaseContext(generation=0, gene=0, obj=0) + a.record_case_outputs( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + results=_make_case_result_set( + ctx, avg_stress=_voce_stress_df(y0=100), + ), + ) + a.end_run(rid) # mark completed to bypass age gate + + deleted = a.prune_empty_runs() + # Not pruned — the case_outputs row protects it. + assert deleted == [] + assert [r.run_id for r in a.list_runs()] == [rid] + + +def test_prune_empty_runs_noop_on_empty_archive(tmp_path): + """An archive with no runs returns an empty list, no errors.""" + with ArchiveDB(tmp_path / "a.db") as a: + assert a.prune_empty_runs() == [] + assert a.prune_empty_runs(dry_run=True) == [] + + +def test_prune_empty_runs_rejects_on_readonly(tmp_path): + """Read-only connections must refuse the prune operation.""" + db = tmp_path / "a.db" + with ArchiveDB(db) as a: + rid = a.start_run(run_id="empty", seed=0, param_names=["x"]) + a.end_run(rid) + with ArchiveDB(db, readonly=True) as a: + with pytest.raises(RuntimeError, match="read-only"): + a.prune_empty_runs() + + +# --- Read-only mode --------------------------------------------------- + + +def test_readonly_rejects_writes(tmp_path): + db = tmp_path / "a.db" + with ArchiveDB(db) as a: + a.start_run(run_id="ro", seed=0, param_names=["x"]) + # Re-open read-only. + with ArchiveDB(db, readonly=True) as a: + # Queries work. + assert a.get_run("ro").run_id == "ro" + # Writes do not. + with pytest.raises(RuntimeError, match="read-only"): + a.start_run(run_id="ro2", seed=0, param_names=["x"]) + + +# --- WAL concurrent reader -------------------------------------------- + + +def test_writer_and_reader_coexist(tmp_path): + """WAL mode: a reader opened while a writer is active sees committed data.""" + db = tmp_path / "a.db" + with ArchiveDB(db) as writer: + rid = writer.start_run(run_id="r", seed=0, param_names=["x"]) + + # Open a read-only connection while writer is alive. Both + # connections exist simultaneously. + reader = ArchiveDB(db, readonly=True) + reader.open() + try: + # Reader sees the existing run. + assert reader.get_run(rid).run_id == rid + + # Writer commits more data; reader sees it on next query. + writer.record_generation( + rid, gen_idx=0, + genes=[_example_gene_record( + rid, gen_idx=0, pop_idx=0, birth_gen=0, birth_gene=0, + )], + ) + # New connection-level query sees the just-committed row. + # (SQLite snapshot-at-txn-start semantics mean the same + # SELECT statement on a live cursor might not; a fresh + # execute does.) + assert len(reader.load_genes(rid, 0)) == 1 + finally: + reader.close() + + +# --- Schema version ---------------------------------------------------- + + +def test_schema_version_stamped(tmp_path): + """On first write, the schema_meta table records the current version.""" + db = tmp_path / "a.db" + with ArchiveDB(db) as a: + pass # just create + # Direct SQL read to verify the stamp. + conn = sqlite3.connect(db) + v = conn.execute( + "SELECT value FROM schema_meta WHERE key='schema_version'" + ).fetchone() + conn.close() + assert int(v[0]) == SCHEMA_VERSION + + +def test_future_schema_version_raises(tmp_path): + """Opening a DB stamped with a newer schema raises at open().""" + db = tmp_path / "a.db" + # Create a normal DB first. + with ArchiveDB(db) as a: + pass + # Manually bump the stamp to a future version. + conn = sqlite3.connect(db) + conn.execute( + "UPDATE schema_meta SET value=? WHERE key='schema_version'", + (str(SCHEMA_VERSION + 5),), + ) + conn.commit() + conn.close() + # Now opening should refuse. + with pytest.raises(RuntimeError, match="schema version"): + with ArchiveDB(db) as a: + pass + + +# --- Context-manager discipline --------------------------------------- + + +def test_query_without_open_raises(tmp_path): + a = ArchiveDB(tmp_path / "a.db") # not opened + with pytest.raises(RuntimeError, match="not open"): + a.list_runs() + + +def test_close_is_idempotent(tmp_path): + a = ArchiveDB(tmp_path / "a.db") + a.open() + a.close() + a.close() # second close: no-op + + +# --- Experimental data --------------------------------------------------- + + +def test_record_experiment_round_trips_dataframe(tmp_path): + """Experimental data round-trips faithfully (column names, dtypes, values).""" + db = tmp_path / "a.db" + df = pd.DataFrame({ + "strain": np.linspace(0.0, 0.1, 11), + "stress": np.linspace(200.0, 350.0, 11), + "extra_meta": ["a"] * 11, + }) + with ArchiveDB(db) as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_experiment(rid, sim_case_idx=0, df=df, label="exp1") + with ArchiveDB(db, readonly=True) as a: + result = a.load_experiment(rid, 0) + assert result is not None + label, got = result + assert label == "exp1" + assert list(got.columns) == ["strain", "stress", "extra_meta"] + pd.testing.assert_frame_equal(got, df) + + +def test_record_experiment_replaces_on_collision(tmp_path): + """Re-recording the same (run_id, sim_case_idx) overwrites — supports resume.""" + df1 = pd.DataFrame({"strain": [0.0, 0.1], "stress": [100.0, 200.0]}) + df2 = pd.DataFrame({"strain": [0.0, 0.2], "stress": [100.0, 250.0]}) + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_experiment(rid, sim_case_idx=0, df=df1, label="v1") + a.record_experiment(rid, sim_case_idx=0, df=df2, label="v2") + result = a.load_experiment(rid, 0) + assert result is not None + label, got = result + assert label == "v2" + pd.testing.assert_frame_equal(got, df2) + + +def test_load_experiment_returns_none_when_missing(tmp_path): + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + # No record_experiment call. + assert a.load_experiment(rid, 0) is None + assert a.load_experiment(rid, 99) is None + + +def test_list_experiments_returns_sim_case_index_label_pairs(tmp_path): + """list_experiments enumerates without loading the (potentially big) DataFrames.""" + df = pd.DataFrame({"strain": [0.0, 0.1], "stress": [100.0, 200.0]}) + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_experiment(rid, sim_case_idx=0, df=df, label="exp_quasi") + a.record_experiment(rid, sim_case_idx=2, df=df, label="exp_dyn") + # No data for sim_case_idx=1 — ordering should still be ascending, + # gaps preserved. + listing = a.list_experiments(rid) + assert listing == [(0, "exp_quasi"), (2, "exp_dyn")] + + +def test_record_experiment_cascades_on_run_delete(tmp_path): + """Deleting a run must remove its experiment rows too (FK cascade).""" + df = pd.DataFrame({"strain": [0.0], "stress": [100.0]}) + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_experiment(rid, sim_case_idx=0, df=df, label="exp") + a.delete_run(rid) + # Experiment row should be gone. + cur = a._conn.execute( + "SELECT COUNT(*) FROM experiments WHERE run_id=?", (rid,), + ) + assert cur.fetchone()[0] == 0 + + +def test_record_experiment_rejects_on_readonly(tmp_path): + db = tmp_path / "a.db" + df = pd.DataFrame({"strain": [0.0], "stress": [100.0]}) + with ArchiveDB(db) as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + with ArchiveDB(db, readonly=True) as a: + with pytest.raises(RuntimeError, match="read-only"): + a.record_experiment(rid, sim_case_idx=0, df=df, label="exp") + + +# --- Backward compat for archives lacking the experiments table --------- + + +def _build_pre_experiments_archive(db_path): + """Build an archive with the pre-experiments-table schema. + + Used to exercise the load_experiment / list_experiments + backward-compat path: those methods must return None / [] when + the table is missing rather than letting sqlite3.OperationalError + bubble up. + """ + import sqlite3 + con = sqlite3.connect(db_path) + con.executescript(""" + CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO schema_meta VALUES('schema_version', '1'); + CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, + completed_at TEXT, seed INTEGER, param_names TEXT NOT NULL, + objective_labels TEXT, config_json TEXT + ); + CREATE TABLE generations ( + run_id TEXT NOT NULL, gen_idx INTEGER NOT NULL, + recorded_at TEXT NOT NULL, n_pop INTEGER NOT NULL, + stats_json TEXT, PRIMARY KEY (run_id, gen_idx) + ); + CREATE TABLE genes ( + run_id TEXT NOT NULL, gen_idx INTEGER NOT NULL, + pop_idx INTEGER NOT NULL, birth_gen INTEGER NOT NULL, + birth_gene INTEGER NOT NULL, gene_vector TEXT NOT NULL, + fitness TEXT NOT NULL, rank INTEGER, + PRIMARY KEY (run_id, gen_idx, pop_idx) + ); + CREATE TABLE case_outputs ( + run_id TEXT NOT NULL, birth_gen INTEGER NOT NULL, + birth_gene INTEGER NOT NULL, sim_case_idx INTEGER NOT NULL, + output_name TEXT NOT NULL, data_blob BLOB NOT NULL, + PRIMARY KEY (run_id, birth_gen, birth_gene, sim_case_idx, output_name) + ); + INSERT INTO runs VALUES ('r1', '2025-01-01T00:00:00', NULL, 1, + '["p"]', '["o"]', NULL); + """) + con.commit() + con.close() + + +def test_load_experiment_returns_none_when_table_missing(tmp_path): + """An archive written before the experiments table existed must + still be readable. load_experiment returns None instead of + raising sqlite3.OperationalError. + + This is the bug Robert reported: a real-world archive on his + machine pre-dated this table, and the plotter crashed with + "no such table: experiments" before falling back to the user's + --experimental CSVs. + """ + db = tmp_path / "old.db" + _build_pre_experiments_archive(db) + with ArchiveDB(db, readonly=True) as a: + # Sanity: the table genuinely isn't there. + cur = a._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='experiments'" + ) + assert cur.fetchone() is None + # The contract: returns None, doesn't raise. + assert a.load_experiment("r1", 0) is None + assert a.load_experiment("r1", 99) is None + + +def test_list_experiments_returns_empty_when_table_missing(tmp_path): + """Same backward-compat as load_experiment, but for the + enumerate path used by tools that ask "what experiments exist?" + before deciding whether to fetch any. + """ + db = tmp_path / "old.db" + _build_pre_experiments_archive(db) + with ArchiveDB(db, readonly=True) as a: + assert a.list_experiments("r1") == [] + + +def test_writable_open_creates_missing_experiments_table(tmp_path): + """Opening a pre-experiments archive in WRITE mode must create + the table on the fly (via ``IF NOT EXISTS`` in the schema script) + so subsequent writes work without manual migration. Read-only + opens skip this and stay backward-compat via the load path's + None return. + """ + db = tmp_path / "old.db" + _build_pre_experiments_archive(db) + df = pd.DataFrame({"strain": [0.0, 0.5], "stress": [100.0, 200.0]}) + # Writable open — schema script runs, experiments table created. + with ArchiveDB(db) as a: + a.record_experiment("r1", sim_case_idx=0, df=df, label="x") + result = a.load_experiment("r1", 0) + assert result is not None + label, got = result + assert label == "x" + pd.testing.assert_frame_equal(got, df) + + +# --- minmax_strain on experiments --------------------------------------- + + +def test_record_experiment_stores_minmax_strain(tmp_path): + """record_experiment must persist the (lo, hi) window so plotters + can shade the optimized region against the full curve.""" + df = pd.DataFrame({"strain": [0.0, 0.1], "stress": [100.0, 200.0]}) + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_experiment(rid, sim_case_idx=0, df=df, label="exp", + minmax_strain=(0.005, 0.13)) + win = a.load_experiment_window(rid, 0) + assert win == (0.005, 0.13) + + +def test_record_experiment_window_supports_unbounded_sides(tmp_path): + """(None, hi) and (lo, None) round-trip — either side is optional.""" + df = pd.DataFrame({"strain": [0.0, 0.1], "stress": [100.0, 200.0]}) + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_experiment(rid, sim_case_idx=0, df=df, + minmax_strain=(None, 0.13)) + a.record_experiment(rid, sim_case_idx=1, df=df, + minmax_strain=(0.005, None)) + a.record_experiment(rid, sim_case_idx=2, df=df, + minmax_strain=None) + assert a.load_experiment_window(rid, 0) == (None, 0.13) + assert a.load_experiment_window(rid, 1) == (0.005, None) + # No window stored at all → None (NOT (None, None) — that + # sentinel would mean "stored but unbounded both sides," + # which the user didn't say). + assert a.load_experiment_window(rid, 2) is None + + +def test_load_experiment_window_returns_none_for_missing_record(tmp_path): + """Querying a (run_id, sim_case_idx) that was never recorded returns + None, not an exception. Plotter relies on this for graceful skip.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + assert a.load_experiment_window(rid, 99) is None + + +def test_load_experiment_window_returns_none_on_archive_without_column(tmp_path): + """An archive written before the minmax_strain column existed + should stay readable read-only — the load returns None and the + plotter falls through to "no shading" cleanly. + """ + import sqlite3, pickle + db = tmp_path / "old.db" + con = sqlite3.connect(db) + con.executescript(""" + CREATE TABLE schema_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO schema_meta VALUES('schema_version', '1'); + CREATE TABLE runs(run_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, + completed_at TEXT, seed INTEGER, param_names TEXT NOT NULL, + objective_labels TEXT, config_json TEXT); + CREATE TABLE experiments( + run_id TEXT NOT NULL, sim_case_idx INTEGER NOT NULL, + label TEXT, data_blob BLOB NOT NULL, + PRIMARY KEY (run_id, sim_case_idx)); + INSERT INTO runs VALUES('r1', '2025', NULL, 1, '["p"]', '["o"]', NULL); + """) + df = pd.DataFrame({"strain": [0.0, 0.1], "stress": [100.0, 200.0]}) + con.execute("INSERT INTO experiments VALUES('r1', 0, 'old', ?)", + (pickle.dumps(df),)) + con.commit(); con.close() + + with ArchiveDB(db, readonly=True) as a: + # The DataFrame still loads — only the new column is missing. + assert a.load_experiment("r1", 0)[0] == "old" + # And the window query degrades to None. + assert a.load_experiment_window("r1", 0) is None + + +def test_writable_open_migrates_minmax_strain_column(tmp_path): + """Opening an old archive in writable mode auto-adds the + minmax_strain column so subsequent record_experiment calls + can persist windows. No manual migration step required.""" + import sqlite3, pickle + db = tmp_path / "old.db" + con = sqlite3.connect(db) + con.executescript(""" + CREATE TABLE schema_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO schema_meta VALUES('schema_version', '1'); + CREATE TABLE runs(run_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, + completed_at TEXT, seed INTEGER, param_names TEXT NOT NULL, + objective_labels TEXT, config_json TEXT); + CREATE TABLE experiments( + run_id TEXT NOT NULL, sim_case_idx INTEGER NOT NULL, + label TEXT, data_blob BLOB NOT NULL, + PRIMARY KEY (run_id, sim_case_idx)); + INSERT INTO runs VALUES('r1', '2025', NULL, 1, '["p"]', '["o"]', NULL); + """) + con.commit(); con.close() + + # Writable open triggers _migrate_add_missing_columns. + df = pd.DataFrame({"strain": [0.0, 0.1], "stress": [100.0, 200.0]}) + with ArchiveDB(db) as a: + # Column is present — record_experiment with minmax_strain + # works without raising. + a.record_experiment("r1", sim_case_idx=0, df=df, label="new", + minmax_strain=(0.005, 0.10)) + assert a.load_experiment_window("r1", 0) == (0.005, 0.10) + + # Verify by hand: the column genuinely exists in the file. + con = sqlite3.connect(db) + cols = [r[1] for r in con.execute( + "PRAGMA table_info(experiments)" + ).fetchall()] + con.close() + assert "minmax_strain" in cols + + +def test_migration_idempotent_on_fresh_archive(tmp_path): + """Running the migration on a brand-new archive (where the column + was already created by the schema script) must be a no-op rather + than raising "duplicate column name.""" + db = tmp_path / "new.db" + # First open creates fresh schema with the column already present. + with ArchiveDB(db): + pass + # Second open re-runs the migration step. Must not raise. + with ArchiveDB(db): + pass + import sqlite3 + con = sqlite3.connect(db) + cols = [r[1] for r in con.execute( + "PRAGMA table_info(experiments)" + ).fetchall()] + con.close() + # Exactly one minmax_strain column, no duplicates. + assert cols.count("minmax_strain") == 1 + + +# --- extractor_config on experiments ----------------------------------- + + +def test_record_experiment_persists_extractor_config(tmp_path): + """Round-trip a full extractor config through record/load.""" + df = pd.DataFrame({"strain": [0.0, 0.1], "stress": [100.0, 200.0]}) + cfg = { + "stress_output": "avg_stress", + "stress_column": "Szz", + "strain_source": "time_rate", + "strain_rate": 1e-3, + "strain_source_output": "avg_def_grad", + "strain_source_column": "F33", + "time_column": "Time", + "window": [0.005, 0.13], + } + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_experiment(rid, sim_case_idx=0, df=df, + extractor_config=cfg) + loaded = a.load_extractor_config(rid, 0) + assert loaded == cfg + + +def test_load_extractor_config_returns_none_when_no_record(tmp_path): + """Querying an unrecorded (run, sim_case) returns None, not raise.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + assert a.load_extractor_config(rid, 99) is None + + +def test_load_extractor_config_returns_none_when_record_has_null_config(tmp_path): + """A recorded experiment with extractor_config=None reads back as None.""" + df = pd.DataFrame({"strain": [0.0], "stress": [0.0]}) + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + # No extractor_config supplied. + a.record_experiment(rid, sim_case_idx=0, df=df, label="bare") + assert a.load_extractor_config(rid, 0) is None + + +def test_load_extractor_config_returns_none_when_column_absent(tmp_path): + """Old archive without the extractor_config column → None on read, + no crash. Read-only opens skip the migration so the column stays + absent.""" + import sqlite3, pickle + db = tmp_path / "old.db" + con = sqlite3.connect(db) + con.executescript(""" + CREATE TABLE schema_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO schema_meta VALUES('schema_version', '1'); + CREATE TABLE runs(run_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, + completed_at TEXT, seed INTEGER, param_names TEXT NOT NULL, + objective_labels TEXT, config_json TEXT); + CREATE TABLE experiments( + run_id TEXT NOT NULL, sim_case_idx INTEGER NOT NULL, + label TEXT, data_blob BLOB NOT NULL, minmax_strain TEXT, + PRIMARY KEY (run_id, sim_case_idx)); + INSERT INTO runs VALUES('r1', '2025', NULL, 1, '["p"]', '["o"]', NULL); + """) + df = pd.DataFrame({"strain": [0.0], "stress": [0.0]}) + con.execute("INSERT INTO experiments VALUES('r1', 0, 'old', ?, NULL)", + (pickle.dumps(df),)) + con.commit(); con.close() + + with ArchiveDB(db, readonly=True) as a: + # The earlier-introduced fields still load. + assert a.load_experiment("r1", 0)[0] == "old" + # And the new column degrades to None gracefully. + assert a.load_extractor_config("r1", 0) is None + + +def test_writable_open_migrates_extractor_config_column(tmp_path): + """Opening an old archive in writable mode auto-adds the new + column (via _migrate_add_missing_columns), and subsequent + record_experiment with extractor_config persists correctly. + """ + import sqlite3, pickle + db = tmp_path / "old.db" + con = sqlite3.connect(db) + con.executescript(""" + CREATE TABLE schema_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO schema_meta VALUES('schema_version', '1'); + CREATE TABLE runs(run_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, + completed_at TEXT, seed INTEGER, param_names TEXT NOT NULL, + objective_labels TEXT, config_json TEXT); + CREATE TABLE experiments( + run_id TEXT NOT NULL, sim_case_idx INTEGER NOT NULL, + label TEXT, data_blob BLOB NOT NULL, + PRIMARY KEY (run_id, sim_case_idx)); + INSERT INTO runs VALUES('r1', '2025', NULL, 1, '["p"]', '["o"]', NULL); + """) + con.commit(); con.close() + + df = pd.DataFrame({"strain": [0.0, 0.1], "stress": [100.0, 200.0]}) + cfg = {"strain_source": "time_rate", "strain_rate": 1e-3} + with ArchiveDB(db) as a: + a.record_experiment("r1", sim_case_idx=0, df=df, + extractor_config=cfg) + loaded = a.load_extractor_config("r1", 0) + # Round-trip should match — the from_dict tolerance is for the + # plotter; the archive stores verbatim what was given. + assert loaded == cfg + + con = sqlite3.connect(db) + cols = [r[1] for r in con.execute( + "PRAGMA table_info(experiments)" + ).fetchall()] + con.close() + assert "extractor_config" in cols + + +# --- case_curves: extracted (independent, dependent) curves -------------- + + +def test_record_case_curve_round_trips_arrays(tmp_path): + """Stored independent/dependent arrays load back identically.""" + db = tmp_path / "a.db" + independent = np.linspace(0.0, 0.1, 50) + dependent = 200.0 + 1900.0 * (1 - np.exp(-48.0 * independent)) + with ArchiveDB(db) as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_case_curve( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + independent=independent, dependent=dependent, + independent_label="strain", dependent_label="stress", + ) + with ArchiveDB(db, readonly=True) as a: + result = a.load_case_curve( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) + assert result is not None + ind, dep, ind_label, dep_label = result + np.testing.assert_array_equal(ind, independent) + np.testing.assert_array_equal(dep, dependent) + assert ind_label == "strain" + assert dep_label == "stress" + + +def test_record_case_curve_validates_shapes(tmp_path): + """Mismatched lengths or non-1-D arrays raise.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + with pytest.raises(ValueError, match="same length"): + a.record_case_curve( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + independent=np.array([0.0, 0.1]), + dependent=np.array([1.0, 2.0, 3.0]), + ) + with pytest.raises(ValueError, match="1-D"): + a.record_case_curve( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + independent=np.zeros((3, 2)), + dependent=np.zeros((3, 2)), + ) + + +def test_load_case_curve_returns_none_for_missing(tmp_path): + """Unrecorded coordinates return None, not raise.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + assert a.load_case_curve( + rid, birth_gen=99, birth_gene=99, sim_case_idx=99, + ) is None + + +def test_load_case_curve_returns_none_when_table_missing(tmp_path): + """Old archive without case_curves table reads as None.""" + import sqlite3 + db = tmp_path / "old.db" + con = sqlite3.connect(db) + con.executescript(""" + CREATE TABLE schema_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO schema_meta VALUES('schema_version', '1'); + CREATE TABLE runs(run_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, + completed_at TEXT, seed INTEGER, param_names TEXT NOT NULL, + objective_labels TEXT, config_json TEXT); + INSERT INTO runs VALUES('r1', '2025', NULL, 1, '["p"]', '["o"]', NULL); + """) + con.commit(); con.close() + with ArchiveDB(db, readonly=True) as a: + assert a.load_case_curve( + "r1", birth_gen=0, birth_gene=0, sim_case_idx=0, + ) is None + + +def test_record_case_curve_replaces_on_collision(tmp_path): + """Re-recording the same coords overwrites — supports retries.""" + a1 = np.array([0.0, 0.1]) + b1 = np.array([100.0, 200.0]) + a2 = np.array([0.0, 0.05, 0.1]) + b2 = np.array([100.0, 150.0, 200.0]) + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_case_curve(rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + independent=a1, dependent=b1) + a.record_case_curve(rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + independent=a2, dependent=b2) + ind, dep, _, _ = a.load_case_curve( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) + np.testing.assert_array_equal(ind, a2) + np.testing.assert_array_equal(dep, b2) + + +def test_case_curves_cascade_on_run_delete(tmp_path): + """delete_run propagates to case_curves via the FK cascade.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + a.record_case_curve( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + independent=np.array([0.0, 0.1]), + dependent=np.array([100.0, 200.0]), + ) + a.delete_run(rid) + cur = a._conn.execute( + "SELECT COUNT(*) FROM case_curves WHERE run_id=?", (rid,), + ) + assert cur.fetchone()[0] == 0 + + +def test_discard_from_generation_drops_case_curves(tmp_path): + """discard_from_generation must delete case_curves at gen >= K.""" + with ArchiveDB(tmp_path / "a.db") as a: + rid = a.start_run(seed=1, param_names=["p"], objective_labels=["o"]) + # Need a generation row first so the FK doesn't get angry. + a.record_generation(rid, gen_idx=0, genes=[], stats={}) + a.record_generation(rid, gen_idx=1, genes=[], stats={}) + for g in (0, 1): + a.record_case_curve( + rid, birth_gen=g, birth_gene=0, sim_case_idx=0, + independent=np.array([0.0, 0.1]), + dependent=np.array([100.0, 200.0]), + ) + a.discard_from_generation(rid, gen_idx=1) + # gen 0 survives, gen 1 is gone. + assert a.load_case_curve( + rid, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) is not None + assert a.load_case_curve( + rid, birth_gen=1, birth_gene=0, sim_case_idx=0, + ) is None diff --git a/workflows/exaconstit-calibrate/tests/test_case_setup.py b/workflows/exaconstit-calibrate/tests/test_case_setup.py new file mode 100644 index 0000000..367a6aa --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_case_setup.py @@ -0,0 +1,442 @@ +""" +Unit tests for :mod:`workflow_common.case_setup`. + +Covers :class:`CaseTemplater` and the two shipped +:class:`PropertyWriter` implementations. Each test writes a small +template fixture to the workspace, exercises the writer, and +asserts the resulting file has the expected content. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from workflow_common import ( + CallablePropertyWriter, + CaseContext, + CaseLayout, + CaseTemplater, + DelimitedPropertyWriter, + PropertyWriter, + TemplatePathResolver, + TemplatePropertyWriter, + TemplateTarget, +) + + +def _make_layout(workspace: Path) -> CaseLayout: + resolver = TemplatePathResolver( + working_dir_pattern="wf/gen_{generation}/gene_{gene}_obj_{obj}", + output_file_patterns={}, + root=workspace, + ) + return CaseLayout( + ctx=CaseContext(generation=0, gene=3, obj=1), + resolver=resolver, + ) + + +# --- CaseTemplater ------------------------------------------------------- + + +def test_templater_renders_single_target(workspace: Path): + """One template, one output. Substitution works.""" + src = workspace / "master.toml" + src.write_text("rate = %%rate%%\nname = %%name%%\n") + templater = CaseTemplater([TemplateTarget(source=src, dest="options.toml")]) + + layout = _make_layout(workspace) + written = templater.render(layout, {"rate": 1e-3, "name": "hello"}) + + assert len(written) == 1 + content = written[0].read_text() + assert "rate = 0.001" in content + assert "name = hello" in content + assert "%%" not in content + + +def test_templater_renders_multiple_targets(workspace: Path): + """Multiple targets all rendered in one render() call.""" + src1 = workspace / "opts.toml" + src2 = workspace / "mesh.toml" + src1.write_text("rate = %%rate%%\n") + src2.write_text("mesh_size = %%mesh%%\n") + templater = CaseTemplater([ + TemplateTarget(source=src1, dest="options.toml"), + TemplateTarget(source=src2, dest="mesh.toml"), + ]) + + layout = _make_layout(workspace) + written = templater.render(layout, {"rate": 1e-3, "mesh": "fine"}) + + assert len(written) == 2 + assert "rate = 0.001" in written[0].read_text() + assert "mesh_size = fine" in written[1].read_text() + + +def test_templater_creates_parent_directories(workspace: Path): + """Destination with nested subpath creates parents.""" + src = workspace / "master.toml" + src.write_text("value = %%v%%\n") + templater = CaseTemplater([ + TemplateTarget(source=src, dest="cfg/sub/options.toml"), + ]) + + layout = _make_layout(workspace) + written = templater.render(layout, {"v": 42}) + + assert written[0].exists() + assert written[0].parent.is_dir() + + +def test_templater_passes_through_extras(workspace: Path): + """Values not used by the template are not an error.""" + src = workspace / "master.toml" + src.write_text("only_this = %%a%%\n") + templater = CaseTemplater([TemplateTarget(source=src, dest="out.toml")]) + + layout = _make_layout(workspace) + # "unused_b" is not referenced by the template; should not raise. + written = templater.render(layout, {"a": 1, "unused_b": 99}) + assert "only_this = 1" in written[0].read_text() + + +def test_templater_strict_raises_on_missing_key(workspace: Path): + """A strict target with an unresolved placeholder raises.""" + src = workspace / "master.toml" + src.write_text("value = %%missing%%\n") + templater = CaseTemplater([ + TemplateTarget(source=src, dest="out.toml", strict=True), + ]) + + layout = _make_layout(workspace) + with pytest.raises(KeyError): + templater.render(layout, {"other": 1}) + + +def test_templater_non_strict_passes_through_unknown(workspace: Path): + """A non-strict target leaves unknown placeholders intact.""" + src = workspace / "master.toml" + src.write_text("a = %%a%%, b = %%b%%\n") + templater = CaseTemplater([ + TemplateTarget(source=src, dest="out.toml", strict=False), + ]) + + layout = _make_layout(workspace) + written = templater.render(layout, {"a": 1}) + content = written[0].read_text() + assert "a = 1" in content + assert "%%b%%" in content + + +def test_templater_accepts_empty_targets(workspace: Path): + """Empty targets is a valid no-op: some workflows do all per-case + file writing inside a PropertyWriter and need no template + rendering at all. render() must succeed silently in that case. + """ + from workflow_common.paths import CaseContext + from workflow_common.results import CaseLayout + + templater = CaseTemplater([]) + assert templater.targets == () + resolver = TemplatePathResolver( + working_dir_pattern="gen_{generation}/gene_{gene}", + output_file_patterns={}, + root=workspace, + ) + layout = CaseLayout( + ctx=CaseContext(generation=0, gene=0, obj=0), + resolver=resolver, + ) + # render() should not raise even with no targets. + templater.render(layout, {"any": "values"}) + + +def test_templater_substitute_false_copies_bytes_verbatim(workspace: Path): + """substitute=False bypasses %%key%% substitution entirely so + binary or binary-safe files can be staged alongside rendered + text files. + """ + # A file whose bytes would be corrupted by a naive text-mode + # read/write round trip. The %%looks_like_a_placeholder%% text + # is included deliberately - substitute=False must NOT try to + # resolve it. + src = workspace / "binary_ish.ori" + raw_bytes = b"\x00\x01\x02%%looks_like_a_placeholder%%\xffquat\n" + src.write_bytes(raw_bytes) + + templater = CaseTemplater([ + TemplateTarget(source=src, dest="staged.ori", substitute=False), + ]) + layout = _make_layout(workspace) + written = templater.render(layout, values={}) + assert len(written) == 1 + # Bytes are preserved exactly - no decoding, no placeholder + # substitution, no trailing-newline fiddling. + assert written[0].read_bytes() == raw_bytes + + +def test_templater_substitute_false_ignores_missing_values(workspace: Path): + """A substitute=False target must not raise + UnresolvedPlaceholderError even if the source happens to contain + text that looks like a %%placeholder%%. The whole point of the + flag is to skip the placeholder pass. + """ + src = workspace / "raw.bin" + src.write_text("this has %%unresolved%% text in it\n") + + # strict=True would normally blow up on %%unresolved%% during a + # substituting render. With substitute=False, strict is ignored. + templater = CaseTemplater([ + TemplateTarget( + source=src, dest="out.bin", + substitute=False, strict=True, + ), + ]) + layout = _make_layout(workspace) + # Empty values mapping: proves we never consulted values at all. + written = templater.render(layout, values={}) + assert written[0].read_text() == "this has %%unresolved%% text in it\n" + + +def test_templater_mixed_substituting_and_raw_targets(workspace: Path): + """A single templater can render some targets and copy others + verbatim in one call. Order is preserved in the returned list. + """ + tmpl_src = workspace / "master.toml" + tmpl_src.write_text("rate = %%rate%%\n") + raw_src = workspace / "static.bin" + raw_src.write_bytes(b"\x00\x01static\n") + + templater = CaseTemplater([ + TemplateTarget(source=tmpl_src, dest="options.toml"), + TemplateTarget(source=raw_src, dest="static.bin", substitute=False), + ]) + layout = _make_layout(workspace) + written = templater.render(layout, values={"rate": 1.5}) + assert len(written) == 2 + assert written[0].read_text() == "rate = 1.5\n" + assert written[1].read_bytes() == b"\x00\x01static\n" + + +# --- TemplatePropertyWriter --------------------------------------------- + + +def test_template_property_writer_basic(workspace: Path): + """Gene values substituted into a template file.""" + tmpl = workspace / "master_props.toml" + tmpl.write_text( + "[Voce]\n" + "yield = %%yield%%\n" + "hardening = %%hardening%%\n" + ) + writer = TemplatePropertyWriter( + template_path=tmpl, dest="properties.toml", + ) + + layout = _make_layout(workspace) + path = writer.write( + layout, + gene=[200.0, 2000.0], + param_names=["yield", "hardening"], + ) + content = path.read_text() + assert "yield = 200" in content + assert "hardening = 2000" in content + + +def test_template_property_writer_mismatch_raises(workspace: Path): + """Gene and param_names must have same length.""" + tmpl = workspace / "p.toml" + tmpl.write_text("x = %%x%%\n") + writer = TemplatePropertyWriter(template_path=tmpl) + + layout = _make_layout(workspace) + with pytest.raises(ValueError, match="entries"): + writer.write(layout, gene=[1.0, 2.0], param_names=["x"]) + + +def test_template_property_writer_extra_values(workspace: Path): + """extra_values are applied, gene values win on collision.""" + tmpl = workspace / "p.toml" + tmpl.write_text( + "gene_val = %%yield%%\n" + "extra_val = %%constant%%\n" + ) + writer = TemplatePropertyWriter( + template_path=tmpl, + extra_values={"constant": "fixed", "yield": "should_be_overridden"}, + ) + layout = _make_layout(workspace) + path = writer.write( + layout, gene=[250.0], param_names=["yield"], + ) + content = path.read_text() + assert "gene_val = 250" in content # gene wins + assert "extra_val = fixed" in content # extra still applied + + +def test_template_property_writer_protocol_conformance(workspace: Path): + """TemplatePropertyWriter satisfies the PropertyWriter Protocol.""" + assert isinstance( + TemplatePropertyWriter(template_path=Path("x")), PropertyWriter + ) + + +# --- DelimitedPropertyWriter -------------------------------------------- + + +def test_delimited_writer_default_one_per_line(workspace: Path): + """Default newline delimiter produces one value per line.""" + writer = DelimitedPropertyWriter(dest="props.txt") + layout = _make_layout(workspace) + path = writer.write( + layout, gene=[200.0, 2000.0, 5.0], + param_names=["a", "b", "c"], + ) + text = path.read_text() + lines = text.rstrip("\n").split("\n") + assert lines == ["200", "2000", "5"] + + +def test_delimited_writer_csv_with_header(workspace: Path): + writer = DelimitedPropertyWriter( + dest="p.csv", delimiter=",", include_names=True, + ) + layout = _make_layout(workspace) + path = writer.write( + layout, gene=[1.0, 2.0], param_names=["a", "b"], + ) + lines = path.read_text().rstrip("\n").split("\n") + assert lines[0] == "a,b" + assert lines[1] == "1,2" + + +def test_delimited_writer_include_names_requires_alignment(workspace: Path): + writer = DelimitedPropertyWriter( + dest="p.txt", include_names=True, + ) + layout = _make_layout(workspace) + with pytest.raises(ValueError): + writer.write(layout, gene=[1.0, 2.0], param_names=["only_one"]) + + +def test_delimited_writer_custom_fmt(workspace: Path): + """fmt controls per-value formatting.""" + writer = DelimitedPropertyWriter(dest="p.txt", fmt="%.4e") + layout = _make_layout(workspace) + path = writer.write(layout, gene=[0.001234], param_names=["x"]) + assert path.read_text().rstrip("\n") == "1.2340e-03" + + +def test_delimited_writer_protocol_conformance(): + """DelimitedPropertyWriter satisfies the PropertyWriter Protocol.""" + assert isinstance(DelimitedPropertyWriter(), PropertyWriter) + + +# --- CallablePropertyWriter -------------------------------------------- + + +def test_callable_property_writer_invokes_user_function(workspace: Path): + """The user callable receives (case_dir, gene, names, sim_case) and + its returned path is echoed back to the framework. + """ + captured = {} + + def write_props(case_dir, gene, names, sim_case): + captured["case_dir"] = case_dir + captured["gene"] = list(gene) + captured["names"] = list(names) + captured["sim_case"] = sim_case + path = case_dir / "mat.props" + case_dir.mkdir(parents=True, exist_ok=True) + path.write_text(f"# gene={list(gene)}\n") + return path + + writer = CallablePropertyWriter(func=write_props) + layout = _make_layout(workspace) + out = writer.write( + layout, gene=[1.5, 2.5], param_names=["a", "b"], + ) + assert out == layout.working_dir / "mat.props" + assert out.read_text() == "# gene=[1.5, 2.5]\n" + assert captured["case_dir"] == layout.working_dir + assert captured["gene"] == [1.5, 2.5] + assert captured["names"] == ["a", "b"] + # No Problem dispatched this, so ctx.sim_case stays None. + assert captured["sim_case"] is None + + +def test_callable_property_writer_receives_sim_case(workspace: Path): + """When ctx.sim_case is set (as Problem does in production), + the callable gets the SimCase object and can reach its + case_data / label for per-experiment logic. + """ + from workflow_common import SimCase + + captured_sim_case = {} + + def write_props(case_dir, gene, names, sim_case): + captured_sim_case["obj"] = sim_case + p = case_dir / "p.txt" + case_dir.mkdir(parents=True, exist_ok=True) + p.write_text("ok\n") + return p + + writer = CallablePropertyWriter(func=write_props) + + sc = SimCase( + case_data={"strain_rate": 1e-3, "ori_file": "grains.ori"}, + label="quasi_static", + ) + resolver = TemplatePathResolver( + working_dir_pattern="wf/gen_{generation}/gene_{gene}_obj_{obj}", + output_file_patterns={}, + root=workspace, + ) + ctx = CaseContext(generation=0, gene=0, obj=0, sim_case=sc) + layout = CaseLayout(ctx=ctx, resolver=resolver) + writer.write(layout, gene=[1.0], param_names=["x"]) + + assert captured_sim_case["obj"] is sc + assert captured_sim_case["obj"].case_data["ori_file"] == "grains.ori" + assert captured_sim_case["obj"].label == "quasi_static" + + +def test_callable_property_writer_rejects_non_path_return(workspace: Path): + """A callable that forgets to return a Path gets a clear TypeError.""" + def bad_writer(case_dir, gene, names, sim_case): + return "/tmp/some/str/path" # str instead of Path + + writer = CallablePropertyWriter(func=bad_writer) + layout = _make_layout(workspace) + with pytest.raises(TypeError, match="pathlib.Path"): + writer.write(layout, gene=[1.0], param_names=["x"]) + + +def test_callable_property_writer_propagates_exceptions(workspace: Path): + """User errors inside the callable surface unchanged so the + configuration-vs-sim-failure distinction stays clear. + """ + class _ConfigError(Exception): + pass + + def raise_config_error(case_dir, gene, names, sim_case): + raise _ConfigError("gene out of bounds") + + writer = CallablePropertyWriter(func=raise_config_error) + layout = _make_layout(workspace) + with pytest.raises(_ConfigError, match="out of bounds"): + writer.write(layout, gene=[1.0], param_names=["x"]) + + +def test_callable_property_writer_protocol_conformance(): + """Structurally satisfies PropertyWriter.""" + def noop(case_dir, gene, names, sim_case): + p = case_dir / "x" + case_dir.mkdir(parents=True, exist_ok=True) + p.touch() + return p + + assert isinstance(CallablePropertyWriter(func=noop), PropertyWriter) diff --git a/workflows/exaconstit-calibrate/tests/test_example_imports.py b/workflows/exaconstit-calibrate/tests/test_example_imports.py new file mode 100644 index 0000000..0fadbac --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_example_imports.py @@ -0,0 +1,69 @@ +""" +Guard against the example script rotting silently. + +This file does NOT run the example — running it would require a +real ``mechanics`` binary and experimental CSVs. It just proves the +module imports cleanly, which catches: + +* Framework imports that were renamed or moved. +* RunConfig / Bounds / Problem signatures that changed. +* The example's custom evaluator classes no longer conforming to + the framework's "evaluate(results, ctx) -> float" contract in + a way that would fail on instantiation. + +When the API evolves, this test fails at CI time rather than when +a user first tries to run the example. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLE_PATH = REPO_ROOT / "examples" / "nsga3_calibration.py" + + +@pytest.mark.skipif( + not EXAMPLE_PATH.is_file(), + reason="examples/nsga3_calibration.py not present in source tree", +) +def test_example_imports_cleanly(): + """Import the example module without running its main().""" + spec = importlib.util.spec_from_file_location( + "nsga3_calibration_example", EXAMPLE_PATH, + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + # Register before exec so any circular-ish intra-module refs work + sys.modules["nsga3_calibration_example"] = module + try: + spec.loader.exec_module(module) + + # Sanity-check that the public surface users copy is there. + assert callable(module.main), "main() missing from example" + assert hasattr(module, "_StdNormalizedStressEvaluator") + assert hasattr(module, "_StdNormalizedSlopeEvaluator") + + # Custom evaluators must be constructable with the documented + # signature - catches drift in the argument list. + import numpy as np + import pandas as pd + from workflow_common import StressStrainExtractor + df = pd.DataFrame({"strain": [0.0, 0.05, 0.10], + "stress": [0.0, 100.0, 150.0]}) + extractor = StressStrainExtractor( + stress_column="s33", + strain_source="time_rate", strain_rate=1e-3, + ) + ev = module._StdNormalizedStressEvaluator( + experimental=df, extractor=extractor, + ) + assert hasattr(ev, "evaluate"), \ + "_StdNormalizedStressEvaluator must expose evaluate()" + finally: + sys.modules.pop("nsga3_calibration_example", None) diff --git a/workflows/exaconstit-calibrate/tests/test_fs.py b/workflows/exaconstit-calibrate/tests/test_fs.py new file mode 100644 index 0000000..cd4cccb --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_fs.py @@ -0,0 +1,112 @@ +""" +Unit tests for :mod:`workflow_common._fs`. + +These cover the low-level filesystem helpers: the ``cd`` context +manager and :func:`atomic_write_text`. Focus is on the invariants +the rest of the framework relies on - exception safety for ``cd``, +and crash-resilience for ``atomic_write_text``. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from workflow_common._fs import ( + atomic_replace, + atomic_write_text, + cd, + ensure_dir, +) + + +def test_cd_changes_and_restores(workspace: Path): + """``cd`` should enter the directory and restore cwd on exit.""" + original = Path.cwd() + target = workspace / "subdir" + target.mkdir() + with cd(target) as resolved: + assert Path.cwd() == target + assert resolved == target + assert Path.cwd() == original + + +def test_cd_restores_even_on_exception(workspace: Path): + """Exceptions inside the ``with`` block must not leak cwd changes.""" + original = Path.cwd() + target = workspace / "subdir" + target.mkdir() + with pytest.raises(RuntimeError, match="boom"): + with cd(target): + raise RuntimeError("boom") + assert Path.cwd() == original + + +def test_cd_raises_when_target_missing(workspace: Path): + """A nonexistent target should raise FileNotFoundError.""" + missing = workspace / "no_such_dir" + with pytest.raises(FileNotFoundError): + with cd(missing): + pass + + +def test_atomic_write_text_basic(workspace: Path): + """Happy path: file appears with exactly the requested contents.""" + target = workspace / "out.txt" + atomic_write_text(target, "hello world") + assert target.read_text() == "hello world" + + +def test_atomic_write_text_creates_parents(workspace: Path): + """Parent directories are created if missing (``mkdir -p`` semantics).""" + target = workspace / "nested" / "deeply" / "out.txt" + atomic_write_text(target, "ok") + assert target.read_text() == "ok" + + +def test_atomic_write_text_overwrites_existing(workspace: Path): + """Writes are fully replacing, not appending.""" + target = workspace / "out.txt" + target.write_text("old contents with more bytes") + atomic_write_text(target, "new") + assert target.read_text() == "new" + + +def test_atomic_write_text_leaves_no_tempfile_on_success(workspace: Path): + """The tempfile-plus-rename scheme must not leak tempfiles.""" + target = workspace / "out.txt" + atomic_write_text(target, "x") + # After a successful write, only the target should remain; no + # leftover ".out.txt..tmp" should be visible. + leftovers = [p for p in workspace.iterdir() if p.name.startswith(".out.txt.")] + assert leftovers == [], f"unexpected tempfiles: {leftovers}" + + +def test_atomic_write_text_unicode(workspace: Path): + """Default UTF-8 encoding handles unicode correctly.""" + target = workspace / "out.txt" + atomic_write_text(target, "resumé café 你好") + assert target.read_text(encoding="utf-8") == "resumé café 你好" + + +def test_atomic_replace(workspace: Path): + """atomic_replace should move src over dst, replacing any existing file.""" + src = workspace / "src.txt" + dst = workspace / "dst.txt" + src.write_text("NEW") + dst.write_text("OLD") + atomic_replace(src, dst) + assert not src.exists() + assert dst.read_text() == "NEW" + + +def test_ensure_dir_creates_and_idempotent(workspace: Path): + """ensure_dir creates missing dirs and no-ops on existing ones.""" + target = workspace / "a" / "b" / "c" + out = ensure_dir(target) + assert target.is_dir() + assert out == target + # Second call must not raise + ensure_dir(target) + assert target.is_dir() diff --git a/workflows/exaconstit-calibrate/tests/test_inspect_archive.py b/workflows/exaconstit-calibrate/tests/test_inspect_archive.py new file mode 100644 index 0000000..d4ce558 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_inspect_archive.py @@ -0,0 +1,1202 @@ +"""Tests for the :mod:`workflows.optimization.inspect_archive` CLI. + +Exercises each view (--runs, --gens, --genes) and each output format +against a small fake archive built by hand. The tests check that the +CLI produces output in the right SHAPE (header + rows, CSV with the +right column count, parseable JSON) rather than exact bytes, because +column widths and formatting nuances shouldn't pin the test. +""" +from __future__ import annotations + +import csv +import io +import json +from pathlib import Path + +import numpy as np +import pytest + +from workflow_common.archive import ArchiveDB, GeneRecord +from workflows.optimization import inspect_archive + + +@pytest.fixture +def tiny_archive(tmp_path: Path) -> Path: + """A three-generation, four-individual archive with known ranks. + + Two runs so --run selection can be tested. ``run-old`` has one + gen and earlier timestamp; ``run-new`` has three gens and is + what --runs defaults to when ``--run`` is omitted. + """ + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + # Old run first so its started_at is earlier. + old_id = a.start_run( + run_id="run-old", seed=1, + param_names=["p1"], objective_labels=["obj"], + ) + a.record_generation( + old_id, gen_idx=0, + genes=[GeneRecord( + run_id=old_id, gen_idx=0, pop_idx=0, + birth_gen=0, birth_gene=0, + gene_vector=np.array([1.0]), + fitness=(0.1,), rank=0, + )], + stats={"avg": [0.1]}, + ) + a.end_run(old_id) + + new_id = a.start_run( + run_id="run-new", seed=2, + param_names=["yield_stress", "hardening"], + objective_labels=["rmse"], + ) + for gen_idx in range(3): + genes = [ + GeneRecord( + run_id=new_id, gen_idx=gen_idx, pop_idx=i, + birth_gen=gen_idx, birth_gene=i, + gene_vector=np.array([200.0 + i, 2000.0 + 10 * i]), + fitness=(1.5 - 0.1 * gen_idx + 0.01 * i,), + # First two individuals per gen are rank 0. + rank=0 if i < 2 else 1, + ) + for i in range(4) + ] + a.record_generation( + new_id, gen_idx=gen_idx, genes=genes, + stats={ + "avg": [1.5 - 0.1 * gen_idx], + "min": [1.4 - 0.1 * gen_idx], + "max": [1.6 - 0.1 * gen_idx], + }, + ) + a.end_run(new_id) + return tmp_path + + +def test_inspect_runs_lists_every_run( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """--runs dumps one row per run; both runs appear; headers present.""" + rc = inspect_archive.main([str(tiny_archive), "--runs"]) + assert rc == 0 + out = capsys.readouterr().out + assert "run_id" in out # header present + assert "run-old" in out + assert "run-new" in out + + +def test_inspect_gens_defaults_to_latest_run( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """--gens (no --run) picks the most recent run automatically.""" + rc = inspect_archive.main([str(tiny_archive), "--gens"]) + assert rc == 0 + out = capsys.readouterr().out + # Three gens in the new run, so three body lines. + body = [ + line for line in out.splitlines() + if line.strip() and line.strip().split()[0].isdigit() + ] + assert len(body) == 3 + + +def test_inspect_gens_respects_explicit_run( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """Passing --run forces that run even when newer ones exist.""" + rc = inspect_archive.main( + [str(tiny_archive), "--gens", "--run", "run-old"], + ) + assert rc == 0 + out = capsys.readouterr().out + body = [ + line for line in out.splitlines() + if line.strip() and line.strip().split()[0].isdigit() + ] + assert len(body) == 1 # only one gen in run-old + + +def test_inspect_gens_unknown_run_errors( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """Unknown run_id fails cleanly with exit code 1.""" + with pytest.raises(SystemExit) as excinfo: + inspect_archive.main( + [str(tiny_archive), "--gens", "--run", "nope"], + ) + assert excinfo.value.code == 1 + err = capsys.readouterr().err + assert "nope" in err + + +def test_inspect_genes_at_specific_gen( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """--genes --gen 2 yields NPOP rows with real parameter names.""" + rc = inspect_archive.main( + [str(tiny_archive), "--genes", "--gen", "2"], + ) + assert rc == 0 + out = capsys.readouterr().out + # Header has the real param names (not 'p0', 'p1'). + assert "yield_stress" in out + assert "hardening" in out + assert "rmse" in out + body = [ + line for line in out.splitlines() + if line.strip() and line.strip().split()[0].isdigit() + ] + assert len(body) == 4 + + +def test_inspect_genes_pareto_only( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """--pareto-only filters to rank-0 individuals.""" + rc = inspect_archive.main( + [str(tiny_archive), "--genes", "--gen", "2", "--pareto-only"], + ) + assert rc == 0 + out = capsys.readouterr().out + body = [ + line for line in out.splitlines() + if line.strip() and line.strip().split()[0].isdigit() + ] + # Fixture put two rank-0 individuals per gen. + assert len(body) == 2 + + +def test_inspect_genes_csv_is_parseable( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """--format csv produces a CSV that csv.DictReader can parse back.""" + rc = inspect_archive.main( + [str(tiny_archive), "--genes", "--gen", "2", "--format", "csv"], + ) + assert rc == 0 + out = capsys.readouterr().out + rows = list(csv.DictReader(io.StringIO(out))) + assert len(rows) == 4 + # Each row has the parameter columns filled with numeric-looking + # strings. + for r in rows: + assert r["yield_stress"] + assert r["hardening"] + assert r["rmse"] + + +def test_inspect_json_is_valid_json( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """--format json produces a valid JSON array of objects.""" + rc = inspect_archive.main( + [str(tiny_archive), "--genes", "--gen", "2", "--format", "json"], + ) + assert rc == 0 + out = capsys.readouterr().out + parsed = json.loads(out) + assert isinstance(parsed, list) + assert len(parsed) == 4 + assert all("yield_stress" in item for item in parsed) + + +def test_inspect_no_header( + tiny_archive: Path, capsys: pytest.CaptureFixture, +): + """--no-header suppresses the header row in CSV output.""" + rc = inspect_archive.main( + [str(tiny_archive), "--genes", "--gen", "2", + "--format", "csv", "--no-header"], + ) + assert rc == 0 + out = capsys.readouterr().out + # First line shouldn't contain the word "yield_stress" (which + # would imply the header printed). + first_line = out.splitlines()[0] + assert "yield_stress" not in first_line + + +def test_inspect_missing_archive_errors( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Pointing at a directory with no *.db files at all errors cleanly. + + Used to rely on the old ``--archive-name`` path; now routes + through ``_resolve_archive_path`` which raises ``SystemExit(1)`` + — argparse convention. The error message names the directory + and the default filename so the user knows what was looked for. + """ + with pytest.raises(SystemExit) as excinfo: + inspect_archive.main([str(tmp_path), "--runs"]) + assert excinfo.value.code == 1 + err = capsys.readouterr().err + assert "archive.db" in err + assert "no " in err + + +# --- Path-resolution behaviors (direct file / auto-pick / prompt) ------- + + +def test_resolve_accepts_file_path_directly(tmp_path: Path): + """Behavior 1: positional arg is a ``.db`` file, used as-is. + + The user reported `./calibration_run/calibration.db` failing + because the old CLI blindly appended `archive.db` to anything. + The fix is: if the path IS a file, return it verbatim. + """ + from workflow_common.archive import ArchiveDB + from workflows.optimization.inspect_archive import _resolve_archive_path + + # Custom filename — not 'archive.db'. + custom = tmp_path / "calibration.db" + with ArchiveDB(custom) as a: + a.start_run( + run_id="r0", seed=1, + param_names=["p"], objective_labels=["o"], + ) + a.end_run("r0") + + resolved = _resolve_archive_path(custom, "archive.db") + assert resolved == custom + + +def test_resolve_prefers_default_name_in_directory(tmp_path: Path): + """Behavior 2: directory contains ``archive.db``; that wins.""" + from workflow_common.archive import ArchiveDB + from workflows.optimization.inspect_archive import _resolve_archive_path + + default = tmp_path / "archive.db" + other = tmp_path / "other.db" + for p in (default, other): + with ArchiveDB(p) as a: + a.start_run( + run_id="r", seed=1, + param_names=["p"], objective_labels=["o"], + ) + a.end_run("r") + + # Even though there's also "other.db", the default wins silently. + resolved = _resolve_archive_path(tmp_path, "archive.db") + assert resolved == default + + +def test_resolve_auto_picks_sole_db_file_and_warns( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Behavior 3a: no archive.db, exactly one *.db → use it, warn on stderr. + + The stderr notice is the audit trail: the user sees which file + was inferred so they can't be surprised later. + """ + from workflow_common.archive import ArchiveDB + from workflows.optimization.inspect_archive import _resolve_archive_path + + sole = tmp_path / "calibration.db" + with ArchiveDB(sole) as a: + a.start_run( + run_id="r", seed=1, + param_names=["p"], objective_labels=["o"], + ) + a.end_run("r") + + resolved = _resolve_archive_path(tmp_path, "archive.db") + assert resolved == sole + err = capsys.readouterr().err + assert "calibration.db" in err + + +def test_resolve_prompts_when_multiple_db_files( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Behavior 3b: multiple *.db files, interactive → prompt for a pick. + + Uses injected ``input_fn`` and ``isatty_fn`` to drive the + prompt without touching real stdin. Verifies the user can + pick one of the candidates by 1-based index. + """ + from workflow_common.archive import ArchiveDB + from workflows.optimization.inspect_archive import _resolve_archive_path + + a_path = tmp_path / "a.db" + b_path = tmp_path / "b.db" + for p in (a_path, b_path): + with ArchiveDB(p) as arch: + arch.start_run( + run_id="r", seed=1, + param_names=["p"], objective_labels=["o"], + ) + arch.end_run("r") + + resolved = _resolve_archive_path( + tmp_path, "archive.db", + input_fn=lambda _prompt: "2", # pick the 2nd (glob is sorted) + isatty_fn=lambda: True, # pretend we have a tty + ) + # Sorted candidates: [a.db, b.db]; index 2 = b.db. + assert resolved == b_path + + +def test_resolve_prompt_rejects_bad_input_then_accepts( + tmp_path: Path, +): + """Prompt loops on garbage input rather than bailing on first error. + + User types 'banana', then '99', then '1'. Resolver loops + until it gets a valid index. + """ + from workflow_common.archive import ArchiveDB + from workflows.optimization.inspect_archive import _resolve_archive_path + + (tmp_path / "one.db").touch() + (tmp_path / "two.db").touch() + with ArchiveDB(tmp_path / "one.db") as a: + a.start_run(run_id="r", seed=1, param_names=["p"], objective_labels=["o"]) + a.end_run("r") + with ArchiveDB(tmp_path / "two.db") as a: + a.start_run(run_id="r", seed=1, param_names=["p"], objective_labels=["o"]) + a.end_run("r") + + replies = iter(["banana", "99", "1"]) + resolved = _resolve_archive_path( + tmp_path, "archive.db", + input_fn=lambda _p: next(replies), + isatty_fn=lambda: True, + ) + assert resolved == tmp_path / "one.db" + + +def test_resolve_prompt_quit_exits_nonzero(tmp_path: Path): + """User typing 'q' at the prompt should exit cleanly with rc=1.""" + from workflow_common.archive import ArchiveDB + from workflows.optimization.inspect_archive import _resolve_archive_path + + for name in ("a.db", "b.db"): + (tmp_path / name).touch() + with ArchiveDB(tmp_path / name) as a: + a.start_run( + run_id="r", seed=1, + param_names=["p"], objective_labels=["o"], + ) + a.end_run("r") + + with pytest.raises(SystemExit) as excinfo: + _resolve_archive_path( + tmp_path, "archive.db", + input_fn=lambda _p: "q", + isatty_fn=lambda: True, + ) + assert excinfo.value.code == 1 + + +def test_resolve_refuses_to_prompt_in_non_interactive_context( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Behavior 3c: multiple *.db and no tty → error, don't hang. + + This is the CI-friendly path. Without this guard, a CI job + piping data to the tool would deadlock at the prompt. + """ + from workflow_common.archive import ArchiveDB + from workflows.optimization.inspect_archive import _resolve_archive_path + + for name in ("a.db", "b.db"): + with ArchiveDB(tmp_path / name) as a: + a.start_run( + run_id="r", seed=1, + param_names=["p"], objective_labels=["o"], + ) + a.end_run("r") + + with pytest.raises(SystemExit) as excinfo: + _resolve_archive_path( + tmp_path, "archive.db", + # input_fn would hang if called; verify it isn't. + input_fn=lambda _p: (_ for _ in ()).throw( + AssertionError("input_fn must NOT be called on non-tty") + ), + isatty_fn=lambda: False, + ) + assert excinfo.value.code == 1 + err = capsys.readouterr().err + assert "multiple" in err + assert "a.db" in err and "b.db" in err + + +def test_resolve_reports_nonexistent_path( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Bad path arg (typo, missing dir) exits 1 with a clear message.""" + from workflows.optimization.inspect_archive import _resolve_archive_path + + with pytest.raises(SystemExit) as excinfo: + _resolve_archive_path( + tmp_path / "does_not_exist", + "archive.db", + ) + assert excinfo.value.code == 1 + err = capsys.readouterr().err + assert "does not exist" in err + + +def test_inspect_accepts_file_path_end_to_end( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Full-CLI round trip: pass a non-default filename directly. + + Regression for the original user report: + python -m ... inspect_archive ./calibration_run/calibration.db --runs + used to fail with "archive not found at calibration_run/calibration.db/archive.db". + """ + from workflow_common.archive import ArchiveDB + + custom = tmp_path / "calibration.db" + with ArchiveDB(custom) as a: + a.start_run( + run_id="calib-run-0", seed=42, + param_names=["yield_stress"], + objective_labels=["rmse"], + ) + a.end_run("calib-run-0") + + rc = inspect_archive.main([str(custom), "--runs"]) + assert rc == 0 + out = capsys.readouterr().out + assert "calib-run-0" in out + + +# --- Cross-generation --pareto-only ------------------------------------ + + +@pytest.fixture +def cross_gen_archive(tmp_path: Path) -> Path: + """Archive where each category's winner lives in a different gen. + + Designed so the cross-gen view can be verified by exact + identity: gen 1 pop 0 is the L2 champion, gen 3 pop 0 is the + stress_rmse champion, gen 5 pop 0 is the slope_rmse champion. + Other individuals cluster mid-fitness so they shouldn't appear + in any top-3 list. + """ + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="cross-gen-test", + seed=42, + param_names=["yield_stress", "hardening"], + objective_labels=["stress_rmse", "slope_rmse"], + ) + for gen_idx in range(6): + genes = [] + for pop_idx in range(5): + # Three "champion" genes scattered across gens: + if gen_idx == 1 and pop_idx == 0: + gv, fit = np.array([250.0, 2000.0]), (0.10, 0.15) + elif gen_idx == 3 and pop_idx == 0: + gv, fit = np.array([260.0, 2200.0]), (0.05, 0.60) + elif gen_idx == 5 and pop_idx == 0: + gv, fit = np.array([230.0, 1800.0]), (0.70, 0.08) + else: + # Mid-pack filler, different gene per slot so + # dedup doesn't collapse them. + gv = np.array([ + 200.0 + gen_idx * 5 + pop_idx, + 2000.0 + gen_idx * 20 + pop_idx * 10, + ]) + fit = (0.3 + 0.01 * pop_idx, 0.4 + 0.01 * pop_idx) + genes.append(GeneRecord( + run_id=rid, gen_idx=gen_idx, pop_idx=pop_idx, + birth_gen=gen_idx, birth_gene=pop_idx, + gene_vector=gv, fitness=fit, rank=pop_idx, + )) + a.record_generation(rid, gen_idx=gen_idx, genes=genes, stats={}) + a.end_run(rid) + return tmp_path + + +def test_pareto_only_shows_cross_generation_winners( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """--pareto-only alone (no --gen) surfaces the historical bests. + + Each of the three category winners in the fixture should + appear as row 1 of its respective category: L2 champion at + the top of the l2 block, stress_rmse winner at the top of its + block, slope_rmse winner at the top of its. + """ + rc = inspect_archive.main( + [str(cross_gen_archive), "--genes", "--pareto-only", "--format", "csv"], + ) + assert rc == 0 + out = capsys.readouterr().out + rows = list(csv.DictReader(io.StringIO(out))) + + l2_rows = [r for r in rows if r["category"] == "l2"] + stress_rows = [r for r in rows if r["category"] == "obj:stress_rmse"] + slope_rows = [r for r in rows if r["category"] == "obj:slope_rmse"] + + # Default --top is 3. + assert len(l2_rows) == 3 + assert len(stress_rows) == 3 + assert len(slope_rows) == 3 + + # Champion identities (verified by birth_gen — the fixture + # places each champion at a known generation). + assert l2_rows[0]["birth_gen"] == "1" + assert stress_rows[0]["birth_gen"] == "3" + assert slope_rows[0]["birth_gen"] == "5" + + +def test_pareto_only_respects_top_flag( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """--top 5 shows five rows per category instead of three.""" + rc = inspect_archive.main( + [str(cross_gen_archive), "--genes", "--pareto-only", + "--top", "5", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + # 5 per category × (1 L2 + 2 objectives) = 15 rows. + assert len(rows) == 15 + + +def test_pareto_only_with_gen_keeps_old_single_gen_behavior( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """--pareto-only --gen N preserves the pre-cross-gen behavior. + + Users who were scripting against the old "rank-0 of one + generation" semantics need that mode to still exist. Binding + it to "pareto-only AND explicit gen" is the compatibility + story. + """ + rc = inspect_archive.main( + [str(cross_gen_archive), "--genes", "--gen", "3", + "--pareto-only", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + # Fixture gave pop_idx 0 rank 0; only that row comes back. + assert len(rows) == 1 + assert rows[0]["gen_idx"] == "3" + assert rows[0]["rank"] == "0" + # And the columns are the OLD layout, not the cross-gen one. + assert "gen_idx" in rows[0] + assert "category" not in rows[0] + + +def test_pareto_only_dedups_identical_genes_across_gens( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Elitism copies good genes across gens; dedup keeps each unique + gene once, attributed to its FIRST-seen (birth) generation. + Without dedup the top-3 table would be ``[champion, champion, + champion]`` — all the same individual surviving three + generations — which tells the user nothing. + """ + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="elitist", + seed=1, + param_names=["p"], + objective_labels=["o"], + ) + # Same champion at gens 0, 1, 2 — elitism. Filler genes + # differ per gen so they dedup to three unique records. + for gen_idx in range(3): + champion = GeneRecord( + run_id=rid, gen_idx=gen_idx, pop_idx=0, + birth_gen=0, birth_gene=0, # birth stays at gen 0 + gene_vector=np.array([42.0]), + fitness=(0.01,), rank=0, + ) + filler = GeneRecord( + run_id=rid, gen_idx=gen_idx, pop_idx=1, + birth_gen=gen_idx, birth_gene=1, + gene_vector=np.array([100.0 + gen_idx]), + fitness=(1.0 + 0.1 * gen_idx,), rank=1, + ) + a.record_generation(rid, gen_idx=gen_idx, + genes=[champion, filler], stats={}) + a.end_run(rid) + + rc = inspect_archive.main( + [str(tmp_path), "--genes", "--pareto-only", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + l2_rows = [r for r in rows if r["category"] == "l2"] + # Champion should appear exactly ONCE despite showing up in + # every generation. + champion_appearances = [r for r in l2_rows if r["p"] == "42"] + assert len(champion_appearances) == 1 + + +def test_pareto_only_skips_nonfinite_fitness( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Failed genes (fitness=inf or nan) must NOT appear in the + rankings — they'd either dominate the "worst" slot meaninglessly + or crash sort comparisons depending on which way the + implementation bent. + """ + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="with-failures", seed=1, + param_names=["p"], objective_labels=["o"], + ) + a.record_generation( + rid, gen_idx=0, + genes=[ + GeneRecord(run_id=rid, gen_idx=0, pop_idx=0, + birth_gen=0, birth_gene=0, + gene_vector=np.array([1.0]), + fitness=(0.5,), rank=0), + GeneRecord(run_id=rid, gen_idx=0, pop_idx=1, + birth_gen=0, birth_gene=1, + gene_vector=np.array([2.0]), + # Simulate a failure-handler penalty. + fitness=(float("inf"),), rank=1), + GeneRecord(run_id=rid, gen_idx=0, pop_idx=2, + birth_gen=0, birth_gene=2, + gene_vector=np.array([3.0]), + fitness=(0.3,), rank=0), + ], + stats={}, + ) + a.end_run(rid) + + rc = inspect_archive.main( + [str(tmp_path), "--genes", "--pareto-only", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + # Only 2 finite individuals — top-3 request returns what's available. + l2_rows = [r for r in rows if r["category"] == "l2"] + assert len(l2_rows) == 2 + # The inf-fitness gene (p=2) must not appear. + assert not any(r["p"] == "2" for r in l2_rows) + + +# --- --limit cap on raw --genes output --------------------------------- + + +def test_limit_caps_raw_genes_output( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Default --limit 200 truncates a 500-row generation with a notice.""" + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="big", seed=1, + param_names=["p"], objective_labels=["o"], + ) + genes = [ + GeneRecord(run_id=rid, gen_idx=0, pop_idx=i, + birth_gen=0, birth_gene=i, + gene_vector=np.array([float(i)]), + fitness=(float(i),), rank=0) + for i in range(500) + ] + a.record_generation(rid, gen_idx=0, genes=genes, stats={}) + a.end_run(rid) + + rc = inspect_archive.main( + [str(tmp_path), "--genes", "--format", "csv"], + ) + assert rc == 0 + captured = capsys.readouterr() + rows = list(csv.DictReader(io.StringIO(captured.out))) + # Default cap is 200. + assert len(rows) == 200 + # User notified via stderr about the truncation. + assert "truncated" in captured.err + assert "500" in captured.err + + +def test_limit_zero_disables_cap( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """--limit 0 returns every row with no truncation notice.""" + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="big", seed=1, + param_names=["p"], objective_labels=["o"], + ) + genes = [ + GeneRecord(run_id=rid, gen_idx=0, pop_idx=i, + birth_gen=0, birth_gene=i, + gene_vector=np.array([float(i)]), + fitness=(float(i),), rank=0) + for i in range(350) + ] + a.record_generation(rid, gen_idx=0, genes=genes, stats={}) + a.end_run(rid) + + rc = inspect_archive.main( + [str(tmp_path), "--genes", "--limit", "0", "--format", "csv"], + ) + assert rc == 0 + captured = capsys.readouterr() + rows = list(csv.DictReader(io.StringIO(captured.out))) + assert len(rows) == 350 + assert "truncated" not in captured.err + + +def test_limit_ignored_for_pareto_only( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """--pareto-only output is already bounded; --limit doesn't touch it. + + A 600-gene cross-gen view at top 3 returns at most 3*(1+M) rows + per run. Setting --limit 1 must not truncate this further. + """ + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="p", seed=1, + param_names=["p"], objective_labels=["oa", "ob"], + ) + for gen_idx in range(3): + genes = [ + GeneRecord(run_id=rid, gen_idx=gen_idx, pop_idx=i, + birth_gen=gen_idx, birth_gene=i, + gene_vector=np.array([float(10 * gen_idx + i)]), + fitness=(float(i), float(i) + 1.0), rank=0) + for i in range(5) + ] + a.record_generation(rid, gen_idx=gen_idx, genes=genes, stats={}) + a.end_run(rid) + + rc = inspect_archive.main( + [str(tmp_path), "--genes", "--pareto-only", + "--limit", "1", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + # Top 3 × (l2 + obj:oa + obj:ob) = 9 rows; --limit 1 must NOT + # shrink this to 1. + assert len(rows) == 9 + + +# --- --gens-best convergence view -------------------------------------- + + +def test_gens_best_tracks_running_minimums( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Per-generation best-ever fitness monotonically improves (or holds).""" + from workflow_common.archive import ArchiveDB, GeneRecord + + # Fixture designed with a visible plateau: + # gen 0: best (0.5, 0.5) + # gen 2: new champion (0.2, 0.3) + # gens 3-6: no one beats the gen-2 champion + # gen 7: new champion (0.15, 0.25) + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="conv", seed=1, + param_names=["p"], objective_labels=["a", "b"], + ) + for gen_idx in range(10): + if gen_idx == 2: + fit = (0.2, 0.3) + elif gen_idx == 7: + fit = (0.15, 0.25) + else: + fit = (0.5, 0.5) + a.record_generation( + rid, gen_idx=gen_idx, + genes=[GeneRecord( + run_id=rid, gen_idx=gen_idx, pop_idx=0, + birth_gen=gen_idx, birth_gene=0, + gene_vector=np.array([float(gen_idx)]), + fitness=fit, rank=0, + )], + stats={}, + ) + a.end_run(rid) + + rc = inspect_archive.main( + [str(tmp_path), "--gens-best", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + assert len(rows) == 10 + + # Running minimums: non-increasing from one generation to the next. + a_best = [float(r["a_best"]) for r in rows] + b_best = [float(r["b_best"]) for r in rows] + for prev, cur in zip(a_best, a_best[1:]): + assert cur <= prev + 1e-12 + for prev, cur in zip(b_best, b_best[1:]): + assert cur <= prev + 1e-12 + + # Champion-birth plateau: gens 2-6 all pinned to birth_gen 2. + plateau = [int(r["champion_birth_gen"]) for r in rows[2:7]] + assert plateau == [2, 2, 2, 2, 2] + # Gen 7 shows the new champion taking over. + assert int(rows[7]["champion_birth_gen"]) == 7 + + +def test_gens_best_skips_nonfinite_fitness( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Failed/penalty genes must not peg the running minimums to inf.""" + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="with-nan", seed=1, + param_names=["p"], objective_labels=["a"], + ) + a.record_generation( + rid, gen_idx=0, + genes=[ + GeneRecord(run_id=rid, gen_idx=0, pop_idx=0, + birth_gen=0, birth_gene=0, + gene_vector=np.array([1.0]), + fitness=(float("inf"),), rank=1), + GeneRecord(run_id=rid, gen_idx=0, pop_idx=1, + birth_gen=0, birth_gene=1, + gene_vector=np.array([2.0]), + fitness=(0.4,), rank=0), + ], + stats={}, + ) + a.end_run(rid) + + rc = inspect_archive.main( + [str(tmp_path), "--gens-best", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + assert len(rows) == 1 + # a_best must reflect the 0.4 gene, not inf. + assert float(rows[0]["a_best"]) == 0.4 + # champion_birth_gen should also name the non-inf gene. + assert int(rows[0]["champion_birth_gen"]) == 0 + + +def test_gens_best_empty_run_returns_no_rows( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """A run with zero generations errors out via _emit's empty-row path. + + Same contract as the other views: an empty result yields a + non-zero exit code so CI scripts don't silently continue when + their expected data isn't there. + """ + from workflow_common.archive import ArchiveDB + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + a.start_run( + run_id="empty", seed=1, + param_names=["p"], objective_labels=["o"], + ) + a.end_run("empty") + + with pytest.raises(SystemExit) as excinfo: + inspect_archive.main([str(tmp_path), "--gens-best"]) + # _emit raises SystemExit(2) on "no rows matched the query". + assert excinfo.value.code == 2 + + +# --- --pareto-only implies --genes ------------------------------------ + + +def test_pareto_only_alone_implies_genes_view( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """Typing just --pareto-only (without --genes) must produce the + cross-generation top-N view. + + Before the fix, the view-selector fallback silently promoted + to --gens, and --pareto-only was dropped. The output was + per-generation summary stats — completely different from what + the user asked for. + """ + rc = inspect_archive.main( + [str(cross_gen_archive), "--pareto-only", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + # Cross-gen view has a "category" column; --gens would have + # "gen_idx"/"recorded_at"/... and no "category". + assert rows, "expected cross-gen rows, got none" + assert "category" in rows[0] + # Must contain the L2 + per-objective blocks. + categories = {r["category"] for r in rows} + assert "l2" in categories + assert any(c.startswith("obj:") for c in categories) + + +def test_pareto_only_combined_with_gens_errors( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """--pareto-only together with a non-gene view is nonsense; + the tool exits 1 with a clear message rather than silently + dropping the flag. + """ + rc = inspect_archive.main( + [str(cross_gen_archive), "--gens", "--pareto-only"], + ) + assert rc == 1 + err = capsys.readouterr().err + assert "--pareto-only" in err + assert "--genes" in err + + +def test_pareto_only_combined_with_runs_errors( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """Same rule for --runs + --pareto-only.""" + rc = inspect_archive.main( + [str(cross_gen_archive), "--runs", "--pareto-only"], + ) + assert rc == 1 + + +def test_pareto_only_combined_with_gens_best_errors( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """And --gens-best.""" + rc = inspect_archive.main( + [str(cross_gen_archive), "--gens-best", "--pareto-only"], + ) + assert rc == 1 + + +# --- l2_norm column visible in cross-gen view ------------------------- + + +def test_cross_gen_view_includes_l2_norm_column( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """Every row in the --pareto-only output carries an l2_norm value. + + The fixture's L2 champion (gen 1 pop 0, fitness (0.10, 0.15)) + has norm sqrt(0.01 + 0.0225) == 0.18027..., which must appear + as the first L2-category row's l2_norm value. + """ + rc = inspect_archive.main( + [str(cross_gen_archive), "--genes", "--pareto-only", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + + # Every row has the column populated with a float. + for r in rows: + assert "l2_norm" in r + val = float(r["l2_norm"]) + assert val >= 0.0 # norms are non-negative + + # The fixture's L2 champion should have the smallest norm in the + # whole table. And the top-L2 row's norm matches the computed + # value for (0.10, 0.15) -> 0.18027... + l2_block = [r for r in rows if r["category"] == "l2"] + assert l2_block, "no L2-category rows" + top_l2 = float(l2_block[0]["l2_norm"]) + # CSV formatting uses "%g" (6 significant figures) so the + # round-trip loses sub-microscopic precision. 1e-5 tolerance + # easily distinguishes the champion's norm from any other + # in the fixture (next-smallest L2 is 0.5). + assert abs(top_l2 - float(np.hypot(0.10, 0.15))) < 1e-5 + + +def test_l2_column_norms_sort_ascending_within_l2_block( + cross_gen_archive: Path, capsys: pytest.CaptureFixture, +): + """The L2 category is ranked by l2_norm; the column must reflect that. + + Scanning down the L2 block, l2_norm values should be + non-decreasing. The column is the same quantity the ranking + uses, so anything else would be a sign the column and the + sort key got out of sync. + """ + rc = inspect_archive.main( + [str(cross_gen_archive), "--genes", "--pareto-only", "--format", "csv"], + ) + assert rc == 0 + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + l2_norms = [ + float(r["l2_norm"]) for r in rows if r["category"] == "l2" + ] + assert l2_norms, "no L2-category rows" + for a, b in zip(l2_norms, l2_norms[1:]): + assert a <= b + 1e-12 + + +# --- --clean-empty-runs CLI flag --------------------------------------- + + +def _seed_archive_with_mixed_runs(tmp_path: Path) -> tuple[str, list[str]]: + """Build an archive with one populated + two empty-completed runs. + + Returns (full_run_id, [empty_ids]) for the tests to assert against. + """ + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + empty_ids = [] + with ArchiveDB(db) as a: + full_rid = a.start_run( + run_id="full-run", seed=1, + param_names=["p"], objective_labels=["o"], + ) + a.record_generation( + full_rid, gen_idx=0, + genes=[GeneRecord( + run_id=full_rid, gen_idx=0, pop_idx=0, + birth_gen=0, birth_gene=0, + gene_vector=np.array([1.0]), + fitness=(0.1,), rank=0, + )], + stats={}, + ) + a.end_run(full_rid) + + for i in range(2): + rid = a.start_run( + run_id=f"empty-{i}", seed=10 + i, + param_names=["p"], objective_labels=["o"], + ) + a.end_run(rid) + empty_ids.append(rid) + return full_rid, empty_ids + + +def test_clean_empty_runs_deletes_empty_and_keeps_full( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Normal mode: removes every completed-empty run, leaves populated ones.""" + full_rid, empty_ids = _seed_archive_with_mixed_runs(tmp_path) + + rc = inspect_archive.main([str(tmp_path), "--clean-empty-runs"]) + assert rc == 0 + err = capsys.readouterr().err + # stderr reports the deletion count and names the victims. + assert "deleted 2 empty run(s)" in err + for rid in empty_ids: + assert rid in err + + # Verify: only full-run remains in the archive. + from workflow_common.archive import ArchiveDB + with ArchiveDB(tmp_path / "archive.db", readonly=True) as a: + survivors = [r.run_id for r in a.list_runs()] + assert survivors == [full_rid] + + +def test_clean_empty_runs_dry_run_lists_without_deleting( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Dry run shows candidates on stderr but doesn't touch the DB.""" + full_rid, empty_ids = _seed_archive_with_mixed_runs(tmp_path) + + rc = inspect_archive.main( + [str(tmp_path), "--clean-empty-runs", "--dry-run"], + ) + assert rc == 0 + err = capsys.readouterr().err + assert "would delete 2 empty run(s)" in err + assert "dry run: no changes were made" in err + + # DB state unchanged. + from workflow_common.archive import ArchiveDB + with ArchiveDB(tmp_path / "archive.db", readonly=True) as a: + survivors = sorted(r.run_id for r in a.list_runs()) + assert survivors == sorted([full_rid] + empty_ids) + + +def test_clean_empty_runs_nothing_to_do( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """Empty-run-free archive yields a helpful 'nothing to do' message.""" + from workflow_common.archive import ArchiveDB, GeneRecord + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + rid = a.start_run( + run_id="only-real", seed=0, + param_names=["p"], objective_labels=["o"], + ) + a.record_generation( + rid, gen_idx=0, + genes=[GeneRecord( + run_id=rid, gen_idx=0, pop_idx=0, + birth_gen=0, birth_gene=0, + gene_vector=np.array([1.0]), + fitness=(0.1,), rank=0, + )], + stats={}, + ) + a.end_run(rid) + + rc = inspect_archive.main([str(tmp_path), "--clean-empty-runs"]) + assert rc == 0 + err = capsys.readouterr().err + assert "no empty runs eligible" in err + # Helper line pointing at --age-minutes 0 — discoverable UX. + assert "--age-minutes 0" in err + + +def test_clean_empty_runs_respects_age_minutes( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """A young, never-ended empty run is protected unless --age-minutes 0.""" + from workflow_common.archive import ArchiveDB + + db = tmp_path / "archive.db" + with ArchiveDB(db) as a: + # Young empty run — not completed, just started. + a.start_run(run_id="young-empty", seed=1, + param_names=["p"], objective_labels=["o"]) + + # Default age gate preserves it. + rc = inspect_archive.main([str(tmp_path), "--clean-empty-runs"]) + assert rc == 0 + err = capsys.readouterr().err + assert "no empty runs eligible" in err + + # --age-minutes 0 catches it. + rc = inspect_archive.main( + [str(tmp_path), "--clean-empty-runs", "--age-minutes", "0"], + ) + assert rc == 0 + err = capsys.readouterr().err + assert "deleted 1 empty run(s)" in err + assert "young-empty" in err + + +def test_clean_empty_runs_rejects_combination_with_pareto_only( + tmp_path: Path, capsys: pytest.CaptureFixture, +): + """--pareto-only + --clean-empty-runs is nonsense; error out.""" + _seed_archive_with_mixed_runs(tmp_path) + rc = inspect_archive.main( + [str(tmp_path), "--clean-empty-runs", "--pareto-only"], + ) + assert rc == 1 + err = capsys.readouterr().err + assert "--pareto-only" in err diff --git a/workflows/exaconstit-calibrate/tests/test_integration.py b/workflows/exaconstit-calibrate/tests/test_integration.py new file mode 100644 index 0000000..4ca168f --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_integration.py @@ -0,0 +1,513 @@ +""" +End-to-end integration tests for the workflow_common framework. + +Why these exist alongside the unit tests +---------------------------------------- +Unit tests cover each module in isolation. Integration tests here +cover the *interactions* between modules - the handoffs that are +easiest to get wrong and hardest to notice until a real run: + +* Does the PathResolver actually agree with the templates about where + options.toml should live? +* Does the manifest's SUBMITTED -> COMPLETED transition happen in the + right order relative to the sentinel write? +* Does the ResultReader read back data that the backend just finished + writing? +* Does a failed case get correctly recorded as FAILED in the manifest + AND have a sentinel that reflects the failure? + +Each test in this file runs the full chain from "I have a parameter +dict" to "I have a DataFrame of stress-strain data". They are slower +than unit tests (one subprocess per case) but still fast enough - +tens of milliseconds each - to run every commit. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import List, Tuple + +import pandas as pd +import pytest + +from workflow_common import ( + CaseContext, + CaseLayout, + CaseState, + LocalBackend, + Manifest, + ManifestEntry, + Sentinel, + SimJobSpec, + TemplatePathResolver, + TextTableReader, + TextTableSpec, + configure_logging, + render_template_file, + write_sentinel, +) +from workflow_common.backends.base import JobOutcome +from workflow_common.sentinel import ( + is_case_complete, + read_sentinel, + validate_outputs, +) + + +# --- Test helpers the integration tests share ---------------------------- + + +def _build_resolver(workspace: Path) -> TemplatePathResolver: + """The resolver used by the integration tests. + + Matches the ExaConstit default output layout convention: + ``/wf/gen_N/gene_N_obj_N/results/options/...``. + """ + return TemplatePathResolver( + working_dir_pattern="wf/gen_{generation}/gene_{gene}_obj_{obj}", + output_file_patterns={ + "avg_stress": "{working_dir}/results/options/avg_stress.txt", + "avg_def_grad": "{working_dir}/results/options/avg_def_grad.txt", + }, + root=workspace, + ) + + +def _build_reader() -> TextTableReader: + """Reader matching the fake binary's output schema.""" + return TextTableReader({ + "avg_stress": TextTableSpec( + columns=["Time", "Volume", "Sxx", "Syy", "Szz", "Sxy", "Sxz", "Syz"], + required=True, + ), + "avg_def_grad": TextTableSpec( + columns=[ + "Time", "Volume", "F11", "F12", "F13", "F21", "F22", "F23", "F31", "F32", "F33", + ], + required=False, + ), + }) + + +def _prepare_case( + resolver: TemplatePathResolver, + master_template: Path, + ctx: CaseContext, + params: dict, +) -> Tuple[CaseLayout, SimJobSpec, Path]: + """Render per-case input and build a SimJobSpec. + + Returns a tuple of (layout, spec, working_dir) so tests have + direct handles to the working dir without re-resolving. + """ + wd = resolver.working_dir(ctx) + wd.mkdir(parents=True, exist_ok=True) + # Values fed into the template. The template uses %%key%% placeholders + # so the keys must match the placeholder names. + values = { + "gene": ctx.gene, + "obj": ctx.obj, + "strain_rate": params.get("strain_rate", 1e-3), + "yield_stress": params.get("yield_stress", 200.0), + "hardening": params.get("hardening", 2000.0), + "temp_k": params.get("temp_k", 298.0), + } + render_template_file(master_template, wd / "options.toml", values) + layout = CaseLayout(ctx=ctx, resolver=resolver) + return layout, None, wd # spec built per-test below + + +def _run_full_case( + *, + ctx: CaseContext, + params: dict, + workspace: Path, + fake_binary: Path, + master_template: Path, + resolver: TemplatePathResolver, + manifest: Manifest, + backend: LocalBackend, + reader: TextTableReader, + required_outputs: List[str], +) -> Tuple[JobOutcome, pd.DataFrame | None]: + """Run one case end-to-end and return (outcome, stress DataFrame). + + This function is the minimum responsible driver - it does every + step a production driver would: + + 1. Render input files. + 2. Record SUBMITTED in the manifest. + 3. Run the simulation. + 4. Validate outputs. + 5. Write sentinel (atomic). + 6. Record terminal manifest entry. + 7. Read results. + """ + layout, _, wd = _prepare_case(resolver, master_template, ctx, params) + spec = SimJobSpec( + working_dir=wd, + binary=fake_binary, + args=(), + num_tasks=1, + duration_s=30, + stdout="run.out", + stderr="run.err", + tag=f"g{ctx.gene}o{ctx.obj}", + ) + + # Manifest: SUBMITTED before the backend gets the spec, so a crash + # during submission is recoverable. + manifest.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(wd), + )) + + # Backend: run the subprocess. + result = backend.submit_one(spec) + + # Output validation: catches rc=0 with missing files (not an issue + # with the fake binary but would be with a killed real one). + ok, bad = validate_outputs(wd, required_outputs) + terminal = ( + CaseState.COMPLETED + if (result.outcome == JobOutcome.OK and ok) + else CaseState.FAILED + ) + + # Sentinel FIRST, manifest SECOND. See the architecture doc for why. + write_sentinel(wd, Sentinel( + rc=result.rc, + wall_time_s=result.wall_time_s, + jobid=result.jobid, + output_files={n: str(layout.output_file(n)) for n in reader.specs}, + status="ok" if terminal == CaseState.COMPLETED else "bad", + message=result.error_message if result.error_message else None, + )) + manifest.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=terminal, rc=result.rc, jobid=result.jobid, + case_dir=str(wd), + )) + + # Results: read what the simulation produced. Only attempt if the + # case terminated successfully; failed cases may have missing or + # truncated files which the reader will reject. + df = None + if terminal == CaseState.COMPLETED: + rs = reader.read(layout) + df = rs.df("avg_stress") + return terminal, df + + +# --- Integration tests --------------------------------------------------- + + +def test_happy_path_full_pipeline( + workspace: Path, fake_binary: Path, master_template: Path +): + """Render -> run -> validate -> sentinel -> manifest -> read, all clean.""" + configure_logging(level="warning", stream=False) + resolver = _build_resolver(workspace) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + backend = LocalBackend(max_workers=1) + reader = _build_reader() + + terminal, df = _run_full_case( + ctx=CaseContext(generation=0, gene=0, obj=0), + params={"strain_rate": 1e-3, "yield_stress": 200.0, "hardening": 2000.0}, + workspace=workspace, fake_binary=fake_binary, + master_template=master_template, resolver=resolver, + manifest=manifest, backend=backend, reader=reader, + required_outputs=["results/options/avg_stress.txt"], + ) + + assert terminal == CaseState.COMPLETED + assert df is not None + # Fake binary writes 50 rows of saturating Voce-like response. + # At t=1 with strain_rate=1e-3, eps_final = 1e-3 and the argument of + # the exponential is 50*1e-3 = 0.05, so 1-exp(-0.05) ~= 0.0488. + # s11_final ~= yield + hardening * 0.0488 = 200 + 2000*0.0488 ~= 297.6. + assert len(df) == 50 + assert df["Szz"].iloc[0] == pytest.approx(200.0, abs=1.0) + # Monotonically increasing (hardening response). + assert (df["Szz"].diff().dropna() > 0).all() + # Final value in the expected range. + assert 290.0 < df["Szz"].iloc[-1] < 310.0 + + +def test_failed_case_recorded_as_failed( + workspace: Path, fake_binary: Path, master_template: Path +): + """A simulation that exits nonzero -> FAILED in manifest + sentinel.""" + configure_logging(level="warning", stream=False) + resolver = _build_resolver(workspace) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + backend = LocalBackend(max_workers=1) + reader = _build_reader() + + os.environ["FAKE_FAIL"] = "1" + try: + terminal, df = _run_full_case( + ctx=CaseContext(generation=0, gene=0, obj=0), + params={}, + workspace=workspace, fake_binary=fake_binary, + master_template=master_template, resolver=resolver, + manifest=manifest, backend=backend, reader=reader, + required_outputs=["results/options/avg_stress.txt"], + ) + finally: + os.environ.pop("FAKE_FAIL", None) + + assert terminal == CaseState.FAILED + assert df is None # reader never called for failed cases + + # The manifest should report FAILED. + entry = manifest.get(0, 0, 0) + assert entry is not None + assert entry.state == CaseState.FAILED + assert entry.rc == 7 + + # The sentinel should exist but indicate failure. + wd = resolver.working_dir(CaseContext(0, 0, 0)) + sen = read_sentinel(wd) + assert sen is not None + assert sen.rc == 7 + assert sen.status == "bad" + + +def test_truncated_output_caught_by_validator( + workspace: Path, fake_binary: Path, master_template: Path +): + """rc=0 with a truncated avg_stress still marked FAILED. + + The fake binary honors FAKE_TRUNCATE=1 to drop the last 10 rows. + The validator only checks size-not-zero, so a truncated-but-nonempty + file slips through validate_outputs. This test then demonstrates + the NEXT line of defense: the TextTableReader's column-count check + catches format drift, and a stricter validator (the caller's + responsibility) could count rows. + """ + configure_logging(level="warning", stream=False) + resolver = _build_resolver(workspace) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + backend = LocalBackend(max_workers=1) + reader = _build_reader() + + os.environ["FAKE_TRUNCATE"] = "1" + try: + terminal, df = _run_full_case( + ctx=CaseContext(generation=0, gene=0, obj=0), + params={}, + workspace=workspace, fake_binary=fake_binary, + master_template=master_template, resolver=resolver, + manifest=manifest, backend=backend, reader=reader, + required_outputs=["results/options/avg_stress.txt"], + ) + finally: + os.environ.pop("FAKE_TRUNCATE", None) + + # Size > 0 so validator passes. Reader then gets the short data. + assert terminal == CaseState.COMPLETED + assert df is not None + # Truncated: should have 40 rows instead of 50. + assert len(df) == 40 + + +def test_missing_optional_output_reported( + workspace: Path, fake_binary: Path, master_template: Path +): + """An absent optional output is collected but does not fail the run.""" + configure_logging(level="warning", stream=False) + resolver = _build_resolver(workspace) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + backend = LocalBackend(max_workers=1) + reader = _build_reader() + + os.environ["FAKE_MISSING"] = "avg_def_grad" + try: + layout_ctx = CaseContext(generation=0, gene=0, obj=0) + terminal, df = _run_full_case( + ctx=layout_ctx, params={}, + workspace=workspace, fake_binary=fake_binary, + master_template=master_template, resolver=resolver, + manifest=manifest, backend=backend, reader=reader, + required_outputs=["results/options/avg_stress.txt"], + ) + finally: + os.environ.pop("FAKE_MISSING", None) + + # The required output is present so the case is COMPLETED. + assert terminal == CaseState.COMPLETED + # A re-read shows avg_def_grad in the missing list. + layout = CaseLayout(ctx=layout_ctx, resolver=resolver) + rs = reader.read(layout) + assert "avg_stress" in rs + assert "avg_def_grad" in rs.missing + assert not bool(rs) + + +def test_parallel_run_produces_independent_outputs( + workspace: Path, fake_binary: Path, master_template: Path +): + """Two parallel cases write to distinct dirs without cross-contamination.""" + configure_logging(level="warning", stream=False) + resolver = _build_resolver(workspace) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + backend = LocalBackend(max_workers=3) + reader = _build_reader() + + # Build 6 cases with different strain rates. + contexts = [ + CaseContext(generation=0, gene=g, obj=o) + for g in range(3) for o in range(2) + ] + specs: List[SimJobSpec] = [] + for ctx in contexts: + layout, _, wd = _prepare_case( + resolver, master_template, ctx, + {"strain_rate": 10 ** (-3 - ctx.obj)}, + ) + specs.append(SimJobSpec( + working_dir=wd, binary=fake_binary, args=(), + duration_s=30, stdout="run.out", stderr="run.err", + )) + manifest.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(wd), + )) + + # Submit everything. + results = backend.submit_batch(specs) + assert len(results) == 6 + for r in results: + assert r.outcome == JobOutcome.OK + + # Sentinel and manifest for each. + for ctx, spec in zip(contexts, specs): + write_sentinel(spec.working_dir, Sentinel(rc=0, wall_time_s=1.0)) + manifest.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.COMPLETED, rc=0, case_dir=str(spec.working_dir), + )) + + # Each case should have its own output with the expected strain rate. + for ctx in contexts: + layout = CaseLayout(ctx=ctx, resolver=resolver) + rs = reader.read(layout) + assert "avg_stress" in rs + # Fake binary uses strain_rate=1e-(3+obj); at t=1, the + # exponent argument is 50*strain_rate which determines saturation. + # Just sanity-check shape and nonzero values. + df = rs.df("avg_stress") + assert len(df) == 50 + assert df["Szz"].iloc[-1] > df["Szz"].iloc[0] + + +def test_integration_manifest_snapshot_and_reload( + workspace: Path, fake_binary: Path, master_template: Path +): + """Snapshot + reload round trip preserves all state from a real run.""" + configure_logging(level="warning", stream=False) + resolver = _build_resolver(workspace) + manifest_path = workspace / "manifest.jsonl" + manifest = Manifest(manifest_path) + manifest.load() + backend = LocalBackend(max_workers=2) + reader = _build_reader() + + contexts = [CaseContext(generation=0, gene=g, obj=0) for g in range(4)] + for ctx in contexts: + _run_full_case( + ctx=ctx, params={}, + workspace=workspace, fake_binary=fake_binary, + master_template=master_template, resolver=resolver, + manifest=manifest, backend=backend, reader=reader, + required_outputs=["results/options/avg_stress.txt"], + ) + + manifest.snapshot() + + # Load from scratch in a new Manifest. + m2 = Manifest(manifest_path) + m2.load() + # All four cases should be COMPLETED in the reloaded manifest. + for ctx in contexts: + e = m2.get(ctx.generation, ctx.gene, ctx.obj) + assert e is not None + assert e.state == CaseState.COMPLETED + + +# --- Relative-workspace coverage (audit Finding 7) ----------------------- + + +@pytest.mark.parametrize("workspace_style", ["absolute", "relative"]) +def test_happy_path_works_with_absolute_and_relative_workspace( + tmp_path: Path, + fake_binary: Path, + master_template: Path, + monkeypatch: pytest.MonkeyPatch, + workspace_style: str, +): + """Regression coverage for the "relative WORKSPACE" bug class. + + Users naturally write ``WORKSPACE = Path("./calibration_run")`` in + their drivers, but every existing integration test uses the + absolute ``tmp_path`` fixture. That coverage gap is exactly how + the double-join bug from earlier in the audit reached production: + the join chain ``working_dir/required_output`` produced a + relative path, and ``validate_outputs`` joined against + ``working_dir`` a second time, giving ``//...``. Tests + on absolute-tmpdir workspaces never saw this because the first + join produced an absolute path and the second was a no-op. + + This parameterized test runs the happy-path pipeline under both + workspace styles. The absolute case is redundant with + ``test_happy_path_full_pipeline`` above; we keep it anyway so + the two branches sit right next to each other and the test + serves as a clear reference for the bug class. + """ + configure_logging(level="warning", stream=False) + + # Build the workspace according to the requested style. + if workspace_style == "absolute": + workspace = tmp_path / "wf_abs" + workspace.mkdir() + else: + # Run from tmp_path so the relative path is scoped to this + # test and doesn't leak into the repo. + monkeypatch.chdir(tmp_path) + workspace = Path("wf_rel") + workspace.mkdir() + assert not workspace.is_absolute(), ( + "sanity: workspace must be relative for the relative branch" + ) + + resolver = _build_resolver(workspace) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + backend = LocalBackend(max_workers=1) + reader = _build_reader() + + terminal, df = _run_full_case( + ctx=CaseContext(generation=0, gene=0, obj=0), + params={"strain_rate": 1e-3, "yield_stress": 200.0, "hardening": 2000.0}, + workspace=workspace, fake_binary=fake_binary, + master_template=master_template, resolver=resolver, + manifest=manifest, backend=backend, reader=reader, + required_outputs=["results/options/avg_stress.txt"], + ) + + # Full pipeline must succeed regardless of workspace shape. + assert terminal == CaseState.COMPLETED + assert df is not None + assert len(df) == 50 + # Load direction is z; Szz is the active component. Same physics + # assertions as the absolute-only happy_path test. + assert df["Szz"].iloc[0] == pytest.approx(200.0, abs=1.0) + assert (df["Szz"].diff().dropna() > 0).all() + assert 290.0 < df["Szz"].iloc[-1] < 310.0 diff --git a/workflows/exaconstit-calibrate/tests/test_integration_restart.py b/workflows/exaconstit-calibrate/tests/test_integration_restart.py new file mode 100644 index 0000000..7a22005 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_integration_restart.py @@ -0,0 +1,292 @@ +""" +Restart-scenario integration tests. + +These simulate the workflow the framework is specifically designed +for: a long-running optimization where the HPC allocation dies in +the middle, and the user restarts the driver. The restart must: + +* Skip cases that completed successfully (sentinel present). +* Rerun cases that were in-flight when the driver died (SUBMITTED + with no terminal transition -> INTERRUPTED on reload). +* Not re-attempt cases that are terminal FAILED by policy (unless + the caller's policy says to - here we demonstrate the default + "leave failed cases alone"). + +Each test below emulates a crash by simply abandoning a Manifest +object partway through a run, then spinning up a new Manifest and +driving it forward. No actual process kill is necessary - the +crash-safety guarantees live entirely in the on-disk state (JSONL +manifest + per-case sentinels). +""" +from __future__ import annotations + +from pathlib import Path +from typing import List + +import pytest + +from workflow_common import ( + CaseContext, + CaseLayout, + CaseState, + LocalBackend, + Manifest, + ManifestEntry, + Sentinel, + SimJobSpec, + TemplatePathResolver, + TextTableReader, + TextTableSpec, + render_template_file, + write_sentinel, +) +from workflow_common.backends.base import JobOutcome +from workflow_common.sentinel import is_case_complete, read_sentinel + + +def _setup(workspace: Path): + """Build the resolver, reader, and manifest path used across tests.""" + resolver = TemplatePathResolver( + working_dir_pattern="wf/gen_{generation}/gene_{gene}_obj_{obj}", + output_file_patterns={ + "avg_stress": "{working_dir}/results/options/avg_stress.txt", + }, + root=workspace, + ) + reader = TextTableReader({ + "avg_stress": TextTableSpec( + columns=["Time", "Volume", "Sxx", "Syy", "Szz", "Sxy", "Sxz", "Syz"], + required=True, + ), + }) + manifest_path = workspace / "manifest.jsonl" + return resolver, reader, manifest_path + + +def _render_and_spec( + ctx: CaseContext, + resolver: TemplatePathResolver, + master_template: Path, + fake_binary: Path, +) -> SimJobSpec: + wd = resolver.working_dir(ctx) + wd.mkdir(parents=True, exist_ok=True) + render_template_file( + master_template, wd / "options.toml", + {"gene": ctx.gene, "obj": ctx.obj, + "strain_rate": 1e-3, "yield_stress": 200.0, + "hardening": 2000.0, "temp_k": 298.0}, + ) + return SimJobSpec( + working_dir=wd, binary=fake_binary, args=(), + duration_s=30, stdout="run.out", stderr="run.err", + tag=f"g{ctx.gene}o{ctx.obj}", + ) + + +def test_restart_skips_completed_cases( + workspace: Path, fake_binary: Path, master_template: Path +): + """Cases with a valid sentinel on disk are skipped on restart.""" + resolver, reader, manifest_path = _setup(workspace) + backend = LocalBackend(max_workers=2) + + # --- First "allocation": complete two cases, then "crash". --------- + m1 = Manifest(manifest_path) + m1.load() + + contexts = [CaseContext(generation=0, gene=i, obj=0) for i in range(4)] + # Run cases 0 and 1 fully through. + for ctx in contexts[:2]: + spec = _render_and_spec(ctx, resolver, master_template, fake_binary) + m1.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(spec.working_dir), + )) + res = backend.submit_one(spec) + assert res.outcome == JobOutcome.OK + write_sentinel(spec.working_dir, Sentinel(rc=0, wall_time_s=res.wall_time_s)) + m1.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.COMPLETED, rc=0, case_dir=str(spec.working_dir), + )) + + # Case 2 is submitted but "dies" before producing a sentinel. We + # simulate this by recording SUBMITTED but not actually running. + ctx = contexts[2] + spec = _render_and_spec(ctx, resolver, master_template, fake_binary) + m1.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(spec.working_dir), + )) + # Case 3 was never even submitted. + + # --- "Allocation dies." Drop m1, spin up m2 fresh. ----------------- + m2 = Manifest(manifest_path) + m2.load() + n_interrupted = m2.mark_submitted_as_interrupted() + # Exactly one case was stuck in SUBMITTED (case 2). + assert n_interrupted == 1 + + # --- Restart logic: for each context, skip if sentinel present. ---- + skipped: List[int] = [] + rerun: List[int] = [] + for ctx in contexts: + wd = resolver.working_dir(ctx) + if is_case_complete(wd): + skipped.append(ctx.gene) + continue + rerun.append(ctx.gene) + spec = _render_and_spec(ctx, resolver, master_template, fake_binary) + m2.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(spec.working_dir), + )) + res = backend.submit_one(spec) + assert res.outcome == JobOutcome.OK + write_sentinel(spec.working_dir, Sentinel(rc=0, wall_time_s=res.wall_time_s)) + m2.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.COMPLETED, rc=0, case_dir=str(spec.working_dir), + )) + + # Cases 0 and 1 were completed; cases 2 and 3 needed to run now. + assert skipped == [0, 1] + assert rerun == [2, 3] + + # Final manifest: every case should be COMPLETED. + for ctx in contexts: + e = m2.get(ctx.generation, ctx.gene, ctx.obj) + assert e is not None + assert e.state == CaseState.COMPLETED + + +def test_crash_between_sentinel_and_manifest_is_safe( + workspace: Path, fake_binary: Path, master_template: Path +): + """The sentinel-first-then-manifest ordering is crash-safe. + + Simulate: case finishes, sentinel is written, BUT the driver dies + before it can record the terminal manifest entry. On restart, + the sentinel is the authoritative signal - the case is treated + as complete even though the manifest still says SUBMITTED. + """ + resolver, reader, manifest_path = _setup(workspace) + backend = LocalBackend(max_workers=1) + + ctx = CaseContext(generation=0, gene=0, obj=0) + spec = _render_and_spec(ctx, resolver, master_template, fake_binary) + + m1 = Manifest(manifest_path) + m1.load() + m1.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(spec.working_dir), + )) + res = backend.submit_one(spec) + assert res.outcome == JobOutcome.OK + + # Write sentinel, but "die" before recording COMPLETED. + write_sentinel(spec.working_dir, Sentinel(rc=0, wall_time_s=res.wall_time_s)) + + # --- Restart: sentinel present, manifest says SUBMITTED. ---------- + m2 = Manifest(manifest_path) + m2.load() + n = m2.mark_submitted_as_interrupted() + # Because we did NOT record COMPLETED, the manifest entry is still + # SUBMITTED, so it gets promoted to INTERRUPTED. But the sentinel + # overrides this - our skip logic relies on sentinel presence, not + # manifest state. + assert n == 1 + assert m2.get(0, 0, 0).state == CaseState.INTERRUPTED + + # The correct restart policy: trust the sentinel. + assert is_case_complete(spec.working_dir) + sen = read_sentinel(spec.working_dir) + assert sen.rc == 0 + + +def test_interrupted_case_can_be_rerun_cleanly( + workspace: Path, fake_binary: Path, master_template: Path +): + """A case marked INTERRUPTED can be cleared and rerun from scratch.""" + resolver, reader, manifest_path = _setup(workspace) + backend = LocalBackend(max_workers=1) + + ctx = CaseContext(generation=0, gene=0, obj=0) + spec = _render_and_spec(ctx, resolver, master_template, fake_binary) + layout = CaseLayout(ctx=ctx, resolver=resolver) + + # Simulate a partial run: the SUBMITTED entry exists but no sentinel. + m1 = Manifest(manifest_path) + m1.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(spec.working_dir), + )) + + # Restart: promote to INTERRUPTED. + m2 = Manifest(manifest_path) + m2.load() + m2.mark_submitted_as_interrupted() + assert m2.get(0, 0, 0).state == CaseState.INTERRUPTED + + # Re-run logic: clear any partial outputs, then submit fresh. + layout.clear_outputs() # idempotent even if none exist + m2.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(spec.working_dir), + )) + res = backend.submit_one(spec) + assert res.outcome == JobOutcome.OK + write_sentinel(spec.working_dir, Sentinel(rc=0, wall_time_s=res.wall_time_s)) + m2.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.COMPLETED, rc=0, case_dir=str(spec.working_dir), + )) + + # Now the case is complete. Read its outputs to verify. + rs = reader.read(layout) + assert "avg_stress" in rs + assert len(rs.df("avg_stress")) == 50 + + +def test_snapshot_survives_simulated_crash( + workspace: Path, fake_binary: Path, master_template: Path +): + """A snapshot written before the crash is honored on reload.""" + resolver, reader, manifest_path = _setup(workspace) + backend = LocalBackend(max_workers=1) + + m1 = Manifest(manifest_path) + m1.load() + contexts = [CaseContext(generation=0, gene=i, obj=0) for i in range(3)] + + # Complete every case, take snapshot. + for ctx in contexts: + spec = _render_and_spec(ctx, resolver, master_template, fake_binary) + m1.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(spec.working_dir), + )) + res = backend.submit_one(spec) + write_sentinel(spec.working_dir, Sentinel(rc=0, wall_time_s=res.wall_time_s)) + m1.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.COMPLETED, rc=0, case_dir=str(spec.working_dir), + )) + m1.snapshot() + + # Simulate a torn-write on the JSONL log by appending garbage. + # This is what a kill-mid-write would produce. + with open(manifest_path, "a") as f: + f.write('{"generation": 0, "gene": 99, "obj": 0, "state": "sub') # truncated + + # Reload: the snapshot is authoritative, the torn line is dropped. + m2 = Manifest(manifest_path) + m2.load() + for ctx in contexts: + e = m2.get(ctx.generation, ctx.gene, ctx.obj) + assert e is not None + assert e.state == CaseState.COMPLETED + # The garbage entry did not materialize into a phantom case. + assert m2.get(0, 99, 0) is None diff --git a/workflows/exaconstit-calibrate/tests/test_integration_smoothing.py b/workflows/exaconstit-calibrate/tests/test_integration_smoothing.py new file mode 100644 index 0000000..b52bb30 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_integration_smoothing.py @@ -0,0 +1,410 @@ +""" +Integration tests that exercise the smoothing layer alongside the +rest of the framework. + +Why these tests matter +---------------------- +The smoothing layer is the last piece before error-metric computation +in a real ExaConstit optimization. These integration tests verify +that the output of the :class:`TextTableReader` can be fed directly +into a :class:`Smoother` and produce sensible, comparable curves. +They also exercise the "normalize scales using experimental ranges" +pattern that is essential for arc-length comparisons between sim +and exp. + +What a production ObjectiveEvaluator (step 6) will do internally +----------------------------------------------------------------- +The pattern demonstrated here is exactly what an ObjectiveEvaluator +in step 6 needs to do internally:: + + 1. Load experimental data once at the top of the run. + 2. For each simulated case: + a. Read simulation output via TextTableReader. + b. Extract strain and stress columns from the DataFrame. + c. Choose the right smoother (auto_smoother, or + PchipSmoother for monotonic sweep-style runs). + d. Smooth both sim and exp onto a common grid. + e. Compute an RMSE or similar error. + 3. Return errors keyed by (gene, obj). + +The tests below walk through each of those steps with real +subprocesses and real output files, so any regression in the +handoffs between modules surfaces immediately. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Tuple + +import numpy as np +import pandas as pd +import pytest + +from workflow_common import ( + ArcLengthSmoother, + CaseContext, + CaseLayout, + LocalBackend, + Manifest, + ManifestEntry, + CaseState, + PchipSmoother, + Sentinel, + SimJobSpec, + TemplatePathResolver, + TextTableReader, + TextTableSpec, + auto_smoother, + is_monotonic, + load_experimental_csv, + render_template_file, + write_sentinel, +) +from workflow_common.backends.base import JobOutcome + + +# --- Fixtures specific to the smoothing integration tests ---------------- + + +@pytest.fixture +def experimental_stress_strain(workspace: Path) -> pd.DataFrame: + """Generate a synthetic experimental reference dataset. + + Saturating Voce-law response with the same character as what the + fake binary produces, but slightly different parameters - so + there is a nonzero error the smoothing pipeline should be able + to quantify. Stored in the workspace as a CSV so the test + exercises ``load_experimental_csv`` too. + + Note: the saturation constant (50) matches the fake binary's so + sim and exp curves have the same shape; only the yield stress, + hardening modulus, and saturation rate differ slightly. That + produces a realistic small-ish RMSE on aligned curves. + """ + strain = np.linspace(0.0, 1.0, 40) + # Reference "truth" params slightly different from what the fake + # binary will be told to use - so sim and exp don't exactly match. + yield_stress = 210.0 + hardening = 1900.0 + # Saturation constant 48 (vs the binary's 50) -> curves are + # the same shape with slightly offset saturation rate. + stress = yield_stress + hardening * (1 - np.exp(-48.0 * strain)) + df = pd.DataFrame({"strain": strain, "stress": stress}) + # Round-trip through disk to mimic a real workflow. + path = workspace / "exp_reference.csv" + df.to_csv(path, index=False) + return load_experimental_csv(path, delimiter=",") + + +def _setup_common(workspace: Path): + """Shared setup shared with other integration tests.""" + resolver = TemplatePathResolver( + working_dir_pattern="wf/gen_{generation}/gene_{gene}_obj_{obj}", + output_file_patterns={ + "avg_stress": "{working_dir}/results/options/avg_stress.txt", + "avg_def_grad": "{working_dir}/results/options/avg_def_grad.txt", + }, + root=workspace, + ) + reader = TextTableReader({ + "avg_stress": TextTableSpec( + columns=["Time", "Volume", "Sxx", "Syy", "Szz", "Sxy", "Sxz", "Syz"], + required=True, + ), + "avg_def_grad": TextTableSpec( + columns=[ + "Time", "Volume", "F11", "F12", "F13", "F21", "F22", "F23", "F31", "F32", "F33", + ], + required=False, + ), + }) + return resolver, reader + + +def _run_case( + workspace: Path, + resolver: TemplatePathResolver, + master_template: Path, + fake_binary: Path, + ctx: CaseContext, + params: dict, + backend: LocalBackend, + manifest: Manifest, +) -> Path: + """Render, submit, and finalize one case. Returns the working directory.""" + wd = resolver.working_dir(ctx) + wd.mkdir(parents=True, exist_ok=True) + render_template_file( + master_template, wd / "options.toml", + {"gene": ctx.gene, "obj": ctx.obj, **params}, + ) + spec = SimJobSpec( + working_dir=wd, binary=fake_binary, args=(), + duration_s=30, stdout="run.out", stderr="run.err", + ) + manifest.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.SUBMITTED, case_dir=str(wd), + )) + res = backend.submit_one(spec) + assert res.outcome == JobOutcome.OK, f"sim failed: {res.error_message}" + write_sentinel(wd, Sentinel(rc=0, wall_time_s=res.wall_time_s)) + manifest.record(ManifestEntry( + generation=ctx.generation, gene=ctx.gene, obj=ctx.obj, + state=CaseState.COMPLETED, rc=0, case_dir=str(wd), + )) + return wd + + +# --- Integration tests --------------------------------------------------- + + +def test_pchip_smooths_sim_output_onto_exp_grid( + workspace: Path, fake_binary: Path, master_template: Path, + experimental_stress_strain: pd.DataFrame, +): + """Full pipeline: sim -> read -> smooth onto experimental strain grid.""" + resolver, reader = _setup_common(workspace) + backend = LocalBackend(max_workers=1) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + + ctx = CaseContext(generation=0, gene=0, obj=0) + _run_case( + workspace, resolver, master_template, fake_binary, ctx, + # strain_rate=1.0 so sim_strain = 1.0 * time ranges over [0, 1], + # matching the experimental strain range. In a real ExaConstit + # run the user aligns these by choosing t_final appropriately; + # here we control both the binary's strain-rate input and the + # experimental data, so we pick matching values directly. + {"strain_rate": 1.0, "yield_stress": 200.0, + "hardening": 2000.0, "temp_k": 298.0}, + backend, manifest, + ) + + # Step 1: read sim output. + layout = CaseLayout(ctx=ctx, resolver=resolver) + rs = reader.read(layout) + sim_df = rs.df("avg_stress") + + # Step 2: derive strain from time + strain_rate so we have a + # common axis with the experiment. In a real workflow this + # would come from the avg_def_grad file via F11 - 1. + sim_strain = 1.0 * sim_df["Time"].to_numpy() + sim_stress = sim_df["Szz"].to_numpy() + + # Sanity: sim is monotonic. + assert is_monotonic(sim_strain) + + # Step 3: smooth sim onto the experimental strain grid using PCHIP. + # We sample exactly at the experimental strain values so errors + # can be computed pointwise. + smoother = PchipSmoother(n_samples=200) + exp_strain = experimental_stress_strain["strain"].to_numpy() + exp_stress = experimental_stress_strain["stress"].to_numpy() + sim_on_exp = smoother.sample_at(sim_strain, sim_stress, exp_strain) + + # Pointwise aligned now: same length, same x. + assert sim_on_exp.x.shape == exp_strain.shape + assert np.allclose(sim_on_exp.x, exp_strain) + + # Step 4: RMSE is a small nonzero value (sim and exp intentionally + # have slightly different yield/hardening/saturation parameters). + # The bound is generous because the fake binary runs only 50 steps + # on a sharp-saturation curve, so transient-regime differences + # produce larger pointwise errors than a well-resolved real run would. + residual = sim_on_exp.y - exp_stress + rmse = float(np.sqrt(np.mean(residual ** 2))) + assert 0.0 < rmse < 200.0, f"rmse {rmse} outside sanity range" + + +def test_sim_and_exp_smoothed_onto_common_grid( + workspace: Path, fake_binary: Path, master_template: Path, + experimental_stress_strain: pd.DataFrame, +): + """A more realistic pattern: both curves smoothed, then compared. + + In real optimizations, the experimental curve is typically noisy + and should be smoothed just like the sim before comparison. + """ + resolver, reader = _setup_common(workspace) + backend = LocalBackend(max_workers=1) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + + ctx = CaseContext(generation=0, gene=0, obj=0) + _run_case( + workspace, resolver, master_template, fake_binary, ctx, + {"strain_rate": 1.0, "yield_stress": 200.0, + "hardening": 2000.0, "temp_k": 298.0}, + backend, manifest, + ) + + layout = CaseLayout(ctx=ctx, resolver=resolver) + rs = reader.read(layout) + sim_strain = 1.0 * rs.df("avg_stress")["Time"].to_numpy() + sim_stress = rs.df("avg_stress")["Szz"].to_numpy() + + exp_strain = experimental_stress_strain["strain"].to_numpy() + exp_stress = experimental_stress_strain["stress"].to_numpy() + + # Smooth both onto a common uniform strain grid. The sim-range is + # the authoritative upper bound since the experiment typically + # has more strain data than the sim does. + common_strain = np.linspace( + max(sim_strain.min(), exp_strain.min()), + min(sim_strain.max(), exp_strain.max()), + 100, + ) + smoother = PchipSmoother(n_samples=100) + sim_smooth = smoother.sample_at(sim_strain, sim_stress, common_strain) + exp_smooth = smoother.sample_at(exp_strain, exp_stress, common_strain) + + # Aligned and comparable. + assert np.allclose(sim_smooth.x, exp_smooth.x) + rmse = float(np.sqrt(np.mean((sim_smooth.y - exp_smooth.y) ** 2))) + assert rmse > 0 + # Smoothed residual should be bounded by the signal range. + sig_range = float(max(exp_stress.max(), sim_stress.max()) - + min(exp_stress.min(), sim_stress.min())) + assert rmse < sig_range + + +def test_arc_length_with_shared_scales_matches_arc_sample_indices( + workspace: Path, fake_binary: Path, master_template: Path, + experimental_stress_strain: pd.DataFrame, +): + """Sim and exp arc-length smoothers using the SAME scales agree index-wise. + + This is the subtle detail that the architecture doc calls out: + when comparing sim to exp by arc length, both smoothers must use + the SAME normalization scales (typically the experimental ranges) + so that the i-th arc-length sample of each curve corresponds to + the same fraction of the same reference path length. + """ + resolver, reader = _setup_common(workspace) + backend = LocalBackend(max_workers=1) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + + ctx = CaseContext(generation=0, gene=0, obj=0) + _run_case( + workspace, resolver, master_template, fake_binary, ctx, + {"strain_rate": 1.0, "yield_stress": 200.0, + "hardening": 2000.0, "temp_k": 298.0}, + backend, manifest, + ) + + layout = CaseLayout(ctx=ctx, resolver=resolver) + rs = reader.read(layout) + sim_strain = 1.0 * rs.df("avg_stress")["Time"].to_numpy() + sim_stress = rs.df("avg_stress")["Szz"].to_numpy() + exp_strain = experimental_stress_strain["strain"].to_numpy() + exp_stress = experimental_stress_strain["stress"].to_numpy() + + # Use experimental range as the common normalization. + x_scale = float(exp_strain.max() - exp_strain.min()) + y_scale = float(exp_stress.max() - exp_stress.min()) + + smoother = ArcLengthSmoother(n_samples=100, x_scale=x_scale, y_scale=y_scale) + sim_smooth = smoother.smooth(sim_strain, sim_stress) + exp_smooth = smoother.smooth(exp_strain, exp_stress) + + # Both curves have 100 samples. + assert len(sim_smooth.x) == 100 + assert len(exp_smooth.x) == 100 + + # The arc-length totals should be close (both curves trace similar + # paths in the normalized (strain, stress) box; the slight Voce + # parameter difference means they aren't identical). + ratio = sim_smooth.s[-1] / exp_smooth.s[-1] + assert 0.8 < ratio < 1.2, f"arc-length total ratio {ratio} out of range" + + +def test_auto_smoother_in_pipeline( + workspace: Path, fake_binary: Path, master_template: Path, +): + """auto_smoother picks PCHIP for our monotonic fake data.""" + resolver, reader = _setup_common(workspace) + backend = LocalBackend(max_workers=1) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + + ctx = CaseContext(generation=0, gene=0, obj=0) + _run_case( + workspace, resolver, master_template, fake_binary, ctx, + {"strain_rate": 1.0, "yield_stress": 200.0, + "hardening": 2000.0, "temp_k": 298.0}, + backend, manifest, + ) + + layout = CaseLayout(ctx=ctx, resolver=resolver) + rs = reader.read(layout) + sim_strain = 1.0 * rs.df("avg_stress")["Time"].to_numpy() + sim_stress = rs.df("avg_stress")["Szz"].to_numpy() + + # Let the framework pick. Fake data is monotonic -> PCHIP. + smoother = auto_smoother(sim_strain, sim_stress, n_samples=50) + # We don't assert the concrete class name here - the point of + # auto_smoother is to Just Work without the caller caring. But + # we do verify the output shape. + out = smoother.smooth(sim_strain, sim_stress) + assert len(out.x) == 50 + assert len(out.y) == 50 + + +def test_legacy_vs_pchip_on_real_sim_output( + workspace: Path, fake_binary: Path, master_template: Path, +): + """Migration check: legacy and PCHIP are close on real simulation output. + + If this test were to produce large differences, it would indicate + that switching to PCHIP midway through the refactor would change + objective values in a way a user might mistake for a real bug. + Verifying that they agree closely on monotonic data lets us + migrate the smoother in a separate step with confidence. + """ + from workflow_common import LegacyLinearSmoother + + resolver, reader = _setup_common(workspace) + backend = LocalBackend(max_workers=1) + manifest = Manifest(workspace / "manifest.jsonl") + manifest.load() + + ctx = CaseContext(generation=0, gene=0, obj=0) + _run_case( + workspace, resolver, master_template, fake_binary, ctx, + {"strain_rate": 1.0, "yield_stress": 200.0, + "hardening": 2000.0, "temp_k": 298.0}, + backend, manifest, + ) + + layout = CaseLayout(ctx=ctx, resolver=resolver) + rs = reader.read(layout) + x = 1e-3 * rs.df("avg_stress")["Time"].to_numpy() + y = rs.df("avg_stress")["Szz"].to_numpy() + + legacy = LegacyLinearSmoother(n_samples=200).smooth(x, y) + pchip = PchipSmoother(n_samples=200).smooth(x, y) + + # Grids should be identical. + assert np.allclose(legacy.x, pchip.x) + # Stress values should be close - but not identical, since PCHIP's + # cubic segments curve differently from the legacy linear + # connect-the-dots. The exact bound depends on how densely the + # source data samples the curve's knee; our fake binary runs only + # 50 steps on a sharp-saturation response, which is near the + # worst-case shape for legacy-vs-PCHIP divergence. Production + # ExaConstit runs typically use hundreds of steps and show + # sub-percent differences. + signal_range = float(y.max() - y.min()) + max_abs_diff = float(np.max(np.abs(legacy.y - pchip.y))) + rel_diff = max_abs_diff / signal_range + # This bound is deliberately loose. Tighten if you increase the + # fake binary's step count or use a gentler saturation rate. + assert rel_diff < 0.10, ( + f"legacy vs PCHIP disagreement {rel_diff:.4f} exceeds 10% of " + "signal range on a 50-point sharp-knee curve - surprising; " + "investigate before switching the default smoother." + ) diff --git a/workflows/exaconstit-calibrate/tests/test_local_backend.py b/workflows/exaconstit-calibrate/tests/test_local_backend.py new file mode 100644 index 0000000..1084936 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_local_backend.py @@ -0,0 +1,289 @@ +""" +Unit tests for :class:`workflow_common.backends.local.LocalBackend`. + +These use the ``fake_binary`` fixture from conftest to exercise the +real subprocess code path. We deliberately do NOT mock out +``subprocess.Popen`` because the subtleties the backend is trying to +handle - stdio redirection, timeouts, bad-binary-paths - all live +inside the subprocess module and are only visible with a real +invocation. +""" +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import List + +import pytest + +from workflow_common.backends.base import JobOutcome, JobResult, SimJobSpec +from workflow_common.backends.local import LocalBackend + + +def _spec( + workspace: Path, + fake_binary: Path, + *, + tag: str = "t", + duration_s: int = 30, + capture: bool = True, +) -> SimJobSpec: + """Build a minimal SimJobSpec aimed at the fake binary.""" + wd = workspace / f"case_{tag}" + wd.mkdir() + # The fake binary reads options.toml; give it a minimal one. + (wd / "options.toml").write_text( + 'basename = "options"\n' + "strain_rate = 1e-3\n" + "yield_stress = 200.0\n" + "hardening = 2000.0\n" + ) + return SimJobSpec( + working_dir=wd, + binary=Path(fake_binary), + args=(), + num_tasks=1, + duration_s=duration_s, + stdout="run.out" if capture else None, + stderr="run.err" if capture else None, + tag=tag, + ) + + +def test_local_serial_happy_path(workspace: Path, fake_binary: Path): + """max_workers=1 runs cases strictly in order and succeeds.""" + specs = [_spec(workspace, fake_binary, tag=f"{i}") for i in range(3)] + backend = LocalBackend(max_workers=1) + results = backend.submit_batch(specs) + assert len(results) == 3 + for r in results: + assert r.outcome == JobOutcome.OK + assert r.rc == 0 + # Output files should be present inside each working dir. + for spec in specs: + assert (spec.working_dir / "results" / "options" / "avg_stress.txt").exists() + + +def test_local_parallel_happy_path(workspace: Path, fake_binary: Path): + """max_workers>1 completes all cases and preserves submission-order output.""" + specs = [_spec(workspace, fake_binary, tag=f"{i}") for i in range(5)] + backend = LocalBackend(max_workers=3) + results = backend.submit_batch(specs) + assert len(results) == 5 + # submit_batch must return results in submission order even though + # they complete in arbitrary order internally. + for spec, result in zip(specs, results): + assert result.spec is spec + assert result.outcome == JobOutcome.OK + + +def test_local_stream_batch_yields_as_completed(workspace: Path, fake_binary: Path): + """stream_batch yields one JobResult per spec; each points to its input.""" + specs = [_spec(workspace, fake_binary, tag=f"{i}") for i in range(3)] + backend = LocalBackend(max_workers=2) + seen: List[JobResult] = list(backend.stream_batch(specs)) + assert len(seen) == 3 + # The set of returned specs must equal the set submitted. + assert {id(r.spec) for r in seen} == {id(s) for s in specs} + + +def test_local_failing_case_reports_failed(workspace: Path, fake_binary: Path): + """rc=7 from the fake binary -> JobOutcome.FAILED, rc preserved.""" + spec = _spec(workspace, fake_binary, tag="fail") + os.environ["FAKE_FAIL"] = "1" + try: + backend = LocalBackend(max_workers=1) + result = backend.submit_one(spec) + finally: + os.environ.pop("FAKE_FAIL", None) + assert result.outcome == JobOutcome.FAILED + assert result.rc == 7 + + +def test_local_bad_binary_reports_submit_error(workspace: Path): + """Nonexistent binary path -> SUBMIT_ERROR, not FAILED.""" + wd = workspace / "case" + wd.mkdir() + # Fake options.toml so the working directory has something. + (wd / "options.toml").write_text("") + spec = SimJobSpec( + working_dir=wd, + binary=Path("/no/such/binary"), + args=(), + duration_s=10, + ) + backend = LocalBackend(max_workers=1) + result = backend.submit_one(spec) + assert result.outcome == JobOutcome.SUBMIT_ERROR + assert result.error_message is not None + + +def test_local_timeout_kills_process(workspace: Path, fake_binary: Path): + """A spec with duration_s shorter than FAKE_SLEEP yields TIMEOUT.""" + spec = _spec(workspace, fake_binary, tag="timeout", duration_s=1) + os.environ["FAKE_SLEEP"] = "5" + try: + t0 = time.monotonic() + backend = LocalBackend(max_workers=1, kill_on_timeout=True) + result = backend.submit_one(spec) + elapsed = time.monotonic() - t0 + finally: + os.environ.pop("FAKE_SLEEP", None) + + assert result.outcome == JobOutcome.TIMEOUT + # Kill-on-timeout should interrupt well before the full 5s sleep. + # Give it a generous upper bound to avoid flakiness on slow CI. + assert elapsed < 4.5 + + +def test_local_stdio_files_are_written(workspace: Path, fake_binary: Path): + """stdout/stderr files requested in the spec actually get written.""" + spec = _spec(workspace, fake_binary, tag="stdio") + backend = LocalBackend(max_workers=1) + result = backend.submit_one(spec) + assert result.outcome == JobOutcome.OK + # The fake binary prints "fake sim ok" to stdout on success via our + # default. It doesn't actually do that currently; the stdout file + # should at least exist (possibly empty). + assert (spec.working_dir / "run.out").exists() + assert (spec.working_dir / "run.err").exists() + + +def test_local_rejects_invalid_workers(): + """max_workers < 1 is nonsensical and must raise.""" + with pytest.raises(ValueError): + LocalBackend(max_workers=0) + + +def test_local_empty_batch_is_noop(workspace: Path): + """submit_batch([]) returns [] without spinning up a pool.""" + backend = LocalBackend(max_workers=4) + assert backend.submit_batch([]) == [] + assert list(backend.stream_batch([])) == [] + + +def test_local_refuses_multi_rank_without_launcher(workspace: Path, fake_binary: Path): + """Requesting num_tasks > 1 with no MPI launcher is a user bug + (would silently drop ranks); the backend refuses at submit time + so the problem surfaces at the first case instead of after a + multi-day run produces single-rank results. + """ + backend = LocalBackend(max_workers=1) # no mpi_launcher + spec = SimJobSpec( + working_dir=workspace / "c", + binary=fake_binary, + args=(), + num_tasks=4, + duration_s=30, + ) + with pytest.raises(ValueError, match="num_tasks=4"): + backend.submit_one(spec) + + +def test_local_ranks_silent_allows_dropped_ranks(workspace: Path, fake_binary: Path): + """ranks_silent=True opt-in preserves the old behavior for users + who legitimately want to run a serial binary despite num_tasks > 1 + (e.g. using num_tasks only for bookkeeping). + """ + backend = LocalBackend(max_workers=1, ranks_silent=True) + spec = _spec(workspace, fake_binary, tag="serial_with_tasks") + # Override to request multi-rank while leaving everything else alone. + spec = SimJobSpec( + working_dir=spec.working_dir, + binary=spec.binary, + args=spec.args, + num_tasks=4, # would normally raise + duration_s=spec.duration_s, + stdout=spec.stdout, + stderr=spec.stderr, + tag=spec.tag, + ) + # Should not raise; the binary runs single-rank. + result = backend.submit_one(spec) + assert result.outcome == JobOutcome.OK + + +def test_local_uses_mpi_launcher_for_multi_rank(workspace: Path, tmp_path): + """When mpi_launcher is configured, the launcher is prepended + with the configured ntasks flag and the task count. + """ + backend = LocalBackend(mpi_launcher="mpirun") + spec = SimJobSpec( + working_dir=workspace / "c", + binary=Path("/usr/bin/echo"), + args=("hello",), + num_tasks=4, + ) + cmd = backend._build_cmd(spec) + assert cmd[:3] == ["mpirun", "-n", "4"] + assert cmd[3] == "/usr/bin/echo" + + +def test_local_custom_ntasks_flag(workspace: Path): + """mpi_launcher_ntasks_flag overrides the default '-n' for + launchers that need different syntax (jsrun '--nrs', lrun '-T'). + """ + backend = LocalBackend( + mpi_launcher="jsrun", mpi_launcher_ntasks_flag="--nrs", + ) + spec = SimJobSpec( + working_dir=workspace / "c", + binary=Path("/bin/true"), + args=(), + num_tasks=8, + ) + cmd = backend._build_cmd(spec) + assert cmd[:3] == ["jsrun", "--nrs", "8"] + + +def test_local_poll_stats_zero_before_any_jobs(workspace: Path): + """Fresh backend with no work: zero running, cores_total from OS.""" + backend = LocalBackend(max_workers=4) + stats = backend.poll_stats() + assert stats.running_jobs == 0 + assert stats.max_concurrent == 4 + assert stats.cores_in_use == 0 + # cores_total may be 0 on exotic kernels; >= 1 everywhere a test runs. + assert stats.cores_total >= 1 + + +def test_local_poll_stats_reports_running_jobs(workspace: Path, fake_binary: Path): + """poll_stats reflects in-flight jobs while stream_batch is pumping.""" + import threading + backend = LocalBackend(max_workers=2) + # Build four serial specs, each with a small sleep so poll_stats + # has a real window to observe in-flight work. + base_specs = [_spec(workspace, fake_binary, tag=f"p{i}") for i in range(4)] + specs = [ + SimJobSpec( + working_dir=s.working_dir, + binary=s.binary, + args=s.args, + env={"FAKE_SLEEP": "0.3"}, + num_tasks=1, + duration_s=s.duration_s, + stdout=s.stdout, + stderr=s.stderr, + tag=s.tag, + ) + for s in base_specs + ] + + seen_running = [] + + def poll(): + # Poll a few times during the batch run. + for _ in range(6): + seen_running.append(backend.poll_stats().running_jobs) + import time as _t; _t.sleep(0.1) + + t = threading.Thread(target=poll) + t.start() + results = list(backend.stream_batch(specs)) + t.join() + assert len(results) == 4 + # At some point during the run, at least one job was in flight. + assert max(seen_running) >= 1 + # After completion, everything is cleared. + assert backend.poll_stats().running_jobs == 0 diff --git a/workflows/exaconstit-calibrate/tests/test_manifest.py b/workflows/exaconstit-calibrate/tests/test_manifest.py new file mode 100644 index 0000000..9e42531 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_manifest.py @@ -0,0 +1,241 @@ +""" +Unit tests for :mod:`workflow_common.manifest` and +:mod:`workflow_common.sentinel`. + +These exercise the crash-safe state tracking that underpins restart. +Key scenarios: + +* Round-tripping state through the JSONL log. +* Snapshot compaction and partial replay. +* Survival of torn (malformed) trailing lines. +* Correct promotion of SUBMITTED -> INTERRUPTED on restart. +* Sentinel read/write/validation. + +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from workflow_common.manifest import ( + CaseState, + Manifest, + ManifestEntry, +) +from workflow_common.sentinel import ( + Sentinel, + clear_sentinel, + is_case_complete, + read_sentinel, + sentinel_path, + validate_outputs, + write_sentinel, +) + + +# --- Manifest ------------------------------------------------------------- + + +def test_manifest_record_and_get(workspace: Path): + """Recording an entry puts it in memory and on disk.""" + m = Manifest(workspace / "m.jsonl") + e = ManifestEntry( + generation=0, gene=1, obj=0, state=CaseState.SUBMITTED + ) + m.record(e) + retrieved = m.get(0, 1, 0) + assert retrieved is not None + assert retrieved.state == CaseState.SUBMITTED + + +def test_manifest_persists_across_reload(workspace: Path): + """A fresh Manifest instance loads the prior one's state.""" + path = workspace / "m.jsonl" + m = Manifest(path) + m.record(ManifestEntry(generation=0, gene=0, obj=0, state=CaseState.SUBMITTED)) + m.record(ManifestEntry(generation=0, gene=0, obj=0, state=CaseState.COMPLETED)) + + m2 = Manifest(path) + m2.load() + got = m2.get(0, 0, 0) + assert got is not None + assert got.state == CaseState.COMPLETED # latest entry wins + + +def test_manifest_snapshot_plus_new_entries(workspace: Path): + """Snapshot + subsequent appends reload correctly after restart.""" + path = workspace / "m.jsonl" + m = Manifest(path) + # Pre-snapshot work: + m.record(ManifestEntry(generation=0, gene=0, obj=0, state=CaseState.COMPLETED)) + m.record(ManifestEntry(generation=0, gene=1, obj=0, state=CaseState.COMPLETED)) + m.snapshot() + # Post-snapshot work: + m.record(ManifestEntry(generation=1, gene=0, obj=0, state=CaseState.SUBMITTED)) + + m2 = Manifest(path) + m2.load() + # All three cases should be present, snapshot entries plus post-snapshot. + assert m2.get(0, 0, 0).state == CaseState.COMPLETED + assert m2.get(0, 1, 0).state == CaseState.COMPLETED + assert m2.get(1, 0, 0).state == CaseState.SUBMITTED + + +def test_manifest_tolerates_torn_trailing_line(workspace: Path): + """A malformed final line should be dropped, not crash the load.""" + path = workspace / "m.jsonl" + m = Manifest(path) + m.record(ManifestEntry(generation=0, gene=0, obj=0, state=CaseState.COMPLETED)) + # Append a half-written line, mimicking a kill mid-write. + with open(path, "a") as f: + f.write('{"generation": 0, "gene": 1, "obj": 0, "sta') # no newline, truncated + + m2 = Manifest(path) + m2.load() + # The complete earlier entry must still be there. + assert m2.get(0, 0, 0).state == CaseState.COMPLETED + # The torn entry must be absent. + assert m2.get(0, 1, 0) is None + + +def test_mark_submitted_as_interrupted(workspace: Path): + """SUBMITTED entries without a terminal transition become INTERRUPTED.""" + m = Manifest(workspace / "m.jsonl") + m.record(ManifestEntry(generation=0, gene=0, obj=0, state=CaseState.SUBMITTED)) + m.record(ManifestEntry(generation=0, gene=1, obj=0, state=CaseState.COMPLETED)) + + # New Manifest -> load -> mark: the submitted-but-no-terminal gets + # promoted; the completed one is left alone. + m2 = Manifest(workspace / "m.jsonl") + m2.load() + n = m2.mark_submitted_as_interrupted() + assert n == 1 + assert m2.get(0, 0, 0).state == CaseState.INTERRUPTED + assert m2.get(0, 1, 0).state == CaseState.COMPLETED + + +def test_mark_submitted_as_interrupted_idempotent(workspace: Path): + """Calling mark_submitted_as_interrupted twice finds nothing new the 2nd time.""" + m = Manifest(workspace / "m.jsonl") + m.record(ManifestEntry(generation=0, gene=0, obj=0, state=CaseState.SUBMITTED)) + m.load() # Not strictly needed here but mirrors restart + assert m.mark_submitted_as_interrupted() == 1 + # Second call: no SUBMITTED entries remain. + assert m.mark_submitted_as_interrupted() == 0 + + +def test_manifest_filter_state(workspace: Path): + """filter_state yields only entries matching the requested state.""" + m = Manifest(workspace / "m.jsonl") + m.record(ManifestEntry(generation=0, gene=0, obj=0, state=CaseState.COMPLETED)) + m.record(ManifestEntry(generation=0, gene=1, obj=0, state=CaseState.FAILED)) + m.record(ManifestEntry(generation=0, gene=2, obj=0, state=CaseState.COMPLETED)) + + completed = list(m.filter_state(CaseState.COMPLETED)) + failed = list(m.filter_state(CaseState.FAILED)) + assert len(completed) == 2 + assert len(failed) == 1 + + +def test_case_state_terminal_property(): + """is_terminal should be True only for COMPLETED and FAILED.""" + assert CaseState.COMPLETED.is_terminal + assert CaseState.FAILED.is_terminal + assert not CaseState.PENDING.is_terminal + assert not CaseState.SUBMITTED.is_terminal + assert not CaseState.INTERRUPTED.is_terminal + + +def test_manifest_entry_json_round_trip(): + """to_json_line followed by from_dict(json.loads(...)) is an identity.""" + e = ManifestEntry( + generation=2, gene=3, obj=1, + state=CaseState.COMPLETED, + rc=0, jobid="f1A2B3", message="ok", case_dir="/tmp/x", + ) + serialized = e.to_json_line() + e2 = ManifestEntry.from_dict(json.loads(serialized)) + assert e2.generation == e.generation + assert e2.gene == e.gene + assert e2.obj == e.obj + assert e2.state == e.state + assert e2.rc == e.rc + assert e2.jobid == e.jobid + + +# --- Sentinel ------------------------------------------------------------- + + +def test_sentinel_write_read_round_trip(workspace: Path): + """Writing then reading should recover every field.""" + case_dir = workspace / "case" + case_dir.mkdir() + original = Sentinel( + rc=0, wall_time_s=3.14, jobid="abc", + output_files={"avg_stress": "results/options/avg_stress.txt"}, + status="ok", + ) + write_sentinel(case_dir, original) + got = read_sentinel(case_dir) + assert got is not None + assert got.rc == 0 + assert got.wall_time_s == 3.14 + assert got.jobid == "abc" + assert got.status == "ok" + assert got.output_files == {"avg_stress": "results/options/avg_stress.txt"} + + +def test_sentinel_missing_returns_none(workspace: Path): + """No sentinel -> read_sentinel returns None, is_case_complete returns False.""" + assert read_sentinel(workspace) is None + assert not is_case_complete(workspace) + + +def test_sentinel_malformed_returns_none(workspace: Path): + """Garbage in the sentinel file should be treated as no sentinel at all.""" + (workspace / ".done").write_text("{not valid json") + assert read_sentinel(workspace) is None + assert not is_case_complete(workspace) + + +def test_sentinel_clear(workspace: Path): + """clear_sentinel removes the file if present and no-ops otherwise.""" + case_dir = workspace / "case" + case_dir.mkdir() + write_sentinel(case_dir, Sentinel(rc=0, wall_time_s=1.0)) + assert is_case_complete(case_dir) + clear_sentinel(case_dir) + assert not is_case_complete(case_dir) + # Second call must not raise on missing file. + clear_sentinel(case_dir) + + +def test_validate_outputs_happy_path(workspace: Path): + """All required files present and nonempty -> ok=True, bad=[].""" + case_dir = workspace / "case" + case_dir.mkdir() + (case_dir / "a.txt").write_text("xxx") + (case_dir / "b.txt").write_text("yy") + ok, bad = validate_outputs(case_dir, ["a.txt", "b.txt"]) + assert ok + assert bad == [] + + +def test_validate_outputs_flags_missing_and_empty(workspace: Path): + """Missing files and empty files both show up in ``bad``.""" + case_dir = workspace / "case" + case_dir.mkdir() + (case_dir / "empty.txt").write_text("") + (case_dir / "full.txt").write_text("x") + ok, bad = validate_outputs(case_dir, ["empty.txt", "full.txt", "missing.txt"]) + assert not ok + bad_names = [Path(b).name for b in bad] + assert set(bad_names) == {"empty.txt", "missing.txt"} + + +def test_sentinel_path_convention(workspace: Path): + """The sentinel file lives at ``/.done``.""" + assert sentinel_path(workspace).name == ".done" + assert sentinel_path(workspace).parent == workspace diff --git a/workflows/exaconstit-calibrate/tests/test_nsga3_driver.py b/workflows/exaconstit-calibrate/tests/test_nsga3_driver.py new file mode 100644 index 0000000..592a874 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_nsga3_driver.py @@ -0,0 +1,2130 @@ +""" +End-to-end tests for the NSGA-III driver built on DEAP's rcarson3 fork. + +What these prove +---------------- +1. **Driver smoke test** — a small (U-)NSGA-III run produces the + expected final population size, pareto subset, and DEAP logbook + records. +2. **Determinism** — same seed in two separate workspaces produces + bit-identical per-generation gene matrices and objective + matrices. This is the single most important property for + reproducible scientific pipelines and one that depends on + ``random.seed(...)`` being respected throughout DEAP's + variation operators. +3. **Shared-SimCase parity** — N objectives scored against one + shared SimCase produces the same initial-population objective + values as the same N objectives scored against N separate + SimCases. Confirms that the framework's sim-sharing + optimization does not change what DEAP sees. +4. **Checkpoint / resume parity** — a run that completes N + generations matches a run that runs N/2 generations, pickles a + checkpoint, and resumes for N/2 more from that pickle. +5. **UNSGA3 off / on** — both paths run without errors; UNSGA3 is + the fork-only feature that required keeping DEAP. +6. **Reference points + NPOP derivation** — the population size + computed from ``ref_dirs_partitions`` matches the NSGA-III + paper's recipe. +7. **Fail-retry on sim failure** — when the framework emits inf + on sim failure, the driver replaces the failing individual and + retries; fail_limit terminates the run. +8. **Bounds / shape guardrails** — misconfigured bounds or + objective counts raise at construction time, not during + evaluation. + +Heavyweight notes +----------------- +These tests run real subprocesses through LocalBackend and DEAP's +full selection/crossover/mutation pipeline. They are slower than +the pure-unit tests — a few seconds each, not milliseconds. +""" +from __future__ import annotations + +import os +import pickle +import textwrap +import time +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +# Import the driver via its proper package path. conftest.py has +# already put the repo root on sys.path for source-checkout runs; +# when the package is pip-installed the import resolves via +# site-packages. The underscore-prefixed helpers are internal but +# we test them explicitly so importing by name is intentional. +from workflows.optimization.nsga3_driver import ( # noqa: E402 + Bounds, + RunConfig, + RunResult, + run_nsga3, + _build_reference_points, + _derive_population_size, + _is_failed, +) +from workflow_common import ( # noqa: E402 + CaseTemplater, + ConstantPenaltyFailureHandler, + InfinityFailureHandler, + LocalBackend, + Manifest, + ObjectiveSpec, + PchipSmoother, + Problem, + ProblemConfig, + SimCase, + StressStrainExtractor, + StressStrainObjective, + TemplatePathResolver, + TemplatePropertyWriter, + TemplateTarget, + TextTableReader, + TextTableSpec, + load_experimental_csv, + rmse, +) + + +# --- Shared helpers ----------------------------------------------------- + + +def _build_problem( + workspace: Path, + fake_binary: Path, + *, + sim_cases, + objective_specs, + failure_penalty: float = 1e8, + manifest_name: str = "manifest.jsonl", + max_workers: int = 4, + archive=None, + archive_run_id=None, +): + """Build a Problem against the standard test plumbing. + + Same pattern as test_problem.py's fixtures. Kept inline here so + this file is self-contained (no cross-test imports). + """ + options_tmpl = workspace / "master_options.toml" + if not options_tmpl.exists(): + options_tmpl.write_text(textwrap.dedent("""\ + [Problem] + name = "case_fixed" + basename = "options" + strain_rate = %%strain_rate%% + yield_stress = %%yield_stress%% + hardening = %%hardening%% + temperature_k = %%temp_k%% + """)) + placeholder = workspace / "placeholder.txt" + if not placeholder.exists(): + placeholder.write_text("# placeholder\n") + + templater = CaseTemplater([ + TemplateTarget(source=placeholder, dest=".placeholder"), + ]) + writer = TemplatePropertyWriter( + template_path=options_tmpl, + dest="options.toml", + extra_values={"strain_rate": 1.0, "temp_k": 298.0}, + ) + resolver = TemplatePathResolver( + working_dir_pattern="wf/gen_{generation}/gene_{gene}_sc_{obj}", + output_file_patterns={ + "avg_stress": "{working_dir}/results/options/avg_stress.txt", + "avg_def_grad": "{working_dir}/results/options/avg_def_grad.txt", + }, + root=workspace, + ) + reader = TextTableReader({ + "avg_stress": TextTableSpec( + columns=["Time", "Volume", "Sxx", "Syy", "Szz", "Sxy", "Sxz", "Syz"], + ), + "avg_def_grad": TextTableSpec( + columns=["Time", "Volume", "F11", "F12", "F13", "F21", "F22", "F23", "F31", "F32", "F33"], + required=False, + ), + }) + return Problem( + config=ProblemConfig( + binary=fake_binary, binary_args=(), + num_tasks=1, duration_s=30, + stdout="stdout.log", stderr="stderr.log", + required_outputs=("results/options/avg_stress.txt",), + ), + param_names=["yield_stress", "hardening"], + sim_cases=sim_cases, + objective_specs=objective_specs, + templater=templater, + property_writer=writer, + resolver=resolver, + backend=LocalBackend(max_workers=max_workers), + reader=reader, + failure_handler=ConstantPenaltyFailureHandler(penalty=failure_penalty), + manifest=Manifest(workspace / manifest_name), + archive=archive, + archive_run_id=archive_run_id, + ) + + +@pytest.fixture +def exp_df(): + """Reference Voce-law curve matching the fake binary's saturation constant.""" + strain = np.linspace(0.0, 1.0, 40) + stress = 210.0 + 1900.0 * (1 - np.exp(-48.0 * strain)) + return pd.DataFrame({"strain": strain, "stress": stress}) + + +@pytest.fixture +def stress_evaluator(exp_df): + return StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor( + strain_source="time_rate", strain_rate=1.0, + ), + ) + + +def _slope_evaluator(stress_eval): + """Slope-matching evaluator sharing extractor + exp DataFrame.""" + exp_df = stress_eval.experimental + exp_strain = exp_df[stress_eval.experimental_strain_col].to_numpy() + exp_stress = exp_df[stress_eval.experimental_stress_col].to_numpy() + exp_slope = np.gradient(exp_stress, exp_strain) + exp_slope_df = pd.DataFrame({ + stress_eval.experimental_strain_col: exp_strain, + stress_eval.experimental_stress_col: exp_slope, + }) + + class _SlopeEvaluator: + extractor = stress_eval.extractor + experimental = exp_slope_df + experimental_strain_col = stress_eval.experimental_strain_col + experimental_stress_col = stress_eval.experimental_stress_col + + def evaluate(self, results, ctx): + strain, stress = stress_eval.extractor.extract(results) + slope = np.gradient(stress, strain) + lo = max(strain.min(), exp_strain.min()) + hi = min(strain.max(), exp_strain.max()) + if lo >= hi: + raise ValueError("no strain overlap") + common = np.linspace(lo, hi, 100) + sm = PchipSmoother(n_samples=100) + sim_y = sm.sample_at(strain, slope, common).y + exp_y = sm.sample_at(exp_strain, exp_slope, common).y + return rmse(sim_y, exp_y) + + return _SlopeEvaluator() + + +# --- 1. Smoke test ----------------------------------------------------- + + +def test_nsga3_single_objective_runs(workspace, fake_binary, stress_evaluator): + """Small single-objective run produces the right shape outputs.""" + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + result = run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + ), + ) + assert isinstance(result, RunResult) + assert len(result.final_pop) == 4 + assert len(result.pop_library) == 3 # gen 0, gen 1, gen 2 + # Logbook stats has one record per generation (0, 1, 2). + assert len(result.logbook_stats) == 3 + # Every individual has real fitness values. + for ind in result.final_pop: + assert np.isfinite(ind.fitness.values[0]) + + +def test_nsga3_multi_objective_runs( + workspace, fake_binary, stress_evaluator, +): + """2-objective run: fork's UNSGA3 + HV tracking + ND counting.""" + slope = _slope_evaluator(stress_evaluator) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ + ObjectiveSpec(stress_evaluator, sim_case=0, label="stress"), + ObjectiveSpec(slope, sim_case=0, label="slope"), + ], + ) + result = run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=True, + ), + ) + assert len(result.final_pop) == 4 + # Post-gen-1, HV should be a finite number. + hv_gen_2 = result.logbook_stats[-1]["HV"] + assert np.isfinite(hv_gen_2) + # ND should be a sensible count. + nd = result.logbook_stats[-1]["ND"] + assert 1 <= nd <= 4 + + +# --- 2. Determinism ---------------------------------------------------- + + +def test_nsga3_same_seed_produces_same_trajectory( + fake_binary, stress_evaluator, tmp_path_factory, +): + """Two runs, same seed, separate workspaces -> identical trajectories.""" + ws_a = tmp_path_factory.mktemp("det_a") + ws_b = tmp_path_factory.mktemp("det_b") + + def run(ws): + p = _build_problem( + ws, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + return run_nsga3( + p, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=1234, track_hypervolume=False, + ), + ) + + r1 = run(ws_a) + r2 = run(ws_b) + + assert len(r1.pop_library) == len(r2.pop_library) + for gen, (p1, p2) in enumerate(zip(r1.pop_library, r2.pop_library)): + g1 = np.asarray([list(ind) for ind in p1]) + g2 = np.asarray([list(ind) for ind in p2]) + np.testing.assert_allclose( + g1, g2, rtol=0, atol=0, + err_msg=f"gene matrix differs at gen {gen}", + ) + f1 = np.asarray([ind.fitness.values for ind in p1]) + f2 = np.asarray([ind.fitness.values for ind in p2]) + np.testing.assert_allclose( + f1, f2, rtol=0, atol=0, + err_msg=f"fitness matrix differs at gen {gen}", + ) + + +# --- 3. Shared-vs-separate sim parity ---------------------------------- + + +def test_shared_vs_separate_sim_same_initial_stress( + fake_binary, stress_evaluator, tmp_path_factory, +): + """Shared-SimCase and separate-SimCase topologies: initial stress matches. + + Gen 0 of DEAP is a uniform-random sample whose seed is the only + thing affecting the draw. Both topologies use the same bounds + + same seed, so the initial genes match bit-for-bit. The simulation + is deterministic, so the STRESS RMSE (same evaluator in both + topologies) must also match bit-for-bit. + + The slope objective is not compared across topologies because a + fresh slope evaluator in each run may produce slightly different + numerical paths through the smoother - topology B has two + slope evaluators, one per sim case, both getting the same sim + output. Comparing only stress isolates the parity claim. + """ + ws_a = tmp_path_factory.mktemp("topo_a") + ws_b = tmp_path_factory.mktemp("topo_b") + + slope_a = _slope_evaluator(stress_evaluator) + slope_b = _slope_evaluator(stress_evaluator) + + pa = _build_problem( + ws_a, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ + ObjectiveSpec(stress_evaluator, sim_case=0), + ObjectiveSpec(slope_a, sim_case=0), + ], + ) + pb = _build_problem( + ws_b, fake_binary, + sim_cases=[SimCase(label="quasi_a"), SimCase(label="quasi_b")], + objective_specs=[ + ObjectiveSpec(stress_evaluator, sim_case=0), + ObjectiveSpec(slope_b, sim_case=1), + ], + ) + + bounds = Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ) + cfg = RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=7, track_hypervolume=False, + ) + + ra = run_nsga3(pa, bounds, cfg) + rb = run_nsga3(pb, bounds, cfg) + + # Gen-0 genes must match. + ga = np.asarray([list(ind) for ind in ra.pop_library[0]]) + gb = np.asarray([list(ind) for ind in rb.pop_library[0]]) + np.testing.assert_allclose( + ga, gb, rtol=0, atol=0, + err_msg="gen 0 genes differ between topologies", + ) + # Stress RMSE (first objective) must match. + stress_a = np.asarray([ + ind.fitness.values[0] for ind in ra.pop_library[0] + ]) + stress_b = np.asarray([ + ind.fitness.values[0] for ind in rb.pop_library[0] + ]) + np.testing.assert_allclose( + stress_a, stress_b, rtol=1e-12, atol=1e-9, + err_msg="stress RMSE differs between topologies", + ) + + +# --- 4. UNSGA3 on / off ------------------------------------------------ + + +def test_unsga3_off_runs_but_differs_from_on( + fake_binary, stress_evaluator, tmp_path_factory, +): + """UNSGA3 flag changes the trajectory but both paths run cleanly. + + With 2 objectives both should work; UNSGA3 adds a niching step + before variation, so the second-generation population should + differ. This also acts as a smoke test for the non-UNSGA path. + """ + slope = _slope_evaluator(stress_evaluator) + + def run(ws, unsga3): + p = _build_problem( + ws, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ + ObjectiveSpec(stress_evaluator, sim_case=0), + ObjectiveSpec(slope, sim_case=0), + ], + ) + return run_nsga3( + p, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=unsga3, ref_dirs_partitions=(4, 0), + seed=11, track_hypervolume=False, + ), + ) + + r_on = run(tmp_path_factory.mktemp("u_on"), True) + r_off = run(tmp_path_factory.mktemp("u_off"), False) + # Both succeed. + assert len(r_on.final_pop) == 4 + assert len(r_off.final_pop) == 4 + # Gen 0 is deterministic (random.seed only) so it should match + # regardless of UNSGA3 (UNSGA3 only runs from gen 1 onward). + g0_on = np.asarray([list(ind) for ind in r_on.pop_library[0]]) + g0_off = np.asarray([list(ind) for ind in r_off.pop_library[0]]) + np.testing.assert_allclose(g0_on, g0_off, rtol=0, atol=0) + # Gen 1+ can differ because UNSGA3 reorders via niching. We don't + # *require* them to differ (tiny pop may coincide), but if they + # match exactly we've not really tested anything. Just ensure + # both paths finished. + + +# --- 5. Checkpoint / resume -------------------------------------------- + + +def test_nsga3_checkpoint_resume_produces_same_final_pop( + fake_binary, stress_evaluator, tmp_path_factory, +): + """Full N-generation run == (first-half + resume second-half). + + Writes a checkpoint at gen 1, resumes from it, runs to gen 3, + then compares against a clean 3-generation run. They should + match bit-for-bit. + + This also serves as the "parity with old driver" test: the + checkpoint format uses the same pickle keys the old driver + uses, so an old checkpoint can be loaded by this driver for a + mid-run framework upgrade. + """ + ws_full = tmp_path_factory.mktemp("ckpt_full") + ws_split = tmp_path_factory.mktemp("ckpt_split") + ckpt_dir = ws_split / "checkpoint_files" + + bounds = Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ) + + # Full run, no checkpoint. + pa = _build_problem( + ws_full, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + r_full = run_nsga3( + pa, bounds, + RunConfig( + n_generations=3, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=321, track_hypervolume=False, + ), + ) + + # Split run part 1: gens 0..1, checkpoint at every gen. + pb1 = _build_problem( + ws_split, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + r_part1 = run_nsga3( + pb1, bounds, + RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=321, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + ), + ) + assert (ckpt_dir / "checkpoint_gen_1.pkl").exists() + + # Split run part 2: resume from gen 1, run through gen 3. + pb2 = _build_problem( + ws_split, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + manifest_name="manifest.jsonl", # same as part 1 + ) + r_part2 = run_nsga3( + pb2, bounds, + RunConfig( + n_generations=3, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=321, # ignored on resume (state comes from pickle) + track_hypervolume=False, + resume_from=ckpt_dir / "checkpoint_gen_1.pkl", + ), + ) + + # Final pops must match. + assert len(r_full.pop_library) == len(r_part2.pop_library) + for gen, (pf, ps) in enumerate(zip( + r_full.pop_library, r_part2.pop_library, + )): + gf = np.asarray([list(ind) for ind in pf]) + gs = np.asarray([list(ind) for ind in ps]) + np.testing.assert_allclose( + gf, gs, rtol=0, atol=0, + err_msg=f"gen {gen} genes differ between full and resumed", + ) + ff = np.asarray([ind.fitness.values for ind in pf]) + fs = np.asarray([ind.fitness.values for ind in ps]) + np.testing.assert_allclose( + ff, fs, rtol=0, atol=0, + err_msg=f"gen {gen} fitness differs between full and resumed", + ) + + +def test_checkpoint_file_has_expected_keys( + workspace, fake_binary, stress_evaluator, +): + """Checkpoint format matches the pre-refactor driver's exact keys. + + This guarantees that old ExaConstit_NSGA3.py checkpoints can + be loaded by the new driver - and vice versa. + """ + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + ckpt_dir = workspace / "ck" + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=0, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + ), + ) + with (ckpt_dir / "checkpoint_gen_1.pkl").open("rb") as f: + ckp = pickle.load(f) + # Must contain every key the pre-refactor driver used. We permit + # one additional key (archive_run_id) that the new driver added + # for archive-aware resume; old drivers can still read these + # pickles because pickle ignores extra keys. + required_keys = { + "pop_library", "iter_tot", "generation", + "fail_count", "stop_count", + "logbook1", "logbook2", "rndstate", + } + assert required_keys.issubset(set(ckp.keys())) + # The new key is always present but may be None when no + # archive was configured for the run. + assert "archive_run_id" in ckp + + +# --- 6. Population sizing ---------------------------------------------- + + +@pytest.mark.parametrize("n_obj,partitions,expected_h,expected_npop", [ + # From the NSGA-III paper: H = C(n_obj + P - 1, P). + (2, (4, 0), 5, 8), # C(5, 4) = 5, round up to 8 + (2, (10, 0), 11, 12), # C(11,10) = 11, round up to 12 + (3, (4, 0), 15, 16), # C(6, 4) = 15, round up to 16 + (4, (4, 0), 35, 36), # C(7, 4) = 35, round up to 36 +]) +def test_reference_points_and_npop(n_obj, partitions, expected_h, expected_npop): + """Das-Dennis count and NPOP-from-H formula match the paper.""" + ref, h = _build_reference_points(n_obj, partitions, (1.0, 0.0)) + assert ref.shape[0] == expected_h + assert ref.shape[1] == n_obj + assert h == expected_h + assert _derive_population_size(n_obj, h) == expected_npop + + +def test_single_obj_reference_points(): + """Single-obj trivially returns one reference direction.""" + ref, h = _build_reference_points(1, (10, 0), (1.0, 0.0)) + assert ref.shape == (1, 1) + assert h == 10 + # NPOP derivation uses h=10 → round up to 12. + assert _derive_population_size(1, h) == 12 + + +# --- 7. Failure detection ---------------------------------------------- + + +def test_is_failed_detects_inf(): + """_is_failed correctly flags infinite fitness values.""" + assert _is_failed((float("inf"),), float("inf")) + assert _is_failed((1.0, float("inf")), float("inf")) + assert not _is_failed((1.0, 2.0), float("inf")) + + +def test_is_failed_detects_nan(): + """NaN also counts as failure (np.isfinite returns False).""" + assert _is_failed((float("nan"),), float("inf")) + + +def test_is_failed_respects_threshold(): + """A custom threshold catches below-inf penalties too.""" + assert _is_failed((1e9,), 1e8) + assert not _is_failed((1.0,), 1e8) + + +def test_fail_limit_raises_runtime_error( + workspace, fake_binary, stress_evaluator, +): + """If all sims fail, fail_limit triggers a RuntimeError. + + Forces every sim to fail via the FAKE_FAIL env var and verifies + that the driver stops with a clear error message instead of + silently continuing. + """ + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + # Override the failure handler to emit inf so fail detection + # triggers (default is ConstantPenaltyFailureHandler(1e8)). + problem.failure_handler = InfinityFailureHandler() + + os.environ["FAKE_FAIL"] = "1" + try: + with pytest.raises(RuntimeError, match="fail_limit"): + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=0, track_hypervolume=False, + fail_limit=3, # short limit so test is fast + ), + ) + finally: + os.environ.pop("FAKE_FAIL", None) + + +# --- 8. Guard rails ---------------------------------------------------- + + +def test_bounds_rejects_upper_le_lower(): + with pytest.raises(ValueError, match="strictly greater"): + Bounds( + lower=np.array([0.0, 1.0]), + upper=np.array([1.0, 1.0]), + ) + + +def test_bounds_rejects_shape_mismatch(): + with pytest.raises(ValueError, match="shape"): + Bounds( + lower=np.array([0.0, 0.0]), + upper=np.array([1.0, 1.0, 1.0]), + ) + + +def test_nsga3_rejects_mismatched_bounds( + workspace, fake_binary, stress_evaluator, +): + """bounds.n_params must match problem.param_names.""" + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + with pytest.raises(ValueError, match="param_names"): + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([0.0, 0.0, 0.0]), # 3 params + upper=np.array([1.0, 1.0, 1.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=0, track_hypervolume=False, + ), + ) + + +# --- 9. End-to-end post-processing -------------------------------------- + + +def test_driver_run_postprocess_pipeline( + workspace, fake_binary, stress_evaluator, +): + """Full pipeline: run → checkpoint → load → extract → read case → plot. + + Proves the workflow_common.postprocess module integrates cleanly + with a real checkpoint from the new driver. Exercises the + entire post-analysis path a real user would take. + """ + from workflow_common.postprocess import ( + best_solution_eudist, + extract_gene_results, + load_case_results, + load_checkpoint, + ) + + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + ckpt_dir = workspace / "ck" + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + ), + ) + + # Step 1: load checkpoint from disk. + ckp = load_checkpoint(ckpt_dir / "checkpoint_gen_1.pkl") + assert ckp.n_pop == 4 + assert ckp.n_dim == 2 + + # Step 2: extract GeneResult records. + all_results = extract_gene_results(ckp.pop_library) + assert len(all_results) == 2 # gen 0, gen 1 + last_gen_results = all_results[-1] + assert len(last_gen_results) == 4 + + # Step 3: pick the best via EUDIST. + fits = np.array([r.fitness for r in last_gen_results]) + best_idx = best_solution_eudist(fits, nsmallest=1) + assert len(best_idx) == 1 + best_result = last_gen_results[best_idx[0]] + + # Step 4: re-read the sim output from disk for that gene. + # This is the replacement for the old ind.stress attribute. + case_results = load_case_results( + best_result, sim_case_idx=0, + resolver=problem.resolver, reader=problem.reader, + ) + assert case_results is not None + stress_df = case_results.df("avg_stress") + assert len(stress_df) > 0 + + # Step 5 (smoke): hand it to the plot function. + # We don't assert anything about the figure content - just that + # the data flows through the plotting helper without errors. + pytest.importorskip("matplotlib") + import matplotlib + + matplotlib.use("Agg") + from workflow_common.postprocess import plot_stress_strain_overlay + + exp_strain = np.linspace(0, 1, 20) + exp_stress = 210.0 + 1900.0 * (1 - np.exp(-48.0 * exp_strain)) + fig, ax = plot_stress_strain_overlay( + sim_strain=stress_df["Time"].to_numpy(), + sim_stress=stress_df["Szz"].to_numpy(), + exp_strain=exp_strain, + exp_stress=exp_stress, + title="best gene, final generation", + ) + assert fig is not None + + + + +# --- 10. Archive + rolling cleanup -------------------------------------- + + +def test_archive_captures_every_case_and_every_generation( + workspace, fake_binary, stress_evaluator, +): + """After a 2-gen run with archive, every case output + every gene + stats + is retrievable from the SQLite DB. + + This is the "archive actually captures what ran" test: without + any cleanup happening, just prove the archive side-effect + works end-to-end through the driver. + """ + from workflow_common import ArchiveDB + + db_path = workspace / "opt.db" + archive = ArchiveDB(db_path) + with archive: + # On a fresh run, the caller creates the archive row + # up front via start_run and passes the UUID into Problem. + # On resume, the driver fills in the UUID from the pickle + # instead — see test_archive_resume_adopts_pickled_run_id. + run_id = archive.start_run( + seed=11, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive, + archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=11, track_hypervolume=False, + ), + ) + + # Query the DB with a fresh read-only connection to prove the + # archive persisted properly (and is independent of the live + # connection used during the run). + with ArchiveDB(db_path, readonly=True) as a: + # Run was ended -> completed_at populated. + run = a.get_run(run_id) + assert run.completed_at is not None + assert run.seed == 11 + + # Three generations: gen 0 (init), gen 1, gen 2. + gens = a.list_generations(run_id) + assert [g.gen_idx for g in gens] == [0, 1, 2] + for g in gens: + assert g.n_pop == 4 + + # Case outputs: at minimum, every (gen, gene) that was + # actually run has its sim output in the DB. The number of + # distinct case rows depends on how many individuals were + # re-evaluated vs. survived selection. We check the floor: + # the initial pop (gen 0) has 4 new sims. + genes_g0 = a.load_genes(run_id, 0) + assert len(genes_g0) == 4 + for g in genes_g0: + rs = a.load_case_outputs( + run_id, + birth_gen=g.birth_gen, birth_gene=g.birth_gene, + sim_case_idx=0, + ) + assert rs is not None + # avg_stress is the required output - must be there. + assert "avg_stress" in rs.tables + assert len(rs.df("avg_stress")) > 0 + + +def test_rolling_cleanup_deletes_older_gen_preserves_recent( + workspace, fake_binary, stress_evaluator, +): + """cleanup_keep_generations=2: after gen N, delete gen N-2. + + Runs for 4 generations. Asserts: + - Gen 0 and gen 1 case dirs are DELETED from disk. + - Gen 2 and gen 3 case dirs REMAIN on disk. + - Archive still has the case outputs for gen 0 and gen 1 + (we can pull them back from pickled BLOBs). + """ + from workflow_common import ArchiveDB + from workflow_common.postprocess import load_case_results_from_archive + + db_path = workspace / "opt.db" + archive = ArchiveDB(db_path) + with archive: + run_id = archive.start_run( + seed=7, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive, + archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=3, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=7, track_hypervolume=False, + cleanup_keep_generations=2, + ), + ) + + # Disk state: dirs under wf/gen_N/gene_X_sc_0. + wf_root = workspace / "wf" + # Gen 0 cleanup triggers at end-of-gen-2 (gen >= keep=2). Gen 1 + # cleanup triggers at end-of-gen-3. Both are gone; gen 2 and + # gen 3 remain (gen 3 is current, gen 2 is the safety margin). + def _has_case_dirs(gen_dir: Path) -> bool: + return gen_dir.exists() and any(gen_dir.iterdir()) + + assert not _has_case_dirs(wf_root / "gen_0"), ( + "gen_0 directory still has case dirs after cleanup" + ) + assert not _has_case_dirs(wf_root / "gen_1"), ( + "gen_1 directory still has case dirs after cleanup" + ) + assert _has_case_dirs(wf_root / "gen_2"), ( + "gen_2 (keep-1 safety margin) was cleaned up prematurely" + ) + assert _has_case_dirs(wf_root / "gen_3"), ( + "gen_3 (current) was cleaned up - wrong!" + ) + + # Archive side: the gen 0 sim outputs are gone from disk but the + # DB still has them. Pull one back and prove it's a real DataFrame. + with ArchiveDB(db_path, readonly=True) as a: + genes_g0 = a.load_genes(run_id, 0) + assert genes_g0 + sample = next(g for g in genes_g0 if g.birth_gen == 0) + # Use a duck-typed stand-in for GeneResult to avoid building + # full postprocess objects here; the function only reads + # .generation and .gene. + gr = type("R", (), { + "generation": sample.birth_gen, + "gene": sample.birth_gene, + })() + rs = load_case_results_from_archive( + a, gr, sim_case_idx=0, run_id=run_id, + ) + assert rs is not None, "gen 0 case output not recoverable from archive" + df = rs.df("avg_stress") + assert len(df) > 0 + assert "Szz" in df.columns + + +def test_cleanup_without_archive_raises_at_run_start( + workspace, fake_binary, stress_evaluator, +): + """cleanup_keep_generations without an archive is a misconfiguration. + + The driver must refuse to start rather than silently destroy + the user's only copy of the simulation output. Failing AT RUN + START (not mid-run) keeps the error localized and obvious. + """ + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + # No archive. + ) + with pytest.raises(ValueError, match="archive"): + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=0, track_hypervolume=False, + cleanup_keep_generations=2, + ), + ) + + +def test_cleanup_keep_generations_zero_rejected( + workspace, fake_binary, stress_evaluator, +): + """cleanup_keep_generations=0 would delete the current gen's dirs; reject it.""" + from workflow_common import ArchiveDB + + with ArchiveDB(workspace / "a.db") as archive: + rid = archive.start_run( + seed=0, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive, archive_run_id=rid, + ) + with pytest.raises(ValueError, match="cleanup_keep_generations"): + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=0, track_hypervolume=False, + cleanup_keep_generations=0, + ), + ) + + +def test_archive_resume_discards_stale_generations( + workspace, fake_binary, stress_evaluator, +): + """Resume from gen 1 pickle: archive rows for gen 2+ get discarded. + + Simulates a mid-run crash. We set up the mid-run state by + running 2 gens fully, then resume from the gen 1 pickle and + run 2 more gens. The archive at the end must contain gens 0..3 + of the RESUMED run, with no leftover gen 2 entries from the + pre-crash sequence (cascade delete via discard_from_generation + cleans them before the phase-2 write). + """ + from workflow_common import ArchiveDB + + db_path = workspace / "opt.db" + ckpt_dir = workspace / "ck" + + # Phase 1: run 2 gens, get a checkpoint at gen 1. + archive = ArchiveDB(db_path) + with archive: + run_id = archive.start_run( + seed=21, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=21, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + ), + ) + + # Confirm phase 1 captured gens 0, 1, 2. + with ArchiveDB(db_path, readonly=True) as a: + phase1_gens = [g.gen_idx for g in a.list_generations(run_id)] + assert phase1_gens == [0, 1, 2] + + # Phase 2: resume from gen 1 pickle, run through gen 3. The + # resume path must discard the phase-1 gen 2 archive rows and + # rewrite them fresh during the resumed run. + archive = ArchiveDB(db_path) + with archive: + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=3, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=21, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + resume_from=ckpt_dir / "checkpoint_gen_1.pkl", + ), + ) + + # Archive should now have gens 0..3, all belonging to the same + # run_id, with no duplicate or stale gen 2 rows. Since genes has + # (run_id, gen_idx, pop_idx) as PRIMARY KEY, stale duplicates + # would have raised IntegrityError during phase 2 - they were + # discarded cleanly, so the write succeeded. + with ArchiveDB(db_path, readonly=True) as a: + final_gens = [g.gen_idx for g in a.list_generations(run_id)] + assert final_gens == [0, 1, 2, 3] + for g in final_gens: + loaded = a.load_genes(run_id, g) + assert len(loaded) == 4 + + +def test_archive_resume_rejects_conflicting_run_id_with_helpful_error( + workspace, fake_binary, stress_evaluator, +): + """Resuming with a Problem whose archive_run_id differs from the + pickled one fails with a message that names the fix. + + This is the error path Robert hit in production: the example + driver called ``archive.start_run()`` unconditionally in main(), + generating a fresh UUID. The library raises rather than silently + continuing with the wrong run_id (which would cross-contaminate + archive rows). The message must name the fix — "only start_run + when not resuming" — so next time the error is diagnosable. + """ + from workflow_common import ArchiveDB + + db_path = workspace / "opt.db" + ckpt_dir = workspace / "ck" + + # Phase 1: one quick run, pickle one checkpoint. + archive = ArchiveDB(db_path) + with archive: + run_id_a = archive.start_run( + seed=42, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive, archive_run_id=run_id_a, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + ), + ) + + # Phase 2: resume, but simulate the driver bug — start a NEW + # archive run and set its fresh UUID on the Problem before + # handing to run_nsga3. The library must refuse. + archive2 = ArchiveDB(db_path) + with archive2: + run_id_b = archive2.start_run( + seed=42, param_names=["yield_stress", "hardening"], + ) + assert run_id_a != run_id_b # different UUIDs + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive2, archive_run_id=run_id_b, + ) + with pytest.raises(ValueError) as excinfo: + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + resume_from=ckpt_dir / "checkpoint_gen_0.pkl", + ), + ) + msg = str(excinfo.value) + # Must name both UUIDs so the user can tell which is which, + # and must name the fix so the next person hitting it doesn't + # need to spelunk the source. + assert run_id_a in msg + assert run_id_b in msg + assert "start_run" in msg + assert "resume" in msg.lower() + + +def test_archive_resume_adopts_pickled_run_id_when_problem_has_none( + workspace, fake_binary, stress_evaluator, +): + """Resume path: Problem built with archive=..., archive_run_id=None + picks up the pickled run_id and continues writing to that row. + + This is the path Robert's example driver takes after the fix: + on resume, the driver does NOT call archive.start_run() — it + leaves archive_run_id=None on the Problem. The library reads + the UUID out of the checkpoint and assigns it before any + writes happen. + + Verifies end-to-end: + * Problem construction accepts the orphan case + (archive set but archive_run_id=None), which used to + be rejected by __init__. + * After resume, wf_problem.archive_run_id holds the + pickled UUID. + * Archive rows are written under the pickled run, not a + new one — list_runs() still shows exactly one run. + """ + from workflow_common import ArchiveDB + + db_path = workspace / "opt.db" + ckpt_dir = workspace / "ck" + + # Phase 1: full-setup fresh run that produces a checkpoint. + archive = ArchiveDB(db_path) + with archive: + pickled_run_id = archive.start_run( + seed=99, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive, archive_run_id=pickled_run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=99, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + ), + ) + + # Phase 2: resume with archive_run_id=None — this is what + # the example driver does on resume. The library fills it in + # from the pickle. + archive2 = ArchiveDB(db_path) + with archive2: + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=archive2, + archive_run_id=None, # <-- the fix in action + ) + assert problem.archive_run_id is None # pre-resume state + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=99, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + resume_from=ckpt_dir / "checkpoint_gen_0.pkl", + ), + ) + # After run_nsga3 the Problem should now carry the + # pickled UUID — library's resume-adoption worked. + assert problem.archive_run_id == pickled_run_id + + # No new run was created — we continued writing to the original. + with ArchiveDB(db_path, readonly=True) as a: + runs = a.list_runs() + assert len(runs) == 1 + assert runs[0].run_id == pickled_run_id + # All generations landed under the one run. + gens = [g.gen_idx for g in a.list_generations(pickled_run_id)] + assert gens == [0, 1, 2] + + +def test_problem_rejects_run_id_without_archive(workspace, fake_binary, stress_evaluator): + """The inverse orphan — run_id without archive — is still a user + error: there's nowhere to write. Caught at construction. + """ + import pytest + with pytest.raises(ValueError, match="archive_run_id=... requires archive"): + _build_problem( + workspace, fake_binary, + sim_cases=[SimCase()], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + archive=None, + archive_run_id="some-orphan-uuid", + ) + + +# --- Logbook .log file writing ------------------------------------------ + + +def test_logbook_files_written_per_generation( + workspace, fake_binary, stress_evaluator, tmp_path, +): + """logbook1_stats.log and logbook2_solutions.log exist and contain + one header + per-gen entries after a run. + + Matches the pre-refactor driver's output. Downstream team tooling + parses these as tab-delimited text; check the shape rather than + exact byte layout so a DEAP formatter tweak doesn't break the + test. + """ + log_dir = tmp_path / "logs" + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + log_dir=log_dir, + ), + ) + + stats_path = log_dir / "logbook1_stats.log" + solutions_path = log_dir / "logbook2_solutions.log" + + # Both files exist. + assert stats_path.is_file(), "logbook1_stats.log not written" + assert solutions_path.is_file(), "logbook2_solutions.log not written" + + # Stats file: one header line + one body line per generation. + # gen_0 + gen_1 + gen_2 = three body lines. The exact header + # text is DEAP's; we just check it's there. + stats_text = stats_path.read_text() + assert "gen" in stats_text + assert "avg" in stats_text and "std" in stats_text + # Count lines that begin with a digit — the body lines. There + # may be a header wrap too, but body lines are the robust count. + body_lines = [ + line for line in stats_text.splitlines() + if line.strip() and line.strip().split()[0].isdigit() + ] + assert len(body_lines) == 3, ( + f"expected 3 gen body lines (0, 1, 2) in stats log, got " + f"{len(body_lines)}:\n{stats_text}" + ) + + # Solutions file: NPOP rows per gen × 3 gens = 12 body rows. + solutions_text = solutions_path.read_text() + solutions_body = [ + line for line in solutions_text.splitlines() + if line.strip() and line.strip().split()[0].isdigit() + ] + assert len(solutions_body) == 12, ( + f"expected 12 individual entries (4 pop × 3 gens), got " + f"{len(solutions_body)}" + ) + + +def test_logbook_files_disabled_by_write_logbook_files_false( + workspace, fake_binary, stress_evaluator, tmp_path, +): + """Setting write_logbook_files=False skips the .log files entirely. + + In-memory logbooks are still populated (so downstream tools that + reach into RunResult still work); only the on-disk artifacts are + suppressed. The opt-out exists for tests and for users running + completely silent / pipe-driven workflows. + """ + log_dir = tmp_path / "logs" + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + log_dir=log_dir, + write_logbook_files=False, + ), + ) + # log_dir may not even exist if the writer wasn't constructed. + assert not (log_dir / "logbook1_stats.log").exists() + assert not (log_dir / "logbook2_solutions.log").exists() + + +def test_logbook_files_rewritten_cleanly_on_resume( + workspace, fake_binary, stress_evaluator, tmp_path, +): + """On resume-from-checkpoint, both .log files are rewritten from the + logbooks in the pickle, then continue to grow incrementally. + + The before-resume run + the after-resume run must collectively + contain exactly one record per generation at the end — no gaps, + no duplicates. Gaps would happen if the writer appended blindly + (it would skip gens already in the pickle); duplicates would + happen if we didn't truncate before replaying. + """ + ckpt_dir = tmp_path / "ckpt" + log_dir = tmp_path / "logs" + + # Initial run: 2 generations, checkpoints each gen. + problem1 = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + run_nsga3( + problem1, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + log_dir=log_dir, + ), + ) + + # Capture the pre-resume state of the two files. + stats_path = log_dir / "logbook1_stats.log" + stats_pre = stats_path.read_text() + stats_body_pre = [ + line for line in stats_pre.splitlines() + if line.strip() and line.strip().split()[0].isdigit() + ] + assert len(stats_body_pre) == 3 # gens 0, 1, 2 + + # Resume from gen 1 (forces gen 2 to be re-run). After the + # resume completes, the .log file should still have exactly 3 + # body lines (0, 1, 2) — the first rewrite from the pickle, + # followed by an incremental append for the re-run gen 2. + problem2 = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi")], + objective_specs=[ObjectiveSpec(stress_evaluator, sim_case=0)], + ) + run_nsga3( + problem2, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=2, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + checkpoint_dir=ckpt_dir, checkpoint_freq=1, + resume_from=ckpt_dir / "checkpoint_gen_1.pkl", + log_dir=log_dir, + ), + ) + + stats_post = stats_path.read_text() + stats_body_post = [ + line for line in stats_post.splitlines() + if line.strip() and line.strip().split()[0].isdigit() + ] + # Exactly 3 body lines still — no duplicates, no gaps. + assert len(stats_body_post) == 3, ( + f"expected 3 body lines after resume, got {len(stats_body_post)}:\n" + f"{stats_post}" + ) + + +# --- Experimental data archiving ---------------------------------------- + + +def test_run_archives_experimental_data_per_sim_case( + workspace, fake_binary, stress_evaluator, exp_df, +): + """The driver must store evaluator.experimental into the archive + once archive_run_id is known. Plotting tools rely on this so users + don't have to re-supply CSV paths post-run. + """ + from workflow_common import ArchiveDB + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=99, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi"), SimCase(label="dynamic")], + objective_specs=[ + ObjectiveSpec(stress_evaluator, sim_case=0, label="stress_0"), + ObjectiveSpec(stress_evaluator, sim_case=1, label="stress_1"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=99, track_hypervolume=False, + ), + ) + + # Archive must now have experimental data for both SimCases. + with ArchiveDB(db, readonly=True) as a: + listing = a.list_experiments(run_id) + assert listing == [(0, "quasi"), (1, "dynamic")] + result0 = a.load_experiment(run_id, 0) + assert result0 is not None + label0, df0 = result0 + assert label0 == "quasi" + # Round-trip the original experimental DataFrame faithfully. + pd.testing.assert_frame_equal(df0, exp_df) + + +def test_run_skips_archive_for_evaluators_without_experimental_data( + workspace, fake_binary, +): + """An evaluator that doesn't expose .experimental shouldn't crash + the archiving step. The SimCase just won't have experimental data + recorded, and the plotter falls back gracefully later. + """ + from workflow_common import ArchiveDB + from workflow_common.objectives import ObjectiveEvaluator + + class _NoExpEvaluator(ObjectiveEvaluator): + # Custom evaluator with no experimental DataFrame. + # Returns a value that varies with the gene so NSGA-III's + # niching has something to work with (a constant returner + # would cause divide-by-zero in the reference-point step). + def evaluate(self, results, ctx): + # Use the case context's gene index as a stand-in + # variation source. + return 0.5 + 0.01 * ctx.gene + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=1, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="custom")], + objective_specs=[ + ObjectiveSpec(_NoExpEvaluator(), sim_case=0, label="custom"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=1, track_hypervolume=False, + ), + ) + + # No experimental data was recorded, but the run still succeeded. + with ArchiveDB(db, readonly=True) as a: + assert a.list_experiments(run_id) == [] + + +def test_run_archives_minmax_strain_from_case_data( + workspace, fake_binary, stress_evaluator, +): + """case_data['minmax_strain'] must flow through the driver into + the archive's experiments.minmax_strain column. This is the + plumbing that lets the plotter shade the optimization window + without the user re-supplying it post-run. + """ + from workflow_common import ArchiveDB + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=42, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[ + SimCase(label="windowed", + case_data={"minmax_strain": (0.005, 0.13)}), + SimCase(label="upper_only", + case_data={"minmax_strain": (None, 0.10)}), + SimCase(label="no_window", case_data={}), + ], + objective_specs=[ + ObjectiveSpec(stress_evaluator, sim_case=0, label="a"), + ObjectiveSpec(stress_evaluator, sim_case=1, label="b"), + ObjectiveSpec(stress_evaluator, sim_case=2, label="c"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + ), + ) + + with ArchiveDB(db, readonly=True) as a: + assert a.load_experiment_window(run_id, 0) == (0.005, 0.13) + assert a.load_experiment_window(run_id, 1) == (None, 0.10) + # SimCase 2 didn't supply a window — None, not (None, None). + assert a.load_experiment_window(run_id, 2) is None + + +def test_run_handles_malformed_minmax_strain_gracefully( + workspace, fake_binary, stress_evaluator, caplog, +): + """A misformatted minmax_strain (e.g. a string instead of a tuple) + shouldn't abort the run. The driver should log a warning, drop + the window, and still record the experiment so the rest of the + plotter pipeline keeps working. + """ + import logging + from workflow_common import ArchiveDB + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=7, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[ + SimCase(label="bad", + case_data={"minmax_strain": "not a tuple"}), + ], + objective_specs=[ + ObjectiveSpec(stress_evaluator, sim_case=0, label="a"), + ], + archive=archive, archive_run_id=run_id, + ) + with caplog.at_level(logging.WARNING): + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=7, track_hypervolume=False, + ), + ) + + # Run completed; experiment recorded but window dropped. + with ArchiveDB(db, readonly=True) as a: + assert a.load_experiment(run_id, 0) is not None + assert a.load_experiment_window(run_id, 0) is None + # Warning was logged so user can find the misconfiguration. + assert any( + "minmax_strain" in rec.message + for rec in caplog.records + ) + + +def test_run_archives_extractor_config_from_evaluator( + workspace, fake_binary, stress_evaluator, +): + """``evaluator.extractor.to_dict()`` must flow through the driver + to ``archive.load_extractor_config``. Without this, post-run + plotters fall back to default extractor settings that may not + match what the optimizer actually used — silently producing + different curves than the run scored against (Robert's + 'only 1 of 2 SimCases plotted' bug). + """ + from workflow_common import ArchiveDB + from workflow_common.objectives import StressStrainExtractor + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=11, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi"), SimCase(label="dynamic")], + objective_specs=[ + ObjectiveSpec(stress_evaluator, sim_case=0, label="a"), + ObjectiveSpec(stress_evaluator, sim_case=1, label="b"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=11, track_hypervolume=False, + ), + ) + + # The fixture's stress_evaluator uses time_rate strain with + # strain_rate=1.0 — those settings should round-trip into the + # archive verbatim. Both SimCases share the same evaluator, + # so both archived configs should match. + with ArchiveDB(db, readonly=True) as a: + for sc_idx in (0, 1): + cfg = a.load_extractor_config(run_id, sc_idx) + assert cfg is not None, ( + f"sim_case {sc_idx} has no archived extractor config" + ) + assert cfg["strain_source"] == "time_rate" + assert cfg["strain_rate"] == 1.0 + # Reconstruct should match the original exactly. + rebuilt = StressStrainExtractor.from_dict(cfg) + assert rebuilt == stress_evaluator.extractor + + +def test_run_skips_extractor_config_for_evaluators_without_one( + workspace, fake_binary, +): + """An evaluator without an ``.extractor`` attribute (custom + user-defined) shouldn't crash the archiving step. The + experiment is still recorded; extractor_config column for + that SimCase stays NULL; the plotter falls back to its + default extractor with a warning. + """ + from workflow_common import ArchiveDB + from workflow_common.objectives import ObjectiveEvaluator + + class _CustomEvaluator(ObjectiveEvaluator): + # No .extractor and no .experimental — but this evaluator + # type is fine to optimize against, just doesn't auto-archive. + # We give it an experimental DataFrame so the experiment + # still gets stored, just without an extractor config. + def __init__(self): + self.experimental = pd.DataFrame({ + "strain": np.linspace(0, 0.1, 5), + "stress": np.linspace(100, 200, 5), + }) + + def evaluate(self, results, ctx): + return 0.5 + 0.01 * ctx.gene + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=2, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="custom")], + objective_specs=[ + ObjectiveSpec(_CustomEvaluator(), sim_case=0, label="x"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=2, track_hypervolume=False, + ), + ) + + # Experiment recorded; extractor_config absent. + with ArchiveDB(db, readonly=True) as a: + assert a.load_experiment(run_id, 0) is not None + assert a.load_extractor_config(run_id, 0) is None + + +def test_run_archives_full_range_curves_per_gene_and_simcase( + workspace, fake_binary, stress_evaluator, +): + """Every successfully-evaluated (gene, sim_case) pair must yield a + case_curve row covering the FULL extraction range. The window + (when supplied via the extractor) is stripped before extraction + so re-analysis with different metrics or windows can use the + archived curve directly. + + This pins Robert's ask: 'we should always make sure to save off + the independent and dependent variables related to our objective + functions for the full simulation range.' + """ + from workflow_common import ArchiveDB + from workflow_common.objectives import StressStrainExtractor, StressStrainObjective + + # Use an evaluator whose extractor has a window so the + # window-stripping logic gets exercised. The optimizer scores + # against the windowed curve; the archive stores the full one. + exp_df = stress_evaluator.experimental + windowed_extractor = StressStrainExtractor( + strain_source="time_rate", strain_rate=1.0, + window=(0.05, 0.5), + ) + windowed_evaluator = StressStrainObjective( + experimental=exp_df, + extractor=windowed_extractor, + ) + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=42, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="quasi"), SimCase(label="dynamic")], + objective_specs=[ + ObjectiveSpec(windowed_evaluator, sim_case=0, label="a"), + ObjectiveSpec(windowed_evaluator, sim_case=1, label="b"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=42, track_hypervolume=False, + ), + ) + + with ArchiveDB(db, readonly=True) as a: + # 4 genes × 2 SimCases × 2 generations (gen 0 + 1 transition, + # per n_generations semantics) = 16 rows expected. + cur = a._conn.execute( + "SELECT COUNT(*) FROM case_curves WHERE run_id=?", (run_id,), + ) + n_rows = cur.fetchone()[0] + assert n_rows == 16, f"expected 16 case_curves rows, got {n_rows}" + + # Pick one and confirm the strain range exceeds the window. + # Window was (0.05, 0.5); time_rate strain at rate=1.0 with + # the fake binary's t in [0,1] gives strain in [0,1]. So the + # archived curve should span (or come close to) [0,1], not + # be clipped to [0.05, 0.5]. + result = a.load_case_curve( + run_id, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) + assert result is not None + ind, dep, ind_label, dep_label = result + # Strain min should be <= 0.05 (i.e., extends below the + # window's lower bound — proof window was stripped). + assert ind.min() <= 0.05 + 1e-6, ( + f"archived strain min={ind.min()} exceeds window lo=0.05; " + f"window may not have been stripped before extraction" + ) + # Strain max should be >= 0.5 (extends above window upper). + assert ind.max() >= 0.5 - 1e-6, ( + f"archived strain max={ind.max()} below window hi=0.5" + ) + assert ind_label == "strain" + assert dep_label == "stress" + + +def test_run_skips_curve_archive_for_evaluators_without_extractor( + workspace, fake_binary, +): + """Custom evaluators without an ``.extractor`` attribute don't + crash the run — they just don't contribute case_curves rows.""" + from workflow_common import ArchiveDB + from workflow_common.objectives import ObjectiveEvaluator + + class _CustomEvaluator(ObjectiveEvaluator): + def __init__(self): + self.experimental = pd.DataFrame({ + "strain": [0.0, 0.1], "stress": [100.0, 200.0], + }) + + def evaluate(self, results, ctx): + return 0.5 + 0.01 * ctx.gene + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=2, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="custom")], + objective_specs=[ + ObjectiveSpec(_CustomEvaluator(), sim_case=0, label="x"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=2, track_hypervolume=False, + ), + ) + + with ArchiveDB(db, readonly=True) as a: + # No case_curves rows — extractor unavailable. + cur = a._conn.execute( + "SELECT COUNT(*) FROM case_curves WHERE run_id=?", (run_id,), + ) + assert cur.fetchone()[0] == 0 + + +def test_run_archives_curves_with_sign_matched_to_experimental( + workspace, fake_binary, +): + """When the extractor produces positive-signed sim curves but + the evaluator's ``.experimental`` is compression-shaped, the + framework should sign-match the simulated curve before archiving. + + Pins Robert's ask: a defensive check at save-off so the archive + holds curves consistent with the experimental data. + """ + from workflow_common import ArchiveDB + from workflow_common.objectives import ( + ObjectiveEvaluator, StressStrainExtractor, + ) + + # Stub evaluator that exposes both ``.extractor`` and + # ``.experimental`` (so Problem's curve-archiving logic finds + # them and pairs them) but has an ``evaluate`` method that + # returns finite values regardless of any sim/exp mismatch — + # avoids DEAP crashing on all-infinite fitnesses while still + # exercising the sign-correction path. + class _StubEvaluator(ObjectiveEvaluator): + def __init__(self): + self.extractor = StressStrainExtractor( + strain_source="time_rate", strain_rate=1.0, + ) + # Compression-shaped reference: negative strain, + # negative stress. + exp_strain = np.linspace(0.0, -1.0, 25) + exp_stress = -210.0 - 1900.0 * ( + 1 - np.exp(48.0 * exp_strain) + ) + self.experimental = pd.DataFrame({ + "strain": exp_strain, "stress": exp_stress, + }) + self.experimental_strain_col = "strain" + self.experimental_stress_col = "stress" + + def evaluate(self, results, ctx): + return 0.5 + 0.01 * ctx.gene + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=7, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="compression")], + objective_specs=[ + ObjectiveSpec(_StubEvaluator(), sim_case=0, label="x"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=7, track_hypervolume=False, + ), + ) + + with ArchiveDB(db, readonly=True) as a: + # Pull one archived curve and check signs. + result = a.load_case_curve( + run_id, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) + assert result is not None + ind, dep, _, _ = result + # Both axes should have non-positive dominant signs + # (matching exp), even though the raw simulation produced + # positive values for each. + assert ind[int(np.argmax(np.abs(ind)))] <= 0, ( + f"strain not sign-corrected; dominant value=" + f"{ind[int(np.argmax(np.abs(ind)))]}" + ) + assert dep[int(np.argmax(np.abs(dep)))] <= 0, ( + f"stress not sign-corrected; dominant value=" + f"{dep[int(np.argmax(np.abs(dep)))]}" + ) + + +def test_run_archives_curves_unchanged_when_no_experimental_data( + workspace, fake_binary, +): + """An evaluator without experimental data leaves the sim curve + untouched (no reference to align against). The raw extractor + output is what gets archived. + """ + from workflow_common import ArchiveDB + from workflow_common.objectives import ( + ObjectiveEvaluator, StressStrainExtractor, + ) + + # Custom evaluator with .extractor but no .experimental. + class _NoExpEvaluator(ObjectiveEvaluator): + def __init__(self): + self.extractor = StressStrainExtractor( + strain_source="time_rate", strain_rate=1.0, + ) + # No .experimental attribute. + + def evaluate(self, results, ctx): + return 0.5 + 0.01 * ctx.gene + + db = workspace / "opt.db" + archive = ArchiveDB(db) + with archive: + run_id = archive.start_run( + seed=8, param_names=["yield_stress", "hardening"], + ) + problem = _build_problem( + workspace, fake_binary, + sim_cases=[SimCase(label="ne")], + objective_specs=[ + ObjectiveSpec(_NoExpEvaluator(), sim_case=0, label="x"), + ], + archive=archive, archive_run_id=run_id, + ) + run_nsga3( + problem, + bounds=Bounds( + lower=np.array([150.0, 1500.0]), + upper=np.array([300.0, 2500.0]), + ), + config=RunConfig( + n_generations=1, population_size=4, + unsga3=True, ref_dirs_partitions=(4, 0), + seed=8, track_hypervolume=False, + ), + ) + + with ArchiveDB(db, readonly=True) as a: + result = a.load_case_curve( + run_id, birth_gen=0, birth_gene=0, sim_case_idx=0, + ) + assert result is not None + ind, dep, _, _ = result + # fake_binary produces positive Szz and positive strain, + # so without sign-matching both should remain positive. + assert ind[int(np.argmax(np.abs(ind)))] >= 0 + assert dep[int(np.argmax(np.abs(dep)))] >= 0 diff --git a/workflows/exaconstit-calibrate/tests/test_objectives.py b/workflows/exaconstit-calibrate/tests/test_objectives.py new file mode 100644 index 0000000..d9639f3 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_objectives.py @@ -0,0 +1,718 @@ +""" +Unit tests for :mod:`workflow_common.objectives`. + +Covers the extractor (with its three strain-derivation modes), the +shipped :class:`StressStrainObjective` evaluator, each error metric, +and the three :class:`FailureHandler` policies. +""" +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from workflow_common import ( + CaseContext, + CaseResultSet, + ConstantPenaltyFailureHandler, + ERROR_METRICS, + FailureHandler, + InfinityFailureHandler, + LegacyLinearSmoother, + ObjectiveEvaluator, + PartialProgressFailureHandler, + PchipSmoother, + StressStrainExtractor, + StressStrainObjective, + TabularResult, + mae, + max_abs_error, + rmse, +) + + +# --- helpers ------------------------------------------------------------- + + +def _make_result_set( + stress_df: pd.DataFrame, + def_grad_df: pd.DataFrame = None, + *, + strain_output_name: str = "avg_def_grad", +) -> CaseResultSet: + """Build a CaseResultSet in memory for a test. + + The ``strain_output_name`` knob lets tests build a result set + where the strain-source table is registered under a non-default + name (e.g. ``"avg_lagrangian_strain"`` for a direct-strain test). + """ + ctx = CaseContext(generation=0, gene=0, obj=0) + tables = { + "avg_stress": TabularResult( + name="avg_stress", df=stress_df, source_path=Path("fake/avg_stress.txt"), + ), + } + if def_grad_df is not None: + tables[strain_output_name] = TabularResult( + name=strain_output_name, df=def_grad_df, + source_path=Path(f"fake/{strain_output_name}.txt"), + ) + return CaseResultSet(ctx=ctx, tables=tables) + + +def _voce_stress(strain: np.ndarray, y0: float, H: float, k: float) -> np.ndarray: + """Voce-law saturating stress response.""" + return y0 + H * (1 - np.exp(-k * strain)) + + +# --- Error metrics ------------------------------------------------------- + + +def test_rmse_zero_for_identical(): + a = np.array([1.0, 2.0, 3.0]) + assert rmse(a, a) == 0.0 + + +def test_rmse_known_value(): + """Sanity on a hand-computable example.""" + sim = np.array([1.0, 2.0, 3.0]) + exp = np.array([2.0, 2.0, 2.0]) + # residual: [-1, 0, 1], sqrt(mean(1, 0, 1)) = sqrt(2/3) + assert rmse(sim, exp) == pytest.approx(np.sqrt(2 / 3)) + + +def test_mae_and_max_abs(): + sim = np.array([1.0, 2.0, 3.0]) + exp = np.array([2.0, 2.0, 0.0]) + # residual: [-1, 0, 3], abs: [1, 0, 3] + assert mae(sim, exp) == pytest.approx(4 / 3) + assert max_abs_error(sim, exp) == pytest.approx(3.0) + + +def test_error_metrics_table_keys(): + """The ERROR_METRICS table should expose at least the three defaults.""" + assert {"rmse", "mae", "max_abs"}.issubset(ERROR_METRICS.keys()) + + +# --- StressStrainExtractor ----------------------------------------------- + + +def test_extractor_biot_strain(): + """Biot strain: strain = X - 1, where X is the axial stretch.""" + t = np.linspace(0, 1, 10) + stress_df = pd.DataFrame({ + "Time": t, + "Szz": _voce_stress(t * 0.01, 200, 2000, 50), + "Sxx": np.zeros_like(t), + "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), + "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + F33 = 1.0 + 0.01 * t + dg_df = pd.DataFrame({"Time": t, "F33": F33}) + + extractor = StressStrainExtractor() # default: biot + strain, stress = extractor.extract(_make_result_set(stress_df, dg_df)) + assert np.allclose(strain, 0.01 * t) + assert len(stress) == len(t) + + +def test_extractor_log_strain(): + """Hencky / log strain: strain = log(F33).""" + t = np.linspace(0, 1, 10) + stress_df = pd.DataFrame({ + "Time": t, + "Szz": np.ones_like(t), + "Sxx": np.zeros_like(t), + "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), + "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + F33 = 1.0 + 0.5 * t + dg_df = pd.DataFrame({"Time": t, "F33": F33}) + + extractor = StressStrainExtractor(strain_source="log") + strain, _ = extractor.extract(_make_result_set(stress_df, dg_df)) + assert np.allclose(strain, np.log(F33)) + + +def test_extractor_time_rate(): + """Constant-rate: strain = rate * time. No def-grad needed.""" + t = np.linspace(0, 1, 10) + stress_df = pd.DataFrame({ + "Time": t, "Szz": np.ones_like(t), "Sxx": np.zeros_like(t), + "Syy": np.zeros_like(t), "Sxy": np.zeros_like(t), + "Syz": np.zeros_like(t), "Sxz": np.zeros_like(t), + }) + extractor = StressStrainExtractor( + strain_source="time_rate", strain_rate=2e-3, + ) + strain, _ = extractor.extract(_make_result_set(stress_df)) + assert np.allclose(strain, 2e-3 * t) + + +def test_extractor_time_rate_requires_strain_rate(): + t = np.linspace(0, 1, 5) + stress_df = pd.DataFrame({ + "Time": t, "Szz": np.zeros_like(t), "Sxx": np.zeros_like(t), + "Syy": np.zeros_like(t), "Sxy": np.zeros_like(t), + "Syz": np.zeros_like(t), "Sxz": np.zeros_like(t), + }) + extractor = StressStrainExtractor(strain_source="time_rate") # no rate + with pytest.raises(ValueError, match="strain_rate"): + extractor.extract(_make_result_set(stress_df)) + + +def test_extractor_log_strain_rejects_nonpositive(): + """``log`` strain requires the axial stretch > 0 everywhere; zero or negative raises.""" + t = np.linspace(0, 1, 5) + stress_df = pd.DataFrame({ + "Time": t, "Szz": np.zeros_like(t), "Sxx": np.zeros_like(t), + "Syy": np.zeros_like(t), "Sxy": np.zeros_like(t), + "Syz": np.zeros_like(t), "Sxz": np.zeros_like(t), + }) + dg_df = pd.DataFrame({"Time": t, "F33": np.array([1, 0.5, 0, 1, 1])}) + extractor = StressStrainExtractor(strain_source="log") + with pytest.raises(ValueError, match="F33 > 0"): + extractor.extract(_make_result_set(stress_df, dg_df)) + + +def test_extractor_missing_def_grad_raises(): + """Biot/log strain need the strain-source file; without it we get a clear KeyError.""" + t = np.linspace(0, 1, 5) + stress_df = pd.DataFrame({ + "Time": t, "Szz": np.zeros_like(t), "Sxx": np.zeros_like(t), + "Syy": np.zeros_like(t), "Sxy": np.zeros_like(t), + "Syz": np.zeros_like(t), "Sxz": np.zeros_like(t), + }) + # no dg passed in + extractor = StressStrainExtractor() + with pytest.raises(KeyError, match="avg_def_grad"): + extractor.extract(_make_result_set(stress_df)) + + +def test_extractor_detects_nan_in_output(): + """Non-finite values in sim output are a red flag; raise.""" + t = np.linspace(0, 1, 5) + stress = np.array([100.0, 200.0, np.nan, 400.0, 500.0]) + stress_df = pd.DataFrame({ + "Time": t, "Szz": stress, "Sxx": np.zeros_like(t), + "Syy": np.zeros_like(t), "Sxy": np.zeros_like(t), + "Syz": np.zeros_like(t), "Sxz": np.zeros_like(t), + }) + dg_df = pd.DataFrame({"Time": t, "F33": 1.0 + 0.01 * t}) + extractor = StressStrainExtractor() + with pytest.raises(ValueError, match="NaN"): + extractor.extract(_make_result_set(stress_df, dg_df)) + + +# --- StressStrainObjective ----------------------------------------------- + + +def _exp_df() -> pd.DataFrame: + """Simple exp reference with matching Voce parameters.""" + strain = np.linspace(0, 0.01, 30) + stress = _voce_stress(strain, 200, 2000, 50) + return pd.DataFrame({"strain": strain, "stress": stress}) + + +def _sim_result(y0=200, H=2000, k=50) -> CaseResultSet: + """Sim result for Voce params y0, H, k over strain 0..0.01.""" + t = np.linspace(0, 1, 50) + strain = 0.01 * t + stress = _voce_stress(strain, y0, H, k) + stress_df = pd.DataFrame({ + "Time": t, "Szz": stress, + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + dg_df = pd.DataFrame({"Time": t, "F33": 1.0 + strain}) + return _make_result_set(stress_df, dg_df) + + +def test_objective_zero_when_sim_matches_exp(): + """Identical Voce params => near-zero RMSE (up to smoothing error).""" + exp_df = _exp_df() + evaluator = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + ) + err = evaluator.evaluate(_sim_result(), CaseContext(0, 0, 0)) + # Won't be exactly 0 because PCHIP resamples on a different grid. + assert err < 1.0, f"expected near-zero, got {err}" + + +def test_objective_positive_when_sim_differs(): + """Different Voce params produce a measurable error.""" + exp_df = _exp_df() + evaluator = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + ) + # Shift yield stress by 50 MPa. + err = evaluator.evaluate(_sim_result(y0=250), CaseContext(0, 0, 0)) + assert err > 10.0 + + +def test_objective_respects_custom_metric(): + """A callable metric is honored.""" + exp_df = _exp_df() + + def constant(sim, exp): + return 42.0 + + evaluator = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + metric=constant, + ) + assert evaluator.evaluate(_sim_result(), CaseContext(0, 0, 0)) == 42.0 + + +def test_objective_respects_named_metric(): + """String names 'rmse', 'mae', 'max_abs' resolve.""" + exp_df = _exp_df() + for name in ("rmse", "mae", "max_abs"): + evaluator = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + metric=name, + ) + err = evaluator.evaluate(_sim_result(y0=250), CaseContext(0, 0, 0)) + assert err > 0 + + +def test_objective_unknown_metric_raises(): + exp_df = _exp_df() + evaluator = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + metric="nonexistent", + ) + with pytest.raises(ValueError, match="unknown metric"): + evaluator.evaluate(_sim_result(), CaseContext(0, 0, 0)) + + +def test_objective_no_strain_overlap_raises(): + """Sim and exp with disjoint strain ranges should raise, not silently zero.""" + exp_df = pd.DataFrame({ + "strain": np.linspace(5.0, 10.0, 20), + "stress": np.linspace(100.0, 200.0, 20), + }) + evaluator = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + ) + with pytest.raises(ValueError, match="overlap"): + evaluator.evaluate(_sim_result(), CaseContext(0, 0, 0)) + + +def test_objective_protocol_conformance(): + exp_df = _exp_df() + evaluator = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + ) + assert isinstance(evaluator, ObjectiveEvaluator) + + +def test_objective_alternate_smoother(): + """LegacyLinearSmoother is an acceptable drop-in.""" + exp_df = _exp_df() + evaluator = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + smoother=LegacyLinearSmoother(n_samples=200), + ) + err = evaluator.evaluate(_sim_result(y0=250), CaseContext(0, 0, 0)) + assert err > 0 + + +# --- FailureHandlers ----------------------------------------------------- + + +def test_infinity_handler(): + h = InfinityFailureHandler() + v = h.on_failure(CaseContext(0, 0, 0), "timeout", None) + assert v == float("inf") + + +def test_infinity_handler_custom_value(): + h = InfinityFailureHandler(value=1e18) + v = h.on_failure(CaseContext(0, 0, 0), "timeout", None) + assert v == 1e18 + + +def test_constant_penalty_handler(): + h = ConstantPenaltyFailureHandler(penalty=999.0) + assert h.on_failure(CaseContext(0, 0, 0), "rc=7", None) == 999.0 + + +def test_partial_progress_no_results_returns_base_penalty(): + exp_df = _exp_df() + inner = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + ) + h = PartialProgressFailureHandler( + inner_evaluator=inner, base_penalty=1e6, + ) + v = h.on_failure(CaseContext(0, 0, 0), "no output", None) + assert v == 1e6 + + +def test_partial_progress_half_way(): + """A sim that got halfway through should get roughly half the penalty.""" + exp_df = _exp_df() + inner = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + ) + h = PartialProgressFailureHandler( + inner_evaluator=inner, + base_penalty=1e6, + progress_weight=1.0, + strain_target=0.01, + ) + # Partial sim: only first 25 rows (strain 0..0.005, half of 0.01). + t = np.linspace(0, 0.5, 25) + strain = 0.01 * t + stress = _voce_stress(strain, 200, 2000, 50) + stress_df = pd.DataFrame({ + "Time": t, "Szz": stress, + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + dg_df = pd.DataFrame({"Time": t, "F33": 1.0 + strain}) + partial = _make_result_set(stress_df, dg_df) + + v = h.on_failure(CaseContext(0, 0, 0), "timeout", partial) + # Roughly 50% of base_penalty since progress_fraction ~ 0.5. + # Plus a small contribution from the inner error (close to 0 for + # matching parameters). + assert 4e5 < v < 6e5 + + +def test_failure_handlers_protocol_conformance(): + exp_df = _exp_df() + inner = StressStrainObjective( + experimental=exp_df, + extractor=StressStrainExtractor(), + ) + assert isinstance(InfinityFailureHandler(), FailureHandler) + assert isinstance(ConstantPenaltyFailureHandler(), FailureHandler) + assert isinstance( + PartialProgressFailureHandler(inner_evaluator=inner), FailureHandler + ) + + +def test_extractor_supports_non_exaconstit_column_convention(): + """The framework lives in ExaConstit and defaults to ExaConstit's + column convention (Szz / F33 / Time). Users calibrating a different + FEM code whose output uses a different convention (s11 / F11 / time, + or anything else) can still drive the extractor by supplying + explicit column names. This test verifies that override path stays + working — the framework provides ExaConstit defaults but doesn't + hard-code them anywhere in the logic. + """ + t = np.linspace(0, 1, 10) + # Fake a non-ExaConstit code's output: lowercase "time", "s11" as + # the axial stress, no Volume column. + stress_df = pd.DataFrame({ + "time": t, + "s11": _voce_stress(t * 0.01, 200, 2000, 50), + }) + dg_df = pd.DataFrame({"time": t, "F11": 1.0 + 0.01 * t}) + + extractor = StressStrainExtractor( + stress_column="s11", + strain_source_column="F11", + time_column="time", + # strain_source left at default ("biot") — applies the X-1 + # formula to whatever strain_source_column names. + ) + strain, stress = extractor.extract(_make_result_set(stress_df, dg_df)) + assert np.allclose(strain, 0.01 * t) + assert len(stress) == len(t) + # Sanity: default-construction against the same non-ExaConstit + # DataFrame fails loudly (ExaConstit default Szz not in columns). + default_extractor = StressStrainExtractor() + with pytest.raises(KeyError, match="Szz"): + default_extractor.extract(_make_result_set(stress_df, dg_df)) + + + + + +def test_extractor_direct_strain_reads_column_verbatim(): + """``strain_source='direct'`` reads a strain measure already on disk. + + Use case: the simulation outputs Lagrange/Euler/Biot strain in + its own file, and the user wants to consume it directly rather + than recomputing from F. The extractor's job becomes "pull the + column" with no math applied. + """ + t = np.linspace(0, 1, 10) + stress_df = pd.DataFrame({ + "Time": t, "Szz": _voce_stress(t * 0.05, 200, 2000, 50), + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + # Simulate a Lagrange-strain output file with E33 already + # computed by the sim. + e33 = 0.5 * ((1.0 + 0.05 * t) ** 2 - 1.0) + strain_df = pd.DataFrame({"Time": t, "E33": e33}) + extractor = StressStrainExtractor( + strain_source="direct", + strain_source_output="avg_lagrangian_strain", + strain_source_column="E33", + ) + rs = _make_result_set(stress_df, strain_df, + strain_output_name="avg_lagrangian_strain") + strain, stress = extractor.extract(rs) + # No transformation — strain comes straight from the column. + assert np.allclose(strain, e33) + assert len(stress) == len(t) + + +def test_extractor_window_crops_to_strain_interval(): + """``window=(lo, hi)`` keeps only points where lo <= |strain| <= hi. + + This is the optimization-restriction feature: skip the elastic + regime, ignore the elastic-plastic transition, focus the + optimizer on the plastic portion. + """ + t = np.linspace(0, 1, 21) + stress_df = pd.DataFrame({ + "Time": t, "Szz": _voce_stress(t * 0.1, 200, 2000, 50), + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + # Strain ramps 0 -> 0.1 (Biot). + dg_df = pd.DataFrame({"Time": t, "F33": 1.0 + 0.1 * t}) + extractor = StressStrainExtractor(window=(0.02, 0.08)) + strain, stress = extractor.extract(_make_result_set(stress_df, dg_df)) + # Every kept point lies in the interval. + assert np.all(strain >= 0.02 - 1e-12) + assert np.all(strain <= 0.08 + 1e-12) + # Same length on both sides. + assert len(strain) == len(stress) + # Original had 21 points; cropping to [0.02, 0.08] keeps roughly + # the middle 13 points (indices 4..16 inclusive on a uniform grid). + assert 10 <= len(strain) <= 14 + + +def test_extractor_window_works_for_compression_via_abs(): + """Window comparison uses ``|strain|`` so a compression run with + negative strain values gets the same windowing as tension. + """ + t = np.linspace(0, 1, 11) + stress_df = pd.DataFrame({ + "Time": t, "Szz": -1.0 * _voce_stress(t * 0.1, 200, 2000, 50), + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + # Compression: F33 < 1, strain < 0. + dg_df = pd.DataFrame({"Time": t, "F33": 1.0 - 0.1 * t}) + extractor = StressStrainExtractor(window=(0.02, 0.08)) + strain, _ = extractor.extract(_make_result_set(stress_df, dg_df)) + # Returned strain is still negative (window doesn't change sign); + # only the |strain| satisfies the bounds. + assert np.all(strain <= 0.0) + assert np.all(np.abs(strain) >= 0.02 - 1e-12) + assert np.all(np.abs(strain) <= 0.08 + 1e-12) + + +def test_extractor_window_excludes_all_raises(): + """A window with no overlap with the actual strain range fails loudly.""" + t = np.linspace(0, 1, 5) + stress_df = pd.DataFrame({ + "Time": t, "Szz": np.zeros_like(t), + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + dg_df = pd.DataFrame({"Time": t, "F33": 1.0 + 0.01 * t}) + # Strain range is [0, 0.01]; window at [10, 100] is way out. + extractor = StressStrainExtractor(window=(10.0, 100.0)) + with pytest.raises(ValueError, match="excluded every data point"): + extractor.extract(_make_result_set(stress_df, dg_df)) + + +def test_extractor_window_bad_bounds_raises(): + """``window=(hi, lo)`` is operator error; flag at extract time.""" + t = np.linspace(0, 1, 5) + stress_df = pd.DataFrame({ + "Time": t, "Szz": np.zeros_like(t), + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Sxy": np.zeros_like(t), "Syz": np.zeros_like(t), + "Sxz": np.zeros_like(t), + }) + dg_df = pd.DataFrame({"Time": t, "F33": 1.0 + 0.01 * t}) + extractor = StressStrainExtractor(window=(0.05, 0.01)) # inverted + with pytest.raises(ValueError, match="lower bound .* exceeds"): + extractor.extract(_make_result_set(stress_df, dg_df)) + + +# --- StressStrainExtractor JSON serialization --------------------------- + + +def test_extractor_to_dict_round_trips_through_json(): + """to_dict -> json.dumps -> json.loads -> from_dict is identity. + + Used by the archive to persist run-time extractor configs so + plotters reconstruct exactly what the optimizer scored against. + Tuples MUST round-trip back to tuples (not lists) since the + dataclass declares window: Tuple. + """ + import json + e = StressStrainExtractor( + stress_column="Sxx", + strain_source="time_rate", + strain_rate=1.5e-3, + time_column="t", + window=(0.005, 0.13), + ) + d = e.to_dict() + serialized = json.dumps(d) + restored = StressStrainExtractor.from_dict(json.loads(serialized)) + assert restored == e + # Window must be a tuple, not a list — dataclass type contract. + assert isinstance(restored.window, tuple) + + +def test_extractor_from_dict_tolerates_missing_keys(): + """An older config (lacking newer fields) should still load by + falling back to dataclass defaults, so a forward-compat plotter + can read older archives.""" + # Just stress info — most of the dataclass uses defaults. + e = StressStrainExtractor.from_dict({ + "strain_source": "time_rate", + "strain_rate": 1e-3, + }) + assert e.strain_source == "time_rate" + assert e.strain_rate == 1e-3 + # Defaulted fields. + assert e.stress_column == "Szz" + assert e.window is None + + +def test_extractor_from_dict_ignores_unknown_keys(): + """Forward-compat the other way: a newer archive with extra + fields should still load on older code that doesn't know about + them.""" + e = StressStrainExtractor.from_dict({ + "strain_source": "biot", + "strain_source_column": "F33", + "future_field_not_yet_invented": "ignored", + }) + assert e.strain_source == "biot" + assert e.strain_source_column == "F33" + + +def test_extractor_to_dict_round_trips_window_none(): + """window=None must JSON-serialize as null and round-trip back to None.""" + import json + e = StressStrainExtractor(window=None) + d = e.to_dict() + assert d["window"] is None + assert json.loads(json.dumps(d))["window"] is None + assert StressStrainExtractor.from_dict(d).window is None + + + + +# --- Example evaluator alignment direction ------------------------------ + + +def test_std_normalized_stress_evaluator_aligns_exp_to_sim_via_pchip(): + """The example's stress evaluator interpolates EXPERIMENTAL data + onto the SIMULATION's strain grid via PchipSmoother — the + direction the original ExaConstit code used. Verify the score + matches what we'd compute by hand using PchipSmoother.sample_at, + NOT what np.interp(sim_at_exp_grid) would produce. + + Pins Robert's correction: 'It was interpolating the experimental + data to the simulation data. It's the entire reason you have + workflow/smoothing.py.' + """ + import sys + from pathlib import Path + sys.path.insert(0, str(Path(__file__).parent.parent / "examples")) + import nsga3_calibration + + from workflow_common.smoothing import PchipSmoother + + # Sim and exp on different strain grids. + sim_strain = np.linspace(0.0, 0.1, 50) + sim_stress = 200.0 + 1900.0 * (1 - np.exp(-48.0 * sim_strain)) + exp_strain = np.linspace(0.0, 0.1, 25) + exp_stress = 210.0 + 1900.0 * (1 - np.exp(-48.0 * exp_strain)) + exp_df = pd.DataFrame({"strain": exp_strain, "stress": exp_stress}) + + class _FakeExtractor: + def extract(self, results): + return sim_strain, sim_stress + + ev = nsga3_calibration._StdNormalizedStressEvaluator( + experimental=exp_df, + extractor=_FakeExtractor(), + ) + score = ev.evaluate(None, None) + + # Hand-compute the expected score: align exp onto sim's strain + # grid via PchipSmoother (same tool the evaluator uses), then + # RMSE / std on that grid. + smoother = PchipSmoother(strict_monotonic=False) + exp_at_sim = smoother.sample_at( + exp_strain, exp_stress, sim_strain, + ).y + residual = sim_stress - exp_at_sim + expected = float(np.sqrt(np.mean(residual ** 2)) / np.std(exp_at_sim)) + assert score == pytest.approx(expected, rel=1e-9) + + +def test_std_normalized_slope_evaluator_aligns_exp_to_sim_via_pchip(): + """Slope evaluator's analogue of the stress test.""" + import sys + from pathlib import Path + sys.path.insert(0, str(Path(__file__).parent.parent / "examples")) + import nsga3_calibration + + from workflow_common.smoothing import PchipSmoother + + sim_strain = np.linspace(0.0, 0.1, 50) + sim_stress = 200.0 + 1900.0 * (1 - np.exp(-48.0 * sim_strain)) + exp_strain = np.linspace(0.0, 0.1, 25) + exp_stress = 210.0 + 1900.0 * (1 - np.exp(-48.0 * exp_strain)) + exp_df = pd.DataFrame({"strain": exp_strain, "stress": exp_stress}) + + class _FakeExtractor: + def extract(self, results): + return sim_strain, sim_stress + + ev = nsga3_calibration._StdNormalizedSlopeEvaluator( + experimental=exp_df, + extractor=_FakeExtractor(), + ) + score = ev.evaluate(None, None) + + smoother = PchipSmoother(strict_monotonic=False) + exp_at_sim = smoother.sample_at( + exp_strain, exp_stress, sim_strain, + ).y + diff_strain = np.diff(sim_strain) + sim_slope = np.diff(sim_stress) / diff_strain + exp_slope = np.diff(exp_at_sim) / diff_strain + residual = sim_slope - exp_slope + expected = float(np.sqrt(np.mean(residual ** 2)) / np.std(exp_slope)) + assert score == pytest.approx(expected, rel=1e-9) diff --git a/workflows/exaconstit-calibrate/tests/test_paths.py b/workflows/exaconstit-calibrate/tests/test_paths.py new file mode 100644 index 0000000..e01ece2 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_paths.py @@ -0,0 +1,201 @@ +""" +Unit tests for :mod:`workflow_common.paths`. + +Covers the :class:`TemplatePathResolver` - the default +:class:`PathResolver` implementation. Exercises the format-string +substitution for working directories and output files, the +``{working_dir}`` special key, missing-key error reporting, and the +``defaults`` / ``extra`` precedence rules. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from workflow_common.paths import ( + CaseContext, + PathResolver, + TemplatePathResolver, +) + + +def test_case_context_as_format_mapping_basic(): + """Top-level fields and the gen alias both appear in the mapping.""" + ctx = CaseContext(generation=4, gene=7, obj=1) + m = ctx.as_format_mapping() + assert m["generation"] == 4 + assert m["gen"] == 4 # alias + assert m["gene"] == 7 + assert m["obj"] == 1 + + +def test_case_context_extra_merged(): + """Entries in ``extra`` appear in the format mapping too.""" + ctx = CaseContext( + generation=0, gene=0, obj=0, extra={"rve_name": "grain_32"} + ) + assert ctx.as_format_mapping()["rve_name"] == "grain_32" + + +def test_case_context_extra_cannot_shadow_core_fields(): + """Top-level fields win over collisions in ``extra``.""" + # Users sometimes pass "generation" or "gen" in extra by mistake; + # the top-level value must take precedence. + ctx = CaseContext( + generation=99, gene=0, obj=0, extra={"generation": 1, "gen": 2} + ) + m = ctx.as_format_mapping() + assert m["generation"] == 99 + assert m["gen"] == 99 + + +def test_working_dir_basic(workspace: Path): + """Simple pattern produces the expected path.""" + resolver = TemplatePathResolver( + working_dir_pattern="wf/gen_{generation}/gene_{gene}", + output_file_patterns={}, + root=workspace, + ) + ctx = CaseContext(generation=2, gene=5, obj=0) + assert resolver.working_dir(ctx) == workspace / "wf" / "gen_2" / "gene_5" + + +def test_working_dir_absolute_pattern_ignores_root(workspace: Path): + """Absolute patterns bypass the root prefix.""" + resolver = TemplatePathResolver( + working_dir_pattern="/absolute/path/gen_{generation}", + output_file_patterns={}, + root=workspace, + ) + ctx = CaseContext(generation=3, gene=0, obj=0) + assert resolver.working_dir(ctx) == Path("/absolute/path/gen_3") + + +def test_working_dir_missing_key_raises_helpfully(workspace: Path): + """Missing placeholder -> KeyError whose message lists available keys.""" + resolver = TemplatePathResolver( + working_dir_pattern="{missing_key}/gen_{generation}", + output_file_patterns={}, + root=workspace, + ) + ctx = CaseContext(generation=0, gene=0, obj=0) + with pytest.raises(KeyError) as excinfo: + resolver.working_dir(ctx) + # The error must mention the key name AND the available fallbacks, + # so typos are easy to debug. + assert "missing_key" in str(excinfo.value) + assert "generation" in str(excinfo.value) + + +def test_output_file_with_working_dir_placeholder(workspace: Path): + """``{working_dir}`` in an output pattern expands to the case's working dir.""" + resolver = TemplatePathResolver( + working_dir_pattern="gen_{generation}/gene_{gene}", + output_file_patterns={ + "stress": "{working_dir}/results/{basename}/avg_stress.txt", + }, + defaults={"basename": "options"}, + root=workspace, + ) + ctx = CaseContext(generation=0, gene=1, obj=0) + expected = workspace / "gen_0" / "gene_1" / "results" / "options" / "avg_stress.txt" + assert resolver.output_file("stress", ctx) == expected + + +def test_output_file_unknown_logical_name_raises(workspace: Path): + """Unknown output names get a descriptive KeyError.""" + resolver = TemplatePathResolver( + working_dir_pattern="x", + output_file_patterns={"known": "{working_dir}/a.txt"}, + root=workspace, + ) + ctx = CaseContext(generation=0, gene=0, obj=0) + with pytest.raises(KeyError) as excinfo: + resolver.output_file("not_a_thing", ctx) + assert "not_a_thing" in str(excinfo.value) + assert "known" in str(excinfo.value) # lists the valid ones + + +def test_defaults_are_overridden_by_extra(workspace: Path): + """``ctx.extra`` entries override the resolver's defaults on name collision.""" + resolver = TemplatePathResolver( + working_dir_pattern="{basename}/gene_{gene}", + output_file_patterns={}, + defaults={"basename": "options"}, + root=workspace, + ) + ctx = CaseContext( + generation=0, gene=0, obj=0, extra={"basename": "override"} + ) + assert resolver.working_dir(ctx) == workspace / "override" / "gene_0" + + +def test_known_outputs_returns_registered_names(workspace: Path): + """known_outputs reflects the patterns passed at construction.""" + resolver = TemplatePathResolver( + working_dir_pattern="x", + output_file_patterns={"a": "x/a", "b": "x/b", "c": "x/c"}, + root=workspace, + ) + assert set(resolver.known_outputs()) == {"a", "b", "c"} + + +def test_template_resolver_satisfies_protocol(workspace: Path): + """TemplatePathResolver should pass runtime_checkable isinstance check.""" + resolver = TemplatePathResolver( + working_dir_pattern="x", + output_file_patterns={"a": "x/a"}, + root=workspace, + ) + assert isinstance(resolver, PathResolver) + + +def test_output_file_auto_prepends_working_dir_for_bare_relative_pattern(): + """Regression: output patterns WITHOUT an explicit ``{working_dir}`` + marker used to return bare relative paths, which then resolved + against the driver's cwd at read-time and silently missed the case + dir. Now they auto-prepend working_dir, matching how + ``working_dir_pattern`` auto-prepends ``root``. + """ + from pathlib import Path + + from workflow_common import TemplatePathResolver, CaseContext + + r = TemplatePathResolver( + working_dir_pattern="gen_{generation}/gene_{gene}_obj_{obj}", + output_file_patterns={ + "bare": "results/options/avg_stress.txt", + "with_marker": "{working_dir}/results/options/avg_stress.txt", + "abs": "/tmp/external.txt", + }, + root=Path("calibration_run"), + ) + ctx = CaseContext(generation=0, gene=3, obj=1) + wd = r.working_dir(ctx) + + # Bare relative: auto-prepended working_dir. + assert r.output_file("bare", ctx) == wd / "results/options/avg_stress.txt" + # Explicit marker: unchanged — user opted in, we don't double up. + assert r.output_file("with_marker", ctx) == wd / "results/options/avg_stress.txt" + # Absolute: honored exactly as-is, no prepend. + assert r.output_file("abs", ctx) == Path("/tmp/external.txt") + + +def test_output_file_bare_relative_matches_working_dir_regardless_of_root_type(tmp_path): + """The auto-prepend must work for both absolute and relative + workspace roots — relative was the case that hurt in production. + """ + from pathlib import Path + + from workflow_common import TemplatePathResolver, CaseContext + + for root in (Path("relative_workspace"), tmp_path / "absolute_workspace"): + r = TemplatePathResolver( + working_dir_pattern="gen_{generation}/gene_{gene}", + output_file_patterns={"s": "results/avg_stress.txt"}, + root=root, + ) + ctx = CaseContext(generation=0, gene=0, obj=0) + expected = r.working_dir(ctx) / "results/avg_stress.txt" + assert r.output_file("s", ctx) == expected diff --git a/workflows/exaconstit-calibrate/tests/test_plot_solutions.py b/workflows/exaconstit-calibrate/tests/test_plot_solutions.py new file mode 100644 index 0000000..4880ce4 --- /dev/null +++ b/workflows/exaconstit-calibrate/tests/test_plot_solutions.py @@ -0,0 +1,1957 @@ +"""Tests for the public selection API and the plot_solutions example. + +Two surfaces: + +1. :mod:`workflows.optimization.inspect_archive` public API — + ``select_top_genes``, ``pick_run``, ``dedup_on_gene_vector``, + ``collect_all_genes``, ``resolve_archive_path``. These were + promoted from internal helpers and need their library contracts + pinned independently of the CLI. + +2. :mod:`examples.plot_solutions` — the data-loading helpers + (skipping matplotlib UI which requires a display). Verifies the + example correctly composes the public API to produce ranked + ``RankedGene`` lists matching the user's request. + +Matplotlib plotting itself isn't tested at the rendering level; the +example uses matplotlib's standard ``Slider`` and ``pick_event`` +APIs, which are themselves well-tested upstream. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from workflow_common.archive import ArchiveDB, GeneRecord +from workflow_common.paths import CaseContext +from workflow_common.results import CaseResultSet, TabularResult +from workflows.optimization.inspect_archive import ( + RankedGene, + collect_all_genes, + dedup_on_gene_vector, + pick_run, + resolve_archive_path, + select_top_genes, +) + + +# --- Helpers ------------------------------------------------------------ + + +def _voce_stress_df(y0=200.0, H=1900.0, k=48.0, n=20) -> pd.DataFrame: + """Synthetic Voce-curve stress data shaped like ExaConstit avg_stress.""" + t = np.linspace(0.0, 1.0, n) + stress = y0 + H * (1 - np.exp(-k * t)) + return pd.DataFrame({ + "Time": t, "Volume": np.ones_like(t), + "Sxx": np.zeros_like(t), "Syy": np.zeros_like(t), + "Szz": stress, + "Sxy": np.zeros_like(t), "Sxz": np.zeros_like(t), + "Syz": np.zeros_like(t), + }) + + +def _voce_defgrad_df(rate=1.0, n=20) -> pd.DataFrame: + t = np.linspace(0.0, 1.0, n) + eps = rate * t + F33 = 1.0 + eps + return pd.DataFrame({ + "Time": t, "Volume": np.ones_like(t), + "F11": 1.0 - 0.5 * eps, "F12": np.zeros_like(t), + "F13": np.zeros_like(t), + "F21": np.zeros_like(t), "F22": 1.0 - 0.5 * eps, + "F23": np.zeros_like(t), + "F31": np.zeros_like(t), "F32": np.zeros_like(t), + "F33": F33, + }) + + +def _make_case_result_set( + ctx: CaseContext, y0: float = 200.0, H: float = 1900.0, +) -> CaseResultSet: + return CaseResultSet(ctx=ctx, tables={ + "avg_stress": TabularResult( + name="avg_stress", df=_voce_stress_df(y0=y0, H=H), + source_path=Path("fake/avg_stress.txt"), + ), + "avg_def_grad": TabularResult( + name="avg_def_grad", df=_voce_defgrad_df(), + source_path=Path("fake/avg_def_grad.txt"), + ), + }) + + +# --- select_top_genes -------------------------------------------------- + + +def _gene( + *, gen_idx, pop_idx, fitness, gene_vec=None, rank=0, +): + """Compact GeneRecord factory for ranking tests.""" + return GeneRecord( + run_id="r", gen_idx=gen_idx, pop_idx=pop_idx, + birth_gen=gen_idx, birth_gene=pop_idx, + gene_vector=np.asarray( + gene_vec if gene_vec is not None + else [float(gen_idx), float(pop_idx)], + dtype=float, + ), + fitness=tuple(fitness), rank=rank, + ) + + +def test_select_top_genes_l2_ranks_by_l2_norm_ascending(): + """The L2 category should be sorted ascending by sqrt(sum(f^2)).""" + genes = [ + _gene(gen_idx=0, pop_idx=0, fitness=[3.0, 4.0]), # L2=5 + _gene(gen_idx=0, pop_idx=1, fitness=[1.0, 1.0]), # L2~1.41 (best) + _gene(gen_idx=0, pop_idx=2, fitness=[6.0, 8.0]), # L2=10 + ] + out = select_top_genes( + genes, objective_labels=["a", "b"], top_n=3, + categories=("l2",), + ) + l2 = out["l2"] + assert [rg.gene.pop_idx for rg in l2] == [1, 0, 2] + assert [rg.rank for rg in l2] == [0, 1, 2] + assert [rg.category for rg in l2] == ["l2"] * 3 + # Score == L2 norm exactly for the L2 category. + assert l2[0].score == pytest.approx(np.sqrt(2.0)) + assert l2[1].score == pytest.approx(5.0) + + +def test_select_top_genes_per_objective_ranks_each_axis_separately(): + """obj: