Skip to content

Mesh

mesh

Mesh subpackage: staggered mesh, fixed mesh, and pressure EOS classes.

AdamsWilliamsonEOS(settings, basic_radii)

Bases: EOS

Adams-Williamson equation of state (EOS).

Matches SPIDER's eos_adamswilliamson.c: exponential density profile

rho(z) = rhos * exp(beta * z)

where z = R_surf - r is depth. The parameter beta [1/m] is passed directly from the config (adams_williamson_beta), NOT derived from the adiabatic bulk modulus K_S. This ensures the density profile is identical to SPIDER's when both codes use the same beta.

The previous implementation used a rational (hyperbolic) form rho = rhos * K_S / (K_S - rhosgdepth) derived from K_S, which differs from SPIDER's exponential by up to 6% at CMB depth, causing a 3% R_int mismatch for the same target planet mass.

Source code in src/aragog/mesh/pressure_eos.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __init__(
    self,
    settings: _MeshParameters,
    basic_radii: npt.NDArray,
):
    self._settings: _MeshParameters = settings
    self._basic_radii: npt.NDArray = basic_radii
    self._outer_boundary = np.max(basic_radii)
    self._inner_boundary = np.min(basic_radii)
    self._surface_density: float = self._settings.surface_density
    self._gravitational_acceleration: float = self._settings.gravitational_acceleration
    self._adiabatic_bulk_modulus: float = self._settings.adiabatic_bulk_modulus
    self._surface_pressure: float = self._settings.surface_pressure
    # Use beta from config if set (> 0); fall back to K_S-derived value
    beta_cfg = float(getattr(self._settings, 'adams_williamson_beta', 0.0))
    if beta_cfg > 0:
        self._beta = beta_cfg
    else:
        self._beta = (
            self._surface_density
            * self._gravitational_acceleration
            / self._adiabatic_bulk_modulus
        )
    self._basic_pressure = self.get_pressure_from_radii(basic_radii)
    self._basic_density = self.get_density_from_radii(basic_radii)
    self._staggered_effective_density = self.get_effective_density(basic_radii)

basic_density property

Density at basic nodes

basic_pressure property

Pressure at basic nodes

staggered_effective_density property

Effective density at staggered nodes

staggered_pressure property

Pressure at staggered nodes

get_density(pressure)

Computes density from pressure (SPIDER parity):

\[ \rho(P) = \rho_s + P \beta / g \]

Matches SPIDER's eos_adamswilliamson.c:GetRho (line 116): rho = rhos - P * beta / gravity where gravity is negative.

Args: pressure: Pressure

Returns: Density

Source code in src/aragog/mesh/pressure_eos.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
def get_density(self, pressure: FloatOrArray) -> npt.NDArray:
    r"""Computes density from pressure (SPIDER parity):

    $$
    \rho(P) = \rho_s + P \beta / g
    $$

    Matches SPIDER's eos_adamswilliamson.c:GetRho (line 116):
    ``rho = rhos - P * beta / gravity`` where gravity is negative.

    Args:
        pressure: Pressure

    Returns:
        Density
    """
    density: npt.NDArray = (
        self._surface_density + pressure * self._beta / self._gravitational_acceleration
    )

    return density

get_density_from_radii(radii)

Computes density from radii using SPIDER-parity exponential form:

\[ \rho(r) = \rho_s \exp(\beta (R_s - r)) \]

where \(\rho_s\) is surface density, \(\beta\) is the Adams-Williamson parameter [1/m], and \(R_s\) is the surface radius. Matches SPIDER's eos_adamswilliamson.c via the chain rho(P(r)).

Args: radii: Radii

Returns Density

Source code in src/aragog/mesh/pressure_eos.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def get_density_from_radii(self, radii: FloatOrArray) -> FloatOrArray:
    r"""Computes density from radii using SPIDER-parity exponential form:

    $$
        \rho(r) = \rho_s \exp(\beta (R_s - r))
    $$

    where $\rho_s$ is surface density, $\beta$ is the Adams-Williamson
    parameter [1/m], and $R_s$ is the surface radius. Matches SPIDER's
    eos_adamswilliamson.c via the chain rho(P(r)).

    Args:
        radii: Radii

    Returns
        Density
    """
    depth = self._outer_boundary - radii
    density: FloatOrArray = self._surface_density * np.exp(self._beta * depth)

    return density

get_effective_density(radii)

Computes effective density using density rho(r) integration over a spherical shell bounded by radii

Args: radii: Radii array

Returns: Effective Density array

Source code in src/aragog/mesh/pressure_eos.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def get_effective_density(self, radii) -> npt.NDArray:
    r"""
    Computes effective density using density rho(r) integration
    over a spherical shell bounded by radii

    Args:
        radii: Radii array

    Returns:
        Effective Density array
    """

    mass_shell = self.get_mass_within_shell(radii)
    volume_shell = 4 / 3 * np.pi * (np.power(radii[1:], 3.0) - np.power(radii[:-1], 3.0))

    return mass_shell / volume_shell

get_mass_element(radii)

Computes the mass element:

\[ \frac{\delta m}{\delta r} = 4 \pi r^2 \rho \]

where \(\delta m\) is the mass element, \(r\) is radius, and \(\rho\) is density.

Args: radii: Radii

Returns: The mass element at radii

Source code in src/aragog/mesh/pressure_eos.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def get_mass_element(self, radii: FloatOrArray) -> npt.NDArray:
    r"""Computes the mass element:

    $$
    \frac{\delta m}{\delta r} = 4 \pi r^2 \rho
    $$

    where $\delta m$ is the mass element, $r$ is radius, and $\rho$ is
    density.

    Args:
        radii: Radii

    Returns:
        The mass element at radii
    """
    mass_element: npt.NDArray = (
        4 * np.pi * np.square(radii) * self.get_density_from_radii(radii)
    )

    return mass_element

get_mass_within_radii(radii)

Computes mass within radii using SPIDER-parity exponential form:

\[ m(r) = 4\pi \int r^2 \rho_s \exp(\beta (R_s - r)) dr \]

The antiderivative is (matching SPIDER eos_adamswilliamson.c:191):

\[ M(r) = 4\pi \rho(r) \left( -\frac{2}{\beta^3} - \frac{r^2}{\beta} - \frac{2r}{\beta^2} \right) \]

where rho(r) = rhos * exp(beta*(R_s - r)).

Args: radii: Radii

Returns: Mass within radii (from inner_boundary to radii)

Source code in src/aragog/mesh/pressure_eos.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def get_mass_within_radii(self, radii: FloatOrArray) -> npt.NDArray:
    r"""Computes mass within radii using SPIDER-parity exponential form:

    $$
    m(r) = 4\pi \int r^2 \rho_s \exp(\beta (R_s - r)) dr
    $$

    The antiderivative is (matching SPIDER eos_adamswilliamson.c:191):

    $$
    M(r) = 4\pi \rho(r) \left( -\frac{2}{\beta^3} - \frac{r^2}{\beta}
           - \frac{2r}{\beta^2} \right)
    $$

    where rho(r) = rhos * exp(beta*(R_s - r)).

    Args:
        radii: Radii

    Returns:
        Mass within radii (from inner_boundary to radii)
    """
    beta = self._beta

    def mass_integral(r: FloatOrArray) -> npt.NDArray:
        rho = self.get_density_from_radii(r)
        return 4.0 * np.pi * rho * (-2.0 / beta**3 - r**2 / beta - 2.0 * r / beta**2)

    mass: npt.NDArray = mass_integral(radii) - mass_integral(self._inner_boundary)

    return mass

get_mass_within_shell(radii)

Computes the mass within spherical shells bounded by radii.

Args: radii: Radii

Returns: Mass within the bounded spherical shells

Source code in src/aragog/mesh/pressure_eos.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
def get_mass_within_shell(self, radii: npt.NDArray) -> npt.NDArray:
    """Computes the mass within spherical shells bounded by radii.

    Args:
        radii: Radii

    Returns:
        Mass within the bounded spherical shells
    """
    mass: npt.NDArray = self.get_mass_within_radii(radii[1:]) - self.get_mass_within_radii(
        radii[:-1]
    )

    return mass

get_pressure_from_radii(radii)

Computes pressure from radii using SPIDER-parity exponential form:

\[ P(r) = \frac{\rho_s g}{\beta} \left( \exp(\beta (R_s - r)) - 1 \right) + P_{surf} \]

Matches SPIDER's eos_adamswilliamson.c:GetPressureFromRadius. Note: g is positive here (unlike SPIDER where gravity is negative).

Parameters:

Name Type Description Default
radii FloatOrArray

Radii at which to compute pressure.

required

Returns:

Type Description
NDArray

Pressure at the given radii.

Source code in src/aragog/mesh/pressure_eos.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def get_pressure_from_radii(self, radii: FloatOrArray) -> npt.NDArray:
    r"""Computes pressure from radii using SPIDER-parity exponential form:

    $$
    P(r) = \frac{\rho_s g}{\beta} \left( \exp(\beta (R_s - r)) - 1 \right)
          + P_{surf}
    $$

    Matches SPIDER's eos_adamswilliamson.c:GetPressureFromRadius.
    Note: g is positive here (unlike SPIDER where gravity is negative).

    Parameters
    ----------
    radii : FloatOrArray
        Radii at which to compute pressure.

    Returns
    -------
    npt.NDArray
        Pressure at the given radii.
    """
    depth = self._outer_boundary - radii
    pressure: npt.NDArray = (
        self._surface_density
        * self._gravitational_acceleration
        / self._beta
        * (np.exp(self._beta * depth) - 1.0)
        + self._surface_pressure
    )

    return pressure

get_pressure_gradient(pressure)

Computes the pressure gradient:

\[ \frac{dP}{dr} = -g \rho \]

where \(\rho\) is density, \(P\) is pressure, and \(g\) is gravitational acceleration.

Args: pressure: Pressure

Returns: Pressure gradient

Source code in src/aragog/mesh/pressure_eos.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def get_pressure_gradient(self, pressure: FloatOrArray) -> npt.NDArray:
    r"""Computes the pressure gradient:

    $$
    \frac{dP}{dr} = -g \rho
    $$

    where $\rho$ is density, $P$ is pressure, and  $g$ is gravitational
    acceleration.

    Args:
        pressure: Pressure

    Returns:
        Pressure gradient
    """
    dPdr: npt.NDArray = -self._gravitational_acceleration * self.get_density(pressure)

    return dPdr

get_radii_from_pressure(pressure)

Computes radii from pressure (SPIDER parity):

\[ r(P) = R_s - \frac{1}{\beta} \ln\left(1 + \frac{P \beta}{\rho_s g}\right) \]

Inverse of get_pressure_from_radii.

Args: pressure: Pressure

Returns: Radii

Source code in src/aragog/mesh/pressure_eos.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def get_radii_from_pressure(self, pressure: FloatOrArray) -> npt.NDArray:
    r"""Computes radii from pressure (SPIDER parity):

    $$
    r(P) = R_s - \frac{1}{\beta} \ln\left(1 + \frac{P \beta}{\rho_s g}\right)
    $$

    Inverse of get_pressure_from_radii.

    Args:
        pressure: Pressure

    Returns:
        Radii
    """
    radii: npt.NDArray = (
        self._outer_boundary
        - np.log(
            1.0
            + (pressure - self._surface_pressure)
            * self._beta
            / (self._surface_density * self._gravitational_acceleration)
        )
        / self._beta
    )

    return radii

set_staggered_pressure(staggered_radii)

Set staggered pressure based on staggered radii.

Source code in src/aragog/mesh/pressure_eos.py
103
104
105
106
107
108
def set_staggered_pressure(
    self,
    staggered_radii: npt.NDArray,
) -> None:
    """Set staggered pressure based on staggered radii."""
    self._staggered_pressure = self.get_pressure_from_radii(staggered_radii)

EOS

Bases: ABC

Generic EOS class

FixedMesh(settings, radii, mass_radii, outer_boundary, inner_boundary) dataclass

A fixed mesh

Args: settings: Mesh parameters radii: Radii of the mesh mass_radii: Mass coordinates of the mesh outer_boundary: Outer boundary for computing depth below the surface inner_boundary: Inner boundary for computing height above the base

Attributes: settings: Mesh parameters radii: Radii of the mesh mass_radii: Mass coordinates of the mesh outer_boundary: Outer boundary for computing depth below the surface inner_boundary: Inner boundary for computing height above the base area: Surface area delta_mesh: Delta radii in mass coordinates depth: Depth below the outer boundary height: Height above the inner boundary mixing_length: Mixing length mixing_length_squared: Mixing length squared mixing_length_cubed: Mixing length cubed number_of_nodes: Number of nodes volume: Volume of the spherical shells defined between neighbouring radii total_volume: Total volume

area cached property

Area

Mesh(parameters)

A staggered mesh.

The basic mesh is used for the flux calculations and the staggered mesh is used for the volume calculations.

Args: parameters: Parameters

Source code in src/aragog/mesh/__init__.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def __init__(self, parameters: Parameters):

    # STEP 1: Set up the basic mesh
    self.settings: _MeshParameters = parameters.mesh
    # Start with uniform spatial spacing to initialize EOS/density
    initial_spatial: npt.NDArray = self.get_constant_spacing()
    if self.settings.eos_method == 1:
        # Analytic Adams-Williamson: rho(P) = rho_top * exp(P/K_S)
        # under constant gravity; the density anchor is configured
        # via ``surface_density``. Standalone-only; PROTEUS production
        # runs go through ``eos_method = 2``.
        self.eos = AdamsWilliamsonEOS(self.settings, initial_spatial)
    elif self.settings.eos_method == 2:
        # User-defined EOS = lookup table loaded from
        # ``mesh.eos_file`` (a four-column ``r, P, rho, g`` profile).
        # In the PROTEUS-coupled path this file is written by the
        # structure solver: Zalmoxis (PALEOS-driven) writes
        # ``zalmoxis_output.dat``; the dummy-static and SPIDER paths
        # write ``spider_mesh.dat``. The four columns also drive a
        # per-node gravity profile (overrides the scalar
        # ``gravitational_acceleration``).
        self.eos = UserDefinedEOS(self.settings, initial_spatial)
    else:
        msg: str = 'Unknown method to initialize Equation of State'
        raise ValueError(msg)

    if parameters.mesh.mass_coordinates:
        # Compute planet density and mass coordinates from the initial spatial grid
        self._planet_density: float = self.get_planet_density(initial_spatial)
        initial_mass_coordinates: npt.NDArray = (
            self.get_basic_mass_coordinates_from_spatial_coordinates(initial_spatial)
        )

        # Create UNIFORM mass coordinate grid (this is the key change)
        xi_min = initial_mass_coordinates[0, 0]
        xi_max = initial_mass_coordinates[-1, 0]
        basic_mass_coordinates = np.linspace(
            xi_min, xi_max, self.settings.number_of_nodes
        ).reshape(-1, 1)

        # Derive NON-UNIFORM spatial coordinates from the uniform xi grid
        # by solving the mass-coordinate equation at each node via Newton
        # iteration, matching SPIDER's GetRadiusFromMassCoordinate.
        #
        # SPIDER's equation (eos_adamswilliamson.c:296-311):
        #   f(r) = (r_core^3 + 3*M_AW(r_core, r)/rho_avg)^(1/3) - xi = 0
        #
        # Newton-via-brentq avoids the O(h^4) interpolation error
        # of a PCHIP fit; on an N-point grid that error accumulated
        # to ~3% node position offsets, large enough to perturb the
        # Adams-Williamson reference state.
        from scipy.optimize import brentq

        r_core = float(initial_spatial[0, 0])
        r_surf = float(initial_spatial[-1, 0])
        rho_avg = self._planet_density

        def _xi_of_r(r: float) -> float:
            """Mass coordinate xi(r) matching SPIDER's definition.

            SPIDER's mass integral is WITHOUT 4pi (convention in
            SPIDER, see eos_adamswilliamson.c:185). Aragog's
            get_mass_within_radii includes 4pi. Divide by 4pi to
            match SPIDER, since rho_avg was also computed without
            4pi (from staggered_effective_density * delta_r^3).
            """
            M_shell_4pi = self.eos.get_mass_within_radii(np.array([r])).item()
            M_shell = M_shell_4pi / (4.0 * np.pi)
            return (r_core**3 + 3.0 * M_shell / rho_avg) ** (1.0 / 3.0)

        basic_coordinates = np.empty_like(basic_mass_coordinates)
        basic_coordinates[0, 0] = r_core  # CMB: exact
        basic_coordinates[-1, 0] = r_surf  # surface: exact
        for j in range(1, self.settings.number_of_nodes - 1):
            xi_target = float(basic_mass_coordinates[j, 0])
            # Bracket: always use the full domain [r_core, r_surf]
            # to avoid floating-point edge cases near the boundaries
            basic_coordinates[j, 0] = brentq(
                lambda r: _xi_of_r(r) - xi_target,
                r_core + 1.0,
                r_surf - 1.0,
                xtol=1.0,
                rtol=1e-12,
            )
        logger.debug('Basic mass coordinates (uniform) = %s', basic_mass_coordinates)
        logger.debug('Basic spatial coordinates (non-uniform) = %s', basic_coordinates)
    else:
        basic_coordinates = initial_spatial
        basic_mass_coordinates = initial_spatial

    self.basic: FixedMesh = FixedMesh(
        self.settings,
        basic_coordinates,
        basic_mass_coordinates,
        np.max(basic_coordinates),
        np.min(basic_coordinates),
    )

    # STEP 2: Set up the staggered mesh
    staggered_mass_coordinates: npt.NDArray = (
        self.basic.mass_radii[:-1] + 0.5 * self.basic.delta_mesh
    )
    if parameters.mesh.mass_coordinates:
        staggered_coordinates: npt.NDArray = (
            self.get_staggered_spatial_coordinates_from_mass_coordinates(
                staggered_mass_coordinates
            )
        )
    else:
        staggered_coordinates = staggered_mass_coordinates
    self.staggered: FixedMesh = FixedMesh(
        self.settings,
        staggered_coordinates,
        staggered_mass_coordinates,
        self.basic.outer_boundary,
        self.basic.inner_boundary,
    )
    self.eos.set_staggered_pressure(self.staggered.radii)

    # STEP 3: Set up the transform matrices
    if parameters.mesh.mass_coordinates:
        self._dxidr: npt.NDArray = self.get_dxidr_basic()
    else:
        self._dxidr: npt.NDArray = np.ones_like(self.basic.radii)
    self._d_dr_transform: npt.NDArray = self._get_d_dr_transform_matrix()
    self._quantity_transform: npt.NDArray = self._get_quantity_transform_matrix()

dxidr property

dxi/dr at basic nodes

d_dr_at_basic_nodes(staggered_quantity)

Determines d/dr at the basic nodes of a quantity defined at the staggered nodes.

Args: staggered_quantity: A quantity defined at the staggered nodes.

Returns: d/dr at the basic nodes

Source code in src/aragog/mesh/__init__.py
431
432
433
434
435
436
437
438
439
440
441
442
443
def d_dr_at_basic_nodes(self, staggered_quantity: npt.NDArray) -> npt.NDArray:
    """Determines d/dr at the basic nodes of a quantity defined at the staggered nodes.

    Args:
        staggered_quantity: A quantity defined at the staggered nodes.

    Returns:
        d/dr at the basic nodes
    """
    d_dr_at_basic_nodes: npt.NDArray = self._d_dr_transform.dot(staggered_quantity)
    logger.debug('d_dr_at_basic_nodes = %s', d_dr_at_basic_nodes)

    return d_dr_at_basic_nodes

get_basic_mass_coordinates_from_spatial_coordinates(basic_coordinates)

Computes mass coordinates matching SPIDER's definition.

SPIDER's mass coordinate (eos_adamswilliamson.c:296-311):

xi(r)^3 = r_core^3 + 3 * M_AW(r_core, r) / rho_avg_mantle

where M_AW is the A-W mass integral from r_core to r (without 4pi), and rho_avg_mantle is the mantle-only average density. At the CMB: xi = r_core. At the surface: xi = r_surface (by construction of rho_avg_mantle).

Args: Basic spatial coordinates

Returns: Basic mass coordinates

Source code in src/aragog/mesh/__init__.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def get_basic_mass_coordinates_from_spatial_coordinates(
    self, basic_coordinates: npt.NDArray
) -> npt.NDArray:
    """Computes mass coordinates matching SPIDER's definition.

    SPIDER's mass coordinate (eos_adamswilliamson.c:296-311):

        xi(r)^3 = r_core^3 + 3 * M_AW(r_core, r) / rho_avg_mantle

    where M_AW is the A-W mass integral from r_core to r (without
    4pi), and rho_avg_mantle is the mantle-only average density.
    At the CMB: xi = r_core. At the surface: xi = r_surface
    (by construction of rho_avg_mantle).

    Args:
        Basic spatial coordinates

    Returns:
        Basic mass coordinates
    """
    r_core = basic_coordinates[0, 0]

    # xi^3 at CMB = r_core^3
    basic_mass_coordinates = np.zeros_like(basic_coordinates)
    basic_mass_coordinates[:, :] = np.power(r_core, 3.0)

    # Cumulative mantle mass contribution.
    # staggered_effective_density * (r^3_outer - r^3_inner) gives
    # mass_shell / (4/3*pi). Dividing by rho_avg (which is
    # M_no4pi / V_no4pi = M_no4pi / ((r_s^3-r_c^3)/3)) gives
    # the correct xi^3 increment: 3 * M_shell_no4pi / rho_avg.
    basic_volumes = np.power(basic_coordinates[1:, 0], 3.0) - np.power(
        basic_coordinates[:-1, 0], 3.0
    )
    for i in range(1, self.settings.number_of_nodes):
        basic_mass_coordinates[i:, :] += (
            self.staggered_effective_density[i - 1, :]
            * basic_volumes[i - 1]
            / self._planet_density
        )

    return np.power(basic_mass_coordinates, 1.0 / 3.0)

get_constant_spacing()

Constant radius spacing across the mantle

Returns: Radii with constant spacing as a column vector

Source code in src/aragog/mesh/__init__.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
def get_constant_spacing(self) -> npt.NDArray:
    """Constant radius spacing across the mantle

    Returns:
        Radii with constant spacing as a column vector
    """
    radii: npt.NDArray = np.linspace(
        self.settings.inner_radius,
        self.settings.outer_radius,
        self.settings.number_of_nodes,
    )
    radii = np.atleast_2d(radii).T

    return radii

get_dxidr_basic()

Computes dxidr at basic nodes.

Source code in src/aragog/mesh/__init__.py
365
366
367
368
369
370
371
372
373
374
375
def get_dxidr_basic(self) -> npt.NDArray:
    """Computes dxidr at basic nodes."""

    dxidr = (
        self.eos.basic_density
        / self._planet_density
        * np.power(self.basic.radii, 2.0)
        / np.power(self.basic.mass_radii, 2.0)
    )

    return dxidr

get_planet_density(basic_coordinates)

Computes the mantle average density for mass-coordinate mapping.

Matches SPIDER's EOSAdamsWilliamson_GetMassCoordinateAverageRho (eos_adamswilliamson.c:249-267): the average density is computed over the mantle shell only (r_core to r_surface), NOT including the core. This ensures the mass-coordinate xi grid maps to the same physical radii as SPIDER's Newton-solved mesh.

The previous implementation used whole-planet density (core + mantle) / r_surface^3, which produced ~3% node position offsets and cascading 12% pressure / 20% density / 17% flux differences.

Args: Basic spatial coordinates

Returns: Mantle average density (for mass-coordinate normalization)

Source code in src/aragog/mesh/__init__.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
def get_planet_density(self, basic_coordinates: npt.NDArray) -> float:
    """Computes the mantle average density for mass-coordinate mapping.

    Matches SPIDER's ``EOSAdamsWilliamson_GetMassCoordinateAverageRho``
    (eos_adamswilliamson.c:249-267): the average density is computed
    over the mantle shell only (r_core to r_surface), NOT including
    the core. This ensures the mass-coordinate xi grid maps to the
    same physical radii as SPIDER's Newton-solved mesh.

    The previous implementation used whole-planet density (core +
    mantle) / r_surface^3, which produced ~3% node position offsets
    and cascading 12% pressure / 20% density / 17% flux differences.

    Args:
        Basic spatial coordinates

    Returns:
        Mantle average density (for mass-coordinate normalization)
    """
    r_core = basic_coordinates[0, 0]
    r_surf = basic_coordinates[-1, 0]
    # Use the analytic A-W mass integral (without 4pi, SPIDER
    # convention) for the average density. The discrete sum
    # (rho * delta_r^3) is inconsistent because the 4pi/3
    # factors don't cancel between the volume elements and
    # the effective density definition.
    M_4pi = (
        self.eos.get_mass_within_radii(np.array([[r_surf]]))
        - self.eos.get_mass_within_radii(np.array([[r_core]]))
    ).item()
    M_no4pi = M_4pi / (4.0 * np.pi)
    mantle_volume_no4pi = (np.power(r_surf, 3.0) - np.power(r_core, 3.0)) / 3.0
    mantle_avg_density = M_no4pi / mantle_volume_no4pi
    return float(mantle_avg_density)

get_staggered_spatial_coordinates_from_mass_coordinates(staggered_mass_coordinates)

Computes the staggered spatial coordinates from staggered mass coordinates.

Args: Staggered mass coordinates

Returns: Staggered spatial coordinates

Source code in src/aragog/mesh/__init__.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def get_staggered_spatial_coordinates_from_mass_coordinates(
    self, staggered_mass_coordinates: npt.NDArray
) -> npt.NDArray:
    """Computes the staggered spatial coordinates from staggered mass coordinates.

    Args:
        Staggered mass coordinates

    Returns:
        Staggered spatial coordinates
    """

    # Initialise the staggered spatial coordinate to the inner boundary
    staggered_coordinates = np.ones_like(staggered_mass_coordinates) * np.power(
        self.settings.inner_radius, 3.0
    )

    # Add first half cell contribution
    staggered_coordinates += (
        self._planet_density
        * (
            np.power(staggered_mass_coordinates[0, :], 3.0)
            - np.power(self.basic.mass_radii[0, :], 3.0)
        )
        / self.staggered_effective_density[0, :]
    )

    # Get spatial coordinates by adding individual cell contributions to the mantle mass
    shell_effective_density = 0.5 * (
        self.staggered_effective_density[1:, :] + self.staggered_effective_density[:-1, :]
    )
    shell_mass_volumes = np.power(staggered_mass_coordinates[1:, :], 3.0) - np.power(
        staggered_mass_coordinates[:-1, :], 3.0
    )
    for i in range(1, self.settings.number_of_nodes - 1):
        staggered_coordinates[i:, :] += (
            self._planet_density
            * shell_mass_volumes[i - 1, :]
            / shell_effective_density[i - 1, :]
        )

    return np.power(staggered_coordinates, 1.0 / 3.0)

quantity_at_basic_nodes(staggered_quantity)

Determines a quantity at the basic nodes that is defined at the staggered nodes.

Uses backward and forward differences at the inner and outer radius, respectively, to obtain the quantity values of the basic nodes at the innermost and outermost nodes. When using temperature boundary conditions, values at outer boundaries will be overwritten. When using flux boundary conditions, values at outer boundaries will be used to provide estimate of individual components of heat fluxes though the total heat flux is imposed.

Args: staggered_quantity: A quantity defined at the staggered nodes

Returns: The quantity at the basic nodes

Source code in src/aragog/mesh/__init__.py
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
def quantity_at_basic_nodes(self, staggered_quantity: npt.NDArray) -> npt.NDArray:
    """Determines a quantity at the basic nodes that is defined at the staggered nodes.

    Uses backward and forward differences at the inner and outer radius, respectively, to
    obtain the quantity values of the basic nodes at the innermost and outermost nodes.
    When using temperature boundary conditions, values at outer boundaries will be overwritten.
    When using flux boundary conditions, values at outer boundaries will be used to provide
    estimate of individual components of heat fluxes though the total heat flux is imposed.

    Args:
        staggered_quantity: A quantity defined at the staggered nodes

    Returns:
        The quantity at the basic nodes
    """
    quantity_at_basic_nodes: npt.NDArray = self._quantity_transform.dot(staggered_quantity)
    logger.debug('quantity_at_basic_nodes = %s', quantity_at_basic_nodes)

    return quantity_at_basic_nodes

quantity_at_staggered_nodes(basic_quantity)

Determines a quantity at the staggered nodes that is defined at the basic nodes.

Staggered nodes are always located at cell centers, whatever the mesh.

Args: basic_quantity: A quantity defined at the basic nodes

Returns: The quantity at the staggered nodes

Source code in src/aragog/mesh/__init__.py
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
def quantity_at_staggered_nodes(self, basic_quantity: npt.NDArray) -> npt.NDArray:
    """Determines a quantity at the staggered nodes that is defined at the basic nodes.

    Staggered nodes are always located at cell centers, whatever the mesh.

    Args:
        basic_quantity: A quantity defined at the basic nodes

    Returns:
        The quantity at the staggered nodes
    """
    quantity_at_staggered_nodes: npt.NDArray = 0.5 * (
        basic_quantity[:-1, ...] + basic_quantity[1:, ...]
    )
    logger.debug('quantity_at_staggered_nodes = %s', quantity_at_staggered_nodes)

    return quantity_at_staggered_nodes

UserDefinedEOS(settings, basic_radii)

Bases: EOS

User defined equation of state (EOS).

Pressure field and effective density field on staggered nodes provided by the user.

Source code in src/aragog/mesh/pressure_eos.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
def __init__(
    self,
    settings: _MeshParameters,
    basic_radii: npt.NDArray,
):
    self._interp_pressure = PchipInterpolator(settings.eos_radius, settings.eos_pressure)
    self._interp_density = PchipInterpolator(settings.eos_radius, settings.eos_density)
    self._basic_pressure = self._interp_pressure(basic_radii).reshape(-1, 1)
    basic_effective_density = self._interp_density(basic_radii).reshape(-1, 1)
    self._staggered_effective_density = 0.5 * (
        basic_effective_density[:-1, :] + basic_effective_density[1:, :]
    )
    # Assumes density and effective density are the same at basic nodes
    self._basic_density = basic_effective_density

    # Cumulative mass M(r) = integral_{r_eos[0]}^{r} 4 pi r^2 rho(r) dr,
    # precomputed on the EOS radius grid so get_mass_within_radii is O(1).
    # Anchored at r_eos[0]; differences M(r2) - M(r1) cancel the anchor.
    r_eos = np.asarray(settings.eos_radius)
    rho_eos = np.asarray(settings.eos_density)
    integrand = 4.0 * np.pi * r_eos**2 * rho_eos
    cum_mass = cumulative_trapezoid(integrand, r_eos, initial=0.0)
    self._interp_mass = PchipInterpolator(r_eos, cum_mass)

basic_density property

Effective density at basic nodes

basic_pressure property

Pressure at basic nodes

staggered_effective_density property

Effective density at staggered nodes

staggered_pressure property

Pressure at staggered nodes

get_mass_within_radii(radii)

4pi-included cumulative mass M(r), anchored at the inner EOS radius.

Mesh.get_planet_density and the mass-coordinate brentq loop use only differences M(r2) - M(r1), so the anchor cancels.

Source code in src/aragog/mesh/pressure_eos.py
378
379
380
381
382
383
384
def get_mass_within_radii(self, radii: FloatOrArray) -> npt.NDArray:
    r"""4pi-included cumulative mass M(r), anchored at the inner EOS radius.

    Mesh.get_planet_density and the mass-coordinate brentq loop use only
    differences M(r2) - M(r1), so the anchor cancels.
    """
    return np.asarray(self._interp_mass(np.asarray(radii)))

set_staggered_pressure(staggered_radii)

Set staggered pressure based on staggered radii.

Source code in src/aragog/mesh/pressure_eos.py
371
372
373
374
375
376
def set_staggered_pressure(
    self,
    staggered_radii: npt.NDArray,
) -> None:
    """Set staggered pressure based on staggered radii."""
    self._staggered_pressure = self._interp_pressure(staggered_radii).reshape(-1, 1)

derive_core_density_from_mesh(mesh_file, M_core)

Derive the self-consistent average core density from a mantle mesh file.

Reads the CMB radius from a 5-column ascending-r mantle mesh file (r, P, rho, g, T) and returns :math:\rho_\mathrm{core} = M_\mathrm{core} / (\tfrac{4}{3} \pi R_\mathrm{cmb}^3).

The first row of the file is the bottom of the mantle (CMB), matching the format Zalmoxis writes to zalmoxis_output.dat. This is the Aragog-side analogue of the SPIDER wrapper's -rho_core echo-back: the on-disk mesh's :math:R_\mathrm{cmb} and the latest hf_row['M_core'] give a self-consistent average density that survives mesh-blending fall-backs and stale-cache cases where hf_row['core_density'] has drifted from the mesh state.

Parameters:

Name Type Description Default
mesh_file str

Path to the Zalmoxis-format mantle mesh file. The first row is treated as the CMB; only the first column (r) is read.

required
M_core float

Core mass [kg]. Must be positive.

required

Returns:

Type Description
float

Self-consistent average core density [kg m^-3].

Raises:

Type Description
FileNotFoundError

If mesh_file does not exist.

ValueError

If M_core <= 0, the mesh file is empty, or the parsed :math:R_\mathrm{cmb} is non-positive.

Source code in src/aragog/mesh/__init__.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def derive_core_density_from_mesh(mesh_file: str, M_core: float) -> float:
    """Derive the self-consistent average core density from a mantle mesh file.

    Reads the CMB radius from a 5-column ascending-r mantle mesh file
    (``r``, ``P``, ``rho``, ``g``, ``T``) and returns
    :math:`\\rho_\\mathrm{core} = M_\\mathrm{core} / (\\tfrac{4}{3} \\pi R_\\mathrm{cmb}^3)`.

    The first row of the file is the bottom of the mantle (CMB), matching
    the format Zalmoxis writes to ``zalmoxis_output.dat``. This is the
    Aragog-side analogue of the SPIDER wrapper's ``-rho_core`` echo-back:
    the on-disk mesh's :math:`R_\\mathrm{cmb}` and the latest
    ``hf_row['M_core']`` give a self-consistent average density that
    survives mesh-blending fall-backs and stale-cache cases where
    ``hf_row['core_density']`` has drifted from the mesh state.

    Parameters
    ----------
    mesh_file : str
        Path to the Zalmoxis-format mantle mesh file. The first row is
        treated as the CMB; only the first column (``r``) is read.
    M_core : float
        Core mass [kg]. Must be positive.

    Returns
    -------
    float
        Self-consistent average core density [kg m^-3].

    Raises
    ------
    FileNotFoundError
        If ``mesh_file`` does not exist.
    ValueError
        If ``M_core <= 0``, the mesh file is empty, or the parsed
        :math:`R_\\mathrm{cmb}` is non-positive.
    """
    if M_core <= 0.0:
        raise ValueError(f'M_core must be positive, got {M_core}')

    with open(mesh_file) as f:
        first_line = f.readline().strip()
        if not first_line:
            raise ValueError(f'Mesh file {mesh_file} is empty')
        try:
            R_cmb = float(first_line.split()[0])
        except (IndexError, ValueError) as exc:
            raise ValueError(
                f'Could not parse R_cmb from first row of {mesh_file}: {first_line!r}'
            ) from exc

    if R_cmb <= 0.0:
        raise ValueError(f'Parsed R_cmb must be positive, got {R_cmb}')

    return float(M_core / (4.0 / 3.0 * np.pi * R_cmb**3))