"""Function Set"""
from __future__ import annotations
from functools import cached_property
from itertools import product
from typing import TYPE_CHECKING, Self
from IPython.display import Math, display
from .birth import make_P, make_T
from .cases import Elem, FCase, PCase
from .constraint import C
from .index import I
if TYPE_CHECKING:
from .parameter import P
from .theta import T
from .variable import V
[docs]
class F:
r"""
Provides relational operations between parameter, variable, parametric variable,
or function sets (F).
This class is not intended to be declared by the user directly.
It is constructed based on operations between parameter sets (P or list of numbers or number),
variable sets (V), or function sets (F).
:param one: First element.
:type one: int | float | list[int | float] | P | V | T | F, optional
:param two: Second element. Defaults to 0.
:type two: int | float | list[int | float] | P | V | T | F, optional
:param one_type: Type of `one`. Defaults to None.
:type one_type: Elem, optional
:param two_type: Type of `two`. Defaults to None.
:type two_type: Elem, optional
:param mul: Multiplication operation. Defaults to False.
:type mul: bool, optional
:param add: Addition operation. Defaults to False.
:type add: bool, optional
:param sub: Subtraction operation. Defaults to False.
:type sub: bool, optional
:param div: Division operation. Defaults to False.
:type div: bool, optional
:param consistent: If the function is already consistent, saves computation. Defaults to False.
:type consistent: bool, optional
:param case: Special function case. Defaults to None.
:type case: FCase, optional
:param parent: Parent function. Defaults to None.
:type parent: Self, optional
:param pos: Position of the function in the parent. Defaults to None.
:type pos: int, optional
:param index: Index of the function. Defaults to None.
:type index: tuple[I] | list[tuple[I]] | None, optional
:param issumhow: If the function is a summation, provides variable, index, and position. Defaults to None.
:type issumhow: tuple[V, I, int], optional
:param process: Whether to make matrices. Defaults to True.
:type process: bool, optional
:ivar one: First element
:vartype one: P | V | F
:ivar two: Second element
:vartype two: P | V | F
:ivar mul: Multiplication flag
:vartype mul: bool
:ivar add: Addition flag
:vartype add: bool
:ivar sub: Subtraction flag
:vartype sub: bool
:ivar div: Division flag
:vartype div: bool
:ivar rel: Relation symbol
:vartype rel: str
:ivar name: Name of the function, describing the operation
:vartype name: str
:ivar index: Index of the function set
:vartype index: I
:ivar array: List of elements in the function
:vartype array: list[P | T | V]
:ivar vars: List of variables in the function
:vartype vars: list[V]
:ivar struct: Structure of the function
:vartype struct: tuple[Elem, Elem]
:ivar rels: Relations in the function
:vartype rels: list[str]
:ivar elms: Elements in the function
:vartype elms: list[P | V]
:ivar isnegvar: If the function is :math:`-1 \cdot v` (negation)
:vartype isnegvar: bool
:ivar isconsistent: If the function is consistent
:vartype isconsistent: bool
:ivar n: Number id, set by the program
:vartype n: int
:ivar pname: Name set by the program
:vartype pname: str
:ivar elmo: Elements with relation (also a sesame street character)
:vartype elmo: dict[int, list[P | V | T | str]]
:raises ValueError: If none of `mul`, `add`, `sub`, or `div` is True
"""
def __init__(
self,
# ------Elements -----------
one: int | float | list[int | float] | P | V | T | Self | None = None,
two: int | float | list[int | float] | P | V | T | Self | None = None,
# --------- Types --------------
one_type: Elem | None = None,
two_type: Elem | None = None,
# ------- Relations -----------
mul: bool = False,
add: bool = False,
sub: bool = False,
div: bool = False,
# ------ Vector ---------------
parent: Self | None = None,
pos: int | None = None,
index: tuple[I] | list[tuple[I]] | None = None,
# ------- Other attributes -----
case: FCase | None = None,
consistent: bool = False,
issumhow: tuple[V, I, int] | None = None,
):
# set by program or birther function (parent)
self.parent = parent
# position of the function in the parent
self.pos = pos
# number id, set by the program based on order of declaration
self.n = None
# members of the function set
self._: list[F] = []
# maps indices to functions in the set
self.map = {}
self.index = index
# special function cases
self.case = case
self.consistent = consistent
# this gives you (variable, list of indices, set to sum, pos of set to sum)
self.issumhow = issumhow
# evaluates the value of the function
self.X: {int, float} = {}
# calculated variable
self.calculation: V = None
# category of the constraint
# constraints can be printed by category
self.category: str = ""
self._matrix: dict = {}
if self.issumhow:
# self.one =
self.mis = 0
self._one = one._
self._two = two._
self.one = one
self.two = two
self.index = one.index + (two.index,)
self.handle_rel(mul, add, sub, div, ignore=True)
self.one_type = Elem.F
self.two_type = Elem.V
self.make_args()
self._ = []
self.n = 0
self.name, self.pname = "", ""
self.A, self.P, self.Y, self.Z, self.B, self.F = ([] for _ in range(6))
self.variables = []
elif one is not None or two is not None:
# A basic Function is of the type
# P*V, V + P, V - P
# P can be a number (int or float), parameter set (P) or list[int | float]
# for multiplication P comes before variable
one, two = self.types(one, one_type, two, two_type)
if not self.consistent:
# internal operations are made to adhere to the consistent form
# this is notified to avoid doing this operation
# saves time and computational resources
one, one_type, two, two_type, add, sub, mul, div = self.make_consistent(
one, one_type, two, two_type, add, sub, mul, div
)
# now that the function is consistent
# set one and two
self.one = one()
self.two = two()
# if the entirety of self is being returned on call
# this prevents the entirety of self being an element of a function
# as variables can mutate in gana
if self.one_type == Elem.V and self.one.make_copy:
self.one = self.one.copy()
if self.two_type == Elem.V and self.two.make_copy:
self.two = self.two.copy()
# check the mismatch
# and rectify it if necessary
# all iterations in the function will be done using _one and _two
self.handle_mismatch()
# fix the relational attributes
self.handle_rel(mul, add, sub, div)
if not self.index:
# update the index
self.handle_index()
self.make_args()
# self.give_name()
# donot birth for birthed functions
if self.parent is None:
# needs the mismatch to generate matrices
self.generate_matrices()
# matrix is passed on to the birthed functions
self.birth_functions()
# make a matrix of positions
self.P = [f.P for f in self._ if f is not None]
else:
self.update_variables()
# self.variables = [
# v(*i) for v, i in zip(self.parent.variables, self.index)
# ]
else:
# if an empty function is created, attributes still need to be set
# empty functions are used when doing operations outside
# is more efficient computationally
# this is especially true when the final structure is known
# sum(variable_{i}) for example, where
# instead of adding the function recursively, the final structure can be passed
self.mis = 0
self._one = []
self._two = []
self.one = one
self.two = two
self.index = None
self.handle_rel(mul, add, sub, div, ignore=True)
self.one_type = one_type
self.two_type = two_type
self.make_args()
self._ = []
self.n = 0
self.name, self.pname = "", ""
self.A, self.P, self.Y, self.Z, self.B, self.F = ([] for _ in range(6))
self.variables = []
self.give_name()
@property
def matrix(self) -> dict:
"""Matrix as dict
Returns:
dict: Dictionary mapping of positions to values in A matrix
"""
if self._matrix:
return self._matrix
if self.parent is not None:
self._matrix = dict(zip(self.P, self.A))
else:
self._matrix = {f: f.matrix for f in self._}
return self._matrix
@property
def struct(self) -> tuple[Elem, Elem]:
"""Structure of the function"""
return (self.one_type, self.two_type)
@property
def elements(self) -> list[T | P | V]:
"""Elements in the function"""
return (
self.variables + self.mul_parameters + self.rhs_parameters + self.rhs_thetas
)
@property
def index_flat(self) -> list[tuple[I, ...] | set[tuple[I, ...]]]:
"""Flattens the index of the function"""
return (
[v.index for v in self.variables]
+ [p.index for p in self.mul_parameters]
+ [r.index for r in self.rhs_parameters]
+ [t.index for t in self.rhs_thetas]
)
# -----------------------------------------------------
# Helpers
# -----------------------------------------------------
[docs]
def categorize(self, category: str):
"""Categorizes the function
:param category: Category name
:type category: str
"""
self.category = category
for c in self._:
c.category = category
[docs]
def make_consistent(
self,
one: V | P | T | Self,
one_type: Elem | None,
two: V | P | T | Self,
two_type: Elem | None,
add: bool,
sub: bool,
mul: bool,
div: bool,
) -> tuple[V | P | T | Self, Elem, V | P | T | Self, Elem, bool, bool, bool | bool]:
"""Sets the function in a consistent form
Also makes parameters from int, float, or list[int|float] if needed
sets self.isconsistent to True
:param one: First element
:type one: V | P | T | F
:param one_type: Type of `one`
:type one_type: Elem | None
:param two: Second element
:type two: V | P | T | F
:param two_type: Type of `two`
:type two_type: Elem | None
:param add: Addition operation
:type add: bool
:param sub: Subtraction operation
:type sub: bool
:param mul: Multiplication operation
:type mul: bool
:param div: Division operation
:type div: bool
:returns: Consistent elements and their types along with relation flags
:rtype: tuple[V | P | T | F, Elem, V | P | T | F, Elem, bool, bool, bool | bool]
"""
# make consistent
self.isconsistent = True
# basically, keep variables (or function) to the left for add and sub
# if function involves a parameters and variable or function
# for multiplication keep variable (or function) on the right (P*V|F)
if (add and one_type in [Elem.P, Elem.T]) or (
mul and two_type in [Elem.P, Elem.T]
):
# for addition, always keep V|F + P
# for multiplication, always keep P * V|F
return two, two_type, one, one_type, add, sub, mul, div
elif sub and one_type in [Elem.P, Elem.T]:
# for subtraction, always keep -V + P
add = True
sub = False
one_type, two_type = two_type, one_type
return -two, two_type, one, one_type, add, sub, mul, div
elif div and two_type == Elem.P:
div = False
mul = True
one_type, two_type = two_type, one_type
return 1 / two, two_type, one, one_type, add, sub, mul, div
return one, one_type, two, two_type, add, sub, mul, div
[docs]
def handle_mismatch(self):
r"""Determine mismatch between indices
Stretches the shorter index to match the longer one.
This comes up in writing 'multiscale' constraints, e.g.:
.. math::
\mathbf{production}_{operation, hour} - \mathrm{Parameter}_{operation, time} \cdot \mathbf{capacity}_{operation, year} \leq \theta
One of the indices needs to be divisible by the other if there is a mismatch
Sets ``self.mis``, ``self._one``, ``self._two``, ``self.one``, ``self.two``
"""
if self.parent is None and (self.one and self.two):
# only applies for non birthed functions
lone = len(self.one)
ltwo = len(self.two)
# check the compatibility
if not lone % ltwo == 0 and not ltwo % lone == 0:
raise ValueError(
f"{self.one} with index {self.one.index} (length = {lone}) and {self.two} with {self.two.index} (length = {ltwo}) are not compatible"
)
if lone > ltwo:
# one is longer, keep as is
# negative informs that one is longer
self.mis = -int(lone / ltwo)
self._one, self._one_map = self.one._, self.one.map
# stretch two
self._two = [x for x in self.two._ for _ in range(-self.mis)]
self._two_map = [i for i in self.two.map for _ in range(-self.mis)]
# self._two_map = (
# self.one.map
# ) # [i for i in self.two.map for _ in range(-self.mis)]
elif ltwo > lone:
# two is longer, keep as is
# positive informs that two is longer
self.mis = int(ltwo / lone)
# stretch one
self._one = [x for x in self.one._ for _ in range(self.mis)]
self._one_map = [i for i in self.one.map for _ in range(self.mis)]
self._two, self._two_map = self.two._, self.two.map
else:
self.mis = 0
self._one, self._one_map = self.one._, self.one.map
self._two, self._two_map = self.two._, self.two.map
else:
# for birthed functions, there is never a mismatch
# moreover, the variables are passed on from the parent
self.mis = 0
# this handles both P and the rest
self._one, self._two = [self.one], [self.two]
# parameters will be passing floats and ints
if isinstance(self.one, (int, float)) or self.one is None:
self._one_map = self.two.map
else:
self._one_map = self.one.map
if isinstance(self.two, (int, float)) or self.two is None:
self._two_map = self.one.map
else:
self._two_map = self.two.map
[docs]
def handle_index(self):
r"""
Handles (compounds if needed) the index
Irrespective of the operation being done
The index of a function is index.one + index.two
Not in the mathematical sense!
i am just using the __add__ dunder for I to create
a function index basically.
This is of the form
.. math::
f(\mathbf{x}, \mathbf{y})_{i,j} = \mathbf{x}_{i} + \mathbf{y}_{j}
sets ``self.index``
"""
# index is a combination of one and two
index: tuple[tuple[I]] = []
# update the index
if self.one_type == Elem.F:
index += self.one.index
else:
# elif self.one_type in [Elem.V, Elem.T, Elem.P]:
index += (self.one.index,)
if self.two_type == Elem.F:
index += self.two.index
else:
# if self.two_type in [Elem.V, Elem.T, Elem.P]:
index += (self.two.index,)
self.index = tuple(index)
[docs]
def handle_rel(
self, mul: bool, add: bool, sub: bool, div: bool, ignore: bool = False
):
"""
Handles the relation of the function
sets self.args, self.mul, self.add, self.sub, self.div, self.rel
"""
# rel is used for printing
# For the purpose of operations signs are explicit bools
self.mul = mul
self.add = add
self.sub = sub
self.div = div
# rel looks good for printing
# one rel two
if self.mul:
self.rel = "×"
elif self.add:
self.rel = "+"
elif self.sub:
self.rel = "-"
elif self.div:
self.rel = "÷"
else:
if not ignore:
# if no operation is specified, raise an error
# this is to avoid confusion
# if you want to create a function without an operation, use F()
raise ValueError("one of mul, add, sub or div must be True")
[docs]
def make_args(self):
"""
Makes the arguments for the function
This is convenient for passing to the birther functions
and while making calls to the function.
Also sets self.args
"""
# these are passed on for mutation or birthing
self.args = {
"one_type": self.one_type,
"two_type": self.two_type,
"mul": self.mul,
"add": self.add,
"sub": self.sub,
"div": self.div,
"consistent": self.consistent,
"case": self.case,
}
[docs]
def birth_functions(self):
"""
Creates a vector of functions
Accordingly sets n
sets self._, self.n
"""
n_elements = min(len(self._one), len(self._two))
# _one and _two are used because
# they are created post handling an length mismatches
# for n, (one, one_idx, two, two_idx) in enumerate(
# zip(self._one, self._one_map, self._two, self._two_map)
# ):
_one_map = list(self._one_map)
_two_map = list(self._two_map)
for n in range(n_elements):
one = self._one[n]
one_idx = _one_map[n]
two = self._two[n]
two_idx = _two_map[n]
# only update the indices for F and V for functions
index: tuple[tuple[I]] = []
if self.one_type == Elem.F:
index += one_idx
else:
# if self.one_type == Elem.V:
index += (one_idx,)
if two is None:
# you can have just a P or T masquerading as a function
# so check are unnecessary
# f = one(*one.index)
index = tuple(index)
if isinstance(one, (int, float)):
# this happens when there is a skipped index
self.map[index] = None
self._.append(None)
continue
else:
f = one()
f.map[index] = f
else:
# this is done to handle skipping
# for shifted indices (.step)
if self.two_type == Elem.F:
index += two_idx
else:
index += (two_idx,)
index = tuple(index)
f = F()
f.parent = self
f.index = index
if one:
if self.one_type in [Elem.P, Elem.T]:
f.one = one
else:
f.one = one(*one_idx)
if self.two_type in [Elem.P, Elem.T]:
f.two = two
else:
f.two = two(*two_idx)
f.pos = n
f.one_type, f.two_type = self.one_type, self.two_type
f.mul, f.add, f.sub, f.div = self.mul, self.add, self.sub, self.div
f.rel = self.rel
f.consistent = self.consistent
f.case = self.case
f.issumhow = self.issumhow
f.update_variables()
f.give_name()
f.map[one_idx, two_idx] = f
f.A = self.A[n]
f.B = self.B[n]
# update the map
self.map[index] = f
# only member of the birthed function is itself
f._ = [f] # populate the set
self._.append(f)
[docs]
def update_variables(self):
"""Updates the variables in the function"""
self.variables: list[V] = []
if self.one_type == Elem.F:
# if function, extend the lists
self.variables.extend(self.one.variables)
elif self.one_type == Elem.V:
# if variable, append the variable
self.variables.append(self.one)
if self.two_type == Elem.F:
self.variables.extend(self.two.variables)
elif self.two_type == Elem.V:
self.variables.append(self.two)
# make a matrix of positions of the variables
self.P = [v.n for v in self.variables if v is not None]
[docs]
def give_name(self):
"""Gives a name to the function"""
# set by program
self.pname: str = ""
if self.case == FCase.SUM:
variable, over, _ = self.issumhow
self.name = f"sigma({variable}({variable.index}),{over})"
# elif self.case == FCase.NEGSUM:
# self.name = f'-sigma({self.variables[0].parent}[{self.variables[0].pos}:{self.variables[-1].pos}])'
else:
_name = ""
if self.one is not None:
_name += str(self.one)
if self.two is not None:
_name += f"{self.rel}{self.two}"
self.name = _name
[docs]
def types(
self,
one: V | P | T | Self,
one_type: Elem | None,
two: V | P | T | Self,
two_type: Elem | None,
) -> tuple[V | P | T | Self, V | P | T | Self]:
"""
Sets whether there is an element of a particular type in one and two
:param one: First element
:param one_type: Type of V | P | T | F
:param two: Second element
:param two_type: Type of V | P | T | F
:return: Updated elements
:rtype: tuple[V | P | T | F, V | P | T | F]
"""
# this is meant to be avoided as far as possible
# look at how the operations for each element is defined
# to some extent it is difficult to avoid some sort of instance check
# but if there is an instance check happening prior to the operation
# it is better to pass the type directly
# every instance check is time consumed, so at the least
# avoid multiple instance checks for the same element
def check_type(elem: P | V | T | Self):
from .parameter import P
from .theta import T
from .variable import V
if isinstance(elem, V):
return Elem.V
if isinstance(elem, P):
return Elem.P
if isinstance(elem, T):
return Elem.T
if isinstance(elem, F):
return Elem.F
if not one_type:
# If one type is not known
# perform instance check
one_type = check_type(one)
if not two_type:
two_type = check_type(two)
self.one_type = one_type
self.two_type = two_type
return one, two
[docs]
def generate_matrices(self):
r"""
Generates matrices
A - variable coefficients
P - position of continuous variables in program
Y - position of integer variables in program
Z - position of parametric variables in program
B - rhs parameters
F - pvar (theta) parameters
The general form is:
.. math::
\mathrm{A} \cdot \mathbf{V} = \mathrm{B} + \mathrm{F} \cdot \theta
sets ``self.A``, ``self.P``, ``self.Y``, ``self.Z``, ``self.B``, ``self.F``
"""
# TODO, pass this on for birthed functions
self.variables: list[V] = []
self.rhs_parameters: list[P] = []
self.mul_parameters: list[P] = []
self.rhs_thetas: list[T] = []
# theta parameter multipliers
self.F = []
# theta parameter positions
self.Z = []
# The following are a list of cases:
# Base cases:
# V + P (parameter is always on the right)
# V - P (parameter is always on the right)
# P*V (parameter is always on the left)
# Function cases
# F + P (parameter is computed in total and pushed to the right)
# F - P (parameter is computed in total and pushed to the right)
# Compound cases:
# V + F (both have A, two has B)
# V - F (both have A, two has B)
# V*F (not implemented yet)
# these (SUM, NEGSUM) are just boxes
# if self.case == FCase.SUM:
# # all positive
# self.A = [[1] * len(self.index)] * len(self.index)
# self.B = [0] * len(self.index)
# elif self.case == FCase.NEGSUM:
# # all negative
# self.A = [[-1] * len(self.index)] * len(self.index)
# self.B = [0] * len(self.index)
# else:
# update the elements in the function
if self.one_type == Elem.F:
# two can be F, V, P, or T
self.variables.extend(self.one.variables)
# irrespective, we only need to take A here
if self.mis > 0:
# if there is a mismatch,
# positive indicates that two is longer
# so scale the A to match
self.A = [row[:] for _ in range(self.mis) for row in self.one.A]
else:
self.A = self.one.A
elif self.one_type == Elem.V:
# two can be F, V, P, or T
self.variables.append(self.one)
# irrespective, we only need to take A here
if self.mis > 0:
# if there is a mismatch,
# positive indicates that two is longer
# so scale the A to match
self.A = [row[:] for _ in range(self.mis) for row in self.one.A]
else:
self.A = self.one.A
elif self.one_type == Elem.T:
# TODO Bilevel: this is only possible for multiplication of variable/function with theta
pass
elif self.one_type == Elem.P:
# this is only possible if mul is True
# and two is V
self.mul_parameters.append(self.one)
if self.mul:
if self.two_type == Elem.F and self.two.case == FCase.SUM:
if self.two.parent is not None:
self.A = [self.one._[n] * i for n, i in enumerate(self.two.A)]
else:
self.A = [
[self.one._[n] * i for i in j]
for n, j in enumerate(self.two.A)
]
else:
# so you A is a the parameter matrix
# self.A = self.one.A
if self.mis > 0:
# if there is a mismatch,
# positive indicates that two is longer
self.A = [row[:] for _ in range(self.mis) for row in self.one.A]
else:
self.A = self.one.A
# at this point, it can be of the type P*(V|F)
# if F = V +- P, we use the operation P*V +- P*P
# so P always shows up at two
# if this is just of the form (P*V) or (P*F) where F = P*V
# B will not be set if self.two_type is not P
# it is just safe to set a B here, if needed it will be overwritten
if self.mis > 0:
# if there is a mismatch,
# positive indicates that two is longer
# make a B of length of two
self.B = [0] * len(self._two)
else:
# if one is longer
# or there is no mismatch (either one or two will do)
self.B = [0] * len(self._one)
# update the elements in the function
if self.two_type == Elem.F:
# one could have been a V, T, or F
self.variables.extend(self.two.variables)
if self.one_type in [Elem.F, Elem.V]:
# if V or F, A definitely exists, so update A
if self.mis < 0:
# if there is a mismatch,
# negative indicates that one is longer
# scale two's A to correct mismatch
_A = [row[:] for _ in range(-self.mis) for row in self.two.A]
else:
_A = self.two.A
if self.add:
self.A = [a + b for a, b in zip(self.A, _A)]
if self.sub:
self.A = [a + [-bb for bb in b] for a, b in zip(self.A, _A)]
elif self.two_type == Elem.V:
# one could have been a V, T, or F
self.variables.append(self.two)
if self.one_type in [Elem.F, Elem.V]:
# if V or F, A definitely exists, so update A
if self.mis < 0:
# if there is a mismatch,
# negative indicates that one is longer
# scale two's A to correct mismatch
_A = [row[:] for _ in range(-self.mis) for row in self.two.A]
else:
_A = self.two.A
if self.add:
self.A = [a + b for a, b in zip(self.A, _A)]
if self.sub:
self.A = [a + [-bb for bb in b] for a, b in zip(self.A, _A)]
elif self.two_type == Elem.T:
# if self.one_type == Elem.F and self.one.two_type == Elem.T:
# if self.add:
# self.F = [i + [-1] for i in self.one.F]
# if self.sub:
# self.F = [i + [1] for i in self.one.F]
# else:
# if self.add:
# self.F = [[-1]] * len(self._one)
# if self.sub:
# self.F = [[1]] * len(self._one)
self.rhs_thetas.append(self.two)
if self.two_type == Elem.P:
# this is only possible for addition and subtraction
if self.add:
self.rhs_parameters.append(self.two)
# if addition, since B is rhs, negate
if self.mis < 0:
# if there is a mismatch,
# negative indicates that one is longer
# so scale the parameter to match
self.B = [-b for b in self.two._] * (-self.mis)
else:
self.B = [-b for b in self.two._]
elif self.sub:
self.rhs_parameters.append(self.two)
# if subtraction, since B is rhs, keep as is
if self.mis < 0:
# if there is a mismatch,
# negative indicates that one is longer
# so scale the parameter to match
self.B = self.two._ * (-self.mis)
else:
self.B = self.two._
else:
# if not caught by the parameter check
# set a B of zeros
self.B = [0] * len(self.A)
# -----------------------------------------------------
# Printing
# -----------------------------------------------------
[docs]
def latex(self) -> str:
"""LaTeX Equation"""
if self.case == FCase.CALC:
# if this is a calculated variable
if self.calculation.case == FCase.SUM:
# self.case = FCase.SUM
# two_ = self.latex()
# self.case = FCase.CALC
self.case = FCase.SUM
two = self.latex()
self.case = FCase.CALC
return rf"{self.calculation.latex()} = {two}"
if self.one_type == Elem.P and self.parent:
# if this is a child function with a parameter
# one will int/float
one = self.one
else:
one = self.one.latex()
return rf"{self.calculation.latex()} = {one} \cdot {self.two.latex()}"
if self.case == FCase.FVAR:
# if this is a variable being treated as a function
return self.two.latex()
if self.case in [FCase.SUM, FCase.NEGSUM]:
# if this is a summation
v, over, pos = self.issumhow
# the position of the index over which it is being summed is passed by sigma
# use i for summed index
index = [
(
"i"
if n == pos
else (
i[0].ltx
if isinstance(i, list)
else i.ltx.replace("[", "").replace("]", "")
)
)
for n, i in enumerate(v.index)
]
index = ", ".join(index)
if v.ltx:
oneissum = v.ltx
else:
oneissum = v.name
ltx = rf"\sum_{{i \in {over.ltx}}} {oneissum}_{{{index}}}"
if self.case == FCase.NEGSUM:
# if this is a summation
# return the summation
return rf"-{ltx}"
return rf"{ltx}"
if self.one is None and self.mul:
one = "0"
elif self.one is not None:
# _one = self.one(self.index.one)
if self.one_type == Elem.P and self.parent:
# if this is a child function with a parameter
# one will int/float
one = self.one
else:
one = self.one.latex()
else:
one = ""
if self.two is not None:
# _two = self.two(self.index.two)
if self.two_type == Elem.P and self.parent:
# if this is a child function with a parameter
# two will int/float
two = self.two
else:
two = self.two.latex()
else:
two = None
if two is None:
return rf"{one}"
if one is None:
return rf"{two}"
if self.add:
return rf"{one} + {two}"
if self.sub:
if (
self.one_type == Elem.F
and self.one.struct != (Elem.P, Elem.V)
and self.two_type == Elem.F
and self.two.struct != (Elem.P, Elem.V)
and not self.two.case == FCase.SUM
):
# bracket are important for function minuses
# alternatively, the entire function can be negated
return rf"({one}) - ({two})"
if (
self.two_type == Elem.F
and self.two.struct != (Elem.P, Elem.V)
and not self.two.case == FCase.SUM
):
return rf"{one} - ({two})"
return rf"{one} - {two}"
if self.mul:
# handling special case where something is multiplied by -1
if self.case == FCase.NEGVAR:
# if self.one and self.one.isnum and self.one[0] in [-1, -1.0]:
return rf"-{two}"
if self.one_type == Elem.F:
# if one is a function, it should be bracketed
return rf"({one}) \cdot {two}"
if self.two_type == Elem.F:
# if two is a function, it should be bracketed
return rf"{one} \cdot ({two})"
if self.one_type == Elem.F and self.two_type == Elem.F:
# if both are functions, they should be bracketed
return rf"({one}) \cdot ({two})"
return rf"{one} \cdot {two}"
if self.div:
# not the most developed gana operation, yet
return rf"\frac{{{one}}}{{{two}}}"
[docs]
def show(self, descriptive: bool = False):
"""
Display the function
:param descriptive: Whether to show all birthed functions, defaults to False
:type descriptive: bool, optional
"""
if descriptive:
for f in self._:
display(Math(rf"[{f.n}]" + r"\text{ }" + f.latex()))
else:
display(Math(rf"[{self.n}]" + r"\text{ }" + self.latex()))
[docs]
@cached_property
def longname(self):
"""Gives a longer more descriptive name for the function"""
_name = ""
if self.one is not None:
if isinstance(self.one, (int, float)):
_name += str(self.one)
else:
_name += self.one.longname
if self.two is not None:
if isinstance(self.two, (int, float)):
_name += f"{self.rel}{self.two}"
else:
_name += f"{self.rel}{self.two.longname}"
return _name
# -----------------------------------------------------
# Operators
# -----------------------------------------------------
def __neg__(self):
if self.case == FCase.NEGVAR:
# if function is a negated variable
# return the variable
return self.two
if self.one_type == Elem.V:
# negative of variable is -1*v
# which is a function
one_type = Elem.F
else:
one_type = self.one_type
if self.case == FCase.SUM:
# -(E1 + ... + En) = -E1 - ... - En
# create and return a negative summation
return
if self.add:
return F(
# -(E1 + E2) = -E1 - E2
one=-self.one,
sub=True,
two=self.two,
one_type=one_type,
two_type=self.two_type,
)
if self.sub:
# -(E1 - E2) = -E1 + E2
return F(
one=-self.one,
add=True,
two=self.two,
one_type=one_type,
two_type=self.two_type,
)
if self.mul:
# -(E1 * E2) = -E1 * E2
return F(
one=-self.one,
mul=True,
two=self.two,
one_type=one_type,
two_type=self.two_type,
)
if self.div:
# -(E1 / E2) = -E1 / E2
return F(
one=-self.one,
div=True,
two=self.two,
one_type=one_type,
two_type=self.two_type,
)
def __pos__(self):
return self
def __add__(
self,
other: (
V
| P
| T
| Self
| int
| float
| tuple[int | float]
| list[int | float | tuple[int | float]]
| None
),
) -> Self:
from .parameter import P
# F + None = F
if other is None:
# if adding with nothing, return itself
return self
if isinstance(other, (int, float)):
# if adding with a number
# F + 0 = F
if other in [0, 0.0]:
# if adding with 0, return self
return self
if self.two_type == Elem.P:
if self.add:
# of the type, (V | F + P) + P
return F(
one=self.one,
add=True,
two=self.two + make_P(other, index=self.two.index),
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if self.sub:
# of the type, (V | F - P1) + P2
# add if P2 > P1
two = make_P(other, index=self.two.index) - self.two
if two == 0:
# if equal to zero, return one
return self.one
if self.two.case in [PCase.NEGNUM, PCase.NUM]:
# if subtracting a number from a parameter
# return the parameter
if two._[0] < 0:
# this is leading to a negative parameter
return F(
one=self.one,
sub=True,
two=two,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if other < 0:
return F(
one=self,
sub=True,
two=make_P(other, index=self.two.index),
one_type=Elem.F,
two_type=Elem.P,
consistent=True,
)
return F(
one=self,
add=True,
two=make_P(other, index=self.two.index),
one_type=Elem.F,
two_type=Elem.P,
consistent=True,
)
if isinstance(other, tuple):
# if adding with a tuple
# this is a theta
other = make_T(other, index=self.one.index)
if isinstance(other, list):
# check 0th to see if tuple
if isinstance(other[0], tuple):
# if adding with a list of tuples
# this is a theta
other = make_T(other)
return F(
one=self, add=True, two=other, one_type=Elem.F, two_type=Elem.T
)
if self.two_type == Elem.P:
if self.add:
# of the type, V | F + P + P
return F(
one=self.one,
add=True,
two=self.two + make_P(other),
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if self.sub:
# of the type, V | F - P + P
return F(
one=self.one,
sub=True,
two=make_P(other) - self.two,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
return F(
one=self,
add=True,
two=make_P(other),
one_type=Elem.F,
two_type=Elem.P,
)
if isinstance(other, P):
# if adding with a parameter
if self.two_type == Elem.P:
if self.add:
# of the type, V | F + P1 + P2
return F(
one=self.one,
add=True,
two=self.two + other,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if self.sub:
# of the type, V | F - P + P
return F(
one=self.one,
sub=True,
two=other - self.two,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
# these are of the type
# F + P where F can be P*V or V/P
if isinstance(other, F):
return F(one=self, add=True, two=other, one_type=Elem.F, two_type=Elem.F)
return F(one=self, add=True, two=other, one_type=Elem.F, issumhow=self.issumhow)
def __radd__(
self,
other: (
V
| P
| T
| Self
| int
| float
| tuple[int | float]
| list[int | float | tuple[int | float]]
| None
),
) -> Self:
return self + other
def __sub__(
self,
other: (
V
| P
| T
| Self
| int
| float
| tuple[int | float]
| list[int | float | tuple[int | float]]
| None
),
) -> Self:
from .parameter import P
if other is None:
# if subtracting with nothing, return itself
return self
if isinstance(other, (int, float)):
# if substracting with a number
if other in [0, 0.0]:
# if subtracting with 0, return self
return self
if self.two_type == Elem.P:
if self.add:
# of the type, V | F + P + P
two = self.two - make_P(other, index=self.two.index)
if two.case in [PCase.NUM, PCase.NEGNUM]:
if two > 0:
# if self.two - other is positive
return F(
one=self.one,
add=True,
two=two,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if two < 0:
# if self.two - other is negative
return F(
one=self.one,
sub=True,
two=-two,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
else:
# if they are equal only self.one remains
return self.one
else:
# if self.two - other is not a number
return F(
one=self.one,
add=True,
two=two - make_P(other, index=self.two.index),
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if self.sub:
# for substraction # of the type, V | F - P - P := V | F - (P + P)
return F(
one=self.one,
sub=True,
two=two + make_P(other, index=self.two.index),
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if not self.two:
index = self.one.index
elif not self.one:
index = self.two.index
else:
if self.two and self.one and len(self.two) > len(self.one):
index = self.two.index
else:
index = self.one.index
return F(
one=self,
sub=True,
two=make_P(other, index=index),
one_type=Elem.F,
two_type=Elem.P,
consistent=True,
)
if isinstance(other, tuple):
# if subtracting with a tuple
# this is a theta
other = make_T(other, index=self.one.index)
if isinstance(other, list):
# check 0th to see if tuple
if isinstance(other[0], tuple):
# if subtracting with a list of tuples
# this is a theta
other = make_T(other)
return F(
one=self, sub=True, two=other, one_type=Elem.F, two_type=Elem.T
)
if self.two_type == Elem.P:
if self.add:
# of the type, V | F + P + P
return F(
one=self.one,
add=True,
two=self.two + make_P(other),
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if self.sub:
# of the type, V | F - P + P
return F(
one=self.one,
sub=True,
two=make_P(other) - self.two,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
return F(
one=self,
sub=True,
two=make_P(other),
one_type=Elem.F,
two_type=Elem.P,
consistent=True,
)
if isinstance(other, P):
# if subtracting with a parameter
if self.two_type == Elem.P:
if self.add:
two = self.two - other
if two.case in [PCase.NUM, PCase.NEGNUM]:
# of the type, V | F + P1 - P2 := V | F + P3
if two > 0:
return F(
one=self.one,
add=True,
two=two,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
elif two < 0:
# of the type, V | F + P1 - P2:= V | F - P3
return F(
one=self.one,
sub=True,
two=-two,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
else:
return F(
one=self.one,
add=True,
two=self.two + other,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if self.sub:
# of the type, V | F - (P + P)
return F(
one=self.one,
sub=True,
two=self.two + other,
one_type=self.one_type,
two_type=Elem.P,
consistent=True,
)
if isinstance(other, F):
return F(one=self, sub=True, two=other, one_type=Elem.F, two_type=Elem.F)
return F(one=self, sub=True, two=other, one_type=Elem.F, issumhow=self.issumhow)
def __rsub__(
self,
other: (
V
| P
| T
| Self
| int
| float
| tuple[int | float]
| list[int | float | tuple[int | float]]
| None
),
) -> Self:
return -self + other
def __mul__(
self,
other: (
V
| P
| T
| Self
| int
| float
| tuple[int | float]
| list[int | float | tuple[int | float]]
| None
),
) -> Self:
from .theta import T
from .variable import V
if other is None:
# multiplying by nothing
return None
if isinstance(other, (int, float)):
if other in [0, 0.0]:
# multiplying by 0
return 0
if other in [1, 1.0]:
# multiplying by 1
return self
# make a numeric parameter
if self.case == FCase.SUM:
one = make_P([other] * len(self))
one.index = self.index
return F(
one=one,
mul=True,
two=self,
one_type=Elem.P,
two_type=Elem.F,
)
two = make_P(other, index=self.one.index)
if self.mul:
return F(one=two * self.one, mul=True, two=self.two)
if self.div:
return F(one=two * self.one, div=True, two=self.two)
if self.add:
if two < 0:
# if multiplying a negative number
return F(one=two * self.one, sub=True, two=-two * self.two)
if two > 0:
# if multiplying a positive number
return F(one=two * self.one, add=True, two=two * self.two)
if self.sub:
if two < 0:
# if multiplying a negative number
return F(one=two * self.one, add=True, two=-two * self.two)
if two > 0:
# if multiplying a positive number
return F(one=two * self.one, sub=True, two=two * self.two)
if isinstance(other, list):
# check 0th to see if tuple
if isinstance(other[0], tuple):
# if multiplying with a list of tuples
# this is a theta
raise NotImplementedError(
f"{self}*{other}: Multiplication with a parametric variable is not implemented yet."
)
# make a parameter from the list
two = make_P(other)
# this by default is a parameter set
if self.case == FCase.SUM:
return F(
one=two,
mul=True,
two=self,
one_type=Elem.P,
two_type=Elem.F,
)
if self.mul:
return F(one=two * self.one, mul=True, two=self.two)
if self.div:
return F(one=two * self.one, div=True, two=self.two)
if self.add:
return F(one=two * self.one, add=True, two=two * self.two)
if self.sub:
return F(one=two * self.one, sub=True, two=two * self.two)
if isinstance(other, tuple):
# if multiplying with a tuple
# this is a theta
raise NotImplementedError(
f"{self}*{other}: Multiplication of function and parametric variable is not implemented yet."
)
if isinstance(other, F):
raise NotImplementedError(
f"{self}*{other}: Multiplication of two functions is not implemented yet."
)
if isinstance(other, V):
raise NotImplementedError(
f"{self}*{other}: Multiplication of variable and function is not implemented yet."
)
if isinstance(other, T):
raise NotImplementedError(
f"{self}*{other}: Multiplication of function and parametric variable is not implemented yet."
)
# what remains is P
if self.case == FCase.SUM:
return F(one=other, mul=True, two=self, one_type=Elem.P, two_type=Elem.F)
if self.mul:
return F(one=other * self.one, mul=True, two=self.two)
if self.div:
return F(one=other * self.one, div=True, two=self.two)
if self.add:
if other < 0:
# multiplying a negative number
return F(one=other * self.one, sub=True, two=-other * self.two)
if other > 0:
# multiplying a positive number
return F(one=other * self.one, add=True, two=other * self.two)
if self.sub:
if other < 0:
# multiplying a negative number
return F(one=other * self.one, add=True, two=-other * self.two)
if other > 0:
# multiplying a positive number
return F(one=other * self.one, sub=True, two=other * self.two)
def __rmul__(
self,
other: (
V
| P
| T
| Self
| int
| float
| tuple[int | float]
| list[int | float | tuple[int | float]]
| None
),
) -> Self:
return self * other
def __truediv__(
self,
other: (
V
| P
| T
| Self
| int
| float
| tuple[int | float]
| list[int | float | tuple[int | float]]
| None
),
) -> Self:
return self * (1 / other)
# -----------------------------------------------------
# Relational
# -----------------------------------------------------
def __eq__(self, other: Self | P | V | T):
return C(self - other)
def __le__(self, other: Self | P | V | T):
return C(self - other, leq=True)
def __ge__(self, other: Self | P | V | T):
return C(-self + other, leq=True)
def __lt__(self, other: Self | P | V | T):
return self <= other
def __gt__(self, other: Self | P | V | T):
return self >= other
# -----------------------------------------------------
# Vector
# -----------------------------------------------------
def __call__(self, *key: list[I]) -> Self:
if not key or (key == self.index):
# if the index is an exact match
# or no key is passed
return self
# if a subset is passed,
# first create a product to match
# the indices
indices = list(product(*[list(product(*k)) for k in key]))
# create a new function set to return
f = F(**self.args)
f.name, f.pname, f.n = self.name, self.pname, self.n
f.A = []
f.B = []
f.P = []
f.index = key
# should be able to map these
for n, index in enumerate(indices):
# this helps weed out any None indices
# i.e. skips
if index is None:
function = None
else:
# here the index could match the entire function index or
# be the index of one or two
# we can check whether it matches the entire function index first
if index in self.map:
# if the index matches the entire function index
# then we can just use the function from the map
function = self.map[index]
elif index[0] in self._one_map:
# if the index matches the first element of _one_map
# this is a one type function
index = list(self.map)[list(self._one_map).index(index[0])]
elif index[0] in self._two_map:
# if the index matches the second element of _two_map
# this is a two type function
index = list(self.map)[list(self._two_map).index(index[0])]
elif (
tuple(index[0] for _ in range(len(self.one.elements)))
in self._one_map
):
# if the index matches the first element of _one_map
index = list(self.map)[
list(self._one_map).index(
tuple(index[0] for _ in range(len(self.one.elements)))
)
]
elif (
tuple(index[0] for _ in range(len(self.two.elements)))
in self._two_map
):
# if the index matches the second element of _two_map
index = list(self.map)[
list(self._two_map).index(
tuple(index[0] for _ in range(len(self.two.elements)))
)
]
function: Self = self.map[index]
f.map[index] = function
f.one = function.one
f.two = function.two
f._one.append(self._one[n])
f._two.append(self._two[n])
# f.generate_matrices()
var_index = self.index_flat[: len(self.variables)]
mul_index = self.index_flat[
len(self.variables) : len(self.variables) + len(self.mul_parameters)
]
rhs_index = self.index_flat[
len(self.variables)
+ len(self.mul_parameters) : len(self.variables)
+ len(self.mul_parameters)
+ len(self.rhs_parameters)
]
theta_index = self.index_flat[
len(self.variables)
+ len(self.mul_parameters)
+ len(self.rhs_parameters) :
]
f.variables = [v(*i) for v, i in zip(self.variables, var_index)]
f.mul_parameters = [
p(*i) for p, i in zip(self.mul_parameters, mul_index)
]
f.rhs_parameters = [
r(*i) for r, i in zip(self.rhs_parameters, rhs_index)
]
f.rhs_thetas = [t(*i) for t, i in zip(self.rhs_thetas, theta_index)]
f.A.append(self.A[function.n])
f.B.append(self.B[function.n])
f.P.append(self.P[function.n])
f._.append(function)
return f
def __getitem__(self, pos: int) -> F:
return self._[pos]
def __iter__(self) -> Self:
return iter(self._)
def __len__(self):
return len(self._)
# -----------------------------------------------------
# Solution
# -----------------------------------------------------
[docs]
def solution(self, n_sol: int = 0) -> float | int | list[float | int]:
"""Evaluate the value of the function.
:param n_sol: The solution number to evaluate, defaults to 0
:type n_sol: int, optional
:returns: Evaluated function value(s)
:rtype: float | int | list[float | int]
"""
# if this is a function container, evaluate all its children
if self.parent is None:
# if this is a function, set
# do evaluations for all the children and return
return [f.solution(n_sol) for f in self._]
def function_eval(f: Self):
"""Handle special function cases without recursion."""
match f.case:
case FCase.SUM:
return sum(v.X[n_sol] for v in f.variables)
case FCase.NEGSUM:
return -sum(v.X[n_sol] for v in f.variables)
case FCase.NEGVAR:
return -f.two.X[n_sol]
case FCase.FVAR:
return f.two.X[n_sol]
case _:
return f.solution(n_sol)
def resolve(elem, elem_type):
"""Return evaluated value based on element type."""
match elem_type:
case Elem.P:
return elem
case Elem.V:
return elem.X[n_sol]
case Elem.F:
return function_eval(elem)
case _:
return None
one = resolve(self.one, self.parent.one_type)
two = resolve(self.two, self.parent.two_type)
# arithmetic evaluation
if self.mul:
self.X[n_sol] = (one or 1) * (two or 1)
elif self.div:
self.X[n_sol] = one / two
elif self.add:
self.X[n_sol] = (one or 0) + (two or 0)
elif self.sub:
self.X[n_sol] = (one or 0) - (two or 0)
return self.X[n_sol]
[docs]
def eval(
self, *values: float | int | list[float | int]
) -> float | int | list[float | int]:
"""Evaluate the function for given parameter values.
:param values: Values for variables in the order they feature in the function
:type values: float | int | list[float | int]
:returns: Evaluated function value(s)
:rtype: float | int | list[float | int]
"""
if self.parent:
return sum(a * v for a, v in zip(self.A, values))
_sol = []
for f, v in zip(self._, *values):
_sol.append(f.eval(*v))
return _sol
# -----------------------------------------------------
# Hashing
# -----------------------------------------------------
def __str__(self):
return self.name
def __repr__(self):
return self.name
def __hash__(self):
try:
return hash(self.name)
except AttributeError:
# Fallback for uninitialized state during unpickling
return id(self)