Module layer

=============================================================================== SFPPy Module: Layer (Packaging Materials) =============================================================================== Defines packaging materials as 1D layers. Supports: - Multilayer assembly (layer1 + layer2) - Mass transfer modeling (layer >> food) - Automatic meshing for finite-volume solvers

Main Components: - Base Class: layer (Defines all packaging materials) - Properties: D (diffusivity), k (partition coefficient), l (thickness) - Supports + (stacking) and splitting layers - Propagates contact temperature from food.py - Predefined Materials (Subclasses): - LDPE, PP, PET, Cardboard, Ink - Dynamic Property Models: - Dmodel(), kmodel(): Call property.py to predict diffusion and partitioning

Integration with SFPPy Modules: - Used in migration.py to define the left-side boundary. - Retrieves chemical properties from loadpubchem.py. - Works with food.py to model food-contact interactions.

Example:

from patankar.layer import LDPE
A = LDPE(l=50e-6, D=1e-14)

=============================================================================== Details =============================================================================== Layer builder for patankar package

All materials are represented as layers and be combined, merged with mathematical operations such as +. The general object general object is of class layer.

Specific materials with known properties have been derived: LDPE(),HDPE(),PP()…air()

List of implemnted materials:

| Class Name              | Type     | Material                        | Code    |
|-------------------------|----------|---------------------------------|---------|
| AdhesiveAcrylate        | adhesive | acrylate adhesive               | Acryl   |
| AdhesiveEVA             | adhesive | EVA adhesive                    | EVA     |
| AdhesiveNaturalRubber   | adhesive | natural rubber adhesive         | rubber  |
| AdhesivePU              | adhesive | polyurethane adhesive           | PU      |
| AdhesivePVAC            | adhesive | PVAc adhesive                   | PVAc    |
| AdhesiveSyntheticRubber | adhesive | synthetic rubber adhesive       | sRubber |
| AdhesiveVAE             | adhesive | VAE adhesive                    | VAE     |
| Cardboard               | paper    | cardboard                       | board   |
| HDPE                    | polymer  | high-density polyethylene       | HDPE    |
| HIPS                    | polymer  | high-impact polystyrene         | HIPS    |
| LDPE                    | polymer  | low-density polyethylene        | LDPE    |
| LLDPE                   | polymer  | linear low-density polyethylene | LLDPE   |
| PA6                     | polymer  | polyamide 6                     | PA6     |
| PA66                    | polymer  | polyamide 6,6                   | PA6,6   |
| SBS                     | polymer  | styrene-based polymer SBS       | SBS     |
| PBT                     | polymer  | polybutylene terephthalate      | PBT     |
| PEN                     | polymer  | polyethylene naphthalate        | PEN     |
| PP                      | polymer  | isotactic polypropylene         | PP      |
| PPrubber                | polymer  | atactic polypropylene           | aPP     |
| PS                      | polymer  | polystyrene                     | PS      |
| Paper                   | paper    | paper                           | paper   |
| air                     | air      | ideal gas                       | gas     |
| gPET                    | polymer  | glassy PET                      | PET     |
| oPP                     | polymer  | bioriented polypropylene        | oPP     |
| plasticizedPVC          | polymer  | plasticized PVC                 | pPVC    |
| rPET                    | polymer  | rubbery PET                     | rPET    |
| rigidPVC                | polymer  | rigid PVC                       | PVC     |

Mass transfer within each layer are governed by a diffusion coefficient, a Henri-like coefficient enabling to describe the partitioning between layers. All materials are automatically meshed using a modified finite volume technique exact at steady state and offering good accuracy in non-steady conditions.

A temperature and substance can be assigned to layers.

@version: 1.24 @project: SFPPy - SafeFoodPackaging Portal in Python initiative @author: INRAE\olivier.vitrac@agroparistech.fr @licence: MIT @Date: 2022-02-21 @rev. 2025-03-04

Expand source code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
===============================================================================
SFPPy Module: Layer (Packaging Materials)
===============================================================================
Defines **packaging materials** as 1D layers. Supports:
- **Multilayer assembly (`layer1 + layer2`)**
- **Mass transfer modeling (`layer >> food`)**
- **Automatic meshing for finite-volume solvers**

**Main Components:**
- **Base Class: `layer`** (Defines all packaging materials)
    - Properties: `D` (diffusivity), `k` (partition coefficient), `l` (thickness)
    - Supports **+ (stacking)** and **splitting** layers
    - Propagates contact temperature from `food.py`
- **Predefined Materials (Subclasses)**:
    - `LDPE`, `PP`, `PET`, `Cardboard`, `Ink`
- **Dynamic Property Models:**
    - `Dmodel()`, `kmodel()`: Call `property.py` to predict diffusion and partitioning

**Integration with SFPPy Modules:**
- Used in `migration.py` to define the **left-side boundary**.
- Retrieves chemical properties from `loadpubchem.py`.
- Works with `food.py` to model **food-contact** interactions.

Example:
```python
from patankar.layer import LDPE
A = LDPE(l=50e-6, D=1e-14)
```


===============================================================================
Details
===============================================================================
Layer builder for patankar package

All materials are represented as layers and be combined, merged with mathematical
operations such as +. The general object general object is of class layer.

Specific materials with known properties have been derived: LDPE(),HDPE(),PP()...air()

List of implemnted materials:

    | Class Name              | Type     | Material                        | Code    |
    |-------------------------|----------|---------------------------------|---------|
    | AdhesiveAcrylate        | adhesive | acrylate adhesive               | Acryl   |
    | AdhesiveEVA             | adhesive | EVA adhesive                    | EVA     |
    | AdhesiveNaturalRubber   | adhesive | natural rubber adhesive         | rubber  |
    | AdhesivePU              | adhesive | polyurethane adhesive           | PU      |
    | AdhesivePVAC            | adhesive | PVAc adhesive                   | PVAc    |
    | AdhesiveSyntheticRubber | adhesive | synthetic rubber adhesive       | sRubber |
    | AdhesiveVAE             | adhesive | VAE adhesive                    | VAE     |
    | Cardboard               | paper    | cardboard                       | board   |
    | HDPE                    | polymer  | high-density polyethylene       | HDPE    |
    | HIPS                    | polymer  | high-impact polystyrene         | HIPS    |
    | LDPE                    | polymer  | low-density polyethylene        | LDPE    |
    | LLDPE                   | polymer  | linear low-density polyethylene | LLDPE   |
    | PA6                     | polymer  | polyamide 6                     | PA6     |
    | PA66                    | polymer  | polyamide 6,6                   | PA6,6   |
    | SBS                     | polymer  | styrene-based polymer SBS       | SBS     |
    | PBT                     | polymer  | polybutylene terephthalate      | PBT     |
    | PEN                     | polymer  | polyethylene naphthalate        | PEN     |
    | PP                      | polymer  | isotactic polypropylene         | PP      |
    | PPrubber                | polymer  | atactic polypropylene           | aPP     |
    | PS                      | polymer  | polystyrene                     | PS      |
    | Paper                   | paper    | paper                           | paper   |
    | air                     | air      | ideal gas                       | gas     |
    | gPET                    | polymer  | glassy PET                      | PET     |
    | oPP                     | polymer  | bioriented polypropylene        | oPP     |
    | plasticizedPVC          | polymer  | plasticized PVC                 | pPVC    |
    | rPET                    | polymer  | rubbery PET                     | rPET    |
    | rigidPVC                | polymer  | rigid PVC                       | PVC     |


Mass transfer within each layer are governed by a diffusion coefficient, a Henri-like coefficient
enabling to describe the partitioning between layers. All materials are automatically meshed using
a modified finite volume technique exact at steady state and offering good accuracy in non-steady
conditions.

A temperature and substance can be assigned to layers.


@version: 1.24
@project: SFPPy - SafeFoodPackaging Portal in Python initiative
@author: INRAE\\olivier.vitrac@agroparistech.fr
@licence: MIT
@Date: 2022-02-21
@rev. 2025-03-04

"""

# ---- History ----
# Created on Tue Jan 18 09:14:34 2022
# 2022-01-19 RC
# 2022-01-20 full indexing and simplification
# 2022-01-21 add split()
# 2022-01-22 add child classes for common polymers
# 2022-01-23 full implementation of units
# 2022-01-26 mesh() method generating mesh objects
# 2022-02-21 add compatibility with migration


oneline = "Build multilayer objects"

docstr = """
Build layer(s) for SENSPATANKAR

Example of caller:
    from patankar.layer import layer
    A=layer(D=1e-14,l=50e-6)
    A

"""


# Package Dependencies
# ====================
# <--  generic packages  -->
import sys
import inspect
import textwrap
import numpy as np
from copy import deepcopy as duplicate
# <--  local packages  -->
if 'SIbase' not in dir(): # avoid loading it twice
    from patankar.private.pint import UnitRegistry as SIbase
    from patankar.private.pint import set_application_registry as fixSIbase
if 'migrant' not in dir():
    from patankar.loadpubchem import migrant


__all__ = ['AdhesiveAcrylate', 'AdhesiveEVA', 'AdhesiveNaturalRubber', 'AdhesivePU', 'AdhesivePVAC', 'AdhesiveSyntheticRubber', 'AdhesiveVAE', 'Cardboard', 'HDPE', 'HIPS', 'LDPE', 'LLDPE', 'PA6', 'PA66', 'PBT', 'PEN', 'PP', 'PPrubber', 'PS', 'Paper', 'R', 'RT0K', 'SBS', 'SI', 'SIbase', 'T0K', 'air', 'check_units', 'fixSIbase', 'format_scientific_latex', 'gPET', 'help_layer', 'iRT0K', 'layer', 'layerLink', 'list_layer_subclasses', 'mesh', 'migrant', 'oPP', 'plasticizedPVC', 'qSI', 'rPET', 'rigidPVC', 'toSI']

__project__ = "SFPPy"
__author__ = "Olivier Vitrac"
__copyright__ = "Copyright 2022"
__credits__ = ["Olivier Vitrac"]
__license__ = "MIT"
__maintainer__ = "Olivier Vitrac"
__email__ = "olivier.vitrac@agroparistech.fr"
__version__ = "1.24"

# %% Private functions and classes

# Initialize unit conversion (intensive initialization with old Python versions)
# NB: degC and kelvin must be used for temperature
# conversion as obj,valueSI,unitSI = toSI(qSI(numvalue,"unit"))
# conversion as obj,valueSI,unitSI = toSI(qSI("value unit"))
def toSI(q): q=q.to_base_units(); return q,q.m,str(q.u)
NoUnits = 'a.u.'     # value for arbitrary unit
UnknownUnits = 'N/A' # no non indentified units
if ("SI" not in locals()) or ("qSI" not in locals()):
    SI = SIbase()      # unit engine
    fixSIbase(SI)      # keep the same instance between calls
    qSI = SI.Quantity  # main unit consersion method from string
    # constants (usable in layer object methods)
    # define R,T0K,R*T0K,1/(R*T0K) with there SI units
    constants = {}
    R,constants["R"],constants["Runit"] = toSI(qSI(1,'avogadro_number*boltzmann_constant'))
    T0K,constants["T0K"],constants["T0Kunit"] = toSI(qSI(0,'degC'))
    RT0K,constants["RT0K"],constants["RT0Kunit"] = toSI(R*T0K)
    iRT0K,constants["iRT0K"],constants["iRT0Kunit"] = toSI(1/RT0K)


# Concise data validator with unit convertor to SI
# To prevent many issues with temperature and to adhere to 2024 golden standard in layer
# defaulttempUnits has been set back to "degC" from "K".
def check_units(value,ProvidedUnits=None,ExpectedUnits=None,defaulttempUnits="degC"):
    """ check numeric inputs and convert them to SI units """
    # by convention, NumPy arrays and None are return unchanged (prevent nesting)
    if isinstance(value,np.ndarray) or value is None:
        return value,UnknownUnits
    if isinstance(value,tuple):
        if len(value) != 2:
            raise ValueError('value should be a tuple: (value,"unit"')
        ProvidedUnits = value[1]
        value = value[0]
    if isinstance(value,list): # the function is vectorized
        value = np.array(value)
    if {"degC", "K"} & {ProvidedUnits, ExpectedUnits}: # the value is a temperature
        ExpectedUnits = defaulttempUnits if ExpectedUnits is None else ExpectedUnits
        ProvidedUnits = ExpectedUnits if ProvidedUnits is None else ProvidedUnits
        if ProvidedUnits=="degC" and ExpectedUnits=="K":
            value += constants["T0K"]
        elif ProvidedUnits=="K" and ExpectedUnits=="degC":
            value -= constants["T0K"]
        return np.array([value]),ExpectedUnits
    else: # the value is not a temperature
        ExpectedUnits = NoUnits if ExpectedUnits is None else ExpectedUnits
        if (ProvidedUnits==ExpectedUnits) or (ProvidedUnits==NoUnits) or (ExpectedUnits==None):
            conversion =1               # no conversion needed
            units = ExpectedUnits if ExpectedUnits is not None else NoUnits
        else:
            q0,conversion,units = toSI(qSI(1,ProvidedUnits))
        return np.array([value*conversion]),units

# _toSI: function helper for the enduser outside layer
def _toSI(value=None):
    '''return an SI value from (value,"unit")'''
    if not isinstance(value,tuple) or len(value)!=2 \
        or not isinstance(value[0],(float,int,list,np.ndarray)) \
            or  not isinstance(value[1],str):
        raise ValueError('value must be (currentvalue,"unit") - for example: (10,"days")')
    return check_units(value)[0]


# formatsci equivalent
def format_scientific_latex(value, numdigits=4, units=None, prefix="",mathmode="$"):
    """
    Formats a number in scientific notation only when necessary, using LaTeX.

    Parameters:
    -----------
    value : float
        The number to format.
    numdigits : int, optional (default=4)
        Number of significant digits for formatting.
    units : str, optional (default=None)
        LaTeX representation of units. If None, no units are added.
    prefix: str, optional (default="")
    mathmode: str, optional (default="$")

    Returns:
    --------
    str
        The formatted number in standard or LaTeX scientific notation.

    Examples:
    ---------
    >>> format_scientific_latex(1e-12)
    '$10^{-12}$'

    >>> format_scientific_latex(1.5e-3)
    '0.0015'

    >>> format_scientific_latex(1.3e10)
    '$1.3 \\cdot 10^{10}$'

    >>> format_scientific_latex(0.00341)
    '0.00341'

    >>> format_scientific_latex(3.41e-6)
    '$3.41 \\cdot 10^{-6}$'
    """

    if value == 0:
        return "$0$" if units is None else rf"$0 \, {units}$"
    # Get formatted number using Matlab-like %g behavior
    formatted = f"{value:.{numdigits}g}"
    # If the formatting results in an `e` notation, convert to LaTeX
    if "e" in formatted or "E" in formatted:
        coefficient, exponent = formatted.split("e")
        exponent = int(exponent)  # Convert exponent to integer
        # Remove trailing zeros in coefficient
        coefficient = coefficient.rstrip("0").rstrip(".")  # Ensures "1.00" -> "1"
        # LaTeX scientific format
        sci_notation = rf"{prefix}{coefficient} \cdot 10^{{{exponent}}}"
        return sci_notation if units is None else rf"{mathmode}{sci_notation} \, {units}{mathmode}"
    # Otherwise, return standard notation
    return formatted if units is None else rf"{mathmode}{prefix}{formatted} \, {units}{mathmode}"




# helper function to list all classes
def list_layer_subclasses():
    """
    Lists all classes in this module that derive from 'layer',
    along with their layertype and layermaterial properties.

    Returns:
        list of tuples (classname, layertype, layermaterial)
    """
    subclasses_info = []
    current_module = sys.modules[__name__]  # This refers to layer.py itself
    for name, obj in inspect.getmembers(current_module, inspect.isclass):
        # Make sure 'obj' is actually a subclass of layer (and not 'layer' itself)
        if obj is not layer and issubclass(obj, layer):
            try:
                # Instantiate with default parameters so that .layertype / .layermaterial are accessible
                instance = obj()
                subclasses_info.append(
                    {"classname":name,
                     "type":instance._type[0],
                     "material":instance._material[0],
                     "code":instance._code[0]}
                )
            except TypeError as e:
                # Log error and rethrow for debugging
                print(f"⚠️ Error: Could not instantiate class '{name}'. Check its constructor.")
                print(f"🔍 Exception: {e}")
                raise  # Rethrow the error with full traceback
    return subclasses_info


# general help for layer
def help_layer():
    """
    Print all subclasses with their type/material info in a Markdown table with dynamic column widths.
    """
    derived = list_layer_subclasses()
    # Extract table content
    headers = ["Class Name", "Type", "Material", "Code"]
    rows = [[item["classname"], item["type"], item["material"], item["code"]] for item in derived]
    # Compute column widths based on content
    col_widths = [max(len(str(cell)) for cell in col) for col in zip(headers, *rows)]
    # Formatting row template
    row_format = "| " + " | ".join(f"{{:<{w}}}" for w in col_widths) + " |"
    # Print header
    print(row_format.format(*headers))
    print("|-" + "-|-".join("-" * w for w in col_widths) + "-|")

    # Print table rows
    for row in rows:
        print(row_format.format(*row))


# generic class to store linked parameter values in a sparse manner
class layerLink:
    """
    A sparse representation of properties (`D`, `k`, `C0`) used in `layer` instances.

    This class allows storing and manipulating selected values of a property (`D`, `k`, or `C0`)
    while keeping a sparse structure. It enables seamless interaction with `layer` objects
    by overriding values dynamically and ensuring efficient memory usage.

    The primary use case is to fit and control property values externally while keeping
    the `layer` representation internally consistent.

    Attributes
    ----------
    property : str
        The name of the property linked (`"D"`, `"k"`, or `"C0"`).
    indices : np.ndarray
        A NumPy array storing the indices of explicitly defined values.
    values : np.ndarray
        A NumPy array storing the corresponding values at `indices`.
    length : int
        The total length of the sparse vector, ensuring coverage of all indices.
    replacement : str, optional
        Defines how missing values are handled:
        - `"repeat"`: Propagates the last known value beyond `length`.
        - `"periodic"`: Cycles through known values beyond `length`.
        - Default: No automatic replacement within `length`.

    Methods
    -------
    set(index, value)
        Sets values at specific indices. If `None` or `np.nan` is provided, the index is removed.
    get(index=None)
        Retrieves values at the given indices. Returns `NaN` for missing values.
    getandreplace(indices, altvalues)
        Similar to `get()`, but replaces `NaN` values with corresponding values from `altvalues`.
    getfull(altvalues)
        Returns the full vector using `getandreplace(None, altvalues)`.
    lengthextension()
        Ensures `length` covers all stored indices (`max(indices) + 1`).
    rename(new_property_name)
        Renames the `property` associated with this `layerLink`.
    nzcount()
        Returns the number of explicitly stored (nonzero) elements.
    __getitem__(index)
        Allows retrieval using `D_link[index]`, equivalent to `get(index)`.
    __setitem__(index, value)
        Allows assignment using `D_link[index] = value`, equivalent to `set(index, value)`.
    __add__(other)
        Concatenates two `layerLink` instances with the same property.
    __mul__(n)
        Repeats the `layerLink` instance `n` times, shifting indices accordingly.

    Examples
    --------
    Create a `layerLink` for `D` and manipulate its values:

    ```python
    D_link = layerLink("D")
    D_link.set([0, 2], [1e-14, 3e-14])
    print(D_link.get())  # Expected: array([1e-14, nan, 3e-14])

    D_link[1] = 2e-14
    print(D_link.get())  # Expected: array([1e-14, 2e-14, 3e-14])
    ```

    Concatenating two `layerLink` instances:

    ```python
    A = layerLink("D")
    A.set([0, 2], [1e-14, 3e-14])

    B = layerLink("D")
    B.set([1, 3], [2e-14, 4e-14])

    C = A + B  # Concatenates while shifting indices
    print(C.get())  # Expected: array([1e-14, 3e-14, nan, nan, 2e-14, 4e-14])
    ```

    Handling missing values with `getandreplace()`:

    ```python
    alt_values = np.array([5e-14, 6e-14, 7e-14, 8e-14])
    print(D_link.getandreplace([0, 1, 2, 3], alt_values))
    # Expected: array([1e-14, 2e-14, 3e-14, 8e-14])  # Fills NaNs from alt_values
    ```

    Ensuring correct behavior for `*`:

    ```python
    B = A * 3  # Repeats A three times
    print(B.indices)  # Expected: [0, 2, 4, 6, 8, 10]
    print(B.values)   # Expected: [1e-14, 3e-14, 1e-14, 3e-14, 1e-14, 3e-14]
    print(B.length)   # Expected: 3 * A.length
    ```


    Other Examples:
    ----------------

    ### **Creating a Link**
    D_link = layerLink("D", indices=[1, 3], values=[5e-14, 7e-14], length=4)
    print(D_link)  # <Link for D: 2 of 4 replacement values>

    ### **Retrieving Values**
    print(D_link.get())       # Full vector with None in unspecified indices
    print(D_link.get(1))      # Returns 5e-14
    print(D_link.get([0,2]))  # Returns [None, None]

    ### **Setting Values**
    D_link.set(2, 6e-14)
    print(D_link.get())  # Now index 2 is replaced

    ### **Resetting with a Prototype**
    prototype = [None, 5e-14, None, 7e-14, 8e-14]
    D_link.reset(prototype)
    print(D_link.get())  # Now follows the new structure

    ### **Getting and Setting Values with []**
    D_link = layerLink("D", indices=[1, 3, 5], values=[5e-14, 7e-14, 6e-14], length=10)
    print(D_link[3])      # ✅ Returns 7e-14
    print(D_link[:5])     # ✅ Returns first 5 elements (with NaNs where undefined)
    print(D_link[[1, 3]]) # ✅ Returns [5e-14, 7e-14]
    D_link[2] = 9e-14     # ✅ Sets D[2] to 9e-14
    D_link[0:4:2] = [1e-14, 2e-14]  # ✅ Sets D[0] = 1e-14, D[2] = 2e-14
    print(len(D_link))    # ✅ Returns 10 (full vector length)

    ###**Practical Syntaxes**
    D_link = layerLink("D")
    D_link[2] = 3e-14  # ✅ single value
    D_link[0] = 1e-14
    print(D_link.get())
    print(D_link[1])
    print(repr(D_link))
    D_link[:4] = 1e-16  # ✅ Fills indices 0,1,2,3 with 1e-16
    print(D_link.get())  # ✅ Outputs: [1e-16, 1e-16, 1e-16, 1e-16, nan, 1e-14]
    D_link[[1,2]] = None  # ✅ Fills indices 0,1,2,3 with 1e-16
    print(D_link.get())  # ✅ Outputs: [1e-16, 1e-16, 1e-16, 1e-16, nan, 1e-14]
    D_link[[0]] = 1e-10
    print(D_link.get())

    ###**How it works inside layer: a short simulation**
    # layerLink created by user
    duser = layerLink()
    duser.getfull([1e-15,2e-15,3e-15])
    duser[0] = 1e-10
    duser.getfull([1e-15,2e-15,3e-15])
    duser[1]=1e-9
    duser.getfull([1e-15,2e-15,3e-15])
    # layerLink used internally
    dalias=duser
    dalias[1]=2e-11
    duser.getfull([1e-15,2e-15,3e-15,4e-15])
    dalias[1]=2.1e-11
    duser.getfull([1e-15,2e-15,3e-15,4e-15])

    ###**Combining layerLinks instances**
    A = layerLink("D")
    A.set([0, 2], [1e-11, 3e-11])  # length=3
    B = layerLink("D")
    B.set([1, 3], [2e-14, 4e-12])  # length=4
    C = A + B
    print(C.indices)  # Expected: [0, 2, 4, 6]
    print(C.values)   # Expected: [1.e-11 3.e-11 2.e-14 4.e-12]
    print(C.length)   # Expected: 3 + 4 = 7


    TEST CASES:
    -----------

    print("🔹 Test 1: Initialize empty layerLink")
    D_link = layerLink("D")
    print(D_link.get())  # Expected: array([]) or array([nan, nan, nan]) if length is pre-set
    print(repr(D_link))  # Expected: No indices set

    print("\n🔹 Test 2: Assigning values at specific indices")
    D_link[2] = 3e-14
    D_link[0] = 1e-14
    print(D_link.get())  # Expected: array([1.e-14, nan, 3.e-14])
    print(D_link[1])     # Expected: nan

    print("\n🔹 Test 3: Assign multiple values at once")
    D_link[[1, 4]] = [2e-14, 5e-14]
    print(D_link.get())  # Expected: array([1.e-14, 2.e-14, 3.e-14, nan, 5.e-14])

    print("\n🔹 Test 4: Remove a single index")
    D_link[1] = None
    print(D_link.get())  # Expected: array([1.e-14, nan, 3.e-14, nan, 5.e-14])

    print("\n🔹 Test 5: Remove multiple indices at once")
    D_link[[0, 2]] = None
    print(D_link.get())  # Expected: array([nan, nan, nan, nan, 5.e-14])

    print("\n🔹 Test 6: Removing indices using a slice")
    D_link[3:5] = None
    print(D_link.get())  # Expected: array([nan, nan, nan, nan, nan])

    print("\n🔹 Test 7: Assign new values after removals")
    D_link[1] = 7e-14
    D_link[3] = 8e-14
    print(D_link.get())  # Expected: array([nan, 7.e-14, nan, 8.e-14, nan])

    print("\n🔹 Test 8: Check periodic replacement")
    D_link = layerLink("D", replacement="periodic")
    D_link[2] = 3e-14
    D_link[0] = 1e-14
    print(D_link[5])  # Expected: 1e-14 (since 5 mod 2 = 0)

    print("\n🔹 Test 9: Check repeat replacement")
    D_link = layerLink("D", replacement="repeat")
    D_link[2] = 3e-14
    D_link[0] = 1e-14
    print(D_link.get())  # Expected: array([1.e-14, nan, 3.e-14])
    print(D_link[3])     # Expected: 3e-14 (repeat last known value)

    print("\n🔹 Test 10: Resetting with a prototype")
    D_link.reset([None, 5e-14, None, 7e-14])
    print(D_link.get())  # Expected: array([nan, 5.e-14, nan, 7.e-14])

    print("\n🔹 Test 11: Edge case - Assigning nan explicitly")
    D_link[1] = np.nan
    print(D_link.get())  # Expected: array([nan, nan, nan, 7.e-14])

    print("\n🔹 Test 12: Assigning a range with a scalar value (broadcasting)")
    D_link[0:3] = 9e-14
    print(D_link.get())  # Expected: array([9.e-14, 9.e-14, 9.e-14, 7.e-14])

    print("\n🔹 Test 13: Assigning a slice with a list of values")
    D_link[1:4] = [6e-14, 5e-14, 4e-14]
    print(D_link.get())  # Expected: array([9.e-14, 6.e-14, 5.e-14, 4.e-14])

    print("\n🔹 Test 14: Length updates correctly after removals")
    D_link[[1, 2]] = None
    print(len(D_link))   # Expected: 4 (since max index is 3)

    print("\n🔹 Test 15: Setting index beyond length auto-extends")
    D_link[6] = 2e-14
    print(len(D_link))   # Expected: 7 (since max index is 6)
    print(D_link.get())  # Expected: array([9.e-14, nan, nan, 4.e-14, nan, nan, 2.e-14])

    """

    def __init__(self, property="D", indices=None, values=None, length=None,
                 replacement="repeat", dtype=np.float64, maxlength=None):
        """constructs a link"""
        self.property = property  # "D", "k", or "C0"
        self.replacement = replacement
        self.dtype = dtype
        self._maxlength = maxlength
        if isinstance(indices,(int,float)): indices = [indices]
        if isinstance(values,(int,float)): values = [values]

        if indices is None or values is None:
            self.indices = np.array([], dtype=int)
            self.values = np.array([], dtype=dtype)
        else:
            self.indices = np.array(indices, dtype=int)
            self.values = np.array(values, dtype=dtype)

        self.length = length if length is not None else (self.indices.max() + 1 if self.indices.size > 0 else 0)
        self._validate()

    def _validate(self):
        """Ensures consistency between indices and values."""
        if len(self.indices) != len(self.values):
            raise ValueError("indices and values must have the same length.")
        if self.indices.size > 0 and self.length < self.indices.max() + 1:
            raise ValueError("length must be at least max(indices) + 1.")

    def reset(self, prototypevalues):
        """
        Resets the link instance based on the prototype values.

        - Stores only non-None values.
        - Updates `indices`, `values`, and `length` accordingly.
        """
        self.indices = np.array([i for i, v in enumerate(prototypevalues) if v is not None], dtype=int)
        self.values = np.array([v for v in prototypevalues if v is not None], dtype=self.dtype)
        self.length = len(prototypevalues)  # Update the total length

    def get(self, index=None):
        """
        Retrieves values based on index or returns the full vector.

        Rules:
        - If `index=None`, returns the full vector with overridden values (no replacement applied).
        - If `index` is a scalar, returns the corresponding value, applying replacement rules if needed.
        - If `index` is an array, returns an array of the requested indices, applying replacement rules.

        Returns:
        - NumPy array with requested values.
        """
        if index is None:
            # Return the full vector WITHOUT applying any replacement
            full_vector = np.full(self.length, np.nan, dtype=self.dtype)
            full_vector[self.indices] = self.values  # Set known values
            return full_vector

        if np.isscalar(index):
            return self._get_single(index)

        # Ensure index is an array
        index = np.array(index, dtype=int)
        return np.array([self._get_single(i) for i in index], dtype=self.dtype)

    def _get_single(self, i):
        """Retrieves the value for a single index, applying rules if necessary."""
        if i in self.indices:
            return self.values[np.where(self.indices == i)[0][0]]

        if i >= self.length:  # Apply replacement *only* for indices beyond length
            if self.replacement == "periodic":
                return self.values[i % len(self.values)]
            elif self.replacement == "repeat":
                return self._get_single(self.length - 1)  # Repeat last known value

        return np.nan  # Default case for undefined in-bounds indices


    def set(self, index, value):
        """
        Sets values at specific indices.

        - If `index=None`, resets the link with `value`.
        - If `index` is a scalar, updates or inserts the value.
        - If `index` is an array, updates corresponding values.
        - If `value` is `None` or `np.nan`, removes the corresponding index.
        """
        if index is None:
            self.reset(value)
            return

        index = np.array(index, dtype=int)
        value = np.array(value, dtype=self.dtype)

        # check against _maxlength if defined
        if self._maxlength is not None:
            if np.any(index>=self._maxlength):
                raise IndexError(f"index cannot exceeds the number of layers-1 {self._maxlength-1}")

        # Handle scalars properly
        if np.isscalar(index):
            index = np.array([index])
            value = np.array([value])

        # Detect None or NaN values and remove those indices
        mask = np.isnan(value) if value.dtype.kind == 'f' else np.array([v is None for v in value])
        if np.any(mask):
            self._remove_indices(index[mask])  # Remove these indices
            index, value = index[~mask], value[~mask]  # Keep only valid values

        if index.size > 0:  # If there are remaining valid values, store them
            for i, v in zip(index, value):
                if i in self.indices:
                    self.values[np.where(self.indices == i)[0][0]] = v
                else:
                    self.indices = np.append(self.indices, i)
                    self.values = np.append(self.values, v)

        # Update length to ensure it remains valid
        if self.indices.size > 0:
            self.length = max(self.indices) + 1  # Adjust length based on max index
        else:
            self.length = 0  # Reset to 0 if empty

        self._validate()

    def _remove_indices(self, indices):
        """
        Removes indices from `self.indices` and `self.values` and updates length.
        """
        mask = np.isin(self.indices, indices, invert=True)
        self.indices = self.indices[mask]
        self.values = self.values[mask]

        # Update length after removal
        if self.indices.size > 0:
            self.length = max(self.indices) + 1  # Adjust length based on remaining max index
        else:
            self.length = 0  # Reset to 0 if no indices remain

    def reshape(self, new_length):
        """
        Reshapes the link instance to a new length.

        - If indices exceed new_length-1, they are removed with a warning.
        - If replacement operates beyond new_length-1, a warning is issued.
        """
        if new_length < self.length:
            invalid_indices = self.indices[self.indices >= new_length]
            if invalid_indices.size > 0:
                print(f"⚠️ Warning: Indices {invalid_indices.tolist()} are outside new length {new_length}. They will be removed.")
                mask = self.indices < new_length
                self.indices = self.indices[mask]
                self.values = self.values[mask]

        # Check if replacement would be applied beyond the new length
        if self.replacement == "repeat" and self.indices.size > 0 and self.length > new_length:
            print(f"⚠️ Warning: Repeat rule was defined for indices beyond {new_length-1}, but will not be used.")

        if self.replacement == "periodic" and self.indices.size > 0 and self.length > new_length:
            print(f"⚠️ Warning: Periodic rule was defined for indices beyond {new_length-1}, but will not be used.")

        self.length = new_length

    def __repr__(self):
        """Returns a detailed string representation."""
        txt = (f"Link(property='{self.property}', indices={self.indices.tolist()}, "
                f"values={self.values.tolist()}, length={self.length}, replacement='{self.replacement}')")
        print(txt)
        return(str(self))

    def __str__(self):
        """Returns a compact summary string."""
        return f"<{self.property}:{self.__class__.__name__}: {len(self.indices)}/{self.length}  values>"

    # Override `len()`
    def __len__(self):
        """Returns the length of the vector managed by the link object."""
        return self.length

    # Override `getitem` (support for indexing and slicing)
    def __getitem__(self, index):
        """
        Allows `D_link[index]` or `D_link[slice]` to retrieve values.

        - If `index` is an integer, returns a single value.
        - If `index` is a slice or list/array, returns a NumPy array of values.
        """
        if isinstance(index, slice):
            return self.get(np.arange(index.start or 0, index.stop or self.length, index.step or 1))
        return self.get(index)

    # Override `setitem` (support for indexing and slicing)
    def __setitem__(self, index, value):
        """
        Allows `D_link[index] = value` or `D_link[slice] = list/scalar`.

        - If `index` is an integer, updates or inserts a single value.
        - If `index` is a slice or list/array, updates multiple values.
        - If `value` is `None` or `np.nan`, removes the corresponding index.
        """
        if isinstance(index, slice):
            indices = np.arange(index.start or 0, index.stop or self.length, index.step or 1)

        elif isinstance(index, (list, np.ndarray)):  # Handle non-contiguous indices
            indices = np.array(index, dtype=int)

        elif np.isscalar(index):  # Single index assignment
            indices = np.array([index], dtype=int)

        else:
            raise TypeError(f"Unsupported index type: {type(index)}")

        if value is None or (isinstance(value, float) and np.isnan(value)):  # Remove these indices
            self._remove_indices(indices)
        else:
            values = np.full_like(indices, value, dtype=self.dtype) if np.isscalar(value) else np.array(value, dtype=self.dtype)
            if len(indices) != len(values):
                raise ValueError(f"Cannot assign {len(values)} values to {len(indices)} indices.")
            self.set(indices, values)

    def getandreplace(self, indices=None, altvalues=None):
        """
        Retrieves values for the given indices, replacing NaN values with corresponding values from altvalues.

        - If `indices` is None or empty, it defaults to `[0, 1, ..., self.length - 1]`
        - altvalues should be a NumPy array with the same dtype as self.values.
        - altvalues **can be longer than** self.length, but **cannot be shorter than the highest requested index**.
        - If an index is undefined (`NaN` in get()), it is replaced with altvalues[index].

        Parameters:
        ----------
        indices : list or np.ndarray (default: None)
            The indices to retrieve values for. If None, defaults to full range `[0, ..., self.length - 1]`.
        altvalues : list or np.ndarray
            Alternative values to use where `get()` returns `NaN`.

        Returns:
        -------
        np.ndarray
            A NumPy array of values, with NaNs replaced by altvalues.
        """
        if indices is None or len(indices) == 0:
            indices = np.arange(self.length)  # Default to full range

        indices = np.array(indices, dtype=int)
        altvalues = np.array(altvalues, dtype=self.dtype)

        max_requested_index = indices.max() if indices.size > 0 else 0
        if max_requested_index >= altvalues.shape[0]:  # Ensure altvalues covers all requested indices
            raise ValueError(
                f"altvalues is too short! It has length {altvalues.shape[0]}, but requested index {max_requested_index}."
            )
        # Get original values
        original_values = self.get(indices)
        # Replace NaN values with corresponding values from altvalues
        mask_nan = np.isnan(original_values)
        original_values[mask_nan] = altvalues[indices[mask_nan]]
        return original_values


    def getfull(self, altvalues):
        """
        Retrieves the full vector using `getandreplace(None, altvalues)`.

        - If `length == 0`, returns `altvalues` as a NumPy array of the correct dtype.
        - Extends `self.length` to match `altvalues` if it's shorter.
        - Supports multidimensional `altvalues` by flattening it.

        Parameters:
        ----------
        altvalues : list or np.ndarray
            Alternative values to use where `get()` returns `NaN`.

        Returns:
        -------
        np.ndarray
            Full vector with NaNs replaced by altvalues.
        """
        # Convert altvalues to a NumPy array and flatten if needed
        altvalues = np.array(altvalues, dtype=self.dtype).flatten()

        # If self has no length, return altvalues directly
        if self.length == 0:
            return altvalues

        # Extend self.length to match altvalues if needed
        if self.length < altvalues.shape[0]:
            self.length = altvalues.shape[0]

        return self.getandreplace(None, altvalues)

    @property
    def nzlength(self):
        """
        Returns the number of stored nonzero elements (i.e., indices with values).
        """
        return len(self.indices)

    def lengthextension(self):
        """
        Ensures that the length of the layerLink instance is at least `max(indices) + 1`.

        - If there are no indices, the length remains unchanged.
        - If `length` is already sufficient, nothing happens.
        - Otherwise, it extends `length` to `max(indices) + 1`.
        """
        if self.indices.size > 0:  # Only extend if there are indices
            self.length = max(self.length, max(self.indices) + 1)

    def rename(self, new_property_name):
        """
        Renames the property associated with this link.

        Parameters:
        ----------
        new_property_name : str
            The new property name.

        Raises:
        -------
        TypeError:
            If `new_property_name` is not a string.
        """
        if not isinstance(new_property_name, str):
            raise TypeError(f"Property name must be a string, got {type(new_property_name).__name__}.")
        self.property = new_property_name


    def __add__(self, other):
        """
        Concatenates two layerLink instances.

        - Only allowed if both instances have the same property.
        - Calls `lengthextension()` on both instances before summing lengths.
        - Shifts `other`'s indices by `self.length` to maintain sparsity.
        - Concatenates values and indices.

        Returns:
        -------
        layerLink
            A new concatenated layerLink instance.
        """
        if not isinstance(other, layerLink):
            raise TypeError(f"Cannot concatenate {type(self).__name__} with {type(other).__name__}")

        if self.property != other.property:
            raise ValueError(f"Cannot concatenate: properties do not match ('{self.property}' vs. '{other.property}')")

        # Ensure lengths are properly extended before computing new length
        self.lengthextension()
        other.lengthextension()

        # Create a new instance for the result
        result = layerLink(self.property)

        # Copy self's values
        result.indices = np.array(self.indices, dtype=int)
        result.values = np.array(self.values, dtype=self.dtype)

        # Adjust other’s indices and add them
        shifted_other_indices = np.array(other.indices) + self.length
        result.indices = np.concatenate([result.indices, shifted_other_indices])
        result.values = np.concatenate([result.values, np.array(other.values, dtype=self.dtype)])

        # ✅ Correct length calculation: Sum of the two lengths (assuming lengths are extended)
        result.length = self.length + other.length

        return result


    def __mul__(self, n):
        """
        Repeats the layerLink instance `n` times.

        - Uses `+` to concatenate multiple copies with shifted indices.
        - Each repetition gets indices shifted by `self.length * i`.

        Returns:
        -------
        layerLink
            A new layerLink instance with repeated data.
        """
        if not isinstance(n, int) or n <= 0:
            raise ValueError("Multiplication factor must be a positive integer")

        result = layerLink(self.property)
        for i in range(n):
            shifted_instance = layerLink(self.property)
            shifted_instance.indices = np.array(self.indices) + i * self.length
            shifted_instance.values = np.array(self.values, dtype=self.dtype)
            shifted_instance.length = self.length
            result += shifted_instance  # Use `+` to merge each repetition

        return result

# %% Core class: layer
# default values (usable in layer object methods)
# these default values can be moved in a configuration file


# Main class definition
# =======================
class layer:
    """
    ------------------------------------------------------------------------------
    **Core Functionality**
    ------------------------------------------------------------------------------
    This class models layers in food packaging, handling mass transfer, partitioning,
    and meshing for finite-volume simulations using a modified Patankar method.
    Layers can be assembled into multilayers via the `+` operator and support
    dynamic property linkage using `layerLink`.

    ------------------------------------------------------------------------------
    **Key Properties**
    ------------------------------------------------------------------------------
    - `l`: Thickness of the layer (m)
    - `D`: Diffusion coefficient (m²/s)
    - `k`: Partition coefficient (dimensionless)
    - `C0`: Initial concentration (arbitrary units)
    - `rho`: Density (kg/m³)
    - `T`: Contact temperature (°C)
    - `substance`: Migrant/substance modeled for diffusion
    - `medium`: The food medium in contact with the layer
    - `Dmodel`, `kmodel`: Callable models for diffusion and partitioning

    ------------------------------------------------------------------------------
    **Methods**
    ------------------------------------------------------------------------------
    - `__add__(self, other)`: Combines two layers into a multilayer structure.
    - `__mul__(self, n)`: Duplicates a layer `n` times to create a multilayer.
    - `__getitem__(self, i)`: Retrieves a sublayer from a multilayer.
    - `__setitem__(self, i, other)`: Replaces sublayers in a multilayer structure.
    - `mesh(self)`: Generates a numerical mesh for finite-volume simulations.
    - `struct(self)`: Returns a dictionary representation of the layer properties.
    - `resolvename(param_value, param_key, **unresolved)`: Resolves synonyms for parameter names.
    - `help(cls)`: Displays a dynamically formatted summary of input parameters.

    ------------------------------------------------------------------------------
    **Integration with SFPPy Modules**
    ------------------------------------------------------------------------------
    - Works with `migration.py` for mass transfer simulations.
    - Interfaces with `food.py` to define food-contact conditions.
    - Uses `property.py` for predicting diffusion (`D`) and partitioning (`k`).
    - Connects with `geometry.py` for 3D packaging simulations.

    ------------------------------------------------------------------------------
    **Usage Example**
    ------------------------------------------------------------------------------
    ```python
    from patankar.layer import LDPE, PP, layerLink

    # Define a polymer layer with default properties
    A = LDPE(l=50e-6, D=1e-14)

    # Create a multilayer structure
    B = PP(l=200e-6, D=1e-15)
    multilayer = A + B

    # Assign dynamic property linkage
    k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
    multilayer.klink = k_link

    # Simulate migration
    from patankar.migration import senspatankar
    from patankar.food import ethanol
    medium = ethanol()
    solution = senspatankar(multilayer, medium)
    solution.plotCF()
    ```

    ------------------------------------------------------------------------------
    **Notes**
    ------------------------------------------------------------------------------
    - This class supports dynamic property inheritance, meaning `D` and `k` can be computed
      based on the substance defined in `substance` and `medium`.
    - The `layerLink` mechanism allows parameter adjustments without modifying the core object.
    - The modified finite-volume meshing ensures **accurate steady-state and transient** behavior.

    """

    # -----------------------------------------------------------------------------
    # Class attributes that can be overidden in instances.
    # Their default values are set in classes and overriden with similar
    # instance properties with @property.setter.
    # These values cannot be set during construction, but only after instantiation.
    # -----------------------------------------------------------------------------
    # These properties are essential for model predictions, they cannot be customized
    # beyond the rules accepted by the model predictors (they are not metadata)
    _physicalstate = "solid"        # solid (default), liquid, gas, porous
    _chemicalclass = "polymer"      # polymer (default), other
    _chemicalsubstance = None       # None (default), monomer for polymers
    _polarityindex = 0.0            # polarity index (roughly: 0=hexane, 10=water)

    # Low-level prediction properties (these properties are common with patankar.food)
    _lowLevelPredictionPropertyList = ["physicalstate","chemicalclass",
                                       "chemicalsubstance","polarityindex","ispolymer","issolid"]

    # --------------------------------------------------------------------
    # PRIVATE PROPERTIES (cannot be changed by the user)
    # __ read only attributes
    #  _ private attributes (not public)
    # --------------------------------------------------------------------
    __description = "LAYER object"                # description
    __version = 1.0                               # version
    __contact = "olivier.vitrac@agroparistech.fr" # contact person
    _printformat = "%0.4g"   # format to display D, k, l values


    # Synonyms dictionary: Maps alternative names to the actual parameter
    # these synonyms can be used during construction
    _synonyms = {
        "substance": {"migrant", "compound", "chemical","molecule","solute"},
        "medium": {"food","simulant","fluid","liquid","contactmedium"},
        "C0": {"CP0", "Cp0"},
        "l": {"lp", "lP"},
        "D": {"Dp", "DP"},
        "k": {"kp", "kP"},
        "T": {"temp","Temp","temperature","Temperature",
              "contacttemperature","ContactTemperature","contactTemperature"}
    }
    # Default values for parameters (note that Td cannot be changed by the end-user)
    _defaults = {
        "l": 5e-5,   # Thickness (m)
        "D": 1e-14,  # Diffusion coefficient (m^2/s)
        "k": 1.0,      # Henri-like coefficient (dimensionless)
        "C0": 1000,  # Initial concentration (arbitrary units)
        "rho": 1000, # Default density (kg/m³)
        "T": 40.0,     # Default temperature (°C)
        "Td": 25.0,    # Reference temperature for densities (°C)
        # Units (do not change)
        "lunit": "m",
        "Dunit": "m**2/s",
        "kunit": "a.u.",  # NoUnits
        "Cunit": "a.u.",  # NoUnits
        "rhounit": "kg/m**3",
        "Tunit": "degC",  # Temperatures are indicated in °C instead of K (to reduce end-user mistakes)
        # Layer properties
        "layername": "my layer",
        "layertype": "unknown type",
        "layermaterial": "unknown material",
        "layercode": "N/A",
        # Mesh parameters
        "nmeshmin": 20,
        "nmesh": 600,
        # Substance
        "substance": None,
        "simulant": None,
        # Other parameters
        "verbose": None,
        "verbosity": 2
    }

    # List units
    _parametersWithUnits = {
        "l": "m",
        "D": "m**2/s",
        "k": "a.u.",
        "C": "a.u.",
        "rhp": "kg/m**3",
        "T": "degC",
        }

    # Brief descriptions for each parameter
    _descriptionInputs = {
        "l": "Thickness of the layer (m)",
        "D": "Diffusion coefficient (m²/s)",
        "k": "Henri-like coefficient (dimensionless)",
        "C0": "Initial concentration (arbitrary units)",
        "rho": "Density of the material (kg/m³)",
        "T": "Layer temperature (°C)",
        "Td": "Reference temperature for densities (°C)",
        "lunit": "Unit of thickness (default: m)",
        "Dunit": "Unit of diffusion coefficient (default: m²/s)",
        "kunit": "Unit of Henri-like coefficient (default: a.u.)",
        "Cunit": "Unit of initial concentration (default: a.u.)",
        "rhounit": "Unit of density (default: kg/m³)",
        "Tunit": "Unit of temperature (default: degC)",
        "layername": "Name of the layer",
        "layertype": "Type of layer (e.g., polymer, ink, air)",
        "layermaterial": "Material composition of the layer",
        "layercode": "Identification code for the layer",
        "nmeshmin": "Minimum number of FV mesh elements for the layer",
        "nmesh": "Number of FV mesh elements for numerical computation",
        "verbose": "Verbose mode (None or boolean)",
        "verbosity": "Level of verbosity for debug messages (integer)"
    }

    # --------------------------------------------------------------------
    # CONSTRUCTOR OF INSTANCE PROPERTIES
    # None = missing numeric value (managed by default)
    # --------------------------------------------------------------------
    def __init__(self,
                 l=None, D=None, k=None, C0=None, rho=None, T=None,
                 lunit=None, Dunit=None, kunit=None, Cunit=None, rhounit=None, Tunit=None,
                 layername=None,layertype=None,layermaterial=None,layercode=None,
                 substance = None, medium = None,
                 # Dmodel = None, kmodel = None, they are defined via migrant (future overrides)
                 nmesh=None, nmeshmin=None, # simulation parametes
                 # link properties (for fitting and linking properties across simulations)
                 Dlink=None, klink=None, C0link=None, Tlink=None, llink=None,
                 verbose=None, verbosity=2,**unresolved):
        """

        Parameters
        ----------

        layername : TYPE, optional, string
                    DESCRIPTION. Layer Name. The default is "my layer".
        layertype : TYPE, optional, string
                    DESCRIPTION. Layer Type. The default is "unknown type".
        layermaterial : TYPE, optional, string
                        DESCRIPTION. Material identification . The default is "unknown material".
        PHYSICAL QUANTITIES
        l : TYPE, optional, scalar or tupple (value,"unit")
            DESCRIPTION. Thickness. The default is 50e-6 (m).
        D : TYPE, optional, scalar or tupple (value,"unit")
            DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
        k : TYPE, optional, scalar or tupple (value,"unit")
            DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
        C0 : TYPE, optional, scalar or tupple (value,"unit")
            DESCRIPTION. Initial concentration. The default is 1000 (a.u.).
        PHYSICAL UNITS
        lunit : TYPE, optional, string
                DESCRIPTION. Length units. The default unit is "m.
        Dunit : TYPE, optional, string
                DESCRIPTION. Diffusivity units. The default unit is 1e-14 "m^2/s".
        kunit : TYPE, optional, string
                DESCRIPTION. Henry-like coefficient. The default unit is "a.u.".
        Cunit : TYPE, optional, string
                DESCRIPTION. Initial concentration. The default unit is "a.u.".
        Returns
        -------
        a monolayer object which can be assembled into a multilayer structure

        """
        # resolve alternative names used by end-users
        substance = layer.resolvename(substance,"substance",**unresolved)
        medium = layer.resolvename(medium, "medium", **unresolved)
        C0 = layer.resolvename(C0,"C0",**unresolved)
        l = layer.resolvename(l,"l",**unresolved)
        D = layer.resolvename(D,"D",**unresolved)
        k = layer.resolvename(k,"k",**unresolved)
        T = layer.resolvename(T,"T",**unresolved)

        # Assign defaults only if values are not provided
        l = l if l is not None else layer._defaults["l"]
        D = D if D is not None else layer._defaults["D"]
        k = k if k is not None else layer._defaults["k"]
        C0 = C0 if C0 is not None else layer._defaults["C0"]
        rho = rho if rho is not None else layer._defaults["rho"]
        T = T if T is not None else layer._defaults["T"]
        lunit = lunit if lunit is not None else layer._defaults["lunit"]
        Dunit = Dunit if Dunit is not None else layer._defaults["Dunit"]
        kunit = kunit if kunit is not None else layer._defaults["kunit"]
        Cunit = Cunit if Cunit is not None else layer._defaults["Cunit"]
        rhounit = rhounit if rhounit is not None else layer._defaults["rhounit"]
        Tunit = Tunit if Tunit is not None else layer._defaults["Tunit"]
        nmesh = nmesh if nmesh is not None else layer._defaults["nmesh"]
        nmeshmin = nmeshmin if nmeshmin is not None else layer._defaults["nmeshmin"]
        verbose = verbose if verbose is not None else layer._defaults["verbose"]
        verbosity = verbosity if verbosity is not None else layer._defaults["verbosity"]

        # Assign layer id properties
        layername = layername if layername is not None else layer._defaults["layername"]
        layertype = layertype if layertype is not None else layer._defaults["layertype"]
        layermaterial = layermaterial if layermaterial is not None else layer._defaults["layermaterial"]
        layercode = layercode if layercode is not None else layer._defaults["layercode"]

        # validate all physical paramaters with their units
        l,lunit = check_units(l,lunit,layer._defaults["lunit"])
        D,Dunit = check_units(D,Dunit,layer._defaults["Dunit"])
        k,kunit = check_units(k,kunit,layer._defaults["kunit"])
        C0,Cunit = check_units(C0,Cunit,layer._defaults["Cunit"])
        rho,rhounit = check_units(rho,rhounit,layer._defaults["rhounit"])
        T,Tunit = check_units(T,Tunit,layer._defaults["Tunit"])

        # set attributes: id and physical properties
        self._name = [layername]
        self._type = [layertype]
        self._material = [layermaterial]
        self._code = [layercode]
        self._nlayer = 1
        self._l = l[:1]
        self._D = D[:1]
        self._k = k[:1]
        self._C0 = C0[:1]
        self._rho = rho[:1]
        self._T = T
        self._lunit = lunit
        self._Dunit = Dunit
        self._kunit = kunit
        self._Cunit = Cunit
        self._rhounit = rhounit
        self._Tunit = Tunit
        self._nmesh = nmesh
        self._nmeshmin = nmeshmin

        # intialize links for X = D,k,C0,T,l (see documentation of layerLink)
        # A link enables the values of X to be defined and controlled outside the instance
        self._Dlink  = self._initialize_link(Dlink, "D")
        self._klink  = self._initialize_link(klink, "k")
        self._C0link = self._initialize_link(C0link, "C0")
        self._Tlink  = self._initialize_link(Tlink, "T")
        self._llink  = self._initialize_link(llink, "l")

        # set substance, medium and related D and k models
        if isinstance(substance,str):
            substance = migrant(substance)
        if substance is not None and not isinstance(substance,migrant):
            raise ValueError(f"subtance must be None a or a migrant not a {type(substance).__name__}")
        self._substance = substance
        if medium is not None:
            from patankar.food import foodlayer # local import only if needed
            if not isinstance(medium,foodlayer):
                raise ValueError(f"medium must be None or a foodlayer not a {type(medium).__name__}")
        self._medium = medium
        self._Dmodel = "default"  # do not use directly self._compute_Dmodel (force refresh)
        self._kmodel = "default"  # do not use directly self._compute_kmodel (force refresh)

        # set history for all layers merged with +
        self._layerclass_history = []
        self._ispolymer_history = []
        self._chemicalsubstance_history = []

        # set verbosity attributes
        self.verbosity = 0 if verbosity is None else verbosity
        self.verbose = verbosity>0 if verbose is None else verbose

        # we initialize the acknowlegment process for future property propagation
        self._hasbeeninherited = {}


    # --------------------------------------------------------------------
    # Helper method: initializes and validates layerLink attributes
    # (Dlink, klink, C0link, Tlink, llink)
    # --------------------------------------------------------------------
    def _initialize_link(self, link, expected_property):
        """
        Initializes and validates a layerLink attribute.

        Parameters:
        ----------
        link : layerLink or None
            The `layerLink` instance to be assigned.
        expected_property : str
            The expected property name (e.g., "D", "k", "C0", "T").

        Returns:
        -------
        layerLink or None
            The validated `layerLink` instance or None.

        Raises:
        -------
        TypeError:
            If `link` is not a `layerLink` or `None`.
        ValueError:
            If `link.property` does not match `expected_property`.
        """
        if link is None:
            return None
        if isinstance(link, layerLink):
            if link.property == expected_property:
                return link
            raise ValueError(f'{expected_property}link.property should be "{expected_property}" not "{link.property}"')
        raise TypeError(f"{expected_property}link must be a layerLink not a {type(link).__name__}")


    # --------------------------------------------------------------------
    # Class method returning help() for the end user
    # --------------------------------------------------------------------
    @classmethod
    def help(cls):
        """
        Prints a dynamically formatted summary of all input parameters,
        adjusting column widths based on content and wrapping long descriptions.
        """

        # Column Headers
        headers = ["Parameter", "Default Value", "Has Synonyms?", "Description"]
        col_widths = [len(h) for h in headers]  # Start with header widths

        # Collect Data Rows
        rows = []
        for param, default in cls._defaults.items():
            has_synonyms = "✅ Yes" if param in cls._synonyms else "❌ No"
            description = cls._descriptionInputs.get(param, "No description available")

            # Update column widths dynamically
            col_widths[0] = max(col_widths[0], len(param))
            col_widths[1] = max(col_widths[1], len(str(default)))
            col_widths[2] = max(col_widths[2], len(has_synonyms))
            col_widths[3] = max(col_widths[3], len(description))

            rows.append([param, str(default), has_synonyms, description])

        # Function to wrap text for a given column width
        def wrap_text(text, width):
            return textwrap.fill(text, width)

        # Print Table with Adjusted Column Widths
        separator = "+-" + "-+-".join("-" * w for w in col_widths) + "-+"
        print("\n### **Accepted Parameters and Defaults**\n")
        print(separator)
        print("| " + " | ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) + " |")
        print(separator)
        for row in rows:
            # Wrap text in the description column
            row[3] = wrap_text(row[3], col_widths[3])

            # Print row
            print("| " + " | ".join(row[i].ljust(col_widths[i]) for i in range(3)) + " | " + row[3])
        print(separator)

        # Synonyms Table
        print("\n### **Parameter Synonyms**\n")
        syn_headers = ["Parameter", "Synonyms"]
        syn_col_widths = [
            max(len("Parameter"), max(len(k) for k in cls._synonyms.keys())),  # Ensure it fits "Parameter"
            max(len("Synonyms"), max(len(", ".join(v)) for v in cls._synonyms.values()))  # Ensure it fits "Synonyms"
        ]
        syn_separator = "+-" + "-+-".join("-" * w for w in syn_col_widths) + "-+"
        print(syn_separator)
        print("| " + " | ".join(h.ljust(syn_col_widths[i]) for i, h in enumerate(syn_headers)) + " |")
        print(syn_separator)
        for param, synonyms in cls._synonyms.items():
            print(f"| {param.ljust(syn_col_widths[0])} | {', '.join(synonyms).ljust(syn_col_widths[1])} |")
        print(syn_separator)


    # --------------------------------------------------------------------
    # Class method to handle ambiguous definitions from end-user
    # --------------------------------------------------------------------
    @classmethod
    def resolvename(cls, param_value, param_key, **unresolved):
        """
        Resolves the correct parameter value using known synonyms.

        - If param_value is already set (not None), return it.
        - If a synonym exists in **unresolved, assign its value.
        - If multiple synonyms of the same parameter appear in **unresolved, raise an error.
        - Otherwise, return None.

        Parameters:
        - `param_name` (any): The original value (if provided).
        - `param_key` (str): The legitimate parameter name we are resolving.
        - `unresolved` (dict): The dictionary of unrecognized keyword arguments.

        Returns:
        - The resolved value or None if not found.
        """
        if param_value is not None:
            return param_value  # The parameter is explicitly defined, do not override
        if not unresolved:      # shortcut
            return None
        resolved_value = None
        found_keys = []
        # Check if param_key itself is present in unresolved
        if param_key in unresolved:
            found_keys.append(param_key)
            resolved_value = unresolved[param_key]
        # Check if any of its synonyms are in unresolved
        if param_key in cls._synonyms:
            for synonym in cls._synonyms[param_key]:
                if synonym in unresolved:
                    found_keys.append(synonym)
                    resolved_value = unresolved[synonym]
        # Raise error if multiple synonyms were found
        if len(found_keys) > 1:
            raise ValueError(
                f"Conflicting definitions: Multiple synonyms {found_keys} were provided for '{param_key}'."
            )
        return resolved_value


    # --------------------------------------------------------------------
    # overloading binary addition (note that the output is of type layer)
    # --------------------------------------------------------------------
    def __add__(self, other):
        """ C = A + B | overload + operator """
        if isinstance(other, layer):
            res = duplicate(self)
            res._nmeshmin = min(self._nmeshmin, other._nmeshmin)
            # Propagate substance
            if self._substance is None:
                res._substance = other._substance
            else:
                if isinstance(self._substance, migrant) and isinstance(other._substance, migrant):
                    if self._substance.M != other._substance.M:
                        print("Warning: the smallest substance is propagated everywhere")
                    res._substance = self._substance if self._substance.M <= other._substance.M else other._substance
                else:
                    res._substance = None
            # Concatenate general attributes
            for p in ["_name", "_type", "_material", "_code", "_nlayer"]:
                setattr(res, p, getattr(self, p) + getattr(other, p))
            # Concatenate numeric arrays
            for p in ["_l", "_D", "_k", "_C0", "_rho", "_T"]:
                setattr(res, p, np.concatenate((getattr(self, p), getattr(other, p))))
            # Handle history tracking
            res._layerclass_history = self.layerclass_history + other.layerclass_history
            res._ispolymer_history = self.ispolymer_history + other.ispolymer_history
            res._chemicalsubstance_history = self.chemicalsubstance_history + other.chemicalsubstance_history
            # Manage layerLink attributes (Dlink, klink, C0link, Tlink, llink)
            property_map = {
                "Dlink": ("D", self.Dlink, other.Dlink),
                "klink": ("k", self.klink, other.klink),
                "C0link": ("C0", self.C0link, other.C0link),
                "Tlink": ("T", self.Tlink, other.Tlink),
                "llink": ("l", self.llink, other.llink),
            }
            for attr, (prop, self_link, other_link) in property_map.items():
                if (self_link is not None) and (other_link is not None):
                    # Case 1: Both have a link → Apply `+`
                    setattr(res, '_'+attr, self_link + other_link)
                elif self_link is not None:
                    # Case 2: Only self has a link → Use as-is
                    setattr(res, '_'+attr, self_link)
                elif other_link is not None:
                    # Case 3: Only other has a link → Shift indices and use
                    shifted_link = duplicate(other_link)
                    shifted_link.indices += len(getattr(self, prop))
                    setattr(res, '_'+attr, shifted_link)
                else:
                    # Case 4: Neither has a link → Result is None
                    setattr(res, '_'+attr, None)
            return res
        else:
            raise ValueError("Invalid layer object")


    # --------------------------------------------------------------------
    # overloading binary multiplication (note that the output is of type layer)
    # --------------------------------------------------------------------
    def __mul__(self,ntimes):
        """ nA = A*n | overload * operator """
        if isinstance(ntimes, int) and ntimes>0:
            res = duplicate(self)
            if ntimes>1:
                for n in range(1,ntimes): res += self
            return res
        else: raise ValueError("multiplicator should be a strictly positive integer")


    # --------------------------------------------------------------------
    # len method
    # --------------------------------------------------------------------
    def __len__(self):
        """ length method """
        return self._nlayer

    # --------------------------------------------------------------------
    # object indexing (get,set) method
    # --------------------------------------------------------------------
    def __getitem__(self,i):
        """ get indexing method """
        res = duplicate(self)
        # check indices
        isscalar = isinstance(i,int)
        if isinstance(i,slice):
            if i.step==None: j = list(range(i.start,i.stop))
            else: j = list(range(i.start,i.stop,i.step))
            res._nlayer = len(j)
        if isinstance(i,int): res._nlayer = 1
        # pick indices for each property
        for p in ["_name","_type","_material","_l","_D","_k","_C0"]:
            content = getattr(self,p)
            try:
                if isscalar: setattr(res,p,content[i:i+1])
                else: setattr(res,p,content[i])
            except IndexError as err:
                if self.verbosity>0 and self.verbose:
                    print("bad layer object indexing: ",err)
        return res

    def __setitem__(self,i,other):
        """ set indexing method """
        # check indices
        if isinstance(i,slice):
            if i.step==None: j = list(range(i.start,i.stop))
            else: j = list(range(i.start,i.stop,i.step))
        elif isinstance(i,int): j = [i]
        else:raise IndexError("invalid index")
        islayer = isinstance(other,layer)
        isempty = not islayer and isinstance(other,list) and len(other)<1
        if isempty:         # empty right hand side
            for p in ["_name","_type","_material","_l","_D","_k","_C0"]:
                content = getattr(self,p)
                try:
                    newcontent = [content[k] for k in range(self._nlayer) if k not in j]
                except IndexError as err:
                    if self.verbosity>0 and self.verbose:
                        print("bad layer object indexing: ",err)
                if isinstance(content,np.ndarray) and not isinstance(newcontent,np.ndarray):
                    newcontent = np.array(newcontent)
                setattr(self,p,newcontent)
            self._nlayer = len(newcontent)
        elif islayer:        # islayer right hand side
            nk1 = len(j)
            nk2 = other._nlayer
            if nk1 != nk2:
                raise IndexError("the number of elements does not match the number of indices")
            for p in ["_name","_type","_material","_l","_D","_k","_C0"]:
                content1 = getattr(self,p)
                content2 = getattr(other,p)
                for k in range(nk1):
                    try:
                        content1[j[k]] = content2[k]
                    except IndexError as err:
                        if self.verbosity>0 and self.verbose:
                            print("bad layer object indexing: ",err)
                setattr(self,p,content1)
        else:
            raise ValueError("only [] or layer object are accepted")


    # --------------------------------------------------------------------
    # Getter methods (show private/hidden properties and meta-properties)
    # --------------------------------------------------------------------
    # Return class or instance attributes
    @property
    def physicalstate(self): return self._physicalstate
    @property
    def chemicalclass(self): return self._chemicalclass
    @property
    def chemicalsubstance(self): return self._chemicalsubstance
    @property
    def polarityindex(self):
        # rescaled to match predictions - standard scale [0,10.2] - predicted scale [0,7.12]
        return self._polarityindex * migrant("water").polarityindex/10.2
    @property
    def ispolymer(self): return self.chemicalclass == "polymer"
    @property
    def issolid(self): return self.physicalstate == "solid"
    @property
    def layerclass_history(self):
        return self._layerclass_history if self._layerclass_history != [] else [self.layerclass]
    @property
    def ispolymer_history(self):
        return self._ispolymer_history if self._ispolymer_history != [] else [self.ispolymer]
    @property
    def chemicalsubstance_history(self):
        return self._chemicalsubstance_history if self._chemicalsubstance_history != [] else [self.chemicalsubstance]
    @property
    def layerclass(self): return type(self).__name__
    @property
    def name(self): return self._name
    @property
    def type(self): return self._type
    @property
    def material(self): return self._material
    @property
    def code(self): return self._code
    @property
    def l(self): return self._l if not self.hasllink else self.llink.getfull(self._l)
    @property
    def D(self):
        Dtmp = None
        if self.Dmodel == "default": # default behavior
            Dtmp = self._compute_Dmodel()
        elif callable(self.Dmodel): # user override
            Dtmp = self.Dmodel()
        if Dtmp is not None:
            Dtmp = np.full_like(self._D, Dtmp,dtype=np.float64)
            if self.hasDlink:
                return self.Dlink.getfull(Dtmp) # substitution rules are applied as defined in Dlink
            else:
                return Dtmp
        return self._D if not self.hasDlink else self.Dlink.getfull(self._D)
    @property
    def k(self):
        ktmp = None
        if self.kmodel == "default": # default behavior
            ktmp = self._compute_kmodel()
        elif callable(self.kmodel): # user override
            ktmp = self.kmodel()
        if ktmp is not None:
            ktmp = np.full_like(self._k, ktmp,dtype=np.float64)
            if self.hasklink:
                return self.klink.getfull(ktmp) # substitution rules are applied as defined in klink
            else:
                return ktmp
        return self._k if not self.hasklink else self.klink.getfull(self._k)
    @property
    def C0(self): return self._C0 if not self.hasC0link else self.COlink.getfull(self._C0)
    @property
    def rho(self): return self._rho
    @property
    def T(self): return self._T if not self.hasTlink else self.Tlink.getfull(self._T)
    @property
    def TK(self): return self._T+T0K
    @property
    def lunit(self): return self._lunit
    @property
    def Dunit(self): return self._Dunit
    @property
    def kunit(self): return self._kunit
    @property
    def Cunit(self): return self._Cunit
    @property
    def rhounit(self): return self._rhounit
    @property
    def Tunit(self): return self._Tunit
    @property
    def TKunit(self): return "K"
    @property
    def n(self): return self._nlayer
    @property
    def nmesh(self): return self._nmesh
    @property
    def nmeshmin(self): return self._nmeshmin
    @property
    def resistance(self): return self.l*self.k/self.D
    @property
    def permeability(self): return self.D/(self.l*self.k)
    @property
    def lag(self): return self.l**2/(6*self.D)
    @property
    def pressure(self): return self.k*self.C0
    @property
    def thickness(self): return sum(self.l)
    @property
    def concentration(self): return sum(self.l*self.C0)/self.thickness
    @property
    def relative_thickness(self): return self.l/self.thickness
    @property
    def relative_resistance(self): return self.resistance/sum(self.resistance)
    @property
    def rank(self): return (self.n-np.argsort(np.array(self.resistance))).tolist()
    @property
    def referencelayer(self): return np.argmax(self.resistance)
    @property
    def lreferencelayer(self): return self.l[self.referencelayer]
    @property
    def Foscale(self): return self.D[self.referencelayer]/self.lreferencelayer**2

    # substance/solute/migrant/chemical (of class migrant or None)
    @property
    def substance(self): return self._substance
    @property
    def migrant(self): return self.substance # alias/synonym of substance
    @property
    def solute(self): return self.substance # alias/synonym of substance
    @property
    def chemical(self): return self.substance # alias/synonym of substance
    # medium (of class foodlayer or None)
    @property
    def medium(self): return self._medium

    # Dmodel and kmodel returned as properties (they are lambda functions)
    # Note about the implementation: They are attributes that remain None or a callable function
    # polymer and mass are udpdated on the fly (the code loops over all layers)
    @property
    def Dmodel(self):
        return self._Dmodel
    @Dmodel.setter
    def Dmodel(self,value):
        if value is None or callable(value):
            self._Dmodel = value
        else:
            raise ValueError("Dmodel must be None or a callable function")
    @property
    def _compute_Dmodel(self):
        """Return a callable function that evaluates D with updated parameters."""
        if not isinstance(self._substance,migrant) or self._substance.Deval() is None:
            return lambda **kwargs: None  # Return a function that always returns None
        template = self._substance.Dtemplate.copy()
        template.update()
        def func(**kwargs):
            D = np.empty_like(self._D)
            for (i,),T in np.ndenumerate(self.T.ravel()): # loop over all layers via T
                template.update(polymer=self.layerclass_history[i],T=T) # updated layer properties
                # inherit eventual user parameters
                D[i] = self._substance.D.evaluate(**dict(template, **kwargs))
            return D
        return func # we return a callable function not a value

    # polarity index and molar volume are updated on the fly
    @property
    def kmodel(self):
        return self._kmodel
    @kmodel.setter
    def kmodel(self,value):
        if value is None or callable(value):
            self._kmodel = value
        else:
            raise ValueError("kmodel must be None or a callable function")
    @property
    def _compute_kmodel(self):
        """Return a callable function that evaluates k with updated parameters."""
        if not isinstance(self._substance,migrant) or self._substance.keval() is None:
            return lambda **kwargs: None  # Return a function that always returns None
        template = self._substance.ktemplate.copy()
        # add solute (i) properties: Pi and Vi have been set by loadpubchem already
        template.update(ispolymer = True)
        def func(**kwargs):
            k = np.full_like(self._k,self._k,dtype=np.float64)
            for (i,),T in np.ndenumerate(self.T.ravel()): # loop over all layers via T
                if not self.ispolymer_history[i]: # k can be evaluated only in polymes via FH theory
                    continue # we keep the existing k value
                # add/update monomer properties
                monomer = migrant(self.chemicalsubstance_history[i])
                template.update(Pk = monomer.polarityindex,
                                Vk = monomer.molarvolumeMiller)
                # inherit eventual user parameters
                k[i] = self._substance.k.evaluate(**dict(template, **kwargs))
            return k
        return func # we return a callable function not a value


    @property
    def hasDmodel(self):
        """Returns True if a Dmodel has been defined"""
        if hasattr(self, "_compute_Dmodel"):
            if self._compute_Dmodel() is not None:
                return True
            elif callable(self.Dmodel):
                return self.Dmodel() is not None
        return False

    @property
    def haskmodel(self):
        """Returns True if a kmodel has been defined"""
        if hasattr(self, "_compute_kmodel"):
            if self._compute_kmodel() is not None:
                return True
            elif callable(self.kmodel):
                return self.kmodel() is not None
        return False


    # --------------------------------------------------------------------
    # comparators based resistance
    # --------------------------------------------------------------------
    def __eq__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1==value2

    def __ne__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1!=value2

    def __lt__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1<value2

    def __gt__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1>value2

    def __le__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1<=value2

    def __ge__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1>=value2


    # --------------------------------------------------------------------
    # Generates mesh
    # --------------------------------------------------------------------
    def mesh(self,nmesh=None,nmeshmin=None):
        """ nmesh() generates mesh based on nmesh and nmeshmin, nmesh(nmesh=value,nmeshmin=value) """
        if nmesh==None: nmesh = self.nmesh
        if nmeshmin==None: nmeshmin = self.nmeshmin
        if nmeshmin>nmesh: nmeshmin,nmesh = nmesh, nmeshmin
        # X = mesh distribution (number of nodes per layer)
        X = np.ones(self._nlayer)
        for i in range(1,self._nlayer):
           X[i] = X[i-1]*(self.permeability[i-1]*self.l[i])/(self.permeability[i]*self.l[i-1])
        X = np.maximum(nmeshmin,np.ceil(nmesh*X/sum(X)))
        X = np.round((X/sum(X))*nmesh).astype(int)
        # do the mesh
        x0 = 0
        mymesh = []
        for i in range(self._nlayer):
            mymesh.append(mesh(self.l[i]/self.l[self.referencelayer],X[i],x0=x0,index=i))
            x0 += self.l[i]
        return mymesh

    # --------------------------------------------------------------------
    # Setter methods and tools to validate inputs checknumvalue and checktextvalue
    # --------------------------------------------------------------------
    @physicalstate.setter
    def physicalstate(self,value):
        if value not in ("solid","liquid","gas","supercritical"):
            raise ValueError(f"physicalstate must be solid/liduid/gas/supercritical and not {value}")
        self._physicalstate = value
    @chemicalclass.setter
    def chemicalclass(self,value):
        if value not in ("polymer","other"):
            raise ValueError(f"chemicalclass must be polymer/oher and not {value}")
        self._chemicalclass= value
    @chemicalsubstance.setter
    def chemicalsubstance(self,value):
        if not isinstance(value,str):
            raise ValueError("chemicalsubtance must be str not a {type(value).__name__}")
        self._chemicalsubstance= value
    @polarityindex.setter
    def polarityindex(self,value):
        if not isinstance(value,(float,int)):
            raise ValueError("polarity index must be float not a {type(value).__name__}")
        self._polarityindex= value

    def checknumvalue(self,value,ExpectedUnits=None):
        """ returns a validate value to set properties """
        if isinstance(value,tuple):
            value = check_units(value,ExpectedUnits=ExpectedUnits)
        if isinstance(value,int): value = float(value)
        if isinstance(value,float): value = np.array([value])
        if isinstance(value,list): value = np.array(value)
        if len(value)>self._nlayer:
            value = value[:self._nlayer]
            if self.verbosity>1 and self.verbose:
                print('dimension mismatch, the extra value(s) has been removed')
        elif len(value)<self._nlayer:
            value = np.concatenate((value,value[-1:]*np.ones(self._nlayer-len(value))))
            if self.verbosity>1 and self.verbose:
                print('dimension mismatch, the last value has been repeated')
        return value

    def checktextvalue(self,value):
        """ returns a validate value to set properties """
        if not isinstance(value,list): value = [value]
        if len(value)>self._nlayer:
            value = value[:self._nlayer]
            if self.verbosity>1 and self.verbose:
                print('dimension mismatch, the extra entry(ies) has been removed')
        elif len(value)<self._nlayer:
            value = value + value[-1:]*(self._nlayer-len(value))
            if self.verbosity>1 and self.verbose:
                print('dimension mismatch, the last entry has been repeated')
        return value

    @l.setter
    def l(self,value): self._l =self.checknumvalue(value,layer._defaults["lunit"])
    @D.setter
    def D(self,value): self._D=self.checknumvalue(value,layer._defaults["Dunit"])
    @k.setter
    def k(self,value): self._k =self.checknumvalue(value,layer._defaults["kunit"])
    @C0.setter
    def C0(self,value): self._C0 =self.checknumvalue(value,layer._defaults["Cunit"])
    @rho.setter
    def rho(self,value): self._rho =self.checknumvalue(value,layer._defaults["rhounit"])
    @T.setter
    def T(self,value): self._T =self.checknumvalue(value,layer._defaults["Tunit"])
    @name.setter
    def name(self,value): self._name =self.checktextvalue(value)
    @type.setter
    def type(self,value): self._type =self.checktextvalue(value)
    @material.setter
    def material(self,value): self._material =self.checktextvalue(value)
    @nmesh.setter
    def nmesh(self,value): self._nmesh = max(value,self._nlayer*self._nmeshmin)
    @nmeshmin.setter
    def nmeshmin(self,value): self._nmeshmin = max(value,round(self._nmesh/(2*self._nlayer)))
    @substance.setter
    def substance(self,value):
        if isinstance(value,str):
            value = migrant(value)
        if not isinstance(value,migrant) and value is not None:
            raise TypeError(f"value must be a migrant not a {type(value).__name__}")
        self._substance = value
    @migrant.setter
    def migrant(self,value):
        self.substance = value
    @chemical.setter
    def chemical(self,value):
        self.substance = value
    @solute.setter
    def solute(self,value):
        self.substance = value
    @medium.setter
    def medium(self,value):
        from patankar.food import foodlayer
        if not isinstance(value,foodlayer):
            raise TypeError(f"value must be a foodlayer not a {type(value).__name__}")
        self._medium = value

    # --------------------------------------------------------------------
    #  getter and setter for links: Dlink, klink, C0link, Tlink, llink
    # --------------------------------------------------------------------
    @property
    def Dlink(self):
        """Getter for Dlink"""
        return self._Dlink
    @Dlink.setter
    def Dlink(self, value):
        """Setter for Dlink"""
        self._Dlink = self._initialize_link(value, "D")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def klink(self):
        """Getter for klink"""
        return self._klink
    @klink.setter
    def klink(self, value):
        """Setter for klink"""
        self._klink = self._initialize_link(value, "k")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def C0link(self):
        """Getter for C0link"""
        return self._C0link
    @C0link.setter
    def C0link(self, value):
        """Setter for C0link"""
        self._C0link = self._initialize_link(value, "C0")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def Tlink(self):
        """Getter for Tlink"""
        return self._Tlink
    @Tlink.setter
    def Tlink(self, value):
        """Setter for Tlink"""
        self._Tlink = self._initialize_link(value, "T")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def llink(self):
        """Getter for llink"""
        return self._llink
    @llink.setter
    def llink(self, value):
        """Setter for llink"""
        self._llink = self._initialize_link(value, "l")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def hasDlink(self):
        """Returns True if Dlink is defined"""
        return self.Dlink is not None
    @property
    def hasklink(self):
        """Returns True if klink is defined"""
        return self.klink is not None
    @property
    def hasC0link(self):
        """Returns True if C0link is defined"""
        return self.C0link is not None
    @property
    def hasTlink(self):
        """Returns True if Tlink is defined"""
        return self.Tlink is not None
    @property
    def hasllink(self):
        """Returns True if llink is defined"""
        return self.llink is not None

    # --------------------------------------------------------------------
    # returned LaTeX-formated properties
    # --------------------------------------------------------------------
    def Dlatex(self, numdigits=4, units=r"\mathrm{m^2 \cdot s^{-1}}",prefix="D=",mathmode="$"):
        """Returns diffusivity values (D) formatted in LaTeX scientific notation."""
        return [format_scientific_latex(D, numdigits, units, prefix,mathmode) for D in self.D]

    def klatex(self, numdigits=4, units="a.u.",prefix="k=",mathmode="$"):
        """Returns Henry-like values (k) formatted in LaTeX scientific notation."""
        return [format_scientific_latex(k, numdigits, units, prefix,mathmode) for k in self.k]

    def llatex(self, numdigits=4, units="m",prefix="l=",mathmode="$"):
        """Returns thickness values (k) formatted in LaTeX scientific notation."""
        return [format_scientific_latex(l, numdigits, units, prefix,mathmode) for l in self.l]

    def C0latex(self, numdigits=4, units="a.u.",prefix="C0=",mathmode="$"):
        """Returns Initial Concentratoin values (C0) formatted in LaTeX scientific notation."""
        return [format_scientific_latex(c, numdigits, units, prefix,mathmode) for c in self.C0]

    # --------------------------------------------------------------------
    # hash methods (assembly and layer-by-layer)
    # note that list needs to be converted into tuples to be hashed
    # --------------------------------------------------------------------
    def __hash__(self):
        """ hash layer-object (assembly) method """
        return hash((tuple(self._name),
                     tuple(self._type),
                     tuple(self._material),
                     tuple(self._l),
                     tuple(self._D),
                     tuple(self.k),
                     tuple(self._C0),
                     tuple(self._rho)))

    # layer-by-layer @property = decoration to consider it
    # as a property instead of a method/attribute
    # comprehension for n in range(self._nlayer) applies it to all layers
    @property
    def hashlayer(self):
        """ hash layer (layer-by-layer) method """
        return [hash((self._name[n],
                      self._type[n],
                      self._material[n],
                      self._l[n],
                      self._D[n],
                      self.k[n],
                      self._C0[n],
                      self._rho[n]))
                for n in range(self._nlayer)
                ]


    # --------------------------------------------------------------------
    # repr method (since the getter are defined, the '_' is dropped)
    # --------------------------------------------------------------------
    # density and temperature are not shown
    def __repr__(self):
        """ disp method """
        print("\n[%s version=%0.4g, contact=%s]" % (self.__description,self.__version,self.__contact))
        if self._nlayer==0:
            print("empty %s" % (self.__description))
        else:
            hasDmodel, haskmodel = self.hasDmodel, self.haskmodel
            hasDlink, hasklink, hasC0link, hasTlink, hasllink = self.hasDlink, self.hasklink, self.hasC0link, self.hasTlink, self.hasllink
            properties_hasmodel = {"l":False,"D":hasDmodel,"k":haskmodel,"C0":False}
            properties_haslink = {"l":hasllink,"D":hasDlink,"k":hasklink,"C0":hasC0link,"T":hasTlink}
            if hasDmodel or haskmodel:
                properties_hasmodel["T"] = False
            fmtval = '%10s: '+self._printformat+" [%s]"
            fmtstr = '%10s= %s'
            if self._nlayer==1:
                print(f'monolayer of {self.__description}:')
            else:
                print(f'{self._nlayer}-multilayer of {self.__description}:')
            for n in range(1,self._nlayer+1):
                modelinfo = {
                    "D": f"{self._substance.D.__name__}({self.layerclass_history[n-1]},{self._substance},T={float(self.T[0])} {self.Tunit})" if hasDmodel else "",
                    "k": f"{self._substance.k.__name__}(<{self.chemicalsubstance_history[n-1]}>,{self._substance})" if haskmodel else "",
                    }
                print('-- [ layer %d of %d ] ---------- barrier rank=%d --------------'
                      % (n,self._nlayer,self.rank[n-1]))
                for p in ["name","type","material","code"]:
                    v = getattr(self,p)
                    print('%10s: "%s"' % (p,v[n-1]),flush=True)
                for p in properties_hasmodel.keys():
                    v = getattr(self,p)                 # value
                    vunit = getattr(self,p[0]+"unit")   # value unit
                    print(fmtval % (p,v[n-1],vunit),flush=True)
                    isoverridenbylink = False
                    if properties_haslink[p]:
                        isoverridenbylink = not np.isnan(getattr(self,p+"link").get(n-1))
                    if isoverridenbylink:
                        print(fmtstr % ("",f"value controlled by {p}link[{n-1}] (external)"),flush=True)
                    elif properties_hasmodel[p]:
                        print(fmtstr % ("",modelinfo[p]),flush=True)
        return str(self)

    def __str__(self):
        """Formatted string representation of layer"""
        all_identical = len(set(self.layerclass_history)) == 1
        cls = self.__class__.__name__ if all_identical else "multilayer"
        return f"<{cls} with {self.n} layer{'s' if self.n>1 else ''}: {self.name}>"

    # --------------------------------------------------------------------
    # Returns the equivalent dictionary from an object for debugging
    # --------------------------------------------------------------------
    def _todict(self):
        """ returns the equivalent dictionary from an object """
        return dict((key, getattr(self, key)) for key in dir(self) if key not in dir(self.__class__))
    # --------------------------------------------------------------------

    # --------------------------------------------------------------------
    # Simplify layers by collecting similar ones
    # --------------------------------------------------------------------
    def simplify(self):
        """ merge continuous layers of the same type """
        nlayer = self._nlayer
        if nlayer>1:
           res = self[0]
           ires = 0
           ireshash = res.hashlayer[0]
           for i in range(1,nlayer):
               if self.hashlayer[i]==ireshash:
                   res.l[ires] = res.l[ires]+self.l[i]
               else:
                   res = res + self[i]
                   ires = ires+1
                   ireshash = self.hashlayer[i]
        else:
             res = self.copy()
        return res

    # --------------------------------------------------------------------
    # Split layers into a tuple
    # --------------------------------------------------------------------
    def split(self):
        """ split layers """
        out = ()
        if self._nlayer>0:
            for i in range(self._nlayer):
                out = out + (self[i],) # (,) special syntax for tuple singleton
        return out

    # --------------------------------------------------------------------
    # deepcopy
    # --------------------------------------------------------------------
    def copy(self,**kwargs):
        """
        Creates a deep copy of the current layer instance.

        Returns:
        - layer: A new layer instance identical to the original.
        """
        return duplicate(self).update(**kwargs)

    # --------------------------------------------------------------------
    # update contact conditions from a foodphysics instance (or do the reverse)
    # material << medium
    # material@medium
    # --------------------------------------------------------------------
    def _from(self,medium=None):
        """Propagates contact conditions from food instance"""
        from patankar.food import foodphysics, foodlayer
        if not isinstance(medium,foodphysics):
            raise TypeError(f"medium must be a foodphysics, foodlayer not a {type(medium).__name__}")
        if not hasattr(medium, "contacttemperature"):
            medium.contacttemperature = self.T[0]
        T = medium.get_param("contacttemperature",40,acceptNone=False)
        self.T = np.full_like(self.T,T,dtype=np.float64)
        if medium.substance is not None:
            self.substance = medium.substance
        else:
            medium.substance = self.substance # do the reverse if substance is not defined in medium
        # inherit fully medium only if it is a foodlayer (foodphysics is too restrictive)
        if isinstance(medium,foodlayer):
            self.medium = medium

    # overload operator <<
    def __lshift__(self, medium):
        """Overloads << to propagate contact conditions from food."""
        self._from(medium)
    # overload operator @ (same as <<)
    def __matmul__(self, medium):
        """Overloads @ to propagate contact conditions from food."""
        self._from(medium)


    # --------------------------------------------------------------------
    # Inheritance registration mechanism associated with food >> layer
    # It is used by food, not by layer (please refer to food.py).
    # Note that layer >> food means mass transfer simulation
    # --------------------------------------------------------------------
    def acknowledge(self, what=None, category=None):
        """
        Register inherited properties under a given category.

        Parameters:
        -----------
        what : str or list of str or a set
            The properties or attributes that have been inherited.
        category : str
            The category under which the properties are grouped.
        """
        if category is None or what is None:
            raise ValueError("Both 'what' and 'category' must be provided.")
        if isinstance(what, str):
            what = {what}  # Convert string to a set
        elif isinstance(what, list):
            what = set(what)  # Convert list to a set for uniqueness
        elif not isinstance(what,set):
            raise TypeError("'what' must be a string, a list, or a set of strings.")
        if category not in self._hasbeeninherited:
            self._hasbeeninherited[category] = set()
        self._hasbeeninherited[category].update(what)

    # --------------------------------------------------------------------
    # migration simulation overloaded as sim = layer >> food
    # using layer >> food without output works also.
    # The result is stored in food.lastsimulation
    # --------------------------------------------------------------------
    def contact(self,medium,**kwargs):
        """alias to migration method"""
        return self.migration(medium,**kwargs)

    def migration(self,medium=None,**kwargs):
        """interface to simulation engine: senspantankar"""
        from patankar.food import foodphysics
        from patankar.migration import senspatankar
        if medium is None:
            medium = self.medium
        if not isinstance(medium,foodphysics):
            raise TypeError(f"medium must be a foodphysics not a {type(medium).__name__}")
        sim = senspatankar(self,medium,**kwargs)
        medium.lastsimulation = sim # store the last simulation result in medium
        medium.lastinput = self # store the last input (self)
        sim.savestate(self,medium) # store store the inputs in sim for chaining
        return sim

    # overloading operation
    def __rshift__(self, medium):
        """Overloads >> to propagate migration to food."""
        from patankar.food import foodphysics
        if not isinstance(medium,foodphysics):
            raise TypeError(f"medium must be a foodphysics object not a {type(medium).__name__}")
        return self.contact(medium)

    # --------------------------------------------------------------------
    # Safe update method
    # --------------------------------------------------------------------
    def update(self, **kwargs):
        """
        Update layer parameters following strict validation rules.

        Rules:
        1) key should be listed in self._defaults
        2) for some keys, synonyms are acceptable as reported in self._synonyms
        3) values cannot be None if they were not None in _defaults
        4) values should be str if they were initially str, idem with bool
        5) values which were numeric (int, float, np.ndarray) should remain numeric.
        6) lists are acceptable as numeric arrays
        7) all numerical (float, np.ndarray, list) except int must be converted into numpy arrays.
           Values which were int in _defaults must remain int and an error should be raised
           if a float value is proposed.
        8) keys listed in _parametersWithUnits can be assigned with tuples (value, "unit").
           They will be converted automatically with check_units(value).
        9) for parameters with a default value None, any value is acceptable
        10) A clear error message should be displayed for any bad value showing the
            current value of the parameter and its default value.
        """

        if not kwargs:  # shortcut
            return self # for chaining

        param_counts = {key: 0 for key in self._defaults}  # Track how many times each param is set

        def resolve_key(key):
            """Resolve key considering synonyms and check for duplicates."""
            for main_key, synonyms in self._synonyms.items():
                if key == main_key or key in synonyms:
                    param_counts[main_key] += 1
                    return main_key
            param_counts[key] += 1
            return key

        def validate_value(key, value):
            """Validate and process the value according to the rules."""
            default_value = self._defaults[key]

            # Rule 3: values cannot be None if they were not None in _defaults
            if value is None and default_value is not None:
                raise ValueError(f"Invalid value for '{key}': None is not allowed. "
                                 f"Current: {getattr(self, key)}, Default: {default_value}")

            # Rule 9: If default is None, any value is acceptable
            if default_value is None:
                return value

            # Rule 4 & 5: Ensure type consistency (str, bool, or numeric types)
            if isinstance(default_value, str) and not isinstance(value, str):
                raise TypeError(f"Invalid type for '{key}': Expected str, got {type(value).__name__}. "
                                f"Current: {getattr(self, key)}, Default: {default_value}")
            if isinstance(default_value, bool) and not isinstance(value, bool):
                raise TypeError(f"Invalid type for '{key}': Expected bool, got {type(value).__name__}. "
                                f"Current: {getattr(self, key)}, Default: {default_value}")

            # Rule 6 & 7: Convert numeric types properly
            if isinstance(default_value, (int, float, np.ndarray)):
                if isinstance(value, list):
                    value = np.array(value)

                if isinstance(default_value, int):
                    if isinstance(value, float) or (isinstance(value, np.ndarray) and np.issubdtype(value.dtype, np.floating)):
                        raise TypeError(f"Invalid type for '{key}': Expected integer, got float. "
                                        f"Current: {getattr(self, key)}, Default: {default_value}")
                    if isinstance(value, (int, np.integer)):
                        return int(value)  # Ensure it remains an int
                    raise TypeError(f"Invalid type for '{key}': Expected integer, got {type(value).__name__}. "
                                    f"Current: {getattr(self, key)}, Default: {default_value}")

                if isinstance(value, (int, float, list, np.ndarray)):
                    return np.array(value, dtype=float)  # Convert everything to np.array for floats

                raise TypeError(f"Invalid type for '{key}': Expected numeric, got {type(value).__name__}. "
                                f"Current: {getattr(self, key)}, Default: {default_value}")

            # Rule 8: Convert units if applicable
            if key in self._parametersWithUnits and isinstance(value, tuple):
                value, unit = value
                converted_value, _ = check_units((value, unit), ExpectedUnits=self._parametersWithUnits[key])
                return converted_value

            return value

        # Apply updates while tracking parameter occurrences
        for key, value in kwargs.items():
            resolved_key = resolve_key(key)

            if resolved_key not in self._defaults:
                raise KeyError(f"Invalid key '{key}'. Allowed keys: {list(self._defaults.keys())}.")

            try:
                validated_value = validate_value(resolved_key, value)
                setattr(self, resolved_key, validated_value)
            except (TypeError, ValueError) as e:
                raise ValueError(f"Error updating '{key}': {e}")

        # Ensure that no parameter was set multiple times due to synonyms
        duplicate_keys = [k for k, v in param_counts.items() if v > 1]
        if duplicate_keys:
            raise ValueError(f"Duplicate assignment detected for parameters: {duplicate_keys}. "
                             "Use only one synonym per parameter.")

        return self # to enable chaining

    # Basic tool for debugging
    # --------------------------------------------------------------------
    # STRUCT method - returns the equivalent dictionary from an object
    # --------------------------------------------------------------------
    def struct(self):
        """ returns the equivalent dictionary from an object """
        return dict((key, getattr(self, key)) for key in dir(self) if key not in dir(self.__class__))

# %% Mesh class
# Mesh class
# =======================
class mesh():
    """ simple nodes class for finite-volume methods """
    def __init__(self,l,n,x0=0,index=None):
       self.x0 = x0
       self.l = l
       self.n = n
       de = dw = l/(2*n)
       self.de = np.ones(n)*de
       self.dw = np.ones(n)*dw
       self.xmesh = np.linspace(0+dw,l-de,n) # nodes positions
       self.w = self.xmesh - dw
       self.e = self.xmesh + de
       self.index = np.full(n, int(index), dtype=np.int32)

    def __repr__(self):
        print(f"-- mesh object (layer index={self.index[0]}) --")
        print("%25s = %0.4g" % ("start at x0", self.x0))
        print("%25s = %0.4g" % ("domain length l", self.l))
        print("%25s = %0.4g" % ("number of nodes n", self.n))
        print("%25s = %0.4g" % ("dw", self.dw[0]))
        print("%25s = %0.4g" % ("de", self.de[0]))
        return "mesh%d=[%0.4g %0.4g]" % \
            (self.n,self.x0+self.xmesh[0],self.x0+self.xmesh[-1])


# %% Material classes
"""
=======================================================
Child classes derived from layer
this section can be extended to define specific layers
    * polymer
    * ink
    * air
    * paper and board

These classes are more flexible than the parent class layer.
They can include temperature dependence, refined tunning, etc.

Properties taken from
    * linear thermal expansoin
    https://omnexus.specialchem.com/polymer-properties/properties/coefficient-of-linear-thermal-expansion

Once the layers are incorporated in a multilayer structure,
they loose their original subclass and become only an object
layer. These subclasses are therefore useful to refine the
properties of each layer before standarizing them.

Polarity index is used as an helper to set Henri-like coefficients.
A common scale for polarity index for solvents is from 0 to 10:
- 0-3: Non-polar solvents (e.g., hexane)
- 4-6: Moderately polar solvents (e.g., acetone)
- 7-10: Polar solvents (e.g., water)
We consider that polymers are solid solvents.
=========================================================
"""

# <<<<<<<<<<<<<<<<<<<<<<< P O L Y O L E F I N S >>>>>>>>>>>>>>>>>>>>>>

# <-- LDPE polymer ---------------------------------->
class LDPE(layer):
    """  extended pantankar.layer for low-density polyethylene LDPE  """
    _chemicalsubstance = "ethylene" # monomer for polymers
    _polarityindex = 1.0  # Very non-polar (typical for polyolefins)
    def __init__(self,l=100e-6,D=1e-12,T=None,
                 k=None,C0=None,lunit=None,Dunit=None,kunit=None,Cunit=None,
                 layername="layer in LDPE",**extra):
        """ LDPE layer constructor """
        super().__init__(
                       l=l,D=D,k=k,C0=C0, T=T,
                       lunit=lunit,Dunit=Dunit,kunit=kunit,Cunit=Cunit,
                       layername=layername,
                       layertype="polymer", # set by default at inititialization
                       layermaterial="low-density polyethylene",
                       layercode="LDPE",
                       **extra
                       )
    def density(self,T=None):
        """ density of LDPE: density(T in K) """
        T = self.T if T is None else check_units(T,None,"degC")[0]
        return 920 *(1-3*(T-layer._defaults["Td"])*20e-5),"kg/m**3" # lowest temperature
    @property
    def Tg(self):
        """ glass transition temperature of LDPE """
        return -130,"degC" # lowest temperature


# <-- HDPE polymer ---------------------------------->
class HDPE(layer):
    """  extended pantankar.layer for high-density polyethylene HDPE  """
    _chemicalsubstance = "ethylene" # monomer for polymers
    _polarityindex = 2.0 # Non-polar, slightly higher density, similar overall polarity to LDPE
    def __init__(self,l=500e-6,D=1e-13, T=None,
                 k=None,C0=None,lunit=None,Dunit=None,kunit=None,Cunit=None,
                 layername="layer in HDPE",**extra):
        """ HDPE layer constructor """
        layer.__init__(self,
                       l=l,D=D,k=k,C0=C0, T=T,
                       lunit=lunit,Dunit=Dunit,kunit=kunit,Cunit=Cunit,
                       layername=layername,
                       layertype="polymer", # set by default at inititialization
                       layermaterial="high-density polyethylene",
                       layercode="HDPE",
                       **extra
                       )
    def density(self,T=None):
        """ density of HDPE: density(T in K) """
        T = self.T if T is None else check_units(T,None,"degC")[0]
        return 940 *(1-3*(T-layer._defaults["Td"])*11e-5),"kg/m**3" # lowest temperature
    @property
    def Tg(self):
        """ glass transition temperature of HDPE """
        return -100,"degC" # highest temperature

# <-- LLDPE polymer ---------------------------------->
class LLDPE(layer):
    """ extended pantankar.layer for linear low-density polyethylene LLDPE """
    _chemicalsubstance = "ethylene" # monomer for polymers
    _polarityindex = 1.5 # Similar to LDPE, can be slightly more polar if co-monomer is present
    def __init__(self, l=80e-6, D=1e-12, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in LLDPE",**extra):
        """
        LLDPE layer constructor
        Defaults are set to typical values found in the literature or between
        LDPE/HDPE ones. Adjust them as necessary for your models.
        """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="linear low-density polyethylene",
            layercode="LLDPE",
            **extra
        )
    def density(self, T=None):
        """
        density of LLDPE: density(T in K)
        By default, uses an approximate value between LDPE and HDPE.
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        # Similar formula to LDPE and HDPE, with a coefficient suitable for LLDPE.
        return 915 * (1 - 3 * (T - layer._defaults["Td"]) * 15e-5), "kg/m**3"
    @property
    def Tg(self):
        """
        glass transition temperature of LLDPE
        Typically close to LDPE, though slightly higher or lower can be found in the literature.
        """
        return -120, "degC"

# <-- PP polymer ---------------------------------->
class PP(layer):
    _chemicalsubstance = "propylene" # monomer for polymers
    _polarityindex = 1.0  # Among the least polar, similar to PE
    """  extended pantankar.layer for isotactic polypropylene PP  """
    def __init__(self,l=300e-6,D=1e-14, T=None,
                 k=None,C0=None,lunit=None,Dunit=None,kunit=None,Cunit=None,
                 layername="layer in PP",**extra):
        """ PP layer constructor """
        layer.__init__(self,
                       l=l,D=D,k=k,C0=C0, T=T,
                       lunit=lunit,Dunit=Dunit,kunit=kunit,Cunit=Cunit,
                       layername=layername,
                       layertype="polymer", # set by default at inititialization
                       layermaterial="isotactic polypropylene",
                       layercode="PP",
                       **extra
                       )
    def density(self,T=None):
        """ density of PP: density(T in K) """
        T = self.T if T is None else check_units(T,None,"degC")[0]
        return 910 *(1-3*(T-layer._defaults["Td"])*7e-5),"kg/m**3" # lowest temperature
    @property
    def Tg(self):
        """ glass transition temperature of PP """
        return 0,"degC" # highest temperature

# -- PPrubber (atactic polypropylene) ---------------------------------
class PPrubber(layer):
    _chemicalsubstance = "propylene" # monomer for polymers
    _polarityindex = 1.0  # Also very non-polar
    """ extended pantankar.layer for atactic (rubbery) polypropylene PP """
    def __init__(self, l=100e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PPrubber",**extra):
        """ PPrubber layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="atactic polypropylene",
            layercode="aPP",
            **extra
        )
    def density(self, T=None):
        """
        density of atactic (rubbery) PP: density(T in K)
        Approximate initial density ~900 kg/m^3, linear thermal expansion factor
        can be adjusted.
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 900 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of atactic/rubbery PP """
        return -20, "degC"


# -- oPP (bioriented polypropylene) ------------------------------------
class oPP(layer):
    """ extended pantankar.layer for bioriented polypropylene oPP """
    _chemicalsubstance = "propylene" # monomer for polymers
    _polarityindex = 1.0   # Non-polar, but oriented film might have slight morphological differences
    def __init__(self, l=40e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in oPP",**extra):
        """ oPP layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="bioriented polypropylene",
            layercode="oPP",
            **extra
        )
    def density(self, T=None):
        """
        density of bioriented PP: density(T in K)
        Typically close to isotactic PP around ~910 kg/m^3.
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 910 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of bioriented PP """
        return 0, "degC"


# <<<<<<<<<<<<<<<<<<<<<<< P O L Y V I N Y L S >>>>>>>>>>>>>>>>>>>>>>

# -- PS (polystyrene) -----------------------------------------------
class PS(layer):
    """ extended pantankar.layer for polystyrene (PS) """
    _chemicalsubstance = "styrene" # monomer for polymers
    _polarityindex = 3.0  # Slightly more polar than polyolefins, but still considered relatively non-polar
    def __init__(self, l=100e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PS",**extra):
        """ PS layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polystyrene",
            layercode="PS",
            **extra
        )
    def density(self, T=None):
        """
        density of PS: ~1050 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1050 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PS """
        return 100, "degC"


# -- HIPS (high-impact polystyrene) -----------------------------------
class HIPS(layer):
    """ extended pantankar.layer for high-impact polystyrene (HIPS) """
    _chemicalsubstance = "styrene" # monomer for polymers
    _polarityindex = 3.0  # Similar or very close to PS in polarity
    def __init__(self, l=100e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in HIPS",**extra):
        """ HIPS layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="high-impact polystyrene",
            layercode="HIPS",
            **extra
        )
    def density(self, T=None):
        """
        density of HIPS: ~1040 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1040 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of HIPS """
        return 95, "degC"


# -- PBS (assuming a styrene-based polymer) ---------------------------
class SBS(layer):
    _chemicalsubstance = "styrene" # Styrene + butadiene
    _polarityindex = 3.5  # Non-polar but somewhat more interactive than pure PE/PP due to styrene units
    """
    extended pantankar.layer for a styrene-based SBS
    Adjust Tg/density as needed for your scenario.
    """
    def __init__(self, l=100e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PBS",**extra):
        """ DBS layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="styrene-based polymer SBS",
            layercode="SBS",
            **extra
        )
    def density(self, T=None):
        """
        density of 'DBS': approximate, around ~1030 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1030 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of 'DBS' """
        return 90, "degC"


# -- rigidPVC ---------------------------------------------------------
class rigidPVC(layer):
    """ extended pantankar.layer for rigid PVC """
    _chemicalsubstance = "vinyl chloride" # monomer for polymers
    _polarityindex = 4.0  # Chlorine substituents give moderate polarity.
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in rigid PVC",**extra):
        """ rigid PVC layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="rigid PVC",
            layercode="PVC",
            **extra
        )
    def density(self, T=None):
        """
        density of rigid PVC: ~1400 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1400 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of rigid PVC """
        return 80, "degC"


# -- plasticizedPVC ---------------------------------------------------
class plasticizedPVC(layer):
    """ extended pantankar.layer for plasticized PVC """
    _chemicalsubstance = "vinyl chloride" # monomer for polymers
    _polarityindex = 4.5  # Plasticizers can slightly change overall polarity/solubility.
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in plasticized PVC",**extra):
        """ plasticized PVC layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="plasticized PVC",
            layercode="pPVC",
            **extra
        )
    def density(self, T=None):
        """
        density of plasticized PVC: ~1300 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1300 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of plasticized PVC """
        return -40, "degC"


# <<<<<<<<<<<<<<<<<<<<<<< P O L Y E S T E R S >>>>>>>>>>>>>>>>>>>>>>

# -- gPET (glassy PET, T < 76°C) --------------------------------------
class gPET(layer):
    """ extended pantankar.layer for PET in its glassy state (below ~76°C) """
    _chemicalsubstance = "ethylene terephthalate" # monomer for polymers
    _polarityindex = 5.0  # Polyester with significant dipolar interactions (Ph = phenylene ring).
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in gPET",**extra):
        """ glassy PET layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="glassy PET",
            layercode="PET",
            **extra
        )
    def density(self, T=None):
        """
        density of glassy PET: ~1350 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1350 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ approximate glass transition temperature of PET """
        return 76, "degC"


# -- rPET (rubbery PET, T > 76°C) --------------------------------------
class rPET(layer):
    """ extended pantankar.layer for PET in its rubbery state (above ~76°C) """
    _chemicalsubstance = "ethylene terephthalate" # monomer for polymers
    _polarityindex = 5.0  # Polyester with significant dipolar interactions (Ph = phenylene ring).
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in rPET",**extra):
        """ rubbery PET layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="rubbery PET",
            layercode="rPET",
            **extra
        )
    def density(self, T=None):
        """
        density of rubbery PET: ~1350 kg/m^3
        but with a different expansion slope possible, if needed
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1350 * (1 - 3*(T - layer._defaults["Td"]) * 1e-4), "kg/m**3"

    @property
    def Tg(self):
        """ approximate glass transition temperature of PET """
        return 76, "degC"


# -- PBT --------------------------------------------------------------
class PBT(layer):
    """ extended pantankar.layer for polybutylene terephthalate (PBT) """
    _chemicalsubstance = "Buthylene terephthalate" # monomer for polymers
    _polarityindex = 5.5  # Similar to PET, slight structural differences
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PBT",**extra):
        """ PBT layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polybutylene terephthalate",
            layercode="PBT",
            **extra
        )
    def density(self, T=None):
        """
        density of PBT: ~1310 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1310 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PBT """
        return 40, "degC"


# -- PEN --------------------------------------------------------------
class PEN(layer):
    _chemicalsubstance = "Buthylene terephthalate" # monomer for polymers
    _polarityindex = 6  # More aromatic than PET, often better barrier properties
    """ extended pantankar.layer for polyethylene naphthalate (PEN) """
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PEN",**extra):
        """ PEN layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polyethylene naphthalate",
            layercode="PEN",
            **extra
        )
    def density(self, T=None):
        """
        density of PEN: ~1330 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1330 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PEN """
        return 120, "degC"


# <<<<<<<<<<<<<<<<<<<<<<< P O L Y A M I D E S >>>>>>>>>>>>>>>>>>>>>>

# -- PA6 --------------------------------------------------------------
class PA6(layer):
    _chemicalsubstance = "caprolactam" # monomer for polymers
    _polarityindex = 7.5  # Strong hydrogen-bonding, thus quite polar.
    """ extended pantankar.layer for polyamide 6 (PA6) """
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PA6",**extra):
        """ PA6 layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polyamide 6",
            layercode="PA6",
            **extra
        )
    def density(self, T=None):
        """
        density of PA6: ~1140 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1140 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PA6 """
        return 50, "degC"


# -- PA66 -------------------------------------------------------------
class PA66(layer):
    _chemicalsubstance = "hexamethylenediamine" # monomer for polymers
    _polarityindex = 7.5  # Similar to PA6, strongly polar with hydrogen bonds.
    """ extended pantankar.layer for polyamide 66 (PA66) """
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PA66",**extra):
        """ PA66 layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polyamide 6,6",
            layercode="PA6,6",
            **extra
        )
    def density(self, T=None):
        """
        density of PA66: ~1150 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1150 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PA66 """
        return 70, "degC"


# <<<<<<<<<<<<<<<<<<<<<<< A D H E S I V E S >>>>>>>>>>>>>>>>>>>>>>

# -- AdhesiveNaturalRubber --------------------------------------------
class AdhesiveNaturalRubber(layer):
    _chemicalsubstance = "cis-1,4-polyisoprene" # monomer for polymers
    _polarityindex = 2  # Mostly non-polar; elasticity from cis-isoprene chains.
    """ extended pantankar.layer for natural rubber adhesives """
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive natural rubber",**extra):
        """ constructor for a natural rubber-based adhesive layer """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="natural rubber adhesive",
            layercode="rubber",
            **extra
        )
    def density(self, T=None):
        """ typical density ~910 kg/m^3, adjust as needed """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 910 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ approximate Tg of natural rubber adhesives """
        return -70, "degC"


# -- AdhesiveSyntheticRubber ------------------------------------------
class AdhesiveSyntheticRubber(layer):
    _chemicalsubstance = "cis-1,4-polyisoprene" # styrene-butadiene rubber (SBR) or similar
    _polarityindex = 2.0  # non-polar or slightly polar, depending on rubber type.
    """ extended pantankar.layer for synthetic rubber adhesives """
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive synthetic rubber",**extra):
        """ constructor for a synthetic rubber-based adhesive layer """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="synthetic rubber adhesive",
            layercode="sRubber",
            **extra
        )
    def density(self, T=None):
        """ typical density ~920 kg/m^3, adjust as needed """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 920 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ approximate Tg of synthetic rubber adhesives """
        return -50, "degC"


# -- AdhesiveEVA (ethylene-vinyl acetate) ------------------------------
class AdhesiveEVA(layer):
    _chemicalsubstance = "ethylene" # Ethylene + vinyl acetate
    _polarityindex = 2.5  # Mostly non-polar backbone with some polar acetate groups.
    """ extended pantankar.layer for EVA-based adhesives """
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive EVA",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="EVA adhesive",
            layercode="EVA",
            **extra
        )
    def density(self, T=None):
        """ typical density ~930 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 930 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of EVA adhesives """
        return -30, "degC"


# -- AdhesiveVAE (vinyl acetate-ethylene) -----------------------------
class AdhesiveVAE(layer):
    _chemicalsubstance = "vinyl acetate" # Ethylene + vinyl acetate
    _polarityindex = 4.0  # More polar than EVA (larger fraction of acetate).
    """ extended pantankar.layer for VAE adhesives """
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive VAE",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="VAE adhesive",
            layercode="VAE",
            **extra
        )
    def density(self, T=None):
        """ typical density ~950 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 950 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of VAE adhesives """
        return 10, "degC"


# -- AdhesivePVAC (polyvinyl acetate) ---------------------------------

class AdhesivePVAC(layer):
    """ extended pantankar.layer for PVAc adhesives """
    _chemicalsubstance = "vinyl acetate" # Vinyl acetate (CH₂=CHO–Ac)
    _polarityindex = 7.0  # PVAc is fairly polar (acetate groups)
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive PVAc",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="PVAc adhesive",
            layercode="PVAc",
            **extra
        )
    def density(self, T=None):
        """ typical density ~1100 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1100 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of PVAc adhesives """
        return 35, "degC"


# -- AdhesiveAcrylate -------------------------------------------------
class AdhesiveAcrylate(layer):
    """ extended pantankar.layer for acrylate adhesives """
    _chemicalsubstance = "n-butyl acrylate" # Acrylic esters (e.g. n-butyl acrylate)
    _polarityindex = 6.0  # Ester groups confer moderate polarity
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive acrylate",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="acrylate adhesive",
            layercode="Acryl",
            **extra
        )
    def density(self, T=None):
        """ typical density ~1000 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1000 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of acrylate adhesives """
        return -20, "degC"


# -- AdhesivePU (polyurethane) ----------------------------------------
class AdhesivePU(layer):
    """ extended pantankar.layer for polyurethane adhesives """
    _chemicalsubstance = "diisocyanate" # Diisocyanate + polyol (–NH–CO–O–)
    _polarityindex = 5.0  # Can vary widely by chemistry; moderate polarity.
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive PU",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="polyurethane adhesive",
            layercode="PU",
            **extra
        )
    def density(self, T=None):
        """ typical density ~1100 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1100 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of polyurethane adhesives """
        return -50, "degC"


# <<<<<<<<<<<<<<<<<<<<<<< P A P E R   &   C A R D B O A R D >>>>>>>>>>>>>>>>>>>>>>

# -- Paper ------------------------------------------------------------
class Paper(layer):
    """ extended pantankar.layer for paper (cellulose-based) """
    _physicalstate = "porous"       # solid (default), liquid, gas, porous
    _chemicalclass = "other"       # polymer (default), other
    _chemicalsubstance = "cellulose" # Cellulose (β-D-glucopyranose units)
    _polarityindex = 8.5  # Highly polar, strong hydrogen-bonding.
    def __init__(self, l=80e-6, D=1e-15, T=None,  # a guess for barrier properties
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="paper layer",**extra):
        """ Paper layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="paper",
            layermaterial="paper",
            layercode="paper",
            **extra
        )
    def density(self, T=None):
        """
        approximate density for typical paper ~800 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 800 * (1 - 3*(T - layer._defaults["Td"]) * 1e-5), "kg/m**3"

    @property
    def Tg(self):
        """
        glass transition temperature is not typically used for paper,
        but we provide a placeholder.
        """
        return 200, "degC"  # purely illustrative placeholder


# -- Cardboard --------------------------------------------------------
class Cardboard(layer):
    """ extended pantankar.layer for cardboard (cellulose-based) """
    _physicalstate = "porous"       # solid (default), liquid, gas, porous
    _chemicalclass = "other"       # polymer (default), other
    _chemicalsubstance = "cellulose"
    _polarityindex = 8.0  # Can vary widely by chemistry; moderate polarity.
    def __init__(self, l=500e-6, D=1e-15, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="cardboard layer",**extra):
        """ Cardboard layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="paper",
            layermaterial="cardboard",
            layercode="board",
            **extra
        )
    def density(self, T=None):
        """
        approximate density for typical cardboard ~700 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 700 * (1 - 3*(T - layer._defaults["Td"]) * 1e-5), "kg/m**3"

    @property
    def Tg(self):
        """
        same placeholder concept for paper-based material
        """
        return 200, "degC"





# <<<<<<<<<<<<<<<<<<<<<<< G A S E S  >>>>>>>>>>>>>>>>>>>>>>

# <-- air | ideal gas layer ---------------------------------->
class air(layer):
    """  extended pantankar.layer for ideal gases such as air """
    _physicalstate = "gas"       # solid (default), liquid, gas, porous
    _chemicalclass = "other"       # polymer (default), other
    def __init__(self,l=1e-2,D=1e-6,T=None,
                 lunit=None,Dunit=None,Cunit=None,
                 layername="air layer",layercode="air",**extra):
        """ air layer constructor """
        T = layer._defaults["T"] if T is None else check_units(T,None,"degC")[0]
        TK = constants["T0K"]+T
        kair = 1/(constants["R"] *TK)
        kairunit = constants["iRT0Kunit"]
        layer.__init__(self,
                       l=l,D=D,k=kair,C0=0,T=T,
                       lunit=lunit,Dunit=Dunit,kunit=kairunit,Cunit=Cunit,
                       layername=layername,
                       layertype="air", # set by default at inititialization
                       layermaterial="ideal gas",
                       layercode="gas",
                       **extra
                       )

    def density(self, T=None):
        """Density of air at atmospheric pressure: density(T in K)"""
        TK = self.TK if T is None else check_units(T,None,"K")[0]
        P_atm = 101325  # Pa (1 atm)
        M_air = 28.9647 # g/mol = 0.0289647 kg/mol (Molar mass of dry air).
        return P_atm / ((constants["R"]/M_air) * TK), "kg/m**3"

# %% For testing and debugging
# ===================================================
# main()
# ===================================================
# for debugging purposes (code called as a script)
# the code is called from here
# ===================================================
if __name__ == '__main__':
    G = air(T=60)
    P = LDPE(D=1e-8,Dunit='cm**2/s')
    P = LDPE(D=(1e-8,"cm**2/s"))
    A = LDPE()
    A=layer(D=1e-14,l=50e-6)
    print("\n",repr(A),"\n"*2)
    A
    B=A*3
    D = B[1:2]
    B=A+A
    C=B[1]
    B.l = [1,2]
    A.struct()
    E = B*4
    #E[1:4]=[]
    E
    # ======
    A = layer(layername = "layer A")
    B = layer(layername = "layer B")
    C = layer(layername = "layer C")
    D = layer(layername = "layer D")
    # test = A+B+C+D
    # test[2] = test[0]
    # test[3] = []
    # test
    test = A+A+B+B+B+C
    print("\n",repr(test),"\n"*2)
    testsimple = test.simplify()
    print("\n",repr(testsimple),"\n"*2)
    testsimple.mesh()

    # test with substance
    m1 = migrant(name='limonene')
    m2 = migrant(name='anisole')
    pet_with_limonene = gPET(substance=m1,D=None,T=40,l=(50,"um"))
    PP_with_anisole = PP(substance=m2,D=None,T=40,l=(200,"um"))
    print("\n",repr(pet_with_limonene),"\n"*2)

    test = pet_with_limonene + PP_with_anisole
    test.D
    print("\n",repr(test),"\n"*2)

Functions

def check_units(value, ProvidedUnits=None, ExpectedUnits=None, defaulttempUnits='degC')

check numeric inputs and convert them to SI units

Expand source code
def check_units(value,ProvidedUnits=None,ExpectedUnits=None,defaulttempUnits="degC"):
    """ check numeric inputs and convert them to SI units """
    # by convention, NumPy arrays and None are return unchanged (prevent nesting)
    if isinstance(value,np.ndarray) or value is None:
        return value,UnknownUnits
    if isinstance(value,tuple):
        if len(value) != 2:
            raise ValueError('value should be a tuple: (value,"unit"')
        ProvidedUnits = value[1]
        value = value[0]
    if isinstance(value,list): # the function is vectorized
        value = np.array(value)
    if {"degC", "K"} & {ProvidedUnits, ExpectedUnits}: # the value is a temperature
        ExpectedUnits = defaulttempUnits if ExpectedUnits is None else ExpectedUnits
        ProvidedUnits = ExpectedUnits if ProvidedUnits is None else ProvidedUnits
        if ProvidedUnits=="degC" and ExpectedUnits=="K":
            value += constants["T0K"]
        elif ProvidedUnits=="K" and ExpectedUnits=="degC":
            value -= constants["T0K"]
        return np.array([value]),ExpectedUnits
    else: # the value is not a temperature
        ExpectedUnits = NoUnits if ExpectedUnits is None else ExpectedUnits
        if (ProvidedUnits==ExpectedUnits) or (ProvidedUnits==NoUnits) or (ExpectedUnits==None):
            conversion =1               # no conversion needed
            units = ExpectedUnits if ExpectedUnits is not None else NoUnits
        else:
            q0,conversion,units = toSI(qSI(1,ProvidedUnits))
        return np.array([value*conversion]),units
def fixSIbase(registry)

Set the application registry, which is used for unpickling operations and when invoking pint.Quantity or pint.Unit directly.

Parameters

registry : pint.UnitRegistry
 
Expand source code
def set_application_registry(registry):
    """Set the application registry, which is used for unpickling operations
    and when invoking pint.Quantity or pint.Unit directly.

    Parameters
    ----------
    registry : pint.UnitRegistry
    """
    application_registry.set(registry)
def format_scientific_latex(value, numdigits=4, units=None, prefix='', mathmode='$')

Formats a number in scientific notation only when necessary, using LaTeX.

Parameters:

value : float The number to format. numdigits : int, optional (default=4) Number of significant digits for formatting. units : str, optional (default=None) LaTeX representation of units. If None, no units are added. prefix: str, optional (default="") mathmode: str, optional (default="$")

Returns:

str The formatted number in standard or LaTeX scientific notation.

Examples:

>>> format_scientific_latex(1e-12)
'$10^{-12}$'
>>> format_scientific_latex(1.5e-3)
'0.0015'
>>> format_scientific_latex(1.3e10)
'$1.3 \cdot 10^{10}$'
>>> format_scientific_latex(0.00341)
'0.00341'
>>> format_scientific_latex(3.41e-6)
'$3.41 \cdot 10^{-6}$'
Expand source code
def format_scientific_latex(value, numdigits=4, units=None, prefix="",mathmode="$"):
    """
    Formats a number in scientific notation only when necessary, using LaTeX.

    Parameters:
    -----------
    value : float
        The number to format.
    numdigits : int, optional (default=4)
        Number of significant digits for formatting.
    units : str, optional (default=None)
        LaTeX representation of units. If None, no units are added.
    prefix: str, optional (default="")
    mathmode: str, optional (default="$")

    Returns:
    --------
    str
        The formatted number in standard or LaTeX scientific notation.

    Examples:
    ---------
    >>> format_scientific_latex(1e-12)
    '$10^{-12}$'

    >>> format_scientific_latex(1.5e-3)
    '0.0015'

    >>> format_scientific_latex(1.3e10)
    '$1.3 \\cdot 10^{10}$'

    >>> format_scientific_latex(0.00341)
    '0.00341'

    >>> format_scientific_latex(3.41e-6)
    '$3.41 \\cdot 10^{-6}$'
    """

    if value == 0:
        return "$0$" if units is None else rf"$0 \, {units}$"
    # Get formatted number using Matlab-like %g behavior
    formatted = f"{value:.{numdigits}g}"
    # If the formatting results in an `e` notation, convert to LaTeX
    if "e" in formatted or "E" in formatted:
        coefficient, exponent = formatted.split("e")
        exponent = int(exponent)  # Convert exponent to integer
        # Remove trailing zeros in coefficient
        coefficient = coefficient.rstrip("0").rstrip(".")  # Ensures "1.00" -> "1"
        # LaTeX scientific format
        sci_notation = rf"{prefix}{coefficient} \cdot 10^{{{exponent}}}"
        return sci_notation if units is None else rf"{mathmode}{sci_notation} \, {units}{mathmode}"
    # Otherwise, return standard notation
    return formatted if units is None else rf"{mathmode}{prefix}{formatted} \, {units}{mathmode}"
def help_layer()

Print all subclasses with their type/material info in a Markdown table with dynamic column widths.

Expand source code
def help_layer():
    """
    Print all subclasses with their type/material info in a Markdown table with dynamic column widths.
    """
    derived = list_layer_subclasses()
    # Extract table content
    headers = ["Class Name", "Type", "Material", "Code"]
    rows = [[item["classname"], item["type"], item["material"], item["code"]] for item in derived]
    # Compute column widths based on content
    col_widths = [max(len(str(cell)) for cell in col) for col in zip(headers, *rows)]
    # Formatting row template
    row_format = "| " + " | ".join(f"{{:<{w}}}" for w in col_widths) + " |"
    # Print header
    print(row_format.format(*headers))
    print("|-" + "-|-".join("-" * w for w in col_widths) + "-|")

    # Print table rows
    for row in rows:
        print(row_format.format(*row))
def list_layer_subclasses()

Lists all classes in this module that derive from 'layer', along with their layertype and layermaterial properties.

Returns

list of tuples (classname, layertype, layermaterial)

Expand source code
def list_layer_subclasses():
    """
    Lists all classes in this module that derive from 'layer',
    along with their layertype and layermaterial properties.

    Returns:
        list of tuples (classname, layertype, layermaterial)
    """
    subclasses_info = []
    current_module = sys.modules[__name__]  # This refers to layer.py itself
    for name, obj in inspect.getmembers(current_module, inspect.isclass):
        # Make sure 'obj' is actually a subclass of layer (and not 'layer' itself)
        if obj is not layer and issubclass(obj, layer):
            try:
                # Instantiate with default parameters so that .layertype / .layermaterial are accessible
                instance = obj()
                subclasses_info.append(
                    {"classname":name,
                     "type":instance._type[0],
                     "material":instance._material[0],
                     "code":instance._code[0]}
                )
            except TypeError as e:
                # Log error and rethrow for debugging
                print(f"⚠️ Error: Could not instantiate class '{name}'. Check its constructor.")
                print(f"🔍 Exception: {e}")
                raise  # Rethrow the error with full traceback
    return subclasses_info
def toSI(q)
Expand source code
def toSI(q): q=q.to_base_units(); return q,q.m,str(q.u)

Classes

class AdhesiveAcrylate (l=2e-05, D=1e-13, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='adhesive acrylate', **extra)

extended pantankar.layer for acrylate adhesives

Parameters

layername : TYPE, optional, string
DESCRIPTION. Layer Name. The default is "my layer".
layertype : TYPE, optional, string
DESCRIPTION. Layer Type. The default is "unknown type".
layermaterial : TYPE, optional, string
DESCRIPTION. Material identification . The default is "unknown material".
PHYSICAL QUANTITIES
l : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Thickness. The default is 50e-6 (m).
D : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
k : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
C0 : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Initial concentration. The default is 1000 (a.u.).
PHYSICAL UNITS
lunit : TYPE, optional, string
DESCRIPTION. Length units. The default unit is "m.
Dunit : TYPE, optional, string
DESCRIPTION. Diffusivity units. The default unit is 1e-14 "m^2/s".
kunit : TYPE, optional, string
DESCRIPTION. Henry-like coefficient. The default unit is "a.u.".
Cunit : TYPE, optional, string
DESCRIPTION. Initial concentration. The default unit is "a.u.".

Returns

a monolayer object which can be assembled into a multilayer structure
 
Expand source code
class AdhesiveAcrylate(layer):
    """ extended pantankar.layer for acrylate adhesives """
    _chemicalsubstance = "n-butyl acrylate" # Acrylic esters (e.g. n-butyl acrylate)
    _polarityindex = 6.0  # Ester groups confer moderate polarity
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive acrylate",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="acrylate adhesive",
            layercode="Acryl",
            **extra
        )
    def density(self, T=None):
        """ typical density ~1000 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1000 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of acrylate adhesives """
        return -20, "degC"

Ancestors

Instance variables

var Tg

approximate Tg of acrylate adhesives

Expand source code
@property
def Tg(self):
    """ approximate Tg of acrylate adhesives """
    return -20, "degC"

Methods

def density(self, T=None)

typical density ~1000 kg/m^3

Expand source code
def density(self, T=None):
    """ typical density ~1000 kg/m^3 """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1000 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class AdhesiveEVA (l=2e-05, D=1e-13, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='adhesive EVA', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

Parameters

layername : TYPE, optional, string
DESCRIPTION. Layer Name. The default is "my layer".
layertype : TYPE, optional, string
DESCRIPTION. Layer Type. The default is "unknown type".
layermaterial : TYPE, optional, string
DESCRIPTION. Material identification . The default is "unknown material".
PHYSICAL QUANTITIES
l : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Thickness. The default is 50e-6 (m).
D : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
k : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
C0 : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Initial concentration. The default is 1000 (a.u.).
PHYSICAL UNITS
lunit : TYPE, optional, string
DESCRIPTION. Length units. The default unit is "m.
Dunit : TYPE, optional, string
DESCRIPTION. Diffusivity units. The default unit is 1e-14 "m^2/s".
kunit : TYPE, optional, string
DESCRIPTION. Henry-like coefficient. The default unit is "a.u.".
Cunit : TYPE, optional, string
DESCRIPTION. Initial concentration. The default unit is "a.u.".

Returns

a monolayer object which can be assembled into a multilayer structure
 
Expand source code
class AdhesiveEVA(layer):
    _chemicalsubstance = "ethylene" # Ethylene + vinyl acetate
    _polarityindex = 2.5  # Mostly non-polar backbone with some polar acetate groups.
    """ extended pantankar.layer for EVA-based adhesives """
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive EVA",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="EVA adhesive",
            layercode="EVA",
            **extra
        )
    def density(self, T=None):
        """ typical density ~930 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 930 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of EVA adhesives """
        return -30, "degC"

Ancestors

Instance variables

var Tg

approximate Tg of EVA adhesives

Expand source code
@property
def Tg(self):
    """ approximate Tg of EVA adhesives """
    return -30, "degC"

Methods

def density(self, T=None)

typical density ~930 kg/m^3

Expand source code
def density(self, T=None):
    """ typical density ~930 kg/m^3 """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 930 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class AdhesiveNaturalRubber (l=2e-05, D=1e-13, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='adhesive natural rubber', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

constructor for a natural rubber-based adhesive layer

Expand source code
class AdhesiveNaturalRubber(layer):
    _chemicalsubstance = "cis-1,4-polyisoprene" # monomer for polymers
    _polarityindex = 2  # Mostly non-polar; elasticity from cis-isoprene chains.
    """ extended pantankar.layer for natural rubber adhesives """
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive natural rubber",**extra):
        """ constructor for a natural rubber-based adhesive layer """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="natural rubber adhesive",
            layercode="rubber",
            **extra
        )
    def density(self, T=None):
        """ typical density ~910 kg/m^3, adjust as needed """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 910 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ approximate Tg of natural rubber adhesives """
        return -70, "degC"

Ancestors

Instance variables

var Tg

approximate Tg of natural rubber adhesives

Expand source code
@property
def Tg(self):
    """ approximate Tg of natural rubber adhesives """
    return -70, "degC"

Methods

def density(self, T=None)

typical density ~910 kg/m^3, adjust as needed

Expand source code
def density(self, T=None):
    """ typical density ~910 kg/m^3, adjust as needed """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 910 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class AdhesivePU (l=2e-05, D=1e-13, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='adhesive PU', **extra)

extended pantankar.layer for polyurethane adhesives

Parameters

layername : TYPE, optional, string
DESCRIPTION. Layer Name. The default is "my layer".
layertype : TYPE, optional, string
DESCRIPTION. Layer Type. The default is "unknown type".
layermaterial : TYPE, optional, string
DESCRIPTION. Material identification . The default is "unknown material".
PHYSICAL QUANTITIES
l : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Thickness. The default is 50e-6 (m).
D : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
k : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
C0 : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Initial concentration. The default is 1000 (a.u.).
PHYSICAL UNITS
lunit : TYPE, optional, string
DESCRIPTION. Length units. The default unit is "m.
Dunit : TYPE, optional, string
DESCRIPTION. Diffusivity units. The default unit is 1e-14 "m^2/s".
kunit : TYPE, optional, string
DESCRIPTION. Henry-like coefficient. The default unit is "a.u.".
Cunit : TYPE, optional, string
DESCRIPTION. Initial concentration. The default unit is "a.u.".

Returns

a monolayer object which can be assembled into a multilayer structure
 
Expand source code
class AdhesivePU(layer):
    """ extended pantankar.layer for polyurethane adhesives """
    _chemicalsubstance = "diisocyanate" # Diisocyanate + polyol (–NH–CO–O–)
    _polarityindex = 5.0  # Can vary widely by chemistry; moderate polarity.
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive PU",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="polyurethane adhesive",
            layercode="PU",
            **extra
        )
    def density(self, T=None):
        """ typical density ~1100 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1100 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of polyurethane adhesives """
        return -50, "degC"

Ancestors

Instance variables

var Tg

approximate Tg of polyurethane adhesives

Expand source code
@property
def Tg(self):
    """ approximate Tg of polyurethane adhesives """
    return -50, "degC"

Methods

def density(self, T=None)

typical density ~1100 kg/m^3

Expand source code
def density(self, T=None):
    """ typical density ~1100 kg/m^3 """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1100 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class AdhesivePVAC (l=2e-05, D=1e-13, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='adhesive PVAc', **extra)

extended pantankar.layer for PVAc adhesives

Parameters

layername : TYPE, optional, string
DESCRIPTION. Layer Name. The default is "my layer".
layertype : TYPE, optional, string
DESCRIPTION. Layer Type. The default is "unknown type".
layermaterial : TYPE, optional, string
DESCRIPTION. Material identification . The default is "unknown material".
PHYSICAL QUANTITIES
l : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Thickness. The default is 50e-6 (m).
D : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
k : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
C0 : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Initial concentration. The default is 1000 (a.u.).
PHYSICAL UNITS
lunit : TYPE, optional, string
DESCRIPTION. Length units. The default unit is "m.
Dunit : TYPE, optional, string
DESCRIPTION. Diffusivity units. The default unit is 1e-14 "m^2/s".
kunit : TYPE, optional, string
DESCRIPTION. Henry-like coefficient. The default unit is "a.u.".
Cunit : TYPE, optional, string
DESCRIPTION. Initial concentration. The default unit is "a.u.".

Returns

a monolayer object which can be assembled into a multilayer structure
 
Expand source code
class AdhesivePVAC(layer):
    """ extended pantankar.layer for PVAc adhesives """
    _chemicalsubstance = "vinyl acetate" # Vinyl acetate (CH₂=CHO–Ac)
    _polarityindex = 7.0  # PVAc is fairly polar (acetate groups)
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive PVAc",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="PVAc adhesive",
            layercode="PVAc",
            **extra
        )
    def density(self, T=None):
        """ typical density ~1100 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1100 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of PVAc adhesives """
        return 35, "degC"

Ancestors

Instance variables

var Tg

approximate Tg of PVAc adhesives

Expand source code
@property
def Tg(self):
    """ approximate Tg of PVAc adhesives """
    return 35, "degC"

Methods

def density(self, T=None)

typical density ~1100 kg/m^3

Expand source code
def density(self, T=None):
    """ typical density ~1100 kg/m^3 """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1100 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class AdhesiveSyntheticRubber (l=2e-05, D=1e-13, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='adhesive synthetic rubber', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

constructor for a synthetic rubber-based adhesive layer

Expand source code
class AdhesiveSyntheticRubber(layer):
    _chemicalsubstance = "cis-1,4-polyisoprene" # styrene-butadiene rubber (SBR) or similar
    _polarityindex = 2.0  # non-polar or slightly polar, depending on rubber type.
    """ extended pantankar.layer for synthetic rubber adhesives """
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive synthetic rubber",**extra):
        """ constructor for a synthetic rubber-based adhesive layer """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="synthetic rubber adhesive",
            layercode="sRubber",
            **extra
        )
    def density(self, T=None):
        """ typical density ~920 kg/m^3, adjust as needed """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 920 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ approximate Tg of synthetic rubber adhesives """
        return -50, "degC"

Ancestors

Instance variables

var Tg

approximate Tg of synthetic rubber adhesives

Expand source code
@property
def Tg(self):
    """ approximate Tg of synthetic rubber adhesives """
    return -50, "degC"

Methods

def density(self, T=None)

typical density ~920 kg/m^3, adjust as needed

Expand source code
def density(self, T=None):
    """ typical density ~920 kg/m^3, adjust as needed """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 920 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class AdhesiveVAE (l=2e-05, D=1e-13, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='adhesive VAE', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

Parameters

layername : TYPE, optional, string
DESCRIPTION. Layer Name. The default is "my layer".
layertype : TYPE, optional, string
DESCRIPTION. Layer Type. The default is "unknown type".
layermaterial : TYPE, optional, string
DESCRIPTION. Material identification . The default is "unknown material".
PHYSICAL QUANTITIES
l : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Thickness. The default is 50e-6 (m).
D : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
k : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
C0 : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Initial concentration. The default is 1000 (a.u.).
PHYSICAL UNITS
lunit : TYPE, optional, string
DESCRIPTION. Length units. The default unit is "m.
Dunit : TYPE, optional, string
DESCRIPTION. Diffusivity units. The default unit is 1e-14 "m^2/s".
kunit : TYPE, optional, string
DESCRIPTION. Henry-like coefficient. The default unit is "a.u.".
Cunit : TYPE, optional, string
DESCRIPTION. Initial concentration. The default unit is "a.u.".

Returns

a monolayer object which can be assembled into a multilayer structure
 
Expand source code
class AdhesiveVAE(layer):
    _chemicalsubstance = "vinyl acetate" # Ethylene + vinyl acetate
    _polarityindex = 4.0  # More polar than EVA (larger fraction of acetate).
    """ extended pantankar.layer for VAE adhesives """
    def __init__(self, l=20e-6, D=1e-13, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="adhesive VAE",**extra):
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="adhesive",
            layermaterial="VAE adhesive",
            layercode="VAE",
            **extra
        )
    def density(self, T=None):
        """ typical density ~950 kg/m^3 """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 950 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"
    @property
    def Tg(self):
        """ approximate Tg of VAE adhesives """
        return 10, "degC"

Ancestors

Instance variables

var Tg

approximate Tg of VAE adhesives

Expand source code
@property
def Tg(self):
    """ approximate Tg of VAE adhesives """
    return 10, "degC"

Methods

def density(self, T=None)

typical density ~950 kg/m^3

Expand source code
def density(self, T=None):
    """ typical density ~950 kg/m^3 """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 950 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class Cardboard (l=0.0005, D=1e-15, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='cardboard layer', **extra)

extended pantankar.layer for cardboard (cellulose-based)

Cardboard layer constructor

Expand source code
class Cardboard(layer):
    """ extended pantankar.layer for cardboard (cellulose-based) """
    _physicalstate = "porous"       # solid (default), liquid, gas, porous
    _chemicalclass = "other"       # polymer (default), other
    _chemicalsubstance = "cellulose"
    _polarityindex = 8.0  # Can vary widely by chemistry; moderate polarity.
    def __init__(self, l=500e-6, D=1e-15, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="cardboard layer",**extra):
        """ Cardboard layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="paper",
            layermaterial="cardboard",
            layercode="board",
            **extra
        )
    def density(self, T=None):
        """
        approximate density for typical cardboard ~700 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 700 * (1 - 3*(T - layer._defaults["Td"]) * 1e-5), "kg/m**3"

    @property
    def Tg(self):
        """
        same placeholder concept for paper-based material
        """
        return 200, "degC"

Ancestors

Instance variables

var Tg

same placeholder concept for paper-based material

Expand source code
@property
def Tg(self):
    """
    same placeholder concept for paper-based material
    """
    return 200, "degC"

Methods

def density(self, T=None)

approximate density for typical cardboard ~700 kg/m^3

Expand source code
def density(self, T=None):
    """
    approximate density for typical cardboard ~700 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 700 * (1 - 3*(T - layer._defaults["Td"]) * 1e-5), "kg/m**3"

Inherited members

class HDPE (l=0.0005, D=1e-13, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in HDPE', **extra)

extended pantankar.layer for high-density polyethylene HDPE

HDPE layer constructor

Expand source code
class HDPE(layer):
    """  extended pantankar.layer for high-density polyethylene HDPE  """
    _chemicalsubstance = "ethylene" # monomer for polymers
    _polarityindex = 2.0 # Non-polar, slightly higher density, similar overall polarity to LDPE
    def __init__(self,l=500e-6,D=1e-13, T=None,
                 k=None,C0=None,lunit=None,Dunit=None,kunit=None,Cunit=None,
                 layername="layer in HDPE",**extra):
        """ HDPE layer constructor """
        layer.__init__(self,
                       l=l,D=D,k=k,C0=C0, T=T,
                       lunit=lunit,Dunit=Dunit,kunit=kunit,Cunit=Cunit,
                       layername=layername,
                       layertype="polymer", # set by default at inititialization
                       layermaterial="high-density polyethylene",
                       layercode="HDPE",
                       **extra
                       )
    def density(self,T=None):
        """ density of HDPE: density(T in K) """
        T = self.T if T is None else check_units(T,None,"degC")[0]
        return 940 *(1-3*(T-layer._defaults["Td"])*11e-5),"kg/m**3" # lowest temperature
    @property
    def Tg(self):
        """ glass transition temperature of HDPE """
        return -100,"degC" # highest temperature

Ancestors

Instance variables

var Tg

glass transition temperature of HDPE

Expand source code
@property
def Tg(self):
    """ glass transition temperature of HDPE """
    return -100,"degC" # highest temperature

Methods

def density(self, T=None)

density of HDPE: density(T in K)

Expand source code
def density(self,T=None):
    """ density of HDPE: density(T in K) """
    T = self.T if T is None else check_units(T,None,"degC")[0]
    return 940 *(1-3*(T-layer._defaults["Td"])*11e-5),"kg/m**3" # lowest temperature

Inherited members

class HIPS (l=0.0001, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in HIPS', **extra)

extended pantankar.layer for high-impact polystyrene (HIPS)

HIPS layer constructor

Expand source code
class HIPS(layer):
    """ extended pantankar.layer for high-impact polystyrene (HIPS) """
    _chemicalsubstance = "styrene" # monomer for polymers
    _polarityindex = 3.0  # Similar or very close to PS in polarity
    def __init__(self, l=100e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in HIPS",**extra):
        """ HIPS layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="high-impact polystyrene",
            layercode="HIPS",
            **extra
        )
    def density(self, T=None):
        """
        density of HIPS: ~1040 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1040 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of HIPS """
        return 95, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of HIPS

Expand source code
@property
def Tg(self):
    """ glass transition temperature of HIPS """
    return 95, "degC"

Methods

def density(self, T=None)

density of HIPS: ~1040 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of HIPS: ~1040 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1040 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

Inherited members

class LDPE (l=0.0001, D=1e-12, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in LDPE', **extra)

extended pantankar.layer for low-density polyethylene LDPE

LDPE layer constructor

Expand source code
class LDPE(layer):
    """  extended pantankar.layer for low-density polyethylene LDPE  """
    _chemicalsubstance = "ethylene" # monomer for polymers
    _polarityindex = 1.0  # Very non-polar (typical for polyolefins)
    def __init__(self,l=100e-6,D=1e-12,T=None,
                 k=None,C0=None,lunit=None,Dunit=None,kunit=None,Cunit=None,
                 layername="layer in LDPE",**extra):
        """ LDPE layer constructor """
        super().__init__(
                       l=l,D=D,k=k,C0=C0, T=T,
                       lunit=lunit,Dunit=Dunit,kunit=kunit,Cunit=Cunit,
                       layername=layername,
                       layertype="polymer", # set by default at inititialization
                       layermaterial="low-density polyethylene",
                       layercode="LDPE",
                       **extra
                       )
    def density(self,T=None):
        """ density of LDPE: density(T in K) """
        T = self.T if T is None else check_units(T,None,"degC")[0]
        return 920 *(1-3*(T-layer._defaults["Td"])*20e-5),"kg/m**3" # lowest temperature
    @property
    def Tg(self):
        """ glass transition temperature of LDPE """
        return -130,"degC" # lowest temperature

Ancestors

Instance variables

var Tg

glass transition temperature of LDPE

Expand source code
@property
def Tg(self):
    """ glass transition temperature of LDPE """
    return -130,"degC" # lowest temperature

Methods

def density(self, T=None)

density of LDPE: density(T in K)

Expand source code
def density(self,T=None):
    """ density of LDPE: density(T in K) """
    T = self.T if T is None else check_units(T,None,"degC")[0]
    return 920 *(1-3*(T-layer._defaults["Td"])*20e-5),"kg/m**3" # lowest temperature

Inherited members

class LLDPE (l=8e-05, D=1e-12, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in LLDPE', **extra)

extended pantankar.layer for linear low-density polyethylene LLDPE

LLDPE layer constructor Defaults are set to typical values found in the literature or between LDPE/HDPE ones. Adjust them as necessary for your models.

Expand source code
class LLDPE(layer):
    """ extended pantankar.layer for linear low-density polyethylene LLDPE """
    _chemicalsubstance = "ethylene" # monomer for polymers
    _polarityindex = 1.5 # Similar to LDPE, can be slightly more polar if co-monomer is present
    def __init__(self, l=80e-6, D=1e-12, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in LLDPE",**extra):
        """
        LLDPE layer constructor
        Defaults are set to typical values found in the literature or between
        LDPE/HDPE ones. Adjust them as necessary for your models.
        """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="linear low-density polyethylene",
            layercode="LLDPE",
            **extra
        )
    def density(self, T=None):
        """
        density of LLDPE: density(T in K)
        By default, uses an approximate value between LDPE and HDPE.
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        # Similar formula to LDPE and HDPE, with a coefficient suitable for LLDPE.
        return 915 * (1 - 3 * (T - layer._defaults["Td"]) * 15e-5), "kg/m**3"
    @property
    def Tg(self):
        """
        glass transition temperature of LLDPE
        Typically close to LDPE, though slightly higher or lower can be found in the literature.
        """
        return -120, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of LLDPE Typically close to LDPE, though slightly higher or lower can be found in the literature.

Expand source code
@property
def Tg(self):
    """
    glass transition temperature of LLDPE
    Typically close to LDPE, though slightly higher or lower can be found in the literature.
    """
    return -120, "degC"

Methods

def density(self, T=None)

density of LLDPE: density(T in K) By default, uses an approximate value between LDPE and HDPE.

Expand source code
def density(self, T=None):
    """
    density of LLDPE: density(T in K)
    By default, uses an approximate value between LDPE and HDPE.
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    # Similar formula to LDPE and HDPE, with a coefficient suitable for LLDPE.
    return 915 * (1 - 3 * (T - layer._defaults["Td"]) * 15e-5), "kg/m**3"

Inherited members

class PA6 (l=0.0002, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in PA6', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

PA6 layer constructor

Expand source code
class PA6(layer):
    _chemicalsubstance = "caprolactam" # monomer for polymers
    _polarityindex = 7.5  # Strong hydrogen-bonding, thus quite polar.
    """ extended pantankar.layer for polyamide 6 (PA6) """
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PA6",**extra):
        """ PA6 layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polyamide 6",
            layercode="PA6",
            **extra
        )
    def density(self, T=None):
        """
        density of PA6: ~1140 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1140 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PA6 """
        return 50, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of PA6

Expand source code
@property
def Tg(self):
    """ glass transition temperature of PA6 """
    return 50, "degC"

Methods

def density(self, T=None)

density of PA6: ~1140 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of PA6: ~1140 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1140 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

Inherited members

class PA66 (l=0.0002, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in PA66', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

PA66 layer constructor

Expand source code
class PA66(layer):
    _chemicalsubstance = "hexamethylenediamine" # monomer for polymers
    _polarityindex = 7.5  # Similar to PA6, strongly polar with hydrogen bonds.
    """ extended pantankar.layer for polyamide 66 (PA66) """
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PA66",**extra):
        """ PA66 layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polyamide 6,6",
            layercode="PA6,6",
            **extra
        )
    def density(self, T=None):
        """
        density of PA66: ~1150 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1150 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PA66 """
        return 70, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of PA66

Expand source code
@property
def Tg(self):
    """ glass transition temperature of PA66 """
    return 70, "degC"

Methods

def density(self, T=None)

density of PA66: ~1150 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of PA66: ~1150 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1150 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

Inherited members

class PBT (l=0.0002, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in PBT', **extra)

extended pantankar.layer for polybutylene terephthalate (PBT)

PBT layer constructor

Expand source code
class PBT(layer):
    """ extended pantankar.layer for polybutylene terephthalate (PBT) """
    _chemicalsubstance = "Buthylene terephthalate" # monomer for polymers
    _polarityindex = 5.5  # Similar to PET, slight structural differences
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PBT",**extra):
        """ PBT layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polybutylene terephthalate",
            layercode="PBT",
            **extra
        )
    def density(self, T=None):
        """
        density of PBT: ~1310 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1310 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PBT """
        return 40, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of PBT

Expand source code
@property
def Tg(self):
    """ glass transition temperature of PBT """
    return 40, "degC"

Methods

def density(self, T=None)

density of PBT: ~1310 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of PBT: ~1310 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1310 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class PEN (l=0.0002, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in PEN', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

PEN layer constructor

Expand source code
class PEN(layer):
    _chemicalsubstance = "Buthylene terephthalate" # monomer for polymers
    _polarityindex = 6  # More aromatic than PET, often better barrier properties
    """ extended pantankar.layer for polyethylene naphthalate (PEN) """
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PEN",**extra):
        """ PEN layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polyethylene naphthalate",
            layercode="PEN",
            **extra
        )
    def density(self, T=None):
        """
        density of PEN: ~1330 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1330 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PEN """
        return 120, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of PEN

Expand source code
@property
def Tg(self):
    """ glass transition temperature of PEN """
    return 120, "degC"

Methods

def density(self, T=None)

density of PEN: ~1330 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of PEN: ~1330 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1330 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class PP (l=0.0003, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in PP', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

PP layer constructor

Expand source code
class PP(layer):
    _chemicalsubstance = "propylene" # monomer for polymers
    _polarityindex = 1.0  # Among the least polar, similar to PE
    """  extended pantankar.layer for isotactic polypropylene PP  """
    def __init__(self,l=300e-6,D=1e-14, T=None,
                 k=None,C0=None,lunit=None,Dunit=None,kunit=None,Cunit=None,
                 layername="layer in PP",**extra):
        """ PP layer constructor """
        layer.__init__(self,
                       l=l,D=D,k=k,C0=C0, T=T,
                       lunit=lunit,Dunit=Dunit,kunit=kunit,Cunit=Cunit,
                       layername=layername,
                       layertype="polymer", # set by default at inititialization
                       layermaterial="isotactic polypropylene",
                       layercode="PP",
                       **extra
                       )
    def density(self,T=None):
        """ density of PP: density(T in K) """
        T = self.T if T is None else check_units(T,None,"degC")[0]
        return 910 *(1-3*(T-layer._defaults["Td"])*7e-5),"kg/m**3" # lowest temperature
    @property
    def Tg(self):
        """ glass transition temperature of PP """
        return 0,"degC" # highest temperature

Ancestors

Instance variables

var Tg

glass transition temperature of PP

Expand source code
@property
def Tg(self):
    """ glass transition temperature of PP """
    return 0,"degC" # highest temperature

Methods

def density(self, T=None)

density of PP: density(T in K)

Expand source code
def density(self,T=None):
    """ density of PP: density(T in K) """
    T = self.T if T is None else check_units(T,None,"degC")[0]
    return 910 *(1-3*(T-layer._defaults["Td"])*7e-5),"kg/m**3" # lowest temperature

Inherited members

class PPrubber (l=0.0001, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in PPrubber', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

PPrubber layer constructor

Expand source code
class PPrubber(layer):
    _chemicalsubstance = "propylene" # monomer for polymers
    _polarityindex = 1.0  # Also very non-polar
    """ extended pantankar.layer for atactic (rubbery) polypropylene PP """
    def __init__(self, l=100e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PPrubber",**extra):
        """ PPrubber layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="atactic polypropylene",
            layercode="aPP",
            **extra
        )
    def density(self, T=None):
        """
        density of atactic (rubbery) PP: density(T in K)
        Approximate initial density ~900 kg/m^3, linear thermal expansion factor
        can be adjusted.
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 900 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of atactic/rubbery PP """
        return -20, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of atactic/rubbery PP

Expand source code
@property
def Tg(self):
    """ glass transition temperature of atactic/rubbery PP """
    return -20, "degC"

Methods

def density(self, T=None)

density of atactic (rubbery) PP: density(T in K) Approximate initial density ~900 kg/m^3, linear thermal expansion factor can be adjusted.

Expand source code
def density(self, T=None):
    """
    density of atactic (rubbery) PP: density(T in K)
    Approximate initial density ~900 kg/m^3, linear thermal expansion factor
    can be adjusted.
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 900 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class PS (l=0.0001, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in PS', **extra)

extended pantankar.layer for polystyrene (PS)

PS layer constructor

Expand source code
class PS(layer):
    """ extended pantankar.layer for polystyrene (PS) """
    _chemicalsubstance = "styrene" # monomer for polymers
    _polarityindex = 3.0  # Slightly more polar than polyolefins, but still considered relatively non-polar
    def __init__(self, l=100e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PS",**extra):
        """ PS layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="polystyrene",
            layercode="PS",
            **extra
        )
    def density(self, T=None):
        """
        density of PS: ~1050 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1050 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of PS """
        return 100, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of PS

Expand source code
@property
def Tg(self):
    """ glass transition temperature of PS """
    return 100, "degC"

Methods

def density(self, T=None)

density of PS: ~1050 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of PS: ~1050 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1050 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

Inherited members

class Paper (l=8e-05, D=1e-15, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='paper layer', **extra)

extended pantankar.layer for paper (cellulose-based)

Paper layer constructor

Expand source code
class Paper(layer):
    """ extended pantankar.layer for paper (cellulose-based) """
    _physicalstate = "porous"       # solid (default), liquid, gas, porous
    _chemicalclass = "other"       # polymer (default), other
    _chemicalsubstance = "cellulose" # Cellulose (β-D-glucopyranose units)
    _polarityindex = 8.5  # Highly polar, strong hydrogen-bonding.
    def __init__(self, l=80e-6, D=1e-15, T=None,  # a guess for barrier properties
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="paper layer",**extra):
        """ Paper layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="paper",
            layermaterial="paper",
            layercode="paper",
            **extra
        )
    def density(self, T=None):
        """
        approximate density for typical paper ~800 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 800 * (1 - 3*(T - layer._defaults["Td"]) * 1e-5), "kg/m**3"

    @property
    def Tg(self):
        """
        glass transition temperature is not typically used for paper,
        but we provide a placeholder.
        """
        return 200, "degC"  # purely illustrative placeholder

Ancestors

Instance variables

var Tg

glass transition temperature is not typically used for paper, but we provide a placeholder.

Expand source code
@property
def Tg(self):
    """
    glass transition temperature is not typically used for paper,
    but we provide a placeholder.
    """
    return 200, "degC"  # purely illustrative placeholder

Methods

def density(self, T=None)

approximate density for typical paper ~800 kg/m^3

Expand source code
def density(self, T=None):
    """
    approximate density for typical paper ~800 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 800 * (1 - 3*(T - layer._defaults["Td"]) * 1e-5), "kg/m**3"

Inherited members

class SBS (l=0.0001, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in PBS', **extra)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

DBS layer constructor

Expand source code
class SBS(layer):
    _chemicalsubstance = "styrene" # Styrene + butadiene
    _polarityindex = 3.5  # Non-polar but somewhat more interactive than pure PE/PP due to styrene units
    """
    extended pantankar.layer for a styrene-based SBS
    Adjust Tg/density as needed for your scenario.
    """
    def __init__(self, l=100e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in PBS",**extra):
        """ DBS layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="styrene-based polymer SBS",
            layercode="SBS",
            **extra
        )
    def density(self, T=None):
        """
        density of 'DBS': approximate, around ~1030 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1030 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of 'DBS' """
        return 90, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of 'DBS'

Expand source code
@property
def Tg(self):
    """ glass transition temperature of 'DBS' """
    return 90, "degC"

Methods

def density(self, T=None)

density of 'DBS': approximate, around ~1030 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of 'DBS': approximate, around ~1030 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1030 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

Inherited members

class SIbase (filename='', force_ndarray: bool = False, force_ndarray_like: bool = False, default_as_delta: bool = True, autoconvert_offset_to_baseunit: bool = False, on_redefinition: str = 'warn', system=None, auto_reduce_dimensions=False, preprocessors=None, fmt_locale=None, non_int_type=builtins.float, case_sensitive: bool = True)

The unit registry stores the definitions and relationships between units.

Parameters

filename :
path of the units definition file to load or line-iterable object.
Empty to load the default definition file.
None to leave the UnitRegistry empty.
force_ndarray : bool
convert any input, scalar or not to a numpy.ndarray.
force_ndarray_like : bool
convert all inputs other than duck arrays to a numpy.ndarray.
default_as_delta :
In the context of a multiplication of units, interpret
non-multiplicative units as their delta counterparts.
autoconvert_offset_to_baseunit :
If True converts offset units in quantities are
converted to their base units in multiplicative
context. If False no conversion happens.
on_redefinition : str
action to take in case a unit is redefined. 'warn', 'raise', 'ignore'
auto_reduce_dimensions :
If True, reduce dimensionality on appropriate operations.
preprocessors :
list of callables which are iteratively ran on any input expression
or unit string
fmt_locale :
locale identifier string, used in format_babel. Default to None
case_sensitive : bool, optional
Control default case sensitivity of unit parsing. (Default: True)
Expand source code
class UnitRegistry(SystemRegistry, ContextRegistry, NonMultiplicativeRegistry):
    """The unit registry stores the definitions and relationships between units.

    Parameters
    ----------
    filename :
        path of the units definition file to load or line-iterable object.
        Empty to load the default definition file.
        None to leave the UnitRegistry empty.
    force_ndarray : bool
        convert any input, scalar or not to a numpy.ndarray.
    force_ndarray_like : bool
        convert all inputs other than duck arrays to a numpy.ndarray.
    default_as_delta :
        In the context of a multiplication of units, interpret
        non-multiplicative units as their *delta* counterparts.
    autoconvert_offset_to_baseunit :
        If True converts offset units in quantities are
        converted to their base units in multiplicative
        context. If False no conversion happens.
    on_redefinition : str
        action to take in case a unit is redefined.
        'warn', 'raise', 'ignore'
    auto_reduce_dimensions :
        If True, reduce dimensionality on appropriate operations.
    preprocessors :
        list of callables which are iteratively ran on any input expression
        or unit string
    fmt_locale :
        locale identifier string, used in `format_babel`. Default to None
    case_sensitive : bool, optional
        Control default case sensitivity of unit parsing. (Default: True)
    """

    def __init__(
        self,
        filename="",
        force_ndarray: bool = False,
        force_ndarray_like: bool = False,
        default_as_delta: bool = True,
        autoconvert_offset_to_baseunit: bool = False,
        on_redefinition: str = "warn",
        system=None,
        auto_reduce_dimensions=False,
        preprocessors=None,
        fmt_locale=None,
        non_int_type=float,
        case_sensitive: bool = True,
    ):

        super().__init__(
            filename=filename,
            force_ndarray=force_ndarray,
            force_ndarray_like=force_ndarray_like,
            on_redefinition=on_redefinition,
            default_as_delta=default_as_delta,
            autoconvert_offset_to_baseunit=autoconvert_offset_to_baseunit,
            system=system,
            auto_reduce_dimensions=auto_reduce_dimensions,
            preprocessors=preprocessors,
            fmt_locale=fmt_locale,
            non_int_type=non_int_type,
            case_sensitive=case_sensitive,
        )

    def pi_theorem(self, quantities):
        """Builds dimensionless quantities using the Buckingham π theorem

        Parameters
        ----------
        quantities : dict
            mapping between variable name and units

        Returns
        -------
        list
            a list of dimensionless quantities expressed as dicts

        """
        return pi_theorem(quantities, self)

    def setup_matplotlib(self, enable: bool = True) -> None:
        """Set up handlers for matplotlib's unit support.

        Parameters
        ----------
        enable : bool
            whether support should be enabled or disabled (Default value = True)

        """
        # Delays importing matplotlib until it's actually requested
        from .matplotlib import setup_matplotlib_handlers

        setup_matplotlib_handlers(self, enable)

    wraps = registry_helpers.wraps

    check = registry_helpers.check

Ancestors

  • patankar.private.pint.registry.SystemRegistry
  • patankar.private.pint.registry.ContextRegistry
  • patankar.private.pint.registry.NonMultiplicativeRegistry
  • patankar.private.pint.registry.BaseRegistry

Methods

def check(ureg: UnitRegistry, *args: Union[str, patankar.private.pint.util.UnitsContainer, ForwardRef('Unit'), NoneType]) ‑> Callable[[~F], ~F]

Decorator to for quantity type checking for function inputs.

Use it to ensure that the decorated function input parameters match the expected dimension of pint quantity.

The wrapper function raises: - pint.DimensionalityError if an argument doesn't match the required dimensions.

ureg : UnitRegistry a UnitRegistry instance. args : str or UnitContainer or None Dimensions of each of the input arguments. Use None to skip argument conversion.

Returns

callable
the wrapped function.

Raises

TypeError
If the number of given dimensions does not match the number of function parameters.
ValueError
If the any of the provided dimensions cannot be parsed as a dimension.
Expand source code
def check(
    ureg: "UnitRegistry", *args: Union[str, UnitsContainer, "Unit", None]
) -> Callable[[F], F]:
    """Decorator to for quantity type checking for function inputs.

    Use it to ensure that the decorated function input parameters match
    the expected dimension of pint quantity.

    The wrapper function raises:
      - `pint.DimensionalityError` if an argument doesn't match the required dimensions.

    ureg : UnitRegistry
        a UnitRegistry instance.
    args : str or UnitContainer or None
        Dimensions of each of the input arguments.
        Use `None` to skip argument conversion.

    Returns
    -------
    callable
        the wrapped function.

    Raises
    ------
    TypeError
        If the number of given dimensions does not match the number of function
        parameters.
    ValueError
        If the any of the provided dimensions cannot be parsed as a dimension.
    """
    dimensions = [
        ureg.get_dimensionality(dim) if dim is not None else None for dim in args
    ]

    def decorator(func):

        count_params = len(signature(func).parameters)
        if len(dimensions) != count_params:
            raise TypeError(
                "%s takes %i parameters, but %i dimensions were passed"
                % (func.__name__, count_params, len(dimensions))
            )

        assigned = tuple(
            attr for attr in functools.WRAPPER_ASSIGNMENTS if hasattr(func, attr)
        )
        updated = tuple(
            attr for attr in functools.WRAPPER_UPDATES if hasattr(func, attr)
        )

        @functools.wraps(func, assigned=assigned, updated=updated)
        def wrapper(*args, **kwargs):
            list_args, empty = _apply_defaults(func, args, kwargs)

            for dim, value in zip(dimensions, list_args):

                if dim is None:
                    continue

                if not ureg.Quantity(value).check(dim):
                    val_dim = ureg.get_dimensionality(value)
                    raise DimensionalityError(value, "a quantity of", val_dim, dim)
            return func(*args, **kwargs)

        return wrapper

    return decorator
def pi_theorem(self, quantities)

Builds dimensionless quantities using the Buckingham π theorem

Parameters

quantities : dict
mapping between variable name and units

Returns

list
a list of dimensionless quantities expressed as dicts
Expand source code
def pi_theorem(self, quantities):
    """Builds dimensionless quantities using the Buckingham π theorem

    Parameters
    ----------
    quantities : dict
        mapping between variable name and units

    Returns
    -------
    list
        a list of dimensionless quantities expressed as dicts

    """
    return pi_theorem(quantities, self)
def setup_matplotlib(self, enable: bool = True) ‑> NoneType

Set up handlers for matplotlib's unit support.

Parameters

enable : bool
whether support should be enabled or disabled (Default value = True)
Expand source code
def setup_matplotlib(self, enable: bool = True) -> None:
    """Set up handlers for matplotlib's unit support.

    Parameters
    ----------
    enable : bool
        whether support should be enabled or disabled (Default value = True)

    """
    # Delays importing matplotlib until it's actually requested
    from .matplotlib import setup_matplotlib_handlers

    setup_matplotlib_handlers(self, enable)
def wraps(ureg: UnitRegistry, ret: Union[str, ForwardRef('Unit'), Iterable[Union[str, ForwardRef('Unit'), NoneType]], NoneType], args: Union[str, ForwardRef('Unit'), Iterable[Union[str, ForwardRef('Unit'), NoneType]], NoneType], strict: bool = True) ‑> Callable[[Callable[..., ~T]], Callable[..., patankar.private.pint.quantity.Quantity[~T]]]

Wraps a function to become pint-aware.

Use it when a function requires a numerical value but in some specific units. The wrapper function will take a pint quantity, convert to the units specified in args and then call the wrapped function with the resulting magnitude.

The value returned by the wrapped function will be converted to the units specified in ret.

Parameters

ureg : pint.UnitRegistry
a UnitRegistry instance.
ret : str, pint.Unit, or iterable of str or pint.Unit
Units of each of the return values. Use None to skip argument conversion.
args : str, pint.Unit, or iterable of str or pint.Unit
Units of each of the input arguments. Use None to skip argument conversion.
strict : bool
Indicates that only quantities are accepted. (Default value = True)

Returns

callable
the wrapper function.

Raises

TypeError
if the number of given arguments does not match the number of function parameters. if any of the provided arguments is not a unit a string or Quantity
Expand source code
def wraps(
    ureg: "UnitRegistry",
    ret: Union[str, "Unit", Iterable[Union[str, "Unit", None]], None],
    args: Union[str, "Unit", Iterable[Union[str, "Unit", None]], None],
    strict: bool = True,
) -> Callable[[Callable[..., T]], Callable[..., Quantity[T]]]:
    """Wraps a function to become pint-aware.

    Use it when a function requires a numerical value but in some specific
    units. The wrapper function will take a pint quantity, convert to the units
    specified in `args` and then call the wrapped function with the resulting
    magnitude.

    The value returned by the wrapped function will be converted to the units
    specified in `ret`.

    Parameters
    ----------
    ureg : pint.UnitRegistry
        a UnitRegistry instance.
    ret : str, pint.Unit, or iterable of str or pint.Unit
        Units of each of the return values. Use `None` to skip argument conversion.
    args : str, pint.Unit, or iterable of str or pint.Unit
        Units of each of the input arguments. Use `None` to skip argument conversion.
    strict : bool
        Indicates that only quantities are accepted. (Default value = True)

    Returns
    -------
    callable
        the wrapper function.

    Raises
    ------
    TypeError
        if the number of given arguments does not match the number of function parameters.
        if any of the provided arguments is not a unit a string or Quantity

    """

    if not isinstance(args, (list, tuple)):
        args = (args,)

    for arg in args:
        if arg is not None and not isinstance(arg, (ureg.Unit, str)):
            raise TypeError(
                "wraps arguments must by of type str or Unit, not %s (%s)"
                % (type(arg), arg)
            )

    converter = _parse_wrap_args(args)

    is_ret_container = isinstance(ret, (list, tuple))
    if is_ret_container:
        for arg in ret:
            if arg is not None and not isinstance(arg, (ureg.Unit, str)):
                raise TypeError(
                    "wraps 'ret' argument must by of type str or Unit, not %s (%s)"
                    % (type(arg), arg)
                )
        ret = ret.__class__([_to_units_container(arg, ureg) for arg in ret])
    else:
        if ret is not None and not isinstance(ret, (ureg.Unit, str)):
            raise TypeError(
                "wraps 'ret' argument must by of type str or Unit, not %s (%s)"
                % (type(ret), ret)
            )
        ret = _to_units_container(ret, ureg)

    def decorator(func: Callable[..., T]) -> Callable[..., Quantity[T]]:

        count_params = len(signature(func).parameters)
        if len(args) != count_params:
            raise TypeError(
                "%s takes %i parameters, but %i units were passed"
                % (func.__name__, count_params, len(args))
            )

        assigned = tuple(
            attr for attr in functools.WRAPPER_ASSIGNMENTS if hasattr(func, attr)
        )
        updated = tuple(
            attr for attr in functools.WRAPPER_UPDATES if hasattr(func, attr)
        )

        @functools.wraps(func, assigned=assigned, updated=updated)
        def wrapper(*values, **kw) -> Quantity[T]:

            values, kw = _apply_defaults(func, values, kw)

            # In principle, the values are used as is
            # When then extract the magnitudes when needed.
            new_values, values_by_name = converter(ureg, values, strict)

            result = func(*new_values, **kw)

            if is_ret_container:
                out_units = (
                    _replace_units(r, values_by_name) if is_ref else r
                    for (r, is_ref) in ret
                )
                return ret.__class__(
                    res if unit is None else ureg.Quantity(res, unit)
                    for unit, res in zip_longest(out_units, result)
                )

            if ret[0] is None:
                return result

            return ureg.Quantity(
                result, _replace_units(ret[0], values_by_name) if ret[1] else ret[0]
            )

        return wrapper

    return decorator
class air (l=0.01, D=1e-06, T=None, lunit=None, Dunit=None, Cunit=None, layername='air layer', layercode='air', **extra)

extended pantankar.layer for ideal gases such as air

air layer constructor

Expand source code
class air(layer):
    """  extended pantankar.layer for ideal gases such as air """
    _physicalstate = "gas"       # solid (default), liquid, gas, porous
    _chemicalclass = "other"       # polymer (default), other
    def __init__(self,l=1e-2,D=1e-6,T=None,
                 lunit=None,Dunit=None,Cunit=None,
                 layername="air layer",layercode="air",**extra):
        """ air layer constructor """
        T = layer._defaults["T"] if T is None else check_units(T,None,"degC")[0]
        TK = constants["T0K"]+T
        kair = 1/(constants["R"] *TK)
        kairunit = constants["iRT0Kunit"]
        layer.__init__(self,
                       l=l,D=D,k=kair,C0=0,T=T,
                       lunit=lunit,Dunit=Dunit,kunit=kairunit,Cunit=Cunit,
                       layername=layername,
                       layertype="air", # set by default at inititialization
                       layermaterial="ideal gas",
                       layercode="gas",
                       **extra
                       )

    def density(self, T=None):
        """Density of air at atmospheric pressure: density(T in K)"""
        TK = self.TK if T is None else check_units(T,None,"K")[0]
        P_atm = 101325  # Pa (1 atm)
        M_air = 28.9647 # g/mol = 0.0289647 kg/mol (Molar mass of dry air).
        return P_atm / ((constants["R"]/M_air) * TK), "kg/m**3"

Ancestors

Methods

def density(self, T=None)

Density of air at atmospheric pressure: density(T in K)

Expand source code
def density(self, T=None):
    """Density of air at atmospheric pressure: density(T in K)"""
    TK = self.TK if T is None else check_units(T,None,"K")[0]
    P_atm = 101325  # Pa (1 atm)
    M_air = 28.9647 # g/mol = 0.0289647 kg/mol (Molar mass of dry air).
    return P_atm / ((constants["R"]/M_air) * TK), "kg/m**3"

Inherited members

class qSI (value, units=None)

Implements a class to describe a physical quantity: the product of a numerical value and a unit of measurement.

Parameters

value : str, pint.Quantity or any numeric type
Value of the physical quantity to be created.
units : UnitsContainer, str or pint.Quantity
Units of the physical quantity to be created.

Returns

Expand source code
class Quantity(_Quantity):
    _REGISTRY = registry

Ancestors

  • patankar.private.pint.quantity.Quantity
  • patankar.private.pint.util.PrettyIPython
  • patankar.private.pint.util.SharedRegistryObject
  • typing.Generic
class gPET (l=0.0002, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in gPET', **extra)

extended pantankar.layer for PET in its glassy state (below ~76°C)

glassy PET layer constructor

Expand source code
class gPET(layer):
    """ extended pantankar.layer for PET in its glassy state (below ~76°C) """
    _chemicalsubstance = "ethylene terephthalate" # monomer for polymers
    _polarityindex = 5.0  # Polyester with significant dipolar interactions (Ph = phenylene ring).
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in gPET",**extra):
        """ glassy PET layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="glassy PET",
            layercode="PET",
            **extra
        )
    def density(self, T=None):
        """
        density of glassy PET: ~1350 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1350 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ approximate glass transition temperature of PET """
        return 76, "degC"

Ancestors

Instance variables

var Tg

approximate glass transition temperature of PET

Expand source code
@property
def Tg(self):
    """ approximate glass transition temperature of PET """
    return 76, "degC"

Methods

def density(self, T=None)

density of glassy PET: ~1350 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of glassy PET: ~1350 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1350 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class layer (l=None, D=None, k=None, C0=None, rho=None, T=None, lunit=None, Dunit=None, kunit=None, Cunit=None, rhounit=None, Tunit=None, layername=None, layertype=None, layermaterial=None, layercode=None, substance=None, medium=None, nmesh=None, nmeshmin=None, Dlink=None, klink=None, C0link=None, Tlink=None, llink=None, verbose=None, verbosity=2, **unresolved)

Core Functionality

This class models layers in food packaging, handling mass transfer, partitioning, and meshing for finite-volume simulations using a modified Patankar method. Layers can be assembled into multilayers via the + operator and support dynamic property linkage using layerLink.


Key Properties

  • l: Thickness of the layer (m)
  • D: Diffusion coefficient (m²/s)
  • k: Partition coefficient (dimensionless)
  • C0: Initial concentration (arbitrary units)
  • rho: Density (kg/m³)
  • T: Contact temperature (°C)
  • substance: Migrant/substance modeled for diffusion
  • medium: The food medium in contact with the layer
  • Dmodel, kmodel: Callable models for diffusion and partitioning

Methods

  • __add__(self, other): Combines two layers into a multilayer structure.
  • __mul__(self, n): Duplicates a layer n times to create a multilayer.
  • __getitem__(self, i): Retrieves a sublayer from a multilayer.
  • __setitem__(self, i, other): Replaces sublayers in a multilayer structure.
  • mesh(self): Generates a numerical mesh for finite-volume simulations.
  • struct(self): Returns a dictionary representation of the layer properties.
  • resolvename(param_value, param_key, **unresolved): Resolves synonyms for parameter names.
  • help(cls): Displays a dynamically formatted summary of input parameters.

Integration with SFPPy Modules

  • Works with migration.py for mass transfer simulations.
  • Interfaces with food.py to define food-contact conditions.
  • Uses property.py for predicting diffusion (D) and partitioning (k).
  • Connects with geometry.py for 3D packaging simulations.

Usage Example

from patankar.layer import LDPE, PP, layerLink

# Define a polymer layer with default properties
A = LDPE(l=50e-6, D=1e-14)

# Create a multilayer structure
B = PP(l=200e-6, D=1e-15)
multilayer = A + B

# Assign dynamic property linkage
k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
multilayer.klink = k_link

# Simulate migration
from patankar.migration import senspatankar
from patankar.food import ethanol
medium = ethanol()
solution = senspatankar(multilayer, medium)
solution.plotCF()

Notes

  • This class supports dynamic property inheritance, meaning D and k can be computed based on the substance defined in substance and medium.
  • The layerLink mechanism allows parameter adjustments without modifying the core object.
  • The modified finite-volume meshing ensures accurate steady-state and transient behavior.

Parameters

layername : TYPE, optional, string
DESCRIPTION. Layer Name. The default is "my layer".
layertype : TYPE, optional, string
DESCRIPTION. Layer Type. The default is "unknown type".
layermaterial : TYPE, optional, string
DESCRIPTION. Material identification . The default is "unknown material".
PHYSICAL QUANTITIES
l : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Thickness. The default is 50e-6 (m).
D : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
k : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
C0 : TYPE, optional, scalar or tupple (value,"unit")
DESCRIPTION. Initial concentration. The default is 1000 (a.u.).
PHYSICAL UNITS
lunit : TYPE, optional, string
DESCRIPTION. Length units. The default unit is "m.
Dunit : TYPE, optional, string
DESCRIPTION. Diffusivity units. The default unit is 1e-14 "m^2/s".
kunit : TYPE, optional, string
DESCRIPTION. Henry-like coefficient. The default unit is "a.u.".
Cunit : TYPE, optional, string
DESCRIPTION. Initial concentration. The default unit is "a.u.".

Returns

a monolayer object which can be assembled into a multilayer structure
 
Expand source code
class layer:
    """
    ------------------------------------------------------------------------------
    **Core Functionality**
    ------------------------------------------------------------------------------
    This class models layers in food packaging, handling mass transfer, partitioning,
    and meshing for finite-volume simulations using a modified Patankar method.
    Layers can be assembled into multilayers via the `+` operator and support
    dynamic property linkage using `layerLink`.

    ------------------------------------------------------------------------------
    **Key Properties**
    ------------------------------------------------------------------------------
    - `l`: Thickness of the layer (m)
    - `D`: Diffusion coefficient (m²/s)
    - `k`: Partition coefficient (dimensionless)
    - `C0`: Initial concentration (arbitrary units)
    - `rho`: Density (kg/m³)
    - `T`: Contact temperature (°C)
    - `substance`: Migrant/substance modeled for diffusion
    - `medium`: The food medium in contact with the layer
    - `Dmodel`, `kmodel`: Callable models for diffusion and partitioning

    ------------------------------------------------------------------------------
    **Methods**
    ------------------------------------------------------------------------------
    - `__add__(self, other)`: Combines two layers into a multilayer structure.
    - `__mul__(self, n)`: Duplicates a layer `n` times to create a multilayer.
    - `__getitem__(self, i)`: Retrieves a sublayer from a multilayer.
    - `__setitem__(self, i, other)`: Replaces sublayers in a multilayer structure.
    - `mesh(self)`: Generates a numerical mesh for finite-volume simulations.
    - `struct(self)`: Returns a dictionary representation of the layer properties.
    - `resolvename(param_value, param_key, **unresolved)`: Resolves synonyms for parameter names.
    - `help(cls)`: Displays a dynamically formatted summary of input parameters.

    ------------------------------------------------------------------------------
    **Integration with SFPPy Modules**
    ------------------------------------------------------------------------------
    - Works with `migration.py` for mass transfer simulations.
    - Interfaces with `food.py` to define food-contact conditions.
    - Uses `property.py` for predicting diffusion (`D`) and partitioning (`k`).
    - Connects with `geometry.py` for 3D packaging simulations.

    ------------------------------------------------------------------------------
    **Usage Example**
    ------------------------------------------------------------------------------
    ```python
    from patankar.layer import LDPE, PP, layerLink

    # Define a polymer layer with default properties
    A = LDPE(l=50e-6, D=1e-14)

    # Create a multilayer structure
    B = PP(l=200e-6, D=1e-15)
    multilayer = A + B

    # Assign dynamic property linkage
    k_link = layerLink("k", indices=[1], values=[10])  # Assign partition coefficient to the second layer
    multilayer.klink = k_link

    # Simulate migration
    from patankar.migration import senspatankar
    from patankar.food import ethanol
    medium = ethanol()
    solution = senspatankar(multilayer, medium)
    solution.plotCF()
    ```

    ------------------------------------------------------------------------------
    **Notes**
    ------------------------------------------------------------------------------
    - This class supports dynamic property inheritance, meaning `D` and `k` can be computed
      based on the substance defined in `substance` and `medium`.
    - The `layerLink` mechanism allows parameter adjustments without modifying the core object.
    - The modified finite-volume meshing ensures **accurate steady-state and transient** behavior.

    """

    # -----------------------------------------------------------------------------
    # Class attributes that can be overidden in instances.
    # Their default values are set in classes and overriden with similar
    # instance properties with @property.setter.
    # These values cannot be set during construction, but only after instantiation.
    # -----------------------------------------------------------------------------
    # These properties are essential for model predictions, they cannot be customized
    # beyond the rules accepted by the model predictors (they are not metadata)
    _physicalstate = "solid"        # solid (default), liquid, gas, porous
    _chemicalclass = "polymer"      # polymer (default), other
    _chemicalsubstance = None       # None (default), monomer for polymers
    _polarityindex = 0.0            # polarity index (roughly: 0=hexane, 10=water)

    # Low-level prediction properties (these properties are common with patankar.food)
    _lowLevelPredictionPropertyList = ["physicalstate","chemicalclass",
                                       "chemicalsubstance","polarityindex","ispolymer","issolid"]

    # --------------------------------------------------------------------
    # PRIVATE PROPERTIES (cannot be changed by the user)
    # __ read only attributes
    #  _ private attributes (not public)
    # --------------------------------------------------------------------
    __description = "LAYER object"                # description
    __version = 1.0                               # version
    __contact = "olivier.vitrac@agroparistech.fr" # contact person
    _printformat = "%0.4g"   # format to display D, k, l values


    # Synonyms dictionary: Maps alternative names to the actual parameter
    # these synonyms can be used during construction
    _synonyms = {
        "substance": {"migrant", "compound", "chemical","molecule","solute"},
        "medium": {"food","simulant","fluid","liquid","contactmedium"},
        "C0": {"CP0", "Cp0"},
        "l": {"lp", "lP"},
        "D": {"Dp", "DP"},
        "k": {"kp", "kP"},
        "T": {"temp","Temp","temperature","Temperature",
              "contacttemperature","ContactTemperature","contactTemperature"}
    }
    # Default values for parameters (note that Td cannot be changed by the end-user)
    _defaults = {
        "l": 5e-5,   # Thickness (m)
        "D": 1e-14,  # Diffusion coefficient (m^2/s)
        "k": 1.0,      # Henri-like coefficient (dimensionless)
        "C0": 1000,  # Initial concentration (arbitrary units)
        "rho": 1000, # Default density (kg/m³)
        "T": 40.0,     # Default temperature (°C)
        "Td": 25.0,    # Reference temperature for densities (°C)
        # Units (do not change)
        "lunit": "m",
        "Dunit": "m**2/s",
        "kunit": "a.u.",  # NoUnits
        "Cunit": "a.u.",  # NoUnits
        "rhounit": "kg/m**3",
        "Tunit": "degC",  # Temperatures are indicated in °C instead of K (to reduce end-user mistakes)
        # Layer properties
        "layername": "my layer",
        "layertype": "unknown type",
        "layermaterial": "unknown material",
        "layercode": "N/A",
        # Mesh parameters
        "nmeshmin": 20,
        "nmesh": 600,
        # Substance
        "substance": None,
        "simulant": None,
        # Other parameters
        "verbose": None,
        "verbosity": 2
    }

    # List units
    _parametersWithUnits = {
        "l": "m",
        "D": "m**2/s",
        "k": "a.u.",
        "C": "a.u.",
        "rhp": "kg/m**3",
        "T": "degC",
        }

    # Brief descriptions for each parameter
    _descriptionInputs = {
        "l": "Thickness of the layer (m)",
        "D": "Diffusion coefficient (m²/s)",
        "k": "Henri-like coefficient (dimensionless)",
        "C0": "Initial concentration (arbitrary units)",
        "rho": "Density of the material (kg/m³)",
        "T": "Layer temperature (°C)",
        "Td": "Reference temperature for densities (°C)",
        "lunit": "Unit of thickness (default: m)",
        "Dunit": "Unit of diffusion coefficient (default: m²/s)",
        "kunit": "Unit of Henri-like coefficient (default: a.u.)",
        "Cunit": "Unit of initial concentration (default: a.u.)",
        "rhounit": "Unit of density (default: kg/m³)",
        "Tunit": "Unit of temperature (default: degC)",
        "layername": "Name of the layer",
        "layertype": "Type of layer (e.g., polymer, ink, air)",
        "layermaterial": "Material composition of the layer",
        "layercode": "Identification code for the layer",
        "nmeshmin": "Minimum number of FV mesh elements for the layer",
        "nmesh": "Number of FV mesh elements for numerical computation",
        "verbose": "Verbose mode (None or boolean)",
        "verbosity": "Level of verbosity for debug messages (integer)"
    }

    # --------------------------------------------------------------------
    # CONSTRUCTOR OF INSTANCE PROPERTIES
    # None = missing numeric value (managed by default)
    # --------------------------------------------------------------------
    def __init__(self,
                 l=None, D=None, k=None, C0=None, rho=None, T=None,
                 lunit=None, Dunit=None, kunit=None, Cunit=None, rhounit=None, Tunit=None,
                 layername=None,layertype=None,layermaterial=None,layercode=None,
                 substance = None, medium = None,
                 # Dmodel = None, kmodel = None, they are defined via migrant (future overrides)
                 nmesh=None, nmeshmin=None, # simulation parametes
                 # link properties (for fitting and linking properties across simulations)
                 Dlink=None, klink=None, C0link=None, Tlink=None, llink=None,
                 verbose=None, verbosity=2,**unresolved):
        """

        Parameters
        ----------

        layername : TYPE, optional, string
                    DESCRIPTION. Layer Name. The default is "my layer".
        layertype : TYPE, optional, string
                    DESCRIPTION. Layer Type. The default is "unknown type".
        layermaterial : TYPE, optional, string
                        DESCRIPTION. Material identification . The default is "unknown material".
        PHYSICAL QUANTITIES
        l : TYPE, optional, scalar or tupple (value,"unit")
            DESCRIPTION. Thickness. The default is 50e-6 (m).
        D : TYPE, optional, scalar or tupple (value,"unit")
            DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
        k : TYPE, optional, scalar or tupple (value,"unit")
            DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
        C0 : TYPE, optional, scalar or tupple (value,"unit")
            DESCRIPTION. Initial concentration. The default is 1000 (a.u.).
        PHYSICAL UNITS
        lunit : TYPE, optional, string
                DESCRIPTION. Length units. The default unit is "m.
        Dunit : TYPE, optional, string
                DESCRIPTION. Diffusivity units. The default unit is 1e-14 "m^2/s".
        kunit : TYPE, optional, string
                DESCRIPTION. Henry-like coefficient. The default unit is "a.u.".
        Cunit : TYPE, optional, string
                DESCRIPTION. Initial concentration. The default unit is "a.u.".
        Returns
        -------
        a monolayer object which can be assembled into a multilayer structure

        """
        # resolve alternative names used by end-users
        substance = layer.resolvename(substance,"substance",**unresolved)
        medium = layer.resolvename(medium, "medium", **unresolved)
        C0 = layer.resolvename(C0,"C0",**unresolved)
        l = layer.resolvename(l,"l",**unresolved)
        D = layer.resolvename(D,"D",**unresolved)
        k = layer.resolvename(k,"k",**unresolved)
        T = layer.resolvename(T,"T",**unresolved)

        # Assign defaults only if values are not provided
        l = l if l is not None else layer._defaults["l"]
        D = D if D is not None else layer._defaults["D"]
        k = k if k is not None else layer._defaults["k"]
        C0 = C0 if C0 is not None else layer._defaults["C0"]
        rho = rho if rho is not None else layer._defaults["rho"]
        T = T if T is not None else layer._defaults["T"]
        lunit = lunit if lunit is not None else layer._defaults["lunit"]
        Dunit = Dunit if Dunit is not None else layer._defaults["Dunit"]
        kunit = kunit if kunit is not None else layer._defaults["kunit"]
        Cunit = Cunit if Cunit is not None else layer._defaults["Cunit"]
        rhounit = rhounit if rhounit is not None else layer._defaults["rhounit"]
        Tunit = Tunit if Tunit is not None else layer._defaults["Tunit"]
        nmesh = nmesh if nmesh is not None else layer._defaults["nmesh"]
        nmeshmin = nmeshmin if nmeshmin is not None else layer._defaults["nmeshmin"]
        verbose = verbose if verbose is not None else layer._defaults["verbose"]
        verbosity = verbosity if verbosity is not None else layer._defaults["verbosity"]

        # Assign layer id properties
        layername = layername if layername is not None else layer._defaults["layername"]
        layertype = layertype if layertype is not None else layer._defaults["layertype"]
        layermaterial = layermaterial if layermaterial is not None else layer._defaults["layermaterial"]
        layercode = layercode if layercode is not None else layer._defaults["layercode"]

        # validate all physical paramaters with their units
        l,lunit = check_units(l,lunit,layer._defaults["lunit"])
        D,Dunit = check_units(D,Dunit,layer._defaults["Dunit"])
        k,kunit = check_units(k,kunit,layer._defaults["kunit"])
        C0,Cunit = check_units(C0,Cunit,layer._defaults["Cunit"])
        rho,rhounit = check_units(rho,rhounit,layer._defaults["rhounit"])
        T,Tunit = check_units(T,Tunit,layer._defaults["Tunit"])

        # set attributes: id and physical properties
        self._name = [layername]
        self._type = [layertype]
        self._material = [layermaterial]
        self._code = [layercode]
        self._nlayer = 1
        self._l = l[:1]
        self._D = D[:1]
        self._k = k[:1]
        self._C0 = C0[:1]
        self._rho = rho[:1]
        self._T = T
        self._lunit = lunit
        self._Dunit = Dunit
        self._kunit = kunit
        self._Cunit = Cunit
        self._rhounit = rhounit
        self._Tunit = Tunit
        self._nmesh = nmesh
        self._nmeshmin = nmeshmin

        # intialize links for X = D,k,C0,T,l (see documentation of layerLink)
        # A link enables the values of X to be defined and controlled outside the instance
        self._Dlink  = self._initialize_link(Dlink, "D")
        self._klink  = self._initialize_link(klink, "k")
        self._C0link = self._initialize_link(C0link, "C0")
        self._Tlink  = self._initialize_link(Tlink, "T")
        self._llink  = self._initialize_link(llink, "l")

        # set substance, medium and related D and k models
        if isinstance(substance,str):
            substance = migrant(substance)
        if substance is not None and not isinstance(substance,migrant):
            raise ValueError(f"subtance must be None a or a migrant not a {type(substance).__name__}")
        self._substance = substance
        if medium is not None:
            from patankar.food import foodlayer # local import only if needed
            if not isinstance(medium,foodlayer):
                raise ValueError(f"medium must be None or a foodlayer not a {type(medium).__name__}")
        self._medium = medium
        self._Dmodel = "default"  # do not use directly self._compute_Dmodel (force refresh)
        self._kmodel = "default"  # do not use directly self._compute_kmodel (force refresh)

        # set history for all layers merged with +
        self._layerclass_history = []
        self._ispolymer_history = []
        self._chemicalsubstance_history = []

        # set verbosity attributes
        self.verbosity = 0 if verbosity is None else verbosity
        self.verbose = verbosity>0 if verbose is None else verbose

        # we initialize the acknowlegment process for future property propagation
        self._hasbeeninherited = {}


    # --------------------------------------------------------------------
    # Helper method: initializes and validates layerLink attributes
    # (Dlink, klink, C0link, Tlink, llink)
    # --------------------------------------------------------------------
    def _initialize_link(self, link, expected_property):
        """
        Initializes and validates a layerLink attribute.

        Parameters:
        ----------
        link : layerLink or None
            The `layerLink` instance to be assigned.
        expected_property : str
            The expected property name (e.g., "D", "k", "C0", "T").

        Returns:
        -------
        layerLink or None
            The validated `layerLink` instance or None.

        Raises:
        -------
        TypeError:
            If `link` is not a `layerLink` or `None`.
        ValueError:
            If `link.property` does not match `expected_property`.
        """
        if link is None:
            return None
        if isinstance(link, layerLink):
            if link.property == expected_property:
                return link
            raise ValueError(f'{expected_property}link.property should be "{expected_property}" not "{link.property}"')
        raise TypeError(f"{expected_property}link must be a layerLink not a {type(link).__name__}")


    # --------------------------------------------------------------------
    # Class method returning help() for the end user
    # --------------------------------------------------------------------
    @classmethod
    def help(cls):
        """
        Prints a dynamically formatted summary of all input parameters,
        adjusting column widths based on content and wrapping long descriptions.
        """

        # Column Headers
        headers = ["Parameter", "Default Value", "Has Synonyms?", "Description"]
        col_widths = [len(h) for h in headers]  # Start with header widths

        # Collect Data Rows
        rows = []
        for param, default in cls._defaults.items():
            has_synonyms = "✅ Yes" if param in cls._synonyms else "❌ No"
            description = cls._descriptionInputs.get(param, "No description available")

            # Update column widths dynamically
            col_widths[0] = max(col_widths[0], len(param))
            col_widths[1] = max(col_widths[1], len(str(default)))
            col_widths[2] = max(col_widths[2], len(has_synonyms))
            col_widths[3] = max(col_widths[3], len(description))

            rows.append([param, str(default), has_synonyms, description])

        # Function to wrap text for a given column width
        def wrap_text(text, width):
            return textwrap.fill(text, width)

        # Print Table with Adjusted Column Widths
        separator = "+-" + "-+-".join("-" * w for w in col_widths) + "-+"
        print("\n### **Accepted Parameters and Defaults**\n")
        print(separator)
        print("| " + " | ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) + " |")
        print(separator)
        for row in rows:
            # Wrap text in the description column
            row[3] = wrap_text(row[3], col_widths[3])

            # Print row
            print("| " + " | ".join(row[i].ljust(col_widths[i]) for i in range(3)) + " | " + row[3])
        print(separator)

        # Synonyms Table
        print("\n### **Parameter Synonyms**\n")
        syn_headers = ["Parameter", "Synonyms"]
        syn_col_widths = [
            max(len("Parameter"), max(len(k) for k in cls._synonyms.keys())),  # Ensure it fits "Parameter"
            max(len("Synonyms"), max(len(", ".join(v)) for v in cls._synonyms.values()))  # Ensure it fits "Synonyms"
        ]
        syn_separator = "+-" + "-+-".join("-" * w for w in syn_col_widths) + "-+"
        print(syn_separator)
        print("| " + " | ".join(h.ljust(syn_col_widths[i]) for i, h in enumerate(syn_headers)) + " |")
        print(syn_separator)
        for param, synonyms in cls._synonyms.items():
            print(f"| {param.ljust(syn_col_widths[0])} | {', '.join(synonyms).ljust(syn_col_widths[1])} |")
        print(syn_separator)


    # --------------------------------------------------------------------
    # Class method to handle ambiguous definitions from end-user
    # --------------------------------------------------------------------
    @classmethod
    def resolvename(cls, param_value, param_key, **unresolved):
        """
        Resolves the correct parameter value using known synonyms.

        - If param_value is already set (not None), return it.
        - If a synonym exists in **unresolved, assign its value.
        - If multiple synonyms of the same parameter appear in **unresolved, raise an error.
        - Otherwise, return None.

        Parameters:
        - `param_name` (any): The original value (if provided).
        - `param_key` (str): The legitimate parameter name we are resolving.
        - `unresolved` (dict): The dictionary of unrecognized keyword arguments.

        Returns:
        - The resolved value or None if not found.
        """
        if param_value is not None:
            return param_value  # The parameter is explicitly defined, do not override
        if not unresolved:      # shortcut
            return None
        resolved_value = None
        found_keys = []
        # Check if param_key itself is present in unresolved
        if param_key in unresolved:
            found_keys.append(param_key)
            resolved_value = unresolved[param_key]
        # Check if any of its synonyms are in unresolved
        if param_key in cls._synonyms:
            for synonym in cls._synonyms[param_key]:
                if synonym in unresolved:
                    found_keys.append(synonym)
                    resolved_value = unresolved[synonym]
        # Raise error if multiple synonyms were found
        if len(found_keys) > 1:
            raise ValueError(
                f"Conflicting definitions: Multiple synonyms {found_keys} were provided for '{param_key}'."
            )
        return resolved_value


    # --------------------------------------------------------------------
    # overloading binary addition (note that the output is of type layer)
    # --------------------------------------------------------------------
    def __add__(self, other):
        """ C = A + B | overload + operator """
        if isinstance(other, layer):
            res = duplicate(self)
            res._nmeshmin = min(self._nmeshmin, other._nmeshmin)
            # Propagate substance
            if self._substance is None:
                res._substance = other._substance
            else:
                if isinstance(self._substance, migrant) and isinstance(other._substance, migrant):
                    if self._substance.M != other._substance.M:
                        print("Warning: the smallest substance is propagated everywhere")
                    res._substance = self._substance if self._substance.M <= other._substance.M else other._substance
                else:
                    res._substance = None
            # Concatenate general attributes
            for p in ["_name", "_type", "_material", "_code", "_nlayer"]:
                setattr(res, p, getattr(self, p) + getattr(other, p))
            # Concatenate numeric arrays
            for p in ["_l", "_D", "_k", "_C0", "_rho", "_T"]:
                setattr(res, p, np.concatenate((getattr(self, p), getattr(other, p))))
            # Handle history tracking
            res._layerclass_history = self.layerclass_history + other.layerclass_history
            res._ispolymer_history = self.ispolymer_history + other.ispolymer_history
            res._chemicalsubstance_history = self.chemicalsubstance_history + other.chemicalsubstance_history
            # Manage layerLink attributes (Dlink, klink, C0link, Tlink, llink)
            property_map = {
                "Dlink": ("D", self.Dlink, other.Dlink),
                "klink": ("k", self.klink, other.klink),
                "C0link": ("C0", self.C0link, other.C0link),
                "Tlink": ("T", self.Tlink, other.Tlink),
                "llink": ("l", self.llink, other.llink),
            }
            for attr, (prop, self_link, other_link) in property_map.items():
                if (self_link is not None) and (other_link is not None):
                    # Case 1: Both have a link → Apply `+`
                    setattr(res, '_'+attr, self_link + other_link)
                elif self_link is not None:
                    # Case 2: Only self has a link → Use as-is
                    setattr(res, '_'+attr, self_link)
                elif other_link is not None:
                    # Case 3: Only other has a link → Shift indices and use
                    shifted_link = duplicate(other_link)
                    shifted_link.indices += len(getattr(self, prop))
                    setattr(res, '_'+attr, shifted_link)
                else:
                    # Case 4: Neither has a link → Result is None
                    setattr(res, '_'+attr, None)
            return res
        else:
            raise ValueError("Invalid layer object")


    # --------------------------------------------------------------------
    # overloading binary multiplication (note that the output is of type layer)
    # --------------------------------------------------------------------
    def __mul__(self,ntimes):
        """ nA = A*n | overload * operator """
        if isinstance(ntimes, int) and ntimes>0:
            res = duplicate(self)
            if ntimes>1:
                for n in range(1,ntimes): res += self
            return res
        else: raise ValueError("multiplicator should be a strictly positive integer")


    # --------------------------------------------------------------------
    # len method
    # --------------------------------------------------------------------
    def __len__(self):
        """ length method """
        return self._nlayer

    # --------------------------------------------------------------------
    # object indexing (get,set) method
    # --------------------------------------------------------------------
    def __getitem__(self,i):
        """ get indexing method """
        res = duplicate(self)
        # check indices
        isscalar = isinstance(i,int)
        if isinstance(i,slice):
            if i.step==None: j = list(range(i.start,i.stop))
            else: j = list(range(i.start,i.stop,i.step))
            res._nlayer = len(j)
        if isinstance(i,int): res._nlayer = 1
        # pick indices for each property
        for p in ["_name","_type","_material","_l","_D","_k","_C0"]:
            content = getattr(self,p)
            try:
                if isscalar: setattr(res,p,content[i:i+1])
                else: setattr(res,p,content[i])
            except IndexError as err:
                if self.verbosity>0 and self.verbose:
                    print("bad layer object indexing: ",err)
        return res

    def __setitem__(self,i,other):
        """ set indexing method """
        # check indices
        if isinstance(i,slice):
            if i.step==None: j = list(range(i.start,i.stop))
            else: j = list(range(i.start,i.stop,i.step))
        elif isinstance(i,int): j = [i]
        else:raise IndexError("invalid index")
        islayer = isinstance(other,layer)
        isempty = not islayer and isinstance(other,list) and len(other)<1
        if isempty:         # empty right hand side
            for p in ["_name","_type","_material","_l","_D","_k","_C0"]:
                content = getattr(self,p)
                try:
                    newcontent = [content[k] for k in range(self._nlayer) if k not in j]
                except IndexError as err:
                    if self.verbosity>0 and self.verbose:
                        print("bad layer object indexing: ",err)
                if isinstance(content,np.ndarray) and not isinstance(newcontent,np.ndarray):
                    newcontent = np.array(newcontent)
                setattr(self,p,newcontent)
            self._nlayer = len(newcontent)
        elif islayer:        # islayer right hand side
            nk1 = len(j)
            nk2 = other._nlayer
            if nk1 != nk2:
                raise IndexError("the number of elements does not match the number of indices")
            for p in ["_name","_type","_material","_l","_D","_k","_C0"]:
                content1 = getattr(self,p)
                content2 = getattr(other,p)
                for k in range(nk1):
                    try:
                        content1[j[k]] = content2[k]
                    except IndexError as err:
                        if self.verbosity>0 and self.verbose:
                            print("bad layer object indexing: ",err)
                setattr(self,p,content1)
        else:
            raise ValueError("only [] or layer object are accepted")


    # --------------------------------------------------------------------
    # Getter methods (show private/hidden properties and meta-properties)
    # --------------------------------------------------------------------
    # Return class or instance attributes
    @property
    def physicalstate(self): return self._physicalstate
    @property
    def chemicalclass(self): return self._chemicalclass
    @property
    def chemicalsubstance(self): return self._chemicalsubstance
    @property
    def polarityindex(self):
        # rescaled to match predictions - standard scale [0,10.2] - predicted scale [0,7.12]
        return self._polarityindex * migrant("water").polarityindex/10.2
    @property
    def ispolymer(self): return self.chemicalclass == "polymer"
    @property
    def issolid(self): return self.physicalstate == "solid"
    @property
    def layerclass_history(self):
        return self._layerclass_history if self._layerclass_history != [] else [self.layerclass]
    @property
    def ispolymer_history(self):
        return self._ispolymer_history if self._ispolymer_history != [] else [self.ispolymer]
    @property
    def chemicalsubstance_history(self):
        return self._chemicalsubstance_history if self._chemicalsubstance_history != [] else [self.chemicalsubstance]
    @property
    def layerclass(self): return type(self).__name__
    @property
    def name(self): return self._name
    @property
    def type(self): return self._type
    @property
    def material(self): return self._material
    @property
    def code(self): return self._code
    @property
    def l(self): return self._l if not self.hasllink else self.llink.getfull(self._l)
    @property
    def D(self):
        Dtmp = None
        if self.Dmodel == "default": # default behavior
            Dtmp = self._compute_Dmodel()
        elif callable(self.Dmodel): # user override
            Dtmp = self.Dmodel()
        if Dtmp is not None:
            Dtmp = np.full_like(self._D, Dtmp,dtype=np.float64)
            if self.hasDlink:
                return self.Dlink.getfull(Dtmp) # substitution rules are applied as defined in Dlink
            else:
                return Dtmp
        return self._D if not self.hasDlink else self.Dlink.getfull(self._D)
    @property
    def k(self):
        ktmp = None
        if self.kmodel == "default": # default behavior
            ktmp = self._compute_kmodel()
        elif callable(self.kmodel): # user override
            ktmp = self.kmodel()
        if ktmp is not None:
            ktmp = np.full_like(self._k, ktmp,dtype=np.float64)
            if self.hasklink:
                return self.klink.getfull(ktmp) # substitution rules are applied as defined in klink
            else:
                return ktmp
        return self._k if not self.hasklink else self.klink.getfull(self._k)
    @property
    def C0(self): return self._C0 if not self.hasC0link else self.COlink.getfull(self._C0)
    @property
    def rho(self): return self._rho
    @property
    def T(self): return self._T if not self.hasTlink else self.Tlink.getfull(self._T)
    @property
    def TK(self): return self._T+T0K
    @property
    def lunit(self): return self._lunit
    @property
    def Dunit(self): return self._Dunit
    @property
    def kunit(self): return self._kunit
    @property
    def Cunit(self): return self._Cunit
    @property
    def rhounit(self): return self._rhounit
    @property
    def Tunit(self): return self._Tunit
    @property
    def TKunit(self): return "K"
    @property
    def n(self): return self._nlayer
    @property
    def nmesh(self): return self._nmesh
    @property
    def nmeshmin(self): return self._nmeshmin
    @property
    def resistance(self): return self.l*self.k/self.D
    @property
    def permeability(self): return self.D/(self.l*self.k)
    @property
    def lag(self): return self.l**2/(6*self.D)
    @property
    def pressure(self): return self.k*self.C0
    @property
    def thickness(self): return sum(self.l)
    @property
    def concentration(self): return sum(self.l*self.C0)/self.thickness
    @property
    def relative_thickness(self): return self.l/self.thickness
    @property
    def relative_resistance(self): return self.resistance/sum(self.resistance)
    @property
    def rank(self): return (self.n-np.argsort(np.array(self.resistance))).tolist()
    @property
    def referencelayer(self): return np.argmax(self.resistance)
    @property
    def lreferencelayer(self): return self.l[self.referencelayer]
    @property
    def Foscale(self): return self.D[self.referencelayer]/self.lreferencelayer**2

    # substance/solute/migrant/chemical (of class migrant or None)
    @property
    def substance(self): return self._substance
    @property
    def migrant(self): return self.substance # alias/synonym of substance
    @property
    def solute(self): return self.substance # alias/synonym of substance
    @property
    def chemical(self): return self.substance # alias/synonym of substance
    # medium (of class foodlayer or None)
    @property
    def medium(self): return self._medium

    # Dmodel and kmodel returned as properties (they are lambda functions)
    # Note about the implementation: They are attributes that remain None or a callable function
    # polymer and mass are udpdated on the fly (the code loops over all layers)
    @property
    def Dmodel(self):
        return self._Dmodel
    @Dmodel.setter
    def Dmodel(self,value):
        if value is None or callable(value):
            self._Dmodel = value
        else:
            raise ValueError("Dmodel must be None or a callable function")
    @property
    def _compute_Dmodel(self):
        """Return a callable function that evaluates D with updated parameters."""
        if not isinstance(self._substance,migrant) or self._substance.Deval() is None:
            return lambda **kwargs: None  # Return a function that always returns None
        template = self._substance.Dtemplate.copy()
        template.update()
        def func(**kwargs):
            D = np.empty_like(self._D)
            for (i,),T in np.ndenumerate(self.T.ravel()): # loop over all layers via T
                template.update(polymer=self.layerclass_history[i],T=T) # updated layer properties
                # inherit eventual user parameters
                D[i] = self._substance.D.evaluate(**dict(template, **kwargs))
            return D
        return func # we return a callable function not a value

    # polarity index and molar volume are updated on the fly
    @property
    def kmodel(self):
        return self._kmodel
    @kmodel.setter
    def kmodel(self,value):
        if value is None or callable(value):
            self._kmodel = value
        else:
            raise ValueError("kmodel must be None or a callable function")
    @property
    def _compute_kmodel(self):
        """Return a callable function that evaluates k with updated parameters."""
        if not isinstance(self._substance,migrant) or self._substance.keval() is None:
            return lambda **kwargs: None  # Return a function that always returns None
        template = self._substance.ktemplate.copy()
        # add solute (i) properties: Pi and Vi have been set by loadpubchem already
        template.update(ispolymer = True)
        def func(**kwargs):
            k = np.full_like(self._k,self._k,dtype=np.float64)
            for (i,),T in np.ndenumerate(self.T.ravel()): # loop over all layers via T
                if not self.ispolymer_history[i]: # k can be evaluated only in polymes via FH theory
                    continue # we keep the existing k value
                # add/update monomer properties
                monomer = migrant(self.chemicalsubstance_history[i])
                template.update(Pk = monomer.polarityindex,
                                Vk = monomer.molarvolumeMiller)
                # inherit eventual user parameters
                k[i] = self._substance.k.evaluate(**dict(template, **kwargs))
            return k
        return func # we return a callable function not a value


    @property
    def hasDmodel(self):
        """Returns True if a Dmodel has been defined"""
        if hasattr(self, "_compute_Dmodel"):
            if self._compute_Dmodel() is not None:
                return True
            elif callable(self.Dmodel):
                return self.Dmodel() is not None
        return False

    @property
    def haskmodel(self):
        """Returns True if a kmodel has been defined"""
        if hasattr(self, "_compute_kmodel"):
            if self._compute_kmodel() is not None:
                return True
            elif callable(self.kmodel):
                return self.kmodel() is not None
        return False


    # --------------------------------------------------------------------
    # comparators based resistance
    # --------------------------------------------------------------------
    def __eq__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1==value2

    def __ne__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1!=value2

    def __lt__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1<value2

    def __gt__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1>value2

    def __le__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1<=value2

    def __ge__(self, o):
        value1 = self.resistance if self._nlayer>1 else self.resistance[0]
        if isinstance(o,layer):
            value2 = o.resistance if o._nlayer>1 else o.resistance[0]
        else:
            value2 = o
        return value1>=value2


    # --------------------------------------------------------------------
    # Generates mesh
    # --------------------------------------------------------------------
    def mesh(self,nmesh=None,nmeshmin=None):
        """ nmesh() generates mesh based on nmesh and nmeshmin, nmesh(nmesh=value,nmeshmin=value) """
        if nmesh==None: nmesh = self.nmesh
        if nmeshmin==None: nmeshmin = self.nmeshmin
        if nmeshmin>nmesh: nmeshmin,nmesh = nmesh, nmeshmin
        # X = mesh distribution (number of nodes per layer)
        X = np.ones(self._nlayer)
        for i in range(1,self._nlayer):
           X[i] = X[i-1]*(self.permeability[i-1]*self.l[i])/(self.permeability[i]*self.l[i-1])
        X = np.maximum(nmeshmin,np.ceil(nmesh*X/sum(X)))
        X = np.round((X/sum(X))*nmesh).astype(int)
        # do the mesh
        x0 = 0
        mymesh = []
        for i in range(self._nlayer):
            mymesh.append(mesh(self.l[i]/self.l[self.referencelayer],X[i],x0=x0,index=i))
            x0 += self.l[i]
        return mymesh

    # --------------------------------------------------------------------
    # Setter methods and tools to validate inputs checknumvalue and checktextvalue
    # --------------------------------------------------------------------
    @physicalstate.setter
    def physicalstate(self,value):
        if value not in ("solid","liquid","gas","supercritical"):
            raise ValueError(f"physicalstate must be solid/liduid/gas/supercritical and not {value}")
        self._physicalstate = value
    @chemicalclass.setter
    def chemicalclass(self,value):
        if value not in ("polymer","other"):
            raise ValueError(f"chemicalclass must be polymer/oher and not {value}")
        self._chemicalclass= value
    @chemicalsubstance.setter
    def chemicalsubstance(self,value):
        if not isinstance(value,str):
            raise ValueError("chemicalsubtance must be str not a {type(value).__name__}")
        self._chemicalsubstance= value
    @polarityindex.setter
    def polarityindex(self,value):
        if not isinstance(value,(float,int)):
            raise ValueError("polarity index must be float not a {type(value).__name__}")
        self._polarityindex= value

    def checknumvalue(self,value,ExpectedUnits=None):
        """ returns a validate value to set properties """
        if isinstance(value,tuple):
            value = check_units(value,ExpectedUnits=ExpectedUnits)
        if isinstance(value,int): value = float(value)
        if isinstance(value,float): value = np.array([value])
        if isinstance(value,list): value = np.array(value)
        if len(value)>self._nlayer:
            value = value[:self._nlayer]
            if self.verbosity>1 and self.verbose:
                print('dimension mismatch, the extra value(s) has been removed')
        elif len(value)<self._nlayer:
            value = np.concatenate((value,value[-1:]*np.ones(self._nlayer-len(value))))
            if self.verbosity>1 and self.verbose:
                print('dimension mismatch, the last value has been repeated')
        return value

    def checktextvalue(self,value):
        """ returns a validate value to set properties """
        if not isinstance(value,list): value = [value]
        if len(value)>self._nlayer:
            value = value[:self._nlayer]
            if self.verbosity>1 and self.verbose:
                print('dimension mismatch, the extra entry(ies) has been removed')
        elif len(value)<self._nlayer:
            value = value + value[-1:]*(self._nlayer-len(value))
            if self.verbosity>1 and self.verbose:
                print('dimension mismatch, the last entry has been repeated')
        return value

    @l.setter
    def l(self,value): self._l =self.checknumvalue(value,layer._defaults["lunit"])
    @D.setter
    def D(self,value): self._D=self.checknumvalue(value,layer._defaults["Dunit"])
    @k.setter
    def k(self,value): self._k =self.checknumvalue(value,layer._defaults["kunit"])
    @C0.setter
    def C0(self,value): self._C0 =self.checknumvalue(value,layer._defaults["Cunit"])
    @rho.setter
    def rho(self,value): self._rho =self.checknumvalue(value,layer._defaults["rhounit"])
    @T.setter
    def T(self,value): self._T =self.checknumvalue(value,layer._defaults["Tunit"])
    @name.setter
    def name(self,value): self._name =self.checktextvalue(value)
    @type.setter
    def type(self,value): self._type =self.checktextvalue(value)
    @material.setter
    def material(self,value): self._material =self.checktextvalue(value)
    @nmesh.setter
    def nmesh(self,value): self._nmesh = max(value,self._nlayer*self._nmeshmin)
    @nmeshmin.setter
    def nmeshmin(self,value): self._nmeshmin = max(value,round(self._nmesh/(2*self._nlayer)))
    @substance.setter
    def substance(self,value):
        if isinstance(value,str):
            value = migrant(value)
        if not isinstance(value,migrant) and value is not None:
            raise TypeError(f"value must be a migrant not a {type(value).__name__}")
        self._substance = value
    @migrant.setter
    def migrant(self,value):
        self.substance = value
    @chemical.setter
    def chemical(self,value):
        self.substance = value
    @solute.setter
    def solute(self,value):
        self.substance = value
    @medium.setter
    def medium(self,value):
        from patankar.food import foodlayer
        if not isinstance(value,foodlayer):
            raise TypeError(f"value must be a foodlayer not a {type(value).__name__}")
        self._medium = value

    # --------------------------------------------------------------------
    #  getter and setter for links: Dlink, klink, C0link, Tlink, llink
    # --------------------------------------------------------------------
    @property
    def Dlink(self):
        """Getter for Dlink"""
        return self._Dlink
    @Dlink.setter
    def Dlink(self, value):
        """Setter for Dlink"""
        self._Dlink = self._initialize_link(value, "D")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def klink(self):
        """Getter for klink"""
        return self._klink
    @klink.setter
    def klink(self, value):
        """Setter for klink"""
        self._klink = self._initialize_link(value, "k")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def C0link(self):
        """Getter for C0link"""
        return self._C0link
    @C0link.setter
    def C0link(self, value):
        """Setter for C0link"""
        self._C0link = self._initialize_link(value, "C0")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def Tlink(self):
        """Getter for Tlink"""
        return self._Tlink
    @Tlink.setter
    def Tlink(self, value):
        """Setter for Tlink"""
        self._Tlink = self._initialize_link(value, "T")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def llink(self):
        """Getter for llink"""
        return self._llink
    @llink.setter
    def llink(self, value):
        """Setter for llink"""
        self._llink = self._initialize_link(value, "l")
        if isinstance(value,layerLink): value._maxlength = self.n
    @property
    def hasDlink(self):
        """Returns True if Dlink is defined"""
        return self.Dlink is not None
    @property
    def hasklink(self):
        """Returns True if klink is defined"""
        return self.klink is not None
    @property
    def hasC0link(self):
        """Returns True if C0link is defined"""
        return self.C0link is not None
    @property
    def hasTlink(self):
        """Returns True if Tlink is defined"""
        return self.Tlink is not None
    @property
    def hasllink(self):
        """Returns True if llink is defined"""
        return self.llink is not None

    # --------------------------------------------------------------------
    # returned LaTeX-formated properties
    # --------------------------------------------------------------------
    def Dlatex(self, numdigits=4, units=r"\mathrm{m^2 \cdot s^{-1}}",prefix="D=",mathmode="$"):
        """Returns diffusivity values (D) formatted in LaTeX scientific notation."""
        return [format_scientific_latex(D, numdigits, units, prefix,mathmode) for D in self.D]

    def klatex(self, numdigits=4, units="a.u.",prefix="k=",mathmode="$"):
        """Returns Henry-like values (k) formatted in LaTeX scientific notation."""
        return [format_scientific_latex(k, numdigits, units, prefix,mathmode) for k in self.k]

    def llatex(self, numdigits=4, units="m",prefix="l=",mathmode="$"):
        """Returns thickness values (k) formatted in LaTeX scientific notation."""
        return [format_scientific_latex(l, numdigits, units, prefix,mathmode) for l in self.l]

    def C0latex(self, numdigits=4, units="a.u.",prefix="C0=",mathmode="$"):
        """Returns Initial Concentratoin values (C0) formatted in LaTeX scientific notation."""
        return [format_scientific_latex(c, numdigits, units, prefix,mathmode) for c in self.C0]

    # --------------------------------------------------------------------
    # hash methods (assembly and layer-by-layer)
    # note that list needs to be converted into tuples to be hashed
    # --------------------------------------------------------------------
    def __hash__(self):
        """ hash layer-object (assembly) method """
        return hash((tuple(self._name),
                     tuple(self._type),
                     tuple(self._material),
                     tuple(self._l),
                     tuple(self._D),
                     tuple(self.k),
                     tuple(self._C0),
                     tuple(self._rho)))

    # layer-by-layer @property = decoration to consider it
    # as a property instead of a method/attribute
    # comprehension for n in range(self._nlayer) applies it to all layers
    @property
    def hashlayer(self):
        """ hash layer (layer-by-layer) method """
        return [hash((self._name[n],
                      self._type[n],
                      self._material[n],
                      self._l[n],
                      self._D[n],
                      self.k[n],
                      self._C0[n],
                      self._rho[n]))
                for n in range(self._nlayer)
                ]


    # --------------------------------------------------------------------
    # repr method (since the getter are defined, the '_' is dropped)
    # --------------------------------------------------------------------
    # density and temperature are not shown
    def __repr__(self):
        """ disp method """
        print("\n[%s version=%0.4g, contact=%s]" % (self.__description,self.__version,self.__contact))
        if self._nlayer==0:
            print("empty %s" % (self.__description))
        else:
            hasDmodel, haskmodel = self.hasDmodel, self.haskmodel
            hasDlink, hasklink, hasC0link, hasTlink, hasllink = self.hasDlink, self.hasklink, self.hasC0link, self.hasTlink, self.hasllink
            properties_hasmodel = {"l":False,"D":hasDmodel,"k":haskmodel,"C0":False}
            properties_haslink = {"l":hasllink,"D":hasDlink,"k":hasklink,"C0":hasC0link,"T":hasTlink}
            if hasDmodel or haskmodel:
                properties_hasmodel["T"] = False
            fmtval = '%10s: '+self._printformat+" [%s]"
            fmtstr = '%10s= %s'
            if self._nlayer==1:
                print(f'monolayer of {self.__description}:')
            else:
                print(f'{self._nlayer}-multilayer of {self.__description}:')
            for n in range(1,self._nlayer+1):
                modelinfo = {
                    "D": f"{self._substance.D.__name__}({self.layerclass_history[n-1]},{self._substance},T={float(self.T[0])} {self.Tunit})" if hasDmodel else "",
                    "k": f"{self._substance.k.__name__}(<{self.chemicalsubstance_history[n-1]}>,{self._substance})" if haskmodel else "",
                    }
                print('-- [ layer %d of %d ] ---------- barrier rank=%d --------------'
                      % (n,self._nlayer,self.rank[n-1]))
                for p in ["name","type","material","code"]:
                    v = getattr(self,p)
                    print('%10s: "%s"' % (p,v[n-1]),flush=True)
                for p in properties_hasmodel.keys():
                    v = getattr(self,p)                 # value
                    vunit = getattr(self,p[0]+"unit")   # value unit
                    print(fmtval % (p,v[n-1],vunit),flush=True)
                    isoverridenbylink = False
                    if properties_haslink[p]:
                        isoverridenbylink = not np.isnan(getattr(self,p+"link").get(n-1))
                    if isoverridenbylink:
                        print(fmtstr % ("",f"value controlled by {p}link[{n-1}] (external)"),flush=True)
                    elif properties_hasmodel[p]:
                        print(fmtstr % ("",modelinfo[p]),flush=True)
        return str(self)

    def __str__(self):
        """Formatted string representation of layer"""
        all_identical = len(set(self.layerclass_history)) == 1
        cls = self.__class__.__name__ if all_identical else "multilayer"
        return f"<{cls} with {self.n} layer{'s' if self.n>1 else ''}: {self.name}>"

    # --------------------------------------------------------------------
    # Returns the equivalent dictionary from an object for debugging
    # --------------------------------------------------------------------
    def _todict(self):
        """ returns the equivalent dictionary from an object """
        return dict((key, getattr(self, key)) for key in dir(self) if key not in dir(self.__class__))
    # --------------------------------------------------------------------

    # --------------------------------------------------------------------
    # Simplify layers by collecting similar ones
    # --------------------------------------------------------------------
    def simplify(self):
        """ merge continuous layers of the same type """
        nlayer = self._nlayer
        if nlayer>1:
           res = self[0]
           ires = 0
           ireshash = res.hashlayer[0]
           for i in range(1,nlayer):
               if self.hashlayer[i]==ireshash:
                   res.l[ires] = res.l[ires]+self.l[i]
               else:
                   res = res + self[i]
                   ires = ires+1
                   ireshash = self.hashlayer[i]
        else:
             res = self.copy()
        return res

    # --------------------------------------------------------------------
    # Split layers into a tuple
    # --------------------------------------------------------------------
    def split(self):
        """ split layers """
        out = ()
        if self._nlayer>0:
            for i in range(self._nlayer):
                out = out + (self[i],) # (,) special syntax for tuple singleton
        return out

    # --------------------------------------------------------------------
    # deepcopy
    # --------------------------------------------------------------------
    def copy(self,**kwargs):
        """
        Creates a deep copy of the current layer instance.

        Returns:
        - layer: A new layer instance identical to the original.
        """
        return duplicate(self).update(**kwargs)

    # --------------------------------------------------------------------
    # update contact conditions from a foodphysics instance (or do the reverse)
    # material << medium
    # material@medium
    # --------------------------------------------------------------------
    def _from(self,medium=None):
        """Propagates contact conditions from food instance"""
        from patankar.food import foodphysics, foodlayer
        if not isinstance(medium,foodphysics):
            raise TypeError(f"medium must be a foodphysics, foodlayer not a {type(medium).__name__}")
        if not hasattr(medium, "contacttemperature"):
            medium.contacttemperature = self.T[0]
        T = medium.get_param("contacttemperature",40,acceptNone=False)
        self.T = np.full_like(self.T,T,dtype=np.float64)
        if medium.substance is not None:
            self.substance = medium.substance
        else:
            medium.substance = self.substance # do the reverse if substance is not defined in medium
        # inherit fully medium only if it is a foodlayer (foodphysics is too restrictive)
        if isinstance(medium,foodlayer):
            self.medium = medium

    # overload operator <<
    def __lshift__(self, medium):
        """Overloads << to propagate contact conditions from food."""
        self._from(medium)
    # overload operator @ (same as <<)
    def __matmul__(self, medium):
        """Overloads @ to propagate contact conditions from food."""
        self._from(medium)


    # --------------------------------------------------------------------
    # Inheritance registration mechanism associated with food >> layer
    # It is used by food, not by layer (please refer to food.py).
    # Note that layer >> food means mass transfer simulation
    # --------------------------------------------------------------------
    def acknowledge(self, what=None, category=None):
        """
        Register inherited properties under a given category.

        Parameters:
        -----------
        what : str or list of str or a set
            The properties or attributes that have been inherited.
        category : str
            The category under which the properties are grouped.
        """
        if category is None or what is None:
            raise ValueError("Both 'what' and 'category' must be provided.")
        if isinstance(what, str):
            what = {what}  # Convert string to a set
        elif isinstance(what, list):
            what = set(what)  # Convert list to a set for uniqueness
        elif not isinstance(what,set):
            raise TypeError("'what' must be a string, a list, or a set of strings.")
        if category not in self._hasbeeninherited:
            self._hasbeeninherited[category] = set()
        self._hasbeeninherited[category].update(what)

    # --------------------------------------------------------------------
    # migration simulation overloaded as sim = layer >> food
    # using layer >> food without output works also.
    # The result is stored in food.lastsimulation
    # --------------------------------------------------------------------
    def contact(self,medium,**kwargs):
        """alias to migration method"""
        return self.migration(medium,**kwargs)

    def migration(self,medium=None,**kwargs):
        """interface to simulation engine: senspantankar"""
        from patankar.food import foodphysics
        from patankar.migration import senspatankar
        if medium is None:
            medium = self.medium
        if not isinstance(medium,foodphysics):
            raise TypeError(f"medium must be a foodphysics not a {type(medium).__name__}")
        sim = senspatankar(self,medium,**kwargs)
        medium.lastsimulation = sim # store the last simulation result in medium
        medium.lastinput = self # store the last input (self)
        sim.savestate(self,medium) # store store the inputs in sim for chaining
        return sim

    # overloading operation
    def __rshift__(self, medium):
        """Overloads >> to propagate migration to food."""
        from patankar.food import foodphysics
        if not isinstance(medium,foodphysics):
            raise TypeError(f"medium must be a foodphysics object not a {type(medium).__name__}")
        return self.contact(medium)

    # --------------------------------------------------------------------
    # Safe update method
    # --------------------------------------------------------------------
    def update(self, **kwargs):
        """
        Update layer parameters following strict validation rules.

        Rules:
        1) key should be listed in self._defaults
        2) for some keys, synonyms are acceptable as reported in self._synonyms
        3) values cannot be None if they were not None in _defaults
        4) values should be str if they were initially str, idem with bool
        5) values which were numeric (int, float, np.ndarray) should remain numeric.
        6) lists are acceptable as numeric arrays
        7) all numerical (float, np.ndarray, list) except int must be converted into numpy arrays.
           Values which were int in _defaults must remain int and an error should be raised
           if a float value is proposed.
        8) keys listed in _parametersWithUnits can be assigned with tuples (value, "unit").
           They will be converted automatically with check_units(value).
        9) for parameters with a default value None, any value is acceptable
        10) A clear error message should be displayed for any bad value showing the
            current value of the parameter and its default value.
        """

        if not kwargs:  # shortcut
            return self # for chaining

        param_counts = {key: 0 for key in self._defaults}  # Track how many times each param is set

        def resolve_key(key):
            """Resolve key considering synonyms and check for duplicates."""
            for main_key, synonyms in self._synonyms.items():
                if key == main_key or key in synonyms:
                    param_counts[main_key] += 1
                    return main_key
            param_counts[key] += 1
            return key

        def validate_value(key, value):
            """Validate and process the value according to the rules."""
            default_value = self._defaults[key]

            # Rule 3: values cannot be None if they were not None in _defaults
            if value is None and default_value is not None:
                raise ValueError(f"Invalid value for '{key}': None is not allowed. "
                                 f"Current: {getattr(self, key)}, Default: {default_value}")

            # Rule 9: If default is None, any value is acceptable
            if default_value is None:
                return value

            # Rule 4 & 5: Ensure type consistency (str, bool, or numeric types)
            if isinstance(default_value, str) and not isinstance(value, str):
                raise TypeError(f"Invalid type for '{key}': Expected str, got {type(value).__name__}. "
                                f"Current: {getattr(self, key)}, Default: {default_value}")
            if isinstance(default_value, bool) and not isinstance(value, bool):
                raise TypeError(f"Invalid type for '{key}': Expected bool, got {type(value).__name__}. "
                                f"Current: {getattr(self, key)}, Default: {default_value}")

            # Rule 6 & 7: Convert numeric types properly
            if isinstance(default_value, (int, float, np.ndarray)):
                if isinstance(value, list):
                    value = np.array(value)

                if isinstance(default_value, int):
                    if isinstance(value, float) or (isinstance(value, np.ndarray) and np.issubdtype(value.dtype, np.floating)):
                        raise TypeError(f"Invalid type for '{key}': Expected integer, got float. "
                                        f"Current: {getattr(self, key)}, Default: {default_value}")
                    if isinstance(value, (int, np.integer)):
                        return int(value)  # Ensure it remains an int
                    raise TypeError(f"Invalid type for '{key}': Expected integer, got {type(value).__name__}. "
                                    f"Current: {getattr(self, key)}, Default: {default_value}")

                if isinstance(value, (int, float, list, np.ndarray)):
                    return np.array(value, dtype=float)  # Convert everything to np.array for floats

                raise TypeError(f"Invalid type for '{key}': Expected numeric, got {type(value).__name__}. "
                                f"Current: {getattr(self, key)}, Default: {default_value}")

            # Rule 8: Convert units if applicable
            if key in self._parametersWithUnits and isinstance(value, tuple):
                value, unit = value
                converted_value, _ = check_units((value, unit), ExpectedUnits=self._parametersWithUnits[key])
                return converted_value

            return value

        # Apply updates while tracking parameter occurrences
        for key, value in kwargs.items():
            resolved_key = resolve_key(key)

            if resolved_key not in self._defaults:
                raise KeyError(f"Invalid key '{key}'. Allowed keys: {list(self._defaults.keys())}.")

            try:
                validated_value = validate_value(resolved_key, value)
                setattr(self, resolved_key, validated_value)
            except (TypeError, ValueError) as e:
                raise ValueError(f"Error updating '{key}': {e}")

        # Ensure that no parameter was set multiple times due to synonyms
        duplicate_keys = [k for k, v in param_counts.items() if v > 1]
        if duplicate_keys:
            raise ValueError(f"Duplicate assignment detected for parameters: {duplicate_keys}. "
                             "Use only one synonym per parameter.")

        return self # to enable chaining

    # Basic tool for debugging
    # --------------------------------------------------------------------
    # STRUCT method - returns the equivalent dictionary from an object
    # --------------------------------------------------------------------
    def struct(self):
        """ returns the equivalent dictionary from an object """
        return dict((key, getattr(self, key)) for key in dir(self) if key not in dir(self.__class__))

Subclasses

Static methods

def help()

Prints a dynamically formatted summary of all input parameters, adjusting column widths based on content and wrapping long descriptions.

Expand source code
@classmethod
def help(cls):
    """
    Prints a dynamically formatted summary of all input parameters,
    adjusting column widths based on content and wrapping long descriptions.
    """

    # Column Headers
    headers = ["Parameter", "Default Value", "Has Synonyms?", "Description"]
    col_widths = [len(h) for h in headers]  # Start with header widths

    # Collect Data Rows
    rows = []
    for param, default in cls._defaults.items():
        has_synonyms = "✅ Yes" if param in cls._synonyms else "❌ No"
        description = cls._descriptionInputs.get(param, "No description available")

        # Update column widths dynamically
        col_widths[0] = max(col_widths[0], len(param))
        col_widths[1] = max(col_widths[1], len(str(default)))
        col_widths[2] = max(col_widths[2], len(has_synonyms))
        col_widths[3] = max(col_widths[3], len(description))

        rows.append([param, str(default), has_synonyms, description])

    # Function to wrap text for a given column width
    def wrap_text(text, width):
        return textwrap.fill(text, width)

    # Print Table with Adjusted Column Widths
    separator = "+-" + "-+-".join("-" * w for w in col_widths) + "-+"
    print("\n### **Accepted Parameters and Defaults**\n")
    print(separator)
    print("| " + " | ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) + " |")
    print(separator)
    for row in rows:
        # Wrap text in the description column
        row[3] = wrap_text(row[3], col_widths[3])

        # Print row
        print("| " + " | ".join(row[i].ljust(col_widths[i]) for i in range(3)) + " | " + row[3])
    print(separator)

    # Synonyms Table
    print("\n### **Parameter Synonyms**\n")
    syn_headers = ["Parameter", "Synonyms"]
    syn_col_widths = [
        max(len("Parameter"), max(len(k) for k in cls._synonyms.keys())),  # Ensure it fits "Parameter"
        max(len("Synonyms"), max(len(", ".join(v)) for v in cls._synonyms.values()))  # Ensure it fits "Synonyms"
    ]
    syn_separator = "+-" + "-+-".join("-" * w for w in syn_col_widths) + "-+"
    print(syn_separator)
    print("| " + " | ".join(h.ljust(syn_col_widths[i]) for i, h in enumerate(syn_headers)) + " |")
    print(syn_separator)
    for param, synonyms in cls._synonyms.items():
        print(f"| {param.ljust(syn_col_widths[0])} | {', '.join(synonyms).ljust(syn_col_widths[1])} |")
    print(syn_separator)
def resolvename(param_value, param_key, **unresolved)

Resolves the correct parameter value using known synonyms.

  • If param_value is already set (not None), return it.
  • If a synonym exists in **unresolved, assign its value.
  • If multiple synonyms of the same parameter appear in **unresolved, raise an error.
  • Otherwise, return None.

Parameters: - param_name (any): The original value (if provided). - param_key (str): The legitimate parameter name we are resolving. - unresolved (dict): The dictionary of unrecognized keyword arguments.

Returns: - The resolved value or None if not found.

Expand source code
@classmethod
def resolvename(cls, param_value, param_key, **unresolved):
    """
    Resolves the correct parameter value using known synonyms.

    - If param_value is already set (not None), return it.
    - If a synonym exists in **unresolved, assign its value.
    - If multiple synonyms of the same parameter appear in **unresolved, raise an error.
    - Otherwise, return None.

    Parameters:
    - `param_name` (any): The original value (if provided).
    - `param_key` (str): The legitimate parameter name we are resolving.
    - `unresolved` (dict): The dictionary of unrecognized keyword arguments.

    Returns:
    - The resolved value or None if not found.
    """
    if param_value is not None:
        return param_value  # The parameter is explicitly defined, do not override
    if not unresolved:      # shortcut
        return None
    resolved_value = None
    found_keys = []
    # Check if param_key itself is present in unresolved
    if param_key in unresolved:
        found_keys.append(param_key)
        resolved_value = unresolved[param_key]
    # Check if any of its synonyms are in unresolved
    if param_key in cls._synonyms:
        for synonym in cls._synonyms[param_key]:
            if synonym in unresolved:
                found_keys.append(synonym)
                resolved_value = unresolved[synonym]
    # Raise error if multiple synonyms were found
    if len(found_keys) > 1:
        raise ValueError(
            f"Conflicting definitions: Multiple synonyms {found_keys} were provided for '{param_key}'."
        )
    return resolved_value

Instance variables

var C0
Expand source code
@property
def C0(self): return self._C0 if not self.hasC0link else self.COlink.getfull(self._C0)

Getter for C0link

Expand source code
@property
def C0link(self):
    """Getter for C0link"""
    return self._C0link
var Cunit
Expand source code
@property
def Cunit(self): return self._Cunit
var D
Expand source code
@property
def D(self):
    Dtmp = None
    if self.Dmodel == "default": # default behavior
        Dtmp = self._compute_Dmodel()
    elif callable(self.Dmodel): # user override
        Dtmp = self.Dmodel()
    if Dtmp is not None:
        Dtmp = np.full_like(self._D, Dtmp,dtype=np.float64)
        if self.hasDlink:
            return self.Dlink.getfull(Dtmp) # substitution rules are applied as defined in Dlink
        else:
            return Dtmp
    return self._D if not self.hasDlink else self.Dlink.getfull(self._D)

Getter for Dlink

Expand source code
@property
def Dlink(self):
    """Getter for Dlink"""
    return self._Dlink
var Dmodel
Expand source code
@property
def Dmodel(self):
    return self._Dmodel
var Dunit
Expand source code
@property
def Dunit(self): return self._Dunit
var Foscale
Expand source code
@property
def Foscale(self): return self.D[self.referencelayer]/self.lreferencelayer**2
var T
Expand source code
@property
def T(self): return self._T if not self.hasTlink else self.Tlink.getfull(self._T)
var TK
Expand source code
@property
def TK(self): return self._T+T0K
var TKunit
Expand source code
@property
def TKunit(self): return "K"

Getter for Tlink

Expand source code
@property
def Tlink(self):
    """Getter for Tlink"""
    return self._Tlink
var Tunit
Expand source code
@property
def Tunit(self): return self._Tunit
var chemical
Expand source code
@property
def chemical(self): return self.substance # alias/synonym of substance
var chemicalclass
Expand source code
@property
def chemicalclass(self): return self._chemicalclass
var chemicalsubstance
Expand source code
@property
def chemicalsubstance(self): return self._chemicalsubstance
var chemicalsubstance_history
Expand source code
@property
def chemicalsubstance_history(self):
    return self._chemicalsubstance_history if self._chemicalsubstance_history != [] else [self.chemicalsubstance]
var code
Expand source code
@property
def code(self): return self._code
var concentration
Expand source code
@property
def concentration(self): return sum(self.l*self.C0)/self.thickness

Returns True if C0link is defined

Expand source code
@property
def hasC0link(self):
    """Returns True if C0link is defined"""
    return self.C0link is not None

Returns True if Dlink is defined

Expand source code
@property
def hasDlink(self):
    """Returns True if Dlink is defined"""
    return self.Dlink is not None
var hasDmodel

Returns True if a Dmodel has been defined

Expand source code
@property
def hasDmodel(self):
    """Returns True if a Dmodel has been defined"""
    if hasattr(self, "_compute_Dmodel"):
        if self._compute_Dmodel() is not None:
            return True
        elif callable(self.Dmodel):
            return self.Dmodel() is not None
    return False

Returns True if Tlink is defined

Expand source code
@property
def hasTlink(self):
    """Returns True if Tlink is defined"""
    return self.Tlink is not None
var hashlayer

hash layer (layer-by-layer) method

Expand source code
@property
def hashlayer(self):
    """ hash layer (layer-by-layer) method """
    return [hash((self._name[n],
                  self._type[n],
                  self._material[n],
                  self._l[n],
                  self._D[n],
                  self.k[n],
                  self._C0[n],
                  self._rho[n]))
            for n in range(self._nlayer)
            ]

Returns True if klink is defined

Expand source code
@property
def hasklink(self):
    """Returns True if klink is defined"""
    return self.klink is not None
var haskmodel

Returns True if a kmodel has been defined

Expand source code
@property
def haskmodel(self):
    """Returns True if a kmodel has been defined"""
    if hasattr(self, "_compute_kmodel"):
        if self._compute_kmodel() is not None:
            return True
        elif callable(self.kmodel):
            return self.kmodel() is not None
    return False

Returns True if llink is defined

Expand source code
@property
def hasllink(self):
    """Returns True if llink is defined"""
    return self.llink is not None
var ispolymer
Expand source code
@property
def ispolymer(self): return self.chemicalclass == "polymer"
var ispolymer_history
Expand source code
@property
def ispolymer_history(self):
    return self._ispolymer_history if self._ispolymer_history != [] else [self.ispolymer]
var issolid
Expand source code
@property
def issolid(self): return self.physicalstate == "solid"
var k
Expand source code
@property
def k(self):
    ktmp = None
    if self.kmodel == "default": # default behavior
        ktmp = self._compute_kmodel()
    elif callable(self.kmodel): # user override
        ktmp = self.kmodel()
    if ktmp is not None:
        ktmp = np.full_like(self._k, ktmp,dtype=np.float64)
        if self.hasklink:
            return self.klink.getfull(ktmp) # substitution rules are applied as defined in klink
        else:
            return ktmp
    return self._k if not self.hasklink else self.klink.getfull(self._k)

Getter for klink

Expand source code
@property
def klink(self):
    """Getter for klink"""
    return self._klink
var kmodel
Expand source code
@property
def kmodel(self):
    return self._kmodel
var kunit
Expand source code
@property
def kunit(self): return self._kunit
var l
Expand source code
@property
def l(self): return self._l if not self.hasllink else self.llink.getfull(self._l)
var lag
Expand source code
@property
def lag(self): return self.l**2/(6*self.D)
var layerclass
Expand source code
@property
def layerclass(self): return type(self).__name__
var layerclass_history
Expand source code
@property
def layerclass_history(self):
    return self._layerclass_history if self._layerclass_history != [] else [self.layerclass]

Getter for llink

Expand source code
@property
def llink(self):
    """Getter for llink"""
    return self._llink
var lreferencelayer
Expand source code
@property
def lreferencelayer(self): return self.l[self.referencelayer]
var lunit
Expand source code
@property
def lunit(self): return self._lunit
var material
Expand source code
@property
def material(self): return self._material
var medium
Expand source code
@property
def medium(self): return self._medium
var migrant
Expand source code
@property
def migrant(self): return self.substance # alias/synonym of substance
var n
Expand source code
@property
def n(self): return self._nlayer
var name
Expand source code
@property
def name(self): return self._name
var nmesh
Expand source code
@property
def nmesh(self): return self._nmesh
var nmeshmin
Expand source code
@property
def nmeshmin(self): return self._nmeshmin
var permeability
Expand source code
@property
def permeability(self): return self.D/(self.l*self.k)
var physicalstate
Expand source code
@property
def physicalstate(self): return self._physicalstate
var polarityindex
Expand source code
@property
def polarityindex(self):
    # rescaled to match predictions - standard scale [0,10.2] - predicted scale [0,7.12]
    return self._polarityindex * migrant("water").polarityindex/10.2
var pressure
Expand source code
@property
def pressure(self): return self.k*self.C0
var rank
Expand source code
@property
def rank(self): return (self.n-np.argsort(np.array(self.resistance))).tolist()
var referencelayer
Expand source code
@property
def referencelayer(self): return np.argmax(self.resistance)
var relative_resistance
Expand source code
@property
def relative_resistance(self): return self.resistance/sum(self.resistance)
var relative_thickness
Expand source code
@property
def relative_thickness(self): return self.l/self.thickness
var resistance
Expand source code
@property
def resistance(self): return self.l*self.k/self.D
var rho
Expand source code
@property
def rho(self): return self._rho
var rhounit
Expand source code
@property
def rhounit(self): return self._rhounit
var solute
Expand source code
@property
def solute(self): return self.substance # alias/synonym of substance
var substance
Expand source code
@property
def substance(self): return self._substance
var thickness
Expand source code
@property
def thickness(self): return sum(self.l)
var type
Expand source code
@property
def type(self): return self._type

Methods

def C0latex(self, numdigits=4, units='a.u.', prefix='C0=', mathmode='$')

Returns Initial Concentratoin values (C0) formatted in LaTeX scientific notation.

Expand source code
def C0latex(self, numdigits=4, units="a.u.",prefix="C0=",mathmode="$"):
    """Returns Initial Concentratoin values (C0) formatted in LaTeX scientific notation."""
    return [format_scientific_latex(c, numdigits, units, prefix,mathmode) for c in self.C0]
def Dlatex(self, numdigits=4, units='\\mathrm{m^2 \\cdot s^{-1}}', prefix='D=', mathmode='$')

Returns diffusivity values (D) formatted in LaTeX scientific notation.

Expand source code
def Dlatex(self, numdigits=4, units=r"\mathrm{m^2 \cdot s^{-1}}",prefix="D=",mathmode="$"):
    """Returns diffusivity values (D) formatted in LaTeX scientific notation."""
    return [format_scientific_latex(D, numdigits, units, prefix,mathmode) for D in self.D]
def acknowledge(self, what=None, category=None)

Register inherited properties under a given category.

Parameters:

what : str or list of str or a set The properties or attributes that have been inherited. category : str The category under which the properties are grouped.

Expand source code
def acknowledge(self, what=None, category=None):
    """
    Register inherited properties under a given category.

    Parameters:
    -----------
    what : str or list of str or a set
        The properties or attributes that have been inherited.
    category : str
        The category under which the properties are grouped.
    """
    if category is None or what is None:
        raise ValueError("Both 'what' and 'category' must be provided.")
    if isinstance(what, str):
        what = {what}  # Convert string to a set
    elif isinstance(what, list):
        what = set(what)  # Convert list to a set for uniqueness
    elif not isinstance(what,set):
        raise TypeError("'what' must be a string, a list, or a set of strings.")
    if category not in self._hasbeeninherited:
        self._hasbeeninherited[category] = set()
    self._hasbeeninherited[category].update(what)
def checknumvalue(self, value, ExpectedUnits=None)

returns a validate value to set properties

Expand source code
def checknumvalue(self,value,ExpectedUnits=None):
    """ returns a validate value to set properties """
    if isinstance(value,tuple):
        value = check_units(value,ExpectedUnits=ExpectedUnits)
    if isinstance(value,int): value = float(value)
    if isinstance(value,float): value = np.array([value])
    if isinstance(value,list): value = np.array(value)
    if len(value)>self._nlayer:
        value = value[:self._nlayer]
        if self.verbosity>1 and self.verbose:
            print('dimension mismatch, the extra value(s) has been removed')
    elif len(value)<self._nlayer:
        value = np.concatenate((value,value[-1:]*np.ones(self._nlayer-len(value))))
        if self.verbosity>1 and self.verbose:
            print('dimension mismatch, the last value has been repeated')
    return value
def checktextvalue(self, value)

returns a validate value to set properties

Expand source code
def checktextvalue(self,value):
    """ returns a validate value to set properties """
    if not isinstance(value,list): value = [value]
    if len(value)>self._nlayer:
        value = value[:self._nlayer]
        if self.verbosity>1 and self.verbose:
            print('dimension mismatch, the extra entry(ies) has been removed')
    elif len(value)<self._nlayer:
        value = value + value[-1:]*(self._nlayer-len(value))
        if self.verbosity>1 and self.verbose:
            print('dimension mismatch, the last entry has been repeated')
    return value
def contact(self, medium, **kwargs)

alias to migration method

Expand source code
def contact(self,medium,**kwargs):
    """alias to migration method"""
    return self.migration(medium,**kwargs)
def copy(self, **kwargs)

Creates a deep copy of the current layer instance.

Returns: - layer: A new layer instance identical to the original.

Expand source code
def copy(self,**kwargs):
    """
    Creates a deep copy of the current layer instance.

    Returns:
    - layer: A new layer instance identical to the original.
    """
    return duplicate(self).update(**kwargs)
def klatex(self, numdigits=4, units='a.u.', prefix='k=', mathmode='$')

Returns Henry-like values (k) formatted in LaTeX scientific notation.

Expand source code
def klatex(self, numdigits=4, units="a.u.",prefix="k=",mathmode="$"):
    """Returns Henry-like values (k) formatted in LaTeX scientific notation."""
    return [format_scientific_latex(k, numdigits, units, prefix,mathmode) for k in self.k]
def llatex(self, numdigits=4, units='m', prefix='l=', mathmode='$')

Returns thickness values (k) formatted in LaTeX scientific notation.

Expand source code
def llatex(self, numdigits=4, units="m",prefix="l=",mathmode="$"):
    """Returns thickness values (k) formatted in LaTeX scientific notation."""
    return [format_scientific_latex(l, numdigits, units, prefix,mathmode) for l in self.l]
def mesh(self, nmesh=None, nmeshmin=None)

nmesh() generates mesh based on nmesh and nmeshmin, nmesh(nmesh=value,nmeshmin=value)

Expand source code
def mesh(self,nmesh=None,nmeshmin=None):
    """ nmesh() generates mesh based on nmesh and nmeshmin, nmesh(nmesh=value,nmeshmin=value) """
    if nmesh==None: nmesh = self.nmesh
    if nmeshmin==None: nmeshmin = self.nmeshmin
    if nmeshmin>nmesh: nmeshmin,nmesh = nmesh, nmeshmin
    # X = mesh distribution (number of nodes per layer)
    X = np.ones(self._nlayer)
    for i in range(1,self._nlayer):
       X[i] = X[i-1]*(self.permeability[i-1]*self.l[i])/(self.permeability[i]*self.l[i-1])
    X = np.maximum(nmeshmin,np.ceil(nmesh*X/sum(X)))
    X = np.round((X/sum(X))*nmesh).astype(int)
    # do the mesh
    x0 = 0
    mymesh = []
    for i in range(self._nlayer):
        mymesh.append(mesh(self.l[i]/self.l[self.referencelayer],X[i],x0=x0,index=i))
        x0 += self.l[i]
    return mymesh
def migration(self, medium=None, **kwargs)

interface to simulation engine: senspantankar

Expand source code
def migration(self,medium=None,**kwargs):
    """interface to simulation engine: senspantankar"""
    from patankar.food import foodphysics
    from patankar.migration import senspatankar
    if medium is None:
        medium = self.medium
    if not isinstance(medium,foodphysics):
        raise TypeError(f"medium must be a foodphysics not a {type(medium).__name__}")
    sim = senspatankar(self,medium,**kwargs)
    medium.lastsimulation = sim # store the last simulation result in medium
    medium.lastinput = self # store the last input (self)
    sim.savestate(self,medium) # store store the inputs in sim for chaining
    return sim
def simplify(self)

merge continuous layers of the same type

Expand source code
def simplify(self):
    """ merge continuous layers of the same type """
    nlayer = self._nlayer
    if nlayer>1:
       res = self[0]
       ires = 0
       ireshash = res.hashlayer[0]
       for i in range(1,nlayer):
           if self.hashlayer[i]==ireshash:
               res.l[ires] = res.l[ires]+self.l[i]
           else:
               res = res + self[i]
               ires = ires+1
               ireshash = self.hashlayer[i]
    else:
         res = self.copy()
    return res
def split(self)

split layers

Expand source code
def split(self):
    """ split layers """
    out = ()
    if self._nlayer>0:
        for i in range(self._nlayer):
            out = out + (self[i],) # (,) special syntax for tuple singleton
    return out
def struct(self)

returns the equivalent dictionary from an object

Expand source code
def struct(self):
    """ returns the equivalent dictionary from an object """
    return dict((key, getattr(self, key)) for key in dir(self) if key not in dir(self.__class__))
def update(self, **kwargs)

Update layer parameters following strict validation rules.

Rules: 1) key should be listed in self._defaults 2) for some keys, synonyms are acceptable as reported in self._synonyms 3) values cannot be None if they were not None in _defaults 4) values should be str if they were initially str, idem with bool 5) values which were numeric (int, float, np.ndarray) should remain numeric. 6) lists are acceptable as numeric arrays 7) all numerical (float, np.ndarray, list) except int must be converted into numpy arrays. Values which were int in _defaults must remain int and an error should be raised if a float value is proposed. 8) keys listed in _parametersWithUnits can be assigned with tuples (value, "unit"). They will be converted automatically with check_units(value). 9) for parameters with a default value None, any value is acceptable 10) A clear error message should be displayed for any bad value showing the current value of the parameter and its default value.

Expand source code
def update(self, **kwargs):
    """
    Update layer parameters following strict validation rules.

    Rules:
    1) key should be listed in self._defaults
    2) for some keys, synonyms are acceptable as reported in self._synonyms
    3) values cannot be None if they were not None in _defaults
    4) values should be str if they were initially str, idem with bool
    5) values which were numeric (int, float, np.ndarray) should remain numeric.
    6) lists are acceptable as numeric arrays
    7) all numerical (float, np.ndarray, list) except int must be converted into numpy arrays.
       Values which were int in _defaults must remain int and an error should be raised
       if a float value is proposed.
    8) keys listed in _parametersWithUnits can be assigned with tuples (value, "unit").
       They will be converted automatically with check_units(value).
    9) for parameters with a default value None, any value is acceptable
    10) A clear error message should be displayed for any bad value showing the
        current value of the parameter and its default value.
    """

    if not kwargs:  # shortcut
        return self # for chaining

    param_counts = {key: 0 for key in self._defaults}  # Track how many times each param is set

    def resolve_key(key):
        """Resolve key considering synonyms and check for duplicates."""
        for main_key, synonyms in self._synonyms.items():
            if key == main_key or key in synonyms:
                param_counts[main_key] += 1
                return main_key
        param_counts[key] += 1
        return key

    def validate_value(key, value):
        """Validate and process the value according to the rules."""
        default_value = self._defaults[key]

        # Rule 3: values cannot be None if they were not None in _defaults
        if value is None and default_value is not None:
            raise ValueError(f"Invalid value for '{key}': None is not allowed. "
                             f"Current: {getattr(self, key)}, Default: {default_value}")

        # Rule 9: If default is None, any value is acceptable
        if default_value is None:
            return value

        # Rule 4 & 5: Ensure type consistency (str, bool, or numeric types)
        if isinstance(default_value, str) and not isinstance(value, str):
            raise TypeError(f"Invalid type for '{key}': Expected str, got {type(value).__name__}. "
                            f"Current: {getattr(self, key)}, Default: {default_value}")
        if isinstance(default_value, bool) and not isinstance(value, bool):
            raise TypeError(f"Invalid type for '{key}': Expected bool, got {type(value).__name__}. "
                            f"Current: {getattr(self, key)}, Default: {default_value}")

        # Rule 6 & 7: Convert numeric types properly
        if isinstance(default_value, (int, float, np.ndarray)):
            if isinstance(value, list):
                value = np.array(value)

            if isinstance(default_value, int):
                if isinstance(value, float) or (isinstance(value, np.ndarray) and np.issubdtype(value.dtype, np.floating)):
                    raise TypeError(f"Invalid type for '{key}': Expected integer, got float. "
                                    f"Current: {getattr(self, key)}, Default: {default_value}")
                if isinstance(value, (int, np.integer)):
                    return int(value)  # Ensure it remains an int
                raise TypeError(f"Invalid type for '{key}': Expected integer, got {type(value).__name__}. "
                                f"Current: {getattr(self, key)}, Default: {default_value}")

            if isinstance(value, (int, float, list, np.ndarray)):
                return np.array(value, dtype=float)  # Convert everything to np.array for floats

            raise TypeError(f"Invalid type for '{key}': Expected numeric, got {type(value).__name__}. "
                            f"Current: {getattr(self, key)}, Default: {default_value}")

        # Rule 8: Convert units if applicable
        if key in self._parametersWithUnits and isinstance(value, tuple):
            value, unit = value
            converted_value, _ = check_units((value, unit), ExpectedUnits=self._parametersWithUnits[key])
            return converted_value

        return value

    # Apply updates while tracking parameter occurrences
    for key, value in kwargs.items():
        resolved_key = resolve_key(key)

        if resolved_key not in self._defaults:
            raise KeyError(f"Invalid key '{key}'. Allowed keys: {list(self._defaults.keys())}.")

        try:
            validated_value = validate_value(resolved_key, value)
            setattr(self, resolved_key, validated_value)
        except (TypeError, ValueError) as e:
            raise ValueError(f"Error updating '{key}': {e}")

    # Ensure that no parameter was set multiple times due to synonyms
    duplicate_keys = [k for k, v in param_counts.items() if v > 1]
    if duplicate_keys:
        raise ValueError(f"Duplicate assignment detected for parameters: {duplicate_keys}. "
                         "Use only one synonym per parameter.")

    return self # to enable chaining

A sparse representation of properties (D, k, C0) used in layer instances.

This class allows storing and manipulating selected values of a property (<code>D</code>, <code>k</code>, or <code>C0</code>)
while keeping a sparse structure. It enables seamless interaction with <code><a title="layer.layer" href="#layer.layer">layer</a></code> objects
by overriding values dynamically and ensuring efficient memory usage.

The primary use case is to fit and control property values externally while keeping
the <code><a title="layer.layer" href="#layer.layer">layer</a></code> representation internally consistent.

Attributes
----------
property : str
    The name of the property linked (`"D"`, `"k"`, or `"C0"`).
indices : np.ndarray
    A NumPy array storing the indices of explicitly defined values.
values : np.ndarray
    A NumPy array storing the corresponding values at <code>indices</code>.
length : int
    The total length of the sparse vector, ensuring coverage of all indices.
replacement : str, optional
    Defines how missing values are handled:
    - `"repeat"`: Propagates the last known value beyond <code>length</code>.
    - `"periodic"`: Cycles through known values beyond <code>length</code>.
    - Default: No automatic replacement within <code>length</code>.

Methods
-------
set(index, value)
    Sets values at specific indices. If <code>None</code> or <code>np.nan</code> is provided, the index is removed.
get(index=None)
    Retrieves values at the given indices. Returns <code>NaN</code> for missing values.
getandreplace(indices, altvalues)
    Similar to <code>get()</code>, but replaces <code>NaN</code> values with corresponding values from <code>altvalues</code>.
getfull(altvalues)
    Returns the full vector using <code>getandreplace(None, altvalues)</code>.
lengthextension()
    Ensures <code>length</code> covers all stored indices (`max(indices) + 1`).
rename(new_property_name)
    Renames the <code>property</code> associated with this <code><a title="layer.layerLink" href="#layer.layerLink">layerLink</a></code>.
nzcount()
    Returns the number of explicitly stored (nonzero) elements.
__getitem__(index)
    Allows retrieval using <code>D\_link\[index]</code>, equivalent to <code>get(index)</code>.
__setitem__(index, value)
    Allows assignment using `D_link[index] = value`, equivalent to <code>set(index, value)</code>.
__add__(other)
    Concatenates two <code><a title="layer.layerLink" href="#layer.layerLink">layerLink</a></code> instances with the same property.
__mul__(n)
    Repeats the <code><a title="layer.layerLink" href="#layer.layerLink">layerLink</a></code> instance <code>n</code> times, shifting indices accordingly.

Examples
--------
Create a <code><a title="layer.layerLink" href="#layer.layerLink">layerLink</a></code> for <code>D</code> and manipulate its values:

```python
D_link = layerLink("D")
D_link.set([0, 2], [1e-14, 3e-14])
print(D_link.get())  # Expected: array([1e-14, nan, 3e-14])

D_link[1] = 2e-14
print(D_link.get())  # Expected: array([1e-14, 2e-14, 3e-14])
```

Concatenating two <code><a title="layer.layerLink" href="#layer.layerLink">layerLink</a></code> instances:

```python
A = layerLink("D")
A.set([0, 2], [1e-14, 3e-14])

B = layerLink("D")
B.set([1, 3], [2e-14, 4e-14])

C = A + B  # Concatenates while shifting indices
print(C.get())  # Expected: array([1e-14, 3e-14, nan, nan, 2e-14, 4e-14])
```

Handling missing values with <code>getandreplace()</code>:

```python
alt_values = np.array([5e-14, 6e-14, 7e-14, 8e-14])
print(D_link.getandreplace([0, 1, 2, 3], alt_values))
# Expected: array([1e-14, 2e-14, 3e-14, 8e-14])  # Fills NaNs from alt_values
```

Ensuring correct behavior for `*`:

```python
B = A * 3  # Repeats A three times
print(B.indices)  # Expected: [0, 2, 4, 6, 8, 10]
print(B.values)   # Expected: [1e-14, 3e-14, 1e-14, 3e-14, 1e-14, 3e-14]
print(B.length)   # Expected: 3 * A.length
```


Other Examples:
----------------

### **Creating a Link**
D_link = layerLink("D", indices=[1, 3], values=[5e-14, 7e-14], length=4)
print(D_link)  # <Link for D: 2 of 4 replacement values>

### **Retrieving Values**
print(D_link.get())       # Full vector with None in unspecified indices
print(D_link.get(1))      # Returns 5e-14
print(D_link.get([0,2]))  # Returns [None, None]

### **Setting Values**
D_link.set(2, 6e-14)
print(D_link.get())  # Now index 2 is replaced

### **Resetting with a Prototype**
prototype = [None, 5e-14, None, 7e-14, 8e-14]
D_link.reset(prototype)
print(D_link.get())  # Now follows the new structure

### **Getting and Setting Values with []**
D_link = layerLink("D", indices=[1, 3, 5], values=[5e-14, 7e-14, 6e-14], length=10)
print(D_link[3])      # ✅ Returns 7e-14
print(D_link[:5])     # ✅ Returns first 5 elements (with NaNs where undefined)
print(D_link[[1, 3]]) # ✅ Returns [5e-14, 7e-14]
D_link[2] = 9e-14     # ✅ Sets D[2] to 9e-14
D_link[0:4:2] = [1e-14, 2e-14]  # ✅ Sets D[0] = 1e-14, D[2] = 2e-14
print(len(D_link))    # ✅ Returns 10 (full vector length)

###**Practical Syntaxes**
D_link = layerLink("D")
D_link[2] = 3e-14  # ✅ single value
D_link[0] = 1e-14
print(D_link.get())
print(D_link[1])
print(repr(D_link))
D_link[:4] = 1e-16  # ✅ Fills indices 0,1,2,3 with 1e-16
print(D_link.get())  # ✅ Outputs: [1e-16, 1e-16, 1e-16, 1e-16, nan, 1e-14]
D_link[[1,2]] = None  # ✅ Fills indices 0,1,2,3 with 1e-16
print(D_link.get())  # ✅ Outputs: [1e-16, 1e-16, 1e-16, 1e-16, nan, 1e-14]
D_link[[0]] = 1e-10
print(D_link.get())

###**How it works inside layer: a short simulation**
# layerLink created by user
duser = layerLink()
duser.getfull([1e-15,2e-15,3e-15])
duser[0] = 1e-10
duser.getfull([1e-15,2e-15,3e-15])
duser[1]=1e-9
duser.getfull([1e-15,2e-15,3e-15])
# layerLink used internally
dalias=duser
dalias[1]=2e-11
duser.getfull([1e-15,2e-15,3e-15,4e-15])
dalias[1]=2.1e-11
duser.getfull([1e-15,2e-15,3e-15,4e-15])

###**Combining layerLinks instances**
A = layerLink("D")
A.set([0, 2], [1e-11, 3e-11])  # length=3
B = layerLink("D")
B.set([1, 3], [2e-14, 4e-12])  # length=4
C = A + B
print(C.indices)  # Expected: [0, 2, 4, 6]
print(C.values)   # Expected: [1.e-11 3.e-11 2.e-14 4.e-12]
print(C.length)   # Expected: 3 + 4 = 7


TEST CASES:
-----------

print("🔹 Test 1: Initialize empty layerLink")
D_link = layerLink("D")
print(D_link.get())  # Expected: array([]) or array([nan, nan, nan]) if length is pre-set
print(repr(D_link))  # Expected: No indices set

print("

🔹 Test 2: Assigning values at specific indices") D_link[2] = 3e-14 D_link[0] = 1e-14 print(D_link.get()) # Expected: array([1.e-14, nan, 3.e-14]) print(D_link[1]) # Expected: nan

print("

🔹 Test 3: Assign multiple values at once") D_link[[1, 4]] = [2e-14, 5e-14] print(D_link.get()) # Expected: array([1.e-14, 2.e-14, 3.e-14, nan, 5.e-14])

print("

🔹 Test 4: Remove a single index") D_link[1] = None print(D_link.get()) # Expected: array([1.e-14, nan, 3.e-14, nan, 5.e-14])

print("

🔹 Test 5: Remove multiple indices at once") D_link[[0, 2]] = None print(D_link.get()) # Expected: array([nan, nan, nan, nan, 5.e-14])

print("

🔹 Test 6: Removing indices using a slice") D_link[3:5] = None print(D_link.get()) # Expected: array([nan, nan, nan, nan, nan])

print("

🔹 Test 7: Assign new values after removals") D_link[1] = 7e-14 D_link[3] = 8e-14 print(D_link.get()) # Expected: array([nan, 7.e-14, nan, 8.e-14, nan])

print("

🔹 Test 8: Check periodic replacement") D_link = layerLink("D", replacement="periodic") D_link[2] = 3e-14 D_link[0] = 1e-14 print(D_link[5]) # Expected: 1e-14 (since 5 mod 2 = 0)

print("

🔹 Test 9: Check repeat replacement") D_link = layerLink("D", replacement="repeat") D_link[2] = 3e-14 D_link[0] = 1e-14 print(D_link.get()) # Expected: array([1.e-14, nan, 3.e-14]) print(D_link[3]) # Expected: 3e-14 (repeat last known value)

print("

🔹 Test 10: Resetting with a prototype") D_link.reset([None, 5e-14, None, 7e-14]) print(D_link.get()) # Expected: array([nan, 5.e-14, nan, 7.e-14])

print("

🔹 Test 11: Edge case - Assigning nan explicitly") D_link[1] = np.nan print(D_link.get()) # Expected: array([nan, nan, nan, 7.e-14])

print("

🔹 Test 12: Assigning a range with a scalar value (broadcasting)") D_link[0:3] = 9e-14 print(D_link.get()) # Expected: array([9.e-14, 9.e-14, 9.e-14, 7.e-14])

print("

🔹 Test 13: Assigning a slice with a list of values") D_link[1:4] = [6e-14, 5e-14, 4e-14] print(D_link.get()) # Expected: array([9.e-14, 6.e-14, 5.e-14, 4.e-14])

print("

🔹 Test 14: Length updates correctly after removals") D_link[[1, 2]] = None print(len(D_link)) # Expected: 4 (since max index is 3)

print("

🔹 Test 15: Setting index beyond length auto-extends") D_link[6] = 2e-14 print(len(D_link)) # Expected: 7 (since max index is 6) print(D_link.get()) # Expected: array([9.e-14, nan, nan, 4.e-14, nan, nan, 2.e-14])

constructs a link

Expand source code
class layerLink:
    """
    A sparse representation of properties (`D`, `k`, `C0`) used in `layer` instances.

    This class allows storing and manipulating selected values of a property (`D`, `k`, or `C0`)
    while keeping a sparse structure. It enables seamless interaction with `layer` objects
    by overriding values dynamically and ensuring efficient memory usage.

    The primary use case is to fit and control property values externally while keeping
    the `layer` representation internally consistent.

    Attributes
    ----------
    property : str
        The name of the property linked (`"D"`, `"k"`, or `"C0"`).
    indices : np.ndarray
        A NumPy array storing the indices of explicitly defined values.
    values : np.ndarray
        A NumPy array storing the corresponding values at `indices`.
    length : int
        The total length of the sparse vector, ensuring coverage of all indices.
    replacement : str, optional
        Defines how missing values are handled:
        - `"repeat"`: Propagates the last known value beyond `length`.
        - `"periodic"`: Cycles through known values beyond `length`.
        - Default: No automatic replacement within `length`.

    Methods
    -------
    set(index, value)
        Sets values at specific indices. If `None` or `np.nan` is provided, the index is removed.
    get(index=None)
        Retrieves values at the given indices. Returns `NaN` for missing values.
    getandreplace(indices, altvalues)
        Similar to `get()`, but replaces `NaN` values with corresponding values from `altvalues`.
    getfull(altvalues)
        Returns the full vector using `getandreplace(None, altvalues)`.
    lengthextension()
        Ensures `length` covers all stored indices (`max(indices) + 1`).
    rename(new_property_name)
        Renames the `property` associated with this `layerLink`.
    nzcount()
        Returns the number of explicitly stored (nonzero) elements.
    __getitem__(index)
        Allows retrieval using `D_link[index]`, equivalent to `get(index)`.
    __setitem__(index, value)
        Allows assignment using `D_link[index] = value`, equivalent to `set(index, value)`.
    __add__(other)
        Concatenates two `layerLink` instances with the same property.
    __mul__(n)
        Repeats the `layerLink` instance `n` times, shifting indices accordingly.

    Examples
    --------
    Create a `layerLink` for `D` and manipulate its values:

    ```python
    D_link = layerLink("D")
    D_link.set([0, 2], [1e-14, 3e-14])
    print(D_link.get())  # Expected: array([1e-14, nan, 3e-14])

    D_link[1] = 2e-14
    print(D_link.get())  # Expected: array([1e-14, 2e-14, 3e-14])
    ```

    Concatenating two `layerLink` instances:

    ```python
    A = layerLink("D")
    A.set([0, 2], [1e-14, 3e-14])

    B = layerLink("D")
    B.set([1, 3], [2e-14, 4e-14])

    C = A + B  # Concatenates while shifting indices
    print(C.get())  # Expected: array([1e-14, 3e-14, nan, nan, 2e-14, 4e-14])
    ```

    Handling missing values with `getandreplace()`:

    ```python
    alt_values = np.array([5e-14, 6e-14, 7e-14, 8e-14])
    print(D_link.getandreplace([0, 1, 2, 3], alt_values))
    # Expected: array([1e-14, 2e-14, 3e-14, 8e-14])  # Fills NaNs from alt_values
    ```

    Ensuring correct behavior for `*`:

    ```python
    B = A * 3  # Repeats A three times
    print(B.indices)  # Expected: [0, 2, 4, 6, 8, 10]
    print(B.values)   # Expected: [1e-14, 3e-14, 1e-14, 3e-14, 1e-14, 3e-14]
    print(B.length)   # Expected: 3 * A.length
    ```


    Other Examples:
    ----------------

    ### **Creating a Link**
    D_link = layerLink("D", indices=[1, 3], values=[5e-14, 7e-14], length=4)
    print(D_link)  # <Link for D: 2 of 4 replacement values>

    ### **Retrieving Values**
    print(D_link.get())       # Full vector with None in unspecified indices
    print(D_link.get(1))      # Returns 5e-14
    print(D_link.get([0,2]))  # Returns [None, None]

    ### **Setting Values**
    D_link.set(2, 6e-14)
    print(D_link.get())  # Now index 2 is replaced

    ### **Resetting with a Prototype**
    prototype = [None, 5e-14, None, 7e-14, 8e-14]
    D_link.reset(prototype)
    print(D_link.get())  # Now follows the new structure

    ### **Getting and Setting Values with []**
    D_link = layerLink("D", indices=[1, 3, 5], values=[5e-14, 7e-14, 6e-14], length=10)
    print(D_link[3])      # ✅ Returns 7e-14
    print(D_link[:5])     # ✅ Returns first 5 elements (with NaNs where undefined)
    print(D_link[[1, 3]]) # ✅ Returns [5e-14, 7e-14]
    D_link[2] = 9e-14     # ✅ Sets D[2] to 9e-14
    D_link[0:4:2] = [1e-14, 2e-14]  # ✅ Sets D[0] = 1e-14, D[2] = 2e-14
    print(len(D_link))    # ✅ Returns 10 (full vector length)

    ###**Practical Syntaxes**
    D_link = layerLink("D")
    D_link[2] = 3e-14  # ✅ single value
    D_link[0] = 1e-14
    print(D_link.get())
    print(D_link[1])
    print(repr(D_link))
    D_link[:4] = 1e-16  # ✅ Fills indices 0,1,2,3 with 1e-16
    print(D_link.get())  # ✅ Outputs: [1e-16, 1e-16, 1e-16, 1e-16, nan, 1e-14]
    D_link[[1,2]] = None  # ✅ Fills indices 0,1,2,3 with 1e-16
    print(D_link.get())  # ✅ Outputs: [1e-16, 1e-16, 1e-16, 1e-16, nan, 1e-14]
    D_link[[0]] = 1e-10
    print(D_link.get())

    ###**How it works inside layer: a short simulation**
    # layerLink created by user
    duser = layerLink()
    duser.getfull([1e-15,2e-15,3e-15])
    duser[0] = 1e-10
    duser.getfull([1e-15,2e-15,3e-15])
    duser[1]=1e-9
    duser.getfull([1e-15,2e-15,3e-15])
    # layerLink used internally
    dalias=duser
    dalias[1]=2e-11
    duser.getfull([1e-15,2e-15,3e-15,4e-15])
    dalias[1]=2.1e-11
    duser.getfull([1e-15,2e-15,3e-15,4e-15])

    ###**Combining layerLinks instances**
    A = layerLink("D")
    A.set([0, 2], [1e-11, 3e-11])  # length=3
    B = layerLink("D")
    B.set([1, 3], [2e-14, 4e-12])  # length=4
    C = A + B
    print(C.indices)  # Expected: [0, 2, 4, 6]
    print(C.values)   # Expected: [1.e-11 3.e-11 2.e-14 4.e-12]
    print(C.length)   # Expected: 3 + 4 = 7


    TEST CASES:
    -----------

    print("🔹 Test 1: Initialize empty layerLink")
    D_link = layerLink("D")
    print(D_link.get())  # Expected: array([]) or array([nan, nan, nan]) if length is pre-set
    print(repr(D_link))  # Expected: No indices set

    print("\n🔹 Test 2: Assigning values at specific indices")
    D_link[2] = 3e-14
    D_link[0] = 1e-14
    print(D_link.get())  # Expected: array([1.e-14, nan, 3.e-14])
    print(D_link[1])     # Expected: nan

    print("\n🔹 Test 3: Assign multiple values at once")
    D_link[[1, 4]] = [2e-14, 5e-14]
    print(D_link.get())  # Expected: array([1.e-14, 2.e-14, 3.e-14, nan, 5.e-14])

    print("\n🔹 Test 4: Remove a single index")
    D_link[1] = None
    print(D_link.get())  # Expected: array([1.e-14, nan, 3.e-14, nan, 5.e-14])

    print("\n🔹 Test 5: Remove multiple indices at once")
    D_link[[0, 2]] = None
    print(D_link.get())  # Expected: array([nan, nan, nan, nan, 5.e-14])

    print("\n🔹 Test 6: Removing indices using a slice")
    D_link[3:5] = None
    print(D_link.get())  # Expected: array([nan, nan, nan, nan, nan])

    print("\n🔹 Test 7: Assign new values after removals")
    D_link[1] = 7e-14
    D_link[3] = 8e-14
    print(D_link.get())  # Expected: array([nan, 7.e-14, nan, 8.e-14, nan])

    print("\n🔹 Test 8: Check periodic replacement")
    D_link = layerLink("D", replacement="periodic")
    D_link[2] = 3e-14
    D_link[0] = 1e-14
    print(D_link[5])  # Expected: 1e-14 (since 5 mod 2 = 0)

    print("\n🔹 Test 9: Check repeat replacement")
    D_link = layerLink("D", replacement="repeat")
    D_link[2] = 3e-14
    D_link[0] = 1e-14
    print(D_link.get())  # Expected: array([1.e-14, nan, 3.e-14])
    print(D_link[3])     # Expected: 3e-14 (repeat last known value)

    print("\n🔹 Test 10: Resetting with a prototype")
    D_link.reset([None, 5e-14, None, 7e-14])
    print(D_link.get())  # Expected: array([nan, 5.e-14, nan, 7.e-14])

    print("\n🔹 Test 11: Edge case - Assigning nan explicitly")
    D_link[1] = np.nan
    print(D_link.get())  # Expected: array([nan, nan, nan, 7.e-14])

    print("\n🔹 Test 12: Assigning a range with a scalar value (broadcasting)")
    D_link[0:3] = 9e-14
    print(D_link.get())  # Expected: array([9.e-14, 9.e-14, 9.e-14, 7.e-14])

    print("\n🔹 Test 13: Assigning a slice with a list of values")
    D_link[1:4] = [6e-14, 5e-14, 4e-14]
    print(D_link.get())  # Expected: array([9.e-14, 6.e-14, 5.e-14, 4.e-14])

    print("\n🔹 Test 14: Length updates correctly after removals")
    D_link[[1, 2]] = None
    print(len(D_link))   # Expected: 4 (since max index is 3)

    print("\n🔹 Test 15: Setting index beyond length auto-extends")
    D_link[6] = 2e-14
    print(len(D_link))   # Expected: 7 (since max index is 6)
    print(D_link.get())  # Expected: array([9.e-14, nan, nan, 4.e-14, nan, nan, 2.e-14])

    """

    def __init__(self, property="D", indices=None, values=None, length=None,
                 replacement="repeat", dtype=np.float64, maxlength=None):
        """constructs a link"""
        self.property = property  # "D", "k", or "C0"
        self.replacement = replacement
        self.dtype = dtype
        self._maxlength = maxlength
        if isinstance(indices,(int,float)): indices = [indices]
        if isinstance(values,(int,float)): values = [values]

        if indices is None or values is None:
            self.indices = np.array([], dtype=int)
            self.values = np.array([], dtype=dtype)
        else:
            self.indices = np.array(indices, dtype=int)
            self.values = np.array(values, dtype=dtype)

        self.length = length if length is not None else (self.indices.max() + 1 if self.indices.size > 0 else 0)
        self._validate()

    def _validate(self):
        """Ensures consistency between indices and values."""
        if len(self.indices) != len(self.values):
            raise ValueError("indices and values must have the same length.")
        if self.indices.size > 0 and self.length < self.indices.max() + 1:
            raise ValueError("length must be at least max(indices) + 1.")

    def reset(self, prototypevalues):
        """
        Resets the link instance based on the prototype values.

        - Stores only non-None values.
        - Updates `indices`, `values`, and `length` accordingly.
        """
        self.indices = np.array([i for i, v in enumerate(prototypevalues) if v is not None], dtype=int)
        self.values = np.array([v for v in prototypevalues if v is not None], dtype=self.dtype)
        self.length = len(prototypevalues)  # Update the total length

    def get(self, index=None):
        """
        Retrieves values based on index or returns the full vector.

        Rules:
        - If `index=None`, returns the full vector with overridden values (no replacement applied).
        - If `index` is a scalar, returns the corresponding value, applying replacement rules if needed.
        - If `index` is an array, returns an array of the requested indices, applying replacement rules.

        Returns:
        - NumPy array with requested values.
        """
        if index is None:
            # Return the full vector WITHOUT applying any replacement
            full_vector = np.full(self.length, np.nan, dtype=self.dtype)
            full_vector[self.indices] = self.values  # Set known values
            return full_vector

        if np.isscalar(index):
            return self._get_single(index)

        # Ensure index is an array
        index = np.array(index, dtype=int)
        return np.array([self._get_single(i) for i in index], dtype=self.dtype)

    def _get_single(self, i):
        """Retrieves the value for a single index, applying rules if necessary."""
        if i in self.indices:
            return self.values[np.where(self.indices == i)[0][0]]

        if i >= self.length:  # Apply replacement *only* for indices beyond length
            if self.replacement == "periodic":
                return self.values[i % len(self.values)]
            elif self.replacement == "repeat":
                return self._get_single(self.length - 1)  # Repeat last known value

        return np.nan  # Default case for undefined in-bounds indices


    def set(self, index, value):
        """
        Sets values at specific indices.

        - If `index=None`, resets the link with `value`.
        - If `index` is a scalar, updates or inserts the value.
        - If `index` is an array, updates corresponding values.
        - If `value` is `None` or `np.nan`, removes the corresponding index.
        """
        if index is None:
            self.reset(value)
            return

        index = np.array(index, dtype=int)
        value = np.array(value, dtype=self.dtype)

        # check against _maxlength if defined
        if self._maxlength is not None:
            if np.any(index>=self._maxlength):
                raise IndexError(f"index cannot exceeds the number of layers-1 {self._maxlength-1}")

        # Handle scalars properly
        if np.isscalar(index):
            index = np.array([index])
            value = np.array([value])

        # Detect None or NaN values and remove those indices
        mask = np.isnan(value) if value.dtype.kind == 'f' else np.array([v is None for v in value])
        if np.any(mask):
            self._remove_indices(index[mask])  # Remove these indices
            index, value = index[~mask], value[~mask]  # Keep only valid values

        if index.size > 0:  # If there are remaining valid values, store them
            for i, v in zip(index, value):
                if i in self.indices:
                    self.values[np.where(self.indices == i)[0][0]] = v
                else:
                    self.indices = np.append(self.indices, i)
                    self.values = np.append(self.values, v)

        # Update length to ensure it remains valid
        if self.indices.size > 0:
            self.length = max(self.indices) + 1  # Adjust length based on max index
        else:
            self.length = 0  # Reset to 0 if empty

        self._validate()

    def _remove_indices(self, indices):
        """
        Removes indices from `self.indices` and `self.values` and updates length.
        """
        mask = np.isin(self.indices, indices, invert=True)
        self.indices = self.indices[mask]
        self.values = self.values[mask]

        # Update length after removal
        if self.indices.size > 0:
            self.length = max(self.indices) + 1  # Adjust length based on remaining max index
        else:
            self.length = 0  # Reset to 0 if no indices remain

    def reshape(self, new_length):
        """
        Reshapes the link instance to a new length.

        - If indices exceed new_length-1, they are removed with a warning.
        - If replacement operates beyond new_length-1, a warning is issued.
        """
        if new_length < self.length:
            invalid_indices = self.indices[self.indices >= new_length]
            if invalid_indices.size > 0:
                print(f"⚠️ Warning: Indices {invalid_indices.tolist()} are outside new length {new_length}. They will be removed.")
                mask = self.indices < new_length
                self.indices = self.indices[mask]
                self.values = self.values[mask]

        # Check if replacement would be applied beyond the new length
        if self.replacement == "repeat" and self.indices.size > 0 and self.length > new_length:
            print(f"⚠️ Warning: Repeat rule was defined for indices beyond {new_length-1}, but will not be used.")

        if self.replacement == "periodic" and self.indices.size > 0 and self.length > new_length:
            print(f"⚠️ Warning: Periodic rule was defined for indices beyond {new_length-1}, but will not be used.")

        self.length = new_length

    def __repr__(self):
        """Returns a detailed string representation."""
        txt = (f"Link(property='{self.property}', indices={self.indices.tolist()}, "
                f"values={self.values.tolist()}, length={self.length}, replacement='{self.replacement}')")
        print(txt)
        return(str(self))

    def __str__(self):
        """Returns a compact summary string."""
        return f"<{self.property}:{self.__class__.__name__}: {len(self.indices)}/{self.length}  values>"

    # Override `len()`
    def __len__(self):
        """Returns the length of the vector managed by the link object."""
        return self.length

    # Override `getitem` (support for indexing and slicing)
    def __getitem__(self, index):
        """
        Allows `D_link[index]` or `D_link[slice]` to retrieve values.

        - If `index` is an integer, returns a single value.
        - If `index` is a slice or list/array, returns a NumPy array of values.
        """
        if isinstance(index, slice):
            return self.get(np.arange(index.start or 0, index.stop or self.length, index.step or 1))
        return self.get(index)

    # Override `setitem` (support for indexing and slicing)
    def __setitem__(self, index, value):
        """
        Allows `D_link[index] = value` or `D_link[slice] = list/scalar`.

        - If `index` is an integer, updates or inserts a single value.
        - If `index` is a slice or list/array, updates multiple values.
        - If `value` is `None` or `np.nan`, removes the corresponding index.
        """
        if isinstance(index, slice):
            indices = np.arange(index.start or 0, index.stop or self.length, index.step or 1)

        elif isinstance(index, (list, np.ndarray)):  # Handle non-contiguous indices
            indices = np.array(index, dtype=int)

        elif np.isscalar(index):  # Single index assignment
            indices = np.array([index], dtype=int)

        else:
            raise TypeError(f"Unsupported index type: {type(index)}")

        if value is None or (isinstance(value, float) and np.isnan(value)):  # Remove these indices
            self._remove_indices(indices)
        else:
            values = np.full_like(indices, value, dtype=self.dtype) if np.isscalar(value) else np.array(value, dtype=self.dtype)
            if len(indices) != len(values):
                raise ValueError(f"Cannot assign {len(values)} values to {len(indices)} indices.")
            self.set(indices, values)

    def getandreplace(self, indices=None, altvalues=None):
        """
        Retrieves values for the given indices, replacing NaN values with corresponding values from altvalues.

        - If `indices` is None or empty, it defaults to `[0, 1, ..., self.length - 1]`
        - altvalues should be a NumPy array with the same dtype as self.values.
        - altvalues **can be longer than** self.length, but **cannot be shorter than the highest requested index**.
        - If an index is undefined (`NaN` in get()), it is replaced with altvalues[index].

        Parameters:
        ----------
        indices : list or np.ndarray (default: None)
            The indices to retrieve values for. If None, defaults to full range `[0, ..., self.length - 1]`.
        altvalues : list or np.ndarray
            Alternative values to use where `get()` returns `NaN`.

        Returns:
        -------
        np.ndarray
            A NumPy array of values, with NaNs replaced by altvalues.
        """
        if indices is None or len(indices) == 0:
            indices = np.arange(self.length)  # Default to full range

        indices = np.array(indices, dtype=int)
        altvalues = np.array(altvalues, dtype=self.dtype)

        max_requested_index = indices.max() if indices.size > 0 else 0
        if max_requested_index >= altvalues.shape[0]:  # Ensure altvalues covers all requested indices
            raise ValueError(
                f"altvalues is too short! It has length {altvalues.shape[0]}, but requested index {max_requested_index}."
            )
        # Get original values
        original_values = self.get(indices)
        # Replace NaN values with corresponding values from altvalues
        mask_nan = np.isnan(original_values)
        original_values[mask_nan] = altvalues[indices[mask_nan]]
        return original_values


    def getfull(self, altvalues):
        """
        Retrieves the full vector using `getandreplace(None, altvalues)`.

        - If `length == 0`, returns `altvalues` as a NumPy array of the correct dtype.
        - Extends `self.length` to match `altvalues` if it's shorter.
        - Supports multidimensional `altvalues` by flattening it.

        Parameters:
        ----------
        altvalues : list or np.ndarray
            Alternative values to use where `get()` returns `NaN`.

        Returns:
        -------
        np.ndarray
            Full vector with NaNs replaced by altvalues.
        """
        # Convert altvalues to a NumPy array and flatten if needed
        altvalues = np.array(altvalues, dtype=self.dtype).flatten()

        # If self has no length, return altvalues directly
        if self.length == 0:
            return altvalues

        # Extend self.length to match altvalues if needed
        if self.length < altvalues.shape[0]:
            self.length = altvalues.shape[0]

        return self.getandreplace(None, altvalues)

    @property
    def nzlength(self):
        """
        Returns the number of stored nonzero elements (i.e., indices with values).
        """
        return len(self.indices)

    def lengthextension(self):
        """
        Ensures that the length of the layerLink instance is at least `max(indices) + 1`.

        - If there are no indices, the length remains unchanged.
        - If `length` is already sufficient, nothing happens.
        - Otherwise, it extends `length` to `max(indices) + 1`.
        """
        if self.indices.size > 0:  # Only extend if there are indices
            self.length = max(self.length, max(self.indices) + 1)

    def rename(self, new_property_name):
        """
        Renames the property associated with this link.

        Parameters:
        ----------
        new_property_name : str
            The new property name.

        Raises:
        -------
        TypeError:
            If `new_property_name` is not a string.
        """
        if not isinstance(new_property_name, str):
            raise TypeError(f"Property name must be a string, got {type(new_property_name).__name__}.")
        self.property = new_property_name


    def __add__(self, other):
        """
        Concatenates two layerLink instances.

        - Only allowed if both instances have the same property.
        - Calls `lengthextension()` on both instances before summing lengths.
        - Shifts `other`'s indices by `self.length` to maintain sparsity.
        - Concatenates values and indices.

        Returns:
        -------
        layerLink
            A new concatenated layerLink instance.
        """
        if not isinstance(other, layerLink):
            raise TypeError(f"Cannot concatenate {type(self).__name__} with {type(other).__name__}")

        if self.property != other.property:
            raise ValueError(f"Cannot concatenate: properties do not match ('{self.property}' vs. '{other.property}')")

        # Ensure lengths are properly extended before computing new length
        self.lengthextension()
        other.lengthextension()

        # Create a new instance for the result
        result = layerLink(self.property)

        # Copy self's values
        result.indices = np.array(self.indices, dtype=int)
        result.values = np.array(self.values, dtype=self.dtype)

        # Adjust other’s indices and add them
        shifted_other_indices = np.array(other.indices) + self.length
        result.indices = np.concatenate([result.indices, shifted_other_indices])
        result.values = np.concatenate([result.values, np.array(other.values, dtype=self.dtype)])

        # ✅ Correct length calculation: Sum of the two lengths (assuming lengths are extended)
        result.length = self.length + other.length

        return result


    def __mul__(self, n):
        """
        Repeats the layerLink instance `n` times.

        - Uses `+` to concatenate multiple copies with shifted indices.
        - Each repetition gets indices shifted by `self.length * i`.

        Returns:
        -------
        layerLink
            A new layerLink instance with repeated data.
        """
        if not isinstance(n, int) or n <= 0:
            raise ValueError("Multiplication factor must be a positive integer")

        result = layerLink(self.property)
        for i in range(n):
            shifted_instance = layerLink(self.property)
            shifted_instance.indices = np.array(self.indices) + i * self.length
            shifted_instance.values = np.array(self.values, dtype=self.dtype)
            shifted_instance.length = self.length
            result += shifted_instance  # Use `+` to merge each repetition

        return result

Instance variables

var nzlength

Returns the number of stored nonzero elements (i.e., indices with values).

Expand source code
@property
def nzlength(self):
    """
    Returns the number of stored nonzero elements (i.e., indices with values).
    """
    return len(self.indices)

Methods

def get(self, index=None)

Retrieves values based on index or returns the full vector.

Rules: - If index=None, returns the full vector with overridden values (no replacement applied). - If index is a scalar, returns the corresponding value, applying replacement rules if needed. - If index is an array, returns an array of the requested indices, applying replacement rules.

Returns: - NumPy array with requested values.

Expand source code
def get(self, index=None):
    """
    Retrieves values based on index or returns the full vector.

    Rules:
    - If `index=None`, returns the full vector with overridden values (no replacement applied).
    - If `index` is a scalar, returns the corresponding value, applying replacement rules if needed.
    - If `index` is an array, returns an array of the requested indices, applying replacement rules.

    Returns:
    - NumPy array with requested values.
    """
    if index is None:
        # Return the full vector WITHOUT applying any replacement
        full_vector = np.full(self.length, np.nan, dtype=self.dtype)
        full_vector[self.indices] = self.values  # Set known values
        return full_vector

    if np.isscalar(index):
        return self._get_single(index)

    # Ensure index is an array
    index = np.array(index, dtype=int)
    return np.array([self._get_single(i) for i in index], dtype=self.dtype)
def getandreplace(self, indices=None, altvalues=None)

Retrieves values for the given indices, replacing NaN values with corresponding values from altvalues.

  • If indices is None or empty, it defaults to [0, 1, ..., self.length - 1]
  • altvalues should be a NumPy array with the same dtype as self.values.
  • altvalues can be longer than self.length, but cannot be shorter than the highest requested index.
  • If an index is undefined (NaN in get()), it is replaced with altvalues[index].

Parameters:

indices : list or np.ndarray (default: None) The indices to retrieve values for. If None, defaults to full range [0, ..., self.length - 1]. altvalues : list or np.ndarray Alternative values to use where get() returns NaN.

Returns:

np.ndarray A NumPy array of values, with NaNs replaced by altvalues.

Expand source code
def getandreplace(self, indices=None, altvalues=None):
    """
    Retrieves values for the given indices, replacing NaN values with corresponding values from altvalues.

    - If `indices` is None or empty, it defaults to `[0, 1, ..., self.length - 1]`
    - altvalues should be a NumPy array with the same dtype as self.values.
    - altvalues **can be longer than** self.length, but **cannot be shorter than the highest requested index**.
    - If an index is undefined (`NaN` in get()), it is replaced with altvalues[index].

    Parameters:
    ----------
    indices : list or np.ndarray (default: None)
        The indices to retrieve values for. If None, defaults to full range `[0, ..., self.length - 1]`.
    altvalues : list or np.ndarray
        Alternative values to use where `get()` returns `NaN`.

    Returns:
    -------
    np.ndarray
        A NumPy array of values, with NaNs replaced by altvalues.
    """
    if indices is None or len(indices) == 0:
        indices = np.arange(self.length)  # Default to full range

    indices = np.array(indices, dtype=int)
    altvalues = np.array(altvalues, dtype=self.dtype)

    max_requested_index = indices.max() if indices.size > 0 else 0
    if max_requested_index >= altvalues.shape[0]:  # Ensure altvalues covers all requested indices
        raise ValueError(
            f"altvalues is too short! It has length {altvalues.shape[0]}, but requested index {max_requested_index}."
        )
    # Get original values
    original_values = self.get(indices)
    # Replace NaN values with corresponding values from altvalues
    mask_nan = np.isnan(original_values)
    original_values[mask_nan] = altvalues[indices[mask_nan]]
    return original_values
def getfull(self, altvalues)

Retrieves the full vector using getandreplace(None, altvalues).

  • If length == 0, returns altvalues as a NumPy array of the correct dtype.
  • Extends self.length to match altvalues if it's shorter.
  • Supports multidimensional altvalues by flattening it.

Parameters:

altvalues : list or np.ndarray Alternative values to use where get() returns NaN.

Returns:

np.ndarray Full vector with NaNs replaced by altvalues.

Expand source code
def getfull(self, altvalues):
    """
    Retrieves the full vector using `getandreplace(None, altvalues)`.

    - If `length == 0`, returns `altvalues` as a NumPy array of the correct dtype.
    - Extends `self.length` to match `altvalues` if it's shorter.
    - Supports multidimensional `altvalues` by flattening it.

    Parameters:
    ----------
    altvalues : list or np.ndarray
        Alternative values to use where `get()` returns `NaN`.

    Returns:
    -------
    np.ndarray
        Full vector with NaNs replaced by altvalues.
    """
    # Convert altvalues to a NumPy array and flatten if needed
    altvalues = np.array(altvalues, dtype=self.dtype).flatten()

    # If self has no length, return altvalues directly
    if self.length == 0:
        return altvalues

    # Extend self.length to match altvalues if needed
    if self.length < altvalues.shape[0]:
        self.length = altvalues.shape[0]

    return self.getandreplace(None, altvalues)
def lengthextension(self)

Ensures that the length of the layerLink instance is at least max(indices) + 1.

  • If there are no indices, the length remains unchanged.
  • If length is already sufficient, nothing happens.
  • Otherwise, it extends length to max(indices) + 1.
Expand source code
def lengthextension(self):
    """
    Ensures that the length of the layerLink instance is at least `max(indices) + 1`.

    - If there are no indices, the length remains unchanged.
    - If `length` is already sufficient, nothing happens.
    - Otherwise, it extends `length` to `max(indices) + 1`.
    """
    if self.indices.size > 0:  # Only extend if there are indices
        self.length = max(self.length, max(self.indices) + 1)
def rename(self, new_property_name)

Renames the property associated with this link.

Parameters:

new_property_name : str The new property name.

Raises:

Typeerror

If new_property_name is not a string.

Expand source code
def rename(self, new_property_name):
    """
    Renames the property associated with this link.

    Parameters:
    ----------
    new_property_name : str
        The new property name.

    Raises:
    -------
    TypeError:
        If `new_property_name` is not a string.
    """
    if not isinstance(new_property_name, str):
        raise TypeError(f"Property name must be a string, got {type(new_property_name).__name__}.")
    self.property = new_property_name
def reset(self, prototypevalues)

Resets the link instance based on the prototype values.

  • Stores only non-None values.
  • Updates indices, values, and length accordingly.
Expand source code
def reset(self, prototypevalues):
    """
    Resets the link instance based on the prototype values.

    - Stores only non-None values.
    - Updates `indices`, `values`, and `length` accordingly.
    """
    self.indices = np.array([i for i, v in enumerate(prototypevalues) if v is not None], dtype=int)
    self.values = np.array([v for v in prototypevalues if v is not None], dtype=self.dtype)
    self.length = len(prototypevalues)  # Update the total length
def reshape(self, new_length)

Reshapes the link instance to a new length.

  • If indices exceed new_length-1, they are removed with a warning.
  • If replacement operates beyond new_length-1, a warning is issued.
Expand source code
def reshape(self, new_length):
    """
    Reshapes the link instance to a new length.

    - If indices exceed new_length-1, they are removed with a warning.
    - If replacement operates beyond new_length-1, a warning is issued.
    """
    if new_length < self.length:
        invalid_indices = self.indices[self.indices >= new_length]
        if invalid_indices.size > 0:
            print(f"⚠️ Warning: Indices {invalid_indices.tolist()} are outside new length {new_length}. They will be removed.")
            mask = self.indices < new_length
            self.indices = self.indices[mask]
            self.values = self.values[mask]

    # Check if replacement would be applied beyond the new length
    if self.replacement == "repeat" and self.indices.size > 0 and self.length > new_length:
        print(f"⚠️ Warning: Repeat rule was defined for indices beyond {new_length-1}, but will not be used.")

    if self.replacement == "periodic" and self.indices.size > 0 and self.length > new_length:
        print(f"⚠️ Warning: Periodic rule was defined for indices beyond {new_length-1}, but will not be used.")

    self.length = new_length
def set(self, index, value)

Sets values at specific indices.

  • If index=None, resets the link with value.
  • If index is a scalar, updates or inserts the value.
  • If index is an array, updates corresponding values.
  • If value is None or np.nan, removes the corresponding index.
Expand source code
def set(self, index, value):
    """
    Sets values at specific indices.

    - If `index=None`, resets the link with `value`.
    - If `index` is a scalar, updates or inserts the value.
    - If `index` is an array, updates corresponding values.
    - If `value` is `None` or `np.nan`, removes the corresponding index.
    """
    if index is None:
        self.reset(value)
        return

    index = np.array(index, dtype=int)
    value = np.array(value, dtype=self.dtype)

    # check against _maxlength if defined
    if self._maxlength is not None:
        if np.any(index>=self._maxlength):
            raise IndexError(f"index cannot exceeds the number of layers-1 {self._maxlength-1}")

    # Handle scalars properly
    if np.isscalar(index):
        index = np.array([index])
        value = np.array([value])

    # Detect None or NaN values and remove those indices
    mask = np.isnan(value) if value.dtype.kind == 'f' else np.array([v is None for v in value])
    if np.any(mask):
        self._remove_indices(index[mask])  # Remove these indices
        index, value = index[~mask], value[~mask]  # Keep only valid values

    if index.size > 0:  # If there are remaining valid values, store them
        for i, v in zip(index, value):
            if i in self.indices:
                self.values[np.where(self.indices == i)[0][0]] = v
            else:
                self.indices = np.append(self.indices, i)
                self.values = np.append(self.values, v)

    # Update length to ensure it remains valid
    if self.indices.size > 0:
        self.length = max(self.indices) + 1  # Adjust length based on max index
    else:
        self.length = 0  # Reset to 0 if empty

    self._validate()
class mesh (l, n, x0=0, index=None)

simple nodes class for finite-volume methods

Expand source code
class mesh():
    """ simple nodes class for finite-volume methods """
    def __init__(self,l,n,x0=0,index=None):
       self.x0 = x0
       self.l = l
       self.n = n
       de = dw = l/(2*n)
       self.de = np.ones(n)*de
       self.dw = np.ones(n)*dw
       self.xmesh = np.linspace(0+dw,l-de,n) # nodes positions
       self.w = self.xmesh - dw
       self.e = self.xmesh + de
       self.index = np.full(n, int(index), dtype=np.int32)

    def __repr__(self):
        print(f"-- mesh object (layer index={self.index[0]}) --")
        print("%25s = %0.4g" % ("start at x0", self.x0))
        print("%25s = %0.4g" % ("domain length l", self.l))
        print("%25s = %0.4g" % ("number of nodes n", self.n))
        print("%25s = %0.4g" % ("dw", self.dw[0]))
        print("%25s = %0.4g" % ("de", self.de[0]))
        return "mesh%d=[%0.4g %0.4g]" % \
            (self.n,self.x0+self.xmesh[0],self.x0+self.xmesh[-1])
class migrant (name=None, M=None, logP=None, Dmodel='Piringer', Dtemplate={'polymer': 'LLDPE', 'M': 50, 'T': 40}, kmodel='kFHP', ktemplate={'Pi': 1.41, 'Pk': 3.97, 'Vi': 124.1, 'Vk': 30.9, 'ispolymer': True, 'alpha': 0.14, 'lngmin': 0.0, 'Psat': 1.0}, db=<patankar.loadpubchem.CompoundIndex object>, raiseerror=True)

A class representing a migrating chemical substance.

It can be initialized in three main ways:

1) Case (a) - By a textual name/CAS only (for a real compound search):


Example: m = migrant(name="anisole", db=my_compound_index) # or m = migrant(name="anisole", db=my_compound_index, M=None) In this mode: • A lookup is performed using db.find(name), which may return one or more records. • If multiple records match, data from each record is merged: - compound = The text used in the query (e.g. "anisole") - name = Concatenation of all distinct names from the search results - CAS = Concatenation of all CAS numbers from the search results - M = The minimum of all found molecular weights, stored in self.M (a numpy array also keeps the full set) - formula = The first formula - logP = All logP values concatenated into a numpy array (self.logP_array). The main attribute self.logP will be the same array or you may pick a single representative.

2) Case (b) - By numeric molecular weight(s) alone (generic substance):


Example: m = migrant(M=200) m = migrant(M=[100, 500]) # Possibly a range In this mode: • No search is performed. • name = "generic" (unless you override it). • compound = "single molecular weight" if 1 entry in M, or "list of molecular weights ranging from X to Y" if multiple. • CAS = None • M = the minimum of all provided M values (also stored in a numpy array) • logP = None by default, or can be supplied explicitly as an array

3) Case (c) - Name + numeric M/logP => Surrogate / hypothetical:


Example: m = migrant(name="mySurrogate", M=[200, 250], logP=[2.5, 3.0]) or m = migrant(name="surrogate", M=200) In this mode: • No lookup is performed. This is a “fake” compound not found in PubChem. • compound = "single molecular weight" or "list of molecular weights ranging from X to Y" if multiple. • name = whatever user provides • CAS = None • M = min of the provided M array, stored in a numpy array • logP = user-provided array or single float, stored in a numpy array

Attributes

compound : str
For case (a) => the search text; For case (b,c) => textual description of the numeric M array.
name : str or list
For case (a) => aggregated list of all found names (string-joined); For case (b) => "generic" or user-supplied name; For case (c) => user-supplied name.
CAS : list or None
For case (a) => aggregated CAS from search results; For case (b,c) => None.
M : float
The minimum M from either the search results or the user-supplied array.
M_array : numpy.ndarray
The full array of all M values found or provided.
logP : float or numpy.ndarray or None
For case (a) => an array of all logP from the search results (or None if not found); For case (b) => None or user-supplied value/array; For case (c) => user-supplied value/array.

Create a new migrant instance.

Parameters

name : str or None
  • A textual name for the substance to be looked up in PubChem (case a), or a custom name for a surrogate (case c).
  • If None, and M is given, we treat it as a numeric-only initialization (case b).
M : float or list/ndarray of float or None
  • For case (a): If provided as None, we do a PubChem search by name.
  • For case (b): The numeric molecular weight(s). No search is performed if name is None.
  • For case (c): Combined name and numeric M => a surrogate with no search.
logP : float or list/ndarray of float or None
  • For case (a): Typically None. If the PubChem search returns logP, it’s stored automatically.
  • For case (b,c): user can supply. If given, stored in self.logP as a numpy array.
db : instance of CompoundIndex or similar, optional
  • If you want to perform a PubChem search (case a) automatically, pass an instance.
  • If omitted or None, no search is attempted, even if name is given.
raiseerror : bool (default=True), optional
Raise an error if name is not found

Advanced Parameters

Property models from MigrationPropertyModels can be directly attached to the substance. Based on the current version of migration.py two models are proposed: - Set a diffusivity model using - Dmodel="model name" default ="Piringer" - Dtemplate=template dict coding for the key:value parameters (e.g, to bed used Diringer(key1=value1…)) note: the template needs to be valid (do not use None) default = {"polymer":None, "M":None, "T":None} - Set a Henry-like model using - kmodel="model name" default =None - ktemplate=template dict coding for the key:value parameters default = {"Pi":1.41, "Pk":3.97, "Vi":124.1, "Vk":30.9, "ispolymer":True, "alpha":0.14, "lngmin":0.0,"Psat":1.0} other models could be implemented in the future, read the module property.py for details.

Example of usage of Dpiringer m = migrant(name='limonene') # without the helper function Dvalue = m.D.evaluate(**dict(m.Dtemplate,polymer="LDPE",T=60)) # with the helper function Dvalue = m.Deval(polymer="LDPE",T=60)

Raises

ValueError if insufficient arguments are provided for any scenario.

Expand source code
class migrant:
    """
    A class representing a migrating chemical substance.

    It can be initialized in three main ways:

    1) Case (a) - By a textual name/CAS only (for a real compound search):
       ---------------------------------------------------------
       Example:
           m = migrant(name="anisole", db=my_compound_index)
           # or
           m = migrant(name="anisole", db=my_compound_index, M=None)
       In this mode:
         • A lookup is performed using db.find(name), which may return one or more records.
         • If multiple records match, data from each record is merged:
             - compound  = The text used in the query (e.g. "anisole")
             - name      = Concatenation of all distinct names from the search results
             - CAS       = Concatenation of all CAS numbers from the search results
             - M         = The minimum of all found molecular weights, stored in self.M (a numpy array also keeps the full set)
             - formula   = The first formula
             - logP      = All logP values concatenated into a numpy array (self.logP_array).
                           The main attribute self.logP will be the same array or you may pick a single representative.

    2) Case (b) - By numeric molecular weight(s) alone (generic substance):
       ---------------------------------------------------------
       Example:
           m = migrant(M=200)
           m = migrant(M=[100, 500])  # Possibly a range
       In this mode:
         • No search is performed.
         • name = "generic" (unless you override it).
         • compound = "single molecular weight" if 1 entry in M, or
                      "list of molecular weights ranging from X to Y" if multiple.
         • CAS = None
         • M   = the minimum of all provided M values (also stored in a numpy array)
         • logP = None by default, or can be supplied explicitly as an array

    3) Case (c) - Name + numeric M/logP => Surrogate / hypothetical:
       ---------------------------------------------------------
       Example:
           m = migrant(name="mySurrogate", M=[200, 250], logP=[2.5, 3.0])
         or
           m = migrant(name="surrogate", M=200)
       In this mode:
         • No lookup is performed. This is a “fake” compound not found in PubChem.
         • compound = "single molecular weight" or
                      "list of molecular weights ranging from X to Y" if multiple.
         • name = whatever user provides
         • CAS = None
         • M   = min of the provided M array, stored in a numpy array
         • logP = user-provided array or single float, stored in a numpy array

    Attributes
    ----------
    compound : str
        For case (a) => the search text;
        For case (b,c) => textual description of the numeric M array.
    name : str or list
        For case (a) => aggregated list of all found names (string-joined);
        For case (b) => "generic" or user-supplied name;
        For case (c) => user-supplied name.
    CAS : list or None
        For case (a) => aggregated CAS from search results;
        For case (b,c) => None.
    M : float
        The *minimum* M from either the search results or the user-supplied array.
    M_array : numpy.ndarray
        The full array of all M values found or provided.
    logP : float or numpy.ndarray or None
        For case (a) => an array of all logP from the search results (or None if not found);
        For case (b) => None or user-supplied value/array;
        For case (c) => user-supplied value/array.
    """

    # class attribute, maximum width
    _maxdisplay = 40

    # migrant constructor
    def __init__(self, name=None,
                 M=None, logP=None,
                 Dmodel = "Piringer",
                 Dtemplate = {"polymer":"LLDPE", "M":50, "T":40}, # do not use None
                 kmodel = "kFHP",
                 ktemplate = {"Pi":1.41, "Pk":3.97, "Vi":124.1, "Vk":30.9, "ispolymer":True,
                              "alpha":0.14, "lngmin":0.0,"Psat":1.0}, # do not use None
                 db=dbdefault,
                 raiseerror=True):
        """
        Create a new migrant instance.

        Parameters
        ----------
        name : str or None
            - A textual name for the substance to be looked up in PubChem (case a),
              or a custom name for a surrogate (case c).
            - If None, and M is given, we treat it as a numeric-only initialization (case b).
        M : float or list/ndarray of float or None
            - For case (a): If provided as None, we do a PubChem search by name.
            - For case (b): The numeric molecular weight(s). No search is performed if name is None.
            - For case (c): Combined name and numeric M => a surrogate with no search.
        logP : float or list/ndarray of float or None
            - For case (a): Typically None. If the PubChem search returns logP, it’s stored automatically.
            - For case (b,c): user can supply. If given, stored in self.logP as a numpy array.
        db : instance of CompoundIndex or similar, optional
            - If you want to perform a PubChem search (case a) automatically, pass an instance.
            - If omitted or None, no search is attempted, even if name is given.
        raiseerror : bool (default=True), optional
            Raise an error if name is not found

        Advanced Parameters
        -------------------
        Property models from MigrationPropertyModels can be directly attached to the substance.
        Based on the current version of migration.py two models are proposed:
            - Set a diffusivity model using
                    - Dmodel="model name"
                      default ="Piringer"
                    - Dtemplate=template dict coding for the key:value parameters
                      (e.g, to bed used Diringer(key1=value1...))
                      note: the template needs to be valid (do not use None)
                      default = {"polymer":None, "M":None, "T":None}
            - Set a Henry-like model using
                    - kmodel="model name"
                      default =None
                    - ktemplate=template dict coding for the key:value parameters
                      default =  {"Pi":1.41, "Pk":3.97, "Vi":124.1, "Vk":30.9, "ispolymer":True, "alpha":0.14, "lngmin":0.0,"Psat":1.0}
            other models could be implemented in the future, read the module property.py for details.

        Example of usage of Dpiringer
            m = migrant(name='limonene')
            # without the helper function
            Dvalue = m.D.evaluate(**dict(m.Dtemplate,polymer="LDPE",T=60))
            # with the helper function
            Dvalue = m.Deval(polymer="LDPE",T=60)

        Raises
        ------
        ValueError if insufficient arguments are provided for any scenario.
        """

        # local import
        # import implicity property migration models (e.g., Dpiringer)
        from patankar.property import MigrationPropertyModels, MigrationPropertyModel_validator

        self.compound = None   # str
        self.name = None       # str or list
        self.cid = None        # int or list
        self.CAS = None        # list or None
        self.M = None          # float
        self.formula = None
        self.smiles = None
        self.M_array = None    # np.ndarray
        self.logP = None       # float / np.ndarray / None

        # special case
        if name==M==None:
            name = 'toluene'

        # Convert M to a numpy array if given
        if M is not None:
            if isinstance(M, (float, int)):
                M_array = np.array([float(M)], dtype=float)
            else:
                # Convert to array
                M_array = np.array(M, dtype=float)
        else:
            M_array = None

        # Similarly, convert logP to array if provided
        if logP is not None:
            if isinstance(logP, (float, int)):
                logP_array = np.array([float(logP)], dtype=float)
            else:
                logP_array = np.array(logP, dtype=float)
        else:
            logP_array = None

        # Case (a): name is provided, M=None => real compound lookup
        if (name is not None) and (M is None):
            if db is None:
                raise ValueError("A db instance is required for searching by name when M is None.")

            df = db.find(name, output_format="simple")
            if df.empty:
                if raiseerror:
                    raise ValueError(f"<{name}> not found")
                print(f"LOADPUBCHEM ERRROR: <{name}> not found - empty object returned")
                # No record found
                self.compound = name
                self.name = [name]
                self.cid = []
                self.CAS = []
                self.M_array = np.array([], dtype=float)
                self.M = None
                self.formula = None
                self.smiles = None
                self.logP = None
            else:
                # Possibly multiple matching rows
                self.compound = name
                all_names = []
                all_cid = []
                all_cas = []
                all_m = []
                all_formulas = []
                all_smiles = []
                all_logP = []

                for _, row in df.iterrows():

                    # Gather a list/set of names
                    row_names = row.get("name", [])
                    if isinstance(row_names, str):
                        row_names = [row_names]
                    row_syns = row.get("synonyms", [])
                    combined_names = set(row_names) | set(row_syns)
                    all_names.extend(list(combined_names))

                    # CID
                    row_cid = row.get("CID", [])
                    if row_cid:
                        all_cid.append(row_cid)

                    # CAS
                    row_cas = row.get("CAS", [])
                    if row_cas:
                        all_cas.extend(row_cas)

                    # M
                    row_m = row.get("M", None)
                    if row_m is not None:
                        try:
                            all_m.append(float(row_m))
                        except:
                            all_m.append(np.nan)
                    else:
                        all_m.append(np.nan)

                    # logP
                    row_logp = row.get("logP", None)
                    if row_logp not in (None, ""):
                        try:
                            all_logP.append(float(row_logp))
                        except:
                            all_logP.append(np.nan)
                    else:
                        all_logP.append(np.nan)

                    # formula (as a string)
                    row_formula = row.get("formula", None)
                    # Even if None, we append so the index lines up with M
                    all_formulas.append(row_formula)

                    # SMILES (as a string)
                    row_smiles = row.get("SMILES", None)
                    # Even if None, we append so the index lines up with M
                    all_smiles.append(row_smiles)

                # Convert to arrays
                arr_m = np.array(all_m, dtype=float)
                arr_logp = np.array(all_logP, dtype=float)

                # Some dedup / cleaning
                unique_names = list(set(all_names))
                unique_cid = list(set(all_cid))
                unique_cas = list(set(all_cas))

                # Store results in the migrant object
                self.name = unique_names
                self.cid = unique_cid[0] if len(unique_cid)==1 else unique_cid
                self.CAS = unique_cas if unique_cas else None
                self.M_array = arr_m
                # Minimum M
                if np.isnan(arr_m).all():
                    self.M = None
                    self.formula = None
                    self.smiles = None
                else:
                    idx_min = np.nanargmin(arr_m)         # index of min M
                    self.M = arr_m[idx_min]               # pick that M
                    self.formula = all_formulas[idx_min]  # pick formula from same record
                    self.smiles = all_smiles[idx_min]     # pick smilesfrom same record

                # Valid logP
                valid_logp = arr_logp[~np.isnan(arr_logp)]
                if valid_logp.size > 0:
                    self.logP = valid_logp  # or store as a list/mean/etc.
                else:
                    self.logP = None

        # Case (b): name is None, M is provided => generic substance
        # ----------------------------------------------------------------
        elif (name is None) and (M_array is not None):
            # No search performed
            if M_array.size == 1:
                self.compound = "single molecular weight"
            else:
                self.compound = (f"list of molecular weights ranging from "
                                 f"{float(np.min(M_array))} to {float(np.max(M_array))}")

            # name => "generic" or if user explicitly set name=..., handle it here
            self.name = "generic"  # from instructions
            self.cid = None
            self.CAS = None
            self.M_array = M_array
            self.M = float(np.min(M_array))
            self.formula = None
            self.smiles = None
            self.logP = logP_array  # user-supplied or None

        # Case (c): name is not None and M is provided => surrogate
        # ----------------------------------------------------------------
        elif (name is not None) and (M_array is not None):
            # No search is done, it doesn't exist in PubChem
            if M_array.size == 1:
                self.compound = "single molecular weight"
            else:
                self.compound = (f"list of molecular weights ranging from "
                                 f"{float(np.min(M_array))} to {float(np.max(M_array))}")

            self.name = name
            self.cid
            self.CAS = None
            self.M_array = M_array
            self.M = float(np.min(M_array))
            self.formula = None
            self.smiles = None
            self.logP = logP_array

        else:
            # If none of these scenarios apply, user gave incomplete or conflicting args
            raise ValueError("Invalid arguments. Provide either name for search (case a), "
                             "or M for a generic (case b), or both for a surrogate (case c).")


        # Model validation and paramameterization
        # ----------------------------------------

        # Diffusivity model
        if Dmodel is not None:
            if not isinstance(Dmodel,str):
                raise TypeError(f"Dmodel should be str not a {type(Dmodel).__name__}")
            if Dmodel not in MigrationPropertyModels["D"]:
                raise ValueError(f'The diffusivity model "{Dmodel}" does not exist')
            Dmodelclass = MigrationPropertyModels["D"][Dmodel]
            if not MigrationPropertyModel_validator(Dmodelclass,Dmodel,"D"):
                raise TypeError(f'The diffusivity model "{Dmodel}" is corrupted')
            if Dtemplate is None:
                Dtemplate = {}
            if not isinstance(Dtemplate,dict):
                raise TypeError(f"Dtemplate should be a dict not a {type(Dtemplate).__name__}")
            self.D  = Dmodelclass
            self.Dtemplate = Dtemplate.copy()
            self.Dtemplate.update(M=self.M,logP=self.logP)
        else:
            self.D = None
            self.Dtemplate = None

        # Henry-like model
        if kmodel is not None:
            if not isinstance(kmodel,str):
                raise TypeError(f"kmodel should be str not a {type(kmodel).__name__}")
            if kmodel not in MigrationPropertyModels["k"]:
                raise ValueError(f'The Henry-like model "{kmodel}" does not exist')
            kmodelclass = MigrationPropertyModels["k"][kmodel]
            if not MigrationPropertyModel_validator(kmodelclass,kmodel,"k"):
                raise TypeError(f'The Henry-like model "{kmodel}" is corrupted')
            if ktemplate is None:
                ktemplate = {}
            if not isinstance(ktemplate,dict):
                raise TypeError(f"ktemplate should be a dict not a {type(ktemplate).__name__}")
            self.k  = kmodelclass
            self.ktemplate = ktemplate.copy()
            self.ktemplate.update(Pi=self.polarityindex,Vi=self.molarvolumeMiller)
        else:
            self.k = None
            self.ktemplate = None

    # helper property to combine D and Dtemplate
    @property
    def Deval(self):
        """Return a callable function that evaluates D with updated parameters."""
        if self.D is None:
            return lambda **kwargs: None  # Return a function that always returns None
        def func(**kwargs):
            updated_template = dict(self.Dtemplate, **kwargs)
            return self.D.evaluate(**updated_template)
        return func

    # helper property to combine k and ktemplate
    @property
    def keval(self):
        """Return a callable function that evaluates k with updated parameters."""
        if self.k is None:
            return lambda **kwargs: None  # Return a function that always returns None
        def func(**kwargs):
            updated_template = dict(self.ktemplate, **kwargs)
            return self.k.evaluate(**updated_template)
        return func



    def __repr__(self):
        """Formatted string representation summarizing key attributes."""
        # Define header
        info = [f"<{self.__class__.__name__} object>"]
        # Collect attributes
        attributes = {
            "Compound": self.compound,
            "Name": self.name,
            "cid": self.cid,
            "CAS": self.CAS,
            "M (min)": self.M,
            "M_array": self.M_array if self.M_array is not None else "N/A",
            "formula": self.formula,
            "smiles": self.smiles if hasattr(self,"smiles") else "N/A",
            "logP": self.logP,
            "P' (calc)": self.polarityindex
        }
        if isinstance(self,migrantToxtree):
            attributes["Compound"] = self.ToxTree["IUPACTraditionalName"]
            attributes["Name"] = self.ToxTree["IUPACName"]
            attributes["Toxicology"] = self.CramerClass
            attributes["TTC"] = f"{self.TTC} {self.TTCunits}"
            attributes["CF TTC"] = f"{self.CFTTC} {self.CFTTCunits}"
            alerts = self.alerts
            # Process alerts
            alert_index = 0
            for key, value in alerts.items():
                if key.startswith("Alert") and key != "Alertscounter" and value.upper() == "YES":
                    alert_index += 1
                    # Convert key name to readable format (split at capital letters)
                    alert_text = ''.join([' ' + char if char.isupper() and i > 0 else char for i, char in enumerate(key)])
                    attributes[f"alert {alert_index}"] = alert_text.strip()  # Remove leading space

        # Determine column width based on longest attribute name
        key_width = max(len(k) for k in attributes.keys()) + 2  # Add padding
        # Format attributes with indentation
        for key, value in attributes.items():
            formatted_key = f"{key}:".rjust(key_width)
            formatted_value = self.dispmax(value)
            info.append(f"  {formatted_key} {formatted_value}")
        # Print formatted representation
        repr_str = "\n".join(info)
        print(repr_str)
        # Return a short summary for interactive use
        return str(self)

    def __str__(self):
        """Formatted string representing the migrant"""
        onename = self.name[0] if isinstance(self.name,list) else self.name
        return f"<{self.__class__.__name__}: {self.dispmax(onename,16)} - M={self.M} g/mol>"

    def dispmax(self,content,maxwidth=None):
        """ optimize display """
        strcontent = str(content)
        maxwidth = self._maxdisplay if maxwidth is None else min(maxwidth,self._maxdisplay)
        if len(strcontent)>maxwidth:
            nchar = round(maxwidth/2)
            return strcontent[:nchar]+" [...] "+strcontent[-nchar:]
        else:
            return content

    # calculated propeties (rough estimates)
    @property
    def polarityindex(self,logP=None,V=None):
        """
            Computes the polarity index (P') of the compound.

            The polarity index (P') is derived from the compound's logP value and
            its molar volume V(), using an empirical (fitted) quadratic equation:

                E = logP * ln(10) - S
                P' = (-B - sqrt(B² - 4A(C - E))) / (2A)

            where:
                - S is the entropy contribution, calculated from molar volume.
                - A, B, C are empirical coefficients.

            Returns
            -------
            float
                The estimated polarity index P' based on logP and molar volume.

            Notes
            -----
            - For highly polar solvents (beyond water), P' saturates at **10.2**.
            - For extremely hydrophobic solvents (beyond n-Hexane), P' is **0**.
            - Accuracy is dependent on the reliability of logP and molar volume models.

            Example
            -------
            >>> compound.polarityindex
            8.34  # Example output
        """
        return polarity_index(logP=self.logP if logP is None else logP,
                              V=self.molarvolumeMiller if V is None else V)

    @property
    def molarvolumeMiller(self, a=0.997, b=1.03):
        """
        Estimates molar volume using the Miller empirical model.

        The molar volume (V_m) is calculated based on molecular weight (M)
        using the empirical formula:

            V_m = a * M^b  (cm³/mol)

        where:
            - `a = 0.997`, `b = 1.03` are empirically derived constants.
            - `M` is the molecular weight (g/mol).
            - `V_m` is the molar volume (cm³/mol).

        Returns
        -------
        float
            Estimated molar volume in cm³/mol.

        Notes
        -----
        - This is an approximate model and may not be accurate for all compounds.
        - Alternative models include the **Yalkowsky & Valvani method**.

        Example
        -------
        >>> compound.molarvolumeMiller
        130.5  # Example output
        """
        return a * self.M**b


    @property
    def molarvolumeLinear(self):
        """
        Estimates molar volume using a simple linear approximation.

        This method provides a rough estimate of molar volume, particularly
        useful for small to mid-sized non-ionic organic molecules. It is based on:

            V_m = 0.935 * M + 14.2  (cm³/mol)

        where:
            - `M` is the molecular weight (g/mol).
            - `V_m` is the estimated molar volume (cm³/mol).
            - Empirical coefficients are derived from **Yalkowsky & Valvani (1980s)**.

        Returns
        -------
        float
            Estimated molar volume in cm³/mol.

        Notes
        -----
        - This method is often *"okay"* for non-ionic organic compounds.
        - Accuracy decreases for very large, ionic, or highly branched molecules.
        - More precise alternatives include **Miller's model** or **group contribution methods**.

        Example
        -------
        >>> compound.molarvolumeLinear
        120.7  # Example output
        """
        return 0.935 * self.M + 14.2

Subclasses

  • patankar.loadpubchem.migrantToxtree

Instance variables

var Deval

Return a callable function that evaluates D with updated parameters.

Expand source code
@property
def Deval(self):
    """Return a callable function that evaluates D with updated parameters."""
    if self.D is None:
        return lambda **kwargs: None  # Return a function that always returns None
    def func(**kwargs):
        updated_template = dict(self.Dtemplate, **kwargs)
        return self.D.evaluate(**updated_template)
    return func
var keval

Return a callable function that evaluates k with updated parameters.

Expand source code
@property
def keval(self):
    """Return a callable function that evaluates k with updated parameters."""
    if self.k is None:
        return lambda **kwargs: None  # Return a function that always returns None
    def func(**kwargs):
        updated_template = dict(self.ktemplate, **kwargs)
        return self.k.evaluate(**updated_template)
    return func
var molarvolumeLinear

Estimates molar volume using a simple linear approximation.

This method provides a rough estimate of molar volume, particularly useful for small to mid-sized non-ionic organic molecules. It is based on:

V_m = 0.935 * M + 14.2  (cm³/mol)

where: - M is the molecular weight (g/mol). - V_m is the estimated molar volume (cm³/mol). - Empirical coefficients are derived from Yalkowsky & Valvani (1980s).

Returns

float
Estimated molar volume in cm³/mol.

Notes

  • This method is often "okay" for non-ionic organic compounds.
  • Accuracy decreases for very large, ionic, or highly branched molecules.
  • More precise alternatives include Miller's model or group contribution methods.

Example

>>> compound.molarvolumeLinear
120.7  # Example output
Expand source code
@property
def molarvolumeLinear(self):
    """
    Estimates molar volume using a simple linear approximation.

    This method provides a rough estimate of molar volume, particularly
    useful for small to mid-sized non-ionic organic molecules. It is based on:

        V_m = 0.935 * M + 14.2  (cm³/mol)

    where:
        - `M` is the molecular weight (g/mol).
        - `V_m` is the estimated molar volume (cm³/mol).
        - Empirical coefficients are derived from **Yalkowsky & Valvani (1980s)**.

    Returns
    -------
    float
        Estimated molar volume in cm³/mol.

    Notes
    -----
    - This method is often *"okay"* for non-ionic organic compounds.
    - Accuracy decreases for very large, ionic, or highly branched molecules.
    - More precise alternatives include **Miller's model** or **group contribution methods**.

    Example
    -------
    >>> compound.molarvolumeLinear
    120.7  # Example output
    """
    return 0.935 * self.M + 14.2
var molarvolumeMiller

Estimates molar volume using the Miller empirical model.

The molar volume (V_m) is calculated based on molecular weight (M) using the empirical formula:

V_m = a * M^b  (cm³/mol)

where: - a = 0.997, b = 1.03 are empirically derived constants. - M is the molecular weight (g/mol). - V_m is the molar volume (cm³/mol).

Returns

float
Estimated molar volume in cm³/mol.

Notes

  • This is an approximate model and may not be accurate for all compounds.
  • Alternative models include the Yalkowsky & Valvani method.

Example

>>> compound.molarvolumeMiller
130.5  # Example output
Expand source code
@property
def molarvolumeMiller(self, a=0.997, b=1.03):
    """
    Estimates molar volume using the Miller empirical model.

    The molar volume (V_m) is calculated based on molecular weight (M)
    using the empirical formula:

        V_m = a * M^b  (cm³/mol)

    where:
        - `a = 0.997`, `b = 1.03` are empirically derived constants.
        - `M` is the molecular weight (g/mol).
        - `V_m` is the molar volume (cm³/mol).

    Returns
    -------
    float
        Estimated molar volume in cm³/mol.

    Notes
    -----
    - This is an approximate model and may not be accurate for all compounds.
    - Alternative models include the **Yalkowsky & Valvani method**.

    Example
    -------
    >>> compound.molarvolumeMiller
    130.5  # Example output
    """
    return a * self.M**b
var polarityindex

Computes the polarity index (P') of the compound.

The polarity index (P') is derived from the compound's logP value and its molar volume V(), using an empirical (fitted) quadratic equation:

E = logP * ln(10) - S
P' = (-B - sqrt(B² - 4A(C - E))) / (2A)

where: - S is the entropy contribution, calculated from molar volume. - A, B, C are empirical coefficients.

Returns

float
The estimated polarity index P' based on logP and molar volume.

Notes

  • For highly polar solvents (beyond water), P' saturates at 10.2.
  • For extremely hydrophobic solvents (beyond n-Hexane), P' is 0.
  • Accuracy is dependent on the reliability of logP and molar volume models.

Example

>>> compound.polarityindex
8.34  # Example output
Expand source code
@property
def polarityindex(self,logP=None,V=None):
    """
        Computes the polarity index (P') of the compound.

        The polarity index (P') is derived from the compound's logP value and
        its molar volume V(), using an empirical (fitted) quadratic equation:

            E = logP * ln(10) - S
            P' = (-B - sqrt(B² - 4A(C - E))) / (2A)

        where:
            - S is the entropy contribution, calculated from molar volume.
            - A, B, C are empirical coefficients.

        Returns
        -------
        float
            The estimated polarity index P' based on logP and molar volume.

        Notes
        -----
        - For highly polar solvents (beyond water), P' saturates at **10.2**.
        - For extremely hydrophobic solvents (beyond n-Hexane), P' is **0**.
        - Accuracy is dependent on the reliability of logP and molar volume models.

        Example
        -------
        >>> compound.polarityindex
        8.34  # Example output
    """
    return polarity_index(logP=self.logP if logP is None else logP,
                          V=self.molarvolumeMiller if V is None else V)

Methods

def dispmax(self, content, maxwidth=None)

optimize display

Expand source code
def dispmax(self,content,maxwidth=None):
    """ optimize display """
    strcontent = str(content)
    maxwidth = self._maxdisplay if maxwidth is None else min(maxwidth,self._maxdisplay)
    if len(strcontent)>maxwidth:
        nchar = round(maxwidth/2)
        return strcontent[:nchar]+" [...] "+strcontent[-nchar:]
    else:
        return content
class oPP (l=4e-05, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in oPP', **extra)

extended pantankar.layer for bioriented polypropylene oPP

oPP layer constructor

Expand source code
class oPP(layer):
    """ extended pantankar.layer for bioriented polypropylene oPP """
    _chemicalsubstance = "propylene" # monomer for polymers
    _polarityindex = 1.0   # Non-polar, but oriented film might have slight morphological differences
    def __init__(self, l=40e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in oPP",**extra):
        """ oPP layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="bioriented polypropylene",
            layercode="oPP",
            **extra
        )
    def density(self, T=None):
        """
        density of bioriented PP: density(T in K)
        Typically close to isotactic PP around ~910 kg/m^3.
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 910 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of bioriented PP """
        return 0, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of bioriented PP

Expand source code
@property
def Tg(self):
    """ glass transition temperature of bioriented PP """
    return 0, "degC"

Methods

def density(self, T=None)

density of bioriented PP: density(T in K) Typically close to isotactic PP around ~910 kg/m^3.

Expand source code
def density(self, T=None):
    """
    density of bioriented PP: density(T in K)
    Typically close to isotactic PP around ~910 kg/m^3.
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 910 * (1 - 3*(T - layer._defaults["Td"]) * 7e-5), "kg/m**3"

Inherited members

class plasticizedPVC (l=0.0002, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in plasticized PVC', **extra)

extended pantankar.layer for plasticized PVC

plasticized PVC layer constructor

Expand source code
class plasticizedPVC(layer):
    """ extended pantankar.layer for plasticized PVC """
    _chemicalsubstance = "vinyl chloride" # monomer for polymers
    _polarityindex = 4.5  # Plasticizers can slightly change overall polarity/solubility.
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in plasticized PVC",**extra):
        """ plasticized PVC layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="plasticized PVC",
            layercode="pPVC",
            **extra
        )
    def density(self, T=None):
        """
        density of plasticized PVC: ~1300 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1300 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of plasticized PVC """
        return -40, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of plasticized PVC

Expand source code
@property
def Tg(self):
    """ glass transition temperature of plasticized PVC """
    return -40, "degC"

Methods

def density(self, T=None)

density of plasticized PVC: ~1300 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of plasticized PVC: ~1300 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1300 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

Inherited members

class rPET (l=0.0002, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in rPET', **extra)

extended pantankar.layer for PET in its rubbery state (above ~76°C)

rubbery PET layer constructor

Expand source code
class rPET(layer):
    """ extended pantankar.layer for PET in its rubbery state (above ~76°C) """
    _chemicalsubstance = "ethylene terephthalate" # monomer for polymers
    _polarityindex = 5.0  # Polyester with significant dipolar interactions (Ph = phenylene ring).
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in rPET",**extra):
        """ rubbery PET layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="rubbery PET",
            layercode="rPET",
            **extra
        )
    def density(self, T=None):
        """
        density of rubbery PET: ~1350 kg/m^3
        but with a different expansion slope possible, if needed
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1350 * (1 - 3*(T - layer._defaults["Td"]) * 1e-4), "kg/m**3"

    @property
    def Tg(self):
        """ approximate glass transition temperature of PET """
        return 76, "degC"

Ancestors

Instance variables

var Tg

approximate glass transition temperature of PET

Expand source code
@property
def Tg(self):
    """ approximate glass transition temperature of PET """
    return 76, "degC"

Methods

def density(self, T=None)

density of rubbery PET: ~1350 kg/m^3 but with a different expansion slope possible, if needed

Expand source code
def density(self, T=None):
    """
    density of rubbery PET: ~1350 kg/m^3
    but with a different expansion slope possible, if needed
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1350 * (1 - 3*(T - layer._defaults["Td"]) * 1e-4), "kg/m**3"

Inherited members

class rigidPVC (l=0.0002, D=1e-14, T=None, k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None, layername='layer in rigid PVC', **extra)

extended pantankar.layer for rigid PVC

rigid PVC layer constructor

Expand source code
class rigidPVC(layer):
    """ extended pantankar.layer for rigid PVC """
    _chemicalsubstance = "vinyl chloride" # monomer for polymers
    _polarityindex = 4.0  # Chlorine substituents give moderate polarity.
    def __init__(self, l=200e-6, D=1e-14, T=None,
                 k=None, C0=None, lunit=None, Dunit=None, kunit=None, Cunit=None,
                 layername="layer in rigid PVC",**extra):
        """ rigid PVC layer constructor """
        super().__init__(
            l=l, D=D, k=k, C0=C0, T=T,
            lunit=lunit, Dunit=Dunit, kunit=kunit, Cunit=Cunit,
            layername=layername,
            layertype="polymer",
            layermaterial="rigid PVC",
            layercode="PVC",
            **extra
        )
    def density(self, T=None):
        """
        density of rigid PVC: ~1400 kg/m^3
        """
        T = self.T if T is None else check_units(T, None, "degC")[0]
        return 1400 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

    @property
    def Tg(self):
        """ glass transition temperature of rigid PVC """
        return 80, "degC"

Ancestors

Instance variables

var Tg

glass transition temperature of rigid PVC

Expand source code
@property
def Tg(self):
    """ glass transition temperature of rigid PVC """
    return 80, "degC"

Methods

def density(self, T=None)

density of rigid PVC: ~1400 kg/m^3

Expand source code
def density(self, T=None):
    """
    density of rigid PVC: ~1400 kg/m^3
    """
    T = self.T if T is None else check_units(T, None, "degC")[0]
    return 1400 * (1 - 3*(T - layer._defaults["Td"]) * 5e-5), "kg/m**3"

Inherited members