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>>
, enablesfood @ layer
.migration(self, material, **kwargs)
: Simulates migration into a packaging layer.contact(self, material, **kwargs)
: Alias formigration()
.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 extendsfoodphysics
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 contactcontacttemperature
: 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>>
, enablesfood @ layer
.migration(self, material, **kwargs)
: Simulates migration into a packaging layer.contact(self, material, **kwargs)
: Alias formigration()
.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
- The
foodphysics
class is the parent offoodlayer
,nofood
,setoff
,realcontact
, andtestcontact
. - The
PBC
property identifies periodic boundary conditions (used insetoff
). - This class provides dynamic inheritance for mass transfer properties.
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 usinglayerLink
.
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 diffusionmedium
: The food medium in contact with the layerDmodel
,kmodel
: Callable models for diffusion and partitioning
Methods
__add__(self, other)
: Combines two layers into a multilayer structure.__mul__(self, n)
: Duplicates a layern
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
andk
can be computed based on the substance defined insubstance
andmedium
. - 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
ortupple (value,"unit")
- DESCRIPTION. Thickness. The default is 50e-6 (m).
D
:TYPE
, optional, scalar
ortupple (value,"unit")
- DESCRIPTION. Diffusivity. The default is 1e-14 (m^2/s).
k
:TYPE
, optional, scalar
ortupple (value,"unit")
- DESCRIPTION. Henry-like coefficient. The default is 1 (a.u.).
C0
:TYPE
, optional, scalar
ortupple (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)
var C0link
-
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)
var Dlink
-
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"
var Tlink
-
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
var hasC0link
-
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
var hasDlink
-
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
var hasTlink
-
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) ]
var hasklink
-
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
var hasllink
-
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)
var klink
-
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]
var llink
-
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
orlist
- 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
orNone
- 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
ornumpy.ndarray
orNone
- 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
orNone
-
- 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
orlist/ndarray
offloat
orNone
-
- 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
orlist/ndarray
offloat
orNone
-
- 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
ofCompoundIndex
orsimilar
, 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
- ambient
- boiling
- chilled
- frozen
- frying
- hotambient
- hotfilled
- hotoven
- microwave
- oven
- panfrying
- pasteurization
- sterilization
- transportation
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
- realfood
- semisolid
- ethanol50
- simulant
- foodproperty
- foodlayer
- perfectlymixed
- texture
- intermediate
- chemicalaffinity
- foodphysics
Class variables
var description
var k
var kUnits
var level
var volume
var volumeUnits
Inherited members