from __future__ import annotations
from typing import Any, Optional, Union, overload
from pharmpy.basic import BooleanExpr, Expr, Quantity, Unit
from pharmpy.basic.expr import solve
from pharmpy.model import (
AddColumn,
Assignment,
CompartmentalSystem,
CompartmentalSystemBuilder,
DataVariable,
Drop,
Infusion,
Model,
Statements,
get_and_check_dataset,
get_and_check_odes,
)
from .compartments import get_bioavailability, get_lag_times
from .odes import get_initial_conditions, get_zero_order_inputs
@overload
def get_unit_of(model: Model, variable: None) -> dict[str, Unit]: ...
@overload
def get_unit_of(model: Model, variable: Union[str, Expr]) -> Unit: ...
[docs]
def get_unit_of(model: Model, variable: Union[str, Expr, None] = None) -> Unit | dict[str, Unit]:
"""Derive the physical unit of a variable in the model
Unit information for the dataset needs to be available.
The variable can be defined in the code, a dataset column, a parameter
or a random variable. Optionally units could be derived for all variables
in the model.
Parameters
----------
model : Model
Pharmpy model
variable : str | Expr | None
Find physical unit of this variable. For None get a dict with units for
all variables defined by the model.
Returns
-------
Unit
A unit expression
Examples
--------
>>> from pharmpy.modeling import load_example_model, get_unit_of
>>> model = load_example_model("pheno")
>>> get_unit_of(model, "Y")
mg/L
>>> get_unit_of(model, "VC")
L
>>> get_unit_of(model, "WGT")
kg
"""
if isinstance(variable, str):
variable = Expr.symbol(variable)
all_symbols = get_all_symbols(model)
if variable not in all_symbols:
raise ValueError(f"Variable {variable} is not defined in the model")
di = model.datainfo
# FIXME: No multiple DV-support for now
# Map from Symbol -> known Unit
known = {
col.symbol: col.variable.properties.get("unit", None)
for col in di
if not col.variable.properties.get("unit", None) is None
}
# Set of tuples symbol, expression where units cannot yet be deduced
unknown = set()
y = list(model.dependent_variables.keys())[0]
known[y] = known[di.dv_column.symbol]
id_symbol = model.datainfo.id_column.symbol
known[id_symbol] = Unit("")
if model.statements.ode_system is not None:
amount_unit = di.typeix['dose'][0].variable.properties.get("unit", None)
if amount_unit is not None:
for amt in model.statements.ode_system.amounts:
known[amt] = amount_unit
idv_unit = di.idv_column.variable.properties.get("unit", None)
if idv_unit is not None:
known[model.statements.ode_system.t] = idv_unit
lag_times = get_lag_times(model)
for lag_time in lag_times.values():
handle_assignment(di.idv_column.symbol, lag_time, known, unknown, model)
bios = get_bioavailability(model)
for bio in bios.values():
handle_assignment(id_symbol, bio, known, unknown, model)
if amount_unit is not None:
amount_symbol = di.typeix['dose'][0].symbol
ics = get_initial_conditions(model)
for ic in ics.values():
if ic != 0:
handle_assignment(amount_symbol, ic, known, unknown, model)
else:
amount_unit = None
idv_unit = None
add_covariances(model, unknown)
for s in reversed(model.statements):
if variable is not None and variable in known:
return known[variable]
if isinstance(s, Assignment):
handle_assignment(s.symbol, s.expression, known, unknown, model)
elif isinstance(s, CompartmentalSystem):
eqs = s.eqs
zinps = get_zero_order_inputs(model)
for eq, zinp in zip(eqs, zinps):
func = eq.lhs.args[0]
assert isinstance(func, Expr)
funcname = func.name
# FIXME: Could collide
derivative_symbol = Expr.symbol(f"d{funcname}_dt")
if amount_unit is not None and idv_unit is not None:
known[derivative_symbol] = amount_unit / idv_unit
handle_assignment(derivative_symbol, eq.rhs, known, unknown, model)
if zinp != 0:
handle_assignment(derivative_symbol, zinp, known, unknown, model)
unknown = recheck_unknowns(unknown, known, model)
if variable is not None and variable in known:
return known[variable]
if variable is not None:
raise RuntimeError(f"Couldn't deduct unit for {variable}")
else:
all_units = {}
for symbol in all_symbols:
all_units[str(symbol)] = known.get(symbol, None)
return all_units
def add_covariances(model, unknown):
# Unit of variance is the square of the unit of the eta
# Unit of a covariance is the product of the units of the variances
cov = model.random_variables.covariance_matrix
for rv, diag in zip(model.random_variables.symbols, cov.diagonal()):
unknown.add((diag, rv**2))
if len(cov) == 1:
return
for row in range(1, cov.rows):
for col in range(0, row):
e = cov[row, col]
if e != 0:
unknown.add((e, cov[row, row] * cov[col, col]))
def get_all_symbols(model):
symbols = (
set(model.parameters.symbols)
| set(model.random_variables.symbols)
| model.statements.lhs_symbols
| set(model.datainfo.symbols)
)
if model.statements.ode_system is not None:
symbols.add(model.statements.ode_system.t)
return symbols
def product(a, start: Any = 1):
prod = start
for e in a:
prod *= e
return prod
def simplify_for_units(expr: Expr) -> Expr:
# Remove known unitless cases: exp, log
# FIXME: Remove constants other than -1, 1 and 0
if expr.is_add():
return sum((simplify_for_units(term) for term in expr.expr_args), start=Expr(0))
elif expr.is_mul():
return product((simplify_for_units(factor) for factor in expr.expr_args), start=Expr(1))
elif expr.is_exp() or (expr.is_function() and expr.name == "log"):
return Expr(1)
elif expr.is_pow():
return simplify_for_units(expr.expr_args[0]) ** simplify_for_units(expr.expr_args[1])
elif expr.is_function() and expr.name in {"forward", "first"}:
return simplify_for_units(expr.expr_args[0])
elif expr.is_function() and expr.name in {"newind", "count_if"}:
return Expr(1)
else:
return expr
def deduct_equal_units(symbol: Expr, expr: Expr) -> list[BooleanExpr]:
# FIXME: we could also recurse down to additions inside exp and log or parentheses
expr = simplify_for_units(expr)
eqs = []
expr = expr.expand()
if expr.is_add():
for term in expr.expr_args:
eqs.append(BooleanExpr.eq(symbol, simplify_for_units(term)))
elif expr.is_piecewise():
for piece, _ in expr.piecewise_args:
if piece != symbol and not piece.is_number():
eqs.append(BooleanExpr.eq(symbol, simplify_for_units(piece)))
else:
eqs.append(BooleanExpr.eq(symbol, simplify_for_units(expr)))
return eqs
def derive_unit(expr, known):
if expr.is_number():
unit = Unit(1)
elif expr.is_symbol():
unit = known[expr]
elif expr.is_mul():
unit = product([derive_unit(factor, known) for factor in expr.args], start=Unit(1))
elif expr.is_pow():
base, exp = expr.args
if not exp.is_integer():
raise NotImplementedError("Non integer exponent not implemented for unit deduction")
unit = derive_unit(base, known) ** int(exp)
else:
raise NotImplementedError("Expression not implemented for unit deduction")
return unit
def used_symbols(expr, model):
# This is a workardound for free_symbols which doesn't give A_...(t)
assignment = Assignment(Expr("DUMMY"), expr)
symbols = assignment.rhs_symbols
# Handle case where t is only found in amounts. Not needed for deduction
odes = model.statements.ode_system
if odes is not None:
d = {amt: Expr(f"__DUMMY___{i}") for i, amt in enumerate(odes.amounts)}
if model.statements.ode_system.t not in expr.subs(d).free_symbols:
symbols -= {model.statements.ode_system.t}
return symbols
def handle_exp(expression, known, unknown, model):
if isinstance(expression, Expr) and expression.is_exp():
idsymb = model.datainfo.id_column.symbol
handle_assignment(idsymb, expression.args[1], known, unknown, model)
elif len(expression.args) > 0:
for arg in expression.args:
handle_exp(arg, known, unknown, model)
def handle_assignment(symbol, expression, known, unknown, model):
handle_exp(expression, known, unknown, model)
eqs = deduct_equal_units(symbol, expression)
sol = solve(eqs, exclude=known.keys())
for lhs, rhs in sol.items():
# FIXME: Could also learn the unit of the whole assignment
# FIXME: Unit of covariance is product of unit of both rvs
# FIXME: Can get information from conditions in piecewises
if rhs == 1:
unit = Unit(1)
elif used_symbols(rhs, model).issubset(known.keys()):
unit = derive_unit(rhs, known)
else:
unknown.add((lhs, rhs))
continue
known[lhs] = unit
def recheck_unknowns(unknown, known, model):
still_unknown = set()
for symbol, expression in unknown:
# We need to attempt solving the equation again since
# the unknown might not be on the lhs
eq = BooleanExpr.eq(symbol, expression)
sol = solve([eq], exclude=known.keys())
sol_symbol, sol_expression = sol.popitem()
if used_symbols(sol_expression, model).issubset(known.keys()):
unit = derive_unit(sol_expression, known)
known[sol_symbol] = unit
else:
still_unknown.add((symbol, expression))
return still_unknown
def get_factor(expr):
# Get if expr has a factor of any power of ten (except 1)
# If not return None
# Else return the factor as an expression
def is_power_of_ten(x):
s = str(x)
return s.startswith("1") and s[1:] == "0" * (len(s) - 1)
if expr.is_mul():
for arg in expr.args:
if arg.is_integer():
if is_power_of_ten(arg):
return arg
elif arg.is_rational():
p, q = arg.as_numer_denom()
if p == 1 and is_power_of_ten(q):
return arg
elif expr.is_integer() and is_power_of_ten(expr):
return expr
elif expr.is_rational():
p, q = expr.as_numer_denom()
if p == 1 and is_power_of_ten(q):
return expr
return None
def update_factor(expr, factor, div=False):
s_factor = get_factor(expr)
if s_factor is not None:
if not div:
factor *= s_factor
else:
factor /= s_factor
if factor == 1:
factor = None
return factor
[docs]
def convert_unit(
model: Model,
variable: str,
unit: Union[str, Unit],
original_unit: Optional[Union[str, Unit]] = None,
in_dataset: bool = False,
) -> Model:
"""Convert between units for a data variable
The conversion could either be handled in the model code or optionally in the dataset (if applicable).
Note that when doing the conversion in the model the unit of the data variable itself will not
change. The change will affect the unit of relevant parameters in the model.
Note that only ratio units are supported (i.e. not interval scale units like °C).
Parameters
----------
model : Model
Pharmpy model
variable : str
Which variable in the dataset or the model code to convert
unit : str
The new unit
original_unit : str
If no original unit is available in the datainfo this will be used
in_dataset : bool
Set to True if the conversion should be done in the dataset instead of in model code
Returns
-------
Model
Updated Pharmpy model
Examples
--------
>>> from pharmpy.modeling import load_example_model, convert_unit
>>> model = load_example_model("pheno")
>>> model = convert_unit(model, "WGT", "g")
"""
unit = Unit(unit)
column = model.datainfo.find_column(variable)
datavar = model.datainfo.find_variable(variable)
if column.type == 'idv':
raise ValueError(f"Cannot scale the independent variable ({variable})")
if original_unit is None:
original_unit = datavar.properties.get("unit", None)
if original_unit is None:
raise ValueError("Cannot find the original unit of {variable}")
original_unit = Unit(original_unit)
molar_mass = column.variable.properties.get("molar_mass", None)
if not original_unit.is_compatible_with(unit, molar_mass=molar_mass):
raise ValueError(f"Unable to convert from {original_unit} to {unit}: different dimensions.")
if molar_mass:
conversion_factor = (
Quantity(1.0, original_unit)
.convert_to(unit, molar_mass=Quantity(molar_mass, Unit("g/mol")))
.value
)
else:
conversion_factor = Quantity(1.0, original_unit).convert_to(unit).value
conversion_factor = (
int(conversion_factor) if int(conversion_factor) == conversion_factor else conversion_factor
)
if not in_dataset:
if column.type in {'dose', 'dv'}:
odes = get_and_check_odes(model)
_raise_if_rate(odes)
amounts = set(odes.amounts)
if column.type == 'dose':
dosing_cmts = odes.dosing_compartments
cb = CompartmentalSystemBuilder(odes)
bio = dosing_cmts[0].bioavailability
s = model.statements.find_assignment(bio)
if s is not None:
conversion_factor = update_factor(s.expression, conversion_factor, div=True)
if conversion_factor is None:
return model
new_expr = s.expression * conversion_factor
if new_expr == 1:
cb.set_bioavailability(dosing_cmts[0], new_expr)
i = model.statements.get_assignment_index(bio)
new_statements = (
model.statements.before_odes[0:i]
+ model.statements.before_odes[i + 1 :]
+ CompartmentalSystem(cb)
+ model.statements.after_odes
)
else:
new_statements = model.statements.reassign(s.symbol, new_expr)
else:
cb.set_bioavailability(dosing_cmts[0], conversion_factor)
new_statements = (
model.statements.before_odes
+ CompartmentalSystem(cb)
+ model.statements.after_odes
)
else:
after_odes = []
for s in model.statements.after_odes:
if not s.rhs_symbols.isdisjoint(amounts):
conversion_factor = update_factor(s.expression, conversion_factor)
if conversion_factor is None:
return model # FIXME: Could be more!
new_s = Assignment.create(s.symbol, s.expression / conversion_factor)
after_odes.append(new_s)
else:
after_odes.append(s)
new_statements = model.statements.before_odes + odes + after_odes
else:
original_symbol = Expr.symbol(variable)
scaled_symbol = Expr.symbol(f"SCALED_{variable}")
expr = conversion_factor * original_symbol
assignment = Assignment.create(scaled_symbol, expr)
new_statements = assignment + Statements.create(
[s.subs({original_symbol: scaled_symbol}) for s in model.statements]
)
model = model.replace(statements=new_statements)
else:
if original_unit == unit:
return model
df = get_and_check_dataset(model)
df, di = _scale_dataset_column(df, model.datainfo, column, datavar, conversion_factor, unit)
model = model.replace(dataset=df, datainfo=di)
model = model.update_source()
return model
def _raise_if_rate(odes):
for comp in odes.dosing_compartments:
for dose in comp.doses:
if isinstance(dose, Infusion) and dose.rate is not None:
raise ValueError("Cannot convert unit of infusions with rate in model code")
def _scale_dataset_column(df, di, column, datavar, conversion_factor, unit):
colname = column.name
new_var = datavar.set_property("unit", unit)
if isinstance(column.variable_mapping, DataVariable):
scaled_column = conversion_factor * df[colname]
new_col = column.replace(variable_mapping=new_var)
else:
ind = next(k for k, v in column.variable_mapping.items() if v == datavar)
scaled_column = conversion_factor * df.loc[df[column.variable_id] == ind, colname]
new_mapping = dict(column.variable_mapping)
new_mapping[ind] = new_var
new_col = column.replace(variable_mapping=new_mapping)
df = df.assign(**{colname: scaled_column})
di = di.set_column(new_col)
prov_new = (Drop.create(colname), AddColumn.create(colname))
di = di.replace(provenance=di.provenance + prov_new)
return df, di