Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Build Doxygen
run: |
sudo apt update -y
sudo apt install -y cmake ninja-build graphviz graphviz
sudo apt install -y cmake ninja-build graphviz texlive-binaries
Comment thread
sbryngelson marked this conversation as resolved.
Comment thread
sbryngelson marked this conversation as resolved.
git clone https://github.com/doxygen/doxygen.git ../doxygen
cd ../doxygen
git checkout Release_1_16_1
Comment thread
sbryngelson marked this conversation as resolved.
Expand Down
11 changes: 7 additions & 4 deletions docs/documentation/case.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,9 @@ Details of implementation of viscosity in MFC can be found in \cite Coralic15.
- `fluid_pp(i)%%G` is required for `hypoelasticity`.

### 6. Simulation Algorithm


See @ref equations "Equations" for the mathematical models these parameters control.

| Parameter | Type | Description |
| ---: | :----: | :--- |
| `bc_[x,y,z]%%beg[end]` | Integer | Beginning [ending] boundary condition in the $[x,y,z]$-direction (negative integer, see table [Boundary Conditions](#boundary-conditions)) |
Expand Down Expand Up @@ -545,7 +547,7 @@ This option requires `weno_Re_flux` to be true because cell boundary values are
| `type`* | Integer | The geometry of the patch. [1]: Line [2]: Circle [3]: Rectangle |
| `x[y,z]_centroid`* | Real | Centroid of the boundary patch in the x[y,z]-direction |
| `length_x[y,z]`* | Real | Length of the boundary patch in the x[y,z]-direction |
| `radiue`* | Real | Radius of the boundary patch |
| `radius`* | Real | Radius of the boundary patch |
*: These parameters should be prepended with `patch_bc(j)%` where $j$ is the patch index.

Boundary condition patches can be used with the following boundary condition types:
Expand All @@ -563,7 +565,7 @@ Squares and circles on each face are supported for 3D simulations.
- `dt` specifies the constant time step size used in the simulation.
The value of `dt` needs to be sufficiently small to satisfy the Courant-Friedrichs-Lewy (CFL) condition.

- `t_step_start` and `t_step_end` define the time steps at which the simulation starts and ends.
- `t_step_start` and `t_step_stop` define the time steps at which the simulation starts and ends.

`t_step_save` is the time step interval for data output during simulation.
To newly start the simulation, set `t_step_start = 0`.
Expand Down Expand Up @@ -612,7 +614,6 @@ To restart the simulation from $k$-th time step, see @ref running "Restarting Ca
| `schlieren_wrt` | Logical | Add the numerical schlieren to the database|
| `qm_wrt` | Logical | Add the Q-criterion to the database|
| `liutex_wrt` | Logical | Add the Liutex to the database|
| `tau_wrt` | Logical | Add the elastic stress components to the database|
| `fd_order` | Integer | Order of finite differences for computing the vorticity and the numerical Schlieren function [1,2,4] |
| `schlieren_alpha(i)` | Real | Intensity of the numerical Schlieren computed via `alpha(i)` |
| `probe_wrt` | Logical | Write the flow chosen probes data files for each time step |
Expand Down Expand Up @@ -797,6 +798,8 @@ This table lists the sub-grid bubble model parameters, which can be utilized in

Implementation of the parameters into the model follows \cite Ando10.

See @ref equations "Equations" Section 9 for the bubble dynamics equations.

#### 9.1 Ensemble-Averaged Bubble Model

| Parameter | Type | Description |
Expand Down
21 changes: 19 additions & 2 deletions docs/documentation/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,27 @@ Adding a parameter touches both the Python toolchain and Fortran source. Follow
Add a call to `_r()` inside the `_load()` function:

```python
_r("my_param", REAL, {"my_feature_tag"})
_r("my_param", REAL, {"my_feature_tag"},
desc="Description of the parameter",
math=r"\f$\xi\f$")
```

The arguments are: name, type (`INT`, `REAL`, `LOG`, `STR`), and a set of feature tags. You can add an explicit description with `desc="..."`, otherwise one is auto-generated from `_SIMPLE_DESCS` or `_ATTR_DESCS`.
The arguments are:
- **name**: parameter name (must match the Fortran namelist variable)
- **type**: `INT`, `REAL`, `LOG`, `STR`, or `A_REAL` (analytic expression)
- **tags**: set of feature tags for grouping (e.g. `{"bubbles"}`, `{"mhd"}`)
- **desc**: human-readable description (optional; auto-generated from `_SIMPLE_DESCS` or `_ATTR_DESCS` if omitted)
- **math**: LaTeX math symbol in Doxygen format (optional; shown in the Symbol column of @ref parameters)

For indexed families like `fluid_pp`, put the symbol next to its attribute name using tuples:

```python
for f in range(1, NF + 1):
px = f"fluid_pp({f})%"
for a, sym in [("gamma", r"\f$\gamma_k\f$"),
("my_attr", r"\f$\xi_k\f$")]: # <-- add here
_r(f"{px}{a}", REAL, math=sym)
```

**Step 2: Add constraints** (same file, `CONSTRAINTS` dict)

Expand Down
2 changes: 2 additions & 0 deletions docs/documentation/equations.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Each section notes the input parameter(s) that activate the corresponding physic

The models and algorithms described here are detailed in \cite Wilfong26 (MFC 5.0) and \cite Bryngelson21. Foundational references for each model are cited inline; see the \ref citelist "Bibliography" for full details.

For parameter details and allowed values, see @ref case "Case Files" and the @ref parameters "Case Parameters" reference.

---

## 1. Overview
Expand Down
155 changes: 124 additions & 31 deletions toolchain/mfc/lint_docs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Check that file paths, cite keys, and parameters in docs still exist."""
"""Check that file paths, cite keys, parameters, and @ref targets in docs still exist."""

import re
import sys
Expand Down Expand Up @@ -34,6 +34,28 @@
r"|^[A-Z]" # constants/types (uppercase start)
)

# Backtick tokens in case.md that are not real parameters (analytical shorthands,
# stress tensor component names, prose identifiers, hardcoded constants)
CASE_MD_SKIP = {
# Analytical shorthand variables (stretching formulas, "Analytical Definition" table)
"eps", "lx", "ly", "lz", "xc", "yc", "zc", "x_cb",
# Stress tensor component names (descriptive, not params)
"tau_xx", "tau_xy", "tau_xz", "tau_yy", "tau_yz", "tau_zz",
# Prose identifiers (example names, math symbols)
"scaling", "c_h", "thickness",
# Hardcoded Fortran constants (not case-file params)
"init_dir", "zeros_default",
}

# Docs to check for parameter references, with per-file skip sets
PARAM_DOCS = {
"docs/documentation/equations.md": set(),
"docs/documentation/case.md": CASE_MD_SKIP,
}

# Match @ref page_id patterns
REF_RE = re.compile(r"@ref\s+(\w+)")


def check_docs(repo_root: Path) -> list[str]:
"""Check that file paths referenced in documentation still exist."""
Expand Down Expand Up @@ -84,12 +106,45 @@ def check_cite_keys(repo_root: Path) -> list[str]:
return errors


def check_param_refs(repo_root: Path) -> list[str]:
"""Check that parameter names in equations.md exist in the MFC registry."""
eq_path = repo_root / "docs" / "documentation" / "equations.md"
if not eq_path.exists():
return []
def _strip_code_blocks(text: str) -> str:
"""Remove fenced code blocks (``` ... ```) from markdown text."""
lines = text.split("\n")
result = []
in_block = False
for line in lines:
if line.strip().startswith("```"):
in_block = not in_block
continue
if not in_block:
result.append(line)
return "\n".join(result)
Comment thread
sbryngelson marked this conversation as resolved.


def _is_valid_param(param: str, valid_params: set, sub_params: set) -> bool:
"""Check if a param name (possibly with %) is valid against REGISTRY."""
if "(" in param or ")" in param:
return True # Skip indexed refs like patch_icpp(i)%vel(j)

base = param.split("%")[0] if "%" in param else param

if base in valid_params or base in sub_params:
return True

# Check sub-param part after %
if "%" in param:
sub = param.split("%")[-1]
if sub in sub_params:
return True

# Family prefix check
if any(p.startswith(base) for p in valid_params):
return True

return False
Comment thread
sbryngelson marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def check_param_refs(repo_root: Path) -> list[str]: # pylint: disable=too-many-locals
"""Check that parameter names in documentation exist in the MFC registry."""
# Import REGISTRY from the toolchain
toolchain_dir = str(repo_root / "toolchain")
if toolchain_dir not in sys.path:
Expand All @@ -101,36 +156,73 @@ def check_param_refs(repo_root: Path) -> list[str]:
return []

valid_params = set(REGISTRY.all_params.keys())
# Build set of sub-parameter suffixes (the part after %)
sub_params = {p.split("%")[-1] for p in valid_params if "%" in p}
text = eq_path.read_text(encoding="utf-8")
# Build set of sub-parameter base names (strip trailing (N) indexes)
_sub_raw = {p.split("%")[-1] for p in valid_params if "%" in p}
sub_params = set()
for s in _sub_raw:
sub_params.add(s)
base = re.sub(r"\(\d+(?:,\s*\d+)*\)$", "", s)
if base != s:
sub_params.add(base)

errors = []
seen = set()

for match in PARAM_RE.finditer(text):
param = match.group(1)
if param in seen:
for doc_rel, extra_skip in PARAM_DOCS.items():
doc_path = repo_root / doc_rel
if not doc_path.exists():
continue
seen.add(param)

# Skip non-parameter identifiers
if PARAM_SKIP.search(param):
continue
# Skip single-character names (too ambiguous: m, n, p are grid dims)
if len(param) <= 1:
continue
# Skip names containing % (struct members like x_domain%beg are valid
# but the base name before % is what matters)
base = param.split("%")[0] if "%" in param else param
# Check for indexed parameters: strip trailing _N (e.g., patch_icpp(1)%alpha(1))
# In docs these appear as e.g. `patch_icpp(i)%vel(j)` — skip indexed refs
if "(" in param or ")" in param:
continue
text = _strip_code_blocks(doc_path.read_text(encoding="utf-8"))
seen = set()

# Check plain params
for match in PARAM_RE.finditer(text):
param = match.group(1)
if param in seen:
continue
seen.add(param)

if base not in valid_params and base not in sub_params:
# Check if it's a known parameter family prefix
if not any(p.startswith(base) for p in valid_params):
errors.append(f" equations.md references parameter '{param}' not in REGISTRY")
if PARAM_SKIP.search(param):
continue
if len(param) <= 1:
continue
if param in extra_skip:
continue
if "(" in param or ")" in param:
continue
if "[" in param:
continue # Bracket shorthand (e.g., x[y,z]_domain%%beg[end])
Comment thread
sbryngelson marked this conversation as resolved.

# Normalize %% to % for lookup
normalized = param.replace("%%", "%")
if not _is_valid_param(normalized, valid_params, sub_params):
errors.append(f" {doc_rel} references parameter '{param}' not in REGISTRY")

return errors
Comment on lines +273 to +315
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Update the check_section_anchors linter to recognize implicit anchors generated from Markdown headings, in addition to explicit {#id} anchors, to prevent false positives. [possible issue, importance: 7]

Suggested change
def check_section_anchors(repo_root: Path) -> list[str]:
"""Check that markdown ](#id) links have matching {#id} definitions."""
doc_dir = repo_root / "docs" / "documentation"
if not doc_dir.exists():
return []
ignored = _gitignored_docs(repo_root)
errors = []
for md_file in sorted(doc_dir.glob("*.md")):
if md_file.name in ignored:
continue
text = md_file.read_text(encoding="utf-8")
rel = md_file.relative_to(repo_root)
# Collect all {#id} anchors (outside code blocks)
anchors = set()
in_code = False
for line in text.split("\n"):
if line.strip().startswith("```"):
in_code = not in_code
continue
if not in_code:
anchors.update(re.findall(r"\{#([\w-]+)\}", line))
# Check all ](#id) links
in_code = False
for i, line in enumerate(text.split("\n"), 1):
if line.strip().startswith("```"):
in_code = not in_code
continue
if in_code:
continue
for m in re.finditer(r"\]\(#([\w-]+)\)", line):
if m.group(1) not in anchors:
errors.append(
f" {rel}:{i} links to #{m.group(1)}"
f" but no {{#{m.group(1)}}} anchor exists."
f" Fix: add {{#{m.group(1)}}} to the target"
" section header"
)
return errors
def check_section_anchors(repo_root: Path) -> list[str]:
"""Check that markdown ](#id) links have matching {#id} definitions."""
doc_dir = repo_root / "docs" / "documentation"
if not doc_dir.exists():
return []
def _slugify_heading(s: str) -> str:
s = s.strip().lower()
s = re.sub(r"[^\w\s-]", "", s)
s = re.sub(r"\s+", "-", s)
return s
ignored = _gitignored_docs(repo_root)
errors = []
for md_file in sorted(doc_dir.glob("*.md")):
if md_file.name in ignored:
continue
text = md_file.read_text(encoding="utf-8")
rel = md_file.relative_to(repo_root)
anchors = set()
in_code = False
for line in text.split("\n"):
if line.strip().startswith("```"):
in_code = not in_code
continue
if in_code:
continue
anchors.update(re.findall(r"\{#([\w-]+)\}", line))
# Also accept implicit heading anchors (markdown-style)
m = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", line)
if m:
anchors.add(_slugify_heading(m.group(1)))
in_code = False
for i, line in enumerate(text.split("\n"), 1):
if line.strip().startswith("```"):
in_code = not in_code
continue
if in_code:
continue
for m in re.finditer(r"\]\(#([\w-]+)\)", line):
if m.group(1) not in anchors:
errors.append(
f" {rel}:{i} links to #{m.group(1)}"
f" but no {{#{m.group(1)}}} anchor exists."
f" Fix: add {{#{m.group(1)}}} to the target"
" section header"
)
return errors



def check_page_refs(repo_root: Path) -> list[str]:
"""Check that @ref targets in docs reference existing page identifiers."""
doc_dir = repo_root / "docs" / "documentation"
if not doc_dir.exists():
return []

# Collect all @page identifiers
page_ids = {"citelist"} # Doxygen built-in
for md_file in doc_dir.glob("*.md"):
text = md_file.read_text(encoding="utf-8")
m = re.search(r"^\s*@page\s+(\w+)", text, flags=re.MULTILINE)
if m:
page_ids.add(m.group(1))
Comment thread
sbryngelson marked this conversation as resolved.
Comment thread
sbryngelson marked this conversation as resolved.

errors = []
for md_file in sorted(doc_dir.glob("*.md")):
text = _strip_code_blocks(md_file.read_text(encoding="utf-8"))
rel = md_file.relative_to(repo_root)
for match in REF_RE.finditer(text):
ref_target = match.group(1)
if ref_target not in page_ids:
errors.append(f" {rel} uses @ref {ref_target} but no @page with that ID exists")

return errors

Expand All @@ -142,6 +234,7 @@ def main():
all_errors.extend(check_docs(repo_root))
all_errors.extend(check_cite_keys(repo_root))
all_errors.extend(check_param_refs(repo_root))
all_errors.extend(check_page_refs(repo_root))

if all_errors:
print("Doc reference check failed:")
Expand Down
Loading
Loading