Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,16 @@

### Documentation

- Two worked skill examples in `skills/examples/`: a Newton-cooling calibration (reuse the
Modelica wrapper + brent algorithm) and an OpenFOAM dam-break random-sampling study
(author the wrapper *and* the algorithm from scratch). The latter is validated end to end
against OpenFOAM v2412.
- Skill guidance hardened from running those examples: model-vs-calculator distinction,
directory-tree (case-based) codes, locale-safe (`LC_ALL=C`) output parsing, runner-script
invocation gotchas, the `fzd` flag divergence, and a common-failures table.
- New Agent Skill in `skills/fz/` for AI coding agents (Claude Code and compatible),
with a condensed API/CLI reference, fzd algorithm guide, and wrapper implementation
guide (`wrapper.md`).
with a condensed API/CLI reference, fzd algorithm guide, and code-wrapper guide
(`code-wrapper.md`).
- The repo is now a Claude Code plugin marketplace (`.claude-plugin/`): install the skill
from inside Claude Code with `/plugin marketplace add Funz/fz` + `/plugin install fz@funz`.
- New `llms.txt` documentation index and `CLAUDE.md` contributor guide.
Expand Down
282 changes: 282 additions & 0 deletions skills/examples/openfoam-dambreak-random-sampling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
# Example: random-sampling an OpenFOAM dam break — wrapper *and* algorithm built live

A worked example of using the **fz skill** with an AI coding agent (Claude Code) on a code
that has **no ready-made fz package**. It is the deliberate opposite of
[newton-cooling-calibration.md](newton-cooling-calibration.md), where the agent installed an
official Modelica wrapper and brent algorithm: here **both pieces are authored from
scratch**, in the working directory, because nothing exists to install —

- the **OpenFOAM coupling** — a code wrapper (model + runner + calculator), following
[../fz/code-wrapper.md](../fz/code-wrapper.md);
- the **random sampling** — a custom `fzd` algorithm, following
[../fz/algorithm-wrapper.md](../fz/algorithm-wrapper.md).

So this is the path the skill takes when its step-0 check ("is there an official wrapper?")
comes back empty: build the binding yourself, verify it cheaply, then run the study.

> **Validated end to end** against OpenFOAM v2412 (`interFoam`) and the bundled
> `damBreak` tutorial: the wrapper and the custom algorithm below were run as written —
> an 8-sample study completes in ~30 s and yields a peak water height varying ~0.08–0.18 m
> with obstacle height. OpenFOAM-internal names (paths, the `blockMeshDict` obstacle level,
> the function-object column) are still **distribution/version dependent** — confirm them
> in your install. Two `fz` framework fixes were needed for a directory-tree code like
> OpenFOAM to run at all (recursive staging of case subdirectories into and out of the run
> directory); use a build that includes them.

## The engineering problem

The [OpenFOAM "breaking of a dam" tutorial](https://www.openfoam.com/documentation/tutorial-guide/4-multiphase-flow/4.1-breaking-of-a-dam)
(`damBreak`, the `interFoam` VOF multiphase solver) releases a column of water that
collapses across a tank. The tank floor carries a small **obstacle**. We treat the
**obstacle height** as uncertain and do a **random (Monte-Carlo) sampling** of it to study
how it affects a quantity of interest — here the **peak water height** reached at a
downstream location.

## Prerequisites

- **OpenFOAM** installed and its environment **sourced** (`blockMesh`, `setFields`, and the
solver — `interFoam` or `foamRun` depending on your distribution — on PATH). The tutorial
case ships under `$FOAM_TUTORIALS/multiphase/interFoam/laminar/damBreak/damBreak`.
- `fz` on PATH (`pip install 'funz-fz>=1.0'`) and the **fz skill** installed (see
[../howto.md](../howto.md)).
- `claude` CLI, logged in or `ANTHROPIC_API_KEY` set.

Work in a scratch directory **outside any git repository** (Claude Code resolves its
project at the enclosing repo root, which would otherwise hide a project-level skill):

```bash
SANDBOX=$(mktemp -d) && cd "$SANDBOX"
```

## Solve it in one ask

Describe the problem and make the two "build it yourself" requirements explicit, so the
agent doesn't waste a turn hunting for a plugin that isn't there:

```bash
claude -p "Using the fz skill, run a random-sampling study on the OpenFOAM 'damBreak'
tutorial (interFoam). There is NO official fz wrapper for OpenFOAM and NO installable
sampling algorithm — implement both yourself in this directory:

1. Wrap the case as a reusable fz model (follow the skill's code-wrapper guide): copy the
damBreak tutorial, parameterize the obstacle height in system/blockMeshDict, write the
model JSON + a runner script that runs blockMesh and the solver + a local calculator
alias. Mind the variable-prefix collision: OpenFOAM uses '\$' for its own macros, so
choose a different fz varprefix (e.g. '%'). Pick a scalar output: the peak water height
at a downstream location (add an interfaceHeight function object if needed).
2. Write a custom fzd random-sampling algorithm (follow the skill's algorithm-wrapper
guide) that draws N uniform samples of the obstacle height in one batch.

Verify the wrapper on a single height before the study. Then sample the obstacle height
(20 draws, seed 1) over a sensible range and report mean/std of the peak height; write the
per-sample results to results.json (records of obstacle_height, peak_height, status)." \
--allowedTools "Bash,Read,Write,Edit,Glob,Grep,Skill" --max-turns 120
```

## The path the agent follows

### 0. No official wrapper → author one

The skill's step 0 is *check for an official wrapper*. `fz install model openfoam` resolves
to `github.com/Funz/fz-openfoam`, which does not exist — so the agent falls through to the
authoring path ([code-wrapper.md](../fz/code-wrapper.md)) instead of a one-line install.

### 1. Get the case

```bash
cp -r "$FOAM_TUTORIALS/multiphase/interFoam/laminar/damBreak/damBreak" case
# conda-forge OpenFOAM leaves FOAM_TUTORIALS unset; use $WM_PROJECT_DIR/tutorials/... there
```

### 2. Parameterize the obstacle height — mind the prefix collision

In this geometry the obstacle is the **un-meshed floor block** between `x = 2` and
`x = 2.16438`: the `blocks` list simply omits it, so the mesh flows around a solid step
whose top sits at the shared y-level `0.32876` (× `scale 0.146` ≈ 0.048 m). Raising or
lowering that level *is* changing the obstacle height, so the agent replaces every
occurrence of `0.32876` in `system/blockMeshDict` with the fz variable:

```bash
sed -i 's/0.32876/%obstacle_height/g' case/system/blockMeshDict # 8 vertex rows
```

**OpenFOAM already uses `$` for macro expansion**, so reusing fz's default `$` prefix would
clash — the agent picks a non-colliding prefix (`%`), exactly the situation
[code-wrapper.md](../fz/code-wrapper.md) warns about:

```c++
// system/blockMeshDict (the 8 vertices at the obstacle-top level)
(0 %obstacle_height 0)
(2 %obstacle_height 0)
(2.16438 %obstacle_height 0)
(4 %obstacle_height 0)
// ... and the same four at z = 0.1
```

(`%obstacle_height` is in `blockMeshDict` units — multiplied by `scale`; default `0.32876`,
sampled below over `[0.2; 0.5]`. fz's `{}`/`@` formula syntax is untouched: OpenFOAM never
writes `@{...}`, so no formula is triggered.)

### 3. Define the QoI and the model

The base case writes no scalar result, so the agent enables an `interfaceHeight` function
object to record the water height at two downstream probes. `controlDict` already ends with
`functions { #sinclude "sampling" }`, so just drop in `system/sampling`:

```c++
// system/sampling
interfaceHeight1
{
type interfaceHeight;
libs (fieldFunctionObjects);
alpha alpha.water;
locations ((0.35 0 0.005) (0.45 0 0.005)); // just downstream of the obstacle
writeControl timeStep;
writeInterval 20;
}
```

It writes `postProcessing/interfaceHeight1/0/height.dat` with columns
`Time, hB hL (loc0), hB hL (loc1)`; the QoI is the peak `hB` at the first probe (column 2).

`.fz/models/OpenFOAM.json` — `varprefix: "%"`, `commentline: "//"`, and a scalar
`peak_height` output:

```json
{
"id": "OpenFOAM",
"varprefix": "%",
"commentline": "//",
"output": {
"peak_height": "LC_ALL=C awk '!/^#/ && NF {v=$2+0; if (v>m) m=v} END {print m}' postProcessing/interfaceHeight1/0/height.dat"
}
}
Comment on lines +146 to +153
```

The output command runs **inside each case's result directory** after the solver; its
stdout is auto-cast to a number. Two robustness points learned by actually running this:
**`LC_ALL=C`** forces a period decimal separator — without it, on a non-English locale a
plain `sort -g` mis-orders the scientific-notation values and returns a near-zero "max";
and computing the max in `awk` (rather than `sort | tail`) keeps it to one pass.

### 4. The runner script

`.fz/calculators/OpenFOAM.sh` — runs the meshing and the solver in the compiled case dir.
Using the tutorial's own `Allrun` keeps it solver-version agnostic (`Allrun` reads the
solver name from `controlDict`):

```bash
#!/bin/bash
# OpenFOAM.sh — fz runner for the damBreak wrapper. Usage: OpenFOAM.sh <compiled case dir>
[ -d "$1" ] && cd "$1"
command -v blockMesh >/dev/null || { echo "OpenFOAM environment not sourced" >&2; exit 2; }
Comment on lines +169 to +172

bash ./Allrun > log.Allrun 2>&1 # blockMesh + setFields + the case's solver

# fail loudly if the quantity of interest was not produced, so the case is marked error
ls postProcessing/interfaceHeight1/*/height.dat >/dev/null 2>&1 || { echo "no interfaceHeight output" >&2; exit 1; }
```

(Invoke it as `bash ./Allrun`, not `bash Allrun`: `Allrun`'s first line is
`cd "${0%/*}"`, which with a bare name becomes `cd Allrun` and aborts the script.)

### 5. The local calculator alias

`.fz/calculators/localhost_OpenFOAM.json` — wires the model **id** to the runner so fz can
auto-discover it:

```json
{
"uri": "sh://",
"models": { "OpenFOAM": "bash .fz/calculators/OpenFOAM.sh" }
}
```

### 6. Verify the wrapper on one height (definition of done)

Per the skill, prove the binding works on a single case — the cheap-failure gate — before
the study. No `--calculators` is needed; the installed alias is auto-discovered from the
model id:

```bash
fzi --input_path case --model OpenFOAM --format json # finds: obstacle_height
fzr --input_path case --model OpenFOAM \
--input_variables '{"obstacle_height": 0.33}' --format json # one full run; peak_height parses?
```

### 7. Author the random-sampling algorithm

`.fz/algorithms/random_sampling.py` — a one-shot `fzd` algorithm
([algorithm-wrapper.md](../fz/algorithm-wrapper.md)): propose all N points up front, then
stop. `input_vars` arrives as `{name: (min, max)}`; `get_next_design` returning `[]` ends
the loop; `get_analysis` summarizes:

```python
#title: Uniform random sampling (Monte Carlo)
#author: example
#options: n=20;seed=1

import random

class RandomSampling:
def __init__(self, **options):
self.n = int(options.get("n", 20))
self.seed = int(options.get("seed", 1))

def get_initial_design(self, input_vars, output_vars):
rng = random.Random(self.seed)
return [{v: rng.uniform(lo, hi) for v, (lo, hi) in input_vars.items()}
for _ in range(self.n)]

def get_next_design(self, previous_input_vars, previous_output_values):
return [] # one-shot: every point was proposed in the initial design

def get_analysis(self, input_vars, output_values):
ys = [y for y in output_values if y is not None] # None = a failed case
n = len(ys)
mean = sum(ys) / n if n else float("nan")
std = (sum((y - mean) ** 2 for y in ys) / n) ** 0.5 if n else float("nan")
return {
"text": f"random sampling: {n} valid samples, mean={mean:.4g}, std={std:.4g}",
"data": {"n": n, "mean": mean, "std": std,
"min": min(ys, default=None), "max": max(ys, default=None)},
}
```

### 8. Run the study

```bash
fzd --input_dir case --model OpenFOAM \
--input_vars '{"obstacle_height": "[0.2; 0.5]"}' \
--output_expression "peak_height" \
--algorithm .fz/algorithms/random_sampling.py \
--options '{"n": 20, "seed": 1}' \
--results_dir study
```

`fzd` evaluates the sampled heights (each a full CFD run), records `peak_height` per sample
under `study/`, and prints the `get_analysis` summary (mean/std of the peak height). The
agent collects the per-sample records into `results.json`. As validated, an 8-sample run
finishes in ~30 s with peaks spread over ~0.08–0.18 m (mean ≈ 0.12, std ≈ 0.04).

## Notes

- **The prefix collision is the lesson here.** Because OpenFOAM owns `$`, the wrapper must
set a different `varprefix`. The agent confirms its choice with `fzi` — it should report
exactly `obstacle_height`, not stray `$`-macros from the OpenFOAM dictionaries.
- **CFD cost scales with `n`.** This tiny damBreak case runs in ~3 s, so 20 samples is a
minute; a real case is far heavier. Run samples concurrently by supplying several
calculators
(`--calculators '["localhost_OpenFOAM", "localhost_OpenFOAM", "localhost_OpenFOAM"]'`),
or lower `n` while developing. fz deduplicates and caches across runs, so re-running with
a larger `n` reuses what was already computed.
- **Random sampling vs. `fzr`.** A fixed Monte-Carlo list could also be run with
`fzr --input_variables '{"obstacle_height": [<random values>]}'`. Authoring it as an
`fzd` algorithm instead is what makes the *sampling logic itself* a reusable, swappable
component — and is the point this example demonstrates.
- **Promote it later.** Once verified, the two artifacts are exactly what the `fz-<name>`
packaging conventions expect: the case + `.fz/` could become an installable
`fz-openfoam` model, and `random_sampling.py` an installable `fz-randomsampling`
algorithm (see [code-wrapper.md](../fz/code-wrapper.md) and
[algorithm-wrapper.md](../fz/algorithm-wrapper.md)). This example is how a wrapper begins
before it is published.
42 changes: 42 additions & 0 deletions skills/fz/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ before moving to the next — this isolates errors cheaply instead of debugging
6. **Verify output parsing**: `fzo` on the directory containing the outputs.
7. **Run the study**: `fzr` with the full variable grid and calculator(s).

**Single file vs. a case directory.** `input_path` can be one file or a whole directory
tree. Codes like OpenFOAM or Telemac take a *case directory* (`system/`, `constant/`, `0/`,
…); point `input_path` at the directory, parameterize whichever file(s) hold the values,
and the calculator runs inside the per-case copy with the tree intact. Authoring such a
wrapper has extra gotchas (prefix collisions, directory I/O, locale) — see
[code-wrapper.md](code-wrapper.md). (Recursive staging of case subdirectories requires
fz from git main / the next release; fz 1.0 on PyPI stages only top-level files.)

### 0. Check for an existing wrapper first

Funz publishes ready-made wrappers for common simulation codes (Modelica, MORET, …) as
Expand Down Expand Up @@ -120,11 +128,19 @@ Output command results are auto-cast to Python literals (int, float, list, dict)
possible. For reusability, save as `.fz/models/perfectgas.json` (project) or
`~/.fz/models/perfectgas.json` (global) and refer to it by alias: `model="perfectgas"`.

> **A model never says how to *run* the code.** It declares only the input syntax and the
> output parsers. *Where and how* the simulation executes is the calculator's job (see
> "Calculators" below). There is no `run` field in a model. If you forget the calculator,
> fz falls back to `sh://` and tries to execute the input file itself — the tell-tale
> symptom is `Permission denied ... ./input.txt`.

### 3–6. Verify each step before the full run

```bash
# 3. Variables found? (returns variables as keys, None values)
fzi --input_path input.txt --model perfectgas --format json
# Must list EXACTLY your variables. Stray names (e.g. the code's own $-macros) mean your
# varprefix collides with the code's syntax — change it (e.g. varprefix="%") and re-check.

# 4. Compilation correct for one case?
fzc --input_path input.txt --model perfectgas \
Expand Down Expand Up @@ -262,6 +278,10 @@ read [algorithm-wrapper.md](algorithm-wrapper.md).
- In `output` parsing commands, awk field references like `$3` must survive shell quoting:
in JSON model files write them normally; inside a single-quoted inline `--model` JSON
argument they are safe as-is, but never wrap them in double quotes on the shell.
- Make numeric `output` commands **locale-independent**: prefix with `LC_ALL=C`. On a
non-English locale, tools like `sort -g` use a comma decimal and silently mis-order
period/scientific-notation values (returning a wrong "max"); computing min/max in `awk`
(one pass) is more robust than `sort | head/tail`.
- `fzr` argument order in Python is `(input_path, input_variables, model, results_dir=...,
calculators=...)` — use keyword arguments to stay safe.
- Concurrency: repeat the same calculator URI N times (or set `FZ_MAX_WORKERS`) to run N
Expand All @@ -270,3 +290,25 @@ read [algorithm-wrapper.md](algorithm-wrapper.md).
per-case `out.txt`/`err.txt`; on interrupt, partial results survive and `cache://` resumes.
- Full API and CLI details, environment variables, and the model/calculator JSON schemas:
see [reference.md](reference.md).

## Common failures (read err.txt/log.txt in the case dir first)

| Symptom | Likely cause |
|---------|--------------|
| `Permission denied ... ./input.txt` | No calculator given; fz tried to execute the input. Add a calculator (`sh://bash runner.sh`). |
| All cases `failed`, `N calculator failures` | The calculator command itself errors — read the case's `err.txt`/`log.txt`. |
| Output column is `null` but case is `done` | Output command matched nothing: wrong path/field, missing subdir output (see directory codes), locale (`LC_ALL=C`), or `python` vs `python3`. |
| `fzi` lists extra/unexpected variables | `varprefix` collides with the code's own syntax — change it. |
| `fzd` runs an empty `sh://` / every case fails | On fz 1.0 PyPI, `fzd` doesn't auto-discover calculators — pass them explicitly (fixed in git main). |

## Worked examples

Two end-to-end walkthroughs (natural-language ask + the path the agent follows, with
checkable outcomes), one per archetype:

- [../examples/newton-cooling-calibration.md](../examples/newton-cooling-calibration.md) —
**reuse ready-made packages**: discover the Modelica wrapper + brent algorithm to solve
an inverse problem.
- [../examples/openfoam-dambreak-random-sampling.md](../examples/openfoam-dambreak-random-sampling.md) —
**build from scratch**: author an OpenFOAM (directory-case) wrapper and a custom
random-sampling algorithm when no `fz-*` package exists.
Loading
Loading