diff --git a/docs/exec-plans/completed/20260612-boxcode-nearfield-template-experiment.md b/docs/exec-plans/completed/20260612-boxcode-nearfield-template-experiment.md new file mode 100644 index 0000000..4b07f66 --- /dev/null +++ b/docs/exec-plans/completed/20260612-boxcode-nearfield-template-experiment.md @@ -0,0 +1,71 @@ +# Box-Code Near-Field Template Experiment + +## Objective + +Evaluate whether folded-decomposition charts can support reusable near-field +template corrections for box-code and Volumential-style workflows. + +## Scope + +- Derive the mapped-kernel form for a 2D fan folded piece. +- Focus the near-field form on point targets in physical space. +- Identify singular factors that can be separated from smooth geometry payloads. +- Frame the reuse hypothesis as a functional expansion in smooth metric fields + `M(z)`, not just as raw runtime geometry data. +- Add a small analytic experiment driver with deterministic tests. +- Document feasibility limits and the next CUTKIT/Volumential boundary. + +## Non-Goals + +- No production Volumential integration in this branch. +- No CAD dependency for the first prototype. +- No claim that arbitrary cut shapes have finite exact correction tables. + +## Acceptance Criteria + +- The derivation states the mapped self-interaction form and its split. +- The derivation records the point-target form, target/source geometry taxonomy, + and measurement criteria from issue 61. +- The prototype records a point-target template experiment with a direct + high-order reference value. +- Tests check the experiment against an analytic or independently computed + invariant. +- Documentation explains whether the idea is feasible and what data must remain + runtime geometry payload. + +## Completed Checklist + +- [x] Read issue 61 and repository orientation docs. +- [x] Work through the 2D fan-map singularity derivation. +- [x] Add an experiment module or script for the near-field template prototype. +- [x] Add tests for the prototype invariants. +- [x] Update docs and doc index if new documentation is added. +- [x] Run targeted tests and `make dev`. +- [x] Move this plan to completed with outcomes. + +## Outcomes + +- The near-field template idea is feasible as a CUTKIT experiment: singular and + nearly singular folded-piece interactions can be expressed on fixed template + domains. +- The reusable singular part is a template-space log singular model. The metric + field `M(z) = DT(z)^T DT(z)` and higher-order terms are smooth geometry data. +- The practical reuse hypothesis is that a functional expansion in `M(z)` and + smooth-remainder data covers the folded-decomposition cases with few modes. +- The first prototype covers analytic straight fan maps, point-target + integrals, a log-kernel scale-law check, and a diagonal metric-remainder + sample. + +## Validation + +- `uv run --extra dev python -m pytest tests/evals/test_nearfield_templates.py tests/test_script_wrappers.py` +- `uv run --extra dev python scripts/run_nearfield_template_experiment.py --order 6` +- `make dev` + +## Remaining Risks + +- Template corrections may be reusable only after parameterized compression, not + as shape-independent lookup tables. +- Self-interaction singular quadrature may need specialized rules beyond the + first direct high-order reference prototype. +- Curved analytic pieces and adjacent-piece cases still need follow-up coverage. diff --git a/docs/index.md b/docs/index.md index ff80f0e..b6fe266 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,6 +23,7 @@ Repository-local documentation is the system of record for CUTKIT. - `docs/cad-box-batch-interface.md`: consumer-facing CAD object-or-arrays clip/integrate interface - `docs/volumential-handoff.md`: CUTKIT far/near primitive handoff contract for volumential workflows - `docs/meshmode-cut-overlay.md`: meshmode cut-overlay contract and validation semantics +- `docs/nearfield-template-experiments.md`: folded fan near-field template derivation and prototype - `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 diff --git a/docs/nearfield-template-experiments.md b/docs/nearfield-template-experiments.md new file mode 100644 index 0000000..4799d6e --- /dev/null +++ b/docs/nearfield-template-experiments.md @@ -0,0 +1,538 @@ +# Near-Field Template Experiments + +This note records the first mathematical check for issue 61: whether folded +decomposition charts can support a reusable near-field template story for later +box-code or Volumential work. + +## Bezier Fan Chart + +For the mathematical model, start with the chart type expected from CAD-backed +folded decomposition: a fan from a seed point `V` to a smooth Bezier trim curve +`C(t)`. A degree-`p` Bezier edge is + +$$ +\begin{aligned} +C(t) &= \sum_{a=0}^p B_a^p(t)P_a, \\ +B_a^p(t) &= \binom{p}{a}(1-t)^{p-a}t^a, +\qquad 0\le t\le 1. +\end{aligned} +$$ + +The folded chart is + +$$ +\begin{aligned} +T(r,t) &= (1-r)V + rC(t), +\qquad 0\le r,t\le 1. +\end{aligned} +$$ + +Its Jacobian is + +$$ +J(r,t) = r\det(C(t)-V,C'(t)). +$$ + +The factor `r` is the Duffy-style apex degeneracy already used by +`cutkit.quadrature.folded2d`. It is not a new singularity for physical +integration; it cancels area at the apex. The straight-edge case is only the +degree-1 specialization and should be treated as a smoke-test fixture, not as +the primary derivation. + +## Mapped Kernel + +For the 2D Laplace kernel + +$$ +G(x,y) = -\frac{1}{2\pi}\log |x-y|, +$$ + +the point-target near-field integral is + +$$ +u(x) = \int_{[0,1]^2} G(x,T_e(r,t))\rho(T_e(r,t))J_e(r,t)\,dr\,dt. +$$ + +This is the primary target model for the box-code experiment. The source is a +folded piece, but targets are physical-space discretization nodes in the source +box and neighboring near-field boxes. There is no target folded piece in the +first near-field experiment. + +For reusable tables, express the target relative to the same physical box or +chart payload. If `x0` is a chart/box reference point and `H` is a box-scale map, +write + +$$ +x = x_0 + H\tau, +$$ + +where `tau` is the template-space location of a target node in the containing or +neighboring box. The source-to-point integral is then a fixed-domain integral in +the source chart coordinates and the target offset parameter `tau`: + +$$ +u(\tau) = \int_{[0,1]^2}G(x_0+H\tau,T_e(\xi))\rho(T_e(\xi))J_e(\xi)\,d\xi. +$$ + +The experiment should classify target/source geometry as: + +- target node on or very near the source folded piece; +- target node in the source piece's containing box; +- target node in an edge-neighboring box; +- target node in a vertex-neighboring box; +- well-separated target node, where ordinary tensor-product quadrature should already work. + +## Point-Target Singular Split + +For a target point near the source chart, choose a template coordinate `z_x` +whose mapped source point `T(z_x)` is closest to `x`, or otherwise represents the +local source point responsible for the near-singular behavior. Write nearby +source coordinates as + +$$ +\begin{aligned} +\xi &= z_x + \delta, \\ +d &= x - T(z_x). +\end{aligned} +$$ + +Here `delta` is the local source displacement away from the nearest source point, +and `d` is the physical target offset from that point. The kernel depends on the +physical displacement from the source point to the target: + +$$ +x - T(\xi) = x - T(z_x+\delta). +$$ + +The purpose of the singular split is to approximate this displacement accurately +near `delta=0`, while keeping the approximation expressed on the fixed source +template. Instead of keeping only the first metric term, replace the chart by its +order-`K` Taylor jet around `z_x`: + +$$ +\begin{aligned} +T(z_x+\delta) +&= T(z_x) + \sum_{1\le |\alpha|\le K} \\ +&\quad \frac{1}{\alpha!}\partial^\alpha T(z_x)\delta^\alpha \\ +&\quad + R_{K+1}(\delta). +\end{aligned} +$$ + +Substituting this jet into `x - T(z_x+delta)` gives a polynomial approximation to +the target-to-source displacement. This is the critical object to tabulate +against: + +$$ +\begin{aligned} +P_K(\delta;z_x,d) +&= d - \sum_{1\le |\alpha|\le K} \\ +&\quad \frac{1}{\alpha!}\partial^\alpha T(z_x)\delta^\alpha. +\end{aligned} +$$ + +Thus `P_K` is not an extra geometric map; it is the high-order local model of +the physical vector `x - T(xi)`. Its coefficients are exactly the chart jet at +`z_x` plus the target offset `d`. + +The singular model for the point-target kernel is then + +$$ +\begin{aligned} +G_K^{\mathrm{sing}}(x,z_x,\delta) +&= -\frac{1}{2\pi}\log |P_K(\delta;z_x,d)|. +\end{aligned} +$$ + +For an on-surface target, `d=0`. For an off-surface target, `d` carries the +normal and tangential target offset. Increasing `K` moves curvature and mixed +terms from the remainder into the singular model. If `C(t)` is a degree-`p` +Bezier curve, the fan map `T(r,t)=(1-r)V+rC(t)` is a polynomial of total degree +`p+1` in `(r,t)`, so the Taylor jet terminates once `K >= p+1`. For +rational/NURBS-derived charts, `K` is a truncation order chosen by the requested +accuracy. + +The reusable part is a family of template-space singular model integrals or +correction operators parameterized by the finite jet data +$\{\partial^\alpha T(z_x)\}$ and the target offset `d`. The runtime payload remains +map coefficients, Jacobian data, source coefficients, target-node offsets, and +high-order correction or interpolation data. + +## Metric-Field Expansion Tables + +The table-building objective is to separate fixed singular template integrals +from per-chart smooth geometry and per-target offset coefficients. The table is +not built from the metric term alone. It is built from the high-order +target-to-source displacement polynomial `P_K`. + +For a target node represented by offset coordinates `tau`, write source points +near the closest chart point as `xi = z_x + delta`. The singular distance model is + +$$ +|x-T(z_x+\delta)|^2 \approx |P_K(\delta;z_x,d)|^2. +$$ + +The coefficients of `P_K` are the runtime geometry payload: + +$$ +\mathcal J_K(z_x,d) += \left(d,\{\partial^\alpha T(z_x):1\le |\alpha|\le K\}\right). +$$ + +This includes both on-surface and off-surface targets. On-surface targets have +`d=0`; off-surface targets have nonzero `d`, which may include both normal and +tangential offset components. The metric field is only the first quadratic part +of this larger jet. In the special case `K=1` and `d=0`, the model reduces to + +$$ +\begin{aligned} +|P_1(\delta;z_x,0)|^2 &= \delta^T M(z_x)\delta, \\ +M(z_x) &= DT(z_x)^TDT(z_x). +\end{aligned} +$$ + +That special case is useful for sanity checks, but it is not the intended table +construction. The practical table construction chooses a reference jet +$\mathcal J_0$ for a geometry/target-offset bin and expands the actual jet around +it: + +$$ +\mathcal J_K(z_x,d) = \mathcal J_0 + \Delta\mathcal J(z_x,d). +$$ + +Equivalently, write the displacement polynomial as a reference model plus a +smooth perturbation: + +$$ +\begin{aligned} +P_K(\delta;z_x,d) +&= P_K^0(\delta) + \Delta P_K(\delta;z_x,d). +\end{aligned} +$$ + +Then the log kernel can be expanded around the reference displacement: + +$$ +\begin{aligned} +\log |P_K(\delta;z_x,d)| +&= \log |P_K^0(\delta)| \\ +&\quad + \frac{1}{2}\log\left(1+R_K(\delta;z_x,d)\right), +\end{aligned} +$$ + +where + +$$ +\begin{aligned} +R_K(\delta;z_x,d) +&= \frac{N_K(\delta;z_x,d)}{|P_K^0(\delta)|^2}, \\ +N_K(\delta;z_x,d) +&= 2P_K^0(\delta)\cdot\Delta P_K(\delta;z_x,d) \\ +&\quad + |\Delta P_K(\delta;z_x,d)|^2. +\end{aligned} +$$ + +When each bin is chosen so the perturbation ratio is controlled, this expression +can be expanded in powers, interpolation modes, or a low-rank basis in the jet +perturbation coefficients. The resulting singular model has the schematic form + +$$ +\begin{aligned} +G_K^{\mathrm{sing}}(x,z_x,\delta) +&\approx \sum_\alpha c_\alpha(z_x,\tau)S_\alpha(\delta;\mathcal J_0). +\end{aligned} +$$ + +Here `S_alpha` are fixed template functions for the selected reference jet, and +`c_alpha(z_x,tau)` are smooth functions of the target offset `d`, the metric +entries, and all higher-order chart derivatives included in `P_K`. For Bezier +fan charts, these jet coefficients are smooth functions of the seed point and +Bezier control points. + +For one target node and one source density expansion mode `rho_j`, the singular +contribution becomes + +$$ +\begin{aligned} +u_j^{\mathrm{sing}}(\tau) +&\approx \sum_\alpha \int_{[0,1]^2} \\ +&\quad \rho_j(\xi)c_\alpha(z_x,\tau) \\ +&\quad \times S_\alpha(\xi-z_x;\mathcal J_0)J(\xi)\,d\xi. +\end{aligned} +$$ + +If the smooth per-chart/per-target factor is also expanded in a template basis +`p_beta`, + +$$ +\begin{aligned} +c_\alpha(z_x,\tau)J(\xi) +&\approx \sum_\beta q_{\alpha\beta}(\tau)p_\beta(\xi), +\end{aligned} +$$ + +then the runtime evaluation uses precomputed source-template tables: + +$$ +\begin{aligned} +u_j^{\mathrm{sing}}(\tau) +&\approx \sum_{\alpha,\beta}q_{\alpha\beta}(\tau)T_{j\alpha\beta}. +\end{aligned} +$$ + +$$ +\begin{aligned} +T_{j\alpha\beta} +&= \int_{[0,1]^2} \\ +&\quad \rho_j(\xi)p_\beta(\xi)S_\alpha(\xi-z_x;\mathcal J_0)\,d\xi. +\end{aligned} +$$ + +The tensors `T_{j alpha beta}` are precomputed on fixed source template domains, +or tabulated over a small target-offset grid. At runtime, each folded chart and +target node only supply the coefficients `q_{alpha beta}(tau)` from smooth +jet/Jacobian fields, target offset data, and any higher-order correction or +remainder representation. + +## Gaussian Window And Asymptotic Local Part + +A more controlled near-field experiment is to split the kernel before deciding +what must be tabled. Choose a length scale `sigma`, usually tied to the local box +size or fan diameter, and a smooth radial window `w_sigma(r)` that is near one at +`r=0` and rapidly decays to numerical zero for `r` larger than a few `sigma`. +For the 2D Laplace kernel, write + +$$ +\begin{aligned} +G(x,y) &= G_{\mathrm{sing},\sigma}(x,y) + G_{\mathrm{smooth},\sigma}(x,y), \\ +G_{\mathrm{sing},\sigma}(x,y) &= w_\sigma(|x-y|)G(x,y), \\ +G_{\mathrm{smooth},\sigma}(x,y) &= \left(1-w_\sigma(|x-y|)\right)G(x,y). +\end{aligned} +$$ + +The smooth part can be handled by ordinary folded-decomposition quadrature on +the source fan: + +$$ +\begin{aligned} +u_{\mathrm{smooth},\sigma}(x) +&= \int_{[0,1]^2}G_{\mathrm{smooth},\sigma}(x,T(\xi)) \\ +&\quad \times \rho(T(\xi))J(\xi)\,d\xi. +\end{aligned} +$$ + +The singular-windowed part is numerically local: + +$$ +\begin{aligned} +u_{\mathrm{sing},\sigma}(x) +&= \int_{[0,1]^2}G_{\mathrm{sing},\sigma}(x,T(\xi)) \\ +&\quad \times \rho(T(\xi))J(\xi)\,d\xi. +\end{aligned} +$$ + +Because `G_{sing,sigma}` is compactly supported to numerical tolerance, this +term matters only when the physical target point lies inside the fan piece or +within the chosen window radius of it. For target nodes in neighboring boxes that +are outside this support, the singular local contribution is skipped and the +ordinary folded quadrature of the smooth part is sufficient. + +This changes the hard problem substantially. The windowed singular part only +needs a special local treatment on a restricted target set: + +- target points on or inside the source fan; +- target points within a few `sigma` of the fan boundary; +- target offsets whose support intersects the fan under the high-order local + displacement model `P_K`. + +Following the DMK-style strategy, this local treatment should not be ordinary +folded quadrature of the singular kernel. It should use an analytic or +semi-analytic asymptotic expansion of the windowed singular integral. In local +coordinates, the expansion is built from `P_K`, the Gaussian-window scale, and +smooth expansions of the density and Jacobian. Precomputation, if used, should +target reusable asymptotic moments/coefficient maps for this local expansion, +not a generic table for the whole near-field integral. + +There is an additional simplification for targets inside the source cut region. +Folded decomposition does not require a fixed seed; for a point target `x` inside +the cut region, choose the decomposition anchor to be `x` itself and refold the +local source panel around that target. Each resulting fan has the form + +$$ +\begin{aligned} +T_x(r,t) &= (1-r)x + rC(t). +\end{aligned} +$$ + +The point singularity is then exactly at the Duffy apex `r=0`, and the Jacobian +contributes the usual factor `r`. For the logarithmic kernel, the local integrand +has the form + +$$ +\begin{aligned} +G(x,T_x(r,t))J_x(r,t) +&\sim -\frac{1}{2\pi}\log(r|C(t)-x|)\,r\,\det(C(t)-x,C'(t)), +\end{aligned} +$$ + +which is integrable in the Duffy coordinate. This means inside-target cases may +not need a general reference-jet table at all: they can use target-centered +folded/Duffy coordinates together with the local asymptotic/windowed singular +treatment. The remaining difficult cases are targets just outside the fan, near +boundaries, or otherwise too close for the smooth quadrature but not eligible for +target-centered refolding. + +The smooth remainder no longer needs singular quadrature or local asymptotics; it +is evaluated directly with the same signed folded quadrature machinery used for +far-field source clouds. The open design choices are the window family, the scale +`sigma`, the asymptotic expansion order, and the criterion used to skip the local +singular treatment for targets outside the window support. + +For the Bezier fan map + +$$ +T(r,t) = (1-r)V + rC(t), +$$ + +the metric entries are explicit: + +$$ +\begin{aligned} +T_r &= C(t)-V, \\ +T_t &= rC'(t). +\end{aligned} +$$ + +$$ +M(r,t) = +\left[ +\begin{matrix} +|C(t)-V|^2 & r(C(t)-V)\cdot C'(t) \\ +r(C(t)-V)\cdot C'(t) & r^2|C'(t)|^2 +\end{matrix} +\right]. +$$ + +For Bezier or piecewise-Bezier CAD trims, these entries are smooth +low-parameter functions of `(r,t)`, the seed `V`, and the Bezier control points +`P_a`. They are the first terms of the larger displacement-jet payload. The +mathematical reason precomputed template tables may apply is not that `M` alone +is universal, but that the full finite jet $\mathcal J_K$, the Jacobian, and the +target offset vary smoothly across the folded-decomposition chart family. + +## Feasibility Result + +The answer is more favorable with a Gaussian-window split. Singular or nearly +singular source-folded-piece to physical-point interactions can be moved to fixed +source template domains, and the local singular treatment can be restricted to +targets inside or very close to the fan piece. For targets inside the cut region, +target-centered Duffy refolding can place the singularity at the apex. The +reusable object is therefore not a broad near-field table. It is more likely a +small set of asymptotic expansion formulas, moments, or coefficient maps for the +windowed local singular integral, plus direct folded quadrature for the smooth +remainder. + +The practical hypothesis is now about the compactness of the full point-target +payload, not only the metric field. For each near target, the relevant runtime +data is + +$$ +\mathcal J_K(z_x,d),\quad J(\xi),\quad \rho(\xi),\quad \tau, +$$ + +where $\mathcal J_K$ contains the target offset and all chart derivatives used by +`P_K`. Because Bezier fan maps have smooth, low-parameter structure away from +collapsed seed faces, these jet coefficients should vary smoothly across the +folded-decomposition cases produced by CAD-like trims. A functional expansion in +the jet data, target-offset data, and Jacobian/density factors may therefore +cover enough practical cases to make precomputed near-field tables worthwhile. +The Gaussian-window split improves the odds because the local asymptotic +treatment does not need to represent weakly near or well-separated target +interactions; those move to the smooth folded-quadrature path. Target-centered +refolding improves the odds again for interior targets because the singularity is +placed at the Duffy apex instead of being represented by a generic local jet +table. + +The open numerical question is whether the observed set of jets and target +offsets is compact or low-rank enough after binning by reference jet +$\mathcal J_0$. +If many asymptotic terms, bins, or local coefficient modes are required even +after windowing and target-centered refolding, the technique may not be +worthwhile even though the template formulation is mathematically valid. + +## Measurements + +For each geometry and interaction case, record: + +- reference value; +- ordinary pulled-forward tensor-product quadrature error; +- template singular-correction error; +- Gaussian-window smooth-part quadrature error; +- Gaussian-window singular asymptotic expansion error; +- higher-order correction/remainder approximation error after the asymptotic + local part is removed; +- sensitivity to the window scale `sigma` and support cutoff; +- dependence on quadrature order; +- dependence on target offset, including on-surface, near-surface, containing-box, + and neighbor-box target nodes; +- dependence on seed location and Bezier curve coefficients; +- size, rank, and smoothness of the local asymptotic coefficient data; +- how many window/asymptotic orders, bins, and jet modes are needed for the + observed folded-decomposition cases; +- whether metric-only organization is sufficient for any subfamily, or whether + higher-order jet coefficients dominate the correction size. +- how often source-box and neighbor-box target nodes actually require the + singular local treatment after the window-support test. +- among supported targets, how many can use target-centered Duffy refolding + instead of a more general local expansion. + +The first geometry family should be CAD-independent but CAD-realistic: quadratic +and cubic Bezier fan charts with seed locations chosen to produce positive, +negative, and folded orientations. Straight fan pieces are useful only as +low-level smoke tests. OpenCascade should be used later as a source of +stress-test Bezier/NURBS-derived fixtures, not as a dependency of the numerical +question. + +## Prototype + +`cutkit.evals.nearfield_templates` implements: + +- `FanTemplateMap2D` for the current straight-edge smoke-test fan map. +- `point_target_laplace_potential(...)` for point-target source integrals with + polynomial template densities. +- `diagonal_remainder_sample(...)` for the metric singular split. +- `run_nearfield_template_experiment(...)` for the baseline feasibility check. + +The script wrapper is: + +```bash +uv run python scripts/run_nearfield_template_experiment.py --order 12 +``` + +The current smoke-test experiment checks the exact scale law for the 2D log +kernel. If both the source fan and physical target point are scaled by `lambda`, +then + +$$ +\begin{aligned} +u_\lambda(\lambda x) +&= \lambda^2\left(u(x) - \frac{\log(\lambda)m_\rho}{2\pi}\right). +\end{aligned} +$$ + +where `m_rho` is the density-weighted signed source mass on the unscaled fan +piece. It also records diagonal remainder samples that shrink as the source and +target template coordinates coalesce and a point-target low-order-vs-reference +error. + +## Next Decision + +The idea is feasible as a CUTKIT experiment, with these limits: + +- Far-field source clouds can remain ordinary signed quadrature sources. +- Near-field correction reuse should target a Gaussian-windowed point-target + local asymptotic expansion for targets inside or very close to each fan piece. + The smooth remainder should use ordinary folded-decomposition quadrature. + Interior targets should first try target-centered Duffy refolding before falling + back to the more general local expansion. +- Volumential should still own tree/list composition; CUTKIT should export the + local geometry/operator payloads needed by those lists. diff --git a/scripts/run_nearfield_template_experiment.py b/scripts/run_nearfield_template_experiment.py new file mode 100755 index 0000000..4e5991f --- /dev/null +++ b/scripts/run_nearfield_template_experiment.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Run the folded fan near-field template experiment.""" + +from __future__ import annotations + +import argparse + +from cutkit.evals import run_nearfield_template_experiment + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--order", + type=int, + default=12, + help="Gauss-Legendre order for the target template coordinates.", + ) + parser.add_argument( + "--scale-factor", + type=float, + default=1.75, + help="Positive geometry scale factor for the log-kernel scale-law check.", + ) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + result = run_nearfield_template_experiment( + order=args.order, + scale_factor=args.scale_factor, + ) + + print("field | value") + print("--- | ---") + print(f"order | {result.order}") + print(f"near_point_target | {result.near_point_target}") + print(f"point_target_reference | {result.point_target_reference:.16e}") + print(f"point_target_low_order | {result.point_target_low_order:.16e}") + print(f"point_target_abs_error | {result.point_target_abs_error:.16e}") + print(f"signed_area | {result.signed_area:.16e}") + print(f"density_mass | {result.density_mass:.16e}") + print(f"scale_factor | {result.scale_factor:.16e}") + print(f"scaled_near_point_target | {result.scaled_near_point_target}") + print( + f"scaled_point_target_potential | {result.scaled_point_target_potential:.16e}" + ) + print( + "expected_scaled_point_target_potential | " + f"{result.expected_scaled_point_target_potential:.16e}" + ) + print(f"scaled_abs_error | {result.scaled_abs_error:.16e}") + print("") + print("delta | physical_distance | model_distance | remainder") + print("--- | --- | --- | ---") + for sample in result.diagonal_remainders: + print( + f"{sample.delta:.1e} | {sample.physical_distance:.16e} | " + f"{sample.model_distance:.16e} | {sample.remainder:.16e}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/cutkit/evals/__init__.py b/src/cutkit/evals/__init__.py index 110c262..1b9956b 100644 --- a/src/cutkit/evals/__init__.py +++ b/src/cutkit/evals/__init__.py @@ -74,6 +74,18 @@ run_poisson_galerkin_benchmark, solve_trimmed_poisson_galerkin, ) +from cutkit.evals.nearfield_templates import ( + DiagonalRemainderSample, + FanTemplateMap2D, + NearfieldTemplateExperiment, + diagonal_remainder_sample, + expected_scaled_laplace_point_potential, + laplace_log_kernel, + metric_model_distance, + point_target_laplace_potential, + run_nearfield_template_experiment, + template_density_mass, +) __all__ = [ "case_to_fixture_payload", @@ -138,6 +150,16 @@ "build_poisson_galerkin_geometry_snapshot", "run_poisson_galerkin_benchmark", "solve_trimmed_poisson_galerkin", + "DiagonalRemainderSample", + "FanTemplateMap2D", + "NearfieldTemplateExperiment", + "diagonal_remainder_sample", + "expected_scaled_laplace_point_potential", + "laplace_log_kernel", + "metric_model_distance", + "point_target_laplace_potential", + "run_nearfield_template_experiment", + "template_density_mass", "FormDslParityBenchmark", "FormDslParityRow", "FormDslMultipatchStressBenchmark", diff --git a/src/cutkit/evals/nearfield_templates.py b/src/cutkit/evals/nearfield_templates.py new file mode 100644 index 0000000..0fdd49f --- /dev/null +++ b/src/cutkit/evals/nearfield_templates.py @@ -0,0 +1,336 @@ +"""Near-field template experiments for folded 2D fan pieces.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import hypot, log, pi, sqrt +from typing import Callable + +from cutkit.quadrature import gauss_legendre_01 + +Point2D = tuple[float, float] +TemplateDensity2D = Callable[[float, float], float] +_COINCIDENT_TOL = 1.0e-14 + + +def unit_template_density(_r: float, _t: float) -> float: + """Return the constant template density used by default experiments.""" + + return 1.0 + + +def _sub(lhs: Point2D, rhs: Point2D) -> Point2D: + return (lhs[0] - rhs[0], lhs[1] - rhs[1]) + + +def _dot(lhs: Point2D, rhs: Point2D) -> float: + return lhs[0] * rhs[0] + lhs[1] * rhs[1] + + +def _cross(lhs: Point2D, rhs: Point2D) -> float: + return lhs[0] * rhs[1] - lhs[1] * rhs[0] + + +def _scale_point(point: Point2D, scale: float) -> Point2D: + return (scale * point[0], scale * point[1]) + + +@dataclass(frozen=True) +class FanTemplateMap2D: + """Straight-edge folded fan chart ``T(r, t) = (1-r)V + r C(t)``.""" + + vertex: Point2D + edge_start: Point2D + edge_end: Point2D + + def point(self, r: float, t: float) -> Point2D: + """Map ``(r, t)`` in ``[0, 1]^2`` to physical space.""" + + one_minus_t = 1.0 - t + curve_x = one_minus_t * self.edge_start[0] + t * self.edge_end[0] + curve_y = one_minus_t * self.edge_start[1] + t * self.edge_end[1] + return ( + (1.0 - r) * self.vertex[0] + r * curve_x, + (1.0 - r) * self.vertex[1] + r * curve_y, + ) + + @property + def boundary_det(self) -> float: + """Return ``det(C(t)-V, C'(t))``, constant for a straight edge.""" + + return _cross( + _sub(self.edge_start, self.vertex), + _sub(self.edge_end, self.edge_start), + ) + + @property + def signed_area(self) -> float: + """Return the oriented physical area of the fan piece.""" + + return 0.5 * self.boundary_det + + def signed_jacobian(self, r: float) -> float: + """Return the signed Jacobian of the ``(r, t)`` fan chart.""" + + return r * self.boundary_det + + def local_metric( + self, + r: float, + t: float, + ) -> tuple[tuple[float, float], tuple[float, float]]: + """Return ``DT(r,t)^T DT(r,t)`` for the fan chart.""" + + radial = _sub(self.point(1.0, t), self.vertex) + tangent_base = _sub(self.edge_end, self.edge_start) + tangent = (r * tangent_base[0], r * tangent_base[1]) + return ( + (_dot(radial, radial), _dot(radial, tangent)), + (_dot(tangent, radial), _dot(tangent, tangent)), + ) + + def scaled(self, factor: float) -> FanTemplateMap2D: + """Return the same template scaled about the origin.""" + + return FanTemplateMap2D( + vertex=_scale_point(self.vertex, factor), + edge_start=_scale_point(self.edge_start, factor), + edge_end=_scale_point(self.edge_end, factor), + ) + + +@dataclass(frozen=True) +class DiagonalRemainderSample: + """One mapped-kernel singular split sample near the diagonal.""" + + delta: float + physical_distance: float + model_distance: float + laplace_kernel: float + metric_model_kernel: float + remainder: float + + +@dataclass(frozen=True) +class NearfieldTemplateExperiment: + """Summary of one fan-chart point-target experiment.""" + + fan: FanTemplateMap2D + order: int + near_point_target: Point2D + point_target_reference: float + point_target_low_order: float + point_target_abs_error: float + scaled_near_point_target: Point2D + scaled_point_target_potential: float + expected_scaled_point_target_potential: float + scaled_abs_error: float + signed_area: float + density_mass: float + scale_factor: float + diagonal_remainders: tuple[DiagonalRemainderSample, ...] + + +def laplace_log_kernel(target: Point2D, source: Point2D) -> float: + """Return the 2D Laplace fundamental solution ``-log(|x-y|)/(2*pi)``.""" + + distance = hypot(target[0] - source[0], target[1] - source[1]) + if distance <= 0.0: + raise ValueError("Laplace log kernel is singular at coincident points") + return -log(distance) / (2.0 * pi) + + +def metric_model_distance( + fan: FanTemplateMap2D, + *, + r: float, + t: float, + delta_r: float, + delta_t: float, +) -> float: + """Return the local metric distance for a template-space displacement.""" + + metric = fan.local_metric(r, t) + distance_squared = ( + metric[0][0] * delta_r * delta_r + + 2.0 * metric[0][1] * delta_r * delta_t + + metric[1][1] * delta_t * delta_t + ) + if distance_squared <= 0.0: + raise ValueError("metric model distance must be positive") + return sqrt(distance_squared) + + +def template_density_mass( + fan: FanTemplateMap2D, + *, + order: int, + density: TemplateDensity2D | None = None, +) -> float: + """Return ``integral density(r,t) * J(r,t) dr dt`` on one fan chart.""" + + if order < 1: + raise ValueError("order must be positive") + if density is None: + density = unit_template_density + + nodes, weights = gauss_legendre_01(order) + total = 0.0 + for r, wr in zip(nodes, weights, strict=True): + jacobian = fan.signed_jacobian(r) + for t, wt in zip(nodes, weights, strict=True): + total += density(r, t) * jacobian * wr * wt + return total + + +def point_target_laplace_potential( + fan: FanTemplateMap2D, + target: Point2D, + *, + order: int, + source_density: TemplateDensity2D | None = None, +) -> float: + """Approximate a folded-piece source integral at one point target.""" + + if order < 1: + raise ValueError("order must be positive") + if source_density is None: + source_density = unit_template_density + + nodes, weights = gauss_legendre_01(order) + total = 0.0 + for r, wr in zip(nodes, weights, strict=True): + jacobian = fan.signed_jacobian(r) + for t, wt in zip(nodes, weights, strict=True): + source = fan.point(r, t) + if hypot(target[0] - source[0], target[1] - source[1]) <= _COINCIDENT_TOL: + raise ValueError( + "point target coincides with a source quadrature node; " + "use a singular point-target reference rule" + ) + total += ( + laplace_log_kernel(target, source) + * source_density(r, t) + * jacobian + * wr + * wt + ) + return total + + +def expected_scaled_laplace_point_potential( + base_potential: float, + source_mass: float, + scale_factor: float, +) -> float: + """Return the exact 2D log-kernel scale law for a scaled source and target.""" + + if scale_factor <= 0.0: + raise ValueError("scale_factor must be positive") + return scale_factor**2 * ( + base_potential - log(scale_factor) * source_mass / (2.0 * pi) + ) + + +def diagonal_remainder_sample( + fan: FanTemplateMap2D, + *, + r: float, + t: float, + delta: float, + direction: tuple[float, float] = (1.0, 0.5), +) -> DiagonalRemainderSample: + """Compare the full mapped kernel with the local metric singular model.""" + + if delta <= 0.0: + raise ValueError("delta must be positive") + delta_r = delta * direction[0] + delta_t = delta * direction[1] + source = fan.point(r, t) + target = fan.point(r + delta_r, t + delta_t) + physical_distance = hypot(target[0] - source[0], target[1] - source[1]) + model_distance = metric_model_distance( + fan, + r=r, + t=t, + delta_r=delta_r, + delta_t=delta_t, + ) + laplace_kernel = -log(physical_distance) / (2.0 * pi) + metric_model_kernel = -log(model_distance) / (2.0 * pi) + return DiagonalRemainderSample( + delta=delta, + physical_distance=physical_distance, + model_distance=model_distance, + laplace_kernel=laplace_kernel, + metric_model_kernel=metric_model_kernel, + remainder=laplace_kernel - metric_model_kernel, + ) + + +def run_nearfield_template_experiment( + *, + order: int = 12, + scale_factor: float = 1.75, +) -> NearfieldTemplateExperiment: + """Run the baseline analytic fan near-field template experiment.""" + + fan = FanTemplateMap2D( + vertex=(0.0, 0.0), + edge_start=(1.0, 0.0), + edge_end=(0.35, 0.9), + ) + near_point_target = (0.47, 0.42) + + def point_density(r: float, t: float) -> float: + return 1.0 + r - 0.5 * t + + point_reference = point_target_laplace_potential( + fan, + near_point_target, + order=max(order + 8, 16), + source_density=point_density, + ) + point_low_order = point_target_laplace_potential( + fan, + near_point_target, + order=max(order // 2, 2), + source_density=point_density, + ) + scaled_target = _scale_point(near_point_target, scale_factor) + scaled_value = point_target_laplace_potential( + fan.scaled(scale_factor), + scaled_target, + order=max(order + 8, 16), + source_density=point_density, + ) + density_mass = template_density_mass( + fan, + order=max(order + 8, 16), + density=point_density, + ) + expected_scaled = expected_scaled_laplace_point_potential( + point_reference, + density_mass, + scale_factor, + ) + remainders = tuple( + diagonal_remainder_sample(fan, r=0.43, t=0.37, delta=delta) + for delta in (1.0e-1, 1.0e-2, 1.0e-3) + ) + return NearfieldTemplateExperiment( + fan=fan, + order=order, + near_point_target=near_point_target, + point_target_reference=point_reference, + point_target_low_order=point_low_order, + point_target_abs_error=abs(point_low_order - point_reference), + scaled_near_point_target=scaled_target, + scaled_point_target_potential=scaled_value, + expected_scaled_point_target_potential=expected_scaled, + scaled_abs_error=abs(scaled_value - expected_scaled), + signed_area=fan.signed_area, + density_mass=density_mass, + scale_factor=scale_factor, + diagonal_remainders=remainders, + ) diff --git a/tests/evals/test_nearfield_templates.py b/tests/evals/test_nearfield_templates.py new file mode 100644 index 0000000..d3c60e1 --- /dev/null +++ b/tests/evals/test_nearfield_templates.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import pytest + +from cutkit.evals import ( + FanTemplateMap2D, + diagonal_remainder_sample, + expected_scaled_laplace_point_potential, + point_target_laplace_potential, + run_nearfield_template_experiment, + template_density_mass, +) +from cutkit.quadrature import gauss_legendre_01 + + +def test_fan_map_area_and_metric_are_consistent() -> None: + fan = FanTemplateMap2D( + vertex=(0.0, 0.0), + edge_start=(1.0, 0.0), + edge_end=(0.25, 0.5), + ) + + assert fan.signed_area == pytest.approx(0.25) + metric = fan.local_metric(0.5, 0.25) + assert metric[0][0] > 0.0 + assert metric[1][1] > 0.0 + assert metric[0][1] == pytest.approx(metric[1][0]) + + +def test_point_target_potential_obeys_log_kernel_scale_law() -> None: + fan = FanTemplateMap2D( + vertex=(0.0, 0.0), + edge_start=(1.0, 0.0), + edge_end=(0.35, 0.9), + ) + scale_factor = 2.25 + target = (0.8, 0.7) + value = point_target_laplace_potential(fan, target, order=6) + scaled = point_target_laplace_potential( + fan.scaled(scale_factor), + (scale_factor * target[0], scale_factor * target[1]), + order=6, + ) + expected = expected_scaled_laplace_point_potential( + value, + template_density_mass(fan, order=6), + scale_factor, + ) + + assert scaled == pytest.approx(expected, abs=1.0e-13) + + +def test_scaled_point_target_potential_uses_density_weighted_mass() -> None: + fan = FanTemplateMap2D( + vertex=(0.0, 0.0), + edge_start=(1.0, 0.0), + edge_end=(0.35, 0.9), + ) + + def source_density(r: float, t: float) -> float: + return 1.0 + r + + scale_factor = 1.4 + target = (0.8, 0.7) + value = point_target_laplace_potential( + fan, + target, + order=4, + source_density=source_density, + ) + scaled = point_target_laplace_potential( + fan.scaled(scale_factor), + (scale_factor * target[0], scale_factor * target[1]), + order=4, + source_density=source_density, + ) + expected = expected_scaled_laplace_point_potential( + value, + template_density_mass(fan, order=4, density=source_density), + scale_factor, + ) + + assert scaled == pytest.approx(expected, abs=1.0e-13) + + +def test_metric_singular_remainder_shrinks_toward_diagonal() -> None: + fan = FanTemplateMap2D( + vertex=(0.0, 0.0), + edge_start=(1.0, 0.0), + edge_end=(0.35, 0.9), + ) + coarse = diagonal_remainder_sample(fan, r=0.43, t=0.37, delta=1.0e-2) + fine = diagonal_remainder_sample(fan, r=0.43, t=0.37, delta=1.0e-4) + + assert abs(fine.remainder) < abs(coarse.remainder) + assert fine.physical_distance == pytest.approx(fine.model_distance, rel=1.0e-3) + + +def test_point_target_path_converges_for_near_disjoint_target() -> None: + fan = FanTemplateMap2D( + vertex=(0.0, 0.0), + edge_start=(1.0, 0.0), + edge_end=(0.35, 0.9), + ) + target = (0.8, 0.7) + + def density(r: float, t: float) -> float: + return 1.0 + r * t + + coarse = point_target_laplace_potential( + fan, + target, + order=4, + source_density=density, + ) + fine = point_target_laplace_potential( + fan, + target, + order=10, + source_density=density, + ) + reference = point_target_laplace_potential( + fan, + target, + order=18, + source_density=density, + ) + + assert abs(fine - reference) < abs(coarse - reference) + + +def test_point_target_path_rejects_coincident_source_node() -> None: + fan = FanTemplateMap2D( + vertex=(0.0, 0.0), + edge_start=(1.0, 0.0), + edge_end=(0.35, 0.9), + ) + nodes, _weights = gauss_legendre_01(3) + target = fan.point(nodes[1], nodes[1]) + + with pytest.raises(ValueError, match="coincides with a source quadrature node"): + point_target_laplace_potential(fan, target, order=3) + + +def test_baseline_experiment_records_feasibility_checks() -> None: + result = run_nearfield_template_experiment(order=5, scale_factor=1.5) + + assert result.scaled_abs_error < 1.0e-13 + assert result.point_target_abs_error > 0.0 + assert result.scaled_point_target_potential == pytest.approx( + result.expected_scaled_point_target_potential, + abs=1.0e-13, + ) + assert result.diagonal_remainders[-1].delta == pytest.approx(1.0e-3) + assert abs(result.diagonal_remainders[-1].remainder) < abs( + result.diagonal_remainders[0].remainder + )