Source code for bw_simapro_csv.parameters
import ast
import re
from copy import deepcopy
from typing import Iterable, Pattern, Type
from asteval.astutils import NameFinder
from astunparse import unparse
from loguru import logger
[docs]
def add_prefix_to_uppercase_input_parameters(block: list, prefix: str = "SP_") -> list:
"""For each input parameter, uppercase and add `prefix` to its name and store the original.
Example usage:
... code-block:: python
>>> add_prefix_to_input_parameters([{'name': 'foo'}])
[{'name': 'SP_FOO', 'original_name': 'foo'}]
"""
for dct in block:
dct["original_name"] = dct["name"]
dct["name"] = f"{prefix}{dct['name']}".upper()
return block
[docs]
def build_substitutes(
project_parameters: Iterable, database_parameters: Iterable
) -> dict[str, str]:
"""Build a dictionary of parameter name substitutions.
Project parameters take precedence over database parameters.
Example usage:
... code-block:: python
>>> p = [
>>> {'name': 'sp_foo', 'original_name': 'foo'},
>>> {'name': 'sp_bar', 'original_name': 'bar'}
>>> ]
>>> d = [{'name': 'hey_foo', 'original_name': 'foo'}]
>>> build_substitutes(p, d)
{'foo': 'hey_foo', 'bar': 'sp_bar'}
"""
return {o["original_name"].upper(): o["name"] for o in database_parameters} | {
o["original_name"].upper(): o["name"] for o in project_parameters
}
[docs]
def compile_iff_re(header: dict) -> Pattern:
"""Compile a regular expression for `Iff` patterns taking the decimal separator into account.
Normally a SimaPro `Iff` expression has the form `Iff(test, if_true, if_false)`; however, if
the decimal separator is ",", then it has the form `Iff(test; if_true; if_false)`"""
separator = ";" if header.get("decimal_separator") == "," else ","
return re.compile(
"iff\\(" # Starting condition, case-insensitive
+ "\\s*" # Whitespace
+ f"(?P<condition>[^{separator}]+)" # Anything except separator
+ "\\s*" # Whitespace
+ separator
+ "\\s*" # Whitespace
+ f"(?P<when_true>[^{separator}]+)" # Value if condition is true
+ "\\s*" # Whitespace
+ separator
+ "\\s*" # Whitespace
+ f"(?P<when_false>[^{separator}]+)" # Value if condition is false
+ "\\s*" # Whitespace
+ "\\)", # End parentheses
re.IGNORECASE,
)
[docs]
def fix_iff_formula(formula: str, pattern: Pattern) -> str:
"""
Replace SimaPro 'iff' formula with a Python equivalent 'if-else' expression.
Processes a given string containing SimaPro 'iff' formulae and
replaces them with Python equivalent 'if-else' expressions. The conversion
is done using regular expressions.
Parameters
----------
formula : str
A string containing SimaPro 'iff' formulae.
pattern : compiled regular expression from `compile_iff_re`
Returns
-------
string : str
A string with SimaPro 'iff' formulae replaced by Python 'if-else' expressions.
Examples
--------
>>> string = "iff(A > 0, A, 0)"
>>> fix_iff_formula(string, compile_iff_re({}))
"((A) if (A > 0) else (0))"
"""
while pattern.findall(formula):
match = next(pattern.finditer(formula))
condition_fixed = match.groupdict()["condition"].replace("=", "==")
formula = (
formula[: match.start()]
+ "(({when_true}) if ({condition_fixed}) else ({when_false}))".format(
condition_fixed=condition_fixed, **match.groupdict()
)
+ formula[match.end() :]
)
return formula
# Compile a regular expression for numbers which start with `0` (not allowed in Python).
[docs]
LEADING_ZERO_RE = re.compile(
r"(?P<prefix>^" # Start searching at beginning of line
+ r"|[\s" # Or preceded by white space
+ r"\+\-\*\/]" # Or mathematical operators
+ r"|(\d[eE]))" # Or 'e|E' for exponent (when in a number)
+ r"(?P<number>0\d)" # The number with leading zero
)
[docs]
def fix_leading_zero_formula(formula: str, pattern: Pattern = LEADING_ZERO_RE) -> str:
"""
Replace leading zeros in numbers, as these cause Python syntax errors.
https://github.com/brightway-lca/bw_simapro_csv/issues/19
Parameters
----------
formula : str
A string, possibly containing leading zeros.
pattern : compiled regular expression
Returns
-------
string : str
A string with leading zeros removed.
Examples
--------
>>> string = "01.23 foo +0123 1.02 john012 123e023 123**023 123*0123 123/0123 clive012"
>>> fix_leading_zero_formula(string)
"1.23 foo +123 1.02 john012 123e23 123**23 123*123 123/123 clive012"
"""
while pattern.findall(formula):
match = next(pattern.finditer(formula))
fixed = match.groupdict()["prefix"] + match.groupdict()["number"][1:]
formula = formula[: match.start()] + fixed + formula[match.end() :]
return formula
[docs]
def prepare_formulas(block: list[dict], header: dict, formula_field: str = "formula") -> list[dict]:
"""Make necessary conversions so formulas can be parsed by Python.
Does the following:
* Substitute `^` with `**`.
* Replace `Iff()` clauses using `fix_iff_formula()`
"""
iff_re = compile_iff_re(header)
for obj in block:
# TBD: Would be better to abstract out each modification to a function and loop them
if formula_field in obj:
original, working = deepcopy(obj[formula_field]), obj[formula_field]
if "^" in working:
working = working.replace("^", "**")
if working != original:
logger.debug(
f"""Replacing `^` in formula on line {obj['line_no']}:
{original} >>> {working}"""
)
if "yield" in working:
fixed = working.replace("yield", "YIELD")
logger.debug(
f"""Replacing `yield` statement with `YIELD` in formula on line {obj['line_no']}:
{working} >>> {fixed}"""
)
working = fixed
fixed = fix_leading_zero_formula(working)
if fixed != working:
logger.debug(
f"""Replacing leading zeros in formula on line {obj['line_no']}:
{working} >>> {fixed}"""
)
working = fixed
fixed = fix_iff_formula(working, iff_re)
if fixed != working:
logger.debug(
f"""Replacing `Iff` expression in formula on line {obj['line_no']}:
{working} >>> {fixed}"""
)
working = fixed
if working != original:
if f"original_{formula_field}" not in obj:
obj[f"original_{formula_field}"] = original
obj[formula_field] = working
return block
[docs]
class OnlySelectedUppercase(NameFinder):
"""Change name of all symbols already redefined in ``substitutes``."""
def __init__(self, substitutes=None):
ast.NodeVisitor.__init__(self)
[docs]
def generic_visit(self, node):
if node.__class__.__name__ == "Name" and node.ctx.__class__ == ast.Load:
node.id = node.id.upper()
try:
node.id = self.substitutes[node.id]
except KeyError:
pass
ast.NodeVisitor.generic_visit(self, node)
[docs]
class FormulaSubstitutor:
"""Callable class that will substitute symbol names using ``substitutions`` substitution dictionary."""
def __init__(self, substitutions):
def __call__(self, formula):
parsed = ast.parse(formula)
self.visitor.visit(parsed)
return unparse(parsed).strip()
[docs]
def substitute_in_formulas(obj: dict, visitor: Type, formula_field: str = "formula") -> dict:
"""Substitute variable names in `obj[formula_field]` based on `substitutions`.
Keeps `original_formula`.
Example usage:
... code-block:: python
>>> given = {'formula': 'a * 2'}
>>> substitutions = {'a': 'hi_mom'}
>>> substitute_in_formulas(given, substitutions)
{'formula': 'hi_mom * 2', 'original_formula': 'a * 2'}
"""
if formula_field in obj:
obj[f"original_{formula_field}"] = obj[formula_field]
try:
obj[formula_field] = visitor(obj[formula_field])
except SyntaxError as exc:
logger.critical("Syntax error in field {ff} in object {o}", ff=formula_field, o=obj)
raise SyntaxError from exc
if obj[f"original_{formula_field}"] == obj[formula_field]:
del obj[f"original_{formula_field}"]
return obj