Date: 2026-08-10 Purpose: What Database and Projector are for, how to use them, and why the projection step exists at all Module: src/physics/database
A Database is a precomputed lookup table over a structured tensor grid, evaluated by shape-function interpolation and persisted to HDF5. It moves an expensive property evaluation from the nonlinear loop to setup.
Its main user is physics/materials: normal-metal resistivity depends on temperature, field magnitude and field angle through a Kohler fit too costly to evaluate at every quadrature point in every Newton iteration. The fit is sampled once on a three-dimensional grid, projected, stored, then read by interpolation.
The module has exactly two classes:
| Class | Role |
|---|---|
| Projector | L2-projects an already sampled field: node values → B-spline coefficients → projected node values |
| Database | holds the finished table, evaluates it, saves and loads it |
The Projector does not sample anything. It consumes a mesh field the caller has already filled (mMesh->field( aField )->data()); producing those samples is the caller's job. It is build-time only: Database's mesh constructor creates it, runs it, and destroys it, and external code should not construct one.
The projector is not a fitting convenience. It exists because the derivative accessors must be usable. The solve converts a raw nodal field into the closest smooth B-spline field in the L2 metric; it does not try to improve the property model itself.
Database exposes evaluate_derivx, evaluate_derivy and evaluate_derivz as well as evaluate, and material code uses those derivatives in Newton tangents — drho/dT, drho/dB and related terms. If sampled values were stored directly and interpolated with the element's Lagrange shape functions, the table would be only C⁰: values would stay continuous across element boundaries, but derivatives would jump at every one of them. Tangents built from a saw-tooth derivative degrade Newton convergence, while the values themselves still look reasonable — which is what makes the failure hard to spot.
The projector removes the derivative jumps by doing a least-squares (L2) projection of the sampled data onto a B-spline basis and storing the node values of that smooth representation. In compute_element_matrices (cl_DatabaseProjector.cpp):
| Symbol | Meaning |
|---|---|
| mMel | element mass matrix in the Lagrange basis, Σ_k w_k Nᵀ N, scaled by ½ · element_step per dimension (the tensor grid's Jacobian) |
| mT | the T-matrix from TensorMeshFactory — Bézier extraction, mapping an element's B-spline control-point coefficients to its Lagrange nodal values |
| mBel | Tᵀ · M — maps sampled nodal values to the B-spline-side right-hand side |
| mAel | Bel · T = Tᵀ · M · T — the B-spline mass matrix |
project() assembles A and Y over all elements, solves A · X = Y for the control-point coefficients X, then writes back nodal values Fel = mT · Xel. The stored table is the smooth B-spline field sampled at the grid nodes; at order 2 that representation is C¹, so the derivative accessors return a continuous field.
If it seems odd that smoothness survives being stored as node values and evaluated with Lagrange shape functions, note that those node values are not arbitrary samples: on each element they are exactly mT · Xel, so the Lagrange polynomial reconstructs the extracted B-spline element polynomial rather than approximating it.
Two consequences worth remembering:
The build is collective in call and master-only in work, deliberately.
Every rank needs the finished material, so Database's constructor calls the Projector on all ranks (cl_Database.cpp), and the non-projecting branch pairs share / receive instead. But the work is not distributed:
Why it is built this way: the projector's system is comparatively small, and every solver it can select — MUMPS, PARDISO, SUPERLU, UMFPACK — assembles on the main process anyway. Distributing the build would add more communication overhead than it saves in compute. STRUMPACK is deliberately not offered here, for the same reason: the matrices are too small to interest it.
The solver is chosen at compile time in the Projector constructor, in this order of preference: MUMPS → PARDISO → SUPERLU → UMFPACK. If none is available the constructor raises a hard error.
Do not read the packing code as evidence of a distributed design. A producer such as Metal::populate_rho_database packs values densely over the nodes it owns, while project() reads the field positionally by node->index(). These look like two index spaces; they are one. The value vector is a full-length mesh field and Mesh assigns node->set_index(...) walking the same container, so under single ownership slot k belongs to the node whose index is k. The owner() filter in such a producer is a permanent no-op under this contract.
| Constructor | Use |
|---|---|
| Database( Mesh *, field, aProject = true, material = "" ) | build from a sampled tensor mesh; aProject = false skips the projection and shares raw values |
| Database( const string & path, const string & material ) | load from an HDF5 file, selecting the material's group |
| Database( hid_t, const string & label ) | load from an already-open HDF5 group |
The mesh constructor checks aMesh->is_tensormesh() and raises an error if the mesh is not a tensor mesh. This module only works on structured grids; that is the whole basis of the O(1) element lookup in evaluate.
evaluate locates the element by integer arithmetic on the grid config, maps the query point to parametric coordinates, evaluates the Lagrange shape functions, and contracts them with the stored node values. There is no mesh search; the remaining work is a fixed-size shape-function contraction over the element's nodes.
Clamping to min/max is the caller's responsibility — and the failure mode if you skip it is silent. element_ijk clamps the element index to the grid (cl_TensorMeshConfig.hpp), so an out-of-range query does not crash and does not index missing memory. It selects the boundary element and evaluates it at a parametric coordinate outside [-1, 1], i.e. it extrapolates, and a high-order polynomial extrapolated even slightly outside its element diverges quickly. You get a plausible-looking number that is wrong. Every production consumer clamps first — see the pattern in §6.
save( hid_t ) writes the table into the given HDF5 group. Note that the stored record carries no format-version stamp, so a consumer that caches tables on disk must decide for itself whether a file predates the current writer. The materials module does this by probing for an expected dataset before trusting a cached file.
The grid for a metal's resistivity comes from create_database_mesh in physics/materials: an order-2 tensor mesh of 95 × 35 × 37 nodes — note that Mesh's second argument is aNumNodes, not an element count, so at order 2 this is 47 × 17 × 18 elements (element_steps = node_steps × order). The node steps are { 4.0, 0.1, 5° } from the origin { 0.0, -2, 0.0 }, over axes of temperature, log₁₀ of field magnitude, and field angle, which puts the covered ranges at:
| Axis | Range |
|---|---|
| temperature | 0 … 376 K |
| log₁₀ B | −2 … 1.4, i.e. B from 0.01 to ≈ 25 T |
| angle | 0 … 180° |
Those bounds are what min( d ) and max( d ) report, and what a consumer must clamp to.
The query side (Metal::rho_table) shows the three conventions a consumer must respect:
| Pitfall | Symptom | Avoid by |
|---|---|---|
| Passing a non-tensor mesh | hard error from the Database constructor (is_tensormesh() guard) | build the grid with a TensorMeshFactory / create_database_mesh-style helper |
| Querying outside the grid | no crash — silent extrapolation off the boundary element, diverging fast | clamp to min/max first — always the caller's job |
| Forgetting the value transform | resistivity off by orders of magnitude | remember tables may store a transformed quantity (e.g. log) |
| Assuming stored == sampled | small mismatches against a direct property call | the table is the projection of the samples (§3) |
| Expecting bit-identical rebuilds | spurious "regression" on a rebuilt table | compare single-threaded, or with a tolerance (§3) |
| Trying to parallelize the build | more communication, no speed-up | the master-only design is deliberate (§4) |
| Removing the workers' empty SpMatrix | null dereference on the MPI-solver worker path | leave it; workers bind it when the selected solver uses MPI |
| Trusting a cached HDF5 file by name alone | opaque failure on an old file | probe for an expected dataset before loading |