The finite difference grid uses geometrically increasing layer thicknesses, following Hayne et al. (2017), Appendix A2.2 (Eqs. A31--A33).
The thermal skin depth
where
For the Moon, z_s ≈ 4–7 cm depending on the H-parameter and temperature-dependent thermal properties (Hayne et al., 2017). The grid is constructed using surface-minimum properties (~3 cm), which ensures adequate resolution near the surface where gradients are steepest.
The grid starts at
where
Layer thickness grows geometrically with depth:
where
The grid extends to a total depth of
| Parameter | Default | Description |
|---|---|---|
| 10 | Layers per skin depth | |
| 5 | Growth factor (dz[i+1] = dz[i]*(1+1/n)) | |
| 20 | Total depth in skin depths |
For the Moon, these defaults produce approximately 45 layers extending to a depth of about 1 m.
custom_layers (see heat1d.layers.DepthLayer) let you override
density and conductivity within a depth range — e.g. a thin, low-
thermal-inertia veneer on top of a coarser background. The grid above
is built entirely from the background material's properties
(planet.ks, rhos, cp0); it has no knowledge of any custom layer
on its own.
That used to matter, because apply_custom_layers assigns a layer's
properties to whichever discrete grid nodes happen to fall inside it
— it does not, by itself, insert nodes at the layer's boundaries or
blend properties for a cell that straddles one. A layer thinner than
the local cell size would be misrepresented (its conductivity applied
across a whole cell several times its true thickness, biasing the
surface's effective resistance non-monotonically with grid
refinement), and a layer landing entirely between two nodes would be
invisible to the simulation regardless of its property contrast.
Profile now closes this gap automatically: before deriving grid
spacing, cell coefficients, or default properties, it calls
heat1d.grid.insert_custom_layer_boundaries to guarantee a node at
every custom layer's z_top and z_bottom (reusing an existing node,
or merging two nearly-coincident boundaries, when they already fall
close enough together). Every cell then lies entirely inside or
entirely outside each layer, so apply_custom_layers's per-node
assignment gives every cell the exact resistance of the material it
actually contains — for a layer of any thickness or position, at any
grid coarseness (config.m/config.n/config.b). This does mean a
custom layer adds one or two extra nodes to the grid; it is otherwise
a no-op when no custom layers are given.
The one case this cannot fix is a layer thinner than its own merge
tolerance (a small fraction of the total grid depth) — it gets snapped
away as a near-duplicate node rather than resolved. Profile emits a
UserWarning automatically should this (or any other resolution
shortfall) occur.
heat1d.diagnostics.layer_resolution_check(profile) is the check this
warning is built on, and can be run directly with no simulation needed:
for every grid cell a custom layer overlaps, it compares the model's
assumed thermal resistance (dz / kc at that cell, exactly what the
solver uses) to the cell's true resistance (integrating the actual
layered material) — reporting ratio = 1.0 and
total_resistance_error ≈ 0 in the ordinary case.
heat1d.diagnostics.column_energy_budget(model) complements this with
a time-resolved, per-layer flux-divergence probe on a completed run,
confirming the interior scheme's own energy conservation is intact
independent of grid resolution (see that module's docstring for the
full picture, including a known, unrelated baseline residual at the
node nearest the surface).
What this fix does not remove: an exactly-represented layer still
needs some surrounding resolution to capture the diurnal transient
across a sharp property contrast accurately — this is ordinary
finite-difference truncation error, present (at a much smaller scale)
even for a smooth homogeneous profile, not something specific to custom
layers. For a 2mm, 25x-lower-conductivity surface veneer on Europa, the
default m=10 still gives surface temperatures several K off from a
converged answer, improving roughly monotonically and converging to
<0.1K by m~100. Where the pre-fix behavior needed m~400-800 and
converged non-monotonically (grid refinement could occasionally make
things worse, depending on where a node happened to land relative to
the layer boundary), the fixed grid converges smoothly and an order of
magnitude faster — but a very thin, sharply-contrasting layer still
benefits from checking a couple of m values against each other before
trusting the result.