Files
MeanField/experiments/negative_density_reference_model.py

348 lines
15 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""Independent surface-cell controls; no FEM state, solver, or limiter is changed.
For inward distance s in [0, width], assume h=kappa*s and rho=A*s**n.
Compare exact, uniformly weighted Q2 L2 projection with interpolation at MFEM's
default L2 Q2 Gauss-Legendre nodes. In one dimension Q2 means degree <= 2.
Extrema, zeros, and negative integrals use quadratic algebra, not plot sampling.
The normalized model is t**n, t=s/width. Physical density is
A*width**n*p(t). Curved metrics, tangential variation, higher terms in h,
and changes in kappa under refinement are deliberately outside this control.
Example:
python negative_density_reference_model.py --self-test
python negative_density_reference_model.py --output /tmp/surface-control --png
Only the optional PNG writer requires matplotlib; all arithmetic is stdlib.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import math
from decimal import Decimal, localcontext
from fractions import Fraction
from pathlib import Path
import unittest
INDICES = (Fraction(1), Fraction(3, 2), Fraction(3))
INVERSE_MOMENTS = ((9, -36, 30), (-36, 192, -180), (30, -180, 180))
METHODS = ("exact_l2_projection", "gauss_legendre_interpolation")
def decimal(value):
if isinstance(value, Fraction):
return Decimal(value.numerator) / Decimal(value.denominator)
return Decimal(str(value))
def nodes():
r = (Decimal(3) / 5).sqrt()
return ((1 - r) / 2, Decimal("0.5"), (1 + r) / 2)
def exact_value(t, n):
return t * t.sqrt() if n == Fraction(3, 2) else t ** int(n)
def projection(n):
"""Invert the degree-two Hilbert moment matrix with rational arithmetic."""
moments = [1 / (n + j + 1) for j in range(3)]
return tuple(sum(a * b for a, b in zip(row, moments))
for row in INVERSE_MOMENTS)
def interpolation(n):
x = nodes()
values = [exact_value(t, n) for t in x]
slope = (values[2] - values[0]) / (x[2] - x[0])
curvature = (values[2] + values[0] - 2 * values[1]) * 10 / 3
return (values[1] - slope / 2 + curvature / 4,
slope - curvature, curvature)
def coefficients(n, method):
if method == METHODS[0] or n in (1, 3):
# For n=3 the cubic nodal polynomial is the shifted Legendre P3;
# hence its remainder is also the exact L2 projection. Using that
# identity avoids classifying a rounded zero curvature as an n=1 root.
return tuple(decimal(c) for c in projection(n))
return interpolation(n)
def value(c, t):
return c[0] + t * (c[1] + t * c[2])
def integral(c, left=Decimal(0), right=Decimal(1)):
return sum(a * (right ** (j + 1) - left ** (j + 1)) / (j + 1)
for j, a in enumerate(c))
def analyze(c):
a, b, q = c
if q == 0:
roots = [] if b == 0 else [-a / b]
else:
discriminant = b * b - 4 * a * q
roots = ([] if discriminant < 0 else
sorted({(-b - discriminant.sqrt()) / (2 * q),
(-b + discriminant.sqrt()) / (2 * q)}))
unit_roots = [t for t in roots if 0 <= t <= 1]
extrema = [Decimal(0), Decimal(1)]
if q != 0 and 0 < -b / (2 * q) < 1:
extrema.append(-b / (2 * q))
minimum_at = min(extrema, key=lambda t: value(c, t))
bounds = sorted({Decimal(0), Decimal(1), *unit_roots})
negative = [(left, right) for left, right in zip(bounds, bounds[1:])
if value(c, (left + right) / 2) < 0]
return {
"roots_in_cell": unit_roots,
"minimum_at": minimum_at,
"minimum": value(c, minimum_at),
"negative_intervals": negative,
"negative_width_fraction": sum((b - a for a, b in negative), Decimal(0)),
"negative_mass": -sum((integral(c, a, b) for a, b in negative), Decimal(0)),
"cell_mean": integral(c),
"boundary_value": a,
"interior_endpoint_value": value(c, Decimal(1)),
"nonnegative_on_closed_cell": value(c, minimum_at) >= 0,
}
def as_numbers(item):
if isinstance(item, (Decimal, Fraction)):
result = float(item)
if not math.isfinite(result):
raise ValueError("Nonfinite derived output")
return result
if isinstance(item, dict):
return {k: as_numbers(v) for k, v in item.items()}
if isinstance(item, (tuple, list)):
return [as_numbers(v) for v in item]
return item
def case(n, method):
c = coefficients(n, method)
result = analyze(c)
exact_mean = 1 / (decimal(n) + 1)
if n == 1:
exact_roots, exact_minimum = "0", "0"
elif n == 3:
exact_roots, exact_minimum = "1/5-sqrt(6)/30, 1/5+sqrt(6)/30", "-1/100"
elif method == METHODS[0]:
exact_roots, exact_minimum = "(sqrt(174)-12)/30", "-2/105"
else:
exact_roots = "(-c1+sqrt(c1^2-4*c0*c2))/(2*c2); c_j from the algebraic interpolation formula"
exact_minimum = "c0 from the algebraic interpolation formula"
result.update({
"n": n, "method": method, "coefficients_c0_c1_c2": c,
"coefficients_decimal": [str(v) for v in c],
"exact_projection_coefficients": [str(v) for v in projection(n)],
"roots_in_cell_exact": exact_roots, "minimum_exact": exact_minimum,
"exact_cell_mean": exact_mean,
"mean_error": result["cell_mean"] - exact_mean,
"negative_mass_over_exact_cell_mass": result["negative_mass"] / exact_mean,
"values_at_gl_nodes": [value(c, t) for t in nodes()],
"exact_values_at_gl_nodes": [exact_value(t, n) for t in nodes()],
})
return result
def refinement_row(result, width, amplitude):
n = result["n"]
scale = amplitude * exact_value(width, n)
return {
"n": n, "method": result["method"], "width": width,
"amplitude_A": amplitude, "minimum_density": scale * result["minimum"],
"minimum_distance": width * result["minimum_at"],
"negative_width": width * result["negative_width_fraction"],
"negative_mass_per_unit_area": scale * width * result["negative_mass"],
"exact_mass_per_unit_area": scale * width * result["exact_cell_mean"],
"negative_mass_over_exact_cell_mass": result["negative_mass_over_exact_cell_mass"],
}
class ReferenceChecks(unittest.TestCase):
def test_exact_moments(self):
for n in INDICES:
c = projection(n)
for j in range(3):
self.assertEqual(sum(a / (i + j + 1) for i, a in enumerate(c)),
1 / (n + j + 1))
def test_known_coefficients(self):
self.assertEqual(projection(Fraction(1)), (0, 1, 0))
self.assertEqual(projection(Fraction(3, 2)),
(Fraction(-2, 105), Fraction(16, 35), Fraction(4, 7)))
self.assertEqual(projection(Fraction(3)),
(Fraction(1, 20), Fraction(-3, 5), Fraction(3, 2)))
def test_interpolation_and_integer_identity(self):
for n in INDICES:
c = interpolation(n)
for t in nodes():
self.assertLess(abs(value(c, t) - exact_value(t, n)), Decimal("1e-55"))
if n in (1, 3):
for actual, expected in zip(c, coefficients(n, METHODS[1])):
self.assertLess(abs(actual - expected), Decimal("1e-55"))
def test_positive_dofs_do_not_certify_polynomial(self):
for n in (Fraction(3, 2), Fraction(3)):
result = case(n, METHODS[1])
self.assertTrue(all(v > 0 for v in result["values_at_gl_nodes"]))
self.assertLess(result["minimum"], 0)
def test_roots_and_extrema(self):
for method in METHODS:
self.assertTrue(case(Fraction(1), method)["nonnegative_on_closed_cell"])
for n in (Fraction(3, 2), Fraction(3)):
result = case(n, method)
c = coefficients(n, method)
for t in result["roots_in_cell"]:
self.assertLess(abs(value(c, t)), Decimal("1e-55"))
for left, right in result["negative_intervals"]:
self.assertLess(value(c, (left + right) / 2), 0)
self.assertGreater(result["negative_mass"], 0)
cubic = case(Fraction(3), METHODS[0])
self.assertEqual(cubic["minimum_at"], Decimal("0.2"))
self.assertEqual(cubic["minimum"], Decimal("-0.01"))
self.assertGreater(cubic["boundary_value"], 0)
self.assertEqual(cubic["roots_in_cell"],
[Decimal(".2") - Decimal(6).sqrt() / 30,
Decimal(".2") + Decimal(6).sqrt() / 30])
def test_gauss_mean_and_nonconservation_of_interpolation(self):
weights = (Decimal(5) / 18, Decimal(4) / 9, Decimal(5) / 18)
for n in INDICES:
mean = sum(w * exact_value(t, n) for w, t in zip(weights, nodes()))
self.assertLess(abs(mean - integral(interpolation(n))), Decimal("1e-55"))
self.assertLess(case(Fraction(3, 2), METHODS[1])["mean_error"], Decimal("-1e-4"))
def test_refinement_scales(self):
for n in (Fraction(3, 2), Fraction(3)):
for method in METHODS:
result = case(n, method)
coarse = refinement_row(result, Decimal(1), Decimal(7))
fine = refinement_row(result, Decimal(".5"), Decimal(7))
factor = exact_value(Decimal(".5"), n)
for key, expected in (("minimum_density", factor),
("negative_width", Decimal(".5")),
("negative_mass_per_unit_area", factor / 2)):
self.assertLess(abs(fine[key] / coarse[key] - expected), Decimal("1e-55"))
def write_csv(path, rows):
rows = [as_numbers(row) for row in rows]
with path.open("w", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
writer.writeheader()
for row in rows:
writer.writerow({k: json.dumps(v) if isinstance(v, (list, dict)) else v
for k, v in row.items()})
def plot(output, results, samples):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 3, figsize=(12, 6), constrained_layout=True)
ts = [i / (samples - 1) for i in range(samples)]
for column, n in enumerate(INDICES):
for row in range(2):
ax = axes[row, column]
ax.plot(ts, [t ** float(n) for t in ts], "k", label="Exact density")
for result in results:
if result["n"] != n:
continue
c = [float(v) for v in result["coefficients_c0_c1_c2"]]
ax.plot(ts, [value(c, t) for t in ts],
"--" if result["method"] == METHODS[1] else "-",
label="GL interpolation" if result["method"] == METHODS[1] else "L2 projection")
ax.axhline(0, color="0.7", linewidth=0.8)
ax.scatter([float(t) for t in nodes()],
[float(exact_value(t, n)) for t in nodes()], s=18, color="k")
ax.set_title(f"n = {float(n):g}" + ("; near-surface detail" if row else ""))
ax.set_xlabel("Inward distance / cell width")
if row:
ax.set_xlim(0, 0.35)
ax.set_ylim(-0.027, 0.075 if n == 3 else 0.23)
if column == 0:
ax.set_ylabel("Density / (A width^n)")
axes[0, 0].legend(fontsize=8)
fig.suptitle("Uniform 1D surface-cell model: positive nodal data can yield a negative polynomial")
fig.savefig(output / "profiles.png", dpi=170)
plt.close(fig)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, help="Fresh directory for JSON and CSV artifacts")
parser.add_argument("--self-test", action="store_true")
parser.add_argument("--png", action="store_true", help="Also plot profiles (requires matplotlib)")
parser.add_argument("--widths", nargs="+", default=["1", ".5", ".25", ".125"])
parser.add_argument("--amplitude", default="1", help="A in rho=A*s^n")
parser.add_argument("--samples", type=int, default=501, help="Plot/CSV samples, not extrema checks")
args = parser.parse_args()
widths, amplitude = [Decimal(x) for x in args.widths], Decimal(args.amplitude)
if any(not x.is_finite() or x <= 0 for x in (*widths, amplitude)):
parser.error("Widths and amplitude must be finite and positive")
if args.samples < 2 or (args.png and args.output is None):
parser.error("At least two samples are required; --png requires --output")
with localcontext() as context:
context.prec = 70
if args.self_test:
test_result = unittest.TextTestRunner(verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(ReferenceChecks))
if not test_result.wasSuccessful():
raise SystemExit(1)
if args.output is None:
return
results = [case(n, method) for n in INDICES for method in METHODS]
report = {
"schema": "negative_density_reference_model_v1",
"producer_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"model": "h=kappa*s; rho=A*s^n; t=s/width in [0,1]; uniform physical weight",
"scope": "Analytic mechanism control, not a fit or accuracy certificate for the 3D star",
"arithmetic": "Exact rational projection; 70-digit decimal algebraic interpolation/roots",
"gl_nodes": nodes(), "gl_nodes_exact": "(1-sqrt(3/5))/2, 1/2, (1+sqrt(3/5))/2",
"node_source": "MFEM L2_FECollection defaults to BasisType::GaussLegendre; Q2 tensor element",
"projection_derivation": "c=H^-1 b; H_ij=1/(i+j+1); b_i=1/(n+i+1), i,j=0,1,2",
"interpolation_derivation": "v_j=t_j^n; b=(v_+-v_-)/sqrt(3/5); c=(10/3)*(v_++v_--2v_0); p=v_0+b*(t-.5)+c*(t-.5)^2",
"negative_interval_convention": "Interior points are strictly negative; endpoints are roots except a negative physical-cell endpoint",
"refinement": "rho=A*width^n*p(t); negative mass/area scales width^(n+1); relative first-cell negativity need not disappear",
"cases": results,
}
if args.output is None:
print(json.dumps(as_numbers(report), indent=2, allow_nan=False))
return
args.output.mkdir(parents=True, exist_ok=False)
write_csv(args.output / "summary.csv", results)
write_csv(args.output / "refinement.csv",
[refinement_row(result, width, amplitude) for result in results for width in widths])
profiles = []
for result in results:
for i in range(args.samples):
t = Decimal(i) / (args.samples - 1)
profiles.append({"n": result["n"], "method": result["method"], "t": t,
"exact": exact_value(t, result["n"]),
"approximation": value(result["coefficients_c0_c1_c2"], t)})
write_csv(args.output / "profiles.csv", profiles)
if args.png:
plot(args.output, results, args.samples)
report["artifacts_sha256"] = {p.name: hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(args.output.iterdir())}
(args.output / "summary.json").write_text(json.dumps(as_numbers(report), indent=2, allow_nan=False) + "\n")
print(f"Wrote six analytic controls to {args.output}")
if __name__ == "__main__":
main()