L2_Electric — context contract#

Module mod_electric, published to applications as mod_mupro_electric with the context type type_mupro_ElectricContext and the named BC constants mupro_electric_bc_unset / mupro_electric_bc_min / mupro_electric_bc_max (library/L2_Electric/interface.f90:4-10). Depends on the L0/L1 base, FFT, log, utilities and license modules (library/L2_Electric/electric.f90:4-8).

Audience: a developer writing a new application against this solver (as apps/muFerro does), who needs to know what to put in the context before calling setup/solve, and what happens when they get it wrong.

1. What the solver computes#

The solver finds the electrostatic potential and electric field from a bound charge distribution by solving the Poisson equation with a homogeneous anisotropic permittivity in Fourier space (electric.f90:1071, electric.f90:492, electric.f90:615-619). For a thin film (flag_bulk = .false.) it adds the plate (thin-film boundary-condition) correction — the A-matrix / B-matrix general solution of Laplace’s equation in the film, following “YL’s paper on effect of substrate conditions” (electric.f90:511, electric.f90:690-893), with optional surface charge screening (electric.f90:968-993). For bulk (flag_bulk = .true.) it instead adds a uniform applied field (electric.f90:895-917). Both paths finish by evaluating the electrostatic energy density -E·P - ½ εᵢⱼEᵢEⱼ (electric.f90:1058-1061), zeroed outside the film layers k1..k2 on the film path (electric.f90:1063-1066).

⚠️ Known defect in the energy diagnostic — do not trust ε₂₂/ε₁₂ there. The call that feeds the formula passes context%epsilon(2, 3) three times — into the ε₂₂, ε₂₃ and ε₁₂ slots (electric.f90:1042-1043; the intended mapping survives in the commented block at :444-449). So epsilon(2,2) and epsilon(1,2) are never read by the energy path, and for a diagonal permittivity the ½ε₂₂E₂² term is silently missing from the reported energy. The field solve is unaffected (qq_mpi, :426, maps the diagonal correctly); only the energy number is wrong. The same argument list appears at :925-926 (film BC), where it is latent because those formulas read only ε₃₃/ε₂₃/ε₁₃. Numerical-behaviour change — awaiting the owner’s confirmation; see TASK.md. Until fixed, a contract reader must not conclude that setting epsilon(2,2) or epsilon(1,2) affects the energy. The variable-permittivity (iterative, inhomogeneous) solver is a separate context, type_InhomoElectricContext in electric_inhomo.f90:5, which extends the same base type but is not documented here.

2. The context contract table#

type_ElectricContext extends type_ElectricBase (electric.f90:87-118, electric.f90:131-149). Every component carries a default initialiser (written 2026-08-06, PR-054): that is the forward-compatibility mechanism — a field your application does not know about takes its default instead of being read undefined off the stack (electric.f90:60-86). Two kinds of default:

  • Neutral — “feature off”; an application that never sets it gets the behaviour it had before the field existed.

  • Sentinel — deliberately invalid, so validate_setup can refuse it by name rather than the solver inventing a value.

Shapes use the FFT extents from mod_lib_fft: real-space local block (Rn3, Rn2, Rn1), film-surface plane (Rn2, Rn1).

Component

Kind / shape

Required?

Default and what it means

Read by

BC

integer

Required on the film path (flag_bulk=.false.); ignored on bulk

mupro_electric_bc_unset (=0) — sentinel: “no choice made”, refused by name (electric.f90:88-91)

electric_setup A-matrix branch (electric.f90:513-541); passed to the film-BC solve (electric.f90:686, :927)

filmScreenTop

real(rdp)

Optional (film only)

0.0neutral: no screening charge at the top surface (electric.f90:92-93)

film BC application (electric.f90:927:972, :985)

filmScreenBot

real(rdp)

Optional (film only)

0.0neutral: no screening at the substrate interface (electric.f90:92-93)

film BC application (electric.f90:927:970, :990)

potential

real(rdp), pointer (Rn3,Rn2,Rn1)

Required for solve (output: potential φ)

null() — unbound; refused by validate_solve (electric.f90:94)

written by the Poisson solve (electric.f90:584), corrected by film BC (electric.f90:928)

charge

real(rdp), pointer (:,:,:)

Unused — no live reader in electric.f90 or electric_inhomo.f90

null()

nothing (legacy component; the RHS is chargeRHS)

energy

real(rdp), pointer (Rn3,Rn2,Rn1)

Required for solve (output: energy density)

null() (electric.f90:95)

written by context_electric_energy (electric.f90:1040)

elec

real(rdp), pointer (3,Rn3,Rn2,Rn1)

Required for solve (output: E-field; first index ≥ 3)

null() (electric.f90:96)

all four solve stages (electric.f90:585, :901, :928, :1044)

filmPotentialTop

real(rdp), pointer (Rn2,Rn1)

Required for film solve (applied potential map, top surface)

null() (electric.f90:97)

film BC application (electric.f90:927, as phi_cm:972, :978)

filmPotentialBot

real(rdp), pointer (Rn2,Rn1)

Required for film solve (applied potential map, bottom/substrate)

null() (electric.f90:97)

film BC application (electric.f90:927, as phi_cp:970, :977)

polarization

real(rdp), pointer (3,Rn3,Rn2,Rn1)

Required for solve (input P)

null() (electric.f90:98)

film BC (electric.f90:929) and energy (electric.f90:1045)

chargeRHS

real(rdp), pointer (Rn3,Rn2,Rn1)

Required for solve (input: Poisson RHS charge)

null() (electric.f90:99)

the Poisson solve (electric.f90:584:610)

bulkElectricField

real(rdp) (3)

Optional (bulk only)

0.0neutral: no externally applied field (electric.f90:100-101)

bulk constraint (electric.f90:900:910-912)

md

real(rdp) (6)

Optional

0.0neutral: no bulk depolarization correction (electric.f90:102)

not read by this context — only the inhomogeneous solver reads it (electric_inhomo.f90:608-610)

qq_mpi, Aelec_mpi, AelecDet_mpi, Belec_mpi

allocatables

Internal — do not set. Allocated and filled by setup (electric.f90:478, :503-505); allocated(qq_mpi) is the “setup has run” marker (electric.f90:179, :262)

unallocated

the solve chain (electric.f90:584, :686, :924)

flag_bulk

logical

Optional (selects the path)

.false.film, the more demanding path and therefore the safer default: it is the one validate_setup checks hardest (electric.f90:109-111)

validate_setup (electric.f90:198), setup early return (electric.f90:499), solve path select (electric.f90:1154), energy zeroing (electric.f90:1041, :1063)

epsilon

real(rdp) (3,3)

Required (relative permittivity tensor)

0.0sentinel: zero permittivity is unphysical and epsilon(3,3) divides under a square root, so an unset value is refused by name rather than a material being invented (electric.f90:131-136)

setup (electric.f90:492, :508), film BC (electric.f90:925-926), energy (electric.f90:1042-1043)

choice_fdm_fft

integer, pointer

Unused — marked “below is not used” (electric.f90:137-138)

null()

nothing

BC values (the validator accepts exactly 1…4, electric.f90:56-58): 1 = electric displacement Dₙ=0 at both surfaces (electric.f90:760, :968); 2 = given electric potential at both surfaces (electric.f90:772, :976); 3 = mixed, potential at bottom / Dₙ=0 at top (electric.f90:785, :982); 4 = mixed, Dₙ=0 at bottom / potential at top (electric.f90:798, :988). A BC = 5 (“finite-size b.c.”) appears in dead branches (electric.f90:548, :736, :994) but is refused by the validator (mupro_electric_bc_max = 4, electric.f90:58).

3. The validator contract#

setup and solve take only context and have nowhere to put a status, so the validators are a separate entry point the caller invokes first to learn why before committing (electric.f90:169-172; TASK.md:227).

Why the split is where it is: every check a validator makes corresponds to something its routine actually reads (electric.f90:154-168, :245-255; TASK.md:1352). The converse is not guaranteed — the validators do not cover every read. electric_setup reads epsilon, BC, flag_bulk and the FFT globals (Cn*, mqk*_3, and on the film path mqk*_2 for rk_mpi at :508, plus h1/h2 and dz for the layer geometry, :514-570) to build qq_mpi and the A/B matrices — it dereferences none of the field pointers, so you may bind potential/elec/etc. after setup and before solve. The mqk*_2 allocation and the solve-time BC range are checked as of 2026-08-06 (both gaps were found by this document’s own fact-check); h1/h2/dz are plain module values with no unbound state to detect. The solve chain (context_electric_heterogeneous, context_electric_apply_film_bc / ..._apply_bulk_constraint, context_electric_energy; electric.f90:1149-1160) dereferences the field pointers and the setup products, so validate_solve demands those. A component declared but never read (charge, md, choice_fdm_fft) is demanded by neither.

validate_setup (electric.f90:173-243) — checks in order, with exact refusal messages#

#

Check

Status code

Message

1

qq_mpi not already allocated (setup allocates it unconditionally, so a second setup is an allocation error, electric.f90:158-160)

mupro_status_not_setup

electric setup has already run on this context

2

Cn1/Cn2/Cn3 > 0

mupro_status_not_setup

electric setup requires FFT dimensions to be initialized

3

mqk1_3/mqk2_3/mqk3_3 associated

mupro_status_not_setup

electric setup requires FFT wave-vector arrays

4

film only: Hn1/Hn2/Rn3 > 0

mupro_status_not_setup

electric film setup requires half-complex dimensions

5

film only: BC chosen

mupro_status_invalid_argument

electric film setup requires a boundary condition; set context%BC to one of 1, 2, 3, 4 before setup

6

film only: BC in 1…4

mupro_status_invalid_argument

electric film setup: context%BC is out of range; the supported boundary conditions are 1, 2, 3 and 4

7

epsilon all finite

mupro_status_invalid_argument

electric setup requires finite permittivity

8

epsilon(3,3) > 0 — it divides under sqrt((e11*k1²+e22*k2²)/e33) (electric.f90:508)

mupro_status_invalid_argument

electric setup requires positive epsilon(3,3); it divides under a square root

9

epsilon(1,1) 0 and epsilon(2,2) 0 — a negative numerator makes the sqrt argument negative and rk_mpi silently NaN (electric.f90:165-168)

mupro_status_invalid_argument

electric setup requires non-negative epsilon(1,1) and epsilon(2,2)

Checks 5-6 are asked only on the film path because bulk setup returns before the A-matrix branch (electric.f90:499, :205-208); demanding a BC of a bulk context would refuse valid code (tests/unit/test_l2_electric.f90:146-154).

Note setup itself still terminates by name (not via status) in two cases even if you skip the validator: FFT not set up (mupro_require_fft, electric.f90:408) and, since PR-054, an out-of-range BC reaching the A-matrix branch (mupro_fatal, electric.f90:533-541).

validate_solve (electric.f90:256-314) — checks in order#

#

Check

Status code

Message

1

qq_mpi allocated (setup has run)

mupro_status_not_setup

electric solve requires setup before solve

2

Rn1/Rn2/Rn3 > 0

mupro_status_not_setup

electric solve requires FFT dimensions to be initialized

3

potential, energy, chargeRHS associated

mupro_status_null_pointer

electric solve requires associated <name>

4

each shaped (Rn3, Rn2, Rn1) (electric.f90:316-332)

mupro_status_invalid_shape

electric <name> shape does not match FFT real dimensions

5

elec, polarization associated

mupro_status_null_pointer

electric solve requires associated <name>

6

each shaped (≥3, Rn3, Rn2, Rn1) (electric.f90:334-350)

mupro_status_invalid_shape

electric <name> shape does not match FFT real dimensions

7

film only: Aelec_mpi/AelecDet_mpi/Belec_mpi allocated

mupro_status_not_setup

electric film solve requires the setup's A and B matrices

8

film only: filmPotentialTop/filmPotentialBot associated

mupro_status_null_pointer

electric film solve requires associated filmPotentialTop and filmPotentialBot

9

film only: both shaped (Rn2, Rn1)

mupro_status_invalid_shape

electric film potentials must be shaped (Rn2, Rn1)

The list is derived from what the four solve-chain routines dereference, not from the type definition (electric.f90:245-255). Film arrays are demanded only on the film path: a bulk solve never reaches the boundary-condition routine, so demanding them would reject a valid bulk context (electric.f90:286-288).

4. Minimal working example#

Based on the unit test’s own runtime bring-up and prepare_context helper (tests/unit/test_l2_electric.f90:26-31, :63-79) — everything not assigned here is deliberately left at its default, which is the point of the defaults.

program minimal_electric
  use mod_base_double_precision, only: isp, rdp
  use mod_mupro_runtime
  use mod_mupro_error
  use mod_mupro_electric, only: type_mupro_ElectricContext
  implicit none

  type(type_mupro_RuntimeContext) :: runtime
  type(type_mupro_ElectricContext) :: context
  type(type_mupro_Status) :: status
  integer :: i

  ! The FFT must exist before electric setup (validate_setup checks 2-4).
  call mupro_runtime_default(runtime)
  call mupro_runtime_setup_size(runtime, 4_isp, 4_isp, 4_isp, 0_isp, 0_isp, &
      1.0_rdp, 1.0_rdp, 1.0_rdp, status)
  if (.not. mupro_status_ok(status)) stop 1
  call mupro_runtime_setup_fft(runtime, status)
  if (.not. mupro_status_ok(status)) stop 1

  ! Only what has no safe default: a permittivity (sentinel 0 otherwise) ...
  context%epsilon = 0.0_rdp
  do i = 1, 3
    context%epsilon(i, i) = 1.0_rdp
  end do
  ! ... and, because flag_bulk defaults to .false. (film), a boundary
  ! condition. 1 = electric displacement Dn=0 at both surfaces.
  context%BC = 1

  call context%validate_setup(status)
  if (.not. mupro_status_ok(status)) then
    print *, trim(status%message)
    stop 1
  end if
  call context%setup()

  ! Before solve: bind potential/energy/chargeRHS (Rn3,Rn2,Rn1),
  ! elec/polarization (3,Rn3,Rn2,Rn1), filmPotentialTop/Bot (Rn2,Rn1),
  ! then call context%validate_solve(status) and context%solve().
  call mupro_runtime_finalize(runtime, status)
end program

For the bulk variant set context%flag_bulk = .true. and skip BC entirely — the validator will not ask for it (tests/unit/test_l2_electric.f90:146-154).

5. Failure modes with evidence#

Real defects from this module’s history. Each is the reason one of the checks or defaults above exists.

  1. backward2_mpi returned an identically zero field (PR-030, fixed 2026-07-28). fftwSize2D = 1/(nx*ny) was integer division, always 0, from 2022-11-11 to 2026-07-28 (practical exposure window 2023-02-01 → 2026-07-28). This module’s film path is a direct consumer: the plate solution returns through four backward2_mpi calls (electric.f90:869-872), so the thin-film boundary-condition correction was silently zero. Bulk electric (flag_bulk — what apps/muFerro sets) is provably unaffected because setup and solve return before the film branch (electric.f90:499, :1154-1156). Film electric remains listed as exposed in the historical-results review. (TASK.md:997, TASK.md:1001-1006)

  2. The BC A-matrix branch had no else (found by PR-054, 2026-08-06). The branch selects one of four A-matrices for BC 1…4 (electric.f90:513-532); any other value fell through, left Aelec_mpi holding whatever allocate returned, and the determinant was taken from uninitialised memory (electric.f90:543). muFerro never hit it only because it sets flag_bulk, which returns earlier — every thin-film application was exposed. Now: sentinel default BC = mupro_electric_bc_unset (electric.f90:91), a named validate_setup refusal (electric.f90:209-222), and a terminating else (electric.f90:533-541). The unit test itself went red on the new else because it had been calling the film setup with BC = 0 all along (tests/unit/test_l2_electric.f90:74-77; TASK.md:1424-1425)

  3. Undefined context components on every muFerro run (PR-054). No component of this type had a default initialiser and no pointer had => null(). muFerro assigns 10 of Electric’s 20 components, so BC, filmScreenTop, filmScreenBot, md, charge and choice_fdm_fft were undefined on every run, read off the stack. Intel’s -init=arrays -init=zero debug flags made them read as zero and hid it; gfortran and nvfortran do not, and nvfortran is the production GPU compiler (project rule 8). associated() on those pointers was itself undefined, so an application could not even check its own context. The defaults in the table above are the fix; test_defaults_are_neutral pins each one (electric.f90:63-75; tests/unit/test_l2_electric.f90:87-121; TASK.md:1422)

  4. electric_setup read FFT globals with no precondition check (fixed PR-051/PR-052). It reads them 14 times; calling it before the FFT existed allocated from zeroed Cn* extents and then indexed into the result — undefined behaviour surfacing far from the cause. Now mupro_require_fft terminates by name (electric.f90:404-408) and validate_setup reports the same condition non-fatally (electric.f90:185-196). (TASK.md:1267)

  5. The inhomogeneous path’s license verdict was computed and discarded (fixed PR-028). electric_inhomo.f90 called check_validity("electric") and threw the result away, and had no MUPRODEV gate at all — so it ran the check in developer builds and enforced nothing in release builds. Now gated and acted on (electric_inhomo.f90:60-68). (TASK.md:977-978)

  6. This module’s committed test key material leaked into the CPack source package (W2 finding). library/L2_Electric/tests/key/ was among the directories a source tarball would carry — including pri_dev.pem, the developer signing key — because CPACK_SOURCE_IGNORE_FILES excluded only VCS directories. Fixed in the packaging, not in this module; the directory still exists here and project rule 4 applies: never read, print or commit its contents. (TASK.md:928, TASK.md:1451)

Appendix: helper routine#

mupro_electric_read_permittivity(table, name, permittivity) (electric.f90:352-381, exported at interface.f90:5) reads a 3×3 relative permittivity tensor for material name from a TOML table into permittivity(3,3). Signature: table is type(toml_table), pointer, intent(inout) (changed from allocatable 2023-05-10, electric.f90:359); name is character(len=*), intent(in); permittivity is real(kind=rdp), intent(out), dimension(3,3). Malformed rows are reported by print only — it does not stop, and absent entries leave permittivity unwritten (electric.f90:362-380).