Source code for boundary_conditions

# Copyright 2025-2026 Onera
# This file is part of the Noda package
# SPDX-License-Identifier: GPL-3.0-or-later

"""Handle boundary conditions."""

import numpy as np

import noda.utils as ut
import noda.composition_variables as cv


[docs] class BoundaryConditions: """ Boundary conditions on one side of the domain (left or right). Can be either: * 'Dirichlet': prescribed composition, if 'atom_fractions' is provided in the input parameters. * 'Neumann': prescribed flux, if 'flux' is provided. Defaults to 0-flux if none is specified in the input parameters. The class is used through cvar_fun and J_fun, which are both function of time built from the input parameters, which may be a float or a str with t as the time variable (ex: (3*t + 2)**(1/2)). Attributes ---------- thermo : :class:`thermodynamics.Thermodynamics` Thermodynamic properties handler. comps : list of str System components. inds : list of str Independent components. V_partial : dict Partial molar volumes. See :func:`data_io.get_volume_data`. min_atom_fraction : float Minimum atom fraction accepted. logger : :class:`log_utils.CustomLogger` Logger. side : str Side where boundary condition applies (left or right). type : str Type of boundary condition (Neumann or Dirichlet). Methods ------- cvar_fun(t) : Function of time, returns a :class:`composition_variables.CompositionVariables` instance. J_fun(t) Function of time, returns a flux array. """
[docs] def __init__(self, params, thermo, V_partial, min_atom_fraction, logger, side, ready): """Class constructor.""" self.thermo = thermo self.comps = thermo.comps self.inds = self.comps[1:-1] self.V_partial = V_partial self.min_atom_fraction = min_atom_fraction self.logger = logger self.side = side self.type = self.make_BC_type(params) missing_comps = [] if self.type == "Dirichlet": xparams = self.make_BC_dict(params, "atom_fraction") self.cvar_fun = self.make_cvar_fun(xparams) else: self.cvar_fun = None if self.type == "Neumann": Jparams = self.make_BC_dict(params, "flux") for k in self.comps[1:]: if k not in Jparams: Jparams[k] = "0" missing_comps.append(k) self.J_fun = self.make_J_fun(Jparams) else: self.J_fun = None if len(missing_comps) > 0: logger.info("Auto boundary conditions:", stream=ready) for k in missing_comps: text = f"* {side:5} BC for {k} set to 0-flux" logger.info(text, stream=ready)
[docs] def make_BC_type(self, params): """Guess BC type from input dict and make sure input is consistent.""" if len(params) > 1: msg = (f"{self.side} boundary condition: cannot specify atom " "fractions and fluxes.") raise ut.UserInputError(msg) if 'atom_fraction' in params: res = 'Dirichlet' elif ('flux' in params or params == {}): res = 'Neumann' else: var = list(params.keys())[0] msg = f"{self.side} boundary condition: invalid variable {var}." raise ut.UserInputError(msg) return res
[docs] def make_BC_dict(self, params, var): """Format input dictionary.""" dct = params.get(var, {}) return {ut.format_element_symbol(k) : str(v) for k, v in dct.items()}
[docs] def make_J_fun(self, Jparams): """ Make function that returns a boundary flux array. The inner function returns a 1D array of fluxes, which include all atom components. """ self.check_BC_components(Jparams.keys(), self.comps[1:], 'flux') fun_dct = {} # the default argument is needed because of late-binding (Python # will look up the value of k when the lambda function is called, # and by that time k will be the last k value) fun_dct = {k: lambda t, s=Jparams[k]: eval(s) for k in self.comps[1:]} def fun(t): J_dict = {k: fun_dct[k](t) for k in self.comps[1:]} J_arr = np.array(list(J_dict.values())) return J_arr return fun
[docs] def make_cvar_fun(self, xparams): """ Make function that returns a boundary composition variable. The inner function returns a :class:`composition_variables.CompositionVariables` instance. Two assumptions are made: * vacancies are at equilibrium, * the pore fraction is 0. """ self.check_BC_components(xparams.keys(), self.inds, 'atom_fraction') xparams = self.clip_xBC_values(xparams) # the default argument is needed because of late-binding (Python # will look up the value of k when the lambda function is called, # and by that time k will be the last k value) fun_dct = {k: lambda t, s=xparams[k]: eval(s) for k in self.inds} def fun(t): x_dict = {k: fun_dct[k](t) for k in self.inds} x_arr = np.array([x_dict[k] for k in self.inds]) yVa = self.thermo.yVa_fun(x_arr) cvar = cv.CompositionVariables(self.comps, x_dict, yVa, self.V_partial, fm=1) return cvar return fun
[docs] def check_BC_components(self, found, expected, varname): """Make sure all expected components are present in the BC.""" try: assert set(found) == set(expected) except AssertionError as exc: msg = (f"Missing or extra elements in '{varname}' boundary " "condition.\n" f"expected {expected}\n" f"found {found}") raise ut.UserInputError(msg) from exc
[docs] def clip_xBC_values(self, xparams): """Make sure atom fractions are within accepted bounds.""" min_val = self.min_atom_fraction max_val = 1 - self.min_atom_fraction for k in xparams: v = float(xparams[k]) if v < min_val: msg = (f"{self.side} boundary condition : input atom fraction " f"{k} = {v} replaced by minimum allowed {min_val}.") self.logger.info(msg) xparams[k] = str(min_val) if v > max_val: msg = (f"{self.side} boundary condition : input atom fraction " f"{k} = {v} replaced by maximum allowed {max_val}.") self.logger.info(msg) xparams[k] = str(max_val) return xparams
[docs] def x_fun(self, t): """Atom fractions on boundary.""" return self.cvar_fun(t).x.mid