Source code for esbmtk.esbmtk_base

"""esbmtk: A general purpose Earth Science box model toolkit.

Copyright (C), 2020 Ulrich G. Wortmann

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""

from __future__ import annotations

import time
import typing as tp

import numpy as np
import numpy.typing as npt

if tp.TYPE_CHECKING:
    from .esbmtk import SpeciesProperties

# declare numpy types
NDArrayFloat = npt.NDArray[np.float64]


[docs] class KeywordError(Exception): """Exception raised for errors in keyword arguments. Parameters ---------- message : str Explanation of the error Examples -------- >>> raise KeywordError("Invalid keyword 'xyz'") """ def __init__(self, message): message = f"\n\n{message}\n" super().__init__(message)
[docs] class MissingKeywordError(Exception): """Exception raised when a required keyword argument is missing. Parameters ---------- message : str Explanation of the error Examples -------- >>> raise MissingKeywordError("'name' is a mandatory keyword") """ def __init__(self, message): message = f"\n\n{message}\n" super().__init__(message)
[docs] class InputError(Exception): """Exception raised for errors in the input parameters. Parameters ---------- message : str Explanation of the error Examples -------- >>> raise InputError("Value must be positive") """ def __init__(self, message): message = f"\n\n{message}\n" super().__init__(message)
[docs] class FluxSpecificationError(Exception): """Exception raised for errors in flux specifications. Parameters ---------- message : str Explanation of the error Examples -------- >>> raise FluxSpecificationError("Unknown flux units") """ def __init__(self, message): message = f"\n\n{message}\n" super().__init__(message)
[docs] class SpeciesPropertiesMolweightError(Exception): """Exception raised when molecular weight is missing or invalid. Parameters ---------- message : str Explanation of the error Examples -------- >>> raise SpeciesPropertiesMolweightError("Missing molecular weight for C") """ def __init__(self, message): message = f"\n\n{message}\n" super().__init__(message)
[docs] class InputParsing: """Provides various routines to parse and process keyword arguments. All derived classes need to declare the allowed keyword arguments, their default values and the type in the following format: defaults = {"key": [value, (allowed instances)]} The recommended sequence is to first set default values via __register_variable_names__() and then update with provided values using __update_dict_entries__(defaults, kwargs). Notes ----- This class is not meant to be instantiated directly. """ def __init__(self): raise NotImplementedError("InputParsing has no instance!") def __initialize_keyword_variables__(self, kwargs) -> None: """ Check, register and update keyword variables. Parameters ---------- kwargs : dict Dictionary of keyword arguments to process Returns ------- None Examples -------- >>> self.__initialize_keyword_variables__({"name": "test", "value": 10}) """ self.update = False self.__check_mandatory_keywords__(self.lrk, kwargs) _x = kwargs.get("id", None) self.__register_variable_names__(self.defaults, kwargs) self.__update_dict_entries__(self.defaults, kwargs) self.update = True def __check_mandatory_keywords__(self, lrk: list, kwargs: dict) -> None: """Verify that all required keywords are present in kwargs. Parameters ---------- lrk : list List of required keywords or lists of alternative keywords kwargs : dict Dictionary of provided keyword arguments Raises ------ MissingKeywordError If a required keyword is missing ValueError If exactly one of a list of alternative keywords is not provided TypeError If lrk is not a list or kwargs is not a dictionary Notes ----- If an element of lrk is a list, it represents alternative keywords where exactly one must be provided. Examples -------- >>> self.__check_mandatory_keywords__(["name", ["option1", "option2"]], {"name": "test", "option1": "value"}) """ if not lrk: return # Type checking using the newer isinstance syntax if not isinstance(lrk, list): raise TypeError(f"Required keywords list must be a list, not {type(lrk)}") if not isinstance(kwargs, dict): raise TypeError(f"Keywords must be a dictionary, not {type(kwargs)}") for key in lrk: if isinstance(key, list): # This is a list of alternative keywords - exactly one must be provided valid_keys = [k for k in key if k in kwargs and kwargs[k] != "None"] if len(valid_keys) == 0: # Use context preservation for exceptions try: alternatives = ", ".join(key) raise ValueError( f"No valid alternatives found among: {alternatives}" ) except ValueError as err: raise MissingKeywordError( f"At least one of these keywords must be provided: {key}" ) from err elif len(valid_keys) > 1: try: choices = ", ".join(valid_keys) raise ValueError(f"Multiple choices provided: {choices}") except ValueError as err: raise ValueError( f"Only one of these keywords should be provided: {key}. " f"Found: {valid_keys}" ) from err elif key not in kwargs: raise MissingKeywordError(f"'{key}' is a mandatory keyword") elif kwargs[key] is None: raise MissingKeywordError( f"'{key}' is a mandatory keyword and cannot be None" ) def __register_variable_names__( self, defaults: dict[str, list[any, tuple]], kwargs: dict, ) -> None: """Register the key-value pairs as local instance variables. We register them with their actual variable name and as _variable_name to support setter and getter methods and avoid name conflicts. Parameters ---------- defaults : dict Dictionary with default values and allowed types kwargs : dict Dictionary of keyword arguments Returns ------- None Examples -------- >>> defaults = {"name": ["default", (str,)], "value": [0, (int, float)]} >>> self.__register_variable_names__(defaults, {}) """ for key, value in defaults.items(): if len(value) > 2: # check for properties if value[2]: setattr(self, f"_{key}", value[0]) else: setattr(self, f"{key}", value[0]) else: setattr(self, key, value[0]) # save kwargs dict self.kwargs: dict = kwargs def __update_dict_entries__( self, defaults: dict[str, list[any, tuple]], kwargs: dict[str, any], ) -> None: """ Validate and update instance attributes with provided keyword arguments. Parameters ---------- defaults : dict Dictionary with format {"key": [default_value, (allowed_types)]} kwargs : dict Dictionary with format {"key": value} Raises ------ KeywordError If a key in kwargs is not in defaults InputError If a value in kwargs is not of the expected type or fails validation ValueError If defaults dictionary is empty Notes ----- This function assumes that all defaults have been registered with the instance via __register_variable_names__() Examples -------- >>> defaults = {"name": ["default", (str,)], "value": [0, (int, float)]} >>> self.__update_dict_entries__(defaults, {"name": "test", "value": 10}) """ self.__validate_defaults_and_kwargs__(defaults, kwargs) if not kwargs: return # Nothing to update for key, value in kwargs.items(): self.__process_keyword__(defaults, key, value) def __validate_defaults_and_kwargs__(self, defaults, kwargs): """ Validate that defaults dictionary is not empty. Parameters ---------- defaults : dict Dictionary with default values kwargs : dict Dictionary of keyword arguments Raises ------ ValueError If defaults dictionary is empty """ if not defaults: raise ValueError("Defaults dictionary cannot be empty") def __process_keyword__(self, defaults, key, value): """ Process a single keyword argument. Parameters ---------- defaults : dict Dictionary with format {"key": [default_value, (allowed_types)]} key : str The keyword to process value : any The value to validate and set Raises ------ KeywordError If key is not in defaults InputError If value is not of the expected type or fails validation """ # Check if the key exists in defaults if key not in defaults: raise KeywordError(f"'{key}' is not a valid keyword") # Skip None values as they're handled elsewhere if value is None: # or value == "None": return # Get the expected types expected_types = defaults[key][1] # Validate the value type self.__validate_value_type__(key, value, expected_types) # Perform additional validation based on type try: self._validate_value(key, value, expected_types) except Exception as err: raise InputError(f"Validation failed for '{key}': {str(err)}") from err # Update the values self.__update_attribute_values__(defaults, key, value) def __validate_value_type__(self, key, value, expected_types): """ Validate that a value is of the expected type. Parameters ---------- key : str The keyword being validated value : any The value to validate expected_types : type or tuple of types The expected types for the value Raises ------ InputError If value is not of the expected type """ if not isinstance(value, expected_types): try: actual_type = type(value).__name__ if isinstance(expected_types, tuple): expected_types_str = ", ".join(t.__name__ for t in expected_types) else: expected_types_str = expected_types.__name__ raise TypeError( f"Value '{value}' has type {actual_type}, expected {expected_types_str}" ) except TypeError as err: raise InputError( f"'{value}' for '{key}' must be of type {expected_types_str}, " f"not {actual_type}" ) from err def __update_attribute_values__(self, defaults, key, new_value): """ Update the attribute values in both defaults dictionary and instance. Parameters ---------- defaults : dict Dictionary with format {"key": [default_value, (allowed_types)]} key : str The keyword to update value : any The value to set """ current_value = defaults[key] defaults[key][0] = new_value # update defaults dictionary if len(current_value) > 2: if current_value[2]: setattr(self, f"_{key}", new_value) else: setattr(self, key, new_value) # update instance variables else: setattr(self, key, new_value) def _validate_value(self, key, value, expected_types): """ Perform additional validation on values based on their types. Parameters ---------- key : str The keyword being validated value : any The value to validate expected_types : type or tuple of types The expected types for the value Raises ------ ValueError If the value fails validation Examples -------- >>> self._validate_value("volume", 10.5, (int, float)) """ # String validations if isinstance(value, str): if key == "name" and (not value or value.isspace()): raise ValueError("Name cannot be empty or just whitespace") # Numeric validations elif isinstance(value, int | float): # Using newer isinstance syntax # Example: values that should be positive if key in ("volume", "m_weight", "salinity", "temperature") and value <= 0: raise ValueError(f"'{key}' must be positive, got {value}") # Example: values that should be within a specific range if key == "ph" and (value < 0 or value > 14): raise ValueError(f"pH must be between 0 and 14, got {value}") def __register_with_parent__(self) -> None: """ Register self as attribute of self.parent and set full name. If self.parent == "None", full_name = name. Otherwise, full_name = parent.full_name + "." + self.name Returns ------- None Examples -------- >>> self.__register_with_parent__() """ if self.parent == "None": self.full_name = self.name reg = self else: self.full_name = f"{self.parent.full_name}.{self.name}" reg = self.parent.model if self.full_name in reg.lmo: print("\n -------------- Warning ------------- \n") print(f"\t {self.full_name} is a duplicate name in reg.lmo") print("\n ---------------------- ------------- \n") # register with model reg.lmo.append(self.full_name) reg.lmo2.append(self) reg.dmo.update({self.full_name: self}) setattr(self.parent, self.name, self) self.kwargs["full_name"] = self.full_name self.reg_time = time.monotonic() def __test_and_resolve_duplicates__(self, name, lmo): """ Test for duplicate instance names and resolve by appending _1, _2, etc. Parameters ---------- name : str The instance name to check lmo : list List of existing instance names Returns ------- tuple (resolved_name, updated_lmo_list) Examples -------- >>> name, lmo = self.__test_and_resolve_duplicates__("test", ["existing"]) >>> print(name) test """ if name in lmo: print(f"\n Warning, {name} is a duplicate, trying with {name}_1\n") name = name + "_1" name, lmo = self.__test_and_resolve_duplicates__(name, lmo) else: lmo.append(name) return name, lmo
[docs] class esbmtkBase(InputParsing): """The esbmtk base class template. This class handles keyword arguments, name registration and other common tasks. Examples -------- .. code-block:: python # Define required keywords in lrk list self.lrk: tp.List = ["name"] # Define allowed type per keyword in defaults dict self.defaults: dict[str, list[any, tuple]] = { "name": ["None", (str)], "model": ["None", (str, Model)], "salinity": [35, (int, float)], # int or float } # Parse and register all keywords with the instance self.__initialize_keyword_variables__(kwargs) # Register the instance self.__register_with_parent__() """ def __init__(self) -> None: raise NotImplementedError def __repr__(self, log=0) -> str: """Return string representation of the object. Parameters ---------- log : int, default=0 If 0 and object was just created (<1 second ago), returns empty string to suppress output Returns ------- str String representation of the object Examples -------- >>> repr(obj) 'ClassName(name = "example", value = 42)' """ from esbmtk import Q_ m: str = "" # suppress output during object initialization tdiff = time.monotonic() - self.reg_time m = f"{self.__class__.__name__}(\n" for k, v in self.kwargs.items(): if not isinstance({k}, esbmtkBase): # check if this is not another esbmtk object if "esbmtk" in str(type(v)): m = f"{m} {k} = {v.name},\n" elif isinstance(v, str | Q_): # Using newer isinstance syntax m = f"{m} {k} = '{v}',\n" elif isinstance(v, list | np.ndarray): # Using newer isinstance syntax m = f"{m} {k} = '{v[:3]}',\n" else: m = f"{m} {k} = {v},\n" m = "" if log == 0 and tdiff < 1 else f"{m})" return m def __str__(self, kwargs=None): """Return a string representation of the object with its key attributes. Parameters ---------- kwargs : dict, optional Additional keyword arguments - indent : int Number of spaces to indent output - index : int Index to display for array values, default=-2 Returns ------- str Formatted string representation of the object Examples -------- >>> str(obj) 'example (ClassName) value = 42' """ if kwargs is None: kwargs = {} from esbmtk import Q_ m: str = "" off: str = " " ind: str = kwargs["indent"] * " " if "indent" in kwargs else "" index = int(kwargs["index"]) if "index" in kwargs else -2 m = f"{ind}{self.name} ({self.__class__.__name__})\n" for k, v in self.kwargs.items(): if not isinstance({k}, esbmtkBase): # check if this is not another esbmtk object if "esbmtk" in str(type(v)): pass elif isinstance(v, str) and k != "name" or isinstance(v, Q_): m = f"{m}{ind}{off}{k} = {v}\n" elif isinstance(v, np.ndarray): m = f"{m}{ind}{off}{k}[{index}] = {v[index]:.2e}\n" elif k != "name": m = f"{m}{ind}{off}{k} = {v}\n" return m def __lt__(self, other) -> bool: # Fixed return type annotation from None to bool """Compare if self is less than other for sorting with sorted(). Parameters ---------- other : esbmtkBase Object to compare with Returns ------- bool True if self.n < other.n, False otherwise Examples -------- >>> sorted([obj2, obj1]) # If obj1.n < obj2.n, returns [obj1, obj2] [obj1, obj2] """ return self.n < other.n def __gt__(self, other) -> bool: # Fixed return type annotation from None to bool """Compare if self is greater than other for sorting with sorted(). Parameters ---------- other : esbmtkBase Object to compare with Returns ------- bool True if self.n > other.n, False otherwise Examples -------- >>> sorted([obj1, obj2], reverse=True) # If obj2.n > obj1.n, returns [obj2, obj1] [obj2, obj1] """ return self.n > other.n def __add_flux__(self, lof, flux): """Check if flux is already defined. Otherwise add flux to lof """ if flux in lof: raise FluxSpecificationError(f"Duplicate entry for {flux.name}") else: if flux is not None: lof.append(flux)
[docs] def info(self, **kwargs) -> None: """Show an overview of the object properties. Parameters ---------- **kwargs : dict, optional Additional keyword arguments ---------------------------- * ``indent`` : int Number of spaces for indentation Returns ------- None Examples -------- .. code-block:: python obj.info(indent=2) """ if "indent" not in kwargs: indent = 0 ind = "" else: indent = kwargs["indent"] ind = " " * indent # print basic data about this object print(f"{ind}{self.__str__(kwargs)}")
def __aux_inits__(self) -> None: """Auxiliary initialization code. This method is a placeholder for additional initialization steps that subclasses might implement. Returns ------- None Examples -------- >>> self.__aux_inits__() """ pass
[docs] def ensure_q(self, arg): """Ensure that a given input argument is a quantity object. Parameters ---------- arg : str, Quantity, or numeric The argument to convert to a Quantity Returns ------- Quantity The input argument as a Quantity object Raises ------ InputError If the argument is None, empty, or cannot be converted to a Quantity Examples -------- >>> self.ensure_q("10 kg") <Quantity(10, 'kilogram')> >>> self.ensure_q(existing_quantity) <Quantity(existing_quantity)> """ from esbmtk import Q_ # Check if argument is None or empty if arg is None: raise InputError("Cannot convert None to a Quantity") if isinstance(arg, str) and not arg.strip(): raise InputError("Cannot convert empty string to a Quantity") # Convert based on type if isinstance(arg, Q_): return arg elif isinstance(arg, str): try: return Q_(arg) except Exception as err: raise InputError( f"Failed to convert '{arg}' to a Quantity: {str(err)}" ) from err elif isinstance(arg, int | float): # Using newer isinstance syntax # If only a number is provided with no units, raise an error raise InputError( f"Numeric value {arg} provided without units. " f"Please provide a string with units, e.g., '{arg} kg'" ) else: raise InputError( f"Cannot convert type {type(arg)} to a Quantity. " f"Must be a string or Quantity." )
[docs] def help(self) -> None: """ Show all keywords, their default values and allowed types. Prints information about all available keywords and highlights which ones are mandatory. Returns ------- None Examples -------- >>> obj.help() """ print(f"\n{self.full_name} has the following keywords:\n") for k, v in self.defaults_copy.items(): print(f"{k} defaults to {v[0]}, allowed types = {v[1]}") print() print("The following keywords are mandatory:") for kw in self.lrk: print(f"{kw}")
[docs] def set_flux(self, mass: str, time: str, substance: SpeciesProperties): """ Convert a flux rate to model units (kg/s or mol/s). Parameters ---------- mass : str Mass value with units, e.g., "12 Tmol" or "500 kg" time : str Time unit, e.g., "year", "day", "s" substance : SpeciesProperties Species properties object containing molecular weight information Returns ------- Quantity Flux rate in model units (mol/time or g/time) Raises ------ InputError If input parameters are None, empty, or of incorrect type FluxSpecificationError If unit conversion cannot be performed SpeciesPropertiesMolweightError If substance has no molecular weight defined Examples -------- >>> M.set_flux("12 Tmol", "year", M.C) <Quantity(12, 'teramol / year')> Notes ----- If model mass units are in mol, no mass unit conversion will be made. If model mass units are in kg, the flux will be converted accordingly. """ # Validate all input parameters first self.__validate_flux_inputs__(mass, time, substance) # Validate substance properties self.__validate_substance_properties__(substance) # Convert the mass quantity r = self.__convert_mass_to_model_units__(mass, substance) # Apply the time unit and return the result return self.__apply_time_unit__(r, time)
def __validate_flux_inputs__(self, mass, time, substance): """ Validate the input parameters for set_flux. Parameters ---------- mass : str or Quantity Mass value with units time : str Time unit substance : SpeciesProperties Species properties object Raises ------ InputError If any input parameter is invalid """ from esbmtk import Q_, ureg # Check for empty inputs if not mass: raise InputError("Mass parameter cannot be empty") if not time: raise InputError("Time parameter cannot be empty") if substance is None: raise InputError("Substance parameter cannot be None") # Type checks if not isinstance(mass, str | Q_): raise InputError(f"Mass must be a string or Quantity, not {type(mass)}") if not isinstance(time, str): raise InputError(f"Time must be a string, not {type(time)}") # Validate time units try: ureg(time) except Exception as err: raise InputError(f"Invalid time unit: '{time}'. Error: {str(err)}") from err def __validate_substance_properties__(self, substance): """ Validate that substance has the required properties. Parameters ---------- substance : SpeciesProperties Species properties object Raises ------ SpeciesPropertiesMolweightError If substance properties are missing or invalid """ # Check for m_weight attribute if not hasattr(substance, "m_weight"): raise SpeciesPropertiesMolweightError( f"Substance {getattr(substance, 'full_name', str(substance))} has no 'm_weight' attribute" ) # Check for model unit attributes if not hasattr(substance, "mo") or not hasattr(substance.mo, "m_unit"): raise SpeciesPropertiesMolweightError( f"Substance {getattr(substance, 'full_name', str(substance))} has incomplete model unit definition" ) # Check m_weight value if substance.m_weight <= 0: raise SpeciesPropertiesMolweightError( f"No molecular weight definition for {substance.full_name} (m_weight={substance.m_weight})" ) def __convert_mass_to_model_units__(self, mass, substance): """ Convert mass to model units using substance properties. Parameters ---------- mass : str or Quantity Mass value with units substance : SpeciesProperties Species properties object Returns ------- Quantity Mass converted to model units Raises ------ FluxSpecificationError If unit conversion fails """ from esbmtk import Q_, ureg try: mass = Q_(mass) if isinstance(mass, str) else mass g_per_mol = ureg("g/mol") if mass.is_compatible_with("g") or mass.is_compatible_with("mol"): return mass.to( substance.mo.m_unit, # target unit (mol) "chemistry", # context mw=substance.m_weight * g_per_mol, # g/mol ) else: raise FluxSpecificationError( f"No known conversion for {mass} (units: {mass.units}) and {substance.full_name}" ) except Exception as err: if isinstance(err, FluxSpecificationError): raise else: raise FluxSpecificationError( f"Failed to convert {mass} for {substance.full_name}: {str(err)}" ) from err def __apply_time_unit__(self, quantity, time_unit): """ Apply time unit to a quantity to get a rate. Parameters ---------- quantity : Quantity The quantity to convert to a rate time_unit : str The time unit to apply Returns ------- Quantity Rate with the specified time unit Raises ------ FluxSpecificationError If applying the time unit fails """ from esbmtk import ureg try: return quantity / ureg(time_unit) except Exception as err: raise FluxSpecificationError( f"Failed to apply time unit '{time_unit}': {str(err)}" ) from err def __update_ode_constants__(self, value: int | float, vname: str) -> int: """Replace a value to the global parameter list.""" key = f"{self.full_name}_{vname}" index = self.model.doc.get(key, None) if index != None: self.model.toc = ( self.model.toc[:index] + (value,) + self.model.toc[index + 1 :] ) def __add_to_ode_constants__(self, value: int | float, vname: str) -> int: """ Add a value to the global parameter list and track its index. Parameters ---------- value : any Value to add to the parameter list Returns ------- int Index position of the value in the parameter list Raises ------ AttributeError If the model attribute is missing or does not have required properties TypeError If model.toc is not a sequence that can be extended Examples -------- >>> index = self.__add_to_ode_constants__(42.0) >>> print(index) 5 # If this was the 6th constant added (zero-indexed) """ # Check if model attribute exists if not hasattr(self, "model"): raise AttributeError( "Cannot update ODE constants: 'model' attribute is missing" ) # Check if model has the required attributes if not hasattr(self.model, "toc"): raise AttributeError( "Model object is missing 'toc' attribute required for ODE constants" ) if not hasattr(self.model, "gcc"): raise AttributeError( "Model object is missing 'gcc' attribute required for ODE constants" ) # Add the value to the parameter list if it's not "None" if value != "None": # Validate that toc is a sequence that can be extended try: if not hasattr(self.model.toc, "__iter__"): raise TypeError("Model.toc must be an iterable") except TypeError as err: raise TypeError( f"Model.toc must be a sequence, not {type(self.model.toc)}" ) from err # Update the model's toc tuple with the new value self.model.toc = (*self.model.toc, value) # Get the current index index = self.model.gcc # update index dict name = f"{self.full_name}_{vname}" self.model.doc[name] = index # Increment the counter self.model.gcc = self.model.gcc + 1 else: index = 0 return index
[docs] def validate(self) -> bool: """ Validate the object state after initialization. This method performs comprehensive validation of the object's attributes to ensure they are in a consistent and valid state. Returns ------- bool True if the object is valid Raises ------ ValueError If the object fails validation AttributeError If required attributes are missing Examples -------- >>> obj = SomeClass(name="test", value=10) >>> obj.validate() # Returns True if valid, raises exception if not True """ # Check for required attributes required_attrs = ["name", "full_name"] for attr in required_attrs: try: if not hasattr(self, attr): raise AttributeError(f"Required attribute '{attr}' is missing") value = getattr(self, attr) if value in (None, "None", ""): raise ValueError(f"Required attribute '{attr}' cannot be empty") except (AttributeError, ValueError) as err: raise ValueError( f"Required attribute '{attr}' is missing or empty" ) from err # Validate specific attributes based on their expected properties if hasattr(self, "volume") and hasattr(self, "model"): from esbmtk import Q_ # Example: validate that volume has compatible units with the model try: if ( hasattr(self, "volume") and hasattr(self, "model") and isinstance(self.volume, Q_) and hasattr(self.model, "v_unit") and not self.volume.is_compatible_with(self.model.v_unit) ): raise ValueError( f"Volume units '{self.volume.units}' are incompatible with model units '{self.model.v_unit}'" ) except Exception as err: raise ValueError( f"Volume units {self.volume.units} are not compatible " f"with model volume units {self.model.v_unit}" ) from err # Add other validation rules specific to your application # If all validations pass, return True return True