diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 0000000..945bc4b --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# SessionStart hook: attribute commits made in Claude Code (especially the +# ephemeral "on the web" containers, which clone the repo fresh and have no +# git identity configured) to James Le Houx's GitHub account. +# +# Uses GitHub's noreply email, which reliably maps to the account for commit +# attribution. Scoped to this repo (--local) and idempotent. +set -euo pipefail + +git config --local user.name "James Le Houx" +git config --local user.email "37665786+jameslehoux@users.noreply.github.com" + +echo "git identity set: James Le Houx <37665786+jameslehoux@users.noreply.github.com>" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e06b033 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 8ae9365..6d98686 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,15 +23,19 @@ for linear solves. └────────────────┘ └────────────────┘ └──────────────────┘ TiffReader.H/cpp TortuosityHypre ResultsJSON.H - HDF5Reader.H/cpp EffDiffusivityHypre → BPX, BattINFO - RawReader.H/cpp VolumeFraction → JSON + text output - DatReader.H/cpp PercolationCheck - TortuosityDirect + HDF5Reader.H/cpp TortuosityMLMG → BPX, BattINFO + RawReader.H/cpp EffDiffusivityHypre → JSON + text output + DatReader.H/cpp VolumeFraction + PercolationCheck │ ┌──────────▼──────────┐ - │ Fortran Kernels │ ← Computational hot path - │ (*_F.H ↔ *.F90) │ Matrix fill, flux calc + │ C++ Kernels │ ← Computational hot path + │ (TortuosityKernels │ Matrix fill, flux calc + │ .H, AMReX GPU) │ (AMReX ParallelFor/Array4) └─────────────────────┘ + +A Python layer (`python/`) wraps the C++ libraries via pybind11 and adds a +NumPy-native high-level API plus a pure-Python SciPy/CuPy fallback. ``` ### Module Relationships @@ -41,17 +45,28 @@ MultiFab). All readers produce the same output type — a phase-labeled voxel grid. The readers are independent of the physics layer. **Physics solvers** (`src/props/`) operate on `iMultiFab` phase data: -- `TortuosityHypre` — Solves ∇·(D∇φ) = 0 with Dirichlet BCs to get τ +- `TortuosityHypre` — Solves ∇·(D∇φ) = 0 with Dirichlet BCs to get τ, using + HYPRE structured-grid Krylov solvers + SMG/PFMG multigrid preconditioners +- `TortuosityMLMG` — Same PDE via AMReX's matrix-free MLMG geometric multigrid + (embedded boundary for inactive cells); ~3× less memory, fastest on GPU - `EffectiveDiffusivityHypre` — Solves cell problem ∇·(D∇χ) = -∇·(Dê) for the effective diffusivity tensor via homogenization - `PercolationCheck` — Flood-fill connectivity check (no solver needed) - `VolumeFraction` — Phase counting with MPI reduction - `TortuosityDirect` — Legacy iterative solver (Forward Euler, not HYPRE) -**Fortran interop** — The HYPRE matrix fill and flux calculations are in -Fortran 90 for performance. C interface headers (`*_F.H`) bridge C++ ↔ Fortran -with explicit documentation of index convention differences (C: 0-based, -Fortran: 1-based). +`TortuosityHypre`, `TortuosityMLMG`, and `TortuosityDirect` share +`TortuositySolverBase`, which factors out the solver-independent work: +building the diffusion-coefficient field, removing isolated cells, the +flood-fill activity mask, and post-solve boundary-flux integration → +conservation check → τ. Each backend just implements `solve()`. + +**Native C++ kernels** — The HYPRE/MLMG matrix fill and flux calculations were +historically Fortran 90 but have been **migrated to native C++** +(`TortuosityKernels.H`, using AMReX `ParallelFor`/`Array4`) so the hot path +runs on CPU and GPU (CUDA/HIP) from a single source. There are no `.F90` / +`*_F.H` files in the tree; CMake still lists Fortran as a language for AMReX +compatibility only. **Configuration** is via AMReX `ParmParse` (text `inputs` files). Key types: - `PhysicsConfig.H` — Maps solver output to physical quantities (diffusion, @@ -60,14 +75,20 @@ Fortran: 1-based). ### Key Data Flow +`Diffusion.cpp` selects one of four modes via the `calculation_method` / +`dry_run` / `rev.do_study` inputs: `dry_run` (percolation + volume fraction +only), REV study, `homogenization` (D_eff tensor), or `flow_through` +(tortuosity). + ``` -TIFF/HDF5/RAW file +TIFF/HDF5/RAW/DAT file → Reader.threshold() → iMultiFab (phase IDs: 0, 1, ...) → PercolationCheck (is phase connected inlet→outlet?) → VolumeFraction (what fraction is this phase?) - → TortuosityHypre or EffDiffusivityHypre - → Fortran kernel fills HYPRE matrix (harmonic mean face coefficients) - → HYPRE solve (FlexGMRES, PCG, etc.) + → TortuosityHypre / TortuosityMLMG (flow_through) + or EffectiveDiffusivityHypre + DeffTensor (homogenization) + → C++ kernel fills system (harmonic mean face coefficients) + → solve (HYPRE FlexGMRES/PCG/… or AMReX MLMG V-cycle) → Flux integration → D_eff, tortuosity → ResultsJSON → results.json + results.txt ``` @@ -79,8 +100,9 @@ Inter-cell face diffusivities use the harmonic mean of adjacent cell values: ``` D_face = 2 * D_left * D_right / (D_left + D_right) ``` -This is physically correct for series resistance and appears in both -`TortuosityHypreFill.F90` and `EffDiffFillMtx.F90`. +This is physically correct for series resistance and appears in the C++ +matrix-fill paths of `TortuosityHypre`, `TortuosityMLMG`, and +`EffectiveDiffusivityHypre`. ### Tortuosity Definition ``` @@ -138,22 +160,43 @@ make -j$(nproc) && ctest --output-on-failure | File | Purpose | |------|---------| | `Diffusion.cpp` | **Main application entry point** — orchestrates full pipeline | -| `Tortuosity.H` | Base class + enums (Direction, CellType, SolverType) | -| `TortuosityHypre.H/cpp` | HYPRE-based tortuosity solver (primary solver) | +| `Tortuosity.H` | Base interface + enums (Direction, CellType, SolverType) | +| `TortuositySolverBase.H/cpp` | Shared solver setup + flux/τ post-processing (base class) | +| `TortuosityHypre.H/cpp` | HYPRE structured-grid tortuosity solver | +| `TortuosityMLMG.H/cpp` | Matrix-free AMReX MLMG tortuosity solver (EB, GPU-native) | | `TortuosityDirect.H/cpp` | Legacy iterative tortuosity solver (Forward Euler) | -| `EffectiveDiffusivityHypre.H/cpp` | Effective diffusivity tensor via homogenization | +| `TortuosityKernels.H` | C++ hot-path kernels (isolated-cell removal, face flux, flux integration) | +| `HypreStructSolver.H/cpp` | HYPRE lifecycle/infrastructure shared by HYPRE solvers | +| `EffectiveDiffusivityHypre.H/cpp` | Cell-problem solver for χ fields (homogenization) | +| `DeffTensor.H/cpp` | Assembles 3×3 D_eff tensor from χ gradients | | `VolumeFraction.H/cpp` | Phase volume fraction calculator | | `PercolationCheck.H/cpp` | Flood-fill percolation connectivity check | +| `FloodFill.H/cpp` | GPU-parallel flood-fill (used by percolation/components/mask) | +| `ConnectedComponents.H/cpp` | Labels contiguous regions of a phase | +| `SpecificSurfaceArea.H/cpp` | Interface area via Cauchy–Crofton stereology | +| `ParticleSizeDistribution.H/cpp` | Equivalent-sphere radii from components | +| `ThroughThicknessProfile.H/cpp` | Per-slice volume fraction along a direction | +| `REVStudy.H/cpp` | Representative Elementary Volume convergence study | | `PhysicsConfig.H` | Physics type mapping (diffusion ↔ conductivity ↔ thermal) | +| `SolverConfig.H` | String↔enum parsing (solver type, direction) | +| `BoundaryCondition.H` | BC enums/abstraction (Dirichlet/Neumann/Periodic) | +| `MacroGeometry.H` | Electrode thickness / cross-section / volume helpers | | `ResultsJSON.H` | Structured JSON output (BPX/BattINFO compatible) | -### Source — Fortran Kernels (`src/props/`) +> **Note:** The HYPRE/MLMG matrix-fill and flux kernels were historically +> Fortran 90 (`*_F.H` ↔ `*.F90`) but have been migrated to native C++ in +> `TortuosityKernels.H` (AMReX `ParallelFor`/`Array4`, CPU+GPU). No Fortran +> source files remain in the repository. + +### Source — Python layer (`python/`) | File | Purpose | |------|---------| -| `TortuosityHypreFill_F.H` / `.F90` | HYPRE matrix fill for tortuosity (7-pt stencil) | -| `EffDiffFillMtx_F.H` / `.F90` | HYPRE matrix fill for effective diffusivity cell problem | -| `Tortuosity_filcc_F.H` / `.F90` | Cell type ID, ghost cell fill, initial conditions | -| `Tortuosity_poisson_3d_F.H` / `.F90` | Flux calculation and Forward Euler update (legacy solver) | +| `openimpala/facade.py` | High-level NumPy-native API (tortuosity, volume_fraction, …) | +| `openimpala/session.py` | `Session` context manager — AMReX init/finalize (re-entrant) | +| `openimpala/_solver.py` | Pure-Python SciPy/CuPy fallback backend (no compiled `_core`) | +| `openimpala/cli.py` | `openimpala` CLI entry point | +| `bindings/*.cpp` | pybind11 `_core` module (module/io/props/solvers/config/enums) | +| `bindings/VoxelImage.H` | NumPy → AMReX `iMultiFab` bridge struct | ### Tests (`tests/`) | File | Purpose | @@ -162,12 +205,15 @@ make -j$(nproc) && ctest --output-on-failure | `tEffectiveDiffusivity.cpp` | D_eff tensor on real TIFF data | | `tVolumeFraction.cpp` | Volume fraction validation | | `tPercolationCheck.cpp` | Flood-fill connectivity check | +| `tTortuosityMLMG.cpp` | Matrix-free MLMG solver (directional/geometry variants) | | `tMultiPhaseTransport.cpp` | Synthetic geometry tests (analytical validation) | -| `tTiffReader.cpp` | TIFF I/O correctness | -| `tHDF5Reader.cpp` | HDF5 I/O correctness | -| `tRawReader.cpp` | RAW binary I/O correctness | -| `tests/unit/` | Catch2 unit tests (PhysicsConfig, ResultsJSON) | +| `tSynthetic*.cpp` | Synthetic VF / percolation / D_eff / microstructure / REV tests | +| `tTiffReader.cpp` / `tHDF5Reader.cpp` / `tRawReader.cpp` / `tDatReader.cpp` | I/O correctness | +| `tests/inputs/tDiffusion_*.inputs` | End-to-end `Diffusion` integration tests (dry-run, tiff, hdf5, REV, …) | +| `tests/unit/` | Catch2 unit tests (PhysicsConfig, ResultsJSON, MacroGeometry) | +| `tests/validation/` | V&V scripts (Hashin–Shtrikman bounds, sphere packing, Berea sandstone) | | `tests/benchmarks/` | Python scripts for generating benchmark datasets | +| `python/tests/` | pytest suite for the Python API and bindings | ### Regression Benchmarks Three CTest benchmarks with exact analytical solutions on discrete grids: @@ -182,10 +228,13 @@ Three CTest benchmarks with exact analytical solutions on discrete grids: - **LibTIFF** — TIFF image reading - **nlohmann/json** — JSON output (fetched via CMake FetchContent) - **Catch2 v3** — Test framework (fetched via CMake FetchContent) +- **pybind11** — C++ ↔ Python bindings for the `_core` extension (Python wheels) +- **NumPy / SciPy** (+ optional **CuPy**) — Python API and pure-Python fallback solver ## Code Style - C++17, 100-column limit, 4-space indent (LLVM-based via `.clang-format`) - All code in `namespace OpenImpala` - Headers use `#ifndef` include guards (not `#pragma once`) - Doxygen `@file` / `@brief` / `@param` comments on all public APIs -- Fortran files are NOT processed by clang-format or clang-tidy +- Hot-path kernels are native C++ (AMReX `ParallelFor`/`Array4`) for CPU+GPU; + there are no Fortran source files (the historical `.F90` kernels were ported) diff --git a/notebooks/profiling_and_tuning.ipynb b/notebooks/profiling_and_tuning.ipynb index 047b437..571e6ab 100644 --- a/notebooks/profiling_and_tuning.ipynb +++ b/notebooks/profiling_and_tuning.ipynb @@ -120,14 +120,137 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 3. Solver bake-off on porous media\n\nThis is the headline diagnostic. All available HYPRE Krylov methods, all HYPRE multigrid preconditioners, and the matrix-free AMReX MLMG are run on the *same* porespy structure at 64³. Wall time and iteration count are reported side-by-side; solvers that fail (multigrid stalls on masked rows, etc.) are flagged in red.\n\n**The default solver (`solver=\"auto\"`, since v4.2.20) is MLMG** — it scales near-linearly (see §4) and has the lowest memory footprint of any option. The bake-off below confirms it's also the fastest on this geometry. The runner-up among HYPRE configurations is `flexgmres+pfmg`; `pcg+smg` is the safe fallback if MLMG ever stalls." + "source": [ + "## 3. Solver bake-off on porous media\n", + "\n", + "This is the headline diagnostic. All available HYPRE Krylov methods, all HYPRE multigrid preconditioners, and the matrix-free AMReX MLMG are run on the *same* porespy structure at 64³. Wall time and iteration count are reported side-by-side; solvers that fail (multigrid stalls on masked rows, etc.) are flagged in red.\n", + "\n", + "**The default solver (`solver=\"auto\"`, since v4.2.20) is MLMG** — it scales near-linearly (see §4) and has the lowest memory footprint of any option. The bake-off below confirms it's also the fastest on this geometry. The runner-up among HYPRE configurations is `flexgmres+pfmg`; `pcg+smg` is the safe fallback if MLMG ever stalls.\n", + "\n", + "> **Robustness note.** Standalone structured-multigrid solvers (`smg`, `pfmg`) can abort at the C++ level on a GPU build — a CUDA out-of-memory or HYPRE kernel error that would otherwise kill the notebook kernel and wipe every result. Each combo below therefore runs in its own subprocess, so a hard crash is recorded as a red `FAILED` bar instead of taking the session down with it." + ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": "import sys\n\n# Sanity: make sure the porespy datasets cell (§2) has run. The restructure\n# moved cells around; if you opened the notebook and jumped here directly,\n# `data_medium` won't exist yet and the whole loop raises NameError before\n# any print fires — looking exactly like \"no output\".\nif \"data_medium\" not in dir():\n raise RuntimeError(\n \"data_medium not defined — re-run §2 (Synthetic datasets) first.\"\n )\n\n# Compare Krylov and multigrid choices on 64^3.\n# Each entry is (label, solver, preconditioner). A preconditioner of None means\n# the solver ignores it (standalone SMG/PFMG/Jacobi, or MLMG). `mlmg` is the\n# AMReX-native matrix-free path and the library default since v4.3.0.\ncombos = [\n (\"pcg\", \"pcg\", None),\n (\"pcg+pfmg\", \"pcg\", \"pfmg\"),\n (\"pcg+smg\", \"pcg\", \"smg\"),\n (\"flexgmres+pfmg\", \"flexgmres\", \"pfmg\"),\n (\"gmres\", \"gmres\", None),\n (\"gmres+pfmg\", \"gmres\", \"pfmg\"),\n (\"bicgstab\", \"bicgstab\", None),\n (\"smg\", \"smg\", None),\n (\"pfmg\", \"pfmg\", None),\n (\"mlmg\", \"mlmg\", None),\n]\n\nprint(f\"Running bake-off on {data_medium.shape[0]}³ porespy data... \"\n f\"({len(combos)} solver/preconditioner combinations)\", flush=True)\n\nrecords = []\nfor label, s, pc in combos:\n kwargs = dict(phase=0, direction=\"z\", solver=s, max_grid_size=32, verbose=0)\n if pc is not None:\n kwargs[\"preconditioner\"] = pc\n\n # Flush before each call: a C++-level AMReX::Abort or CUDA OOM kills\n # the kernel without unwinding Python, so anything we haven't printed\n # already is lost. Live progress also makes it obvious *which* solver\n # killed the cell if one of them does.\n sys.stdout.flush()\n\n try:\n t0 = time.perf_counter()\n res = oi.tortuosity(data_medium, **kwargs)\n dt = time.perf_counter() - t0\n records.append({\"label\": label, \"solver\": s, \"precond\": pc, \"t\": dt,\n \"iters\": res.iterations, \"tau\": res.tortuosity,\n \"ok\": res.solver_converged})\n print(f\" {label:18s} t={dt:6.2f}s iters={res.iterations:4d} \"\n f\"tau={res.tortuosity:.4f}\", flush=True)\n except TypeError as e:\n if \"preconditioner\" in str(e) and pc is not None:\n print(f\" {label:18s} SKIP — wheel predates preconditioner plumbing\",\n flush=True)\n records.append({\"label\": label, \"solver\": s, \"precond\": pc,\n \"t\": np.nan, \"iters\": np.nan, \"tau\": np.nan, \"ok\": False})\n continue\n raise\n except Exception as e:\n records.append({\"label\": label, \"solver\": s, \"precond\": pc,\n \"t\": np.nan, \"iters\": np.nan, \"tau\": np.nan, \"ok\": False})\n print(f\" {label:18s} FAILED — {e}\", flush=True)\n\ndf_solvers = pd.DataFrame(records)\ndf_solvers" + "source": [ + "import sys, os, json, subprocess, tempfile\n", + "\n", + "# Sanity: make sure the porespy datasets cell (§2) has run. The restructure\n", + "# moved cells around; if you opened the notebook and jumped here directly,\n", + "# `data_medium` won't exist yet and the whole loop raises NameError before\n", + "# any print fires — looking exactly like \"no output\".\n", + "if \"data_medium\" not in dir():\n", + " raise RuntimeError(\n", + " \"data_medium not defined — re-run §2 (Synthetic datasets) first.\"\n", + " )\n", + "\n", + "# Compare Krylov and multigrid choices on 64^3.\n", + "# Each entry is (label, solver, preconditioner). A preconditioner of None means\n", + "# the solver ignores it (standalone SMG/PFMG/Jacobi, or MLMG). `mlmg` is the\n", + "# AMReX-native matrix-free path and the library default since v4.3.0.\n", + "combos = [\n", + " (\"pcg\", \"pcg\", None),\n", + " (\"pcg+pfmg\", \"pcg\", \"pfmg\"),\n", + " (\"pcg+smg\", \"pcg\", \"smg\"),\n", + " (\"flexgmres+pfmg\", \"flexgmres\", \"pfmg\"),\n", + " (\"gmres\", \"gmres\", None),\n", + " (\"gmres+pfmg\", \"gmres\", \"pfmg\"),\n", + " (\"bicgstab\", \"bicgstab\", None),\n", + " (\"smg\", \"smg\", None),\n", + " (\"pfmg\", \"pfmg\", None),\n", + " (\"mlmg\", \"mlmg\", None),\n", + "]\n", + "\n", + "# --- Crash isolation ---------------------------------------------------------\n", + "# Some HYPRE configurations — notably *standalone* SMG/PFMG on a GPU build —\n", + "# can abort at the C++ level (CUDA out-of-memory or a HYPRE kernel error).\n", + "# That aborts the whole process, killing the Python kernel with no traceback\n", + "# and no way to catch it from the `except` below; df_solvers and every later\n", + "# section die with it.\n", + "#\n", + "# To stay robust we run each combo in its own short-lived subprocess. A hard\n", + "# abort then only takes down that child — the parent records it as FAILED and\n", + "# moves on, and the crashed child's CUDA context is reclaimed cleanly on exit.\n", + "# The cost is one fresh AMReX/HYPRE init per combo (a few seconds); cheap\n", + "# insurance for a diagnostic that deliberately probes failing solvers.\n", + "_data_path = os.path.join(tempfile.gettempdir(), \"oi_bakeoff_data.npy\")\n", + "np.save(_data_path, data_medium)\n", + "\n", + "_CHILD = r\"\"\"\n", + "import sys, json, time, numpy as np, openimpala as oi\n", + "data = np.load(sys.argv[1])\n", + "solver, precond, mgs = sys.argv[2], sys.argv[3], int(sys.argv[4])\n", + "kwargs = dict(phase=0, direction=\"z\", solver=solver, max_grid_size=mgs, verbose=0)\n", + "if precond != \"none\":\n", + " kwargs[\"preconditioner\"] = precond\n", + "with oi.Session():\n", + " t0 = time.perf_counter()\n", + " res = oi.tortuosity(data, **kwargs)\n", + " dt = time.perf_counter() - t0\n", + "print(\"RESULT \" + json.dumps({\n", + " \"t\": dt, \"iters\": int(res.iterations),\n", + " \"tau\": float(res.tortuosity), \"ok\": bool(res.solver_converged),\n", + "}))\n", + "\"\"\"\n", + "\n", + "\n", + "def run_combo(solver, precond, max_grid_size=32, timeout=300):\n", + " \"\"\"Run one solver in an isolated process. A crash, timeout, or\n", + " non-convergence all come back as ok=False instead of killing this kernel.\"\"\"\n", + " try:\n", + " proc = subprocess.run(\n", + " [sys.executable, \"-c\", _CHILD, _data_path, solver,\n", + " precond or \"none\", str(max_grid_size)],\n", + " capture_output=True, text=True, timeout=timeout,\n", + " )\n", + " except subprocess.TimeoutExpired:\n", + " return {\"t\": np.nan, \"iters\": np.nan, \"tau\": np.nan, \"ok\": False,\n", + " \"error\": f\"timeout (>{timeout}s)\"}\n", + "\n", + " for line in proc.stdout.splitlines():\n", + " if line.startswith(\"RESULT \"):\n", + " r = json.loads(line[len(\"RESULT \"):])\n", + " r[\"error\"] = None\n", + " return r\n", + "\n", + " # No RESULT line → the child died before printing. Decode why.\n", + " if proc.returncode < 0:\n", + " import signal\n", + " try:\n", + " sig = signal.Signals(-proc.returncode).name\n", + " except ValueError:\n", + " sig = f\"signal {-proc.returncode}\"\n", + " reason = f\"crashed ({sig}) — likely CUDA OOM / HYPRE abort\"\n", + " else:\n", + " tail = (proc.stderr.strip().splitlines() or [\"no stderr\"])[-1]\n", + " reason = f\"exit {proc.returncode}: {tail[:120]}\"\n", + " return {\"t\": np.nan, \"iters\": np.nan, \"tau\": np.nan, \"ok\": False,\n", + " \"error\": reason}\n", + "\n", + "\n", + "print(f\"Running bake-off on {data_medium.shape[0]}³ porespy data... \"\n", + " f\"({len(combos)} solver/preconditioner combinations, each isolated)\",\n", + " flush=True)\n", + "\n", + "records = []\n", + "for label, s, pc in combos:\n", + " sys.stdout.flush()\n", + " r = run_combo(s, pc, max_grid_size=32)\n", + " records.append({\"label\": label, \"solver\": s, \"precond\": pc,\n", + " \"t\": r[\"t\"], \"iters\": r[\"iters\"], \"tau\": r[\"tau\"],\n", + " \"ok\": r[\"ok\"]})\n", + " if r[\"ok\"]:\n", + " print(f\" {label:18s} t={r['t']:6.2f}s iters={int(r['iters']):4d} \"\n", + " f\"tau={r['tau']:.4f}\", flush=True)\n", + " else:\n", + " print(f\" {label:18s} FAILED — {r['error']}\", flush=True)\n", + "\n", + "df_solvers = pd.DataFrame(records)\n", + "df_solvers\n" + ] }, { "cell_type": "code", @@ -486,4 +609,4 @@ ] } ] -} \ No newline at end of file +}