diff --git a/NEWS.md b/NEWS.md index f7dbd54..a33bb2c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -42,6 +42,32 @@ - The `python://` prefix also works with `fzo --output-cmd NAME="python://..."` on the CLI (as do `jq://`, `yq://`, `xpath://` and `bash://`). +### Vector (array) output support in fzr/fzo + +- Output entries can resolve to a Python list, not just a scalar — a + natural fit for time series, per-node profiles, spectra, etc. Supported + via `python://grep(..., all=True)`, `csv_file(column=...)`, + `hdf5_file(dataset=...)`, `jq://`/`yq://` filters selecting an array, + `xpath://` matching several XML nodes, or a plain shell command printing + a JSON array. `fzr`/`fzo` store the full list per case unmodified — no + flattening, truncation or padding, so cases can have vectors of + different lengths. +- Fixed `xpath://`: an expression matching more than one XML node used to + return a single string with the matched nodes' text concatenated by + `xmllint` with no reliable separator, instead of a vector. It now + returns a list of per-node values (each cast like `grep`'s default); + single-node and zero-node matches are unaffected (same scalar behavior + as before). +- New `tests/test_vector_outputs.py` and `examples/vector_outputs_example.md` + cover vector outputs end-to-end across `fzo`/`fzr`, all extraction forms, + and fzo/fzr coherence. See `doc/model-definition.md` ("output" → "Vector / + array outputs") for the full write-up, including the CSV/JSON + persistence caveat (`to_csv()` stringifies lists; prefer `--format json`, + `to_pickle`, or `to_parquet` for a lossless round trip). +- `fzd` (design of experiments / optimization) still expects a scalar + objective per case; vector-output support there is tracked as a + follow-up. + ## Version 1.1 (2026-06-15) ### CLI argument aliases (README forms now work) diff --git a/README.md b/README.md index 4fb2ad4..5abfe78 100644 --- a/README.md +++ b/README.md @@ -1811,6 +1811,36 @@ results = fz.fzo("output_dir", model) 4. Keep as string 5. Single-element arrays → scalar +### Vector (array) Outputs + +An output entry does not have to be a single number: `python://`, `jq://`, +`yq://` and plain shell commands can all return a full list (a time series, a +per-node profile, a spectrum...), which lands in the results DataFrame as one +Python list per case — `fzr`/`fzo` never flatten, truncate or pad vector +outputs, and cases are free to produce vectors of different lengths: + +```python +model = { + "output": { + # whole JSON array file -> plain Python list + "T_series": "python://json_file('series.json')", + # every regex match -> list + "T_series_grep": "python://grep(r'T=(\S+)', 'log.txt', all=True)", + # jq/yq filter selecting an array + "T_series_jq": "jq://.temperatures results.json", + # xpath:// returns a list too when the expression matches more + # than one XML node (e.g. several siblings) + "T_series_xpath": "xpath://'//value/text()' output.xml", + } +} +``` + +Note the single-element-array simplification above (rule 5) only applies to +the legacy plain-shell-command form; it does not apply to `python://`, +`jq://`, `yq://` or `xpath://` outputs, so a length-1 vector stays a +one-element list with those forms. See `examples/vector_outputs_example.md` +and `doc/model-definition.md` ("output" → "Vector / array outputs") for more. + ### Progress Callbacks Monitor execution progress in real-time with custom callback functions: @@ -3234,6 +3264,7 @@ Practical examples in the `examples/` directory: - **examples/examples.md** - Overview of all examples - **examples/fzd_example.md** - Iterative design of experiments (fzd) examples - **examples/dataframe_input.md** - DataFrame input for non-factorial designs +- **examples/vector_outputs_example.md** - Vector/array-valued outputs with fzr and fzo - **examples/algorithm_options_example.md** - Algorithm options format guide - **examples/r_interpreter_example.md** - R interpreter setup and usage - **examples/shell_path_example.md** - FZ_SHELL_PATH configuration examples diff --git a/doc/INDEX.md b/doc/INDEX.md index af75120..4de5839 100644 --- a/doc/INDEX.md +++ b/doc/INDEX.md @@ -95,6 +95,7 @@ Quick reference index for finding specific topics in the FZ context documentatio | commentline | model-definition.md | "Model Fields" → "commentline" | | interpreter | model-definition.md | "Model Fields" → "interpreter" | | output | model-definition.md | "Model Fields" → "output" | +| Vector / array outputs | model-definition.md | "Model Fields" → "Vector / array outputs" | | Complete model examples | model-definition.md | "Complete Examples" | | Model aliases | model-definition.md | "Model Aliases" | | Output extraction | model-definition.md | "Advanced Output Extraction" | @@ -260,6 +261,7 @@ Quick keyword search: - **Examples**: quick-examples.md - **CLI**: quick-examples.md → "CLI Quick Examples" - **DataFrame**: core-functions.md → "fzr", "fzo" +- **Vector / array outputs**: model-definition.md → "Vector / array outputs", examples/vector_outputs_example.md - **Interrupt**: parallel-and-caching.md → "Interrupt Handling" - **Retry**: parallel-and-caching.md → "Retry Mechanism" - **Performance**: parallel-and-caching.md → "Performance Optimization" diff --git a/doc/model-definition.md b/doc/model-definition.md index cf95445..afd39f5 100644 --- a/doc/model-definition.md +++ b/doc/model-definition.md @@ -242,6 +242,65 @@ model = { } ``` +If the expression matches more than one node (e.g. +`//result/value/text()` against several `` siblings), the result is +a list of per-node values instead of a single string — see "Vector / array +outputs" below. + +### Vector / array outputs + +An `output` entry does not have to resolve to a single value. Any of the +forms above can return a Python list instead of a scalar, and `fzo`/`fzr` +store it as-is (one full list per matched case, in one DataFrame cell) — +this is the natural way to capture a time series, a spatial profile, a +spectrum, or any other array-shaped simulation result: + +```python +model = { + "output": { + # Whole JSON array file -> plain Python list + "T_series": "python://json_file('series.json')", + # Every regex match -> list + "T_series_grep": "python://grep(r'T=(\S+)', 'log.txt', all=True)", + # A CSV column -> list + "T_series_csv": "python://csv_file('data.csv', column='T')", + # An HDF5 dataset -> list (requires the optional h5py package) + "T_series_h5": "python://hdf5_file('results.h5', 'T')", + # jq/yq filter selecting a JSON/YAML array + "T_series_jq": "jq://.temperatures results.json", + # xpath:// selecting more than one XML node + "T_series_xpath": "xpath://'//value/text()' output.xml", + # Plain shell command whose stdout is a JSON array + "T_series_bash": "cat series.json", + } +} +``` + +`fzr` places no constraint on vector length or shape across cases: two +cases can perfectly well produce vectors of different lengths (e.g. an +iterative solver that converges after a variable number of steps) — each +row simply keeps its own list, with no padding or truncation. + +**Single-element vectors**: the plain-shell-command form (`bash://` or the +implicit default) applies `cast_output`'s backward-compatible +simplification, which unwraps a single-element JSON array to its scalar +element (`"echo '[42]'"` → `42`, not `[42]`). This can silently turn one +row of an otherwise vector-valued column into a bare scalar, if that +particular case happens to produce a length-1 result. The `python://`, +`jq://`, `yq://` and `xpath://` forms never do this — a length-1 result +stays a one-element list — so prefer one of those forms whenever a vector +output's length can legitimately be 1. + +**Persisting vector-valued results**: `fzr`/`fzo` results are plain pandas +DataFrames. `to_dict(orient="records")` / `json.dumps(..., default=str)` +(and the CLI's `--format json`) round-trip vectors as native JSON arrays. +`to_csv()` (and `--format csv`) stringifies each list instead (e.g. +`"[1, 2, 3]"`); reload with `json.loads`/`ast.literal_eval` per cell, or +prefer `to_pickle`/`to_parquet` for a lossless round trip. + +See `examples/vector_outputs_example.md` for a complete, runnable +walk-through. + ### id (optional) Unique identifier for the model, useful for documentation and logging. diff --git a/examples/vector_outputs_example.md b/examples/vector_outputs_example.md new file mode 100644 index 0000000..34a4356 --- /dev/null +++ b/examples/vector_outputs_example.md @@ -0,0 +1,149 @@ +# Vector (array) outputs with fzr and fzo + +Simulation outputs are not always a single number: a time series, a spatial +profile, per-node results, a spectrum... fz already lets an `output` entry +evaluate to a Python **list** instead of a scalar. This example shows the +supported ways to produce a vector output, and how it flows through `fzr` +(one full list per case, in one DataFrame cell) and `fzo` (reading the same +results back). + +`fzd` (design of experiments / optimization) still expects a single scalar +objective per case for now — vector output support there is a separate, +follow-up piece of work. + +## The toy model + +A "simulation" that prints an exponential decay time series to `series.json`, +run for `n_steps` steps starting at `T0`: + +```python +import os + +# input.txt and run_case.sh live side by side in the working directory fzr +# is called from -- the same layout used throughout fz's own examples/tests. +with open("input.txt", "w") as f: + f.write("n_steps=${n_steps}\nT0=${T0}\n") + +with open("run_case.sh", "w", newline="\n") as f: + f.write("#!/bin/bash\n") + f.write("source input.txt\n") + f.write( + "python3 -c \"\n" + "import json\n" + "n = int($n_steps)\n" + "T0 = float($T0)\n" + "series = [round(T0 * (0.9 ** i), 3) for i in range(n)]\n" + "print(json.dumps(series))\n" + "with open(\'series.txt\', \'w\') as fh:\n" + " fh.write(chr(10).join(str(v) for v in series))\n" + "\" > series.json\n" + ) +os.chmod("run_case.sh", 0o755) +``` + +## Four ways to extract the same vector + +```python +model_python_json = { + "varprefix": "$", + "delim": "{}", + "output": { + # Whole JSON array file -> plain Python list, no shell involved + "T_series": "python://json_file('series.json')", + }, +} + +model_python_grep_all = { + "varprefix": "$", + "delim": "{}", + "output": { + # One value per line in series.txt -> list, via grep(..., all=True) + "T_series": "python://grep(r'(\\S+)', 'series.txt', all=True)", + }, +} + +model_jq = { + "varprefix": "$", + "delim": "{}", + "output": { + # jq filter selecting the whole array (requires the jq executable) + "T_series": "jq://. series.json", + }, +} + +model_bash = { + "varprefix": "$", + "delim": "{}", + "output": { + # Legacy shell command: cast_output() parses JSON arrays too + "T_series": "cat series.json", + }, +} +``` + +All four are equivalent for this file, and all four return `T_series` as a +Python list — pick whichever matches the rest of your output-extraction +tooling (`csv_file(path, column=...)` and `hdf5_file(path, dataset=...)` work +the same way for CSV/HDF5 vector results, and `xpath://` returns a list when +the expression matches more than one XML node). + +## Running fzr across several cases + +```python +import fz + +result = fz.fzr( + "input.txt", + {"n_steps": 5, "T0": [50.0, 100.0, 200.0]}, # 3 cases + model_python_json, + calculators="sh://bash run_case.sh", + results_dir="vector_demo_results", +) + +for i in range(len(result)): + print(result["T0"].iloc[i], "->", result["T_series"].iloc[i]) +# 50.0 -> [50.0, 45.0, 40.5, 36.45, 32.805] +# 100.0 -> [100.0, 90.0, 81.0, 72.9, 65.61] +# 200.0 -> [200.0, 180.0, 162.0, 145.8, 131.22] +``` + +Each `T_series` cell holds the *full* list for that case — `fzr` does not +flatten, truncate or otherwise reshape vector outputs. Cases are free to +produce vectors of different lengths (e.g. an iterative solver that +converges after a variable number of steps); each row simply keeps its own +list, no padding is applied. + +## Reading the same results back with fzo + +```python +fzo_result = fz.fzo("vector_demo_results/*", model_python_json) + +# fzo() and fzr() may not list cases in the same order (fzo() sorts by +# matched directory), so compare per case via the T0 column rather than +# raw row position. +by_t0 = dict(zip(result["T0"], result["T_series"])) +fzo_by_t0 = dict(zip(fzo_result["T0"], fzo_result["T_series"])) +assert by_t0 == fzo_by_t0 +``` + +## A note on single-element vectors + +The legacy plain-shell-command form (no `python://`/`jq://`/`yq://`/`xpath://` +prefix) applies a backward-compatible simplification: a single-element JSON +array is unwrapped to its scalar element (`"echo '[42]'"` -> `42`, not +`[42]`). This can silently turn one row of a vector-output column into a bare +scalar if that particular case happens to produce a length-1 result. The +`python://`, `jq://`, `yq://` and `xpath://` forms never do this: a +single-element result stays a one-element list. Prefer one of those forms +when a vector output's length can legitimately be 1. + +## Persisting results with vectors + +`fzr`/`fzo` results are plain pandas DataFrames, so list-valued cells behave +like any other Python object column: + +- `results.to_dict(orient="records")` / `json.dumps(..., default=str)` (or + the CLI's `--format json`) round-trip vectors as native JSON arrays. +- `results.to_csv(...)` (or `--format csv`) stringifies each list + (`"[1, 2, 3]"`); reload with `ast.literal_eval` or `json.loads` per cell, + or prefer `to_pickle`/`to_parquet` for a lossless round trip. diff --git a/fz/outparsers.py b/fz/outparsers.py index 6785ff5..bf75a4d 100644 --- a/fz/outparsers.py +++ b/fz/outparsers.py @@ -552,7 +552,11 @@ def evaluate_xpath_output( Returns: The raw text matched by the XPath expression, cast to int/float when possible (same casting as :func:`grep`'s default), otherwise - returned as a string. + returned as a string. If the expression selects a node-set with + more than one node (a vector output — e.g. ``//result/value/text()`` + matching several ```` elements), a list of per-node values + (each cast individually) is returned instead of a single + concatenated string. Raises: RuntimeError: If the ``xmllint`` executable is not found on PATH. @@ -581,10 +585,49 @@ def evaluate_xpath_output( if not file_path.is_absolute(): file_path = out_dir / file_path + def _run_xpath(one_expr: str) -> _subprocess.CompletedProcess: + cmd = ["xmllint", "--xpath", one_expr, str(file_path)] + return _subprocess.run(cmd, capture_output=True, text=True) + + # First, find out whether the expression selects a node-set, and if so + # how many nodes it matches. xmllint concatenates the serialized text + # of every matched node with no separator we can rely on, so a naive + # single call cannot distinguish "one node" from "several nodes whose + # text happens to contain no digits/whitespace" reliably. Wrapping the + # expression in count(...) tells us unambiguously: it succeeds (with an + # integer result) only when the expression evaluates to a node-set, + # and fails with "Invalid type" when it already returns a scalar + # (string/number/boolean, e.g. from count()/sum()/string() themselves). log_debug(f"Evaluating xpath output expression: {xpath_expr} on {file_path}") - cmd = ["xmllint", "--xpath", xpath_expr, str(file_path)] - result = _subprocess.run(cmd, capture_output=True, text=True) + count_result = _run_xpath(f"count({xpath_expr})") + node_count: Optional[int] = None + if count_result.returncode == 0: + try: + node_count = int(count_result.stdout.strip()) + except ValueError: + node_count = None + + if node_count is not None and node_count > 1: + # Vector output: fetch and cast each matched node individually so + # embedded whitespace/newlines in node text can never corrupt the + # split, unlike naively splitting the concatenated output. + values = [] + for i in range(1, node_count + 1): + indexed_result = _run_xpath(f"({xpath_expr})[{i}]") + if indexed_result.returncode != 0: + cmd = ["xmllint", "--xpath", f"({xpath_expr})[{i}]", str(file_path)] + raise _subprocess.CalledProcessError( + indexed_result.returncode, cmd, + output=indexed_result.stdout, stderr=indexed_result.stderr, + ) + values.append(_cast_numeric(indexed_result.stdout.strip())) + return values + + # Scalar path (0 or 1 matched node, or an expression that already + # evaluates to a scalar) — unchanged, backward-compatible behavior. + result = _run_xpath(xpath_expr) if result.returncode != 0: + cmd = ["xmllint", "--xpath", xpath_expr, str(file_path)] raise _subprocess.CalledProcessError( result.returncode, cmd, output=result.stdout, stderr=result.stderr, ) diff --git a/skills/fz/reference.md b/skills/fz/reference.md index 275ae5c..1db575d 100644 --- a/skills/fz/reference.md +++ b/skills/fz/reference.md @@ -38,7 +38,14 @@ fz.fzo(output_path: str, model: str | dict) -> pandas.DataFrame Runs each `model["output"]` command in every directory matched by `output_path` (plain path or glob like `results/*`). Returns one row per directory. Directory names following `key=val,key=val` are parsed back into variable columns. Results are auto-cast (int, -float, list, dict) when possible. +float, list, dict) when possible. An output entry may resolve to a **list** (vector +output: time series, per-node profile, ...) via `python://grep(..., all=True)`, +`csv_file(column=...)`, `hdf5_file(dataset=...)`, `jq://`/`yq://` filters selecting an +array, `xpath://` matching several nodes, or a plain shell command printing a JSON +array; `fzr`/`fzo` store the full list per case, unmodified (cases may have +different-length vectors). Note: the plain-shell-command form still simplifies a +single-element array to its scalar (`echo '[42]'` → `42`) for backward compatibility; +the other forms never do. ### fz.fzr — run a parametric study diff --git a/tests/test_examples_scripts.py b/tests/test_examples_scripts.py index f665ac7..130f525 100644 --- a/tests/test_examples_scripts.py +++ b/tests/test_examples_scripts.py @@ -108,3 +108,6 @@ def test_r_interpreter_example_md_python_blocks_valid_syntax(self): def test_shell_path_example_md_python_blocks_valid_syntax(self): self._check_md_syntax("shell_path_example.md") + + def test_vector_outputs_example_md_python_blocks_valid_syntax(self): + self._check_md_syntax("vector_outputs_example.md") diff --git a/tests/test_vector_outputs.py b/tests/test_vector_outputs.py new file mode 100644 index 0000000..f9efd43 --- /dev/null +++ b/tests/test_vector_outputs.py @@ -0,0 +1,351 @@ +""" +Tests for vector (array/list-valued) outputs in fzo and fzr. + +fz already lets an output entry evaluate to a Python list through several +extraction forms (plain shell echoing a JSON array, python:// expressions +using grep(all=True)/csv_file(column=...)/hdf5_file(dataset), jq:// / yq:// +filters selecting an array, and xpath:// expressions matching more than one +node). This suite locks down that vector outputs: + +- come back as plain Python lists (not strings, not numpy arrays) from + fzo(), for every extraction form; +- survive fzr()'s per-case aggregation unchanged, including across several + cases with vectors of *different* lengths (a very common shape for + simulation outputs: e.g. a time series that runs for a different number + of steps per case); +- stay coherent between fzr() and a subsequent fzo() on the same results + directory (mirrors tests/test_fzo_fzr_coherence.py, but for vectors); +- are distinguished from the legacy single-element-array-to-scalar + simplification that plain shell (``bash://``) outputs still apply. + +Also covers the xpath:// multi-node fix: xmllint concatenates the text of +every matched node with no reliable separator, so a naive implementation +returns one garbled string instead of a list when an expression matches +several nodes. +""" +import json +import os +import shutil +import time + +import pandas as pd +import pytest + +import fz + +requires_jq = pytest.mark.skipif( + shutil.which("jq") is None, reason="jq executable not found on PATH" +) +requires_xmllint = pytest.mark.skipif( + shutil.which("xmllint") is None, reason="xmllint executable not found on PATH" +) + +def _get_value(result, key, index=0): + """Return result[key] at index, for either a DataFrame or a dict of lists.""" + if isinstance(result, pd.DataFrame): + return result[key].iloc[index] + return result[key][index] + + +def _get_length(result, key): + return len(result[key]) + + +# --------------------------------------------------------------------------- +# fzo(): vector outputs from a single result directory, one extraction form +# at a time +# --------------------------------------------------------------------------- + +def test_fzo_bash_json_array_output_is_a_list(): + """A plain shell output printing a JSON array comes back as a Python list.""" + os.makedirs("case_output", exist_ok=True) + # Read the JSON array from a file with "cat" rather than passing it on + # the command line via "echo": echo is a native cmd.exe builtin on + # Windows (unlike grep/head/cat, which FZ_SHELL_PATH remaps to real + # executables), so it doesn't strip shell quoting there the way bash's + # echo does -- "cat" behaves consistently across platforms instead. + with open("case_output/series.json", "w") as f: + f.write("[1, 2, 3, 4, 5]") + + model = { + "output": {"series": "cat series.json"}, + } + result = fz.fzo("case_output", model) + value = _get_value(result, "series") + assert isinstance(value, list) + assert value == [1, 2, 3, 4, 5] + + +def test_fzo_python_grep_all_output_is_a_list(): + """python://grep(..., all=True) returns every match as a list.""" + os.makedirs("case_output", exist_ok=True) + with open("case_output/log.txt", "w") as f: + f.write("step 0: T=90.0\nstep 1: T=55.0\nstep 2: T=40.0\n") + + model = { + "output": { + "T_series": "python://grep(r'T=(\\S+)', 'log.txt', all=True)", + }, + } + result = fz.fzo("case_output", model) + value = _get_value(result, "T_series") + assert isinstance(value, list) + assert value == pytest.approx([90.0, 55.0, 40.0]) + + +def test_fzo_python_csv_column_output_is_a_list(): + """python://csv_file(path, column=...) returns the column as a plain list.""" + os.makedirs("case_output", exist_ok=True) + with open("case_output/data.csv", "w") as f: + f.write("t,T\n0,90\n300,55\n600,40\n") + + model = { + "output": {"T_series": "python://csv_file('data.csv', column='T')"}, + } + result = fz.fzo("case_output", model) + value = _get_value(result, "T_series") + assert isinstance(value, list) + assert value == [90, 55, 40] + + +@requires_jq +def test_fzo_jq_array_output_is_a_list(): + """A jq:// filter selecting an array returns a native Python list.""" + os.makedirs("case_output", exist_ok=True) + with open("case_output/results.json", "w") as f: + json.dump({"temperatures": [90.0, 55.0, 40.0]}, f) + + model = { + "output": {"T_series": "jq://.temperatures results.json"}, + } + result = fz.fzo("case_output", model) + value = _get_value(result, "T_series") + assert isinstance(value, list) + assert value == pytest.approx([90.0, 55.0, 40.0]) + + +@requires_xmllint +def test_fzo_xpath_multiple_nodes_output_is_a_list(): + """ + Regression test: xpath:// used to concatenate all matched node text + with no separator, returning a single garbled string instead of a + vector. It must now return a list, one (cast) element per node. + """ + os.makedirs("case_output", exist_ok=True) + with open("case_output/output.xml", "w") as f: + f.write( + "" + "1.52.53.5" + "" + ) + + model = { + "output": {"series": "xpath://'//value/text()' output.xml"}, + } + result = fz.fzo("case_output", model) + value = _get_value(result, "series") + assert isinstance(value, list) + assert value == pytest.approx([1.5, 2.5, 3.5]) + + +@requires_xmllint +def test_fzo_xpath_single_node_output_stays_scalar(): + """A single-node xpath:// match is unaffected: still a plain scalar.""" + os.makedirs("case_output", exist_ok=True) + with open("case_output/output.xml", "w") as f: + f.write("101.325") + + model = { + "output": {"pressure": "xpath://'//pressure/text()' output.xml"}, + } + result = fz.fzo("case_output", model) + value = _get_value(result, "pressure") + assert value == pytest.approx(101.325) + assert not isinstance(value, list) + + +def test_fzo_bash_single_element_array_is_simplified_to_scalar(): + """ + Documents the existing (legacy) cast_output() behavior for plain shell + outputs: a single-element JSON array is simplified to its scalar + element. This is the opposite of the python://, jq:// and xpath:// + behaviors above, which never simplify a genuine vector output down to + a scalar -- use one of those forms instead of a plain shell command + when a length-1 result must still be preserved as a vector. + """ + os.makedirs("case_output", exist_ok=True) + with open("case_output/series.json", "w") as f: + f.write("[42]") + + model = {"output": {"series": "cat series.json"}} + result = fz.fzo("case_output", model) + value = _get_value(result, "series") + assert value == 42 + assert not isinstance(value, list) + + +def test_fzo_python_single_element_list_is_not_simplified(): + """python:// output values are returned as-is: no scalar simplification.""" + os.makedirs("case_output", exist_ok=True) + with open("case_output/log.txt", "w") as f: + f.write("T=42.0\n") + + model = { + "output": {"series": "python://grep(r'T=(\\S+)', 'log.txt', all=True)"}, + } + result = fz.fzo("case_output", model) + value = _get_value(result, "series") + assert isinstance(value, list) + assert value == pytest.approx([42.0]) + + +# --------------------------------------------------------------------------- +# fzr(): vector outputs aggregated across several cases +# --------------------------------------------------------------------------- + +def _write_perfectgaz_style_case_script(name="run_case.sh"): + """A toy 'simulation' producing a fixed-length time series per case.""" + with open(name, "w", newline="\n") as f: + f.write("#!/bin/bash\n") + f.write("source input.txt\n") + f.write( + "python3 -c \"\n" + "import json\n" + "n = int($n_steps)\n" + "T0 = float($T0)\n" + "series = [round(T0 * (0.9 ** i), 3) for i in range(n)]\n" + "print(json.dumps(series))\n" + "\" > series.json\n" + ) + os.chmod(name, 0o755) + + +def test_fzr_vector_output_single_case(): + """A single fzr() case with a vector output stores the full list.""" + with open("input.txt", "w") as f: + f.write("n_steps=${n_steps}\nT0=${T0}\n") + _write_perfectgaz_style_case_script() + + model = { + "varprefix": "$", + "delim": "{}", + "output": {"T_series": "python://json_file('series.json')"}, + } + result = fz.fzr( + "input.txt", + {"n_steps": 4, "T0": 100.0}, + model, + calculators="sh://bash run_case.sh", + results_dir="vec_results_single", + ) + + value = _get_value(result, "T_series", 0) + assert isinstance(value, list) + assert value == pytest.approx([100.0, 90.0, 81.0, 72.9]) + + +def test_fzr_vector_output_multiple_cases_same_length(): + """Vector outputs aggregate correctly, one full list per row.""" + with open("input.txt", "w") as f: + f.write("n_steps=${n_steps}\nT0=${T0}\n") + _write_perfectgaz_style_case_script() + + model = { + "varprefix": "$", + "delim": "{}", + "output": {"T_series": "python://json_file('series.json')"}, + } + result = fz.fzr( + "input.txt", + {"n_steps": 3, "T0": [100.0, 200.0, 300.0]}, + model, + calculators="sh://bash run_case.sh", + results_dir="vec_results_multi", + ) + + assert _get_length(result, "T_series") == 3 + by_t0 = {} + for i in range(3): + t0 = _get_value(result, "T0", i) + series = _get_value(result, "T_series", i) + assert isinstance(series, list) + assert len(series) == 3 + by_t0[t0] = series + + assert by_t0[100.0] == pytest.approx([100.0, 90.0, 81.0]) + assert by_t0[200.0] == pytest.approx([200.0, 180.0, 162.0]) + assert by_t0[300.0] == pytest.approx([300.0, 270.0, 243.0]) + + +def test_fzr_vector_output_ragged_lengths_across_cases(): + """ + Cases whose vector outputs differ in length (e.g. an iterative solver + that converges after a variable number of steps) must not be padded, + truncated, or otherwise coerced -- each row keeps its own-length list. + """ + with open("input.txt", "w") as f: + f.write("n_steps=${n_steps}\nT0=${T0}\n") + _write_perfectgaz_style_case_script() + + model = { + "varprefix": "$", + "delim": "{}", + "output": {"T_series": "python://json_file('series.json')"}, + } + result = fz.fzr( + "input.txt", + pd.DataFrame( + [ + {"n_steps": 2, "T0": 100.0}, + {"n_steps": 5, "T0": 100.0}, + ] + ), + model, + calculators="sh://bash run_case.sh", + results_dir="vec_results_ragged", + ) + + assert _get_length(result, "T_series") == 2 + lengths = sorted(len(_get_value(result, "T_series", i)) for i in range(2)) + assert lengths == [2, 5] + + +def test_fzr_fzo_coherence_for_vector_output(): + """ + fzo() on the results directory produced by fzr() must return the same + vectors as fzr() itself (mirrors test_fzo_fzr_coherence.py for scalars). + """ + with open("input.txt", "w") as f: + f.write("n_steps=${n_steps}\nT0=${T0}\n") + _write_perfectgaz_style_case_script() + + model = { + "varprefix": "$", + "delim": "{}", + "output": {"T_series": "python://json_file('series.json')"}, + } + fzr_result = fz.fzr( + "input.txt", + {"n_steps": 4, "T0": [50.0, 100.0]}, + model, + calculators="sh://bash run_case.sh", + results_dir="vec_results_coherence", + ) + + time.sleep(0.2) # let file writes settle, as in test_fzo_fzr_coherence.py + + fzo_result = fz.fzo("vec_results_coherence/*", model) + + assert _get_length(fzr_result, "T_series") == _get_length(fzo_result, "T_series") == 2 + + fzr_by_t0 = { + _get_value(fzr_result, "T0", i): _get_value(fzr_result, "T_series", i) + for i in range(2) + } + fzo_by_t0 = { + _get_value(fzo_result, "T0", i): _get_value(fzo_result, "T_series", i) + for i in range(2) + } + assert set(fzr_by_t0) == set(fzo_by_t0) + for t0 in fzr_by_t0: + assert fzr_by_t0[t0] == pytest.approx(fzo_by_t0[t0])