{ "cells": [ { "cell_type": "markdown", "metadata": {}, "id": "af4a9e2001e1", "source": [ "# Complex Helmholtz MHM: a resolved plane wave and variational workflow\n", "\n", "Define outgoing physical data, local Helmholtz forms, a normal-flux interface\n", "and global pressure continuity by hand. Use generic mesh, form, assembly and\n", "solve abstractions; the refinement plots refer to this same declared family.\n", "The locked `introduction` environment executes the native UFL calculations.\n", "\n", "## 1. Derive the wave and its boundary data\n", "\n", "On the unit square with unit density and modulus, choose $\\omega=10\\pi$,\n", "$d=(\\cos(\\pi/13),\\sin(\\pi/13))$ and $p_*(x)=\\exp(i\\omega d\\cdot x)$.\n", "The volume source is zero because $\\lVert d\\rVert=1$:\n", "\n", "$$\n", "\\begin{aligned}\n", "-\\Delta p-\\omega^2p&=0,\\\\\n", "\\partial_np-i\\omega p&=g,\n", "&g&=i\\omega(d\\cdot n-1)p_* \\quad\\text{on }\\partial\\Omega.\n", "\\end{aligned}\n", "$$\n", "\n", "Represent complex pressure by two interleaved real components $z=(\\Re p,\\Im p)$.\n", "The two components are algebraic coordinates, rather than a physical vector.\n", "The weak impedance contribution is $\\omega(z_1v_0-z_0v_1)$. This exact real/imaginary\n", "embedding keeps the outgoing sign. Exterior data act only on the physical\n", "boundary; local interior faces still carry the unknown negative normal gradient.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "519b2848", "metadata": {}, "outputs": [], "source": [ "from pathlib import Path\n", "import os\n", "import sys\n", "from pymhm.io.workspace import workspace_from_archive\n", "\n", "# Download verified support files; this operation does not execute them.\n", "COMPANION_URL = \"https://ipes-lncc.github.io/pymhm/downloads/4ac46f0022294400c5ffc8246fa3bcf42924e7f8dedd6ced12f57bfece4b0bcf/introductory_methods-companion.zip\"\n", "COMPANION_SHA256 = \"4ac46f0022294400c5ffc8246fa3bcf42924e7f8dedd6ced12f57bfece4b0bcf\"\n", "WORKSPACE = Path(\n", " os.environ.get(\"PYMHM_WORKSPACE\", Path.cwd() / \".pymhm-companions\" / COMPANION_SHA256)\n", ")\n", "ROOT = workspace_from_archive(COMPANION_URL, sha256=COMPANION_SHA256, directory=WORKSPACE)\n", "os.environ[\"PYMHM_WORKSPACE\"] = str(ROOT)\n", "if str(ROOT) not in sys.path:\n", " sys.path.insert(0, str(ROOT))\n", "\n", "# Explicitly prepare declared data with the downloaded Python helpers.\n", "from scripts.notebook_reproduction import notebook_workspace\n", "\n", "ROOT = notebook_workspace(\"waves/helmholtz/introductory_methods.ipynb\", directory=ROOT)\n", "root = ROOT\n", "print(\"Workspace:\", ROOT)\n", "\n", "import numpy as np\n" ] }, { "cell_type": "markdown", "metadata": {}, "id": "81bfd1ea693f", "source": [ "## 2. Write the UFL local forms and trace pairings\n", "\n", "Choose continuous local Q4 pressure on a $2\\times2$ subdivision of every\n", "macro square, and vector P2 coordinates for the complex normal-flux trace.\n", "The definition cell below writes the common volume and impedance form, source,\n", "local trial pairing and independent global test pairing:\n", "\n", "$$\n", "\\begin{aligned}\n", " a_K(p,v)&=(\\nabla p,\\nabla v)_K-\\omega^2(p,v)_K\n", " -\\langle i\\omega p,v\\rangle_{\\partial K\\cap\\partial\\Omega},\\\\\n", " b_K(\\lambda,v)&=\\langle\\lambda,v\\rangle_{\\partial K\\setminus\\partial\\Omega},\\\\\n", " c_K(p,\\mu)&=-\\langle p,\\mu\\rangle_{\\partial K\\setminus\\partial\\Omega}.\n", "\\end{aligned}\n", "$$\n", "\n", "`local.native_space` owns native coefficient ordering. `local.trace_pairings`\n", "owns geometric incidence signs. Declare the mathematical signs directly;\n", "do not implement face orientations in the notebook. There are no retained\n", "static modes at the selected nonresonant frequency. Positive frequency alone\n", "does not establish local invertibility at arbitrary frequencies.\n" ] }, { "cell_type": "code", "metadata": {}, "id": "32a7fb10003e", "execution_count": null, "outputs": [], "source": [ "import numpy as np\n", "import ufl\n", "import basix\n", "import basix.ufl\n", "from types import SimpleNamespace\n", "from pymhm import (Equation, TriangleMesh, FaceSpace, SkeletonSpace, MeshHierarchy,\n", " LocalContext, bind_interface, bind_problem, assemble, solve,\n", " MultiscaleProblem, compile_form)\n", "from pymhm.meshes.cartesian import CartesianMacroMesh\n", "from pymhm.backends.spaces import bind_space, coefficient_map\n", "from pymhm.fem.scalar.quadrilateral import qk_space, quadrilateral_quadrature\n", "from examples.minimal_flow_originals import original_diagnostics\n", "\n", "wave_frequency=10*np.pi\n", "wave_direction=np.array((np.cos(np.pi/13),np.sin(np.pi/13)))\n", "\n", "def wave_truth(points):\n", " \"\"\"Evaluate the independently specified unit-amplitude plane wave as Re/Im.\"\"\"\n", " phase=wave_frequency*(np.asarray(points)@wave_direction)\n", " return np.column_stack((np.cos(phase),np.sin(phase)))\n", "\n", "def wave_gradient(points):\n", " \"\"\"Differentiate the analytical wave in physical Cartesian coordinates.\"\"\"\n", " phase=wave_frequency*(np.asarray(points)@wave_direction)\n", " return wave_frequency*np.stack((-np.sin(phase),np.cos(phase)),axis=1)[:,:,None]*wave_direction\n", "\n", "def wave_forms(V,domain):\n", " \"\"\"Write the Helmholtz volume and outgoing physical-boundary impedance forms.\"\"\"\n", " z,v=ufl.TrialFunction(V),ufl.TestFunction(V)\n", " x=ufl.SpatialCoordinate(domain)\n", " n=ufl.FacetNormal(domain)\n", " exterior=ufl.conditional(ufl.Or(ufl.Or(ufl.lt(x[0],1e-12),ufl.gt(x[0],1-1e-12)),\n", " ufl.Or(ufl.lt(x[1],1e-12),ufl.gt(x[1],1-1e-12))),1.,0.)\n", " phase=wave_frequency*(x[0]*wave_direction[0]+x[1]*wave_direction[1])\n", " normal_factor=wave_frequency*(ufl.dot(ufl.as_vector(wave_direction),n)-1)\n", " boundary_source=ufl.as_vector((-normal_factor*ufl.sin(phase),normal_factor*ufl.cos(phase)))\n", " dx=ufl.Measure('dx',domain=domain,metadata={'quadrature_degree':16})\n", " ds=ufl.Measure('ds',domain=domain,metadata={'quadrature_degree':16})\n", " a=(ufl.inner(ufl.grad(z),ufl.grad(v))-wave_frequency**2*ufl.inner(z,v))*dx\n", " a+=wave_frequency*(z[1]*v[0]-z[0]*v[1])*exterior*ds\n", " L=ufl.inner(boundary_source,v)*exterior*ds\n", " return a,L,exterior\n", "\n", "wave_element=basix.ufl.element('Lagrange','quadrilateral',4,shape=(2,),\n", " lagrange_variant=basix.LagrangeVariant.equispaced)\n", "\n", "def wave_local(local: LocalContext):\n", " \"\"\"Declare Q4 complex pressure, P2 normal moments and outgoing exterior data.\"\"\"\n", " native=local.native_space(wave_element)\n", " z,v=ufl.TrialFunction(native.space),ufl.TestFunction(native.space)\n", " a,L,exterior=wave_forms(native.space,native.mesh)\n", " b=local.trace_pairings(lambda phi,ds:ufl.inner(phi,v)*(1-exterior)*ds)\n", " c=local.trace_pairings(lambda phi,ds:-ufl.inner(phi,z)*(1-exterior)*ds,axis='rows')\n", " local.field('pressure',native)\n", " return local.equations(a=a,L=L,b=b,c=c)\n", "\n", "def wave_errors(fields,order):\n", " \"\"\"Integrate physical relative pressure/gradient errors with independent quadrature.\"\"\"\n", " reference,weights=quadrilateral_quadrature(order)\n", " squared=np.zeros(2)\n", " for field in fields:\n", " fine=field.mesh\n", " origins=fine.points[fine.cells[:,0]]\n", " points=(origins[:,None,:]+reference[None,:,:]*fine.spacing).reshape(-1,2)\n", " cell_ids=np.repeat(np.arange(len(fine.cells)),len(reference))\n", " values,gradient=field.values_and_gradient(points,cells=cell_ids)\n", " physical_weights=(fine.areas[:,None]*weights[None,:]).ravel()\n", " squared[0]+=np.sum(physical_weights*np.sum((values-wave_truth(points))**2,axis=1))\n", " squared[1]+=np.sum(physical_weights*np.sum((gradient-wave_gradient(points))**2,axis=(1,2)))\n", " return {'pressure_relative_l2':float(np.sqrt(squared[0])),\n", " 'gradient_relative_l2':float(np.sqrt(squared[1])/wave_frequency)}\n", "\n" ] }, { "cell_type": "markdown", "metadata": {}, "id": "315ecf3f438d", "source": [ "## 3. Define the meshes and global equation, assemble and solve\n", "\n", "The multiplier is the negative physical normal gradient on interior faces.\n", "Exterior multiplier coordinates are zero placeholders because the Robin data\n", "are already in the local impedance form and load. They are not an assertion\n", "that the physical exterior flux vanishes. Fix only these placeholders to zero.\n", "The global equation sums incident pressure moments with the negative sign\n", "chosen in $c_K$; its right-hand side is zero.\n", "\n", "After `assemble` and `solve`, evaluate the named pressure fields using their\n", "executed bases. Independent quadrature orders 10 and 14 integrate physical\n", "relative pressure and broken gradient errors. The separate original-row\n", "checker measures uncondensed coefficient equations, not a physical field norm.\n" ] }, { "cell_type": "code", "metadata": {}, "id": "1f150a2748d2", "execution_count": null, "outputs": [], "source": [ "wave_macro=CartesianMacroMesh(12)\n", "wave_skeleton=SkeletonSpace(wave_macro,tuple(FaceSpace.uniform(2) for _ in wave_macro.faces),components=2)\n", "wave_hierarchy=MeshHierarchy(wave_macro,tuple(wave_macro.submesh(i,2) for i in range(len(wave_macro.cells))))\n", "wave_interface=bind_interface(wave_skeleton,convention='normal')\n", "wave_problem=bind_problem(wave_hierarchy,wave_interface,wave_local,global_equation=Equation(0,0))\n", "wave_system=assemble(wave_problem)\n", "wave_fixed={int(dof):0. for face in wave_macro.boundary_faces for dof in wave_skeleton.dofs(int(face))}\n", "wave_solution=solve(wave_system,fixed=wave_fixed)\n", "wave_pressure=wave_solution.field('pressure')\n", "wave_norms=wave_errors(wave_pressure,10)\n", "wave_norms_finer=wave_errors(wave_pressure,14)\n", "wave_original=original_diagnostics(SimpleNamespace(hybrid=wave_solution),\n", " SimpleNamespace(system=wave_system,applied_boundary=-wave_system.global_load[:wave_system.trace_size],\n", " fixed=wave_fixed,mean_weights=[],mean_values=[],constraints=[]),\n", " [{'complex_pressure_native_block':len(values)} for values in wave_solution.fields])\n", "assert wave_original['accepted']\n", "assert all(np.isclose(wave_norms[k],wave_norms_finer[k],rtol=1e-7,atol=0) for k in wave_norms)\n", "print('By-hand UFL plane-wave errors:',wave_norms,flush=True)\n", "print('Original local/global rows:',{k:v for k,v in wave_original.items() if k!='blocks'},flush=True)\n", "\n" ] }, { "cell_type": "markdown", "metadata": {}, "id": "c3991f9add46", "source": [ "## 4. Independently refine a classical Galerkin reference\n", "\n", "Use the same `wave_forms` on one globally conforming Q4 mesh, with the same\n", "outgoing physical data and no MHM condensation. `MultiscaleProblem.from_global`\n", "allows the same general assembly/solve workflow. The native adapter closes its\n", "resources explicitly; the portable coefficient map and declared field preserve\n", "the executed nodal basis for evaluation.\n", "\n", "Refine the reference on global grids 16, 32 and 64 before plotting its finest\n", "field. Both analytical norms must decrease; a fine reference remains numerical.\n" ] }, { "cell_type": "code", "metadata": {}, "id": "254b6cdac901", "execution_count": null, "outputs": [], "source": [ "def classical_wave(n):\n", " \"\"\"Independently assemble conforming Q4 Galerkin with the same outgoing UFL data.\"\"\"\n", " fine=CartesianMacroMesh(n)\n", " native=bind_space(fine,element=wave_element)\n", " try:\n", " a,L,_=wave_forms(native.space,native.mesh)\n", " problem=MultiscaleProblem.from_global(Equation(a,L),native.size)\n", " system=assemble(problem)\n", " state=solve(system)\n", " _,points=qk_space(fine,4)\n", " canonical=state.trace[coefficient_map(native)]\n", " finally:\n", " native.close()\n", " from pymhm.postprocessing.nodal import nodal_field\n", " from pymhm.postprocessing.fields import DiscreteField\n", " field=DiscreteField(nodal_field('pressure',fine,4,components=2),canonical)\n", " errors=wave_errors((field,),10)\n", " errors_finer=wave_errors((field,),14)\n", " assert all(np.isclose(errors[k],errors_finer[k],rtol=1e-7,atol=0) for k in errors)\n", " assert state.raw_residual<=1e-10\n", " return field,{'n':n,**errors,'global_compatibility_relative':state.raw_residual}\n", "\n", "wave_references=[]\n", "for n in (16,32,64):\n", " wave_reference,row=classical_wave(n)\n", " wave_references.append(row)\n", "assert all(wave_references[i+1][name]