Module food

=============================================================================== SFPPy Module: Food Layer =============================================================================== Defines food materials for migration simulations. Models food as a 0D layer with: - Mass transfer resistance (h) - Partitioning (k) - Contact time & temperature

Main Components: - Base Class: foodphysics (Stores all food-related parameters) - Defines mass transfer properties (h, k) - Implements property propagation (food >> layer) - Subclasses: - foodlayer: General food layer model - setoff: Periodic boundary conditions (e.g., stacked packaging) - nofood: Impervious boundary (no mass transfer) - realcontact & testcontact: Standardized storage and testing conditions

Integration with SFPPy Modules: - Works with migration.py as the left-side boundary for simulations. - Can inherit properties from layer.py for contact temperature propagation. - Used in geometry.py when defining food-contacting packaging.

Example:

from patankar.food import foodlayer
medium = foodlayer(name="ethanol", contacttemperature=(40, "degC"))

@version: 1.22 @project: SFPPy - SafeFoodPackaging Portal in Python initiative @author: INRAE\olivier.vitrac@agroparistech.fr @licence: MIT @Date: 2023-01-25 @rev: 2025-03-03

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

"""
===============================================================================
SFPPy Module: Food Layer
===============================================================================
Defines **food materials** for migration simulations. Models food as a **0D layer** with:
- **Mass transfer resistance (`h`)**
- **Partitioning (`k`)**
- **Contact time & temperature**

**Main Components:**
- **Base Class: `foodphysics`** (Stores all food-related parameters)
    - Defines mass transfer properties (`h`, `k`)
    - Implements property propagation (`food >> layer`)
- **Subclasses:**
    - `foodlayer`: General food layer model
    - `setoff`: Periodic boundary conditions (e.g., stacked packaging)
    - `nofood`: Impervious boundary (no mass transfer)
    - `realcontact` & `testcontact`: Standardized storage and testing conditions

**Integration with SFPPy Modules:**
- Works with `migration.py` as the **left-side boundary** for simulations.
- Can inherit properties from `layer.py` for **contact temperature propagation**.
- Used in `geometry.py` when defining food-contacting packaging.

Example:
```python
from patankar.food import foodlayer
medium = foodlayer(name="ethanol", contacttemperature=(40, "degC"))
```


@version: 1.22
@project: SFPPy - SafeFoodPackaging Portal in Python initiative
@author: INRAE\\olivier.vitrac@agroparistech.fr
@licence: MIT
@Date: 2023-01-25
@rev: 2025-03-03

"""

# Dependencies
import sys
import inspect
import textwrap
import numpy as np
from copy import deepcopy as duplicate

from patankar.layer import check_units, NoUnits, layer # to convert units to SI
from patankar.loadpubchem import migrant

__all__ = ['acetonitrile', 'ambient', 'aqueous', 'boiling', 'check_units', 'chemicalaffinity', 'chilled', 'ethanol', 'ethanol50', 'ethanol95', 'fat', 'foodlayer', 'foodphysics', 'foodproperty', 'frozen', 'frying', 'get_defined_init_params', 'help_food', 'hotambient', 'hotfilled', 'hotoven', 'intermediate', 'is_valid_classname', 'isooctane', 'layer', 'liquid', 'list_food_classes', 'methanol', 'microwave', 'migrant', 'nofood', 'oil', 'oliveoil', 'oven', 'panfrying', 'pasteurization', 'perfectlymixed', 'realcontact', 'realfood', 'rolled', 'semisolid', 'setoff', 'simulant', 'solid', 'stacked', 'sterilization', 'tenax', 'testcontact', 'texture', 'transportation', 'water', 'water3aceticacid', 'wrap_text', 'yogurt']

__project__ = "SFPPy"
__author__ = "Olivier Vitrac"
__copyright__ = "Copyright 2022"
__credits__ = ["Olivier Vitrac"]
__license__ = "MIT"
__maintainer__ = "Olivier Vitrac"
__email__ = "olivier.vitrac@agroparistech.fr"
__version__ = "1.22"
#%% Private Properties and functions

# List of the default SI units used by physical quantity
parametersWithUnits = {"volume":"m**3",
                       "surfacearea":"m**2",
                       "density":"kg/m**3",
                       "contacttemperature":"degC",
                       "h":"m/s",
                       "k":NoUnits,  # user (preferred)
                       "k0":NoUnits, # alias (can be used after instantiation)
                       "CF0":NoUnits,
                       "contacttime":"s"
                       }
# corresponding protperty names                       }
paramaterNamesWithUnits = [p+"Units" for p in parametersWithUnits.keys()]

# List parameters not used with nofood, noPBC
parametersWithUnits_andfallback = [key for key in parametersWithUnits if key != "contacttime"]

LEVEL_ORDER = {"base": 0, "root": 1, "property":2, "contact":3, "user": 4}  # Priority order for sorting

def wrap_text(text, width=20):
    """Wraps text within a specified width and returns a list of wrapped lines."""
    if not isinstance(text, str):
        return [str(text)]
    return textwrap.wrap(text, width) or [""]  # Ensure at least one line

def get_defined_init_params(instance):
    """Returns which parameters from parametersWithUnits are defined in the instance."""
    return [param for param in parametersWithUnits.keys() if hasattr(instance, param)]

def is_valid_classname(name):
    """Returns True if class name is valid (not private/internal)."""
    return name.isidentifier() and not name.startswith("_")  # Exclude _10, __, etc.

def list_food_classes():
    """
    Lists all classes in the 'food' module with:
    - name and description
    - level (class attribute)
    - Inheritance details
    - Parameters from parametersWithUnits that are set in the instance
    """
    subclasses_info = []
    current_module = sys.modules[__name__]  # Reference to the food module

    for name, obj in inspect.getmembers(current_module, inspect.isclass):
        if obj.__module__ == current_module.__name__ and is_valid_classname(name):  # Ensure valid class name
            try:
                instance = obj()  # Try to instantiate
                init_params = get_defined_init_params(instance)
                level = getattr(obj, "level", "other")  # Default to "other" if no level is set

                class_info = {
                    "Class Name": wrap_text(name),
                    "Name": wrap_text(getattr(instance, "name", "N/A")),
                    "Description": wrap_text(getattr(instance, "description", "N/A")),
                    "Level": wrap_text(level),
                    "Inheritance": wrap_text(", ".join(base.__name__ for base in obj.__bases__)),
                    "Init Params": wrap_text(", ".join(init_params) if init_params else ""),
                    "Level Sorting": LEVEL_ORDER.get(level, 3)  # Used for sorting, not for table output
                }
                subclasses_info.append(class_info)
            except TypeError:
                class_info = {
                    "Class Name": wrap_text(name),
                    "Name": ["N/A"],
                    "Description": ["N/A"],
                    "Level": wrap_text(getattr(obj, "level", "other")),
                    "Inheritance": wrap_text(", ".join(base.__name__ for base in obj.__bases__)),
                    "Init Params": wrap_text("⚠️ Cannot instantiate"),
                    "Level Sorting": LEVEL_ORDER.get(getattr(obj, "level", "other"), 3)
                }
                subclasses_info.append(class_info)

    # **Sort first by level priority, then alphabetically within each level**
    subclasses_info.sort(key=lambda x: (x["Level Sorting"], x["Class Name"]))

    return subclasses_info

def help_food():
    """
    Prints all food-related classes with relevant attributes in a **formatted Markdown table**.
    """
    derived = list_food_classes()

    # Define table headers (excluding "Level Sorting" because it's only used for sorting)
    headers = ["Class Name", "Name", "Description", "Level", "Inheritance", "Init Params"]

    # Find the maximum number of lines in any wrapped column (excluding "Level Sorting")
    max_lines_per_row = [
        max(len(value) for key, value in row.items() if key != "Level Sorting")
        for row in derived
    ]

    # Convert dictionary entries to lists and ensure they all have the same number of lines
    formatted_rows = []
    for row, max_lines in zip(derived, max_lines_per_row):
        wrapped_row = {
            key: (value if isinstance(value, list) else [value]) + [""] * (max_lines - len(value))
            for key, value in row.items() if key != "Level Sorting"  # Exclude "Level Sorting"
        }
        for i in range(max_lines):  # Transpose wrapped lines into multiple rows
            formatted_rows.append([wrapped_row[key][i] for key in headers])

    # Compute column widths dynamically
    col_widths = [max(len(str(cell)) for cell in col) for col in zip(headers, *formatted_rows)]

    # Create a formatting row template
    row_format = "| " + " | ".join(f"{{:<{w}}}" for w in col_widths) + " |"

    # Print the table header
    print(row_format.format(*headers))
    print("|-" + "-|-".join("-" * w for w in col_widths) + "-|")

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


#%% Base physics class
# -------------------------------------------------------------------
# Base Class to convert class defaults to instance attributes
# -------------------------------------------------------------------
class foodphysics:
    """
    ===============================================================================
    SFPPy Module: Food Physics (Base Class)
    ===============================================================================
    `foodphysics` serves as the **base class** for all food-related objects in mass
    transfer simulations. It defines key parameters for food interaction with packaging
    materials and implements dynamic property propagation for simulation models.

    ------------------------------------------------------------------------------
    **Core Functionality**
    ------------------------------------------------------------------------------
    - Defines **mass transfer properties**:
      - `h`: Mass transfer coefficient (m/s)
      - `k`: Partition coefficient (dimensionless)
    - Implements **contact conditions**:
      - `contacttime`: Duration of food-packaging contact
      - `contacttemperature`: Temperature of the contact interface
    - Supports **inheritance and property propagation** to layers.
    - Provides **physical state representation** (`solid`, `liquid`, `gas`).
    - Allows **customization of mass transfer coefficients** via `kmodel`.

    ------------------------------------------------------------------------------
    **Key Properties**
    ------------------------------------------------------------------------------
    - `h`: Mass transfer coefficient (m/s) defining resistance at the interface.
    - `k`: Henry-like partition coefficient between the food and the material.
    - `contacttime`: Time duration of the packaging-food interaction.
    - `contacttemperature`: Temperature at the packaging interface (°C).
    - `surfacearea`: Contact surface area between packaging and food (m²).
    - `volume`: Volume of the food medium (m³).
    - `density`: Density of the food medium (kg/m³).
    - `substance`: The migrating substance (e.g., a chemical compound).
    - `medium`: The food medium in contact with packaging.
    - `kmodel`: Custom partitioning model (can be overridden by the user).

    ------------------------------------------------------------------------------
    **Methods**
    ------------------------------------------------------------------------------
    - `__rshift__(self, other)`: Propagates food properties to a layer (`food >> layer`).
    - `__matmul__(self, other)`: Equivalent to `>>`, enables `food @ layer`.
    - `migration(self, material, **kwargs)`: Simulates migration into a packaging layer.
    - `contact(self, material, **kwargs)`: Alias for `migration()`.
    - `update(self, **kwargs)`: Dynamically updates food properties.
    - `get_param(self, key, default=None, acceptNone=True)`: Retrieves a parameter safely.
    - `refresh(self)`: Ensures all properties are validated before simulation.
    - `acknowledge(self, what, category)`: Tracks inherited properties.
    - `copy(self, **kwargs)`: Creates a deep copy of the food object.

    ------------------------------------------------------------------------------
    **Integration with SFPPy Modules**
    ------------------------------------------------------------------------------
    - Works with `migration.py` to define the **left-side boundary condition**.
    - Interfaces with `layer.py` to apply contact temperature propagation.
    - Connects with `geometry.py` for food-contacting packaging surfaces.

    ------------------------------------------------------------------------------
    **Usage Example**
    ------------------------------------------------------------------------------
    ```python
    from patankar.food import foodphysics
    from patankar.layer import layer

    medium = foodphysics(contacttemperature=(40, "degC"), h=(1e-6, "m/s"))
    packaging_layer = layer(D=1e-14, l=50e-6)

    # Propagate food properties to the layer
    medium >> packaging_layer

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

    ------------------------------------------------------------------------------
    **Notes**
    ------------------------------------------------------------------------------
    - The `foodphysics` class is the parent of `foodlayer`, `nofood`, `setoff`,
      `realcontact`, and `testcontact`.
    - The `PBC` property identifies periodic boundary conditions (used in `setoff`).
    - This class provides **dynamic inheritance** for mass transfer properties.

    """

    # General descriptors
    description = "Root physics class used to implement food and mass transfer physics"  # Remains as class attribute
    name = "food physics"
    level = "base"

    # Low-level prediction properties (F=contact medium, i=solute/migrant)
    # these @properties are defined by foodlayer, they should be duplicated
    _lowLevelPredictionPropertyList = [
        "chemicalsubstance","simulant","polarityindex","ispolymer","issolid", # F: common with patankar.layer
        "physicalstate","chemicalclass", # phase F properties
        "substance","migrant","solute", # i properties with synonyms substance=migrant=solute
        # users use "k", but internally we use k0, keep _kmodel in the instance
        "k0","k0unit","kmodel","_compute_kmodel" # Henry-like coefficients returned as properties with possible user override with medium.k0model=None or a function
        ]

    # ------------------------------------------------------
    # Transfer rules for food1 >> food2 and food1 >> result
    # ------------------------------------------------------

    # Mapping of properties to their respective categories
    _list_categories = {
        "contacttemperature": "contact",
        "contacttime": "contact",
        "surfacearea": "geometry",
        "volume": "geometry",
        "substance": "substance",
        "medium": "medium"
    }

    # Rules for property transfer wtih >> or @ based on object type
    # ["property name"]["name of the destination class"][attr]
    #   - if onlyifinherited, only inherited values are transferred
    #   - if checkNmPy, the value will be transferred as a np.ndarray
    #   - name is the name of the property in the destination class (use "" to keep the same name)
    #   - prototype is the class itself (available only after instantiation, keep None here)
    _transferable_properties = {
        "contacttemperature": {
            "foodphysics": {
                "onlyifinherited": True,
                "checkNumPy": False,
                "as": "",
                "prototype": None,
            },
            "layer": {
                "onlyifinherited": False,
                "checkNumPy": True,
                "as": "T",
                "prototype": None
            }
        },
        "contacttime": {
            "foodphysics": {
                "onlyifinherited": True,
                "checkNumPy": True,
                "as": "",
                "prototype": None,
            },
            "SensPatankarResult": {
                "onlyifinherited": False,
                "checkNumPy": True,
                "as": "t",
                "prototype": None
            }
        },
        "surfacearea": {
            "foodphysics": {
                "onlyifinherited": False,
                "checkNumPy": False,
                "as": "surfacearea",
                "prototype": None
            }
        },
        "volume": {
            "foodphysics": {
                "onlyifinherited": False,
                "checkNumPy": True,
                "as": "",
                "prototype": None
            }
        },
        "substance": {
            "foodlayer": {
                "onlyifinherited": False,
                "checkNumPy": False,
                "as": "",
                "prototype": None,
            },
            "layer": {
                "onlyifinherited": False,
                "checkNumPy": False,
                "as": "",
                "prototype": None
            }
        },
        "medium": {
            "layer": {
                "onlyifinherited": False,
                "checkNumPy": False,
                "as": "",
                "prototype": None
            }
        },
    }


    def __init__(self, **kwargs):
        """general constructor"""

        # local import
        from patankar.migration import SensPatankarResult

        # numeric validator
        def numvalidator(key,value):
            if key in parametersWithUnits:          # the parameter is a physical quantity
                if isinstance(value,tuple):         # the supplied value as unit
                    value,_ = check_units(value)    # we convert to SI, we drop the units
                if not isinstance(value,np.ndarray):
                    value = np.array([value])       # we force NumPy class
            return value

        # Iterate through the MRO (excluding foodphysics and object)
        for cls in reversed(self.__class__.__mro__):
            if cls in (foodphysics, object):
                continue
            # For each attribute defined at the class level,
            # if it is not 'description', not callable, and not a dunder, set it as an instance attribute.
            for key, value in cls.__dict__.items(): # we loop on class attributes
                if key in ("description","level") or key in self._lowLevelPredictionPropertyList or key.startswith("__") or key.startswith("_") or callable(value):
                    continue
                if key not in kwargs:
                    setattr(self, key, numvalidator(key,value))
        # Now update/override with any keyword arguments provided at instantiation.
        for key, value in kwargs.items():
            value = numvalidator(key,value)
            if key not in paramaterNamesWithUnits: # we protect the values of units (they are SI, they cannot be changed)
                setattr(self, key, value)
        # we initialize the acknowlegment process for future property propagation
        self._hasbeeninherited = {}
        # we initialize _kmodel if _compute_kmodel exists
        if hasattr(self,"_compute_kmodel"):
            self._kmodel = "default" # do not initialize at self._compute_kmodel (default forces refresh)
        # we initialize the _simstate storing the last simulation result available
        self._simstate = None # simulation results
        self._inpstate = None # their inputs
        # For cooperative multiple inheritance, call the next __init__ if it exists.
        super().__init__()
        # Define actual class references to avoid circular dependency issues
        if self.__class__._transferable_properties["contacttemperature"]["foodphysics"]["prototype"] is None:
            self.__class__._transferable_properties["contacttemperature"]["foodphysics"]["prototype"] = foodphysics
            self.__class__._transferable_properties["contacttemperature"]["layer"]["prototype"] = layer
            self.__class__._transferable_properties["contacttime"]["foodphysics"]["prototype"] = foodphysics
            self.__class__._transferable_properties["contacttime"]["SensPatankarResult"]["prototype"] = SensPatankarResult
            self.__class__._transferable_properties["surfacearea"]["foodphysics"]["prototype"] = foodphysics
            self.__class__._transferable_properties["volume"]["foodphysics"]["prototype"] = foodphysics
            self.__class__._transferable_properties["substance"]["foodlayer"]["prototype"] = migrant
            self.__class__._transferable_properties["substance"]["layer"]["prototype"] = layer
            self.__class__._transferable_properties["medium"]["layer"]["prototype"] = layer

    # ------- [properties to access/modify simstate] --------
    @property
    def lastinput(self):
        """Getter for last layer input."""
        return self._inpstate

    @lastinput.setter
    def lastinput(self, value):
        """Setter for last layer input."""
        self._inpstate = value

    @property
    def lastsimulation(self):
        """Getter for last simulation results."""
        return self._simstate

    @lastsimulation.setter
    def lastsimulation(self, value):
        """Setter for last simulation results."""
        self._simstate = value

    @property
    def hassimulation(self):
        """Returns True if a simulation exists"""
        return self.lastsimulation is not None


    # ------- [inheritance registration mechanism] --------
    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.

        Example:
        --------
        >>> b = B()
        >>> b.acknowledge(what="volume", category="geometry")
        >>> b.acknowledge(what=["surfacearea", "diameter"], category="geometry")
        >>> print(b._hasbeeninherited)
        {'geometry': {'volume', 'surfacearea', 'diameter'}}
        """
        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 refresh(self):
        """refresh all physcal paramaters after instantiation"""
        for key, value in self.__dict__.items():    # we loop on instance attributes
            if key in parametersWithUnits:          # the parameter is a physical quantity
                if isinstance(value,tuple):         # the supplied value as unit
                    value = check_units(value)[0]   # we convert to SI, we drop the units
                    setattr(self,key,value)
                if not isinstance(value,np.ndarray):
                    value = np.array([value])      # we force NumPy class
                    setattr(self,key,value)

    def update(self, **kwargs):
        """
        Update modifiable parameters of the foodphysics object.

        Modifiable Parameters:
            - name (str): New name for the object.
            - description (str): New description.
            - volume (float or tuple): Volume (can be tuple like (1, "L")).
            - surfacearea (float or tuple): Surface area (can be tuple like (1, "cm^2")).
            - density (float or tuple): Density (can be tuple like (1000, "kg/m^3")).
            - CF0 (float or tuple): Initial concentration in the food.
            - contacttime (float or tuple): Contact time (can be tuple like (1, "h")).
            - contacttemperature (float or tuple): Temperature (can be tuple like (25, "degC")).
            - h (float or tuple): Mass transfer coefficient (can be tuple like (1e-6,"m/s")).
            - k (float or tuple): Henry-like coefficient for the food (can be tuple like (1,"a.u.")).

        """
        if not kwargs:  # shortcut
            return self # for chaining
        def checkunits(value):
            """Helper function to convert physical quantities to SI."""
            if isinstance(value, tuple) and len(value) == 2:
                scale = check_units(value)[0]  # Convert to SI, drop unit
                return np.array([scale], dtype=float)  # Ensure NumPy array
            elif isinstance(value, (int, float, np.ndarray)):
                return np.array([value], dtype=float)  # Ensure NumPy array
            else:
                raise ValueError(f"Invalid value for physical quantity: {value}")
        # Update `name` and `description` if provided
        if "name" in kwargs:
            self.name = str(kwargs["name"])
        if "description" in kwargs:
            self.description = str(kwargs["description"])
        # Update physical properties
        for key in parametersWithUnits.keys():
            if key in kwargs:
                value = kwargs[key]
                setattr(self, key, checkunits(value))  # Ensure NumPy array in SI
        # Update medium, migrant (they accept aliases)
        lex = {
            "substance": ("substance", "migrant", "chemical", "solute"),
            "medium": ("medium", "simulant", "food", "contact"),
        }
        used_aliases = {}
        def get_value(canonical_key):
            """Find the correct alias in kwargs and return its value, or None if not found."""
            found_key = None
            for alias in lex.get(canonical_key, ()):  # Get aliases, default to empty tuple
                if alias in kwargs:
                    if alias in used_aliases:
                        raise ValueError(f"Alias '{alias}' is used for multiple canonical keys!")
                    found_key = alias
                    used_aliases[alias] = canonical_key
                    break  # Stop at the first match
            return kwargs.get(found_key, None)  # Return value if found, else None
        # Assign values only if found in kwargs
        new_substance = get_value("substance")
        new_medium = get_value("medium")
        if new_substance is not None: self.substance = new_substance
        if new_medium is not None:self.medium = new_medium
        # return
        return self  # Return self for method chaining if needed

    def get_param(self, key, default=None, acceptNone=True):
        """Retrieve instance attribute with a default fallback if enabled."""
        paramdefaultvalue = 1
        if isinstance(self,(setoff,nofood)):
            if key in parametersWithUnits_andfallback:
                value =  self.__dict__.get(key, paramdefaultvalue) if default is None else self.__dict__.get(key, default)
                if isinstance(value,np.ndarray):
                    value = value.item()
                if value is None and not acceptNone:
                    value = paramdefaultvalue if default is None else default
                return np.array([value])
            if key in paramaterNamesWithUnits:
                return self.__dict__.get(key, parametersWithUnits[key]) if default is None else self.__dict__.get(key, default)
        if key in parametersWithUnits:
            if hasattr(self, key):
                return getattr(self,key)
            else:
                raise KeyError(
                    f"Missing property: '{key}' in instance of class '{self.__class__.__name__}'.\n"
                    f"To define it, use one of the following methods:\n"
                    f"  - Direct assignment:   object.{key} = value\n"
                    f"  - Using update method: object.update({key}=value)\n"
                    f"Note: The value can also be provided as a tuple (value, 'unit')."
                )
        elif key in paramaterNamesWithUnits:
            return self.__dict__.get(key, paramaterNamesWithUnits[key]) if default is None else self.__dict__.get(key, default)
        raise KeyError(f'Use getattr("{key}") to retrieve the value of {key}')

    def __repr__(self):
        """Formatted string representation of the FOODlayer object."""
        # Refresh all definitions
        self.refresh()
        # Header with name and description
        repr_str = f'Food object "{self.name}" ({self.description}) with properties:\n'
        # Helper function to extract a numerical value safely
        def format_value(value):
            """Ensure the value is a float or a single-item NumPy array."""
            if isinstance(value, np.ndarray):
                return value.item() if value.size == 1 else value[0]  # Ensure scalar representation
            elif value is None:
                return value
            return float(value)
        # Collect defined properties and their formatted values
        properties = []
        excluded = ("k") if self.haskmodel else ("k0")
        for key, unit in parametersWithUnits.items():
            if hasattr(self, key) and key not in excluded:  # Include only defined parameters
                value = format_value(getattr(self, key))
                unit_str = self.get_param(f"{key}Units", unit)  # Retrieve unit safely
                if value is not None:
                    properties.append((key, f"{value:0.8g}", unit_str))

        # Sort properties alphabetically
        properties.sort(key=lambda x: x[0])

        # Determine max width for right-aligned names
        max_key_length = max(len(key) for key, _, _ in properties) if properties else 0
        # Construct formatted property list
        for key, value, unit_str in properties:
            repr_str += f"{key.rjust(max_key_length)}: {value} [{unit_str}]\n"
            if key == "k0":
                extra_info = f"{self._substance.k.__name__}(<{self.chemicalsubstance}>,{self._substance})"
                repr_str += f"{' ' * (max_key_length)}= {extra_info}\n"
        print(repr_str.strip())  # Print formatted output
        return str(self)  # Simplified representation for repr()



    def __str__(self):
        """Formatted string representation of the property"""
        simstr = ' [simulated]' if self.hassimulation else ""
        return f"<{self.__class__.__name__}: {self.name}>{simstr}"

    def copy(self,**kwargs):
        """Creates a deep copy of the current food instance."""
        return duplicate(self).update(**kwargs)


    @property
    def PBC(self):
        """
        Returns True if h is not defined or None
        This property is used to identified periodic boundary condition also called setoff mass transfer.

        """
        if not hasattr(self,"h"):
            return False # None
        htmp = getattr(self,"h")
        if isinstance(htmp,np.ndarray):
            htmp = htmp.item()
        return htmp is None

    @property
    def hassubstance(self):
        """Returns True if substance is defined (class migrant)"""
        if not hasattr(self, "_substance"):
            return False
        return isinstance(self._substance,migrant)



    # --------------------------------------------------------------------
    # For convenience, several operators have been overloaded
    #   medium >> packaging      # sets the volume and the surfacearea
    #   medium >> material       # propgates the contact temperature from the medium to the material
    #   sol = medium << material # simulate migration from the material to the medium
    # --------------------------------------------------------------------

    # method: medium._to(material) and its associated operator >>
    def _to(self, other = None):
        """
        Transfers inherited properties to another object based on predefined rules.

        Parameters:
        -----------
        other : object
            The recipient object that will receive the transferred properties.

        Notes:
        ------
        - Only properties listed in `_transferable_properties` are transferred.
        - A property can only be transferred if `other` matches the expected class.
        - The property may have a different name in `other` as defined in `as`.
        - If `onlyifinherited` is True, the property must have been inherited by `self`.
        - If `checkNumPy` is True, ensures NumPy array compatibility.
        - Updates `other`'s `_hasbeeninherited` tracking.
        """
        for prop, classes in self._transferable_properties.items():
            if prop not in self._list_categories:
                continue  # Skip properties not categorized

            category = self._list_categories[prop]

            for class_name, rules in classes.items():

                if not isinstance(other, rules["prototype"]):
                    continue  # Skip if other is not an instance of the expected prototype class

                if rules["onlyifinherited"] and category not in self._hasbeeninherited:
                    continue  # Skip if property must be inherited but is not

                if rules["onlyifinherited"] and prop not in self._hasbeeninherited[category]:
                    continue  # Skip if the specific property has not been inherited

                if not hasattr(self, prop):
                    continue  # Skip if the property does not exist on self

                # Determine the target attribute name in other
                target_attr = rules["as"] if rules["as"] else prop

                # Retrieve the property value
                value = getattr(self, prop)

                # Handle NumPy array check
                if rules["checkNumPy"] and hasattr(other, target_attr):
                    existing_value = getattr(other, target_attr)
                    if isinstance(existing_value, np.ndarray):
                        value = np.full(existing_value.shape, value)

                # Assign the value to other
                setattr(other, target_attr, value)

                # Register the transfer in other’s inheritance tracking
                other.acknowledge(what=target_attr, category=category)

                # to chain >>
                return other

    def __rshift__(self, other):
        """Overloads >> to propagate to other."""
        # inherit substance/migrant from other if self.migrant is None
        if isinstance(other,(layer,foodlayer)):
            if isinstance(self,foodlayer):
                if self.substance is None and other.substance is not None:
                    self.substance = other.substance
        return self._to(other) # propagates

    def __matmul__(self, other):
        """Overload @: equivalent to >> if other is a layer."""
        if not isinstance(other, layer):
            raise TypeError(f"Right operand must be a layer not a {type(other).__name__}")
        return self._to(other)

    # migration method
    def migration(self,material,**kwargs):
        """interface to simulation engine: senspantankar"""
        from patankar.migration import senspatankar
        self._to(material) # propagate contact conditions first
        sim = senspatankar(material,self,**kwargs)
        self.lastsimulation = sim # store the last simulation result in medium
        self.lastinput = material # store the last input (material)
        sim.savestate(material,self) # store store the inputs in sim for chaining
        return sim

    def contact(self,material,**kwargs):
        """alias to migration method"""
        return self.migration(self,material,**kwargs)

    @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


# %% Root classes
# -------------------------------------------------------------------
# ROOT CLASSES
#   - The foodlayer class represents physically the food
#   - The chemicalaffinity class represents the polarity of the medium (with respect to the substance)
#   - The texture class represents the mass transfer reistance between the food and the material in contact
#   - The nofood class enforces an impervious boundary condition on the food side preventing any transfer.
#     This class is useful to simulate mass transfer within the packaging layer in the absence of food.
#   - The setoff class enforces periodic conditions such as when packaging are stacked together.
# -------------------------------------------------------------------

class foodlayer(foodphysics):
    """
    ===============================================================================
    SFPPy Module: Food Layer
    ===============================================================================
    `foodlayer` models food as a **0D layer** in mass transfer simulations, serving
    as the primary class for defining the medium in contact with a packaging material.

    ------------------------------------------------------------------------------
    **Core Functionality**
    ------------------------------------------------------------------------------
    - Models food as a **zero-dimensional (0D) medium** with:
      - A **mass transfer resistance (`h`)** at the interface.
      - A **partitioning behavior (`k`)** between food and packaging.
      - **Contact time (`contacttime`) and temperature (`contacttemperature`)**.
    - Defines **food geometry**:
      - `surfacearea`: Contact area with the material (m²).
      - `volume`: Total volume of the food medium (m³).
    - Supports **impervious (`nofood`) and periodic (`setoff`) conditions**.

    ------------------------------------------------------------------------------
    **Key Properties**
    ------------------------------------------------------------------------------
    - `h`: Mass transfer coefficient (m/s) defining resistance at the interface.
    - `k`: Partition coefficient describing substance solubility in food.
    - `contacttime`: Time duration of the packaging-food interaction.
    - `contacttemperature`: Temperature at the packaging interface (°C).
    - `surfacearea`: Contact surface area between packaging and food (m²).
    - `volume`: Volume of the food medium (m³).
    - `density`: Density of the food medium (kg/m³).
    - `substance`: Migrant (chemical) diffusing into food.
    - `medium`: Food medium in contact with packaging.
    - `impervious`: `True` if no mass transfer occurs (`nofood` class).
    - `PBC`: `True` if periodic boundary conditions apply (`setoff` class).

    ------------------------------------------------------------------------------
    **Methods**
    ------------------------------------------------------------------------------
    - `__rshift__(self, other)`: Propagates food properties to a packaging layer (`food >> layer`).
    - `__matmul__(self, other)`: Equivalent to `>>`, enables `food @ layer`.
    - `migration(self, material, **kwargs)`: Simulates migration into a packaging layer.
    - `contact(self, material, **kwargs)`: Alias for `migration()`.
    - `update(self, **kwargs)`: Dynamically updates food properties.
    - `get_param(self, key, default=None, acceptNone=True)`: Retrieves a parameter safely.
    - `refresh(self)`: Ensures all properties are validated before simulation.
    - `acknowledge(self, what, category)`: Tracks inherited properties.
    - `copy(self, **kwargs)`: Creates a deep copy of the food object.

    ------------------------------------------------------------------------------
    **Integration with SFPPy Modules**
    ------------------------------------------------------------------------------
    - Used as the **left-side boundary** in `migration.py` simulations.
    - Interacts with `layer.py` to propagate temperature and partitioning effects.
    - Interfaces with `geometry.py` for food-contacting packaging simulations.

    ------------------------------------------------------------------------------
    **Usage Example**
    ------------------------------------------------------------------------------
    ```python
    from patankar.food import foodlayer
    medium = foodlayer(name="ethanol", contacttemperature=(40, "degC"))

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

    # Propagate food properties to the packaging
    medium >> packaging

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

    ------------------------------------------------------------------------------
    **Notes**
    ------------------------------------------------------------------------------
    - The `foodlayer` class extends `foodphysics` and provides a physical
      representation of food in contact with packaging.
    - Subclasses include:
      - `setoff`: Periodic boundary conditions (stacked packaging).
      - `nofood`: Impervious boundary (no mass transfer).
      - `realcontact`, `testcontact`: Standardized food contact conditions.
    - The `h` parameter determines if the medium is **well-mixed** or **diffusion-limited**.

    """
    level = "root"
    description = "root food class"  # Remains as class attribute
    name = "generic food layer"
    # -----------------------------------------------------------------------------
    # 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.
    # 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)
    # -----------------------------------------------------------------------------
    # These properties are essential for model predictions, they cannot be customized
    # beyond the rules accepted by the model predictors (they are not metadata)
    # note: similar attributes exist for patanaker.layer objects (similar possible values)
    _physicalstate = "liquid"   # solid, liquid (default), gas, porous
    _chemicalclass = "other"    # polymer, other (default)
    _chemicalsubstance = None   # None (default), monomer for polymers
    _polarityindex = 0.0        # polarity index (roughly: 0=hexane, 10=water)
    # -----------------------------------------------------------------------------
    # Class attributes duplicated as instance parameters
    # -----------------------------------------------------------------------------
    volume,volumeUnits = check_units((1,"dm**3"))
    surfacearea,surfaceareaUnits = check_units((6,"dm**2"))
    density,densityUnits = check_units((1000,"kg/m**3"))
    CF0,CF0units = check_units((0,NoUnits))  # initial concentration (arbitrary units)
    contacttime, contacttime_units = check_units((10,"days"))
    contactemperature,contactemperatureUnits = check_units((40,"degC"),ExpectedUnits="degC") # temperature in °C
    _substance = None # substance container / similar construction in pantankar.layer = migrant
    _k0model = None
    # -----------------------------------------------------------------------------
    # Getter methods for class/instance properties: same definitions as in patankar.layer (mandatory)
    # medium properties
    # -----------------------------------------------------------------------------
    # PHASE PROPERTIES  (attention chemicalsubstance=F substance, substance=i substance)
    @property
    def physicalstate(self): return self._physicalstate
    @property
    def chemicalclass(self): return self._chemicalclass
    @property
    def chemicalsubstance(self): return self._chemicalsubstance
    @property
    def simulant(self): return self._chemicalsubstance # simulant is an alias of chemicalsubstance
    @property
    def polarityindex(self): return self._polarityindex
    @property
    def ispolymer(self): return self.physicalstate == "polymer"
    @property
    def issolid(self): return self.solid == "solid"
    # SUBSTANCE/SOLUTE/MIGRANT properties  (attention chemicalsubstance=F substance, substance=i substance)
    @property
    def substance(self): return self._substance # substance can be ambiguous
    @property
    def migrant(self): return self.substance    # synonym
    @property
    def solute(self): return self.substance     # synonym

    # -----------------------------------------------------------------------------
    # Setter methods for class/instance properties: same definitions as in patankar.layer (mandatory)
    # -----------------------------------------------------------------------------
    # PHASE PROPERTIES  (attention chemicalsubstance=F substance, substance=i substance)
    @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
    @simulant.setter
    def simulant(self,value):
        self.chemicalsubstance = value # simulant is an alias of chemicalcalsubstance
    @polarityindex.setter
    def polarityindex(self,value):
        if not isinstance(value,(float,int)):
            raise ValueError("polarity index must be float not a {type(value).__name__}")
        # rescaled to match predictions - standard scale [0,10.2] - predicted scale [0,7.12]
        return self._polarityindex * migrant("water").polarityindex/10.2
    # SUBSTANCE/SOLUTE/MIGRANT properties  (attention chemicalsubstance=F substance, substance=i substance)
    @substance.setter
    def substance(self,value):
        if isinstance(value,str):
            value = migrant(value)
        if not isinstance(value,migrant):
            raise TypeError(f"substance/migrant/solute must be a migrant not a {type(value).__name__}")
        self._substance = value
    @migrant.setter
    def migrant(self,value):
        self.substance = value
    @solute.setter
    def solute(self,value):
        self.substance = value
    # -----------------------------------------------------------------------------
    # Henry-like coefficient k and its alias k0 (internal use)
    # -----------------------------------------------------------------------------
    #   - k is the name of the Henry-like property for food (as set and seen by the user)
    #   - k0 is the property operated by migration
    #   - k0 = k except if kmodel (lambda function) does not returns None
    #   - kmodel returns None if _substance is not set (proper migrant)
    #   - kmodel = None will override any existing kmodel
    #   - kmodel must be intialized to "default" to refresh its definition with self
    # note: The implementation is almost symmetric with kmodel in patankar.layer.
    # The main difference are:
    #    - food classes are instantiated by foodphysics
    #    - k is used to store the value of k0 (not _k or _k0)
    # -----------------------------------------------------------------------------
    # layer substance (of class migrant or None)
    # k0 and k0units (k and kunits are user inputs)
    @property
    def k0(self):
        ktmp = None
        if self.kmodel == "default": # default behavior
            ktmp = self._compute_kmodel()
        elif callable(self.kmodel): # user override (not the default function)
            ktmp = self.kmodel()
        if ktmp:
            return np.full_like(self.k, ktmp,dtype=np.float64)
        return self.k
    @k0.setter
    def k0(self,value):
        if not isinstance(value,(int,float,np.ndarray)):
            TypeError("k0 must be int, float or np.ndarray")
        if isinstance(self.k,int): self.k = float(self.k)
        self.k = np.full_like(self.k,value,dtype=np.float64)
    @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 or self.chemicalsubstance 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 = False)
        def func(**kwargs):
            if self.chemicalsubstance:
                simulant = migrant(self.chemicalsubstance)
                template.update(Pk = simulant.polarityindex,
                                Vk = simulant.molarvolumeMiller)
                k = self._substance.k.evaluate(**dict(template, **kwargs))
                return k
            else:
                self.k
        return func # we return a callable function not a value


class texture(foodphysics):
    """Parent food texture class"""
    description = "default class texture"
    name = "undefined"
    level = "root"
    h = 1e-3

class chemicalaffinity(foodphysics):
    """Parent chemical affinity class"""
    description = "default chemical affinity"
    name = "undefined"
    level = "root"
    k = 1.0

class nofood(foodphysics):
    """Impervious boundary condition"""
    description = "impervious boundary condition"
    name = "undefined"
    level = "root"
    h = 0

class setoff(foodphysics):
    """periodic boundary conditions"""
    description = "periodic boundary conditions"
    name = "setoff"
    level = "root"
    h = None

class realcontact(foodphysics):
    """real contact conditions"""
    description = "real storage conditions"
    name = "contact conditions"
    level = "root"
    [contacttime,contacttimeUnits] = check_units((200,"days"))
    [contacttemperature,contacttemperatureUnits] = check_units((25,"degC"))

class testcontact(foodphysics):
    """conditions of migration testing"""
    description = "migration testing conditions"
    name = "migration testing"
    level = "root"
    [contacttime,contacttimeUnits] = check_units((10,"days"))
    [contacttemperature,contacttemperatureUnits] = check_units((40,"degC"))

# %% Property classes
# -------------------------------------------------------------------
# SECOND LEVEL CLASSES
# This classes are used as keyword to define new food with a combination of properties.
# -------------------------------------------------------------------

# Food/chemical properties
class foodproperty(foodlayer):
    """Class wrapper of food properties"""
    level="property"

class realfood(foodproperty):
    """Core real food class (second level)"""
    description = "real food class"

class simulant(foodproperty):
    """Core food simulant class (second level)"""
    name = "generic food simulant"
    description = "food simulant"

class solid(foodproperty):
    """Solid food texture"""
    _physicalstate = "solid"    # it will be enforced if solid is defined first (see obj.mro())
    name = "solid food"
    description = "solid food products"
    [h,hUnits] = check_units((1e-8,"m/s"))

class semisolid(texture):
    """Semi-solid food texture"""
    name = "solid food"
    description = "solid food products"
    [h,hUnits] = check_units((1e-7,"m/s"))

class liquid(texture):
    """Liquid food texture"""
    name = "liquid food"
    description = "liquid food products"
    [h,hUnits] = check_units((1e-6,"m/s"))

class perfectlymixed(texture):
    """Perfectly mixed liquid (texture)"""
    name = "perfectly mixed liquid"
    description = "maximize mixing, minimize the mass transfer boundary layer"
    [h,hUnits] = check_units((1e-4,"m/s"))

class fat(chemicalaffinity):
    """Fat contact"""
    name = "fat contact"
    description = "maximize mass transfer"
    [k,kUnits] = check_units((1,NoUnits))

class aqueous(chemicalaffinity):
    """Aqueous food contact"""
    name = "aqueous contact"
    description = "minimize mass transfer"
    [k,kUnits] = check_units((1000,NoUnits))

class intermediate(chemicalaffinity):
    """Intermediate chemical affinity"""
    name = "intermediate"
    description = "intermediate chemical affinity"
    [k,kUnits] = check_units((10,NoUnits))

# Contact conditions

class frozen(realcontact):
    """real contact conditions"""
    description = "freezing storage conditions"
    name = "frrozen"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((6,"months"))
    [contacttemperature,contacttemperatureUnits] = check_units((-20,"degC"))

class chilled(realcontact):
    """real contact conditions"""
    description = "ambient storage conditions"
    name = "ambient"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((30,"days"))
    [contacttemperature,contacttemperatureUnits] = check_units((4,"degC"))

class ambient(realcontact):
    """real contact conditions"""
    description = "ambient storage conditions"
    name = "ambient"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((200,"days"))
    [contacttemperature,contacttemperatureUnits] = check_units((25,"degC"))

class transportation(realcontact):
    """hot transportation contact conditions"""
    description = "hot transportation storage conditions"
    name = "hot transportation"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((1,"month"))
    [contacttemperature,contacttemperatureUnits] = check_units((40,"degC"))

class hotambient(realcontact):
    """real contact conditions"""
    description = "hot ambient storage conditions"
    name = "hot ambient"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((2,"months"))
    [contacttemperature,contacttemperatureUnits] = check_units((40,"degC"))

class hotfilled(realcontact):
    """real contact conditions"""
    description = "hot-filling conditions"
    name = "hotfilled"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((20,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((80,"degC"))

class microwave(realcontact):
    """real contact conditions"""
    description = "microwave-oven conditions"
    name = "microwave"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((10,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((100,"degC"))

class boiling(realcontact):
    """real contact conditions"""
    description = "boiling conditions"
    name = "boiling"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((30,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((100,"degC"))

class pasteurization(realcontact):
    """real contact conditions"""
    description = "pasteurization conditions"
    name = "pasteurization"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((20,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((100,"degC"))

class sterilization(realcontact):
    """real contact conditions"""
    description = "sterilization conditions"
    name = "sterilization"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((20,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((121,"degC"))

class panfrying(realcontact):
    """real contact conditions"""
    description = "panfrying conditions"
    name = "panfrying"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((20,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((120,"degC"))


class frying(realcontact):
    """real contact conditions"""
    description = "frying conditions"
    name = "frying"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((10,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((160,"degC"))

class oven(realcontact):
    """real contact conditions"""
    description = "oven conditions"
    name = "oven"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((1,"hour"))
    [contacttemperature,contacttemperatureUnits] = check_units((180,"degC"))

class hotoven(realcontact):
    """real contact conditions"""
    description = "hot oven conditions"
    name = "hot oven"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((30,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((230,"degC"))


# %% End-User classes
# -------------------------------------------------------------------
# THIRD LEVEL CLASSES
# Theses classes correspond to real cases and can be hybridized to
# derive new classes, for instance, for a specific brand of yoghurt.
# -------------------------------------------------------------------
class stacked(setoff):
    """stacked storage"""
    name = "stacked"
    description = "storage in stacks"
    level = "user"

class rolled(setoff):
    """rolled storage"""
    name = "rolled"
    description = "storage in rolls"
    level = "user"

class isooctane(simulant, perfectlymixed, fat):
    """Isoactane food simulant"""
    _chemicalsubstance = "isooctane"
    _polarityindex = 1.0 # Very non-polar hydrocarbon. Dielectric constant ~1.9.
    name = "isooctane"
    description = "isooctane food simulant"
    level = "user"

class oliveoil(simulant, perfectlymixed, fat):
    """Isoactane food simulant"""
    _chemicalsubstance = "methyl stearate"
    _polarityindex = 1.0 # Primarily triacylglycerides; still quite non-polar, though it contains some polar headgroups (the glycerol backbone).
    name = "olive oil"
    description = "olive oil food simulant"
    level = "user"
class oil(oliveoil): pass # synonym of oliveoil

class ethanol(simulant, perfectlymixed, fat):
    """Ethanol food simulant"""
    _chemicalsubstance = "ethanol"
    _polarityindex = 5.0 # Polar protic solvent; dielectric constant ~24.5. Lower polarity than methanol.
    name = "ethanol"
    description = "ethanol = from pure ethanol down to ethanol 95%"
    level = "user"
class ethanol95(ethanol): pass # synonym of ethanol

class ethanol50(simulant, perfectlymixed, intermediate):
    """Ethanol 50% food simulant"""
    _chemicalsubstance = "ethanol"
    _polarityindex = 7.0 # Intermediate polarity between ethanol and water.
    name = "ethanol 50"
    description = "ethanol 50, food simulant of dairy products"
    level = "user"

class acetonitrile(simulant, perfectlymixed, aqueous):
    """Acetonitrile food simulant"""
    _chemicalsubstance = "acetonitrile"
    _polarityindex = 6.8 # Polar aprotic solvent; dielectric constant ~36. Comparable to methanol in some polarity rankings.
    name = "acetonitrile"
    description = "acetonitrile"
    level = "user"

class methanol(simulant, perfectlymixed, aqueous):
    """Methanol food simulant"""
    _chemicalsubstance = "methanol"
    _polarityindex = 8.1 # Polar protic, dielectric constant ~33. Highly capable of hydrogen bonding, but still less so than water.
    name = "methanol"
    description = "methanol"
    level = "user"

class water(simulant, perfectlymixed, aqueous):
    """Water food simulant"""
    _chemicalsubstance = "water"
    _polarityindex = 10.2
    name = "water"
    description = "water food simulant"
    level = "user"

class water3aceticacid(simulant, perfectlymixed, aqueous):
    """Water food simulant"""
    _chemicalsubstance = "water"
    _polarityindex = 10.0 # Essentially still dominated by water’s polarity; 3% acetic acid does not drastically lower overall polarity.
    name = "water 3% acetic acid"
    description = "water 3% acetic acid - simulant for acidic aqueous foods"
    level = "user"

class tenax(simulant, solid, fat):
    """Tenax(r) food simulant"""
    _physicalstate = "porous"    # it will be enforced if tenax is defined first (see obj.mro())
    name = "Tenax"
    description = "simulant of dry food products"
    level = "user"

class yogurt(realfood, semisolid, ethanol50):
    """Yogurt as an example of real food"""
    description = "yogurt"
    level = "user"
    [k,kUnits] = check_units((1,NoUnits))
    volume,volumeUnits = check_units((125,"mL"))

    # def __init__(self, name="no brand", volume=None, **kwargs):
    #     # Prepare a parameters dict: if a value is provided (e.g. volume), use it;
    #     # otherwise, the default (from class) is used.
    #     params = {}
    #     if volume is not None:
    #         params['volume'] = volume
    #     params['name'] = name
    #     params.update(kwargs)
    #     super().__init__(**params)

# -------------------------------------------------------------------
# Example usage (for debugging)
# -------------------------------------------------------------------
if __name__ == '__main__':
    F = foodlayer()
    E95 = ethanol()
    Y = yogurt()
    YF = yogurt(name="danone", volume=(150,"mL"))
    YF.description = "yogurt with fruits"  # You can still update the description on the instance if needed

    print("\n",repr(F),"\n"*2)
    print("\n",repr(E95),"\n"*2)
    print("\n",repr(Y),"\n"*2)
    print("\n",repr(YF),"\n"*2)

    # How to define a new food easily:
    class sandwich(realfood, solid, fat):
        name = "sandwich"
    S = sandwich()
    print("\n", repr(S))

    help_food()

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 get_defined_init_params(instance)

Returns which parameters from parametersWithUnits are defined in the instance.

Expand source code
def get_defined_init_params(instance):
    """Returns which parameters from parametersWithUnits are defined in the instance."""
    return [param for param in parametersWithUnits.keys() if hasattr(instance, param)]
def help_food()

Prints all food-related classes with relevant attributes in a formatted Markdown table.

Expand source code
def help_food():
    """
    Prints all food-related classes with relevant attributes in a **formatted Markdown table**.
    """
    derived = list_food_classes()

    # Define table headers (excluding "Level Sorting" because it's only used for sorting)
    headers = ["Class Name", "Name", "Description", "Level", "Inheritance", "Init Params"]

    # Find the maximum number of lines in any wrapped column (excluding "Level Sorting")
    max_lines_per_row = [
        max(len(value) for key, value in row.items() if key != "Level Sorting")
        for row in derived
    ]

    # Convert dictionary entries to lists and ensure they all have the same number of lines
    formatted_rows = []
    for row, max_lines in zip(derived, max_lines_per_row):
        wrapped_row = {
            key: (value if isinstance(value, list) else [value]) + [""] * (max_lines - len(value))
            for key, value in row.items() if key != "Level Sorting"  # Exclude "Level Sorting"
        }
        for i in range(max_lines):  # Transpose wrapped lines into multiple rows
            formatted_rows.append([wrapped_row[key][i] for key in headers])

    # Compute column widths dynamically
    col_widths = [max(len(str(cell)) for cell in col) for col in zip(headers, *formatted_rows)]

    # Create a formatting row template
    row_format = "| " + " | ".join(f"{{:<{w}}}" for w in col_widths) + " |"

    # Print the table header
    print(row_format.format(*headers))
    print("|-" + "-|-".join("-" * w for w in col_widths) + "-|")

    # Print all table rows
    for row in formatted_rows:
        print(row_format.format(*row))
def is_valid_classname(name)

Returns True if class name is valid (not private/internal).

Expand source code
def is_valid_classname(name):
    """Returns True if class name is valid (not private/internal)."""
    return name.isidentifier() and not name.startswith("_")  # Exclude _10, __, etc.
def list_food_classes()

Lists all classes in the 'food' module with: - name and description - level (class attribute) - Inheritance details - Parameters from parametersWithUnits that are set in the instance

Expand source code
def list_food_classes():
    """
    Lists all classes in the 'food' module with:
    - name and description
    - level (class attribute)
    - Inheritance details
    - Parameters from parametersWithUnits that are set in the instance
    """
    subclasses_info = []
    current_module = sys.modules[__name__]  # Reference to the food module

    for name, obj in inspect.getmembers(current_module, inspect.isclass):
        if obj.__module__ == current_module.__name__ and is_valid_classname(name):  # Ensure valid class name
            try:
                instance = obj()  # Try to instantiate
                init_params = get_defined_init_params(instance)
                level = getattr(obj, "level", "other")  # Default to "other" if no level is set

                class_info = {
                    "Class Name": wrap_text(name),
                    "Name": wrap_text(getattr(instance, "name", "N/A")),
                    "Description": wrap_text(getattr(instance, "description", "N/A")),
                    "Level": wrap_text(level),
                    "Inheritance": wrap_text(", ".join(base.__name__ for base in obj.__bases__)),
                    "Init Params": wrap_text(", ".join(init_params) if init_params else ""),
                    "Level Sorting": LEVEL_ORDER.get(level, 3)  # Used for sorting, not for table output
                }
                subclasses_info.append(class_info)
            except TypeError:
                class_info = {
                    "Class Name": wrap_text(name),
                    "Name": ["N/A"],
                    "Description": ["N/A"],
                    "Level": wrap_text(getattr(obj, "level", "other")),
                    "Inheritance": wrap_text(", ".join(base.__name__ for base in obj.__bases__)),
                    "Init Params": wrap_text("⚠️ Cannot instantiate"),
                    "Level Sorting": LEVEL_ORDER.get(getattr(obj, "level", "other"), 3)
                }
                subclasses_info.append(class_info)

    # **Sort first by level priority, then alphabetically within each level**
    subclasses_info.sort(key=lambda x: (x["Level Sorting"], x["Class Name"]))

    return subclasses_info
def wrap_text(text, width=20)

Wraps text within a specified width and returns a list of wrapped lines.

Expand source code
def wrap_text(text, width=20):
    """Wraps text within a specified width and returns a list of wrapped lines."""
    if not isinstance(text, str):
        return [str(text)]
    return textwrap.wrap(text, width) or [""]  # Ensure at least one line

Classes

class acetonitrile (**kwargs)

Acetonitrile food simulant

general constructor

Expand source code
class acetonitrile(simulant, perfectlymixed, aqueous):
    """Acetonitrile food simulant"""
    _chemicalsubstance = "acetonitrile"
    _polarityindex = 6.8 # Polar aprotic solvent; dielectric constant ~36. Comparable to methanol in some polarity rankings.
    name = "acetonitrile"
    description = "acetonitrile"
    level = "user"

Ancestors

Class variables

var description
var level
var name

Inherited members

class ambient (**kwargs)

real contact conditions

general constructor

Expand source code
class ambient(realcontact):
    """real contact conditions"""
    description = "ambient storage conditions"
    name = "ambient"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((200,"days"))
    [contacttemperature,contacttemperatureUnits] = check_units((25,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class aqueous (**kwargs)

Aqueous food contact

general constructor

Expand source code
class aqueous(chemicalaffinity):
    """Aqueous food contact"""
    name = "aqueous contact"
    description = "minimize mass transfer"
    [k,kUnits] = check_units((1000,NoUnits))

Ancestors

Subclasses

Class variables

var description
var k
var kUnits
var name

Inherited members

class boiling (**kwargs)

real contact conditions

general constructor

Expand source code
class boiling(realcontact):
    """real contact conditions"""
    description = "boiling conditions"
    name = "boiling"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((30,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((100,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class chemicalaffinity (**kwargs)

Parent chemical affinity class

general constructor

Expand source code
class chemicalaffinity(foodphysics):
    """Parent chemical affinity class"""
    description = "default chemical affinity"
    name = "undefined"
    level = "root"
    k = 1.0

Ancestors

Subclasses

Class variables

var description
var k
var level
var name

Inherited members

class chilled (**kwargs)

real contact conditions

general constructor

Expand source code
class chilled(realcontact):
    """real contact conditions"""
    description = "ambient storage conditions"
    name = "ambient"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((30,"days"))
    [contacttemperature,contacttemperatureUnits] = check_units((4,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class ethanol (**kwargs)

Ethanol food simulant

general constructor

Expand source code
class ethanol(simulant, perfectlymixed, fat):
    """Ethanol food simulant"""
    _chemicalsubstance = "ethanol"
    _polarityindex = 5.0 # Polar protic solvent; dielectric constant ~24.5. Lower polarity than methanol.
    name = "ethanol"
    description = "ethanol = from pure ethanol down to ethanol 95%"
    level = "user"

Ancestors

Subclasses

Class variables

var description
var level
var name

Inherited members

class ethanol50 (**kwargs)

Ethanol 50% food simulant

general constructor

Expand source code
class ethanol50(simulant, perfectlymixed, intermediate):
    """Ethanol 50% food simulant"""
    _chemicalsubstance = "ethanol"
    _polarityindex = 7.0 # Intermediate polarity between ethanol and water.
    name = "ethanol 50"
    description = "ethanol 50, food simulant of dairy products"
    level = "user"

Ancestors

Subclasses

Class variables

var description
var level
var name

Inherited members

class ethanol95 (**kwargs)

Ethanol food simulant

general constructor

Expand source code
class ethanol95(ethanol): pass # synonym of ethanol

Ancestors

Inherited members

class fat (**kwargs)

Fat contact

general constructor

Expand source code
class fat(chemicalaffinity):
    """Fat contact"""
    name = "fat contact"
    description = "maximize mass transfer"
    [k,kUnits] = check_units((1,NoUnits))

Ancestors

Subclasses

Class variables

var description
var k
var kUnits
var name

Inherited members

class foodlayer (**kwargs)

=============================================================================== SFPPy Module: Food Layer =============================================================================== foodlayer models food as a 0D layer in mass transfer simulations, serving as the primary class for defining the medium in contact with a packaging material.


Core Functionality

  • Models food as a zero-dimensional (0D) medium with:
  • A mass transfer resistance (h) at the interface.
  • A partitioning behavior (k) between food and packaging.
  • Contact time (contacttime) and temperature (contacttemperature).
  • Defines food geometry:
  • surfacearea: Contact area with the material (m²).
  • volume: Total volume of the food medium (m³).
  • Supports impervious (nofood) and periodic (setoff) conditions.

Key Properties

  • h: Mass transfer coefficient (m/s) defining resistance at the interface.
  • k: Partition coefficient describing substance solubility in food.
  • contacttime: Time duration of the packaging-food interaction.
  • contacttemperature: Temperature at the packaging interface (°C).
  • surfacearea: Contact surface area between packaging and food (m²).
  • volume: Volume of the food medium (m³).
  • density: Density of the food medium (kg/m³).
  • substance: Migrant (chemical) diffusing into food.
  • medium: Food medium in contact with packaging.
  • impervious: True if no mass transfer occurs (nofood class).
  • PBC: True if periodic boundary conditions apply (setoff class).

Methods

  • __rshift__(self, other): Propagates food properties to a packaging layer (food >> layer).
  • __matmul__(self, other): Equivalent to >>, enables food @ layer.
  • migration(self, material, **kwargs): Simulates migration into a packaging layer.
  • contact(self, material, **kwargs): Alias for migration().
  • update(self, **kwargs): Dynamically updates food properties.
  • get_param(self, key, default=None, acceptNone=True): Retrieves a parameter safely.
  • refresh(self): Ensures all properties are validated before simulation.
  • acknowledge(self, what, category): Tracks inherited properties.
  • copy(self, **kwargs): Creates a deep copy of the food object.

Integration with SFPPy Modules

  • Used as the left-side boundary in migration.py simulations.
  • Interacts with layer.py to propagate temperature and partitioning effects.
  • Interfaces with geometry.py for food-contacting packaging simulations.

Usage Example

from patankar.food import foodlayer
medium = foodlayer(name="ethanol", contacttemperature=(40, "degC"))

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

# Propagate food properties to the packaging
medium >> packaging

# Simulate migration
from patankar.migration import senspatankar
solution = senspatankar(packaging, medium)
solution.plotCF()

Notes

  • The foodlayer class extends foodphysics and provides a physical representation of food in contact with packaging.
  • Subclasses include:
  • setoff: Periodic boundary conditions (stacked packaging).
  • nofood: Impervious boundary (no mass transfer).
  • realcontact, testcontact: Standardized food contact conditions.
  • The h parameter determines if the medium is well-mixed or diffusion-limited.

general constructor

Expand source code
class foodlayer(foodphysics):
    """
    ===============================================================================
    SFPPy Module: Food Layer
    ===============================================================================
    `foodlayer` models food as a **0D layer** in mass transfer simulations, serving
    as the primary class for defining the medium in contact with a packaging material.

    ------------------------------------------------------------------------------
    **Core Functionality**
    ------------------------------------------------------------------------------
    - Models food as a **zero-dimensional (0D) medium** with:
      - A **mass transfer resistance (`h`)** at the interface.
      - A **partitioning behavior (`k`)** between food and packaging.
      - **Contact time (`contacttime`) and temperature (`contacttemperature`)**.
    - Defines **food geometry**:
      - `surfacearea`: Contact area with the material (m²).
      - `volume`: Total volume of the food medium (m³).
    - Supports **impervious (`nofood`) and periodic (`setoff`) conditions**.

    ------------------------------------------------------------------------------
    **Key Properties**
    ------------------------------------------------------------------------------
    - `h`: Mass transfer coefficient (m/s) defining resistance at the interface.
    - `k`: Partition coefficient describing substance solubility in food.
    - `contacttime`: Time duration of the packaging-food interaction.
    - `contacttemperature`: Temperature at the packaging interface (°C).
    - `surfacearea`: Contact surface area between packaging and food (m²).
    - `volume`: Volume of the food medium (m³).
    - `density`: Density of the food medium (kg/m³).
    - `substance`: Migrant (chemical) diffusing into food.
    - `medium`: Food medium in contact with packaging.
    - `impervious`: `True` if no mass transfer occurs (`nofood` class).
    - `PBC`: `True` if periodic boundary conditions apply (`setoff` class).

    ------------------------------------------------------------------------------
    **Methods**
    ------------------------------------------------------------------------------
    - `__rshift__(self, other)`: Propagates food properties to a packaging layer (`food >> layer`).
    - `__matmul__(self, other)`: Equivalent to `>>`, enables `food @ layer`.
    - `migration(self, material, **kwargs)`: Simulates migration into a packaging layer.
    - `contact(self, material, **kwargs)`: Alias for `migration()`.
    - `update(self, **kwargs)`: Dynamically updates food properties.
    - `get_param(self, key, default=None, acceptNone=True)`: Retrieves a parameter safely.
    - `refresh(self)`: Ensures all properties are validated before simulation.
    - `acknowledge(self, what, category)`: Tracks inherited properties.
    - `copy(self, **kwargs)`: Creates a deep copy of the food object.

    ------------------------------------------------------------------------------
    **Integration with SFPPy Modules**
    ------------------------------------------------------------------------------
    - Used as the **left-side boundary** in `migration.py` simulations.
    - Interacts with `layer.py` to propagate temperature and partitioning effects.
    - Interfaces with `geometry.py` for food-contacting packaging simulations.

    ------------------------------------------------------------------------------
    **Usage Example**
    ------------------------------------------------------------------------------
    ```python
    from patankar.food import foodlayer
    medium = foodlayer(name="ethanol", contacttemperature=(40, "degC"))

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

    # Propagate food properties to the packaging
    medium >> packaging

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

    ------------------------------------------------------------------------------
    **Notes**
    ------------------------------------------------------------------------------
    - The `foodlayer` class extends `foodphysics` and provides a physical
      representation of food in contact with packaging.
    - Subclasses include:
      - `setoff`: Periodic boundary conditions (stacked packaging).
      - `nofood`: Impervious boundary (no mass transfer).
      - `realcontact`, `testcontact`: Standardized food contact conditions.
    - The `h` parameter determines if the medium is **well-mixed** or **diffusion-limited**.

    """
    level = "root"
    description = "root food class"  # Remains as class attribute
    name = "generic food layer"
    # -----------------------------------------------------------------------------
    # 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.
    # 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)
    # -----------------------------------------------------------------------------
    # These properties are essential for model predictions, they cannot be customized
    # beyond the rules accepted by the model predictors (they are not metadata)
    # note: similar attributes exist for patanaker.layer objects (similar possible values)
    _physicalstate = "liquid"   # solid, liquid (default), gas, porous
    _chemicalclass = "other"    # polymer, other (default)
    _chemicalsubstance = None   # None (default), monomer for polymers
    _polarityindex = 0.0        # polarity index (roughly: 0=hexane, 10=water)
    # -----------------------------------------------------------------------------
    # Class attributes duplicated as instance parameters
    # -----------------------------------------------------------------------------
    volume,volumeUnits = check_units((1,"dm**3"))
    surfacearea,surfaceareaUnits = check_units((6,"dm**2"))
    density,densityUnits = check_units((1000,"kg/m**3"))
    CF0,CF0units = check_units((0,NoUnits))  # initial concentration (arbitrary units)
    contacttime, contacttime_units = check_units((10,"days"))
    contactemperature,contactemperatureUnits = check_units((40,"degC"),ExpectedUnits="degC") # temperature in °C
    _substance = None # substance container / similar construction in pantankar.layer = migrant
    _k0model = None
    # -----------------------------------------------------------------------------
    # Getter methods for class/instance properties: same definitions as in patankar.layer (mandatory)
    # medium properties
    # -----------------------------------------------------------------------------
    # PHASE PROPERTIES  (attention chemicalsubstance=F substance, substance=i substance)
    @property
    def physicalstate(self): return self._physicalstate
    @property
    def chemicalclass(self): return self._chemicalclass
    @property
    def chemicalsubstance(self): return self._chemicalsubstance
    @property
    def simulant(self): return self._chemicalsubstance # simulant is an alias of chemicalsubstance
    @property
    def polarityindex(self): return self._polarityindex
    @property
    def ispolymer(self): return self.physicalstate == "polymer"
    @property
    def issolid(self): return self.solid == "solid"
    # SUBSTANCE/SOLUTE/MIGRANT properties  (attention chemicalsubstance=F substance, substance=i substance)
    @property
    def substance(self): return self._substance # substance can be ambiguous
    @property
    def migrant(self): return self.substance    # synonym
    @property
    def solute(self): return self.substance     # synonym

    # -----------------------------------------------------------------------------
    # Setter methods for class/instance properties: same definitions as in patankar.layer (mandatory)
    # -----------------------------------------------------------------------------
    # PHASE PROPERTIES  (attention chemicalsubstance=F substance, substance=i substance)
    @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
    @simulant.setter
    def simulant(self,value):
        self.chemicalsubstance = value # simulant is an alias of chemicalcalsubstance
    @polarityindex.setter
    def polarityindex(self,value):
        if not isinstance(value,(float,int)):
            raise ValueError("polarity index must be float not a {type(value).__name__}")
        # rescaled to match predictions - standard scale [0,10.2] - predicted scale [0,7.12]
        return self._polarityindex * migrant("water").polarityindex/10.2
    # SUBSTANCE/SOLUTE/MIGRANT properties  (attention chemicalsubstance=F substance, substance=i substance)
    @substance.setter
    def substance(self,value):
        if isinstance(value,str):
            value = migrant(value)
        if not isinstance(value,migrant):
            raise TypeError(f"substance/migrant/solute must be a migrant not a {type(value).__name__}")
        self._substance = value
    @migrant.setter
    def migrant(self,value):
        self.substance = value
    @solute.setter
    def solute(self,value):
        self.substance = value
    # -----------------------------------------------------------------------------
    # Henry-like coefficient k and its alias k0 (internal use)
    # -----------------------------------------------------------------------------
    #   - k is the name of the Henry-like property for food (as set and seen by the user)
    #   - k0 is the property operated by migration
    #   - k0 = k except if kmodel (lambda function) does not returns None
    #   - kmodel returns None if _substance is not set (proper migrant)
    #   - kmodel = None will override any existing kmodel
    #   - kmodel must be intialized to "default" to refresh its definition with self
    # note: The implementation is almost symmetric with kmodel in patankar.layer.
    # The main difference are:
    #    - food classes are instantiated by foodphysics
    #    - k is used to store the value of k0 (not _k or _k0)
    # -----------------------------------------------------------------------------
    # layer substance (of class migrant or None)
    # k0 and k0units (k and kunits are user inputs)
    @property
    def k0(self):
        ktmp = None
        if self.kmodel == "default": # default behavior
            ktmp = self._compute_kmodel()
        elif callable(self.kmodel): # user override (not the default function)
            ktmp = self.kmodel()
        if ktmp:
            return np.full_like(self.k, ktmp,dtype=np.float64)
        return self.k
    @k0.setter
    def k0(self,value):
        if not isinstance(value,(int,float,np.ndarray)):
            TypeError("k0 must be int, float or np.ndarray")
        if isinstance(self.k,int): self.k = float(self.k)
        self.k = np.full_like(self.k,value,dtype=np.float64)
    @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 or self.chemicalsubstance 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 = False)
        def func(**kwargs):
            if self.chemicalsubstance:
                simulant = migrant(self.chemicalsubstance)
                template.update(Pk = simulant.polarityindex,
                                Vk = simulant.molarvolumeMiller)
                k = self._substance.k.evaluate(**dict(template, **kwargs))
                return k
            else:
                self.k
        return func # we return a callable function not a value

Ancestors

Subclasses

Class variables

var CF0
var CF0units
var contactemperature
var contactemperatureUnits
var contacttime
var contacttime_units
var density
var densityUnits
var description
var level
var name
var surfacearea
var surfaceareaUnits
var volume
var volumeUnits

Instance variables

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 ispolymer
Expand source code
@property
def ispolymer(self): return self.physicalstate == "polymer"
var issolid
Expand source code
@property
def issolid(self): return self.solid == "solid"
var k0
Expand source code
@property
def k0(self):
    ktmp = None
    if self.kmodel == "default": # default behavior
        ktmp = self._compute_kmodel()
    elif callable(self.kmodel): # user override (not the default function)
        ktmp = self.kmodel()
    if ktmp:
        return np.full_like(self.k, ktmp,dtype=np.float64)
    return self.k
var kmodel
Expand source code
@property
def kmodel(self):
    return self._kmodel
var migrant
Expand source code
@property
def migrant(self): return self.substance    # synonym
var physicalstate
Expand source code
@property
def physicalstate(self): return self._physicalstate
var polarityindex
Expand source code
@property
def polarityindex(self): return self._polarityindex
var simulant
Expand source code
@property
def simulant(self): return self._chemicalsubstance # simulant is an alias of chemicalsubstance
var solute
Expand source code
@property
def solute(self): return self.substance     # synonym
var substance
Expand source code
@property
def substance(self): return self._substance # substance can be ambiguous

Inherited members

class foodphysics (**kwargs)

=============================================================================== SFPPy Module: Food Physics (Base Class) =============================================================================== foodphysics serves as the base class for all food-related objects in mass transfer simulations. It defines key parameters for food interaction with packaging materials and implements dynamic property propagation for simulation models.


Core Functionality

  • Defines mass transfer properties:
  • h: Mass transfer coefficient (m/s)
  • k: Partition coefficient (dimensionless)
  • Implements contact conditions:
  • contacttime: Duration of food-packaging contact
  • contacttemperature: Temperature of the contact interface
  • Supports inheritance and property propagation to layers.
  • Provides physical state representation (solid, liquid, gas).
  • Allows customization of mass transfer coefficients via kmodel.

Key Properties

  • h: Mass transfer coefficient (m/s) defining resistance at the interface.
  • k: Henry-like partition coefficient between the food and the material.
  • contacttime: Time duration of the packaging-food interaction.
  • contacttemperature: Temperature at the packaging interface (°C).
  • surfacearea: Contact surface area between packaging and food (m²).
  • volume: Volume of the food medium (m³).
  • density: Density of the food medium (kg/m³).
  • substance: The migrating substance (e.g., a chemical compound).
  • medium: The food medium in contact with packaging.
  • kmodel: Custom partitioning model (can be overridden by the user).

Methods

  • __rshift__(self, other): Propagates food properties to a layer (food >> layer).
  • __matmul__(self, other): Equivalent to >>, enables food @ layer.
  • migration(self, material, **kwargs): Simulates migration into a packaging layer.
  • contact(self, material, **kwargs): Alias for migration().
  • update(self, **kwargs): Dynamically updates food properties.
  • get_param(self, key, default=None, acceptNone=True): Retrieves a parameter safely.
  • refresh(self): Ensures all properties are validated before simulation.
  • acknowledge(self, what, category): Tracks inherited properties.
  • copy(self, **kwargs): Creates a deep copy of the food object.

Integration with SFPPy Modules

  • Works with migration.py to define the left-side boundary condition.
  • Interfaces with layer.py to apply contact temperature propagation.
  • Connects with geometry.py for food-contacting packaging surfaces.

Usage Example

from patankar.food import foodphysics
from patankar.layer import layer

medium = foodphysics(contacttemperature=(40, "degC"), h=(1e-6, "m/s"))
packaging_layer = layer(D=1e-14, l=50e-6)

# Propagate food properties to the layer
medium >> packaging_layer

# Simulate migration
from patankar.migration import senspatankar
solution = senspatankar(packaging_layer, medium)
solution.plotCF()

Notes

general constructor

Expand source code
class foodphysics:
    """
    ===============================================================================
    SFPPy Module: Food Physics (Base Class)
    ===============================================================================
    `foodphysics` serves as the **base class** for all food-related objects in mass
    transfer simulations. It defines key parameters for food interaction with packaging
    materials and implements dynamic property propagation for simulation models.

    ------------------------------------------------------------------------------
    **Core Functionality**
    ------------------------------------------------------------------------------
    - Defines **mass transfer properties**:
      - `h`: Mass transfer coefficient (m/s)
      - `k`: Partition coefficient (dimensionless)
    - Implements **contact conditions**:
      - `contacttime`: Duration of food-packaging contact
      - `contacttemperature`: Temperature of the contact interface
    - Supports **inheritance and property propagation** to layers.
    - Provides **physical state representation** (`solid`, `liquid`, `gas`).
    - Allows **customization of mass transfer coefficients** via `kmodel`.

    ------------------------------------------------------------------------------
    **Key Properties**
    ------------------------------------------------------------------------------
    - `h`: Mass transfer coefficient (m/s) defining resistance at the interface.
    - `k`: Henry-like partition coefficient between the food and the material.
    - `contacttime`: Time duration of the packaging-food interaction.
    - `contacttemperature`: Temperature at the packaging interface (°C).
    - `surfacearea`: Contact surface area between packaging and food (m²).
    - `volume`: Volume of the food medium (m³).
    - `density`: Density of the food medium (kg/m³).
    - `substance`: The migrating substance (e.g., a chemical compound).
    - `medium`: The food medium in contact with packaging.
    - `kmodel`: Custom partitioning model (can be overridden by the user).

    ------------------------------------------------------------------------------
    **Methods**
    ------------------------------------------------------------------------------
    - `__rshift__(self, other)`: Propagates food properties to a layer (`food >> layer`).
    - `__matmul__(self, other)`: Equivalent to `>>`, enables `food @ layer`.
    - `migration(self, material, **kwargs)`: Simulates migration into a packaging layer.
    - `contact(self, material, **kwargs)`: Alias for `migration()`.
    - `update(self, **kwargs)`: Dynamically updates food properties.
    - `get_param(self, key, default=None, acceptNone=True)`: Retrieves a parameter safely.
    - `refresh(self)`: Ensures all properties are validated before simulation.
    - `acknowledge(self, what, category)`: Tracks inherited properties.
    - `copy(self, **kwargs)`: Creates a deep copy of the food object.

    ------------------------------------------------------------------------------
    **Integration with SFPPy Modules**
    ------------------------------------------------------------------------------
    - Works with `migration.py` to define the **left-side boundary condition**.
    - Interfaces with `layer.py` to apply contact temperature propagation.
    - Connects with `geometry.py` for food-contacting packaging surfaces.

    ------------------------------------------------------------------------------
    **Usage Example**
    ------------------------------------------------------------------------------
    ```python
    from patankar.food import foodphysics
    from patankar.layer import layer

    medium = foodphysics(contacttemperature=(40, "degC"), h=(1e-6, "m/s"))
    packaging_layer = layer(D=1e-14, l=50e-6)

    # Propagate food properties to the layer
    medium >> packaging_layer

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

    ------------------------------------------------------------------------------
    **Notes**
    ------------------------------------------------------------------------------
    - The `foodphysics` class is the parent of `foodlayer`, `nofood`, `setoff`,
      `realcontact`, and `testcontact`.
    - The `PBC` property identifies periodic boundary conditions (used in `setoff`).
    - This class provides **dynamic inheritance** for mass transfer properties.

    """

    # General descriptors
    description = "Root physics class used to implement food and mass transfer physics"  # Remains as class attribute
    name = "food physics"
    level = "base"

    # Low-level prediction properties (F=contact medium, i=solute/migrant)
    # these @properties are defined by foodlayer, they should be duplicated
    _lowLevelPredictionPropertyList = [
        "chemicalsubstance","simulant","polarityindex","ispolymer","issolid", # F: common with patankar.layer
        "physicalstate","chemicalclass", # phase F properties
        "substance","migrant","solute", # i properties with synonyms substance=migrant=solute
        # users use "k", but internally we use k0, keep _kmodel in the instance
        "k0","k0unit","kmodel","_compute_kmodel" # Henry-like coefficients returned as properties with possible user override with medium.k0model=None or a function
        ]

    # ------------------------------------------------------
    # Transfer rules for food1 >> food2 and food1 >> result
    # ------------------------------------------------------

    # Mapping of properties to their respective categories
    _list_categories = {
        "contacttemperature": "contact",
        "contacttime": "contact",
        "surfacearea": "geometry",
        "volume": "geometry",
        "substance": "substance",
        "medium": "medium"
    }

    # Rules for property transfer wtih >> or @ based on object type
    # ["property name"]["name of the destination class"][attr]
    #   - if onlyifinherited, only inherited values are transferred
    #   - if checkNmPy, the value will be transferred as a np.ndarray
    #   - name is the name of the property in the destination class (use "" to keep the same name)
    #   - prototype is the class itself (available only after instantiation, keep None here)
    _transferable_properties = {
        "contacttemperature": {
            "foodphysics": {
                "onlyifinherited": True,
                "checkNumPy": False,
                "as": "",
                "prototype": None,
            },
            "layer": {
                "onlyifinherited": False,
                "checkNumPy": True,
                "as": "T",
                "prototype": None
            }
        },
        "contacttime": {
            "foodphysics": {
                "onlyifinherited": True,
                "checkNumPy": True,
                "as": "",
                "prototype": None,
            },
            "SensPatankarResult": {
                "onlyifinherited": False,
                "checkNumPy": True,
                "as": "t",
                "prototype": None
            }
        },
        "surfacearea": {
            "foodphysics": {
                "onlyifinherited": False,
                "checkNumPy": False,
                "as": "surfacearea",
                "prototype": None
            }
        },
        "volume": {
            "foodphysics": {
                "onlyifinherited": False,
                "checkNumPy": True,
                "as": "",
                "prototype": None
            }
        },
        "substance": {
            "foodlayer": {
                "onlyifinherited": False,
                "checkNumPy": False,
                "as": "",
                "prototype": None,
            },
            "layer": {
                "onlyifinherited": False,
                "checkNumPy": False,
                "as": "",
                "prototype": None
            }
        },
        "medium": {
            "layer": {
                "onlyifinherited": False,
                "checkNumPy": False,
                "as": "",
                "prototype": None
            }
        },
    }


    def __init__(self, **kwargs):
        """general constructor"""

        # local import
        from patankar.migration import SensPatankarResult

        # numeric validator
        def numvalidator(key,value):
            if key in parametersWithUnits:          # the parameter is a physical quantity
                if isinstance(value,tuple):         # the supplied value as unit
                    value,_ = check_units(value)    # we convert to SI, we drop the units
                if not isinstance(value,np.ndarray):
                    value = np.array([value])       # we force NumPy class
            return value

        # Iterate through the MRO (excluding foodphysics and object)
        for cls in reversed(self.__class__.__mro__):
            if cls in (foodphysics, object):
                continue
            # For each attribute defined at the class level,
            # if it is not 'description', not callable, and not a dunder, set it as an instance attribute.
            for key, value in cls.__dict__.items(): # we loop on class attributes
                if key in ("description","level") or key in self._lowLevelPredictionPropertyList or key.startswith("__") or key.startswith("_") or callable(value):
                    continue
                if key not in kwargs:
                    setattr(self, key, numvalidator(key,value))
        # Now update/override with any keyword arguments provided at instantiation.
        for key, value in kwargs.items():
            value = numvalidator(key,value)
            if key not in paramaterNamesWithUnits: # we protect the values of units (they are SI, they cannot be changed)
                setattr(self, key, value)
        # we initialize the acknowlegment process for future property propagation
        self._hasbeeninherited = {}
        # we initialize _kmodel if _compute_kmodel exists
        if hasattr(self,"_compute_kmodel"):
            self._kmodel = "default" # do not initialize at self._compute_kmodel (default forces refresh)
        # we initialize the _simstate storing the last simulation result available
        self._simstate = None # simulation results
        self._inpstate = None # their inputs
        # For cooperative multiple inheritance, call the next __init__ if it exists.
        super().__init__()
        # Define actual class references to avoid circular dependency issues
        if self.__class__._transferable_properties["contacttemperature"]["foodphysics"]["prototype"] is None:
            self.__class__._transferable_properties["contacttemperature"]["foodphysics"]["prototype"] = foodphysics
            self.__class__._transferable_properties["contacttemperature"]["layer"]["prototype"] = layer
            self.__class__._transferable_properties["contacttime"]["foodphysics"]["prototype"] = foodphysics
            self.__class__._transferable_properties["contacttime"]["SensPatankarResult"]["prototype"] = SensPatankarResult
            self.__class__._transferable_properties["surfacearea"]["foodphysics"]["prototype"] = foodphysics
            self.__class__._transferable_properties["volume"]["foodphysics"]["prototype"] = foodphysics
            self.__class__._transferable_properties["substance"]["foodlayer"]["prototype"] = migrant
            self.__class__._transferable_properties["substance"]["layer"]["prototype"] = layer
            self.__class__._transferable_properties["medium"]["layer"]["prototype"] = layer

    # ------- [properties to access/modify simstate] --------
    @property
    def lastinput(self):
        """Getter for last layer input."""
        return self._inpstate

    @lastinput.setter
    def lastinput(self, value):
        """Setter for last layer input."""
        self._inpstate = value

    @property
    def lastsimulation(self):
        """Getter for last simulation results."""
        return self._simstate

    @lastsimulation.setter
    def lastsimulation(self, value):
        """Setter for last simulation results."""
        self._simstate = value

    @property
    def hassimulation(self):
        """Returns True if a simulation exists"""
        return self.lastsimulation is not None


    # ------- [inheritance registration mechanism] --------
    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.

        Example:
        --------
        >>> b = B()
        >>> b.acknowledge(what="volume", category="geometry")
        >>> b.acknowledge(what=["surfacearea", "diameter"], category="geometry")
        >>> print(b._hasbeeninherited)
        {'geometry': {'volume', 'surfacearea', 'diameter'}}
        """
        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 refresh(self):
        """refresh all physcal paramaters after instantiation"""
        for key, value in self.__dict__.items():    # we loop on instance attributes
            if key in parametersWithUnits:          # the parameter is a physical quantity
                if isinstance(value,tuple):         # the supplied value as unit
                    value = check_units(value)[0]   # we convert to SI, we drop the units
                    setattr(self,key,value)
                if not isinstance(value,np.ndarray):
                    value = np.array([value])      # we force NumPy class
                    setattr(self,key,value)

    def update(self, **kwargs):
        """
        Update modifiable parameters of the foodphysics object.

        Modifiable Parameters:
            - name (str): New name for the object.
            - description (str): New description.
            - volume (float or tuple): Volume (can be tuple like (1, "L")).
            - surfacearea (float or tuple): Surface area (can be tuple like (1, "cm^2")).
            - density (float or tuple): Density (can be tuple like (1000, "kg/m^3")).
            - CF0 (float or tuple): Initial concentration in the food.
            - contacttime (float or tuple): Contact time (can be tuple like (1, "h")).
            - contacttemperature (float or tuple): Temperature (can be tuple like (25, "degC")).
            - h (float or tuple): Mass transfer coefficient (can be tuple like (1e-6,"m/s")).
            - k (float or tuple): Henry-like coefficient for the food (can be tuple like (1,"a.u.")).

        """
        if not kwargs:  # shortcut
            return self # for chaining
        def checkunits(value):
            """Helper function to convert physical quantities to SI."""
            if isinstance(value, tuple) and len(value) == 2:
                scale = check_units(value)[0]  # Convert to SI, drop unit
                return np.array([scale], dtype=float)  # Ensure NumPy array
            elif isinstance(value, (int, float, np.ndarray)):
                return np.array([value], dtype=float)  # Ensure NumPy array
            else:
                raise ValueError(f"Invalid value for physical quantity: {value}")
        # Update `name` and `description` if provided
        if "name" in kwargs:
            self.name = str(kwargs["name"])
        if "description" in kwargs:
            self.description = str(kwargs["description"])
        # Update physical properties
        for key in parametersWithUnits.keys():
            if key in kwargs:
                value = kwargs[key]
                setattr(self, key, checkunits(value))  # Ensure NumPy array in SI
        # Update medium, migrant (they accept aliases)
        lex = {
            "substance": ("substance", "migrant", "chemical", "solute"),
            "medium": ("medium", "simulant", "food", "contact"),
        }
        used_aliases = {}
        def get_value(canonical_key):
            """Find the correct alias in kwargs and return its value, or None if not found."""
            found_key = None
            for alias in lex.get(canonical_key, ()):  # Get aliases, default to empty tuple
                if alias in kwargs:
                    if alias in used_aliases:
                        raise ValueError(f"Alias '{alias}' is used for multiple canonical keys!")
                    found_key = alias
                    used_aliases[alias] = canonical_key
                    break  # Stop at the first match
            return kwargs.get(found_key, None)  # Return value if found, else None
        # Assign values only if found in kwargs
        new_substance = get_value("substance")
        new_medium = get_value("medium")
        if new_substance is not None: self.substance = new_substance
        if new_medium is not None:self.medium = new_medium
        # return
        return self  # Return self for method chaining if needed

    def get_param(self, key, default=None, acceptNone=True):
        """Retrieve instance attribute with a default fallback if enabled."""
        paramdefaultvalue = 1
        if isinstance(self,(setoff,nofood)):
            if key in parametersWithUnits_andfallback:
                value =  self.__dict__.get(key, paramdefaultvalue) if default is None else self.__dict__.get(key, default)
                if isinstance(value,np.ndarray):
                    value = value.item()
                if value is None and not acceptNone:
                    value = paramdefaultvalue if default is None else default
                return np.array([value])
            if key in paramaterNamesWithUnits:
                return self.__dict__.get(key, parametersWithUnits[key]) if default is None else self.__dict__.get(key, default)
        if key in parametersWithUnits:
            if hasattr(self, key):
                return getattr(self,key)
            else:
                raise KeyError(
                    f"Missing property: '{key}' in instance of class '{self.__class__.__name__}'.\n"
                    f"To define it, use one of the following methods:\n"
                    f"  - Direct assignment:   object.{key} = value\n"
                    f"  - Using update method: object.update({key}=value)\n"
                    f"Note: The value can also be provided as a tuple (value, 'unit')."
                )
        elif key in paramaterNamesWithUnits:
            return self.__dict__.get(key, paramaterNamesWithUnits[key]) if default is None else self.__dict__.get(key, default)
        raise KeyError(f'Use getattr("{key}") to retrieve the value of {key}')

    def __repr__(self):
        """Formatted string representation of the FOODlayer object."""
        # Refresh all definitions
        self.refresh()
        # Header with name and description
        repr_str = f'Food object "{self.name}" ({self.description}) with properties:\n'
        # Helper function to extract a numerical value safely
        def format_value(value):
            """Ensure the value is a float or a single-item NumPy array."""
            if isinstance(value, np.ndarray):
                return value.item() if value.size == 1 else value[0]  # Ensure scalar representation
            elif value is None:
                return value
            return float(value)
        # Collect defined properties and their formatted values
        properties = []
        excluded = ("k") if self.haskmodel else ("k0")
        for key, unit in parametersWithUnits.items():
            if hasattr(self, key) and key not in excluded:  # Include only defined parameters
                value = format_value(getattr(self, key))
                unit_str = self.get_param(f"{key}Units", unit)  # Retrieve unit safely
                if value is not None:
                    properties.append((key, f"{value:0.8g}", unit_str))

        # Sort properties alphabetically
        properties.sort(key=lambda x: x[0])

        # Determine max width for right-aligned names
        max_key_length = max(len(key) for key, _, _ in properties) if properties else 0
        # Construct formatted property list
        for key, value, unit_str in properties:
            repr_str += f"{key.rjust(max_key_length)}: {value} [{unit_str}]\n"
            if key == "k0":
                extra_info = f"{self._substance.k.__name__}(<{self.chemicalsubstance}>,{self._substance})"
                repr_str += f"{' ' * (max_key_length)}= {extra_info}\n"
        print(repr_str.strip())  # Print formatted output
        return str(self)  # Simplified representation for repr()



    def __str__(self):
        """Formatted string representation of the property"""
        simstr = ' [simulated]' if self.hassimulation else ""
        return f"<{self.__class__.__name__}: {self.name}>{simstr}"

    def copy(self,**kwargs):
        """Creates a deep copy of the current food instance."""
        return duplicate(self).update(**kwargs)


    @property
    def PBC(self):
        """
        Returns True if h is not defined or None
        This property is used to identified periodic boundary condition also called setoff mass transfer.

        """
        if not hasattr(self,"h"):
            return False # None
        htmp = getattr(self,"h")
        if isinstance(htmp,np.ndarray):
            htmp = htmp.item()
        return htmp is None

    @property
    def hassubstance(self):
        """Returns True if substance is defined (class migrant)"""
        if not hasattr(self, "_substance"):
            return False
        return isinstance(self._substance,migrant)



    # --------------------------------------------------------------------
    # For convenience, several operators have been overloaded
    #   medium >> packaging      # sets the volume and the surfacearea
    #   medium >> material       # propgates the contact temperature from the medium to the material
    #   sol = medium << material # simulate migration from the material to the medium
    # --------------------------------------------------------------------

    # method: medium._to(material) and its associated operator >>
    def _to(self, other = None):
        """
        Transfers inherited properties to another object based on predefined rules.

        Parameters:
        -----------
        other : object
            The recipient object that will receive the transferred properties.

        Notes:
        ------
        - Only properties listed in `_transferable_properties` are transferred.
        - A property can only be transferred if `other` matches the expected class.
        - The property may have a different name in `other` as defined in `as`.
        - If `onlyifinherited` is True, the property must have been inherited by `self`.
        - If `checkNumPy` is True, ensures NumPy array compatibility.
        - Updates `other`'s `_hasbeeninherited` tracking.
        """
        for prop, classes in self._transferable_properties.items():
            if prop not in self._list_categories:
                continue  # Skip properties not categorized

            category = self._list_categories[prop]

            for class_name, rules in classes.items():

                if not isinstance(other, rules["prototype"]):
                    continue  # Skip if other is not an instance of the expected prototype class

                if rules["onlyifinherited"] and category not in self._hasbeeninherited:
                    continue  # Skip if property must be inherited but is not

                if rules["onlyifinherited"] and prop not in self._hasbeeninherited[category]:
                    continue  # Skip if the specific property has not been inherited

                if not hasattr(self, prop):
                    continue  # Skip if the property does not exist on self

                # Determine the target attribute name in other
                target_attr = rules["as"] if rules["as"] else prop

                # Retrieve the property value
                value = getattr(self, prop)

                # Handle NumPy array check
                if rules["checkNumPy"] and hasattr(other, target_attr):
                    existing_value = getattr(other, target_attr)
                    if isinstance(existing_value, np.ndarray):
                        value = np.full(existing_value.shape, value)

                # Assign the value to other
                setattr(other, target_attr, value)

                # Register the transfer in other’s inheritance tracking
                other.acknowledge(what=target_attr, category=category)

                # to chain >>
                return other

    def __rshift__(self, other):
        """Overloads >> to propagate to other."""
        # inherit substance/migrant from other if self.migrant is None
        if isinstance(other,(layer,foodlayer)):
            if isinstance(self,foodlayer):
                if self.substance is None and other.substance is not None:
                    self.substance = other.substance
        return self._to(other) # propagates

    def __matmul__(self, other):
        """Overload @: equivalent to >> if other is a layer."""
        if not isinstance(other, layer):
            raise TypeError(f"Right operand must be a layer not a {type(other).__name__}")
        return self._to(other)

    # migration method
    def migration(self,material,**kwargs):
        """interface to simulation engine: senspantankar"""
        from patankar.migration import senspatankar
        self._to(material) # propagate contact conditions first
        sim = senspatankar(material,self,**kwargs)
        self.lastsimulation = sim # store the last simulation result in medium
        self.lastinput = material # store the last input (material)
        sim.savestate(material,self) # store store the inputs in sim for chaining
        return sim

    def contact(self,material,**kwargs):
        """alias to migration method"""
        return self.migration(self,material,**kwargs)

    @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

Subclasses

Class variables

var description
var level
var name

Instance variables

var PBC

Returns True if h is not defined or None This property is used to identified periodic boundary condition also called setoff mass transfer.

Expand source code
@property
def PBC(self):
    """
    Returns True if h is not defined or None
    This property is used to identified periodic boundary condition also called setoff mass transfer.

    """
    if not hasattr(self,"h"):
        return False # None
    htmp = getattr(self,"h")
    if isinstance(htmp,np.ndarray):
        htmp = htmp.item()
    return htmp is 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
var hassimulation

Returns True if a simulation exists

Expand source code
@property
def hassimulation(self):
    """Returns True if a simulation exists"""
    return self.lastsimulation is not None
var hassubstance

Returns True if substance is defined (class migrant)

Expand source code
@property
def hassubstance(self):
    """Returns True if substance is defined (class migrant)"""
    if not hasattr(self, "_substance"):
        return False
    return isinstance(self._substance,migrant)
var lastinput

Getter for last layer input.

Expand source code
@property
def lastinput(self):
    """Getter for last layer input."""
    return self._inpstate
var lastsimulation

Getter for last simulation results.

Expand source code
@property
def lastsimulation(self):
    """Getter for last simulation results."""
    return self._simstate

Methods

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.

Example:

>>> b = B()
>>> b.acknowledge(what="volume", category="geometry")
>>> b.acknowledge(what=["surfacearea", "diameter"], category="geometry")
>>> print(b._hasbeeninherited)
{'geometry': {'volume', 'surfacearea', 'diameter'}}
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.

    Example:
    --------
    >>> b = B()
    >>> b.acknowledge(what="volume", category="geometry")
    >>> b.acknowledge(what=["surfacearea", "diameter"], category="geometry")
    >>> print(b._hasbeeninherited)
    {'geometry': {'volume', 'surfacearea', 'diameter'}}
    """
    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 contact(self, material, **kwargs)

alias to migration method

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

Creates a deep copy of the current food instance.

Expand source code
def copy(self,**kwargs):
    """Creates a deep copy of the current food instance."""
    return duplicate(self).update(**kwargs)
def get_param(self, key, default=None, acceptNone=True)

Retrieve instance attribute with a default fallback if enabled.

Expand source code
def get_param(self, key, default=None, acceptNone=True):
    """Retrieve instance attribute with a default fallback if enabled."""
    paramdefaultvalue = 1
    if isinstance(self,(setoff,nofood)):
        if key in parametersWithUnits_andfallback:
            value =  self.__dict__.get(key, paramdefaultvalue) if default is None else self.__dict__.get(key, default)
            if isinstance(value,np.ndarray):
                value = value.item()
            if value is None and not acceptNone:
                value = paramdefaultvalue if default is None else default
            return np.array([value])
        if key in paramaterNamesWithUnits:
            return self.__dict__.get(key, parametersWithUnits[key]) if default is None else self.__dict__.get(key, default)
    if key in parametersWithUnits:
        if hasattr(self, key):
            return getattr(self,key)
        else:
            raise KeyError(
                f"Missing property: '{key}' in instance of class '{self.__class__.__name__}'.\n"
                f"To define it, use one of the following methods:\n"
                f"  - Direct assignment:   object.{key} = value\n"
                f"  - Using update method: object.update({key}=value)\n"
                f"Note: The value can also be provided as a tuple (value, 'unit')."
            )
    elif key in paramaterNamesWithUnits:
        return self.__dict__.get(key, paramaterNamesWithUnits[key]) if default is None else self.__dict__.get(key, default)
    raise KeyError(f'Use getattr("{key}") to retrieve the value of {key}')
def migration(self, material, **kwargs)

interface to simulation engine: senspantankar

Expand source code
def migration(self,material,**kwargs):
    """interface to simulation engine: senspantankar"""
    from patankar.migration import senspatankar
    self._to(material) # propagate contact conditions first
    sim = senspatankar(material,self,**kwargs)
    self.lastsimulation = sim # store the last simulation result in medium
    self.lastinput = material # store the last input (material)
    sim.savestate(material,self) # store store the inputs in sim for chaining
    return sim
def refresh(self)

refresh all physcal paramaters after instantiation

Expand source code
def refresh(self):
    """refresh all physcal paramaters after instantiation"""
    for key, value in self.__dict__.items():    # we loop on instance attributes
        if key in parametersWithUnits:          # the parameter is a physical quantity
            if isinstance(value,tuple):         # the supplied value as unit
                value = check_units(value)[0]   # we convert to SI, we drop the units
                setattr(self,key,value)
            if not isinstance(value,np.ndarray):
                value = np.array([value])      # we force NumPy class
                setattr(self,key,value)
def update(self, **kwargs)

Update modifiable parameters of the foodphysics object.

Modifiable Parameters: - name (str): New name for the object. - description (str): New description. - volume (float or tuple): Volume (can be tuple like (1, "L")). - surfacearea (float or tuple): Surface area (can be tuple like (1, "cm^2")). - density (float or tuple): Density (can be tuple like (1000, "kg/m^3")). - CF0 (float or tuple): Initial concentration in the food. - contacttime (float or tuple): Contact time (can be tuple like (1, "h")). - contacttemperature (float or tuple): Temperature (can be tuple like (25, "degC")). - h (float or tuple): Mass transfer coefficient (can be tuple like (1e-6,"m/s")). - k (float or tuple): Henry-like coefficient for the food (can be tuple like (1,"a.u.")).

Expand source code
def update(self, **kwargs):
    """
    Update modifiable parameters of the foodphysics object.

    Modifiable Parameters:
        - name (str): New name for the object.
        - description (str): New description.
        - volume (float or tuple): Volume (can be tuple like (1, "L")).
        - surfacearea (float or tuple): Surface area (can be tuple like (1, "cm^2")).
        - density (float or tuple): Density (can be tuple like (1000, "kg/m^3")).
        - CF0 (float or tuple): Initial concentration in the food.
        - contacttime (float or tuple): Contact time (can be tuple like (1, "h")).
        - contacttemperature (float or tuple): Temperature (can be tuple like (25, "degC")).
        - h (float or tuple): Mass transfer coefficient (can be tuple like (1e-6,"m/s")).
        - k (float or tuple): Henry-like coefficient for the food (can be tuple like (1,"a.u.")).

    """
    if not kwargs:  # shortcut
        return self # for chaining
    def checkunits(value):
        """Helper function to convert physical quantities to SI."""
        if isinstance(value, tuple) and len(value) == 2:
            scale = check_units(value)[0]  # Convert to SI, drop unit
            return np.array([scale], dtype=float)  # Ensure NumPy array
        elif isinstance(value, (int, float, np.ndarray)):
            return np.array([value], dtype=float)  # Ensure NumPy array
        else:
            raise ValueError(f"Invalid value for physical quantity: {value}")
    # Update `name` and `description` if provided
    if "name" in kwargs:
        self.name = str(kwargs["name"])
    if "description" in kwargs:
        self.description = str(kwargs["description"])
    # Update physical properties
    for key in parametersWithUnits.keys():
        if key in kwargs:
            value = kwargs[key]
            setattr(self, key, checkunits(value))  # Ensure NumPy array in SI
    # Update medium, migrant (they accept aliases)
    lex = {
        "substance": ("substance", "migrant", "chemical", "solute"),
        "medium": ("medium", "simulant", "food", "contact"),
    }
    used_aliases = {}
    def get_value(canonical_key):
        """Find the correct alias in kwargs and return its value, or None if not found."""
        found_key = None
        for alias in lex.get(canonical_key, ()):  # Get aliases, default to empty tuple
            if alias in kwargs:
                if alias in used_aliases:
                    raise ValueError(f"Alias '{alias}' is used for multiple canonical keys!")
                found_key = alias
                used_aliases[alias] = canonical_key
                break  # Stop at the first match
        return kwargs.get(found_key, None)  # Return value if found, else None
    # Assign values only if found in kwargs
    new_substance = get_value("substance")
    new_medium = get_value("medium")
    if new_substance is not None: self.substance = new_substance
    if new_medium is not None:self.medium = new_medium
    # return
    return self  # Return self for method chaining if needed
class foodproperty (**kwargs)

Class wrapper of food properties

general constructor

Expand source code
class foodproperty(foodlayer):
    """Class wrapper of food properties"""
    level="property"

Ancestors

Subclasses

Class variables

var level

Inherited members

class frozen (**kwargs)

real contact conditions

general constructor

Expand source code
class frozen(realcontact):
    """real contact conditions"""
    description = "freezing storage conditions"
    name = "frrozen"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((6,"months"))
    [contacttemperature,contacttemperatureUnits] = check_units((-20,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class frying (**kwargs)

real contact conditions

general constructor

Expand source code
class frying(realcontact):
    """real contact conditions"""
    description = "frying conditions"
    name = "frying"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((10,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((160,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class hotambient (**kwargs)

real contact conditions

general constructor

Expand source code
class hotambient(realcontact):
    """real contact conditions"""
    description = "hot ambient storage conditions"
    name = "hot ambient"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((2,"months"))
    [contacttemperature,contacttemperatureUnits] = check_units((40,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class hotfilled (**kwargs)

real contact conditions

general constructor

Expand source code
class hotfilled(realcontact):
    """real contact conditions"""
    description = "hot-filling conditions"
    name = "hotfilled"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((20,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((80,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class hotoven (**kwargs)

real contact conditions

general constructor

Expand source code
class hotoven(realcontact):
    """real contact conditions"""
    description = "hot oven conditions"
    name = "hot oven"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((30,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((230,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class intermediate (**kwargs)

Intermediate chemical affinity

general constructor

Expand source code
class intermediate(chemicalaffinity):
    """Intermediate chemical affinity"""
    name = "intermediate"
    description = "intermediate chemical affinity"
    [k,kUnits] = check_units((10,NoUnits))

Ancestors

Subclasses

Class variables

var description
var k
var kUnits
var name

Inherited members

class isooctane (**kwargs)

Isoactane food simulant

general constructor

Expand source code
class isooctane(simulant, perfectlymixed, fat):
    """Isoactane food simulant"""
    _chemicalsubstance = "isooctane"
    _polarityindex = 1.0 # Very non-polar hydrocarbon. Dielectric constant ~1.9.
    name = "isooctane"
    description = "isooctane food simulant"
    level = "user"

Ancestors

Class variables

var description
var level
var name

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

  • patankar.layer.AdhesiveAcrylate
  • patankar.layer.AdhesiveEVA
  • patankar.layer.AdhesiveNaturalRubber
  • patankar.layer.AdhesivePU
  • patankar.layer.AdhesivePVAC
  • patankar.layer.AdhesiveSyntheticRubber
  • patankar.layer.AdhesiveVAE
  • patankar.layer.Cardboard
  • patankar.layer.HDPE
  • patankar.layer.HIPS
  • patankar.layer.LDPE
  • patankar.layer.LLDPE
  • patankar.layer.PA6
  • patankar.layer.PA66
  • patankar.layer.PBT
  • patankar.layer.PEN
  • patankar.layer.PP
  • patankar.layer.PPrubber
  • patankar.layer.PS
  • patankar.layer.Paper
  • patankar.layer.SBS
  • patankar.layer.air
  • patankar.layer.gPET
  • patankar.layer.oPP
  • patankar.layer.plasticizedPVC
  • patankar.layer.rPET
  • patankar.layer.rigidPVC

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
class liquid (**kwargs)

Liquid food texture

general constructor

Expand source code
class liquid(texture):
    """Liquid food texture"""
    name = "liquid food"
    description = "liquid food products"
    [h,hUnits] = check_units((1e-6,"m/s"))

Ancestors

Class variables

var description
var h
var hUnits
var name

Inherited members

class methanol (**kwargs)

Methanol food simulant

general constructor

Expand source code
class methanol(simulant, perfectlymixed, aqueous):
    """Methanol food simulant"""
    _chemicalsubstance = "methanol"
    _polarityindex = 8.1 # Polar protic, dielectric constant ~33. Highly capable of hydrogen bonding, but still less so than water.
    name = "methanol"
    description = "methanol"
    level = "user"

Ancestors

Class variables

var description
var level
var name

Inherited members

class microwave (**kwargs)

real contact conditions

general constructor

Expand source code
class microwave(realcontact):
    """real contact conditions"""
    description = "microwave-oven conditions"
    name = "microwave"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((10,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((100,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

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 nofood (**kwargs)

Impervious boundary condition

general constructor

Expand source code
class nofood(foodphysics):
    """Impervious boundary condition"""
    description = "impervious boundary condition"
    name = "undefined"
    level = "root"
    h = 0

Ancestors

Class variables

var description
var h
var level
var name

Inherited members

class oil (**kwargs)

Isoactane food simulant

general constructor

Expand source code
class oil(oliveoil): pass # synonym of oliveoil

Ancestors

Inherited members

class oliveoil (**kwargs)

Isoactane food simulant

general constructor

Expand source code
class oliveoil(simulant, perfectlymixed, fat):
    """Isoactane food simulant"""
    _chemicalsubstance = "methyl stearate"
    _polarityindex = 1.0 # Primarily triacylglycerides; still quite non-polar, though it contains some polar headgroups (the glycerol backbone).
    name = "olive oil"
    description = "olive oil food simulant"
    level = "user"

Ancestors

Subclasses

Class variables

var description
var level
var name

Inherited members

class oven (**kwargs)

real contact conditions

general constructor

Expand source code
class oven(realcontact):
    """real contact conditions"""
    description = "oven conditions"
    name = "oven"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((1,"hour"))
    [contacttemperature,contacttemperatureUnits] = check_units((180,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class panfrying (**kwargs)

real contact conditions

general constructor

Expand source code
class panfrying(realcontact):
    """real contact conditions"""
    description = "panfrying conditions"
    name = "panfrying"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((20,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((120,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class pasteurization (**kwargs)

real contact conditions

general constructor

Expand source code
class pasteurization(realcontact):
    """real contact conditions"""
    description = "pasteurization conditions"
    name = "pasteurization"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((20,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((100,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class perfectlymixed (**kwargs)

Perfectly mixed liquid (texture)

general constructor

Expand source code
class perfectlymixed(texture):
    """Perfectly mixed liquid (texture)"""
    name = "perfectly mixed liquid"
    description = "maximize mixing, minimize the mass transfer boundary layer"
    [h,hUnits] = check_units((1e-4,"m/s"))

Ancestors

Subclasses

Class variables

var description
var h
var hUnits
var name

Inherited members

class realcontact (**kwargs)

real contact conditions

general constructor

Expand source code
class realcontact(foodphysics):
    """real contact conditions"""
    description = "real storage conditions"
    name = "contact conditions"
    level = "root"
    [contacttime,contacttimeUnits] = check_units((200,"days"))
    [contacttemperature,contacttemperatureUnits] = check_units((25,"degC"))

Ancestors

Subclasses

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class realfood (**kwargs)

Core real food class (second level)

general constructor

Expand source code
class realfood(foodproperty):
    """Core real food class (second level)"""
    description = "real food class"

Ancestors

Subclasses

Class variables

var description

Inherited members

class rolled (**kwargs)

rolled storage

general constructor

Expand source code
class rolled(setoff):
    """rolled storage"""
    name = "rolled"
    description = "storage in rolls"
    level = "user"

Ancestors

Class variables

var description
var level
var name

Inherited members

class semisolid (**kwargs)

Semi-solid food texture

general constructor

Expand source code
class semisolid(texture):
    """Semi-solid food texture"""
    name = "solid food"
    description = "solid food products"
    [h,hUnits] = check_units((1e-7,"m/s"))

Ancestors

Subclasses

Class variables

var description
var h
var hUnits
var name

Inherited members

class setoff (**kwargs)

periodic boundary conditions

general constructor

Expand source code
class setoff(foodphysics):
    """periodic boundary conditions"""
    description = "periodic boundary conditions"
    name = "setoff"
    level = "root"
    h = None

Ancestors

Subclasses

Class variables

var description
var h
var level
var name

Inherited members

class simulant (**kwargs)

Core food simulant class (second level)

general constructor

Expand source code
class simulant(foodproperty):
    """Core food simulant class (second level)"""
    name = "generic food simulant"
    description = "food simulant"

Ancestors

Subclasses

Class variables

var description
var name

Inherited members

class solid (**kwargs)

Solid food texture

general constructor

Expand source code
class solid(foodproperty):
    """Solid food texture"""
    _physicalstate = "solid"    # it will be enforced if solid is defined first (see obj.mro())
    name = "solid food"
    description = "solid food products"
    [h,hUnits] = check_units((1e-8,"m/s"))

Ancestors

Subclasses

Class variables

var description
var h
var hUnits
var name

Inherited members

class stacked (**kwargs)

stacked storage

general constructor

Expand source code
class stacked(setoff):
    """stacked storage"""
    name = "stacked"
    description = "storage in stacks"
    level = "user"

Ancestors

Class variables

var description
var level
var name

Inherited members

class sterilization (**kwargs)

real contact conditions

general constructor

Expand source code
class sterilization(realcontact):
    """real contact conditions"""
    description = "sterilization conditions"
    name = "sterilization"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((20,"min"))
    [contacttemperature,contacttemperatureUnits] = check_units((121,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class tenax (**kwargs)

Tenax(r) food simulant

general constructor

Expand source code
class tenax(simulant, solid, fat):
    """Tenax(r) food simulant"""
    _physicalstate = "porous"    # it will be enforced if tenax is defined first (see obj.mro())
    name = "Tenax"
    description = "simulant of dry food products"
    level = "user"

Ancestors

Class variables

var description
var level
var name

Inherited members

class testcontact (**kwargs)

conditions of migration testing

general constructor

Expand source code
class testcontact(foodphysics):
    """conditions of migration testing"""
    description = "migration testing conditions"
    name = "migration testing"
    level = "root"
    [contacttime,contacttimeUnits] = check_units((10,"days"))
    [contacttemperature,contacttemperatureUnits] = check_units((40,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class texture (**kwargs)

Parent food texture class

general constructor

Expand source code
class texture(foodphysics):
    """Parent food texture class"""
    description = "default class texture"
    name = "undefined"
    level = "root"
    h = 1e-3

Ancestors

Subclasses

Class variables

var description
var h
var level
var name

Inherited members

class transportation (**kwargs)

hot transportation contact conditions

general constructor

Expand source code
class transportation(realcontact):
    """hot transportation contact conditions"""
    description = "hot transportation storage conditions"
    name = "hot transportation"
    level = "contact"
    [contacttime,contacttimeUnits] = check_units((1,"month"))
    [contacttemperature,contacttemperatureUnits] = check_units((40,"degC"))

Ancestors

Class variables

var contacttemperature
var contacttemperatureUnits
var contacttime
var contacttimeUnits
var description
var level
var name

Inherited members

class water (**kwargs)

Water food simulant

general constructor

Expand source code
class water(simulant, perfectlymixed, aqueous):
    """Water food simulant"""
    _chemicalsubstance = "water"
    _polarityindex = 10.2
    name = "water"
    description = "water food simulant"
    level = "user"

Ancestors

Class variables

var description
var level
var name

Inherited members

class water3aceticacid (**kwargs)

Water food simulant

general constructor

Expand source code
class water3aceticacid(simulant, perfectlymixed, aqueous):
    """Water food simulant"""
    _chemicalsubstance = "water"
    _polarityindex = 10.0 # Essentially still dominated by water’s polarity; 3% acetic acid does not drastically lower overall polarity.
    name = "water 3% acetic acid"
    description = "water 3% acetic acid - simulant for acidic aqueous foods"
    level = "user"

Ancestors

Class variables

var description
var level
var name

Inherited members

class yogurt (**kwargs)

Yogurt as an example of real food

general constructor

Expand source code
class yogurt(realfood, semisolid, ethanol50):
    """Yogurt as an example of real food"""
    description = "yogurt"
    level = "user"
    [k,kUnits] = check_units((1,NoUnits))
    volume,volumeUnits = check_units((125,"mL"))

    # def __init__(self, name="no brand", volume=None, **kwargs):
    #     # Prepare a parameters dict: if a value is provided (e.g. volume), use it;
    #     # otherwise, the default (from class) is used.
    #     params = {}
    #     if volume is not None:
    #         params['volume'] = volume
    #     params['name'] = name
    #     params.update(kwargs)
    #     super().__init__(**params)

Ancestors

Class variables

var description
var k
var kUnits
var level
var volume
var volumeUnits

Inherited members