L3_Ferroelectric — context contract#

Public interface: mod_mupro_ferroelectric, exporting type_mupro_ferroelectricContext (=> type_ferroelectricContext), type_mupro_inhomoFerroelectricContext, and mupro_ferroelectric_read_electrostrictive (interface.f90:1-6). Audience: a developer writing a new application against this solver, like apps/muFerro. Line references are against the current tree (2026-08-06).

1. What the solver computes#

Given a polarization field op, this module computes the material-dependent pieces of a ferroelectric phase-field model: the Landau–Devonshire bulk free-energy density and its driving force from a 2nd–8th order polynomial (ferroelectric.f90:671-729); the Ginzburg gradient energy and gradient driving force in the Hlinka–Marton form (ferroelectric.f90:751-752); the electrostrictive eigenstrain ε⁰ = Q·PP (ferroelectric.f90:509-567); the elastic driving force −∂f_elast/∂P through the coupling matrix A = C·Q built at setup (ferroelectric.f90:336-343, :355-418); and the polarization bound charge −∇·P, evaluated spectrally (ferroelectric.f90:480-497). It does not step time and does not solve mechanical or electrostatic equilibrium — those are L2_TDGL, L2_Elastic and L2_Electric, which the application composes around this module (apps/muFerro/src/solve.f90:17-64).

2. The context contract — type_mupro_ferroelectricContext#

The type extends the abstract type_ferroelectricBase (ferroelectric.f90:78-100, extension at :133-163). Every component carries a default written 2026-08-06 (PR-054): before that, none of the 106 components across the four solver context types had an initializer, and anything the application did not assign was read straight off the stack — hidden on the platform of record by Intel’s -init=zero (TASK.md:1422). muFerro assigns 29 of the 30 components; the one it cannot assign is the private Amatrix (ferroelectric.f90:73-77, :150).

Field shapes are not checked by the library (only association is, ferroelectric.f90:936-958). The shapes below are what the real caller allocates (apps/muFerro/src/setup.f90:37-62): Rn3, Rn2, Rn1 are the local-slab extents of the FFT decomposition (L0_FFT/fft.f90:98-99).

“Required” means: for setup — refused by validate_setup; for solve — refused by validate_solve; blank — never validated, read as-is.

Material scalars and tensors (ferroelectric.f90:79-80, :140-147)#

Name

Kind / shape

Required

Default and meaning

Read by

a1

real(rdp)

setup

0.0sentinel: with a11, a12 all zero, refused as “no material was set” (:889-894)

get_landau_driving_force (:578-582)

a11

real(rdp)

setup

0.0 — sentinel, same refusal (:889-894)

get_landau_driving_force (:581)

a12

real(rdp)

setup

0.0 — sentinel, same refusal (:889-894)

get_landau_driving_force (:581)

a2

real(rdp)

no

0.0neutral: term absent. NOTE: the TOML reader defaults it to a1 (cubic symmetry, :207-208); the type default does not. Code-filled contexts get no symmetry for free

get_landau_driving_force (:581)

a3

real(rdp)

no

0.0 — neutral; reader defaults to a1 (:209-210)

get_landau_driving_force (:581)

a22

real(rdp)

no

0.0 — neutral; reader defaults to a11 (:211-212)

get_landau_driving_force (:581)

a33

real(rdp)

no

0.0 — neutral; reader defaults to a11 (:213-214)

get_landau_driving_force (:581)

a23

real(rdp)

no

0.0 — neutral; reader defaults to a12 (:215-216)

get_landau_driving_force (:581)

a13

real(rdp)

no

0.0 — neutral; reader defaults to a12 (:217-218)

get_landau_driving_force (:581)

a111

real(rdp)

no

0.0 — neutral: material has no 6th-order term (:220-226)

get_landau_driving_force (:582)

a112

real(rdp)

no

0.0 — neutral, as a111

get_landau_driving_force (:582)

a123

real(rdp)

no

0.0 — neutral, as a111

get_landau_driving_force (:582)

a1111

real(rdp)

no

0.0 — neutral: no 8th-order term (:227-234)

get_landau_driving_force (:582)

a1112

real(rdp)

no

0.0 — neutral, as a1111

get_landau_driving_force (:582)

a1122

real(rdp)

no

0.0 — neutral, as a1111

get_landau_driving_force (:582)

a1123

real(rdp)

no

0.0 — neutral, as a1111

get_landau_driving_force (:582)

electrostrictive

real(rdp) (6,6)

finite only (:905-909)

0.0 — no neutral value exists for a material tensor (:135-139); zero means dead electromechanical coupling: Amatrix = 0, eigenstrain = 0, elastic driving force = 0

setup (:281), get_eigenstrain (:504)

stiffness

real(rdp) (6,6)

finite only (:911-915)

0.0 — same reasoning; zero kills the elastic driving force through Amatrix

setup (:282)

G

real(rdp) (6,6)

no

0.0neutral: “no gradient energy, a real choice” (:79)

get_gradient_energy (:747), get_gradient_driving_force (:796)

Field bindings (ferroelectric.f90:81-90) — all pointers, all => null()#

null() is the sentinel in every row: validate_solve refuses each unbound pointer by component name (:936-958, message shape at :963-972).

Name

Kind / shape

Required

Default and meaning

Read / written by

op

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

solve

null() — sentinel, refused by name

read by all six compute routines (:351, :423, :504, :580, :748, :797)

eigenstrain

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

solve

null() — sentinel

written by get_eigenstrain (:504); read by get_elastic_driving_force (:351)

strain

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

solve

null() — sentinel

read by get_elastic_driving_force (:351); filled by the Elastic solver in a real application (apps/muFerro/src/setup.f90:37)

elast

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

solve

null() — sentinel

written by get_elastic_driving_force (:351)

elec

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

solve (:945)

null() — sentinel

not read by any built routine in this module; the application consumes it — muFerro binds electricField here (apps/muFerro/src/setup/ferroelectric.f90:28) and assembles the TDGL RHS itself (apps/muFerro/src/solve.f90:60)

lan

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

solve

null() — sentinel

written by get_landau_driving_force (:579)

grad

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

solve

null() — sentinel

written by get_gradient_driving_force (:796)

boundCharge

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

solve

null() — sentinel

written by get_polarization_bound_charge (:423)

landau_energy

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

solve

null() — sentinel

written by get_landau_driving_force (:579)

grad_energy

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

solve

null() — sentinel

written by get_gradient_energy (:747)

Internal (not settable)#

Name

Kind / shape

Meaning

Amatrix

real(rdp) (6,6), private

Built by setup as C·Q (:336-343); read by get_elastic_driving_force (:351). Defaulted to 0.0 so it is defined if inspected before setup (:148-150). This is why setup must run before get_elastic_driving_force can produce anything non-zero.

Global state the solve path reads (not on the context)#

  • FFT extents Rn1..Cn3 and wave vectors mqk1_3/mqk2_3/mqk3_3 (L0_FFT/fft.f90:98, :63), set by mupro_fft_setup; used by the bound-charge path (ferroelectric.f90:481-489). muFerro’s order: mupro_mpi_setupmupro_size_setupmupro_fft_setup (apps/muFerro/src/setup.f90:90-102).

  • Slab loop bounds k1, k2 (L0_Base/size.f90:27), used by the elastic driving force loop (ferroelectric.f90:372-374).

  • Optional rotation state rotation_A / rotation_B (L1_Transform/rotate.f90:5, rotate_inhomo.f90:4): landau_routine rotates into and out of the local frame iff one of them is allocated (ferroelectric.f90:642-650, :704-712). Unallocated means identity.

The inhomogeneous variant#

type_mupro_inhomoFerroelectricContext (ferroelectric_inhomo.f90:18-26) replaces the material scalars with per-cell pointer fields. Its pointer components carry no => null() defaults and it has no validator pair — it is exactly the PR-054 defect class (TASK.md:1422) not yet closed there. Do not extrapolate this contract to it.

3. The validator contract#

Both validators are type-bound (ferroelectric.f90:161-162), report through type_mupro_Status (L0_Base/error.f90:15-22) and never terminate. setup itself does not call them — validation is the caller’s step. The only way setup aborts is a failed license check in a non-MUPRODEV build (ferroelectric.f90:269-276).

validate_setup checks the material and nothing else (:883-916):

  1. a1, a11, a12 not all zero (:889-894);

  2. a1, a11, a12 finite (:897-903);

  3. electrostrictive finite (:905-909);

  4. stiffness finite (:911-915).

validate_solve = validate_setup + grid + bindings (:923-959): it re-runs the material checks (:927), then requires Cn1/Cn2/Cn3 > 0 (:930-934), then association of op, eigenstrain, strain, elast, elec, lan, grad, landau_energy, grad_energy, boundCharge in that order (:936-958). First failure wins; you fix one refusal at a time.

Why the split is where it is (:862-877, TASK.md:1503-1505): ferroelectric_setup is the one solver setup in the library that reads no global state at all — it consumes the two tensors on the context and derives Amatrix, nothing more (:281-343). The grid and the bindings are read by the seven driving-force and energy routines, so that is where they are demanded. A first draft required FFT dimensions and every binding in validate_setup; unit.l3_ferroelectric sets up no grid, FFT or MPI precisely because setup needs none, and it went red immediately (tests/unit/test_l3_ferroelectric.f90:8-13, TASK.md:1503).

Exact refusal messages and status codes (constants in L0_Base/error.f90:6-12):

Check

status%code

status%message

all of a1,a11,a12 zero

mupro_status_invalid_argument (6)

ferroelectric setup requires Landau coefficients; a1, a11 and a12 are all still zero, which is the default meaning no material was set (:892-893)

non-finite a1/a11/a12

mupro_status_invalid_argument (6)

ferroelectric setup requires finite Landau coefficients (:901)

non-finite electrostrictive

mupro_status_invalid_argument (6)

ferroelectric setup requires a finite electrostrictive tensor (:907)

non-finite stiffness

mupro_status_invalid_argument (6)

ferroelectric setup requires a finite stiffness tensor (:913)

Cn1/Cn2/Cn3 <= 0

mupro_status_not_setup (3)

ferroelectric solve requires FFT dimensions to be initialized (:932)

any unbound pointer

mupro_status_null_pointer (1)

ferroelectric: context%<name> is not associated; point it at storage first (:969-970)

One non-zero coefficient among a1/a11/a12 passes: it means somebody chose a material (tests/unit/test_l3_ferroelectric.f90:120-126).

4. Minimal working example#

Fills the context and passes validate_setup. Values and structure follow the unit test’s fill_context helper and its validation case (tests/unit/test_l3_ferroelectric.f90:32-50, :103-109) — deliberately no grid, FFT or MPI setup, which is legal for setup (not for solve).

program minimal_ferroelectric
  use mod_mupro_base, only: rdp, type_mupro_Status, mupro_status_success
  use mod_mupro_ferroelectric, only: type_mupro_ferroelectricContext
  implicit none

  type(type_mupro_ferroelectricContext) :: context
  type(type_mupro_Status) :: status
  integer :: i

  ! Material only. Everything not assigned keeps its documented default.
  do i = 1, 6
    context%stiffness(i, i) = 1.0_rdp
    context%electrostrictive(i, i) = 2.0_rdp
  end do
  context%a1  = -1.0_rdp   ! required: all three zero is refused
  context%a11 =  2.0_rdp   ! required
  context%a12 =  0.5_rdp   ! required
  context%a111 = 4.0_rdp   ! optional: zero would mean "term absent"
  context%a112 = 5.0_rdp
  context%a123 = 6.0_rdp

  call context%validate_setup(status)
  if (status%code /= mupro_status_success) then
    print *, trim(status%message)
    stop 1
  end if

  call context%setup()   ! license-gated in non-MUPRODEV builds
end program

Before calling any get_* routine, additionally initialize the runtime (mupro_mpi_setup, mupro_size_setup, mupro_fft_setupapps/muFerro/src/setup.f90:90-102), bind all ten pointers to arrays of the shapes in §2 (complete binding sequence: apps/muFerro/src/setup/ferroelectric.f90:7-36), and gate on validate_solve.

5. Failure modes with evidence#

Real defects this module’s history produced, each with its source:

  1. Unassigned components were undefined, not zero (fixed PR-054, 2026-08-06). No component of this type had a default initializer and no pointer had => null(); anything an application did not assign was read off the stack. Intel’s -init=arrays -init=zero hid it completely; gfortran and nvfortran do not, and nvfortran is the production GPU path (TASK.md:1422). Adding the defaults changed no number on the platform of record (TASK.md:1426). muFerro assigns 29 of 30 components, which is why the defaults matter for the next application (ferroelectric.f90:73-77).

  2. The TOML reader let a1/a11/a12 go unread (fixed PR-046b, 2026-08-02). They had no default, so a document missing one left the context component untouched — undefined — and the Landau energy was computed from it (TASK.md:1335, ferroelectric.f90:178-181). They are mupro_toml_required now (:202-204). The a2=a1-style cascade is cubic symmetry, kept but announced in the log (:206-218).

  3. Same PR, on the material path feeding this context: read_tensor_rank2 read its own intent(out) argument (out_1d = reshape(out, [9]) before anything wrote out), so every component the point-group symmetry left out kept stack garbage (TASK.md:1332). The owner-approved fix is out_1d = 0.0_rdp (L1_MaterialGenerator/tensor.f90:162, TASK.md:1333).

  4. The license verdict was computed and discarded (fixed PR-028). ferroelectric_setup called check_validity and threw the result away, so release builds ran the module unlicensed (TASK.md:977). The gate now terminates on failure (ferroelectric.f90:269-276).

  5. Overbroad validation was attempted twice and caught by the unit test both times. A runtime-initialization guard was applied to ferroelectric_setup during the principle-6 work and unit.l3_ferroelectric went red — setup reads no global state (TASK.md:1270). The first draft of PR-061’s validate_setup repeated the mistake with FFT dimensions and bindings (TASK.md:1503-1505). The test now asserts the split directly: a material-only context passes validate_setup and is refused by validate_solve (tests/unit/test_l3_ferroelectric.f90:128-137).

  6. Most of this directory is not in the build. SRC is only ferroelectric.f90, ferroelectric_inhomo.f90, interface.f90 (CMakeLists.txt:3-6). evolution.f90 does not compile (it cannot see type_msolve) and contains three sites that write through never-associated pointers (msolveContext%sysArray = p1_sys) (TASK.md:1310, TASK.md:1302); LNO.f90 references the non-existent mod_fftw_mpi (CLAUDE.md, dead-code list). Do not take contract semantics from those files.