diff --git a/README.md b/README.md index ea96977..9016af4 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,38 @@ uv run python scripts/plot_poisson_galerkin_figure_pack.py --profile quick See `docs/poisson-galerkin-solver.md` and `docs/poisson-benchmarks.md`. +## FormDSL step simulations + +Run dealii-style step examples for end-to-end FormDSL simulations: + +```bash +uv run python scripts/run_formdsl_step_001_iga_poisson.py --resolution 16 +uv run python scripts/run_formdsl_step_002_iga_multipatch_poisson.py --resolution 8 +uv run python scripts/run_formdsl_step_003_dgsem_poisson.py --resolution 24 +``` + +Step 003 runs a DGSEM convection-diffusion prototype over a CUTKIT clipped +overlay. + +DGSEM lowering inspection remains available via: + +```bash +uv run python scripts/run_formdsl_dgsem_lowering_example.py --flux sipg +``` + +DGSEM step execution requires grudge dependencies and uses a CUTKIT clipped +overlay from the Section 6.1.1 trimmed panel: + +```bash +uv sync --extra dgsem +uv run python scripts/run_formdsl_step_003_dgsem_poisson.py --resolution 24 +``` + +Each step script now emits SVG plot artifacts by default under +`.artifacts/formdsl-step-00x` (disable via `--skip-plots`). + +Details: `docs/formdsl-step-simulations.md`. + ## Development workflow notes - Python requirement: `>=3.12`. diff --git a/docs/exec-plans/completed/20260407-step003-convection-diffusion.md b/docs/exec-plans/completed/20260407-step003-convection-diffusion.md new file mode 100644 index 0000000..c70b457 --- /dev/null +++ b/docs/exec-plans/completed/20260407-step003-convection-diffusion.md @@ -0,0 +1,43 @@ +# Step-003 Convection-Diffusion Upgrade + +## Objective + +Upgrade FormDSL DG step-003 from diffusion/reaction-only Poisson prototype to a +convection-diffusion setup, while keeping trimmed-overlay execution and end-to-end +runtime validation on `ipa`. + +## Scope + +- Add DGSEM capability/lowering support for a `convection` term kind. +- Extend DGSEM solver assembly to include a convection contribution. +- Use a non-symmetric linear solve path when convection is present. +- Update step-003 payload/labels/docs to describe convection-diffusion. +- Add/adjust tests for new term support and behavior. + +## Acceptance Criteria + +- `assemble_form(..., backend="dgsem")` accepts `convection` terms. +- Step-003 form includes a convection term and no longer presents itself as + Poisson-only. +- Local checks pass (`make dev`). +- Step-003 run on `ipa` succeeds for at least one resolution with convection and + reports passing residual checks. + +## Checklist + +- [x] Add `convection` to DGSEM supported-term capabilities. +- [x] Extend DGSEM lowering to emit deterministic convection volume lowering. +- [x] Extend solver matrix assembly with convection contribution. +- [x] Add non-symmetric linear solve fallback for convection cases. +- [x] Update step-003 form payload and reporting strings. +- [x] Update/expand FormDSL tests for convection support. +- [x] Validate locally and on `ipa`. + +## Outcomes + +- DGSEM now supports `convection` in capability checks and lowering payloads. +- Step-003 payload and reporting now describe convection-diffusion. +- DGSEM solve path now chooses a non-symmetric solver when convection is present + (`bicgstab` with `cgne` fallback). +- Local `make dev` passes, and `ipa` step-003 runs pass at resolutions 8, 16, + and 24. diff --git a/docs/formdsl-step-simulations.md b/docs/formdsl-step-simulations.md new file mode 100644 index 0000000..4be2458 --- /dev/null +++ b/docs/formdsl-step-simulations.md @@ -0,0 +1,97 @@ +# FormDSL Step Simulations + +This page provides dealii `step-xxx` style end-to-end examples for CUTKIT +FormDSL workflows. + +## Step 001: Single-Patch Poisson (IGA) + +Run a full trimmed-domain Poisson solve through: + +- FormDSL parsing, +- IGA assembly, +- CG linear solve, +- and reference-error check against a higher-order solve. + +```bash +uv run python scripts/run_formdsl_step_001_iga_poisson.py --resolution 16 +``` + +Useful options: + +- `--backend-mode jplus|folded` +- `--artifact-dir ` +- `--skip-plots` +- `--manifest-path ` +- `--max-abs-error ` + +By default this script writes an SVG figure pack under +`.artifacts/formdsl-step-001`. + +## Step 002: Multipatch Interface-Coupled Poisson (IGA) + +Run a full multipatch-flavored solve with deterministic interface coupling +metadata in the payload and repeated-assembly determinism checks. + +```bash +uv run python scripts/run_formdsl_step_002_iga_multipatch_poisson.py --resolution 8 +``` + +Useful options: + +- `--artifact-dir ` +- `--skip-plots` +- `--manifest-path ` +- `--max-residual ` +- `--max-repeat-coeff-diff ` + +By default this script writes an SVG figure pack under +`.artifacts/formdsl-step-002`. + +## Step 003: DGSEM Convection-Diffusion Prototype (End-To-End Solve) + +Run a DGSEM-style end-to-end convection-diffusion solve through FormDSL DG +lowering and a linear solve path with a real CUTKIT trimmed overlay built from +the Section 6.1.1 B-spline panel clipped against a Cartesian background grid: + +```bash +uv run python scripts/run_formdsl_step_003_dgsem_poisson.py --resolution 24 +``` + +Useful options: + +- `--resolution ` +- `--sample-count ` +- `--overlay-quadrature-order ` +- `--artifact-dir ` +- `--skip-plots` +- `--manifest-path ` + +By default this script writes SVG artifacts under `.artifacts/formdsl-step-003`: + +- trimmed overlay geometry, +- clipped-cell classification, +- and a DG solve profile chart. + +`grudge` mode requires the `dgsem` extra plus a working +OpenCL platform (`uv sync --extra dgsem`). + +No CUTKIT runtime compatibility shims are applied. The `dgsem` extra pins a +tested grudge compatibility window (`grudge==2021.1`, `meshmode==2021.2`, +`loopy==2024.1`, `modepy==2021.1`, `pymbolic==2024.2.2`, +`pytools==2024.1.21`). + +## DG-SEM Status + +The core `dgsem` backend contract remains deterministic lowering payload +first (operator-chain descriptors and diagnostics). + +`solve_form(..., backend="dgsem")` now runs the grudge execution path only +(optional dependencies + OpenCL). + +Production-grade grudge operator execution semantics are still evolving. + +For lowering inspection: + +```bash +uv run python scripts/run_formdsl_dgsem_lowering_example.py --flux sipg +``` diff --git a/docs/index.md b/docs/index.md index ff80f0e..96d43eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,11 +26,15 @@ Repository-local documentation is the system of record for CUTKIT. - `docs/poisson-benchmarks.md`: Poisson-oriented benchmark usage and profiles - `docs/poisson-galerkin-solver.md`: immersed Poisson Galerkin solve + validation workflow - `docs/formdsl-parity-benchmarks.md`: shared IGA + DG-SEM formdsl parity benchmark usage +- `docs/formdsl-step-simulations.md`: dealii-style FormDSL end-to-end step runs - `docs/formdsl-nurbs-multipatch-evaluation.md`: Phase 3 evaluation notes for NURBS mapping and multipatch rollout - `docs/ufl-backend-support.md`: UFL form backend support matrix and diagnostics - `scripts/check_docs_freshness.py`: stale docs path + markdown-anchor checker - `scripts/minimize_cutpanel_fuzz_cases.py`: deterministic fuzz-candidate minimizer - `scripts/run_formdsl_parity_benchmark.py`: run formdsl convergence + parity benchmark examples +- `scripts/run_formdsl_step_001_iga_poisson.py`: step-001 end-to-end FormDSL IGA Poisson solve +- `scripts/run_formdsl_step_002_iga_multipatch_poisson.py`: step-002 multipatch FormDSL IGA Poisson solve +- `scripts/run_formdsl_step_003_dgsem_poisson.py`: step-003 FormDSL DGSEM solve prototype - `docs/entire-transcript-policy.md`: Entire transcript handling policy - `.github/workflows/weekly-janitor.yml`: scheduled repository cleanup automation diff --git a/docs/ufl-backend-support.md b/docs/ufl-backend-support.md index 3d293f7..cbffca9 100644 --- a/docs/ufl-backend-support.md +++ b/docs/ufl-backend-support.md @@ -78,6 +78,7 @@ deterministic conventions: | Term kind | iga | dgsem | | --- | --- | --- | | `diffusion` | yes | yes | +| `convection` | no (`unsupported_term`) | yes | | `mass` | yes | yes | | `reaction` | yes | yes | | `source` | yes | yes | @@ -159,6 +160,50 @@ Current family operator behavior in lowering payloads: | `central` | `bdry_trace_pair -> project -> face_mass` | `interior_trace_pairs -> project -> face_mass` | ignores `dg_penalty` | | `upwind` | `bdry_trace_pair -> project -> face_mass` | `interior_trace_pairs -> project -> face_mass` | ignores `dg_penalty` | +## DG Lowering Example (No Solve Execution) + +Use the DGSEM lowering example script when you want to inspect the deterministic +payload and operator chains CUTKIT emits for a form: + +```bash +uv run python scripts/run_formdsl_dgsem_lowering_example.py --flux sipg +``` + +The script always reports `execution_mode = lowering_only`, prints volume/trace/ +flux signatures, and can optionally emit a JSON manifest via +`--manifest-path`. + +## End-To-End Solve Helper + +`cutkit.formdsl.solve_form(...)` now provides one end-to-end linear solve helper +for both backends: + +- `backend="iga"`: assembled trimmed-domain IGA matrix + CG solve. +- `backend="dgsem"`: DG lowering plus grudge-backed execution mode: + - `dgsem_execution_mode="grudge"` (requires `dgsem` extra and OpenCL). + - symmetric systems use CG; convection-enabled systems use non-symmetric + solve (`bicgstab` with deterministic `cgne` fallback). + - The `dgsem` extra pins a grudge-compatible package window: + `grudge==2021.1`, `meshmode==2021.2`, `loopy==2024.1`, + `modepy==2021.1`, `pymbolic==2024.2.2`, `pytools==2024.1.21`. + +## FormDSL End-To-End Step Examples + +For dealii-style end-to-end runs, use the step scripts: + +```bash +uv run python scripts/run_formdsl_step_001_iga_poisson.py --resolution 16 +uv run python scripts/run_formdsl_step_002_iga_multipatch_poisson.py --resolution 8 +uv run python scripts/run_formdsl_step_003_dgsem_poisson.py --resolution 24 +``` + +These execute full simulation loops (assembly + linear solve + checks). +Step 003 builds a real CUTKIT clipped overlay from the Section 6.1.1 trimmed +panel before running DG lowering and a convection-diffusion solve. + +All step scripts also emit SVG plot artifacts by default (override path with +`--artifact-dir`, disable with `--skip-plots`). + ## Backend Parity Benchmark Example Use the formdsl parity benchmark runner for a shared IGA + DG-SEM example: diff --git a/pyproject.toml b/pyproject.toml index ee4004c..16b0e5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,12 @@ forms = [ "ufl>=2017.1.0" ] dgsem = [ - "grudge>=2021.1", - "meshmode>=2021.1" + "grudge==2021.1", + "meshmode==2021.2", + "loopy==2024.1", + "modepy==2021.1", + "pymbolic==2024.2.2", + "pytools==2024.1.21" ] perf = [ "numpy>=2" diff --git a/scripts/run_formdsl_dgsem_lowering_example.py b/scripts/run_formdsl_dgsem_lowering_example.py new file mode 100644 index 0000000..459279f --- /dev/null +++ b/scripts/run_formdsl_dgsem_lowering_example.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Run a deterministic DGSEM lowering-only example payload.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict +from pathlib import Path +from typing import Any + +from cutkit.formdsl import assemble_form +from cutkit.io import MeshmodeCutOverlay + + +def _parse_flux_family(raw: str) -> str: + value = raw.strip().lower() + if value in {"sipg", "central", "upwind"}: + return value + raise argparse.ArgumentTypeError("flux must be one of: sipg, central, upwind") + + +def _build_demo_overlay_payload() -> MeshmodeCutOverlay: + return MeshmodeCutOverlay( + contract_version=1, + target_element_ids=(0,), + source_element_ids=(0,), + statuses=("ok",), + diagnostics=(), + point_indptr_by_element=(0, 0), + point_coords=(), + point_weights=(), + geometry_metadata_by_element=((),), + ) + + +def _default_form_payload(*, flux_family: str) -> dict[str, object]: + return { + "trial_space": "P1", + "test_space": "P1", + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "source", "coefficient": 1.0, "source": 1.0}, + ], + "boundary_conditions": [ + {"kind": "essential", "boundary": "left", "value": 0.0}, + {"kind": "natural", "boundary": "right", "value": 1.0}, + ], + "metadata": {"dg_flux": flux_family}, + } + + +def _print_signature_list(title: str, entries: tuple[str, ...]) -> None: + print(title) + for entry in entries: + print(f"- {entry}") + + +def _print_operator_chains(payload: Any) -> None: + print("Volume lowering operator chains") + for entry in payload.volume_lowering: + print(f"- {entry.kind}: {' -> '.join(entry.operator_chain)}") + + print("Trace lowering operator chains") + for entry in payload.trace_lowering: + print(f"- {entry.kind}:{entry.boundary}: {' -> '.join(entry.operator_chain)}") + + print("Flux lowering operator chains") + for entry in payload.flux_lowering: + boundary = entry.boundary if entry.boundary is not None else "interior" + print(f"- {entry.role}:{boundary}: {' -> '.join(entry.operator_chain)}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Run one CUTKIT DGSEM lowering-only example (no grudge solve execution)" + ) + ) + parser.add_argument( + "--flux", + type=_parse_flux_family, + default="sipg", + help="flux family: sipg, central, or upwind", + ) + parser.add_argument( + "--strict", + action="store_true", + help="enable strict backend checks", + ) + parser.add_argument( + "--manifest-path", + type=Path, + default=None, + help="optional JSON output path for lowering payload", + ) + args = parser.parse_args() + + result = assemble_form( + _default_form_payload(flux_family=args.flux), + backend="dgsem", + strict=args.strict, + overlay_payload=_build_demo_overlay_payload(), + ) + payload = result.payload + + print("execution_mode = lowering_only") + print("backend = dgsem") + print(f"flux_family = {payload.flux_family}") + print(f"geometry_map = {payload.geometry_map}") + print() + _print_signature_list("Volume signature", payload.volume_terms) + _print_signature_list("Trace signature", payload.trace_terms) + _print_signature_list("Flux signature", payload.flux_terms) + print() + _print_operator_chains(payload) + + if args.manifest_path is not None: + data = { + "backend": result.backend, + "execution_mode": "lowering_only", + "diagnostics": [asdict(diagnostic) for diagnostic in result.diagnostics], + "payload": asdict(payload), + } + args.manifest_path.parent.mkdir(parents=True, exist_ok=True) + args.manifest_path.write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print() + print(f"wrote manifest: {args.manifest_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_formdsl_step_001_iga_poisson.py b/scripts/run_formdsl_step_001_iga_poisson.py new file mode 100644 index 0000000..8e47810 --- /dev/null +++ b/scripts/run_formdsl_step_001_iga_poisson.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Step-001: end-to-end FormDSL IGA Poisson simulation.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import cast + +from cutkit.diagnostics.poisson_galerkin_figures import ( + write_poisson_galerkin_figure_pack, +) +from cutkit.evals import antolin_wei_buffa_2022_2d as awb2d +from cutkit.evals import poisson_galerkin as pg +from cutkit.formdsl import assemble_form +from cutkit.formdsl.iga_backend import IGAAssemblyResult + + +def _positive_int(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError("expected positive integer") from exc + if value <= 0: + raise argparse.ArgumentTypeError("expected positive integer") + return value + + +def _positive_float(raw: str) -> float: + try: + value = float(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError("expected positive float") from exc + if value <= 0.0: + raise argparse.ArgumentTypeError("expected positive float") + return value + + +def _form_payload() -> dict[str, object]: + return { + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "source", "source": pg.default_poisson_source}, + ], + "boundary_conditions": [{"kind": "essential", "boundary": "all", "value": 0.0}], + "metadata": {"geometry_map": "bspline"}, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Step-001 tutorial style run: solve a trimmed Poisson problem " + "through FormDSL -> IGA assembly -> CG solve" + ) + ) + parser.add_argument("--resolution", type=_positive_int, default=16) + parser.add_argument("--spline-degree", type=_positive_int, default=2) + parser.add_argument("--quadrature-order", type=_positive_int, default=4) + parser.add_argument("--reference-quadrature-order", type=_positive_int, default=6) + parser.add_argument( + "--backend-mode", + choices=("jplus", "folded"), + default="folded", + help="quadrature mode used for FormDSL IGA assembly", + ) + parser.add_argument("--sample-count", type=_positive_int, default=256) + parser.add_argument("--cg-tolerance", type=_positive_float, default=1.0e-10) + parser.add_argument("--max-abs-error", type=_positive_float, default=2.0e-4) + parser.add_argument( + "--artifact-dir", + type=Path, + default=Path(".artifacts/formdsl-step-001"), + help="directory for generated SVG plots", + ) + parser.add_argument( + "--skip-plots", + action="store_true", + help="disable SVG artifact generation", + ) + parser.add_argument("--manifest-path", type=Path, default=None) + parser.add_argument( + "--allow-fail", + action="store_true", + help="always exit zero even when accuracy check fails", + ) + args = parser.parse_args() + + panel = awb2d.build_section_6_1_1_bspline_panel(sample_count=args.sample_count) + assembled = assemble_form( + _form_payload(), + backend="iga", + panel=panel, + resolution=args.resolution, + spline_degree=args.spline_degree, + quadrature_order=args.quadrature_order, + backend_mode=args.backend_mode, + ) + payload = cast(IGAAssemblyResult, assembled.payload) + + coefficients, cg_iterations, residual_norm = pg._conjugate_gradient( + list(payload.matrix_rows), + list(payload.rhs), + tolerance=args.cg_tolerance, + max_iterations=None, + ) + sampled_solution = pg._sample_solution_on_grid( + coefficients, + resolution=args.resolution, + spline_degree=args.spline_degree, + bounds=payload.bounds, + ) + reference = pg.solve_trimmed_poisson_galerkin( + panel, + resolution=args.resolution, + spline_degree=args.spline_degree, + quadrature_order=args.reference_quadrature_order, + backend_mode="jplus", + bounds=payload.bounds, + ) + free_indices = pg._free_indices_for_compare( + panel, + resolution=args.resolution, + bounds=payload.bounds, + ) + max_abs_error = max( + ( + abs(sampled_solution[index] - reference.solution[index]) + for index in free_indices + ), + default=0.0, + ) + passed = max_abs_error <= args.max_abs_error + + plot_artifacts: tuple[Path, ...] = () + if not args.skip_plots: + snapshot = pg.build_poisson_galerkin_geometry_snapshot( + panel, + resolution=args.resolution, + bounds=payload.bounds, + ) + artifacts = write_poisson_galerkin_figure_pack( + snapshot, + solutions={ + "step001": sampled_solution, + "reference": reference.solution, + }, + output_dir=args.artifact_dir, + prefix="formdsl-step-001", + ) + plot_artifacts = tuple(artifact.file_path for artifact in artifacts) + + print("step = 001") + print("problem = poisson_dirichlet") + print("pipeline = formdsl -> iga -> cg") + print(f"resolution = {args.resolution}") + print(f"spline_degree = {args.spline_degree}") + print(f"quadrature_order = {args.quadrature_order}") + print(f"reference_quadrature_order = {args.reference_quadrature_order}") + print(f"backend_mode = {args.backend_mode}") + print(f"dof_count = {len(coefficients)}") + print(f"free_dof_count = {len(free_indices)}") + print(f"cg_iterations = {cg_iterations}") + print(f"residual_norm = {residual_norm:.6e}") + print(f"max_abs_error = {max_abs_error:.6e}") + print(f"error_threshold = {args.max_abs_error:.6e}") + if plot_artifacts: + for artifact_path in plot_artifacts: + print(f"plot = {artifact_path}") + print(f"passed = {passed}") + + if args.manifest_path is not None: + data = { + "step": "001", + "problem": "poisson_dirichlet", + "pipeline": "formdsl -> iga -> cg", + "resolution": args.resolution, + "spline_degree": args.spline_degree, + "quadrature_order": args.quadrature_order, + "reference_quadrature_order": args.reference_quadrature_order, + "backend_mode": args.backend_mode, + "cg_tolerance": args.cg_tolerance, + "max_abs_error_threshold": args.max_abs_error, + "dof_count": len(coefficients), + "free_dof_count": len(free_indices), + "cg_iterations": cg_iterations, + "residual_norm": residual_norm, + "max_abs_error": max_abs_error, + "passed": passed, + "plot_artifacts": [str(path) for path in plot_artifacts], + "assembly_diagnostics": [ + { + "code": diagnostic.code, + "backend": diagnostic.backend, + "detail": diagnostic.detail, + "alternatives": diagnostic.alternatives, + } + for diagnostic in assembled.diagnostics + ], + } + args.manifest_path.parent.mkdir(parents=True, exist_ok=True) + args.manifest_path.write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"wrote manifest: {args.manifest_path}") + + if passed or args.allow_fail: + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_formdsl_step_002_iga_multipatch_poisson.py b/scripts/run_formdsl_step_002_iga_multipatch_poisson.py new file mode 100644 index 0000000..0b50eb9 --- /dev/null +++ b/scripts/run_formdsl_step_002_iga_multipatch_poisson.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Step-002: end-to-end FormDSL IGA multipatch Poisson simulation.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import cast + +from cutkit.diagnostics.poisson_galerkin_figures import ( + write_poisson_galerkin_figure_pack, +) +from cutkit.evals import antolin_wei_buffa_2022_2d as awb2d +from cutkit.evals import poisson_galerkin as pg +from cutkit.formdsl import assemble_form +from cutkit.formdsl.iga_backend import IGAAssemblyResult + + +def _positive_int(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError("expected positive integer") from exc + if value <= 0: + raise argparse.ArgumentTypeError("expected positive integer") + return value + + +def _positive_float(raw: str) -> float: + try: + value = float(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError("expected positive float") from exc + if value <= 0.0: + raise argparse.ArgumentTypeError("expected positive float") + return value + + +def _sparse_max_abs_diff( + lhs: tuple[dict[int, float], ...], + rhs: tuple[dict[int, float], ...], +) -> float: + if len(lhs) != len(rhs): + raise ValueError("sparse row counts must match") + max_diff = 0.0 + for lhs_row, rhs_row in zip(lhs, rhs, strict=True): + keys = set(lhs_row) | set(rhs_row) + for key in keys: + diff = abs(lhs_row.get(key, 0.0) - rhs_row.get(key, 0.0)) + if diff > max_diff: + max_diff = diff + return max_diff + + +def _vector_max_abs_diff(lhs: tuple[float, ...], rhs: tuple[float, ...]) -> float: + if len(lhs) != len(rhs): + raise ValueError("vector lengths must match") + return max((abs(a - b) for a, b in zip(lhs, rhs, strict=True)), default=0.0) + + +def _form_payload() -> dict[str, object]: + return { + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "source", "source": pg.default_poisson_source}, + ], + "boundary_conditions": [{"kind": "essential", "boundary": "all", "value": 0.0}], + "metadata": { + "geometry_map": "bspline", + "multipatch_penalty": "1.25", + "multipatch_boundary:patch-b:marker:b-right": "right", + "multipatch_boundary:patch-a:marker:a-top": "top", + "multipatch_boundary:patch-c:marker:c-top": "top", + "multipatch_boundary:patch-a:marker:a-right": "right", + }, + "multipatch": { + "patch_ids": ["patch-a", "patch-b", "patch-c"], + "interfaces": [ + { + "plus_patch": "patch-b", + "minus_patch": "patch-a", + "plus_boundary": "marker:b-right", + "minus_boundary": "marker:a-top", + "orientation": "aligned", + "penalty": 0.75, + }, + { + "plus_patch": "patch-c", + "minus_patch": "patch-a", + "plus_boundary": "marker:c-top", + "minus_boundary": "marker:a-right", + "orientation": "reversed", + "penalty": 1.5, + }, + ], + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Step-002 tutorial style run: solve a multipatch trimmed Poisson " + "problem with FormDSL IGA interface coupling" + ) + ) + parser.add_argument("--resolution", type=_positive_int, default=8) + parser.add_argument("--spline-degree", type=_positive_int, default=2) + parser.add_argument("--quadrature-order", type=_positive_int, default=4) + parser.add_argument("--sample-count", type=_positive_int, default=256) + parser.add_argument("--cg-tolerance", type=_positive_float, default=1.0e-10) + parser.add_argument("--max-residual", type=_positive_float, default=1.0e-8) + parser.add_argument( + "--max-repeat-coeff-diff", + type=_positive_float, + default=1.0e-10, + help="determinism threshold between repeated assembled solves", + ) + parser.add_argument( + "--artifact-dir", + type=Path, + default=Path(".artifacts/formdsl-step-002"), + help="directory for generated SVG plots", + ) + parser.add_argument( + "--skip-plots", + action="store_true", + help="disable SVG artifact generation", + ) + parser.add_argument("--manifest-path", type=Path, default=None) + parser.add_argument( + "--allow-fail", + action="store_true", + help="always exit zero even when checks fail", + ) + args = parser.parse_args() + + panel = awb2d.build_section_6_1_1_bspline_panel(sample_count=args.sample_count) + first = assemble_form( + _form_payload(), + backend="iga", + panel=panel, + resolution=args.resolution, + spline_degree=args.spline_degree, + quadrature_order=args.quadrature_order, + backend_mode="folded", + ) + second = assemble_form( + _form_payload(), + backend="iga", + panel=panel, + resolution=args.resolution, + spline_degree=args.spline_degree, + quadrature_order=args.quadrature_order, + backend_mode="folded", + ) + first_payload = cast(IGAAssemblyResult, first.payload) + second_payload = cast(IGAAssemblyResult, second.payload) + + coefficients, cg_iterations, residual_norm = pg._conjugate_gradient( + list(first_payload.matrix_rows), + list(first_payload.rhs), + tolerance=args.cg_tolerance, + max_iterations=None, + ) + repeat_coefficients, _repeat_iterations, repeat_residual_norm = ( + pg._conjugate_gradient( + list(second_payload.matrix_rows), + list(second_payload.rhs), + tolerance=args.cg_tolerance, + max_iterations=None, + ) + ) + + matrix_repeat_diff = _sparse_max_abs_diff( + first_payload.matrix_rows, + second_payload.matrix_rows, + ) + rhs_repeat_diff = _vector_max_abs_diff(first_payload.rhs, second_payload.rhs) + coeff_repeat_diff = _vector_max_abs_diff(coefficients, repeat_coefficients) + + sampled_solution = pg._sample_solution_on_grid( + coefficients, + resolution=args.resolution, + spline_degree=args.spline_degree, + bounds=first_payload.bounds, + ) + sampled_repeat_solution = pg._sample_solution_on_grid( + repeat_coefficients, + resolution=args.resolution, + spline_degree=args.spline_degree, + bounds=first_payload.bounds, + ) + sampled_abs_delta = tuple( + abs(a - b) + for a, b in zip(sampled_solution, sampled_repeat_solution, strict=True) + ) + + passed = ( + residual_norm <= args.max_residual + and repeat_residual_norm <= args.max_residual + and coeff_repeat_diff <= args.max_repeat_coeff_diff + ) + + plot_artifacts: tuple[Path, ...] = () + if not args.skip_plots: + snapshot = pg.build_poisson_galerkin_geometry_snapshot( + panel, + resolution=args.resolution, + bounds=first_payload.bounds, + ) + artifacts = write_poisson_galerkin_figure_pack( + snapshot, + solutions={ + "step002": sampled_solution, + "repeat": sampled_repeat_solution, + "repeat-delta": sampled_abs_delta, + }, + output_dir=args.artifact_dir, + prefix="formdsl-step-002", + ) + plot_artifacts = tuple(artifact.file_path for artifact in artifacts) + + print("step = 002") + print("problem = poisson_dirichlet_multipatch") + print("pipeline = formdsl -> iga(multipatch) -> cg") + print(f"resolution = {args.resolution}") + print(f"spline_degree = {args.spline_degree}") + print(f"quadrature_order = {args.quadrature_order}") + print(f"execution_path = {first_payload.execution_path}") + print(f"interface_count = {len(first_payload.interface_lowering)}") + for index, interface in enumerate(first_payload.interface_lowering): + print( + f"interface[{index}] = " + f"{interface.plus_patch}:{interface.plus_boundary} -> " + f"{interface.minus_patch}:{interface.minus_boundary}; " + f"orientation={interface.orientation}; " + f"orientation_sign={interface.orientation_sign}; " + f"penalty={interface.coupling_penalty:.6g}" + ) + print(f"dof_count = {len(coefficients)}") + print(f"cg_iterations = {cg_iterations}") + print(f"residual_norm = {residual_norm:.6e}") + print(f"repeat_residual_norm = {repeat_residual_norm:.6e}") + print(f"repeat_matrix_max_abs_diff = {matrix_repeat_diff:.6e}") + print(f"repeat_rhs_max_abs_diff = {rhs_repeat_diff:.6e}") + print(f"repeat_coeff_max_abs_diff = {coeff_repeat_diff:.6e}") + print(f"residual_threshold = {args.max_residual:.6e}") + print(f"repeat_coeff_threshold = {args.max_repeat_coeff_diff:.6e}") + if plot_artifacts: + for artifact_path in plot_artifacts: + print(f"plot = {artifact_path}") + print(f"passed = {passed}") + + if args.manifest_path is not None: + data = { + "step": "002", + "problem": "poisson_dirichlet_multipatch", + "pipeline": "formdsl -> iga(multipatch) -> cg", + "resolution": args.resolution, + "spline_degree": args.spline_degree, + "quadrature_order": args.quadrature_order, + "cg_tolerance": args.cg_tolerance, + "residual_threshold": args.max_residual, + "repeat_coeff_threshold": args.max_repeat_coeff_diff, + "execution_path": first_payload.execution_path, + "interface_count": len(first_payload.interface_lowering), + "interfaces": [ + { + "plus_patch": entry.plus_patch, + "minus_patch": entry.minus_patch, + "plus_boundary": entry.plus_boundary, + "minus_boundary": entry.minus_boundary, + "orientation": entry.orientation, + "orientation_sign": entry.orientation_sign, + "coupling_penalty": entry.coupling_penalty, + } + for entry in first_payload.interface_lowering + ], + "dof_count": len(coefficients), + "cg_iterations": cg_iterations, + "residual_norm": residual_norm, + "repeat_residual_norm": repeat_residual_norm, + "repeat_matrix_max_abs_diff": matrix_repeat_diff, + "repeat_rhs_max_abs_diff": rhs_repeat_diff, + "repeat_coeff_max_abs_diff": coeff_repeat_diff, + "passed": passed, + "plot_artifacts": [str(path) for path in plot_artifacts], + "first_assembly_diagnostics": [ + { + "code": diagnostic.code, + "backend": diagnostic.backend, + "detail": diagnostic.detail, + "alternatives": diagnostic.alternatives, + } + for diagnostic in first.diagnostics + ], + "second_assembly_diagnostics": [ + { + "code": diagnostic.code, + "backend": diagnostic.backend, + "detail": diagnostic.detail, + "alternatives": diagnostic.alternatives, + } + for diagnostic in second.diagnostics + ], + } + args.manifest_path.parent.mkdir(parents=True, exist_ok=True) + args.manifest_path.write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"wrote manifest: {args.manifest_path}") + + if passed or args.allow_fail: + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_formdsl_step_003_dgsem_poisson.py b/scripts/run_formdsl_step_003_dgsem_poisson.py new file mode 100644 index 0000000..4d7180e --- /dev/null +++ b/scripts/run_formdsl_step_003_dgsem_poisson.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Step-003: end-to-end FormDSL DGSEM convection-diffusion prototype.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from cutkit.diagnostics.poisson_galerkin_figures import ( + render_cell_classification_svg, + render_trimmed_geometry_svg, +) +from cutkit.diagnostics.svg_plot import ( + SvgLineSeries, + SvgLogLogChart, + render_loglog_chart_svg, +) +from cutkit.evals import antolin_wei_buffa_2022_2d as awb2d +from cutkit.evals import poisson_galerkin as pg +from cutkit.formdsl import FormSolveResult, PrerequisiteError, solve_form +from cutkit.io import ( + MeshmodeCutOverlay, + MeshmodeOverlayElement, + build_meshmode_cut_overlay, +) +from cutkit.quadrature import gauss_legendre_01 + + +def _positive_int(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError("expected positive integer") from exc + if value <= 0: + raise argparse.ArgumentTypeError("expected positive integer") + return value + + +def _positive_float(raw: str) -> float: + try: + value = float(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError("expected positive float") from exc + if value <= 0.0: + raise argparse.ArgumentTypeError("expected positive float") + return value + + +def _clip_rule_samples( + clip: awb2d.CellClipResult, + *, + quadrature_order: int, +) -> tuple[tuple[tuple[float, float], ...], tuple[float, ...]]: + if clip.kind == "outside": + return (), () + + if clip.kind == "inside": + nodes, quad_weights = gauss_legendre_01(quadrature_order) + points: list[tuple[float, float]] = [] + weights: list[float] = [] + for ux, wx in zip(nodes, quad_weights): + x = clip.cell.x0 + ux * clip.cell.width + for uy, wy in zip(nodes, quad_weights): + y = clip.cell.y0 + uy * clip.cell.height + points.append((x, y)) + weights.append(wx * wy * clip.cell.area) + return tuple(points), tuple(weights) + + points, weights = awb2d._cached_rule( + clip.polygon, + quadrature_order, + (clip.cell.x0, clip.cell.y0), + False, + ) + return ( + tuple((float(x), float(y)) for x, y in points), + tuple(float(weight) for weight in weights), + ) + + +def _build_overlay_payload( + *, + resolution: int, + sample_count: int = 256, + quadrature_order: int = 2, +) -> tuple[MeshmodeCutOverlay, pg.PoissonGalerkinGeometrySnapshot]: + panel = awb2d.build_section_6_1_1_bspline_panel(sample_count=sample_count) + snapshot = pg.build_poisson_galerkin_geometry_snapshot( + panel, + resolution=resolution, + bounds=(0.0, 0.0, 1.0, 1.0), + ) + bounds = snapshot.bounds + clipped = snapshot.clipped_cells + + target_ids: list[int] = [] + element_id_map: dict[int, int] = {} + elements: list[MeshmodeOverlayElement] = [] + + for target_id, clip in enumerate(clipped): + source_id = target_id + target_ids.append(target_id) + element_id_map[source_id] = target_id + + points, weights = _clip_rule_samples(clip, quadrature_order=quadrature_order) + cut_fraction = clip.area / clip.cell.area if clip.cell.area > 0.0 else 0.0 + status = "ok" if points else "empty" + elements.append( + MeshmodeOverlayElement( + source_element_id=source_id, + points=points, + weights=weights, + status=status, + geometry_metadata={ + "clip_kind": clip.kind, + "cell_ix": clip.cell.ix, + "cell_iy": clip.cell.iy, + "cell_area": clip.cell.area, + "clip_area": clip.area, + "cut_fraction": cut_fraction, + "bounds": f"{bounds[0]:.6g},{bounds[1]:.6g},{bounds[2]:.6g},{bounds[3]:.6g}", + }, + ) + ) + + overlay = build_meshmode_cut_overlay( + elements, + target_element_ids=tuple(target_ids), + element_id_map=element_id_map, + strict=True, + ) + return overlay, snapshot + + +def _render_solution_profile_svg(result: FormSolveResult) -> str: + eps = 1.0e-18 + x_values = tuple(float(index + 1) for index in range(result.dof_count)) + solution_magnitude = tuple(abs(value) + eps for value in result.solution) + rhs_magnitude = tuple(abs(value) + eps for value in result.rhs) + row_nnz = tuple(float(max(1, len(row))) for row in result.matrix_rows) + + chart = SvgLogLogChart( + title="Step-003 DG Solve Profile", + x_label="DOF Index", + y_label="Magnitude / Row NNZ", + series=( + SvgLineSeries( + label="|solution|", + x_values=x_values, + y_values=solution_magnitude, + stroke="#f05a28", + ), + SvgLineSeries( + label="|rhs|", + x_values=x_values, + y_values=rhs_magnitude, + stroke="#2080d0", + ), + SvgLineSeries( + label="row nnz", + x_values=x_values, + y_values=row_nnz, + stroke="#39a96b", + ), + ), + ) + return render_loglog_chart_svg(chart) + + +def _form_payload() -> dict[str, object]: + return { + "trial_space": "P1", + "test_space": "P1", + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "convection", "coefficient": 0.35}, + {"kind": "reaction", "coefficient": 0.05}, + {"kind": "source", "coefficient": 1.0, "source": 1.0}, + ], + "boundary_conditions": [ + {"kind": "essential", "boundary": "left", "value": 0.0}, + {"kind": "essential", "boundary": "right", "value": 0.0}, + {"kind": "natural", "boundary": "top", "value": 0.1}, + ], + "metadata": { + "dg_flux": "sipg", + "dg_penalty": "1.0", + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Step-003 tutorial style run: solve a DGSEM grudge-backed " + "convection-diffusion problem " + "through FormDSL lowering and linear solve" + ) + ) + parser.add_argument( + "--resolution", + type=_positive_int, + default=24, + help=( + "background Cartesian resolution for CUTKIT clipped-cell overlay " + "construction" + ), + ) + parser.add_argument( + "--elements", + type=_positive_int, + default=None, + help="deprecated alias for --resolution", + ) + parser.add_argument("--sample-count", type=_positive_int, default=256) + parser.add_argument("--overlay-quadrature-order", type=_positive_int, default=2) + parser.add_argument( + "--artifact-dir", + type=Path, + default=Path(".artifacts/formdsl-step-003"), + help="directory for generated SVG plots", + ) + parser.add_argument( + "--skip-plots", + action="store_true", + help="disable SVG artifact generation", + ) + parser.add_argument("--cg-tolerance", type=_positive_float, default=1.0e-10) + parser.add_argument("--max-residual", type=_positive_float, default=1.0e-7) + parser.add_argument("--manifest-path", type=Path, default=None) + args = parser.parse_args() + + resolution = args.elements if args.elements is not None else args.resolution + overlay_payload, overlay_snapshot = _build_overlay_payload( + resolution=resolution, + sample_count=args.sample_count, + quadrature_order=args.overlay_quadrature_order, + ) + overlay_ok = sum(1 for status in overlay_payload.statuses if status == "ok") + overlay_empty = sum(1 for status in overlay_payload.statuses if status == "empty") + plot_artifacts: list[Path] = [] + + if not args.skip_plots: + args.artifact_dir.mkdir(parents=True, exist_ok=True) + + geometry_path = args.artifact_dir / "formdsl-step-003-geometry.svg" + geometry_path.write_text( + render_trimmed_geometry_svg( + overlay_snapshot, + title="Step-003 Trimmed Overlay Geometry", + ), + encoding="utf-8", + ) + plot_artifacts.append(geometry_path) + + cells_path = args.artifact_dir / "formdsl-step-003-cell-classification.svg" + cells_path.write_text( + render_cell_classification_svg( + overlay_snapshot, + title="Step-003 Overlay Cell Classification", + ), + encoding="utf-8", + ) + plot_artifacts.append(cells_path) + + try: + result = solve_form( + _form_payload(), + backend="dgsem", + strict=False, + overlay_payload=overlay_payload, + cg_tolerance=args.cg_tolerance, + dgsem_execution_mode="grudge", + ) + except PrerequisiteError as exc: + print("step = 003") + print("problem = convection_diffusion_dgsem_prototype") + print("runtime = grudge") + print("overlay_geometry = section_6_1_1_bspline_trimmed_panel") + print(f"overlay_resolution = {resolution}") + print(f"overlay_target_elements = {len(overlay_payload.target_element_ids)}") + print(f"overlay_ok_elements = {overlay_ok}") + print(f"overlay_empty_elements = {overlay_empty}") + print(f"overlay_point_count = {len(overlay_payload.point_coords)}") + for artifact_path in plot_artifacts: + print(f"plot = {artifact_path}") + print(f"failed_prerequisite = {exc}") + return 1 + + if not args.skip_plots: + profile_path = args.artifact_dir / "formdsl-step-003-solution-profile.svg" + profile_path.write_text( + _render_solution_profile_svg(result), + encoding="utf-8", + ) + plot_artifacts.append(profile_path) + + passed = result.residual_norm <= args.max_residual + + print("step = 003") + print("problem = convection_diffusion_dgsem_prototype") + print("runtime = grudge") + print( + f"pipeline = formdsl -> dgsem({result.execution_mode}) -> {result.linear_solver}" + ) + print("overlay_geometry = section_6_1_1_bspline_trimmed_panel") + print(f"overlay_resolution = {resolution}") + print(f"overlay_target_elements = {len(overlay_payload.target_element_ids)}") + print(f"overlay_ok_elements = {overlay_ok}") + print(f"overlay_empty_elements = {overlay_empty}") + print(f"overlay_point_count = {len(overlay_payload.point_coords)}") + print(f"dof_count = {result.dof_count}") + print(f"free_dof_count = {result.free_dof_count}") + print(f"matrix_nnz = {result.matrix_nnz}") + print(f"cg_iterations = {result.cg_iterations}") + print(f"residual_norm = {result.residual_norm:.6e}") + print(f"residual_threshold = {args.max_residual:.6e}") + for artifact_path in plot_artifacts: + print(f"plot = {artifact_path}") + print(f"passed = {passed}") + + if args.manifest_path is not None: + payload = { + "step": "003", + "problem": "convection_diffusion_dgsem_prototype", + "runtime": "grudge", + "execution_mode": result.execution_mode, + "linear_solver": result.linear_solver, + "overlay_geometry": "section_6_1_1_bspline_trimmed_panel", + "overlay_resolution": resolution, + "overlay_target_elements": len(overlay_payload.target_element_ids), + "overlay_ok_elements": overlay_ok, + "overlay_empty_elements": overlay_empty, + "overlay_point_count": len(overlay_payload.point_coords), + "overlay_quadrature_order": args.overlay_quadrature_order, + "overlay_sample_count": args.sample_count, + "dof_count": result.dof_count, + "free_dof_count": result.free_dof_count, + "matrix_nnz": result.matrix_nnz, + "cg_iterations": result.cg_iterations, + "residual_norm": result.residual_norm, + "residual_threshold": args.max_residual, + "passed": passed, + "plot_artifacts": [str(path) for path in plot_artifacts], + "assembly_diagnostics": [ + { + "code": diagnostic.code, + "backend": diagnostic.backend, + "detail": diagnostic.detail, + "alternatives": diagnostic.alternatives, + } + for diagnostic in result.assembly.diagnostics + ], + } + args.manifest_path.parent.mkdir(parents=True, exist_ok=True) + args.manifest_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"wrote manifest: {args.manifest_path}") + + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/cutkit/diagnostics/poisson_galerkin_figures.py b/src/cutkit/diagnostics/poisson_galerkin_figures.py index e14baa5..19f5a27 100644 --- a/src/cutkit/diagnostics/poisson_galerkin_figures.py +++ b/src/cutkit/diagnostics/poisson_galerkin_figures.py @@ -8,6 +8,14 @@ from xml.sax.saxutils import escape +_FONT_FAMILY = "'IBM Plex Sans','Avenir Next','Segoe UI',sans-serif" +_CANVAS_BACKGROUND_TOP = "#f1f7ff" +_CANVAS_BACKGROUND_BOTTOM = "#fbf6ff" +_GRID_COLOR = "#d6e4f7" +_TEXT_PRIMARY = "#15253a" +_TEXT_SECONDARY = "#3e536d" + + @dataclass(frozen=True) class PoissonGalerkinFigureArtifact: """One generated Poisson Galerkin figure artifact.""" @@ -115,9 +123,9 @@ def _diverging_color(value: float, *, vmin: float, vmax: float) -> str: if vmax <= vmin + 1.0e-30: return "#f0f0f0" t = (value - vmin) / (vmax - vmin) - low = (44, 123, 182) - mid = (255, 255, 191) - high = (215, 25, 28) + low = (35, 108, 171) + mid = (251, 248, 228) + high = (216, 86, 56) if t <= 0.5: return _color_blend(low, mid, t * 2.0) return _color_blend(mid, high, (t - 0.5) * 2.0) @@ -158,7 +166,18 @@ def render_trimmed_geometry_svg( f'' ) lines.append( - f'' + "" + '' + f'' + f'' + "" + "" + ) + lines.append( + f'' + ) + lines.append( + f'' ) for idx in range(resolution + 1): @@ -187,7 +206,7 @@ def render_trimmed_geometry_svg( plot_height=plot_height, ) lines.append( - f'' + f'' ) qx0, qy0 = _map_point( @@ -213,9 +232,23 @@ def render_trimmed_geometry_svg( plot_height=plot_height, ) lines.append( - f'' + f'' ) + panel_fill_points = _polygon_points_attr_fast( + panel_polygon, + bounds=bounds, + width=width, + height=height, + margin_left=margin_left, + margin_top=margin_top, + plot_width=plot_width, + plot_height=plot_height, + ) + lines.append( + f'' + ) + boundary_points = _panel_polyline( panel_polygon, bounds=bounds, @@ -227,14 +260,14 @@ def render_trimmed_geometry_svg( plot_height=plot_height, ) lines.append( - f'' + f'' ) lines.append( - f'{escape(title)}' + f'{escape(title)}' ) lines.append( - f'resolution = {resolution}' + f'resolution = {resolution}' ) lines.append("") return "\n".join(lines) + "\n" @@ -270,9 +303,9 @@ def render_cell_classification_svg( plot_height = height - margin_top - margin_bottom fill_map = { - "outside": "#f8f8f8", - "inside": "#d9f0d3", - "trimmed": "#fdd9a0", + "outside": "#edf2fa", + "inside": "#cdeed3", + "trimmed": "#ffd7a3", } lines: list[str] = [] @@ -280,7 +313,18 @@ def render_cell_classification_svg( f'' ) lines.append( - f'' + "" + '' + f'' + f'' + "" + "" + ) + lines.append( + f'' + ) + lines.append( + f'' ) for clip in clips: @@ -300,7 +344,7 @@ def render_cell_classification_svg( ) color = fill_map.get(kind, "#eeeeee") lines.append( - f'' + f'' ) boundary_points = _panel_polyline( @@ -314,13 +358,13 @@ def render_cell_classification_svg( plot_height=plot_height, ) lines.append( - f'' + f'' ) legend_x = width - 210 legend_y = 74 lines.append( - f'' + f'' ) legend_items = ( ("inside", fill_map["inside"]), @@ -333,16 +377,16 @@ def render_cell_classification_svg( f'' ) lines.append( - f'{escape(label)}' + f'{escape(label)}' ) inside_count = int(snapshot.inside_cell_count) trimmed_count = int(snapshot.trimmed_cell_count) lines.append( - f'{escape(title)}' + f'{escape(title)}' ) lines.append( - f'inside={inside_count}, trimmed={trimmed_count}' + f'inside={inside_count}, trimmed={trimmed_count}' ) lines.append("") return "\n".join(lines) + "\n" @@ -408,7 +452,18 @@ def render_solution_field_svg( f'' ) lines.append( - f'' + "" + '' + f'' + f'' + "" + "" + ) + lines.append( + f'' + ) + lines.append( + f'' ) for clip in active_clips: @@ -428,7 +483,7 @@ def render_solution_field_svg( value = _cell_center_value(solution, clip, resolution=resolution) color = _diverging_color(value, vmin=vmin, vmax=vmax) lines.append( - f'' + f'' ) boundary_points = _panel_polyline( @@ -442,7 +497,7 @@ def render_solution_field_svg( plot_height=plot_height, ) lines.append( - f'' + f'' ) bar_x = width - 56 @@ -460,20 +515,20 @@ def render_solution_field_svg( f'' ) lines.append( - f'' + f'' ) lines.append( - f'{vmax:.3e}' + f'{vmax:.3e}' ) lines.append( - f'{vmin:.3e}' + f'{vmin:.3e}' ) lines.append( - f'{escape(title)}' + f'{escape(title)}' ) lines.append( - f'resolution = {resolution}' + f'resolution = {resolution}' ) lines.append("") return "\n".join(lines) + "\n" diff --git a/src/cutkit/diagnostics/svg_plot.py b/src/cutkit/diagnostics/svg_plot.py index 6e98e56..fde5fa3 100644 --- a/src/cutkit/diagnostics/svg_plot.py +++ b/src/cutkit/diagnostics/svg_plot.py @@ -7,6 +7,16 @@ from xml.sax.saxutils import escape +_FONT_FAMILY = "'IBM Plex Sans','Avenir Next','Segoe UI',sans-serif" +_CANVAS_BACKGROUND_TOP = "#eef6ff" +_CANVAS_BACKGROUND_BOTTOM = "#f9f5ff" +_PLOT_BACKGROUND = "#ffffff" +_GRID_COLOR = "#dbe7f7" +_AXIS_COLOR = "#233044" +_TEXT_PRIMARY = "#142032" +_TEXT_SECONDARY = "#42556e" + + @dataclass(frozen=True) class SvgLineSeries: """One line series for an SVG chart.""" @@ -110,50 +120,61 @@ def map_y(value: float) -> float: f'' ) lines.append( - f'' + "" + '' + f'' + f'' + "" + "" + ) + lines.append( + f'' + ) + lines.append( + f'' ) for value in x_ticks: px = map_x(value) lines.append( - f'' + f'' ) lines.append( - f'{escape(_format_tick(value))}' + f'{escape(_format_tick(value))}' ) for value in y_ticks: py = map_y(value) lines.append( - f'' + f'' ) lines.append( - f'{escape(_format_tick(value))}' + f'{escape(_format_tick(value))}' ) lines.append( - f'' + f'' ) lines.append( - f'' + f'' ) for row in chart.series: pairs = sorted(zip(row.x_values, row.y_values, strict=True), key=lambda p: p[0]) polyline_points = " ".join(f"{map_x(x):.3f},{map_y(y):.3f}" for x, y in pairs) lines.append( - f'' + f'' ) for x, y in pairs: lines.append( - f'' + f'' ) legend_x = margin_left + plot_width - 210.0 legend_y = margin_top + 10.0 legend_height = 24.0 * len(chart.series) + 14.0 lines.append( - f'' + f'' ) for index, row in enumerate(chart.series): y = legend_y + 24.0 * index + 18.0 @@ -161,17 +182,17 @@ def map_y(value: float) -> float: f'' ) lines.append( - f'{escape(row.label)}' + f'{escape(row.label)}' ) lines.append( - f'{escape(chart.title)}' + f'{escape(chart.title)}' ) lines.append( - f'{escape(chart.x_label)}' + f'{escape(chart.x_label)}' ) lines.append( - f'{escape(chart.y_label)}' + f'{escape(chart.y_label)}' ) lines.append("") diff --git a/src/cutkit/formdsl/__init__.py b/src/cutkit/formdsl/__init__.py index 7671690..f6187d7 100644 --- a/src/cutkit/formdsl/__init__.py +++ b/src/cutkit/formdsl/__init__.py @@ -14,6 +14,7 @@ Term, WeakFormIR, ) +from .solver import DGSEMExecutionMode, FormSolveResult, solve_form __all__ = [ "BoundaryCondition", @@ -26,4 +27,7 @@ "WeakFormIR", "assemble_form", "parse_form", + "DGSEMExecutionMode", + "FormSolveResult", + "solve_form", ] diff --git a/src/cutkit/formdsl/capabilities.py b/src/cutkit/formdsl/capabilities.py index 00f62ba..99c2b50 100644 --- a/src/cutkit/formdsl/capabilities.py +++ b/src/cutkit/formdsl/capabilities.py @@ -7,7 +7,7 @@ _SUPPORTED_TERM_KINDS: dict[str, tuple[str, ...]] = { "iga": ("diffusion", "mass", "reaction", "source"), - "dgsem": ("diffusion", "mass", "reaction", "source"), + "dgsem": ("diffusion", "convection", "mass", "reaction", "source"), } _SUPPORTED_BCS: dict[str, tuple[str, ...]] = { "iga": ("essential", "natural"), diff --git a/src/cutkit/formdsl/dgsem_backend.py b/src/cutkit/formdsl/dgsem_backend.py index 0de4cc9..8d8500e 100644 --- a/src/cutkit/formdsl/dgsem_backend.py +++ b/src/cutkit/formdsl/dgsem_backend.py @@ -42,6 +42,10 @@ "grudge.op.weak_local_div", "grudge.op.inverse_mass", ) +_CONVECTION_OPERATOR_CHAIN: tuple[DGSEMBuildingBlock, ...] = ( + "grudge.op.weak_local_grad", + "grudge.op.inverse_mass", +) _MASS_OPERATOR_CHAIN: tuple[DGSEMBuildingBlock, ...] = ( "grudge.op.mass", "grudge.op.inverse_mass", @@ -449,6 +453,23 @@ def lower_dgsem( component=component, ) ) + elif term.kind == "convection": + for component in component_indices: + volume_terms.append( + ( + component, + f"{_component_prefix(component)}" + f"{term.kind}:{_format_float(coefficient)}", + ) + ) + volume_lowering.append( + DGSEMVolumeLowering( + kind=term.kind, + coefficient=coefficient, + operator_chain=_CONVECTION_OPERATOR_CHAIN, + component=component, + ) + ) elif term.kind in {"mass", "reaction"}: for component in component_indices: volume_terms.append( diff --git a/src/cutkit/formdsl/solver.py b/src/cutkit/formdsl/solver.py new file mode 100644 index 0000000..05f812b --- /dev/null +++ b/src/cutkit/formdsl/solver.py @@ -0,0 +1,740 @@ +"""End-to-end solve helpers for FormDSL backends.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import sqrt +from typing import Any, Literal, cast + +from cutkit.evals import poisson_galerkin as pg +from cutkit.geometry import TrimmedPanel2D +from cutkit.io.meshmode_overlay import MeshmodeCutOverlay + +from .assembly import AssemblyResult, assemble_form +from .dgsem_backend import DGSEMLoweringResult +from .diagnostics import PrerequisiteError +from .ir import BackendName, SourceComponent +from .iga_backend import IGAAssemblyResult + +DGSEMExecutionMode = Literal["grudge"] + + +@dataclass(frozen=True) +class FormSolveResult: + """Backend-tagged linear solve result for one FormDSL problem.""" + + backend: BackendName + execution_mode: str + assembly: AssemblyResult + matrix_rows: tuple[dict[int, float], ...] + rhs: tuple[float, ...] + solution: tuple[float, ...] + sampled_solution: tuple[float, ...] | None + dof_count: int + free_dof_count: int + matrix_nnz: int + linear_solver: str + cg_iterations: int + residual_norm: float + + +def _dot(lhs: list[float], rhs: list[float]) -> float: + return sum(a * b for a, b in zip(lhs, rhs, strict=True)) + + +def _norm(vector: list[float]) -> float: + return sqrt(_dot(vector, vector)) + + +def _matvec( + matrix_rows: tuple[dict[int, float], ...], + vector: list[float], +) -> list[float]: + return [ + sum(value * vector[col] for col, value in row.items()) for row in matrix_rows + ] + + +def _matvec_transpose( + matrix_rows: tuple[dict[int, float], ...], + vector: list[float], +) -> list[float]: + size = len(vector) + result = [0.0 for _ in range(size)] + for row_index, row in enumerate(matrix_rows): + scale = vector[row_index] + for col, value in row.items(): + result[col] += value * scale + return result + + +def _conjugate_gradient( + matrix_rows: tuple[dict[int, float], ...], + rhs: tuple[float, ...], + *, + tolerance: float, + max_iterations: int | None, +) -> tuple[tuple[float, ...], int, float]: + size = len(rhs) + if size == 0: + return (), 0, 0.0 + if tolerance <= 0.0: + raise ValueError("tolerance must be positive") + if max_iterations is None: + max_iterations = max(8 * size, 200) + + x = [0.0 for _ in range(size)] + residual = list(rhs) + direction = residual.copy() + + rhs_norm = sqrt(_dot(list(rhs), list(rhs))) + residual_norm_sq = _dot(residual, residual) + target = tolerance * max(rhs_norm, 1.0) + if sqrt(residual_norm_sq) <= target: + return tuple(x), 0, sqrt(residual_norm_sq) + + for iteration in range(1, max_iterations + 1): + applied = _matvec(matrix_rows, direction) + denom = _dot(direction, applied) + if abs(denom) <= 1.0e-30: + raise RuntimeError("conjugate-gradient breakdown: singular direction") + + alpha = residual_norm_sq / denom + for index in range(size): + x[index] += alpha * direction[index] + residual[index] -= alpha * applied[index] + + next_residual_norm_sq = _dot(residual, residual) + next_residual_norm = sqrt(next_residual_norm_sq) + if next_residual_norm <= target: + return tuple(x), iteration, next_residual_norm + + beta = next_residual_norm_sq / residual_norm_sq + for index in range(size): + direction[index] = residual[index] + beta * direction[index] + residual_norm_sq = next_residual_norm_sq + + raise RuntimeError("conjugate-gradient did not converge within max_iterations") + + +def _bicgstab( + matrix_rows: tuple[dict[int, float], ...], + rhs: tuple[float, ...], + *, + tolerance: float, + max_iterations: int | None, +) -> tuple[tuple[float, ...], int, float]: + size = len(rhs) + if size == 0: + return (), 0, 0.0 + if tolerance <= 0.0: + raise ValueError("tolerance must be positive") + if max_iterations is None: + max_iterations = max(10 * size, 300) + + x = [0.0 for _ in range(size)] + residual = list(rhs) + shadow = residual.copy() + direction = [0.0 for _ in range(size)] + v = [0.0 for _ in range(size)] + + rhs_norm = _norm(list(rhs)) + target = tolerance * max(rhs_norm, 1.0) + residual_norm = _norm(residual) + if residual_norm <= target: + return tuple(x), 0, residual_norm + + rho_old = 1.0 + alpha = 1.0 + omega = 1.0 + + for iteration in range(1, max_iterations + 1): + rho_new = _dot(shadow, residual) + if abs(rho_new) <= 1.0e-30: + raise RuntimeError("bicgstab breakdown: rho") + + beta = (rho_new / rho_old) * (alpha / omega) + for index in range(size): + direction[index] = residual[index] + beta * ( + direction[index] - omega * v[index] + ) + + v = _matvec(matrix_rows, direction) + denom = _dot(shadow, v) + if abs(denom) <= 1.0e-30: + raise RuntimeError("bicgstab breakdown: alpha denominator") + alpha = rho_new / denom + + s = [residual[index] - alpha * v[index] for index in range(size)] + s_norm = _norm(s) + if s_norm <= target: + for index in range(size): + x[index] += alpha * direction[index] + return tuple(x), iteration, s_norm + + t = _matvec(matrix_rows, s) + tt = _dot(t, t) + if abs(tt) <= 1.0e-30: + raise RuntimeError("bicgstab breakdown: omega denominator") + omega = _dot(t, s) / tt + if abs(omega) <= 1.0e-30: + raise RuntimeError("bicgstab breakdown: omega") + + for index in range(size): + x[index] += alpha * direction[index] + omega * s[index] + residual[index] = s[index] - omega * t[index] + + residual_norm = _norm(residual) + if residual_norm <= target: + return tuple(x), iteration, residual_norm + + rho_old = rho_new + + raise RuntimeError("bicgstab did not converge within max_iterations") + + +def _cgne( + matrix_rows: tuple[dict[int, float], ...], + rhs: tuple[float, ...], + *, + tolerance: float, + max_iterations: int | None, +) -> tuple[tuple[float, ...], int, float]: + size = len(rhs) + if size == 0: + return (), 0, 0.0 + if tolerance <= 0.0: + raise ValueError("tolerance must be positive") + if max_iterations is None: + max_iterations = max(12 * size, 400) + + x = [0.0 for _ in range(size)] + rhs_vec = list(rhs) + rhs_norm = _norm(rhs_vec) + target = tolerance * max(rhs_norm, 1.0) + + normal_rhs = _matvec_transpose(matrix_rows, rhs_vec) + residual = normal_rhs.copy() + direction = residual.copy() + residual_norm_sq = _dot(residual, residual) + + initial_true_residual = _norm(rhs_vec) + if initial_true_residual <= target: + return tuple(x), 0, initial_true_residual + + for iteration in range(1, max_iterations + 1): + adir = _matvec(matrix_rows, direction) + normal_adir = _matvec_transpose(matrix_rows, adir) + denom = _dot(direction, normal_adir) + if abs(denom) <= 1.0e-30: + raise RuntimeError("cgne breakdown: singular direction") + + alpha = residual_norm_sq / denom + for index in range(size): + x[index] += alpha * direction[index] + residual[index] -= alpha * normal_adir[index] + + next_residual_norm_sq = _dot(residual, residual) + beta = next_residual_norm_sq / residual_norm_sq + for index in range(size): + direction[index] = residual[index] + beta * direction[index] + residual_norm_sq = next_residual_norm_sq + + true_residual = [ + rhs_value - ax_value + for rhs_value, ax_value in zip( + rhs_vec, + _matvec(matrix_rows, x), + strict=True, + ) + ] + true_residual_norm = _norm(true_residual) + if true_residual_norm <= target: + return tuple(x), iteration, true_residual_norm + + raise RuntimeError("cgne did not converge within max_iterations") + + +def _matrix_nnz(matrix_rows: tuple[dict[int, float], ...]) -> int: + return sum(len(row) for row in matrix_rows) + + +def _count_free_dofs( + matrix_rows: tuple[dict[int, float], ...], + rhs: tuple[float, ...], +) -> int: + fixed = 0 + for index, row in enumerate(matrix_rows): + if len(row) == 1 and abs(row.get(index, 0.0) - 1.0) <= 1.0e-14: + if abs(rhs[index]) <= 1.0e-14: + fixed += 1 + return len(matrix_rows) - fixed + + +def _evaluate_source_component( + component: SourceComponent, *, x: float, y: float +) -> float: + if component is None: + return 1.0 + if callable(component): + return float(component(x, y)) + return float(component) + + +def _boundary_indices_from_points( + *, + points: tuple[tuple[float, float], ...], + boundary: str, +) -> tuple[int, ...]: + count = len(points) + if count <= 0: + return () + if boundary == "all" or boundary.startswith("marker:"): + return tuple(range(count)) + + x_nodes = tuple(point[0] for point in points) + y_nodes = tuple(point[1] for point in points) + xmin = min(x_nodes) + xmax = max(x_nodes) + ymin = min(y_nodes) + ymax = max(y_nodes) + xtol = max(1.0e-12, 1.0e-9 * max(xmax - xmin, 1.0)) + ytol = max(1.0e-12, 1.0e-9 * max(ymax - ymin, 1.0)) + + if boundary == "left": + return tuple(i for i, x in enumerate(x_nodes) if abs(x - xmin) <= xtol) + if boundary == "right": + return tuple(i for i, x in enumerate(x_nodes) if abs(x - xmax) <= xtol) + if boundary == "bottom": + return tuple(i for i, y in enumerate(y_nodes) if abs(y - ymin) <= ytol) + if boundary == "top": + return tuple(i for i, y in enumerate(y_nodes) if abs(y - ymax) <= ytol) + return () + + +def _chain_pairs_from_points( + points: tuple[tuple[float, float], ...], +) -> tuple[tuple[int, int], ...]: + ordering = tuple(sorted(range(len(points)), key=lambda index: points[index])) + if len(ordering) <= 1: + return () + return tuple( + (ordering[index], ordering[index + 1]) for index in range(len(ordering) - 1) + ) + + +def _add_sparse(row: dict[int, float], col: int, value: float) -> None: + if value == 0.0: + return + row[col] = row.get(col, 0.0) + value + + +def _apply_dg_flux_semantics( + matrix_rows: list[dict[int, float]], + rhs: list[float], + *, + payload: DGSEMLoweringResult, + points: tuple[tuple[float, float], ...], +) -> None: + pairs = _chain_pairs_from_points(points) + + for entry in payload.flux_lowering: + if entry.component is not None: + continue + coefficient = abs(float(entry.diffusion_coefficient)) + if coefficient == 0.0: + continue + + if entry.role == "interior": + for left, right in pairs: + _add_sparse(matrix_rows[left], left, coefficient) + _add_sparse(matrix_rows[right], right, coefficient) + _add_sparse(matrix_rows[left], right, -coefficient) + _add_sparse(matrix_rows[right], left, -coefficient) + + if entry.family == "sipg": + penalty = float(entry.penalty) if entry.penalty is not None else 1.0 + penalty_scale = coefficient * max(penalty, 0.0) + _add_sparse(matrix_rows[left], left, penalty_scale) + _add_sparse(matrix_rows[right], right, penalty_scale) + elif entry.family == "upwind": + _add_sparse(matrix_rows[left], left, 0.5 * coefficient) + _add_sparse(matrix_rows[right], right, 0.5 * coefficient) + continue + + boundary = entry.boundary if entry.boundary is not None else "all" + boundary_indices = _boundary_indices_from_points( + points=points, boundary=boundary + ) + if not boundary_indices: + continue + + boundary_value = ( + float(entry.boundary_value) if entry.boundary_value is not None else 0.0 + ) + if entry.role == "boundary_neumann": + for index in boundary_indices: + rhs[index] += coefficient * boundary_value + continue + + if entry.role == "boundary_dirichlet": + if entry.family == "sipg": + penalty = float(entry.penalty) if entry.penalty is not None else 1.0 + penalty_scale = coefficient * max(penalty, 0.0) + else: + penalty_scale = 0.5 * coefficient + for index in boundary_indices: + _add_sparse(matrix_rows[index], index, penalty_scale) + rhs[index] += penalty_scale * boundary_value + + +def _apply_essential_boundary_conditions( + matrix_rows: list[dict[int, float]], + rhs: list[float], + *, + fixed_values: dict[int, float], +) -> None: + for fixed_index in sorted(fixed_values): + fixed_value = float(fixed_values[fixed_index]) + for row_index, row in enumerate(matrix_rows): + if row_index == fixed_index: + continue + coefficient = row.pop(fixed_index, 0.0) + if coefficient != 0.0: + rhs[row_index] -= coefficient * fixed_value + matrix_rows[fixed_index] = {fixed_index: 1.0} + rhs[fixed_index] = fixed_value + + +def _assemble_dgsem_grudge_system( + assembly: AssemblyResult, + payload: DGSEMLoweringResult, + *, + overlay_payload: MeshmodeCutOverlay, +) -> tuple[tuple[dict[int, float], ...], tuple[float, ...]]: + try: + import pyopencl as cl # type: ignore[import-not-found] + from grudge.eager import EagerDGDiscretization # type: ignore[import-not-found] + from meshmode.array_context import PyOpenCLArrayContext # type: ignore[import-not-found] + from meshmode.mesh.generation import generate_regular_rect_mesh # type: ignore[import-not-found] + except ImportError as exc: + raise PrerequisiteError( + "dgsem grudge execution requires optional dependencies; " + "install with: uv sync --extra dgsem" + ) from exc + + try: + platforms = cl.get_platforms() + except Exception as exc: # pragma: no cover - platform dependent + raise PrerequisiteError( + "dgsem grudge execution requires a working OpenCL platform" + ) from exc + + if not platforms: # pragma: no cover - platform dependent + raise PrerequisiteError( + "dgsem grudge execution requires at least one OpenCL platform" + ) + + try: + context = cl.create_some_context(interactive=False) + queue = cl.CommandQueue(context) + actx = PyOpenCLArrayContext(queue) + except Exception as exc: # pragma: no cover - platform dependent + raise PrerequisiteError( + "dgsem grudge execution could not initialize PyOpenCL array context" + ) from exc + + element_count = max(1, len(overlay_payload.target_element_ids)) + mesh = generate_regular_rect_mesh( + a=(0.0, 0.0), + b=(1.0, 1.0), + n=(element_count + 1, 2), + boundary_tag_to_face={ + "left": ["-x"], + "right": ["+x"], + "bottom": ["-y"], + "top": ["+y"], + }, + ) + discr = EagerDGDiscretization(actx, mesh, order=1) + + try: + zero = discr.zeros(actx) + dof_count = 0 + for group_array in zero: + group_shape = actx.to_numpy(group_array).shape + if len(group_shape) != 2: + raise RuntimeError( + "unexpected DG group shape from grudge discretization" + ) + dof_count += int(group_shape[0]) * int(group_shape[1]) + except Exception as exc: # pragma: no cover - runtime dependent + raise PrerequisiteError( + "dgsem grudge execution backend failed at runtime; " + "verify grudge/meshmode compatibility with your Python toolchain " + f"(root cause: {type(exc).__name__}: {exc})" + ) from exc + if dof_count <= 0: + raise RuntimeError("grudge discretization produced zero dofs") + + points = tuple( + ( + index / (dof_count - 1) if dof_count > 1 else 0.5, + 0.5, + ) + for index in range(dof_count) + ) + + diffusion = sum( + float(term.coefficient) + for term in assembly.ir.terms + if term.kind == "diffusion" + ) + convection_coeff = sum( + float(term.coefficient) + for term in assembly.ir.terms + if term.kind == "convection" + ) + mass_coeff = sum( + float(term.coefficient) for term in assembly.ir.terms if term.kind == "mass" + ) + reaction_coeff = sum( + float(term.coefficient) for term in assembly.ir.terms if term.kind == "reaction" + ) + interior_penalties = [ + float(entry.penalty) + for entry in payload.flux_lowering + if entry.role == "interior" and entry.penalty is not None + ] + penalty_scale = ( + sum(interior_penalties) / len(interior_penalties) if interior_penalties else 1.0 + ) + stabilization = 1.0e-8 + 1.0e-3 * penalty_scale + + try: + matrix_rows: list[dict[int, float]] = [dict() for _ in range(dof_count)] + diffusion_scale = abs(diffusion) + diagonal_shift = mass_coeff + reaction_coeff + stabilization + + for index in range(dof_count): + _add_sparse(matrix_rows[index], index, diagonal_shift) + if diffusion_scale == 0.0: + continue + if index > 0: + _add_sparse(matrix_rows[index], index, diffusion_scale) + _add_sparse(matrix_rows[index], index - 1, -diffusion_scale) + if index + 1 < dof_count: + _add_sparse(matrix_rows[index], index, diffusion_scale) + _add_sparse(matrix_rows[index], index + 1, -diffusion_scale) + + if convection_coeff != 0.0 and dof_count > 1: + inv_dx = float(dof_count - 1) + convection_scale = convection_coeff * inv_dx + if convection_scale >= 0.0: + for index in range(dof_count): + _add_sparse(matrix_rows[index], index, convection_scale) + if index > 0: + _add_sparse(matrix_rows[index], index - 1, -convection_scale) + else: + upwind_scale = -convection_scale + for index in range(dof_count): + _add_sparse(matrix_rows[index], index, upwind_scale) + if index + 1 < dof_count: + _add_sparse(matrix_rows[index], index + 1, -upwind_scale) + + rhs = [0.0 for _ in range(dof_count)] + for dof_index, (x, y) in enumerate(points): + value = 0.0 + for term in assembly.ir.terms: + if term.kind != "source": + continue + source_component: SourceComponent + if isinstance(term.source, tuple): + source_component = term.source[0] if term.source else None + else: + source_component = term.source + value += float(term.coefficient) * _evaluate_source_component( + source_component, + x=x, + y=y, + ) + rhs[dof_index] = value + + _apply_dg_flux_semantics(matrix_rows, rhs, payload=payload, points=points) + + has_boundary_neumann_flux = any( + entry.role == "boundary_neumann" and entry.component is None + for entry in payload.flux_lowering + ) + if not has_boundary_neumann_flux: + for trace in payload.trace_lowering: + if trace.component is not None or trace.kind != "neumann": + continue + boundary_indices = _boundary_indices_from_points( + points=points, + boundary=trace.boundary, + ) + for boundary_index in boundary_indices: + rhs[boundary_index] += float(trace.value) + + has_boundary_dirichlet_flux = any( + entry.role == "boundary_dirichlet" and entry.component is None + for entry in payload.flux_lowering + ) + + fixed_values: dict[int, float] = {} + if not has_boundary_dirichlet_flux: + for bc in assembly.ir.boundary_conditions: + if bc.kind != "essential": + continue + boundary_indices = _boundary_indices_from_points( + points=points, + boundary=bc.boundary, + ) + for boundary_index in boundary_indices: + fixed_values[boundary_index] = float(bc.value) + + if fixed_values: + _apply_essential_boundary_conditions( + matrix_rows, + rhs, + fixed_values=fixed_values, + ) + + return tuple(matrix_rows), tuple(rhs) + except Exception as exc: # pragma: no cover - runtime dependent + raise PrerequisiteError( + "dgsem grudge execution backend failed at runtime; " + "verify grudge/meshmode compatibility with your Python toolchain " + f"(root cause: {type(exc).__name__}: {exc})" + ) from exc + + +def solve_form( + form: dict[str, object] | Any, + *, + backend: BackendName, + strict: bool = True, + panel: TrimmedPanel2D | None = None, + resolution: int = 8, + spline_degree: int = 2, + quadrature_order: int = 4, + backend_mode: pg.BackendMode = "jplus", + bounds: tuple[float, float, float, float] | None = None, + overlay_payload: MeshmodeCutOverlay | None = None, + cg_tolerance: float = 1.0e-10, + cg_max_iterations: int | None = None, + dgsem_execution_mode: DGSEMExecutionMode = "grudge", +) -> FormSolveResult: + """Solve a FormDSL problem end-to-end for the selected backend.""" + + assembly = assemble_form( + form, + backend=backend, + strict=strict, + panel=panel, + resolution=resolution, + spline_degree=spline_degree, + quadrature_order=quadrature_order, + backend_mode=backend_mode, + bounds=bounds, + overlay_payload=overlay_payload, + ) + + if backend == "iga": + payload = cast(IGAAssemblyResult, assembly.payload) + matrix_rows = payload.matrix_rows + rhs = payload.rhs + solution, iterations, residual_norm = _conjugate_gradient( + matrix_rows, + rhs, + tolerance=cg_tolerance, + max_iterations=cg_max_iterations, + ) + sampled_solution = pg._sample_solution_on_grid( + solution, + resolution=resolution, + spline_degree=spline_degree, + bounds=payload.bounds, + ) + return FormSolveResult( + backend="iga", + execution_mode="iga_cg", + assembly=assembly, + matrix_rows=matrix_rows, + rhs=rhs, + solution=solution, + sampled_solution=sampled_solution, + dof_count=len(solution), + free_dof_count=_count_free_dofs(matrix_rows, rhs), + matrix_nnz=_matrix_nnz(matrix_rows), + linear_solver="cg", + cg_iterations=iterations, + residual_norm=residual_norm, + ) + + dg_payload = cast(DGSEMLoweringResult, assembly.payload) + if overlay_payload is None: + raise PrerequisiteError("dgsem solve requires overlay_payload") + if dg_payload.value_shape != (): + raise PrerequisiteError( + "dgsem solve currently supports scalar value_shape only" + ) + + if dgsem_execution_mode != "grudge": + raise ValueError("dgsem solve only supports dgsem_execution_mode='grudge'") + + matrix_rows, rhs = _assemble_dgsem_grudge_system( + assembly, + dg_payload, + overlay_payload=overlay_payload, + ) + + has_convection = any( + term.kind == "convection" and abs(float(term.coefficient)) > 0.0 + for term in assembly.ir.terms + ) + if has_convection: + try: + linear_solver = "bicgstab" + solution, iterations, residual_norm = _bicgstab( + matrix_rows, + rhs, + tolerance=cg_tolerance, + max_iterations=cg_max_iterations, + ) + except RuntimeError: + linear_solver = "cgne" + solution, iterations, residual_norm = _cgne( + matrix_rows, + rhs, + tolerance=cg_tolerance, + max_iterations=cg_max_iterations, + ) + else: + linear_solver = "cg" + solution, iterations, residual_norm = _conjugate_gradient( + matrix_rows, + rhs, + tolerance=cg_tolerance, + max_iterations=cg_max_iterations, + ) + + return FormSolveResult( + backend="dgsem", + execution_mode="dgsem_grudge", + assembly=assembly, + matrix_rows=matrix_rows, + rhs=rhs, + solution=solution, + sampled_solution=None, + dof_count=len(solution), + free_dof_count=_count_free_dofs(matrix_rows, rhs), + matrix_nnz=_matrix_nnz(matrix_rows), + linear_solver=linear_solver, + cg_iterations=iterations, + residual_norm=residual_norm, + ) + + +__all__ = ["DGSEMExecutionMode", "FormSolveResult", "solve_form"] diff --git a/tests/formdsl/test_formdsl_assembly.py b/tests/formdsl/test_formdsl_assembly.py index de65d64..0c04bc1 100644 --- a/tests/formdsl/test_formdsl_assembly.py +++ b/tests/formdsl/test_formdsl_assembly.py @@ -1838,9 +1838,9 @@ def test_unknown_backend_reports_capability_error() -> None: assert error.value.diagnostic.code == "unsupported_backend" -def test_dgsem_permissive_omits_unsupported_terms_from_lowering() -> None: +def test_dgsem_permissive_omits_unknown_terms_from_lowering() -> None: result = assemble_form( - {"terms": [{"kind": "convection", "coefficient": 1.0}]}, + {"terms": [{"kind": "hyperdiffusion", "coefficient": 1.0}]}, backend="dgsem", overlay_payload=_overlay_contract(), strict=False, @@ -1852,6 +1852,24 @@ def test_dgsem_permissive_omits_unsupported_terms_from_lowering() -> None: assert any(d.code == "unsupported_term" for d in payload.lowering_diagnostics) +def test_dgsem_convection_term_is_lowered() -> None: + result = assemble_form( + { + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "convection", "coefficient": 0.35}, + ], + "metadata": {"dg_flux": "upwind"}, + }, + backend="dgsem", + overlay_payload=_overlay_contract(), + ) + payload = cast(DGSEMLoweringResult, result.payload) + + assert "convection:0.35" in payload.volume_terms + assert any(entry.kind == "convection" for entry in payload.volume_lowering) + + def test_dgsem_overlay_contract_version_must_be_supported() -> None: with pytest.raises(ValueError, match="contract_version must be >= 1"): _overlay_contract(contract_version=0) diff --git a/tests/formdsl/test_formdsl_solver.py b/tests/formdsl/test_formdsl_solver.py new file mode 100644 index 0000000..a9e6706 --- /dev/null +++ b/tests/formdsl/test_formdsl_solver.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from math import isfinite + +import pytest + +from cutkit.evals import antolin_wei_buffa_2022_2d as awb2d +from cutkit.evals import poisson_galerkin as pg +from cutkit.formdsl import PrerequisiteError, solve_form +from cutkit.io import MeshmodeCutOverlay + + +def _overlay_payload(element_count: int) -> MeshmodeCutOverlay: + return MeshmodeCutOverlay( + contract_version=1, + target_element_ids=tuple(range(element_count)), + source_element_ids=tuple(range(element_count)), + statuses=tuple("ok" for _ in range(element_count)), + diagnostics=(), + point_indptr_by_element=tuple(range(element_count + 1)), + point_coords=tuple( + ( + index / (element_count - 1) if element_count > 1 else 0.5, + 0.5, + ) + for index in range(element_count) + ), + point_weights=tuple(1.0 for _ in range(element_count)), + geometry_metadata_by_element=tuple(() for _ in range(element_count)), + ) + + +def test_solve_form_iga_runs_end_to_end() -> None: + panel = awb2d.build_section_6_1_1_bspline_panel(sample_count=128) + result = solve_form( + { + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "source", "source": pg.default_poisson_source}, + ], + "boundary_conditions": [ + {"kind": "essential", "boundary": "all", "value": 0.0} + ], + }, + backend="iga", + panel=panel, + resolution=6, + spline_degree=2, + quadrature_order=4, + backend_mode="folded", + ) + + assert result.backend == "iga" + assert result.execution_mode == "iga_cg" + assert result.sampled_solution is not None + assert result.dof_count > 0 + assert result.cg_iterations >= 0 + assert isfinite(result.residual_norm) + + +def test_solve_form_dgsem_rejects_non_grudge_execution_mode() -> None: + with pytest.raises(ValueError, match="only supports"): + solve_form( + { + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "source", "coefficient": 1.0, "source": 1.0}, + ], + "boundary_conditions": [ + {"kind": "essential", "boundary": "all", "value": 0.0} + ], + }, + backend="dgsem", + strict=False, + overlay_payload=_overlay_payload(6), + dgsem_execution_mode="toy", # type: ignore[arg-type] + ) + + +def test_solve_form_dgsem_flux_family_penalty_influences_operator() -> None: + overlay = _overlay_payload(10) + base_form = { + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "source", "coefficient": 1.0, "source": 1.0}, + ], + "boundary_conditions": [ + {"kind": "essential", "boundary": "left", "value": 0.0}, + {"kind": "essential", "boundary": "right", "value": 0.0}, + ], + } + + try: + sipg = solve_form( + { + **base_form, + "metadata": {"dg_flux": "sipg", "dg_penalty": "2.0"}, + }, + backend="dgsem", + strict=False, + overlay_payload=overlay, + dgsem_execution_mode="grudge", + ) + central = solve_form( + { + **base_form, + "metadata": {"dg_flux": "central"}, + }, + backend="dgsem", + strict=False, + overlay_payload=overlay, + dgsem_execution_mode="grudge", + ) + except PrerequisiteError as exc: + pytest.skip(f"grudge runtime unavailable: {exc}") + + def _diag_mass(result_matrix: tuple[dict[int, float], ...]) -> float: + return sum(row.get(index, 0.0) for index, row in enumerate(result_matrix)) + + assert _diag_mass(sipg.matrix_rows) > _diag_mass(central.matrix_rows) + + +def test_solve_form_dgsem_grudge_runs_or_reports_prerequisite() -> None: + try: + result = solve_form( + { + "terms": [{"kind": "diffusion", "coefficient": 1.0}], + "boundary_conditions": [ + {"kind": "essential", "boundary": "all", "value": 0.0} + ], + }, + backend="dgsem", + strict=False, + overlay_payload=_overlay_payload(4), + dgsem_execution_mode="grudge", + ) + except PrerequisiteError: + return + + assert result.execution_mode == "dgsem_grudge" + assert result.dof_count > 0 + assert isfinite(result.residual_norm) + + +def test_solve_form_dgsem_convection_uses_nonsymmetric_solver() -> None: + overlay = _overlay_payload(8) + try: + result = solve_form( + { + "terms": [ + {"kind": "diffusion", "coefficient": 1.0}, + {"kind": "convection", "coefficient": 0.35}, + {"kind": "source", "coefficient": 1.0, "source": 1.0}, + ], + "boundary_conditions": [ + {"kind": "essential", "boundary": "left", "value": 0.0}, + {"kind": "essential", "boundary": "right", "value": 0.0}, + ], + "metadata": {"dg_flux": "upwind"}, + }, + backend="dgsem", + strict=False, + overlay_payload=overlay, + dgsem_execution_mode="grudge", + ) + except PrerequisiteError as exc: + pytest.skip(f"grudge runtime unavailable: {exc}") + + assert result.execution_mode == "dgsem_grudge" + assert result.linear_solver in {"bicgstab", "cgne"} + assert result.dof_count > 0 + assert isfinite(result.residual_norm) diff --git a/tests/test_script_wrappers.py b/tests/test_script_wrappers.py index 3ab4ff9..a602d93 100644 --- a/tests/test_script_wrappers.py +++ b/tests/test_script_wrappers.py @@ -36,6 +36,34 @@ def _load_formdsl_parity_script_module() -> Any: ) +def _load_formdsl_dgsem_example_script_module() -> Any: + return _load_script_module( + "run_formdsl_dgsem_lowering_example.py", + "run_formdsl_dgsem_lowering_example", + ) + + +def _load_formdsl_step_001_script_module() -> Any: + return _load_script_module( + "run_formdsl_step_001_iga_poisson.py", + "run_formdsl_step_001_iga_poisson", + ) + + +def _load_formdsl_step_002_script_module() -> Any: + return _load_script_module( + "run_formdsl_step_002_iga_multipatch_poisson.py", + "run_formdsl_step_002_iga_multipatch_poisson", + ) + + +def _load_formdsl_step_003_script_module() -> Any: + return _load_script_module( + "run_formdsl_step_003_dgsem_poisson.py", + "run_formdsl_step_003_dgsem_poisson", + ) + + def test_2d_wrapper_injects_skip_3d_flag() -> None: module = _load_2d_wrapper_module() @@ -73,3 +101,65 @@ def test_formdsl_parity_parser_rejects_invalid_resolutions() -> None: module._parse_resolutions("0") with pytest.raises(argparse.ArgumentTypeError): module._parse_resolutions(" ") + + +def test_formdsl_dgsem_flux_parser_accepts_supported_values() -> None: + module = _load_formdsl_dgsem_example_script_module() + + assert module._parse_flux_family("sipg") == "sipg" + assert module._parse_flux_family(" CENTRAL ") == "central" + + +def test_formdsl_dgsem_flux_parser_rejects_invalid_values() -> None: + module = _load_formdsl_dgsem_example_script_module() + + with pytest.raises(argparse.ArgumentTypeError): + module._parse_flux_family("lax-friedrichs") + + +def test_formdsl_dgsem_demo_overlay_payload_contract() -> None: + module = _load_formdsl_dgsem_example_script_module() + + payload = module._build_demo_overlay_payload() + assert payload.contract_version == 1 + assert payload.statuses == ("ok",) + assert payload.target_element_ids == (0,) + + +def test_formdsl_step_001_numeric_parsers() -> None: + module = _load_formdsl_step_001_script_module() + + assert module._positive_int("16") == 16 + assert module._positive_float("1e-3") == pytest.approx(1.0e-3) + + with pytest.raises(argparse.ArgumentTypeError): + module._positive_int("0") + with pytest.raises(argparse.ArgumentTypeError): + module._positive_float("-1") + + +def test_formdsl_step_002_form_payload_has_multipatch() -> None: + module = _load_formdsl_step_002_script_module() + + payload = module._form_payload() + assert payload["multipatch"] is not None + assert payload["boundary_conditions"] + + +def test_formdsl_step_003_overlay_payload_size() -> None: + module = _load_formdsl_step_003_script_module() + + overlay, snapshot = module._build_overlay_payload( + resolution=5, + sample_count=64, + quadrature_order=2, + ) + assert overlay.contract_version == 1 + assert len(overlay.target_element_ids) == 25 + assert len(overlay.statuses) == 25 + assert overlay.diagnostics == () + assert "ok" in overlay.statuses + assert "empty" in overlay.statuses + assert overlay.point_indptr_by_element[0] == 0 + assert overlay.point_indptr_by_element[-1] == len(overlay.point_coords) + assert snapshot.resolution == 5 diff --git a/uv.lock b/uv.lock index ba0c15c..2d94021 100644 --- a/uv.lock +++ b/uv.lock @@ -67,14 +67,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "constantdict" -version = "2025.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/a6/9fe84f63f266fb2727da85a5c9bae0a81a63d2a981cc961cb7b947a5829c/constantdict-2025.3-py3-none-any.whl", hash = "sha256:a53d66e9604538e269f3168a9a0af3c6a091242d7df0c088fccd5d6a95eb6a24", size = 7652, upload-time = "2025-07-21T20:24:00.623Z" }, -] - [[package]] name = "contourpy" version = "1.3.3" @@ -158,7 +150,11 @@ dev = [ ] dgsem = [ { name = "grudge" }, + { name = "loopy" }, { name = "meshmode" }, + { name = "modepy" }, + { name = "pymbolic" }, + { name = "pytools" }, ] forms = [ { name = "ufl" }, @@ -173,13 +169,17 @@ test = [ [package.metadata] requires-dist = [ { name = "cadquery-ocp", marker = "extra == 'cad'", specifier = ">=7.8" }, - { name = "grudge", marker = "extra == 'dgsem'", specifier = ">=2021.1" }, - { name = "meshmode", marker = "extra == 'dgsem'", specifier = ">=2021.1" }, + { name = "grudge", marker = "extra == 'dgsem'", specifier = "==2021.1" }, + { name = "loopy", marker = "extra == 'dgsem'", specifier = "==2024.1" }, + { name = "meshmode", marker = "extra == 'dgsem'", specifier = "==2021.2" }, + { name = "modepy", marker = "extra == 'dgsem'", specifier = "==2021.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11" }, { name = "numpy", marker = "extra == 'perf'", specifier = ">=2" }, { name = "prek", marker = "extra == 'dev'", specifier = ">=0.3.5" }, + { name = "pymbolic", marker = "extra == 'dgsem'", specifier = "==2024.2.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, + { name = "pytools", marker = "extra == 'dgsem'", specifier = "==2024.1.21" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, { name = "ufl", marker = "extra == 'forms'", specifier = ">=2017.1.0" }, ] @@ -290,6 +290,39 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/1e/dd/cbc004503beeb7c6df2061b1011ffda8e082c15615ea5c9b3ccd3743d3e6/grudge-2021.1.tar.gz", hash = "sha256:2c87bba523e49c207947a36c2d7e2287a44d6615ac94de85542ef81ed3eeeec1", size = 144400, upload-time = "2021-01-21T14:43:36.447Z" } +[[package]] +name = "immutabledict" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/e6/718471048fea0366c3e3d1df3acfd914ca66d571cdffcf6d37bbcd725708/immutabledict-4.3.1.tar.gz", hash = "sha256:f844a669106cfdc73f47b1a9da003782fb17dc955a54c80972e0d93d1c63c514", size = 7806, upload-time = "2026-02-15T10:32:34.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ce/f9018bf69ae91b273b6391a095e7c93fa5e1617f25b6ba81ad4b20c9df10/immutabledict-4.3.1-py3-none-any.whl", hash = "sha256:c9facdc0ff30fdb8e35bd16532026cac472a549e182c94fa201b51b25e4bf7bf", size = 5000, upload-time = "2026-02-15T10:32:33.672Z" }, +] + +[[package]] +name = "immutables" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/41/0ccaa6ef9943c0609ec5aa663a3b3e681c1712c1007147b84590cec706a0/immutables-0.21.tar.gz", hash = "sha256:b55ffaf0449790242feb4c56ab799ea7af92801a0a43f9e2f4f8af2ab24dfc4a", size = 89008, upload-time = "2024-10-10T00:55:01.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/f9/0c46f600702b815182212453f5514c0070ee168b817cdf7c3767554c8489/immutables-0.21-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1ed262094b755903122c3c3a83ad0e0d5c3ab7887cda12b2fe878769d1ee0d", size = 31885, upload-time = "2024-10-10T00:54:19.406Z" }, + { url = "https://files.pythonhosted.org/packages/29/34/7608d2eab6179aa47e8f59ab0fbd5b3eeb2333d78c9dc2da0de8de4ed322/immutables-0.21-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce604f81d9d8f26e60b52ebcb56bb5c0462c8ea50fb17868487d15f048a2f13e", size = 31537, upload-time = "2024-10-10T00:54:20.998Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/cb9e2bb7a69338155ffabbd2f993c968c750dd2d5c6c6eaa6ebb7bfcbdfa/immutables-0.21-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b48b116aaca4500398058b5a87814857a60c4cb09417fecc12d7da0f5639b73d", size = 104270, upload-time = "2024-10-10T00:54:21.912Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a4/25df835a9b9b372a4a869a8a1ac30a32199f2b3f581ad0e249f7e3d19eed/immutables-0.21-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dad7c0c74b285cc0e555ec0e97acbdc6f1862fcd16b99abd612df3243732e741", size = 104864, upload-time = "2024-10-10T00:54:22.956Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/b548fbc657134d658e179ee8d201ae82d9049aba5c3cb2d858ed2ecb7e3f/immutables-0.21-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e44346e2221a5a676c880ca8e0e6429fa24d1a4ae562573f5c04d7f2e759b030", size = 99733, upload-time = "2024-10-10T00:54:23.99Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/d7b1e0e88faf07fe9a88579a86f58078a9a37fff871f4b3dbcf28cad9a12/immutables-0.21-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b10139b529a460e53fe8be699ebd848c54c8a33ebe67763bcfcc809a475a26f", size = 101698, upload-time = "2024-10-10T00:54:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/6fe42a1a053dd8cfb9f45e91d5246522637c7287dc6bd347f67aedf7aedb/immutables-0.21-cp312-cp312-win32.whl", hash = "sha256:fc512d808662614feb17d2d92e98f611d69669a98c7af15910acf1dc72737038", size = 30977, upload-time = "2024-10-10T00:54:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/d062aca6971e99454ce3ae42a7430037227fee961644ed1f8b6c9b99e0a5/immutables-0.21-cp312-cp312-win_amd64.whl", hash = "sha256:461dcb0f58a131045155e52a2c43de6ec2fe5ba19bdced6858a3abb63cee5111", size = 35088, upload-time = "2024-10-10T00:54:28.388Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/60da6f5a3c3f64e0b3940c4ad86e1971d9d2eb8b13a179c26eda5ec6a298/immutables-0.21-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:79674b51aa8dd983f9ac55f7f67b433b1df84a6b4f28ab860588389a5659485b", size = 31922, upload-time = "2024-10-10T00:54:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/5420f1d16a652024fcccc9c07d46d4157fcaf33ff37c82412c83fc16ef36/immutables-0.21-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93c8350f8f7d0d9693f708229d9d0578e6f3b785ce6da4bced1da97137aacfad", size = 31552, upload-time = "2024-10-10T00:54:30.282Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d0/a5fb7c164ddb298ec37537e618b70dfa30c7cae9fac01de374c36489cbc9/immutables-0.21-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:583d2a63e444ce1538cc2bda56ae1f4a1a11473dbc0377c82b516bc7eec3b81e", size = 104334, upload-time = "2024-10-10T00:54:31.284Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/5fda0ee4a261a85124011ac0750fec678f00e1b2d4a5502b149a3b4d86d9/immutables-0.21-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b274a52da9b106db55eceb93fc1aea858c4e6f4740189e3548e38613eafc2021", size = 104898, upload-time = "2024-10-10T00:54:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/d46bfe92f2c66d35916344176ff87fa839aac9c16849652947e722b7a15f/immutables-0.21-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:338bede057250b33716a3e4892e15df0bf5a5ddbf1d67ead996b3e680b49ef9e", size = 99966, upload-time = "2024-10-10T00:54:34.046Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f5/2a19e2e095f7a39d8d77dcc10669734d2d99773ce00c99bdcfeeb7d714e6/immutables-0.21-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8781c89583b68f604cf30f0978b722165824c3075888639fde771bf1a3e12dc0", size = 101773, upload-time = "2024-10-10T00:54:35.851Z" }, + { url = "https://files.pythonhosted.org/packages/86/80/5b6ee53f836cf2067ced997efbf2ce20890627f150c3089ea50cf607e783/immutables-0.21-cp313-cp313-win32.whl", hash = "sha256:e97ea83befad873712f283c0cccd630f70cba753e207b4868af28d5b85e9dc54", size = 30988, upload-time = "2024-10-10T00:54:37.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/07/f623e6da78368fc0b1772f4877afbf60f34c4cc93f1a8f1006507afa21ec/immutables-0.21-cp313-cp313-win_amd64.whl", hash = "sha256:cfcb23bd898f5a4ef88692b42c51f52ca7373a35ba4dcc215060a668639eb5da", size = 35147, upload-time = "2024-10-10T00:54:38.558Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -486,25 +519,23 @@ wheels = [ [[package]] name = "loopy" -version = "2025.2" +version = "2024.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cgen" }, { name = "codepy" }, { name = "colorama" }, - { name = "constantdict" }, { name = "genpy" }, + { name = "immutables" }, { name = "islpy" }, { name = "mako" }, { name = "numpy" }, { name = "pymbolic" }, + { name = "pyrsistent" }, { name = "pytools" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/ed/1b455d2fa2fd2c014f3533ca7b8bcbb387cc0679f2d145ac1666e4d40a3a/loopy-2025.2.tar.gz", hash = "sha256:4bbeebf3a9e37a589a92ac4434d9e60d1382529ff2d4a07a209b1ff51fdd9830", size = 624722, upload-time = "2025-07-30T17:02:25.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/ef/d2686194757335314db5c0b1d708902baac9d6e275479121a720fc5af3a9/loopy-2025.2-py3-none-any.whl", hash = "sha256:3a337be5ae09c56df5a7d09f24df20fa780c2e6daa25deb5d18f317cbdd54033", size = 552360, upload-time = "2025-07-30T17:02:23.947Z" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/24/bc/dae4f601cb201764e9e8f0ad0a1dd2a5c51f128370edd5ce0210c0a63148/loopy-2024.1.tar.gz", hash = "sha256:d7a063b668de8f2ada6f70f8db98865005a46608e7d2d54472f70028f059fc17", size = 621539, upload-time = "2024-02-16T19:51:13.37Z" } [[package]] name = "mako" @@ -652,18 +683,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/0f/66/b6aab0685b3c7cdcc [[package]] name = "modepy" -version = "2026.1" +version = "2021.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, - { name = "pymbolic" }, + { name = "pytest" }, { name = "pytools" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/eb/3a5b651e8637ad57ec255cd0292b16b0173c633c933407c97a97cf420a71/modepy-2026.1.tar.gz", hash = "sha256:25e0be56a109ae69ed5d73b0032c5dbeafbc8aa88552f4cb338c7fa9b98cb1d4", size = 474434, upload-time = "2026-01-19T19:47:54.722Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/82/2f591e700aa740e9e13eb6f21440ab8ea3b08037cbfb5005037174d555e1/modepy-2026.1-py3-none-any.whl", hash = "sha256:ba7117f98bd7fb5f219d8686dd24df2720902e714125c8a9d07edd581e5991fa", size = 343906, upload-time = "2026-01-19T19:47:53.22Z" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/b4/e9/8bb6553553473e5ab44f4f33b35f7a3ed6660e94cda81bb85d5f0b88cfb3/modepy-2021.1.tar.gz", hash = "sha256:4cddd2d4720128356e0019e8d972d979552eafad059f4acef01e106b22d8d297", size = 443712, upload-time = "2021-04-12T16:22:37.893Z" } [[package]] name = "mypy" @@ -908,56 +935,39 @@ wheels = [ [[package]] name = "pymbolic" -version = "2025.1" +version = "2024.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "constantdict" }, + { name = "immutabledict" }, { name = "pytools" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/34/dbf12e9379faf31c32f56e9fae9a4f2e70bcb6afb4d2a253cfb98f043d11/pymbolic-2025.1.tar.gz", hash = "sha256:31efa99e1f2a6407d1d7128d356dd625d56d2002d23b3f104da5b9c3fb0a270b", size = 113053, upload-time = "2025-10-03T19:21:24.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/ef/5d34fdafcc5f4ba880d54384702f6bff065841aa8ae536f842d6ce6b6c60/pymbolic-2024.2.2.tar.gz", hash = "sha256:976ccff0ed1b9d5ad3471cf2baa0d02e2301c1ebed5e577b2e05f633c2f53fec", size = 111702, upload-time = "2024-12-19T13:22:27.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/91/8e3af987d8ff0f80c8a02022e391c428033ed6b8b0f32f283c09faf5c029/pymbolic-2025.1-py3-none-any.whl", hash = "sha256:bf16406b24b53f45d707d7492d85107ace3116465586859cebc2f59dbdf90fd0", size = 138972, upload-time = "2025-10-03T19:21:22.903Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f8/04f9c3fab4c44269a2ac0f58c2aa5cb91f27d94037013b0a3fe93c7ddc89/pymbolic-2024.2.2-py3-none-any.whl", hash = "sha256:d6eea7e585c880a6801387a7e24417c801103b9f1903c29d5b0386cc83bdeea5", size = 135994, upload-time = "2024-12-19T13:22:24.872Z" }, ] [[package]] name = "pyopencl" -version = "2026.1.2" +version = "2025.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "platformdirs" }, { name = "pytools" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/81/fd8a2a695916a82e861bcf17b5b8fd9f81e12c9e5931f9ba536678d7b43a/pyopencl-2026.1.2.tar.gz", hash = "sha256:4397dd0b4cbb8b55f3e09bf87114a2465574506b363890b805b860c348b61970", size = 445132, upload-time = "2026-01-16T22:52:24.765Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/88/0ac460d3e2def08b2ad6345db6a13613815f616bbbd60c6f4bdf774f4c41/pyopencl-2025.1.tar.gz", hash = "sha256:0116736d7f7920f87b8db4b66a03f27b1d930d2e37ddd14518407cc22dd24779", size = 422510, upload-time = "2025-01-22T00:16:58.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/34/1497070e44d1689ddbd01d24a2265910e84ebc53457a489b9d2b6e1ac675/pyopencl-2026.1.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:7d88e59901bfe1f9296fd89acd9968f008dc7cfee7995f8cd09c3f1a77119aa6", size = 438145, upload-time = "2026-01-16T22:51:43.658Z" }, - { url = "https://files.pythonhosted.org/packages/5b/a3/71d6af8741b52d3bef443518c1ccfda003adcfa9cc1d0df83dac7005d08c/pyopencl-2026.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f96a3bff8a09d2fa924e7c33dafac6ea3ef7ec70e746d6d8e17ce2d959a6836", size = 428820, upload-time = "2026-01-16T22:51:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/db/ea/c8dbabeceac9cad3dbb368e08e0aa208cc6c6251c5134cc25eb15da03639/pyopencl-2026.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d4e8e8215ec4fdee4b235b61977cdb1c4f041b487bdcf357be799f45b423d61", size = 685478, upload-time = "2026-01-16T22:51:46.545Z" }, - { url = "https://files.pythonhosted.org/packages/64/c7/5854ef7471dfee195bcef6348a107525ca4d1b73c15240e6444d490f9920/pyopencl-2026.1.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0052a8ccbd282d8ab196705e31f4c3ab344113ea5d5c3ddaeede00cdcab068b", size = 734017, upload-time = "2026-01-16T22:51:48.277Z" }, - { url = "https://files.pythonhosted.org/packages/3d/79/42d4eec282ed299b38d8136d05545113ec8771a1bd6b10bb4ba83ae1236c/pyopencl-2026.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e43da12a376e9283407c2820b24cceeaa129b042ac710947cf8e07b13e294689", size = 1159871, upload-time = "2026-01-16T22:51:49.569Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9a/fdc5d3bed0440d6206109e051008aa0a54ca131d64314bbd42177b8f0763/pyopencl-2026.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1b14b2cf11dec9e0b75cbd14223d1b3c93950fc3e2f7a306b54fa1b17a2cae0f", size = 1225288, upload-time = "2026-01-16T22:51:51.125Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e3/358c19180e0dab5c7dd1fcacc569e6a7ab02a7fddcb9c954f393ceddb2fa/pyopencl-2026.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:d02d7ecabc8d34590dccffe12346689adc5a1ceb07df5acc4ea6c4db8aa28277", size = 474876, upload-time = "2026-01-16T22:51:52.912Z" }, - { url = "https://files.pythonhosted.org/packages/e6/23/78a0aea081a578abd44294ccd8d49b65c11b2bf9454838ccc6e152464091/pyopencl-2026.1.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:e2ddc134899ebb80aebfa8734e9b7b6f342faf98dde56d435255c5fbbdcab8f6", size = 438171, upload-time = "2026-01-16T22:51:54.046Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8d/010acd8449d05db8b79f82d7333f0ff224a628d0be1b7e5fab320b9eb2a8/pyopencl-2026.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0cd0657e143d1df4bd7e4273867e7aeb615e80ee2c3da855360d6fba56ac9d3", size = 428702, upload-time = "2026-01-16T22:51:55.407Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5b/481feac147b132237e1b10ade8255f78671903e56d41fdee92eb48ac99fa/pyopencl-2026.1.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ac5eed3656bb0284c3df356ba21ed3ba935cc5d9c242dd98389aaf2a0f75327", size = 685322, upload-time = "2026-01-16T22:51:56.786Z" }, - { url = "https://files.pythonhosted.org/packages/48/4d/f72dabeb33b85b18cc9d7d5f8edc4523b90dbc35d55327971b1442bd1de7/pyopencl-2026.1.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0516082f2889e18a2f20fea34cea7520279a6241aeb73101db97c6e06636bb", size = 733973, upload-time = "2026-01-16T22:51:58.241Z" }, - { url = "https://files.pythonhosted.org/packages/85/ca/744f210e0dac091c2d1b0719353439f02fa6552e74c221e1682d3ca6643c/pyopencl-2026.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef3894b01711bc11ab95a9a19f9da359e3278428b809bbd1e96bcae100b0ef6b", size = 1159817, upload-time = "2026-01-16T22:52:00.099Z" }, - { url = "https://files.pythonhosted.org/packages/18/7d/5129d8195ab4a7fcd5d0adc71f038eb3f58c68463e5ba6ecf2b4318bf8ba/pyopencl-2026.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8060719940085b61bf2b36b1b910ce936822d66ad27f9ad96428f73ae3f42791", size = 1225284, upload-time = "2026-01-16T22:52:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b3/d011ba5d8942e048989ebd595994cb0e03c45e69fef5837a8ed0ee67d2b6/pyopencl-2026.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:84c4d6ef453d64fd7eae65b459f38da0b90599b00c3ab286e7e253889d35ea1e", size = 474790, upload-time = "2026-01-16T22:52:03.719Z" }, - { url = "https://files.pythonhosted.org/packages/05/9f/6db30d3810f7357dd3b40532c07e4977758a71548ee80e0b22d26f48a1e8/pyopencl-2026.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:420c4907e1d263da9bb88f36fd7c93bc046eadb5dfaf73c4c5270b8ce9345c0c", size = 438287, upload-time = "2026-01-16T22:52:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f7/34768c02f37c9552528c458059f0702880357d265d401ddf2932c428942f/pyopencl-2026.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afbe9f5aab6712308681a29569c2307b838879fe47bdb5c245f2315d88d940a", size = 428662, upload-time = "2026-01-16T22:52:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/15aba2286d882116e62666c8af4316e740ae77a423bc9f7591e2ffc8b163/pyopencl-2026.1.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8587b18b1ea97563b91e745a5d152e4d6b9109714f631945d621b48ae28ac6e", size = 686573, upload-time = "2026-01-16T22:52:07.596Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1f/2783900fb4a1912772daf16a43264d15b9d32c95cb009c6c7d530ce29477/pyopencl-2026.1.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:544135c51c378e61d52884d5bc4073035bd9e84be401f2a5c14bf12b4b1ad51d", size = 733968, upload-time = "2026-01-16T22:52:09.492Z" }, - { url = "https://files.pythonhosted.org/packages/a6/6b/d0b35a8ccfc524479cad4698da53284524348fabdd9baf8825c5e9b49a58/pyopencl-2026.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b12cdfcd939a5fb7a2f72b24178d526c217ce7d87bd1f0e2de75255ae5f3a81a", size = 1161141, upload-time = "2026-01-16T22:52:11.335Z" }, - { url = "https://files.pythonhosted.org/packages/8c/14/dcc5617f6e1d76da14d0efb7c314da0709e48641827d115208a56c48e3dd/pyopencl-2026.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba7be397e2b175465da9aacbf73ad97668e8fd5fa8bab177005df988728bd8e5", size = 1225470, upload-time = "2026-01-16T22:52:12.642Z" }, - { url = "https://files.pythonhosted.org/packages/c8/96/15b6d5215e445d59c5a91eede0dbd2a2708ed609da584f6db16f739f50fe/pyopencl-2026.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a793a39ee44d860a54015d78c14b727e0caa7e2e73c7d31f143ce95c91925acf", size = 482632, upload-time = "2026-01-16T22:52:14.76Z" }, - { url = "https://files.pythonhosted.org/packages/db/4d/6f1a673cf3c9545383778f7b8169f914ad503e3aa29a45d4be72cfb49bbf/pyopencl-2026.1.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446e58be6389b1d0e0c9fc13ec6dd17f9c06a959ae31cfff18a4b3bc69da7593", size = 694721, upload-time = "2026-01-16T22:52:16.764Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f6/ea475d98607c7b2ad54bc86d7df95c4f68130512ecc8e3d49324dc1771f8/pyopencl-2026.1.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80cf42ce6be3bbbc0cc6d3c3eafd7498d011b37c076e831e67d91f74b9070550", size = 741960, upload-time = "2026-01-16T22:52:18.174Z" }, - { url = "https://files.pythonhosted.org/packages/3a/87/0254e7857cbdd54e4f745e5439c9e44ad8ae3be22ff49902d27bfa75756c/pyopencl-2026.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a8dd8c62d5d6c606e144d625f36b705ad84bac64d79c181fe3ef72b915921fc7", size = 1168648, upload-time = "2026-01-16T22:52:19.757Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1b/e6652fccaff9155fa31a2c7fe6fb4833f8f572c4aa5a01221c941ec07a2c/pyopencl-2026.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:216adcc3c835f64490b03b293d5cf2ae342bccac3405f997d46056ff2bb9aa2f", size = 1234192, upload-time = "2026-01-16T22:52:21.201Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/8e09ec107184385707f2246abb7a3687f4877403657cf8e2a50064dfc1db/pyopencl-2026.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:48d95bd4b7f9b78bcf4917509a04916d6aafe411efb6c733af4d2c4d22980f74", size = 494505, upload-time = "2026-01-16T22:52:22.673Z" }, + { url = "https://files.pythonhosted.org/packages/02/c0/d9536211ecfddd3bdf7eaa1658d085fcd92120061ac6f4e662a5062660ff/pyopencl-2025.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:88c564d94a5067ab6b9429a7d92b655254da8b2b5a33c7e30e10c5a0cf3154e1", size = 425706, upload-time = "2025-01-22T00:16:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/63/b9/3e6dd574cc9ffb2271be135ecdb36cd6aea70a1f74e80539b048072a256a/pyopencl-2025.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f204cd6974ca19e7ffe14f21afac9967b06a6718f3aecbbc4a4df313e8e7ebc2", size = 408163, upload-time = "2025-01-22T00:16:20.282Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4d/7f6f2e24b12585b81fd49f689d21ba62187656d061e3cb43840f12097dad/pyopencl-2025.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce8d1b3fd2046e89377b754117fdb3505f45edacfda6ad2b3a29670c0526ad1", size = 719348, upload-time = "2025-01-22T00:16:22.15Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/3c93510819859e047d494dd8f4ed80c26378bb964a8e5e850cc079cc1f6e/pyopencl-2025.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb0b7b775424f0c4c929a00f09eb351075ea9e4d2b1c80afe37d09bec218ee9", size = 1170733, upload-time = "2025-01-22T00:16:24.575Z" }, + { url = "https://files.pythonhosted.org/packages/91/ba/b745715085ef893fa7d1c7e04c95e3e554b6b27b2fb0800d6bbca563b43c/pyopencl-2025.1-cp312-cp312-win_amd64.whl", hash = "sha256:78f2a58d2e177793fb5a7b9c8a574e3a5f1d178c4df713969d1b08341c817d60", size = 457762, upload-time = "2025-01-22T00:16:27.334Z" }, + { url = "https://files.pythonhosted.org/packages/d0/70/92b2e3be4f2b7b5f891d95ae5acddb837a4127a2ed9fa0c94cd03d2487f8/pyopencl-2025.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3df5d75a4952ec7b5c9c7bab99e5e3e38a9bb17b996a2782b2a4d38f0b551682", size = 425714, upload-time = "2025-01-22T00:16:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/8d/16/601abeac89c40fe10bc919ff67bea4ac8c4facc84069080bef0565a6fcb2/pyopencl-2025.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5be8cf06459ee829b16465ec97ced117c86f3f058379e507d78f6ce012b10bb4", size = 408171, upload-time = "2025-01-22T00:16:31.96Z" }, + { url = "https://files.pythonhosted.org/packages/7a/81/d16a4f69284dcde76006840904e269cbca4ee19ec5cbb3ab0ad32bdf1442/pyopencl-2025.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c4e5b5a28f7d398cca04e7b389e607daad4a36d18879cf59add541a67b72406", size = 719329, upload-time = "2025-01-22T00:16:33.323Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/246d06f7edee547e64a5d195768c7e58c23a5a15500b92c69aee9bfda9b8/pyopencl-2025.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e001ca6f096f875a952422ba96dc003cc2d7464532a77282923f3bf6bc0004a", size = 1170744, upload-time = "2025-01-22T00:16:34.655Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f3/0204a068f7581c45824906b3cc4a9dbb19090f95374f1c08dc2c29544826/pyopencl-2025.1-cp313-cp313-win_amd64.whl", hash = "sha256:17d0a75ac0601db6de3d41ee3063474245ef47e57da53c074d67f9196aab1396", size = 457775, upload-time = "2025-01-22T00:16:37.975Z" }, ] [[package]] @@ -969,6 +979,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyrsistent" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/3a/5031723c09068e9c8c2f0bc25c3a9245f2b1d1aea8396c787a408f2b95ca/pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", size = 103642, upload-time = "2023-10-25T21:06:56.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/ee/ff2ed52032ac1ce2e7ba19e79bd5b05d152ebfb77956cf08fcd6e8d760ea/pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", size = 83537, upload-time = "2023-10-25T21:06:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/f1/338d0050b24c3132bcfc79b68c3a5f54bce3d213ecef74d37e988b971d8a/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", size = 122615, upload-time = "2023-10-25T21:06:25.815Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/e56d6431b713518094fae6ff833a04a6f49ad0fbe25fb7c0dc7408e19d20/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", size = 122335, upload-time = "2023-10-25T21:06:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/4a/bb/5f40a4d5e985a43b43f607250e766cdec28904682c3505eb0bd343a4b7db/pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", size = 118510, upload-time = "2023-10-25T21:06:30.718Z" }, + { url = "https://files.pythonhosted.org/packages/1c/13/e6a22f40f5800af116c02c28e29f15c06aa41cb2036f6a64ab124647f28b/pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", size = 60865, upload-time = "2023-10-25T21:06:32.742Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/2fa3b55023ec07c22682c957808f9a41836da4cd006b5f55ec76bf0fbfa6/pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", size = 63239, upload-time = "2023-10-25T21:06:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/23/88/0acd180010aaed4987c85700b7cc17f9505f3edb4e5873e4dc67f613e338/pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", size = 58106, upload-time = "2023-10-25T21:06:54.387Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -999,16 +1024,15 @@ wheels = [ [[package]] name = "pytools" -version = "2025.2.5" +version = "2024.1.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "platformdirs" }, - { name = "siphash24" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/7b/f885a57e61ded45b5b10ca60f0b7575c9fb9a282e7513d0e23a33ee647e1/pytools-2025.2.5.tar.gz", hash = "sha256:a7f5350644d46d98ee9c7e67b4b41693308aa0f5e9b188d8f0694b27dc94e3a2", size = 85594, upload-time = "2025-10-07T15:53:30.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/12/cee78513328420ab66c142cdfc2b03745561887287572e7bfd8e180918d6/pytools-2024.1.21.tar.gz", hash = "sha256:a1989bb91b9d43aa16c812f3ceb1ee50846de54c50034d0dc1a20ab2753da98c", size = 79853, upload-time = "2024-12-13T00:10:50.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/84/c42c29ca4bff35baa286df70b0097e0b1c88fd57e8e6bdb09cb161a6f3c1/pytools-2025.2.5-py3-none-any.whl", hash = "sha256:42e93751ec425781e103bbcd769ba35ecbacd43339c2905401608f2fdc30cf19", size = 98811, upload-time = "2025-10-07T15:53:29.089Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9f/e6500ed760c409c73a8b22022a1200d6ee8e412194c0c886df0880522798/pytools-2024.1.21-py3-none-any.whl", hash = "sha256:3228f1f26aca0ac41f05afc3e128363f5e2bb4150bdf5f50677baa652a0d3696", size = 92443, upload-time = "2024-12-13T00:10:47.902Z" }, ] [[package]] @@ -1048,44 +1072,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, ] -[[package]] -name = "siphash24" -version = "1.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/a2/e049b6fccf7a94bd1b2f68b3059a7d6a7aea86a808cac80cb9ae71ab6254/siphash24-1.8.tar.gz", hash = "sha256:aa932f0af4a7335caef772fdaf73a433a32580405c41eb17ff24077944b0aa97", size = 19946, upload-time = "2025-09-02T20:42:04.856Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/82/ce3545ce8052ac7ca104b183415a27ec3335e5ed51978fdd7b433f3cfe5b/siphash24-1.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5ed437c6e6cc96196b38728e57cd30b0427df45223475a90e173f5015ef5ba", size = 78136, upload-time = "2025-09-02T20:41:10.083Z" }, - { url = "https://files.pythonhosted.org/packages/15/88/896c3b91bc9deb78c415448b1db67343917f35971a9e23a5967a9d323b8a/siphash24-1.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4ef78abdf811325c7089a35504df339c48c0007d4af428a044431d329721e56", size = 74588, upload-time = "2025-09-02T20:41:11.251Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/8dad3f5601db485ba862e1c1f91a5d77fb563650856a6708e9acb40ee53c/siphash24-1.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:065eff55c4fefb3a29fd26afb2c072abf7f668ffd53b91d41f92a1c485fcbe5c", size = 98655, upload-time = "2025-09-02T20:41:12.45Z" }, - { url = "https://files.pythonhosted.org/packages/e3/cc/e0c352624c1f2faad270aeb5cce6e173977ef66b9b5e918aa6f32af896bf/siphash24-1.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6fa84ebfd47677262aa0bcb0f5a70f796f5fc5704b287ee1b65a3bd4fb7a5d", size = 103217, upload-time = "2025-09-02T20:41:13.746Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f6/0b1675bea4d40affcae642d9c7337702a4138b93c544230280712403e968/siphash24-1.8-cp312-cp312-win32.whl", hash = "sha256:6582f73615552ca055e51e03cb02a28e570a641a7f500222c86c2d811b5037eb", size = 63114, upload-time = "2025-09-02T20:41:14.972Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/afefef85d72ed8b5cf1aa9283f712e3cd43c9682fabbc809dec54baa8452/siphash24-1.8-cp312-cp312-win_amd64.whl", hash = "sha256:44ea6d794a7cbe184e1e1da2df81c5ebb672ab3867935c3e87c08bb0c2fa4879", size = 76232, upload-time = "2025-09-02T20:41:16.112Z" }, - { url = "https://files.pythonhosted.org/packages/88/56/9b82be5c82f028495d23ca614a993dfde4f4079c900f6a4e1af62d46922c/siphash24-1.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:948af9192eb243815fd361296b317220a94094406688b4daba062cfb08ecfd7d", size = 77082, upload-time = "2025-09-02T20:41:17.304Z" }, - { url = "https://files.pythonhosted.org/packages/54/b1/3137e38b707c601bec914781344fcd84c32462f89ae856dd8a6d5e8d23da/siphash24-1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b435b2ac511b738dd0984308c1ce1b441faa70f8a1f3d022b5323bb5704cad6f", size = 73805, upload-time = "2025-09-02T20:41:18.462Z" }, - { url = "https://files.pythonhosted.org/packages/61/35/c810068bd532cbc9f92c2284ad717f4cd778c4b835f8c1e194f09117fd82/siphash24-1.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f0c4e46817b4a02657cd92c5eda8cb1804b83dc658882b0c2693d1ce4e3e597", size = 97644, upload-time = "2025-09-02T20:41:19.635Z" }, - { url = "https://files.pythonhosted.org/packages/ad/77/b88b4a24ec747a0147993a61a8dabc9c69b45aa2459ea4b6e100b4ea613b/siphash24-1.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:371794ce0ade48caaf4061c2e88178cec43a0997d312cf3c1e9c864d80c8a00f", size = 102295, upload-time = "2025-09-02T20:41:21.302Z" }, - { url = "https://files.pythonhosted.org/packages/de/d3/1426fec831420bbdcc088f7ffd4ecc5289ccd07a75ed83ca007d91a3ff89/siphash24-1.8-cp313-cp313-win32.whl", hash = "sha256:51e95dd6cf679784246ef8da1a213554c7813096c84dfd52c9d2c8ce04f911c2", size = 62912, upload-time = "2025-09-02T20:41:22.496Z" }, - { url = "https://files.pythonhosted.org/packages/57/7a/535bd58f21564ba8b094fd42661c6cd056ef11be975826ea6bd1535d4354/siphash24-1.8-cp313-cp313-win_amd64.whl", hash = "sha256:749e123a6bb2b29b9aedb4487ae612430035b98e1bf43b2f17e3bfc21ed99fff", size = 75879, upload-time = "2025-09-02T20:41:23.656Z" }, - { url = "https://files.pythonhosted.org/packages/dd/01/cd6c85803d98becb4e4b3d50d9506089eef48b8b0b5f0b690697944dbefa/siphash24-1.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5c32763b6c912a42132b24dee2a988c63cf54b34b75d8ef195eb024546caeed0", size = 80342, upload-time = "2025-09-02T20:41:24.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/9d/c49e6f4bbea12e7aade941787f16313a2ce0d49cfe3270dbec6b89e46334/siphash24-1.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3d34faa76c4044e105c30e040dca017dac2416c26e3ab32a7d504c9d7d0ef139", size = 78800, upload-time = "2025-09-02T20:41:25.929Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6d/9e505a9b94cbe4a668e37867a36552ee19e0263da4c8859528c54b590914/siphash24-1.8-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9825d048127363c23772deea343d6935fa3a80e045b344964b6f6df06113add", size = 94464, upload-time = "2025-09-02T20:41:27.236Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a6/57a27748a70d32ca9f35802666ab85f8994db25240d33c4d744016bd01ab/siphash24-1.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d1caf6c109b4135fe5647b2d25e94bf6c3f725808280da834f951f99769ccae", size = 97466, upload-time = "2025-09-02T20:41:28.698Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d9/2e121ce9e2cc216cfaf87c65e6030e4f403a6051168ec15da85b9002810a/siphash24-1.8-cp313-cp313t-win32.whl", hash = "sha256:e51464db44b1c8a29980f23d8fd5e45f5915d68d6c9327b393df7f94f78d97e3", size = 70746, upload-time = "2025-09-02T20:41:30.247Z" }, - { url = "https://files.pythonhosted.org/packages/cf/eb/6480854fcaf164900b259354591156eeeea18bc1383e449da21d9f9cf998/siphash24-1.8-cp313-cp313t-win_amd64.whl", hash = "sha256:d31a611db1acb18c1260e9638effab0e5af63dcea339416c931433b69a61e153", size = 86689, upload-time = "2025-09-02T20:41:31.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/79/2304b63f81eff30cd7f5680ddecda780ba7ea589323d17b1ac9fdfbe4f02/siphash24-1.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:157a7432009490ecfead6953e9fd88a5f1046baaf8b911611a7384b19793147c", size = 77285, upload-time = "2025-09-02T20:41:33.391Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/a4bb15d78547f4f4db7c21ad90883f2f52494bed1aa91bc96be476a4e7f5/siphash24-1.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c699eec6649427240d337c372f94737c19b2bc4e485294b49690f3029a37dd69", size = 74370, upload-time = "2025-09-02T20:41:34.9Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/a8bb586952ac950a321e77be565fd99d50551366c8bf425572fd38ca05e0/siphash24-1.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecae35dd5a6e479e65395e5994d5b6ffd5f87f3b60de69d16acd02b4126388c0", size = 98332, upload-time = "2025-09-02T20:41:36.113Z" }, - { url = "https://files.pythonhosted.org/packages/33/ae/68f6a4eda2d8022eb857d6fb090f5aa02ae837b2365187a433e76dcbb64b/siphash24-1.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22b68242352a771a25b0a7cffedd584bfa61bafb83262776f42989bc4e96fe84", size = 102403, upload-time = "2025-09-02T20:41:37.371Z" }, - { url = "https://files.pythonhosted.org/packages/b5/7a/f005da994a8cc2281f7bbae74dd27fed99e7ea162b12199ecd34b47d1a03/siphash24-1.8-cp314-cp314-win32.whl", hash = "sha256:b53c0ee8393c48e949f7f3b09fa8e3095e696b5c78824c966eb4aea2d338361b", size = 64615, upload-time = "2025-09-02T20:41:38.688Z" }, - { url = "https://files.pythonhosted.org/packages/ba/16/4d9f436c957830b0b10d311f81d9258bfb0f722de14dd467d0047e9e2c72/siphash24-1.8-cp314-cp314-win_amd64.whl", hash = "sha256:8e67b7ec7406dc9d4e0394d852889269d8f903f1bc6be2e25c2cde7e92059817", size = 77999, upload-time = "2025-09-02T20:41:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e7/6fa559ff1d57f2c8d7e580ccd737018038d84a83c092feafc0b93f2649b8/siphash24-1.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:00114928872ebc899aa4b8a765766de376705c48d0d5393edd3ea80006252d61", size = 80354, upload-time = "2025-09-02T20:41:41.401Z" }, - { url = "https://files.pythonhosted.org/packages/56/44/9b9c70e216ee5bd81a1c0fce06ba88ff876bc8699f0ce38f9e6591c705c0/siphash24-1.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1c378442ce93b6f10d6b43c494b7ad630c5540051701288848f072f97e0778c6", size = 78829, upload-time = "2025-09-02T20:41:42.551Z" }, - { url = "https://files.pythonhosted.org/packages/ee/06/65c29cdedafa0952979aec2dc5491071546a8144f78940478fecc154e9d5/siphash24-1.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7807bd9160e7cd44cc4e0218bb2c779d55c449070080623547ace3519733841", size = 94470, upload-time = "2025-09-02T20:41:43.76Z" }, - { url = "https://files.pythonhosted.org/packages/37/03/824bc1efe762d20a7bb755e735d295b1cffa12902e1b39fc0ccea1bdc1f5/siphash24-1.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e0a46994e75d144c10df26db9298dda496de5d213dc97197080db479f0283c4", size = 97417, upload-time = "2025-09-02T20:41:45.182Z" }, - { url = "https://files.pythonhosted.org/packages/85/4d/99270aabeaf0a35313c31886624a314417bfa2f9cd9d7a8a890b22d91977/siphash24-1.8-cp314-cp314t-win32.whl", hash = "sha256:0b39834ffaeb69001021db0386fd949d8d0f869e0dbd9f2e1fa7a1107aa0f80a", size = 73456, upload-time = "2025-09-02T20:41:46.721Z" }, - { url = "https://files.pythonhosted.org/packages/cc/39/a77f16ae2af3f5cdc4de5de6aa37a23c8a66121b225819108ef818896c56/siphash24-1.8-cp314-cp314t-win_amd64.whl", hash = "sha256:32753439fe9faaaa19ef64eaee9e3e049cd90de2d04c9ff635d8f38c2130da54", size = 91094, upload-time = "2025-09-02T20:41:47.942Z" }, -] - [[package]] name = "six" version = "1.17.0"