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
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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
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
271
272
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
274
275
276
277
278
279
280
281
282
283
284
285
286
287
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
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
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
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
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
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
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_basic_density(basic_radii)

Set basic-node density based on basic radii.

Refreshing basic_density from the post-mass-coordinate radii keeps get_dxidr_basic consistent with the solved mesh.

Source code in src/aragog/mesh/pressure_eos.py
148
149
150
151
152
153
154
155
156
157
def set_basic_density(
    self,
    basic_radii: npt.NDArray,
) -> None:
    """Set basic-node density based on basic radii.

    Refreshing basic_density from the post-mass-coordinate radii keeps
    get_dxidr_basic consistent with the solved mesh.
    """
    self._basic_density = self.get_density_from_radii(basic_radii)

set_basic_pressure(basic_radii)

Set basic pressure based on basic radii.

Source code in src/aragog/mesh/pressure_eos.py
141
142
143
144
145
146
def set_basic_pressure(
    self,
    basic_radii: npt.NDArray,
) -> None:
    """Set basic pressure based on basic radii."""
    self._basic_pressure = self.get_pressure_from_radii(basic_radii)

set_staggered_pressure(staggered_radii)

Set staggered pressure based on staggered radii.

Source code in src/aragog/mesh/pressure_eos.py
134
135
136
137
138
139
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

get_mass_within_radii(radii) abstractmethod

Cumulative mass [kg] inside radii from a class-specific zero point.

The result has the same shape as radii.

Source code in src/aragog/mesh/pressure_eos.py
49
50
51
52
53
54
@abstractmethod
def get_mass_within_radii(self, radii: FloatOrArray) -> npt.NDArray:
    """Cumulative mass [kg] inside ``radii`` from a class-specific zero point.

    The result has the same shape as ``radii``.
    """

set_staggered_effective_density(basic_radii)

Set each cell's effective density to its mass over its volume.

Parameters:

Name Type Description Default
basic_radii NDArray

Basic-node radii [m], shape (n, 1), the faces of the cells.

required
Source code in src/aragog/mesh/pressure_eos.py
56
57
58
59
60
61
62
63
64
65
66
def set_staggered_effective_density(self, basic_radii: npt.NDArray) -> None:
    """Set each cell's effective density to its mass over its volume.

    Parameters
    ----------
    basic_radii : npt.NDArray
        Basic-node radii [m], shape (n, 1), the faces of the cells.
    """
    mass = np.diff(self.get_mass_within_radii(basic_radii), axis=0)
    volume = 4.0 / 3.0 * np.pi * np.diff(np.power(basic_radii, 3.0), axis=0)
    self._staggered_effective_density = mass / volume

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
235
236
237
238
239
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
271
272
273
274
275
276
277
278
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
321
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
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:
        self._planet_density: float = self.get_planet_density(initial_spatial)
        r_core = float(initial_spatial[0, 0])
        r_surf = float(initial_spatial[-1, 0])
        # xi runs from r_core to r_surf exactly, by construction of the mean density.
        basic_mass_coordinates = np.linspace(
            r_core, r_surf, self.settings.number_of_nodes
        ).reshape(-1, 1)

        # Each node radius solves xi(r) = xi_target with brentq, as SPIDER's
        # GetRadiusFromMassCoordinate does with Newton (eos_adamswilliamson.c:296-311).
        rho_avg = self._planet_density
        M_core = self.eos.get_mass_within_radii(np.array([r_core])).item()

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

            SPIDER's mass integral is WITHOUT 4pi (eos_adamswilliamson.c:185);
            get_mass_within_radii includes it, so divide by 4pi to match rho_avg.
            """
            M_shell_4pi = self.eos.get_mass_within_radii(np.array([r])).item() - M_core
            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
        # Bracket inset 1 m inside the boundaries to avoid floating-point
        # edge cases right at r_core / r_surf.
        r_lo = r_core + 1.0
        r_hi = r_surf - 1.0
        for j in range(1, self.settings.number_of_nodes - 1):
            xi_target = float(basic_mass_coordinates[j, 0])
            basic_coordinates[j, 0] = _radius_for_mass_coordinate(
                _xi_of_r, xi_target, r_lo, r_hi, node=j
            )
        # Zero-width cells divide by zero in the gradient operators, so two
        # nodes clamped to the same endpoint must fail here.
        if np.any(np.diff(basic_coordinates[:, 0]) <= 0.0):
            raise ValueError(
                'Non-monotonic basic mesh after mass-coordinate solve '
                '(duplicate node radii): nodes clamped to the same bracket end, '
                'or an EOS mass integral that does not increase with radius; '
                'check the structure profile.'
            )
        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),
    )
    # Refresh basic pressure and density from the final basic radii, which
    # differ from initial_spatial once the mass-coordinate solve has run.
    # The density refresh keeps get_dxidr_basic consistent with the mesh.
    self.eos.set_basic_pressure(self.basic.radii)
    self.eos.set_basic_density(self.basic.radii)

    # 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:
        self.eos.set_staggered_effective_density(self.basic.radii)
        rb = self.basic.radii[:, 0]
        staggered_coordinates = np.array(
            [
                _radius_for_mass_coordinate(
                    _xi_of_r, float(xi), rb[i], rb[i + 1], node=f'staggered {i}'
                )
                for i, xi in enumerate(staggered_mass_coordinates[:, 0])
            ]
        ).reshape(-1, 1)
    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()
    bc = getattr(parameters, 'boundary_conditions', None)
    self.top_node = getattr(bc, 'top_node', 'linear')
    check_top_node(self.top_node, getattr(bc, 'core_bc', None))
    self.top_bounds = (-np.inf, np.inf)

dxidr property

dxi/dr at basic nodes

top_ratio property

Basic over staggered spacing of the outer cell (the outer row of the transform).

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
480
481
482
483
484
485
486
487
488
489
490
491
492
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

entropy_at_basic_nodes(S)

Entropy at the basic nodes, with the top node from top_node_entropy when limited.

Raises:

Type Description
RuntimeError

When a limited node with finite cells is not between the top cell and the linear node, or leaves the table with the top cell in it and is not the linear node within 4 ulp of the table.

Source code in src/aragog/mesh/__init__.py
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
def entropy_at_basic_nodes(self, S: npt.NDArray) -> npt.NDArray:
    """Entropy at the basic nodes, with the top node from ``top_node_entropy`` when limited.

    Raises
    ------
    RuntimeError
        When a limited node with finite cells is not between the top cell and the linear
        node, or leaves the table with the top cell in it and is not the linear node within
        4 ulp of the table.
    """
    S = np.ravel(S)
    S_basic = self.quantity_at_basic_nodes(S)
    if self.top_node == 'limited':
        lo, hi = self.top_bounds
        node = float(top_node_entropy(S, self.top_ratio, lo, hi, linear=S_basic[-1]))
        ends = sorted((S[-1], S_basic[-1]))
        tol = 1e-12 * max(abs(ends[0]), abs(ends[1]), 1.0)
        # the linear row may lie outside by rounding: 4 ulp, 2 measured at ratio < 2
        near = min(node - lo, hi - node) >= -4 * np.spacing(abs(node))
        if np.isfinite(S_basic[-1]) and not (
            ends[0] - tol <= node <= ends[1] + tol
            and (
                not lo <= S[-1] <= hi or lo <= node <= hi or (node == S_basic[-1] and near)
            )
        ):
            raise RuntimeError(
                f'limited top node {node!r} is outside [{ends[0]!r}, {ends[1]!r}] (top cell, '
                f'linear node) or the table [{lo!r}, {hi!r}] that holds the top cell'
            )
        S_basic[-1] = node
    return S_basic

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
427
428
429
430
431
432
433
434
435
436
437
438
439
440
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
415
416
417
418
419
420
421
422
423
424
425
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
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
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)

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
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
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
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
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
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
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
458
459
460
461
462
463
464
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)))

get_pressure_from_radii(radii)

Computes pressure from radii by interpolating the user-supplied EOS table.

Args: radii: Radii at which to compute pressure.

Returns: Pressure at the given radii.

Source code in src/aragog/mesh/pressure_eos.py
427
428
429
430
431
432
433
434
435
436
437
438
def get_pressure_from_radii(self, radii: FloatOrArray) -> npt.NDArray:
    """Computes pressure from radii by interpolating the user-supplied EOS table.

    Args:
        radii: Radii at which to compute pressure.

    Returns:
        Pressure at the given radii.
    """
    pressure: npt.NDArray = self._interp_pressure(radii).reshape(-1, 1)

    return pressure

set_basic_density(basic_radii)

Set basic-node density based on basic radii.

Interpolate the user density table at the post-mass-coordinate basic radii so get_dxidr_basic reads density on the solved mesh.

Source code in src/aragog/mesh/pressure_eos.py
447
448
449
450
451
452
453
454
455
456
def set_basic_density(
    self,
    basic_radii: npt.NDArray,
) -> None:
    """Set basic-node density based on basic radii.

    Interpolate the user density table at the post-mass-coordinate basic
    radii so get_dxidr_basic reads density on the solved mesh.
    """
    self._basic_density = self._interp_density(basic_radii).reshape(-1, 1)

set_basic_pressure(basic_radii)

Set basic pressure based on basic radii.

Source code in src/aragog/mesh/pressure_eos.py
440
441
442
443
444
445
def set_basic_pressure(
    self,
    basic_radii: npt.NDArray,
) -> None:
    """Set basic pressure based on basic radii."""
    self._basic_pressure = self.get_pressure_from_radii(basic_radii)

set_staggered_pressure(staggered_radii)

Set staggered pressure based on staggered radii.

Source code in src/aragog/mesh/pressure_eos.py
420
421
422
423
424
425
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
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
86
87
88
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))

top_node_entropy(S, ratio, S_min=-np.inf, S_max=np.inf, xp=np, linear=None)

Limited entropy of the top basic node from the top 2 cell entropies.

The linear node is S_N + D with the half step D = (ratio/2)(S_N - S_N-1). The limited node is S_N + sign(D) M(|D|, b), with b = c (S_N - S_min) for D < 0 and b = c (S_max - S_N) for D > 0 (0 outside the table), and the smooth minimum M(a, b) = a b / (a^p + b^p)^(1/p), c = TOP_NODE_SCALE, p = TOP_NODE_POWER.

With the top cell in the table the node keeps at least 1 - c of the top cell's distance to each edge and lies between the top cell and the linear node (to rounding: where the limiter acts, a few ulp outside a linear from the matrix row). d node / d S_N lies in [1 - c, 1 + ratio/2] and d node / d S_N-1 in [-ratio/2, 0], so a cooling top cell lowers the node. With the top cell in the table the node is C^p across D = 0 and leaves the linear node by the fraction 1 - (1 + (|D|/b)^p)^(-1/p) of |D|, below 1e-3 for b >= 4|D| at p = 4. A top cell outside the table whose half step points away from it is its own node; one whose half step points into it takes b from the far edge. At the edge the half step points to, the node is continuous with slope 1 outside and 1 - c inside. An unbounded table gives the linear node. A non-finite cell among the top 2 gives NaN. Where the limiter does not act (step D, factor k exactly 1) the node is linear bit for bit, so the statements above hold to its rounding: a flat top on or next to an edge can lie up to 2 ulp outside the table for ratio < 2, and where k leaves 1 the node fell by at most 1 ulp in the sweeps measured (the tests allow 2). Every Mesh gives ratio < 2: its staggered nodes are the cell midpoints in the mesh coordinate, so ratio = 2 h_N / (h_N-1 + h_N) with h the cell widths.

Parameters:

Name Type Description Default
S array

Cell entropies, innermost first along axis 0; the last 2 are used [J/kg/K].

required
ratio float

Basic over staggered spacing of the outer cell (the outer row of the quantity transform).

required
S_min float

Entropy range of the table [J/kg/K].

-inf
S_max float

Entropy range of the table [J/kg/K].

-inf
xp module

numpy or jax.numpy.

numpy
linear float or array

The linear node as the caller computes it (the outer row of the quantity transform); S_N + D by default.

None

Returns:

Type Description
float or array

Top basic-node entropy [J/kg/K].

Raises:

Type Description
ValueError

With fewer than 2 cells.

Source code in src/aragog/mesh/__init__.py
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
def top_node_entropy(
    S, ratio: float, S_min: float = -np.inf, S_max: float = np.inf, xp=np, linear=None
):
    """Limited entropy of the top basic node from the top 2 cell entropies.

    The linear node is ``S_N + D`` with the half step ``D = (ratio/2)(S_N - S_N-1)``. The limited
    node is ``S_N + sign(D) M(|D|, b)``, with ``b = c (S_N - S_min)`` for ``D < 0`` and
    ``b = c (S_max - S_N)`` for ``D > 0`` (0 outside the table), and the smooth minimum
    ``M(a, b) = a b / (a^p + b^p)^(1/p)``, ``c = TOP_NODE_SCALE``, ``p = TOP_NODE_POWER``.

    With the top cell in the table the node keeps at least ``1 - c`` of the top cell's distance
    to each edge and lies between the top cell and the linear node (to rounding: where the limiter
    acts, a few ulp outside a ``linear`` from the matrix row). ``d node / d S_N`` lies in
    ``[1 - c, 1 + ratio/2]`` and ``d node / d S_N-1`` in ``[-ratio/2, 0]``, so a cooling top cell
    lowers the node. With the top cell in the table the node is C^p across ``D = 0`` and leaves
    the linear node by the fraction ``1 - (1 + (|D|/b)^p)^(-1/p)`` of ``|D|``, below 1e-3 for
    ``b >= 4|D|`` at ``p = 4``. A top cell outside the table whose half step points away from it
    is its own node; one whose half step points into it takes ``b`` from the far edge. At the
    edge the half step points to, the node is continuous with slope 1 outside and ``1 - c``
    inside. An unbounded table gives the linear node. A non-finite cell among the top 2 gives
    NaN. Where the limiter does not act (step ``D``, factor ``k`` exactly 1) the node is
    ``linear`` bit for bit, so the statements above hold to its rounding: a flat top on or next
    to an edge can lie up to 2 ulp outside the table for ``ratio < 2``, and where ``k`` leaves 1
    the node fell by at most 1 ulp in the sweeps measured (the tests allow 2). Every ``Mesh``
    gives ``ratio < 2``: its staggered nodes are the cell midpoints in the mesh coordinate, so
    ``ratio = 2 h_N / (h_N-1 + h_N)`` with ``h`` the cell widths.

    Parameters
    ----------
    S : array
        Cell entropies, innermost first along axis 0; the last 2 are used [J/kg/K].
    ratio : float
        Basic over staggered spacing of the outer cell (the outer row of the quantity transform).
    S_min, S_max : float
        Entropy range of the table [J/kg/K].
    xp : module
        ``numpy`` or ``jax.numpy``.
    linear : float or array, optional
        The linear node as the caller computes it (the outer row of the quantity transform);
        ``S_N + D`` by default.

    Returns
    -------
    float or array
        Top basic-node entropy [J/kg/K].

    Raises
    ------
    ValueError
        With fewer than 2 cells.
    """
    if S.shape[0] < 2:
        raise ValueError(f'the limited top node needs at least 2 cells, got {S.shape[0]}')
    D = 0.5 * ratio * (S[-1] - S[-2])
    a = xp.abs(D)
    x = xp.where(D < 0, S[-1] - S_min, S_max - S[-1])
    b = TOP_NODE_SCALE * xp.where(x < 0, 0.0, x)  # inside slope at x = 0; NaN stays
    # min(a, b) / max(a, b) by branch: autodiff takes a one-sided slope at a = b and at D = 0
    le = a <= b
    q = xp.where(le, a / xp.where(le & (b > 0), b, 1.0), b / xp.where(le, 1.0, a))
    step = xp.where(le, D, xp.where(D < 0, -b, b))
    k = (1.0 + q**TOP_NODE_POWER) ** (-1.0 / TOP_NODE_POWER)
    linear = S[-1] + D if linear is None else linear
    node = xp.where((step == D) & (k == 1.0), linear, S[-1] + step * k)
    return xp.where(xp.isfinite(S[-1] + S[-2]), node, xp.nan)