keecas 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
keecas-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.1
2
+ Name: keecas
3
+ Version: 0.1.1
4
+ Summary: A set of tools for symbolic math computation done in a jupyter notebook, aimed specifically for Quarto. It is based mostly on sympy.
5
+ Author: kompre
6
+ Author-email: s.follador@gmail.com
7
+ Requires-Python: >=3.12,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Dist: flatten-dict (>=0.4.2,<0.5.0)
11
+ Requires-Dist: ipython (>=8.26.0,<9.0.0)
12
+ Requires-Dist: pint (>=0.24.3,<0.25.0)
13
+ Requires-Dist: pipe (>=2.2,<3.0)
14
+ Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
15
+ Requires-Dist: regex (>=2024.7.24,<2025.0.0)
16
+ Requires-Dist: ruamel-yaml (>=0.18.6,<0.19.0)
17
+ Requires-Dist: sympy (>=1.13.1,<2.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+
keecas-0.1.1/README.md ADDED
File without changes
@@ -0,0 +1,52 @@
1
+ # dataframe
2
+ from .dataframe import Dataframe
3
+
4
+ # display
5
+ from .display import (
6
+ options,
7
+ show_eqn,
8
+ verifica,
9
+ dict_to_eq,
10
+ eq_to_dict,
11
+ )
12
+
13
+ # pipe_command
14
+ from . import pipe_command as pc
15
+
16
+ # initialize pint
17
+ from .pint_sympy import unitregistry as u
18
+
19
+ u.formatter.default_format = ".2f~P"
20
+
21
+ # initialize sympy
22
+ import sympy as sp
23
+
24
+ from sympy import latex, Eq, Le, symbols, Basic, Dict, S, ImmutableDenseMatrix as Matrix
25
+
26
+ ## latex printing settings
27
+ sp.init_printing(mul_symbol=options.default_mul_symbol, order="none")
28
+ platex = lambda x: latex(x, mode="inline", mul_symbol=options.default_mul_symbol)
29
+
30
+ ## common sympy functions
31
+
32
+
33
+ __all__ = [
34
+ "Dataframe",
35
+ "show_eqn",
36
+ "options",
37
+ "verifica",
38
+ "dict_to_eq",
39
+ "eq_to_dict",
40
+ "pc",
41
+ "u",
42
+ "sp",
43
+ "latex",
44
+ "Eq",
45
+ "Le",
46
+ "symbols",
47
+ "Basic",
48
+ "Dict",
49
+ "S",
50
+ "Matrix",
51
+ "platex",
52
+ ]
@@ -0,0 +1,271 @@
1
+ import copy
2
+
3
+ from itertools import chain
4
+
5
+
6
+ class Dataframe(dict):
7
+ def __init__(self, *args, filler=None, **kwargs):
8
+ super().__init__()
9
+ self._width = 0
10
+ self._filler = filler
11
+
12
+ if (
13
+ args
14
+ and isinstance(args[0], list)
15
+ and all(isinstance(item, dict) for item in args[0])
16
+ ):
17
+ self._init_from_list_of_dicts(args[0])
18
+ else:
19
+ self._update_initial(*args, **kwargs)
20
+
21
+ def _init_from_list_of_dicts(self, list_of_dicts):
22
+ if not list_of_dicts:
23
+ return
24
+
25
+ keys = list(
26
+ list_of_dicts[0].keys()
27
+ ) # the keys are determined by the first dict (order is important!)
28
+ for key in keys:
29
+ self[key] = [d.get(key, self._filler) for d in list_of_dicts]
30
+
31
+ self._width = len(list_of_dicts)
32
+
33
+ def _update_initial(self, *args, **kwargs):
34
+ if args:
35
+ if len(args) > 1:
36
+ raise TypeError(
37
+ "update expected at most 1 arguments, got %d" % len(args)
38
+ )
39
+ other = dict(args[0])
40
+ other.update(kwargs)
41
+ else:
42
+ other = kwargs
43
+
44
+ for key, value in other.items():
45
+ if isinstance(value, list):
46
+ self[key] = value
47
+ else:
48
+ self[key] = [value]
49
+
50
+ self._width = max(len(value) for value in self.values()) if self else 0
51
+ self._validate_and_fill_data()
52
+
53
+ def _validate_and_fill_data(self):
54
+ if not self:
55
+ return
56
+
57
+ for key, value in self.items():
58
+ if len(value) < self._width:
59
+ self[key] = value + [self._filler] * (self._width - len(value))
60
+
61
+ def update(self, *args, **kwargs):
62
+ if args:
63
+ if len(args) > 1:
64
+ raise TypeError(
65
+ "update expected at most 1 arguments, got %d" % len(args)
66
+ )
67
+ other = dict(args[0])
68
+ other.update(kwargs)
69
+ else:
70
+ other = kwargs
71
+
72
+ # Convert all values to lists if they aren't already
73
+ for key, value in other.items():
74
+ if not isinstance(value, list):
75
+ other[key] = [value]
76
+
77
+ # Find the maximum length of any value in both self and other
78
+ max_length = max(
79
+ [len(value) for value in self.values()]
80
+ + [len(value) for value in other.values()]
81
+ + [self._width]
82
+ )
83
+
84
+ # Update existing keys and add new ones
85
+ for key, value in other.items():
86
+ self[key] = value + [self._filler] * (max_length - len(value))
87
+
88
+ # Adjust existing keys that weren't in the update data
89
+ for key in self:
90
+ if key not in other:
91
+ if len(self[key]) < max_length:
92
+ self[key] = self[key] + [self._filler] * (
93
+ max_length - len(self[key])
94
+ )
95
+ else:
96
+ self[key] = self[key][:max_length]
97
+
98
+ # Update width
99
+ self._width = max_length
100
+
101
+ def append(self, other, strict=True):
102
+ if isinstance(other, Dataframe):
103
+ if strict:
104
+ other = {key: other[key] for key in self.keys() if key in other}
105
+
106
+ for key in self.keys():
107
+ self[key].append(
108
+ other[key][0]
109
+ if key in other and len(other[key]) > 0
110
+ else self._filler
111
+ )
112
+ elif isinstance(other, dict):
113
+ if strict:
114
+ other = {key: other[key] for key in self.keys() if key in other}
115
+
116
+ for key in self.keys():
117
+ self[key].append(other[key] if key in other else self._filler)
118
+ else:
119
+ for key in self.keys():
120
+ self[key].append(other)
121
+
122
+ self._width += 1
123
+
124
+ def extend(self, other, strict=True):
125
+ if isinstance(other, Dataframe):
126
+ # filter keys
127
+ if strict:
128
+ other = Dataframe(
129
+ {key: other[key] for key in self.keys() if key in other}
130
+ )
131
+ if not other:
132
+ return
133
+
134
+ other_width = other.width
135
+
136
+ extra_keys = [k for k in other.keys() if k not in self.keys()]
137
+
138
+ for key in chain(self.keys(), extra_keys):
139
+ match (key in self, key in other):
140
+ case (True, True):
141
+ self[key].extend(
142
+ other[key]
143
+ + [self._filler] * (other_width - len(other[key]))
144
+ )
145
+ case (True, False):
146
+ self[key].extend([self._filler] * other_width)
147
+ case (False, True):
148
+ self[key] = (
149
+ [self._filler] * self._width
150
+ + other[key]
151
+ + [self._filler] * (other_width - len(other[key]))
152
+ )
153
+
154
+ self._width += other_width
155
+ elif isinstance(other, dict):
156
+ # filter keys
157
+ if strict:
158
+ other = {key: other[key] for key in self.keys() if key in other}
159
+ if not other:
160
+ return
161
+ self.extend(Dataframe(other), strict=strict)
162
+
163
+ # max_len = max(len(v) if isinstance(v, list) else 1 for v in other.values())
164
+
165
+ # for key in self.keys():
166
+ # if key in other:
167
+ # v = other[key]
168
+ # if isinstance(v, list):
169
+ # self[key].extend(v + [self._filler] * (max_len - len(v)))
170
+ # else:
171
+ # self[key].extend([v] * max_len)
172
+ # else:
173
+ # self[key].extend([self._filler] * max_len)
174
+
175
+ # self._width += max_len
176
+ elif isinstance(other, list):
177
+ for key in self.keys():
178
+ self[key].extend(other)
179
+
180
+ self._width += len(other)
181
+ else:
182
+ raise ValueError(
183
+ "Cannot extend Dataframe with this type. Use 'append' for single values."
184
+ )
185
+
186
+ def __add__(self, other):
187
+ # if not isinstance(other, Dataframe):
188
+ # raise ValueError("Can only add Dataframe to Dataframe")
189
+ result = copy.deepcopy(Dataframe(self))
190
+ result.extend(other, strict=False)
191
+ return result
192
+
193
+ def __or__(self, other):
194
+ # if not isinstance(other, Dataframe):
195
+ # raise ValueError("Can only perform '|' operation with Dataframe")
196
+ result = copy.deepcopy(Dataframe(self))
197
+ result.update(other)
198
+ return result
199
+
200
+ @property
201
+ def width(self):
202
+ return self._width
203
+
204
+ @property
205
+ def length(self):
206
+ return len(self)
207
+
208
+ @property
209
+ def shape(self):
210
+ return (self.length, self.width)
211
+
212
+ def __repr__(self):
213
+ return f"Dataframe({self.dict_repr()}, shape={self.shape})"
214
+
215
+ def dict_repr(self):
216
+ return super().__repr__()
217
+
218
+ def print_dict(self):
219
+ print(self.dict_repr())
220
+
221
+
222
+ from typing import List, Union, Dict
223
+
224
+
225
+ def create_dataframe(
226
+ keys: List[str],
227
+ width: int,
228
+ seed: Union[any, List, Dict, "Dataframe"] = None,
229
+ default_value: any = None,
230
+ ) -> Dataframe:
231
+
232
+ df = Dataframe()
233
+
234
+ if not isinstance(seed, (list, dict, Dataframe)):
235
+ # Single value seed (can be of any type)
236
+ for key in keys:
237
+ df[key] = [seed] * width
238
+
239
+ elif isinstance(seed, list):
240
+ # List seed (applies to all rows)
241
+ seed_list = seed[:width] + [default_value] * (width - len(seed))
242
+ for key in keys:
243
+ df[key] = seed_list.copy()
244
+
245
+ elif isinstance(seed, Dataframe):
246
+ for key in keys:
247
+ if key in seed:
248
+ df[key] = seed[key][:width] + [default_value] * (width - len(seed[key]))
249
+ else:
250
+ df[key] = [default_value] * width
251
+
252
+ elif isinstance(seed, dict):
253
+ for key in keys:
254
+ if key in seed:
255
+ if isinstance(seed[key], list):
256
+ # List value for this row
257
+ df[key] = seed[key][:width] + [default_value] * (
258
+ width - len(seed[key])
259
+ )
260
+ else:
261
+ # Single value for this row
262
+ df[key] = [seed[key]] * width
263
+ else:
264
+ df[key] = [default_value] * width
265
+
266
+ # Fill any missing rows with default_value
267
+ for key in keys:
268
+ if key not in df:
269
+ df[key] = [default_value] * width
270
+
271
+ return df
@@ -0,0 +1,435 @@
1
+ # %%
2
+ from warnings import warn
3
+ from sympy import (
4
+ latex,
5
+ Eq,
6
+ Le,
7
+ symbols,
8
+ Basic,
9
+ FunctionClass,
10
+ Dict,
11
+ S,
12
+ )
13
+ from IPython.display import Markdown, display
14
+ import re
15
+
16
+ from typing import Union, List, Dict
17
+
18
+ from .dataframe import *
19
+
20
+ # DEFINITION OF DEFAULT VALUES
21
+
22
+ # default values for labels
23
+ from dataclasses import dataclass
24
+
25
+
26
+ @dataclass
27
+ class options:
28
+ EQ_PREFIX: str = "eq-"
29
+ EQ_SUFFIX: str = ""
30
+ VERTICAL_SKIP: str = "8pt"
31
+ PRINT_LABEL: bool = False
32
+ DEBUG = False
33
+ katex = False
34
+ default_mul_symbol = r"\,"
35
+ default_environment = "align"
36
+ default_label_command = r"\label"
37
+
38
+
39
+ from itertools import chain, zip_longest
40
+
41
+
42
+ # determina esito verifica
43
+ def verifica(lhs, rhs, test=Le) -> Markdown:
44
+ """Determines if the left-hand side (lhs) is less than or equal to
45
+ the right-hand side (rhs) based on the provided test function.
46
+
47
+ Args:
48
+ lhs (sympy.Expr): The left-hand side expression.
49
+ rhs (sympy.Expr): The right-hand side expression.
50
+ test (sympy.GreaterThan, sympy.LessThan, sympy.GreaterThanEqual, sympy.LessThanEqual, optional):
51
+ The test function to apply. Defaults to Le (less than or equal to).
52
+
53
+ Returns:
54
+ Markdown: A Markdown object containing the formatted string indicating the verification result (green for success, red for failure).
55
+ """
56
+ match test.__name__:
57
+ case "LessThan":
58
+ symbol_if_true = r"\le"
59
+ symbol_if_false = r">"
60
+ case "StrictLessThan":
61
+ symbol_if_true = r"<"
62
+ symbol_if_false = r"\ge"
63
+ case "GreaterThan":
64
+ symbol_if_true = r"\ge"
65
+ symbol_if_false = r"<"
66
+ case "StrictGreaterThan":
67
+ symbol_if_true = r">"
68
+ symbol_if_false = r"\le"
69
+
70
+ if test(lhs, rhs):
71
+ return Markdown(
72
+ rf"\textcolor{{green}}{{\left[{symbol_if_true}{rhs}\quad \textbf{{VERIFICATO}}\right]}}"
73
+ )
74
+ else:
75
+ return Markdown(
76
+ rf"\textcolor{{red}}{{\left[{symbol_if_false}{rhs}\quad \textbf{{NON VERIFICATO}}\right]}}"
77
+ )
78
+
79
+
80
+ def show_eqn(
81
+ eqns: dict | list[dict] | Dataframe,
82
+ environment: str = None,
83
+ sep: str | list[str] = "&",
84
+ label: str | dict = None,
85
+ label_command: str = None,
86
+ col_wrap: list[None | tuple] = None,
87
+ float_format: str = None,
88
+ debug: bool = None,
89
+ **kwargs,
90
+ ) -> Markdown:
91
+ """
92
+ Generates a LaTeX equation or equation array based on the provided equations.
93
+
94
+ Args:
95
+ eqns (dict | list[dict] | Dataframe): The equations to be displayed. It can be a dictionary, a list of dictionaries, or a Dataframe object.
96
+ environment (str, optional): The LaTeX environment to use for displaying the equations. Defaults to options.default_environment.
97
+ sep (str | list[str], optional): The separator to use between the key and value in each equation. It can be a string or a list of strings. Defaults to "&" or "" for specific environments (e.g. equation, gather).
98
+ label (str | dict, optional): The label to attach to the equation. It can be a string or a dictionary. Defaults to None.
99
+ label_command (str, optional): The LaTeX command to use for attaching the label. Defaults to options.default_label_command.
100
+ col_wrap (list[None | tuple], optional): The column wrapping specification for the Dataframe. Defaults to [None, ('=', '')].
101
+ float_format (str, optional): The float format specification for the Dataframe. Defaults to None.
102
+ debug (bool, optional): Whether to enable debug mode. Defaults to options.DEBUG.
103
+ **kwargs: Additional keyword arguments to be passed to the `myprint_latex` function.
104
+
105
+ Returns:
106
+ Markdown: The LaTeX equation or equation array displayed as a Markdown object.
107
+
108
+ Notes:
109
+ - If `debug` is True, the generated LaTeX code will be printed.
110
+ - If `environment` is not provided, the default environment specified in `options.default_environment` will be used.
111
+ - If `col_wrap` is not provided, the default column wrapping specification will be used.
112
+ - If `float_format` is not provided, the default float format specification will be used.
113
+ - If `label` is not provided, a label will not be attached to the equation.
114
+ - If `label_command` is not provided, the default label command specified in `options.default_label_command` will be used.
115
+ - The `eqns` argument can be a dictionary, a list of dictionaries, or a Dataframe object.
116
+ - The `sep` argument can be a string or a list of strings.
117
+ - The `label` argument can be a string or a dictionary.
118
+ - The `label_command` argument can be a string.
119
+ - The `col_wrap` argument can be a list of None or tuples.
120
+ - The `float_format` argument can be a string.
121
+ - The `debug` argument can be a boolean.
122
+ - The `**kwargs` argument can be any additional keyword arguments to be passed to the `myprint_latex` function.
123
+
124
+ """
125
+
126
+ # set defualt values
127
+ if not debug:
128
+ debug = options.DEBUG
129
+
130
+ if not "mul_symbol" in kwargs:
131
+ kwargs["mul_symbol"] = options.default_mul_symbol
132
+
133
+ if not environment:
134
+ environment = options.default_environment
135
+
136
+ if not col_wrap:
137
+ col_wrap = [
138
+ None,
139
+ ("=", ""),
140
+ ] # no wrapping for the key element (first columns), then '=' sign for the second column
141
+
142
+ # warning message in case of too many labels provided
143
+ single_label_env = ["equation", "cases", "split"]
144
+ if environment.replace("*", "") in single_label_env and isinstance(label, dict):
145
+ warn(
146
+ f"ATTENTION! label is a dict, while the {environment} does not support multiple labels"
147
+ )
148
+
149
+ # handle edge case for 'equation' and 'gather' environment
150
+ if environment.replace("*", "") in ["equation", "gather"]:
151
+ sep = "" # no separator in environment
152
+
153
+ # convert sep to a list: str-> list[str]
154
+ if not isinstance(sep, list):
155
+ sep = [sep]
156
+
157
+ # convert eqns to a Dataframe
158
+ if not isinstance(eqns, Dataframe):
159
+ if isinstance(eqns, list):
160
+ eqns = Dataframe(eqns)
161
+ else:
162
+ eqns = Dataframe([eqns])
163
+
164
+ # adjust sep to the size of the list of eqns(e.g. 'key & val0 & val1' ); assume last value of sep as filler
165
+ sep += [sep[-1]] * (eqns.width - len(sep))
166
+
167
+ # extract keys from first dict
168
+ keys = eqns.keys()
169
+ # determine the number of columns (keys & value0 & value1 ...)
170
+ num_cols = eqns.width + 1
171
+
172
+ # generate the matrix (list[list]]) of keys, many values (first element is the key)
173
+ matrix = {k: [k] + [vv for vv in v] for k, v in eqns.items()}
174
+ # print(f'{matrix=}')
175
+
176
+ # create float_format (dict)
177
+ if isinstance(float_format, tuple):
178
+ float_format = create_dataframe(seed=float_format[0], default_value=float_format[1], keys=keys, width=num_cols)
179
+ else:
180
+ float_format = create_dataframe(seed=float_format, keys=keys, width=num_cols)
181
+ # print(f'{float_format=}')
182
+
183
+ ### col_wrap
184
+ # adjust size of the col_wrap; assume None as default (for compatibility with earlier versions)
185
+ col_wrap = create_dataframe(seed=col_wrap, keys=keys, width=num_cols)
186
+
187
+ for k, v in col_wrap.items():
188
+ # clean the none value in wrapper with tuple
189
+ col_wrap[k] = [cw if cw is not None else ("", "") for cw in v]
190
+ # substitute single value with tuple, assuming last item is ''
191
+ col_wrap[k] = [cw if isinstance(cw, tuple) else (cw, "") for cw in col_wrap[k]]
192
+
193
+ # generate label dict if none is passed
194
+ if not label:
195
+ label = {k: None for k in keys}
196
+
197
+ # define label command
198
+ if not label_command:
199
+ label_command = options.default_label_command
200
+
201
+ def attach_label(key):
202
+ """
203
+ Attaches a label to a given key.
204
+
205
+ Parameters:
206
+ key (str): The key to attach the label to.
207
+
208
+ Returns:
209
+ str: The label attached to the key. If the label is empty or the katex engine is being used for rendering (i.e. in a Jupyter notebook), an empty string is returned.
210
+
211
+ Notes:
212
+ - The label is constructed using the `options.EQ_PREFIX`, the value of `label[key]`, and `options.EQ_SUFFIX`.
213
+ - If `options.PRINT_LABEL` is True, the key and label are printed.
214
+ - The label is wrapped in a LaTeX command specified by `label_command` if it is not empty and the katex engine is not being used for rendering.
215
+ """
216
+
217
+ if isinstance(label, dict):
218
+ text_label = (
219
+ rf"{options.EQ_PREFIX}{label[key]}{options.EQ_SUFFIX}"
220
+ if label.get(key)
221
+ else ""
222
+ )
223
+ if options.PRINT_LABEL:
224
+ print(f"{key}: {text_label}") if text_label else None
225
+
226
+ return (
227
+ rf" {label_command}{{{text_label}}} "
228
+ if label.get(key)
229
+ and not options.katex # don't add the label if there is no label to add, and if katex engine is used for rendering (i.e. jupyter notebook)
230
+ else ""
231
+ )
232
+
233
+ if isinstance(label, str) and not key:
234
+
235
+ text_label = rf"{options.EQ_PREFIX}{label}{options.EQ_SUFFIX}"
236
+
237
+ if options.PRINT_LABEL:
238
+ print(f"label: {text_label}" if text_label else None)
239
+
240
+ return (
241
+ rf" {label_command}{{{text_label}}} "
242
+ if not options.katex # don't add the label if there is no label to add, and if katex engine is used for rendering (i.e. jupyter notebook)
243
+ else ""
244
+ )
245
+
246
+ return ""
247
+
248
+
249
+
250
+ # check if environment is a special (starred "cases*" and "split*" are not valid latex environment, but they need to pass the "*" operator to the "equation" outer environment)
251
+ if environment.replace("*", "") in ["cases", "split"]:
252
+
253
+ # determine if outer env is starred
254
+ env = "align*" if "*" in environment else "align"
255
+
256
+ # clear the cases|split environment from the star
257
+ environment = "aligned"
258
+
259
+ # wrap inner cases|split in outer "align"
260
+ wrap = (
261
+ f"\\begin{{{env}}}{attach_label(None)}\n", # for an equation environment, only one label is allowed
262
+ f"\t\\left\\{{\\begin{{{environment}}}",
263
+ f"\t\n\\end{{{environment}}}\\right.",
264
+ f"\n\\end{{{env}}}",
265
+ )
266
+
267
+ else:
268
+ # do nothing
269
+ wrap = (
270
+ "",
271
+ f"\\begin{{{environment}}}{attach_label(list(keys)[0]) if environment.replace('*', '') in single_label_env else ''}",
272
+ f"\n\\end{{{environment}}}",
273
+ "",
274
+ )
275
+
276
+ # definition of the main template
277
+ template = f"{wrap[0]}{wrap[1]}\n___body___{wrap[2]}{wrap[3]}"
278
+
279
+ # generate the rows
280
+ body_lines = {}
281
+ for key, list_values in matrix.items():
282
+ body_lines[key] = " ".join(
283
+ [
284
+ format_decimal_numbers(
285
+ f'{ f"{cw[0]}{myprint_latex(v, **kwargs)}{cw[-1]}" if v is not None else " " } {s}',
286
+ ff,
287
+ )
288
+ for v, s, cw, ff in zip_longest(
289
+ list_values,
290
+ sep,
291
+ col_wrap[key],
292
+ float_format[key],
293
+ fillvalue="",
294
+ )
295
+ ]
296
+ ) + attach_label(key)
297
+
298
+ # how to join the lines of the body
299
+ join_token = "" if "equation" in environment else " \\\\\n "
300
+
301
+ # generate the body
302
+ body = join_token.join(body_lines.values())
303
+
304
+ # clean the body
305
+ body = replace_all(body, replacement)
306
+
307
+ template = template.replace("___body___", body)
308
+
309
+ if debug:
310
+ print(template)
311
+
312
+ return Markdown(template)
313
+
314
+
315
+ def myprint_latex(expr: Basic | str | Markdown, **kwargs) -> str:
316
+ """Converts a mathematical expression to a LaTeX string.
317
+
318
+ This function handles different input types and allows for customization of the output format.
319
+
320
+ Args:
321
+ expr (Basic | str | Markdown): The mathematical expression to convert.
322
+ * Basic (SymPy): A SymPy expression object.
323
+ * str: A string representation of a mathematical expression.
324
+ * Markdown: A Markdown object that likely contains LaTeX code (data attribute is extracted).
325
+ **kwargs: Additional keyword arguments passed to the SymPy `latex` function for formatting the output.
326
+
327
+ Returns:
328
+ str: The LaTeX string representation of the mathematical expression.
329
+ """
330
+ if isinstance(expr, Markdown):
331
+ return expr.data
332
+
333
+ return latex(expr, **kwargs)
334
+
335
+
336
+ import re
337
+
338
+
339
+ def wrap_floats(text, wrapper=("", "")):
340
+ # Define a regular expression pattern to match decimal numbers
341
+ float_pattern = re.compile(r"-?\d+\.\d+")
342
+
343
+ # Define a function to use as replacement
344
+ def wrap_match(match):
345
+ return f"{wrapper[0]}{match.group(0)}{wrapper[1]}"
346
+
347
+ # Use re.sub to replace all matches with the wrapped version
348
+ wrapped_text = float_pattern.sub(wrap_match, text)
349
+
350
+ return wrapped_text
351
+
352
+
353
+ def format_decimal_numbers(text, format_string="{:.2f}"):
354
+ """
355
+ Finds all decimal numbers in a string, applies a specified format,
356
+ and substitutes them back into the string.
357
+
358
+ Args:
359
+ text: The string to search for decimal numbers.
360
+ format_string: The format string to apply to the decimal numbers.
361
+
362
+ Returns:
363
+ The formatted string.
364
+ """
365
+ if text is None or format_string is None:
366
+ return text
367
+
368
+ def format_match(match):
369
+ value = float(match.group())
370
+ return format_string.format(value)
371
+
372
+ return re.sub(r"-?\d+\.\d+", format_match, text)
373
+
374
+
375
+ def dict_to_eq(result: dict):
376
+ eq = [Eq(k, v) for k, v in result.items()]
377
+ return eq if len(eq) > 1 else eq[0]
378
+
379
+
380
+ def eq_to_dict(result: Eq | list | tuple):
381
+ if hasattr(result, "__iter__"):
382
+ return {x.lhs: x.rhs for x in result}
383
+ else:
384
+ return {result.lhs: result.rhs}
385
+
386
+
387
+ import regex
388
+
389
+ # replacements for the regex function
390
+ replacement = {
391
+ r"\\frac": r"\\dfrac", # fisrt replace all frac with dfrac
392
+ r"\^\{((?:[^{}]|(?:\{(?1)\}))*)}": lambda m: regex.sub(
393
+ "dfrac", "frac", m.group(0)
394
+ ), # then replace all dfrac inside ^{} with frac (small exponent)
395
+ r"\b1 \\cdot": r"",
396
+ r"\\\\": rf"\\\\[{options.VERTICAL_SKIP}]",
397
+ r"\bfor\b": "per",
398
+ r"\botherwise\b": "altrimenti",
399
+ r"\\,": r"{\,}",
400
+ }
401
+
402
+
403
+ # %% replace all the key, value pair
404
+ def replace_all(body, reps=replacement):
405
+ for pattern, repl in reps.items():
406
+ body = regex.sub(pattern, repl, body)
407
+ return body
408
+
409
+
410
+ def latex_inline_dict(var, mapping: dict, **kwargs):
411
+ if not "mul_symbol" in kwargs:
412
+ kwargs["mul_symbol"] = r"\,"
413
+ match (mode := kwargs.get("mode")):
414
+ case "plain" | None:
415
+ wrap = ("", "")
416
+ case "inline":
417
+ wrap = ("$", "$")
418
+ case _:
419
+ wrap = (rf"\begin{{{mode}}}", rf"\end{{{mode}}}")
420
+
421
+ kwargs["mode"] = "plain"
422
+
423
+ _latex = lambda x: replace_all(latex(x, **kwargs))
424
+ return f"{wrap[0]}{_latex(var)} = {_latex(mapping[var])}{wrap[1]}"
425
+
426
+
427
+ if __name__ == "__main__":
428
+ x, y = symbols("x y")
429
+ show_eqn(
430
+ {
431
+ x: 1,
432
+ },
433
+ label={x: "banana"},
434
+ debug=True,
435
+ )
@@ -0,0 +1,95 @@
1
+ import pint
2
+ import sympy.physics.units as sympy_units
3
+ from sympy.physics.units.util import convert_to
4
+
5
+ from sympy import nsimplify, sympify
6
+
7
+ unitregistry = pint.UnitRegistry()
8
+ unitregistry.formatter.default_format = ".2f~P"
9
+
10
+ def pint_to_sympy(quantity: unitregistry.Quantity):
11
+ """convert pint quantity to sympy quantity
12
+
13
+ Args:
14
+ quantity (UnitRegistry.Quantity): a quantity defined with the pint module
15
+
16
+ """
17
+ # divide and extract the magnitude from the units: it will generate a two elements tuple, where the first item will be the magnitude and the second ona a tuple of tuples; each nested tuple is composed by two elements, the unit proper and the exponent to which is elevated; the tuples are supposed to be multiplied together.
18
+
19
+ # quantity is multiplied by 1 so that it is converted to pint.Quantity if pint.Unit is passed instead
20
+ magnitude, units = (1 * quantity).to_tuple()
21
+
22
+ # for each unit (i.e. tuple), check if it exist in the sympy.physics.units module
23
+ for u in units:
24
+ fullname = u[0]
25
+ shortname = f"{pint.Unit(fullname):~}"
26
+ exponent = sympify(u[1])
27
+
28
+ # add a new unit if it doesn't exist
29
+ if not hasattr(sympy_units, fullname):
30
+ if [True for x in unitregistry.parse_unit_name(fullname) if not x[0] == ""]:
31
+ is_prefixed = True
32
+ else:
33
+ is_prefixed = False
34
+
35
+ setattr(
36
+ sympy_units,
37
+ fullname,
38
+ sympy_units.Quantity(
39
+ fullname, abbrev=shortname, is_prefixed=is_prefixed
40
+ ),
41
+ ) # create thwith full namee new sympy unit
42
+ # create the alias with the shortname
43
+ setattr(sympy_units, shortname, getattr(sympy_units, fullname))
44
+
45
+ # set the global scale factor relative to base units (base units are assumed to be in sympy)
46
+ # _magnitude, _units = (1 * pint.Unit(fullname)).to_base_units().to_tuple()
47
+ # _reference = sympify(1)
48
+ # for _u in _units:
49
+ # _reference *= getattr(sympy_units, _u[0])**nsimplify(_u[1])
50
+
51
+ # getattr(sympy_units, fullname).set_global_relative_scale_factor(_magnitude, _reference)
52
+
53
+ # multiply magnitude for the sympy units (create a sympy.core.Mul object)
54
+ magnitude *= (
55
+ getattr(sympy_units, fullname) ** (exponent)
56
+ if exponent != 1
57
+ else getattr(sympy_units, fullname)
58
+ )
59
+
60
+ return sympify(magnitude)
61
+
62
+
63
+ # UnitRegistry = pint.UnitRegistry()
64
+ # UnitRegistry.default_format = '.2f~P'
65
+ # Q = UnitRegistry.Quantity
66
+ # Q._sympy_ = lambda s: sympify(f'{s.m}*{s.u}')
67
+
68
+ pint.Quantity._sympy_ = lambda x: pint_to_sympy(x)
69
+ pint.Unit._sympy_ = lambda x: pint_to_sympy(1 * x)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ u = unitregistry
74
+
75
+ F = 5000 * u.daN # this unit is not present in sympy.core.physics
76
+ A = 2 * u.m
77
+ B = 300 * u.cm
78
+
79
+ # pint unit get converted to sympy units
80
+ print(sympify(F))
81
+ print(sympify(A))
82
+ print(sympify(B))
83
+
84
+ # sympy will not automatically simplify different units
85
+ print(sympify(F / (A * B))) # this is a pressure
86
+
87
+ # you need to use convert to
88
+
89
+ print(convert_to(sympify(F), u.kN))
90
+
91
+ print(convert_to(sympify(F / (A * B)), u.MPa))
92
+
93
+ print(convert_to(sympify(F / (A * B)), u.kPa))
94
+
95
+ print(f"{F:.4f~P}")
@@ -0,0 +1,196 @@
1
+ # %% pipe command
2
+ from pipe import Pipe
3
+ from sympy.parsing.sympy_parser import parse_expr as sympy_parse_expr
4
+ from sympy import Basic, sympify, S, Mul, MatrixBase, UnevaluatedExpr
5
+ from sympy.core.function import UndefinedFunction
6
+ from sympy.physics.units.util import convert_to as sympy_convert_to
7
+ from sympy.physics.units.util import quantity_simplify as sympy_quantity_simplify
8
+ from sympy import topological_sort, default_sort_key
9
+ from itertools import permutations
10
+ from inspect import currentframe
11
+ from keecas.display import wrap_floats
12
+
13
+
14
+ def order_subs(subs: dict) -> list[tuple]:
15
+ """Reorders the substitutions using topological order, ensuring that
16
+ the order of elements passed to the subs function is exhaustive.
17
+
18
+ Args:
19
+ subs (dict): Dictionary of substitutions to perform (VERTICES).
20
+
21
+ Returns:
22
+ list: Ordered list of substitutions.
23
+ """
24
+
25
+ # Generate edges between each vertex
26
+ edges = [
27
+ (i, j) for i, j in permutations(subs.items(), 2) if sympify(i[1]).has(j[0])
28
+ ]
29
+
30
+ # Reorder the dict with topological_sort
31
+ return topological_sort((subs.items(), edges), default_sort_key)
32
+
33
+
34
+ @Pipe
35
+ def subs(
36
+ expression: Basic,
37
+ substitution: dict,
38
+ sorted=True,
39
+ # simplify_quantity=True, **kwargs
40
+ ) -> Basic:
41
+
42
+ # filter out None expressions from the expression
43
+ if expression is None:
44
+ return
45
+
46
+ # filter out non Basic expressions from the substitution dict
47
+ substitution = {
48
+ lhs: rhs
49
+ for lhs, rhs in substitution.items()
50
+ if isinstance(lhs, Basic | UndefinedFunction | str) and rhs is not None
51
+ }
52
+
53
+ if sorted:
54
+ substitution = order_subs(substitution)
55
+
56
+ expression = S(expression).subs(substitution)
57
+
58
+ # if simplify_quantity:
59
+ # expression = expression | quantity_simplify(**kwargs)
60
+
61
+ return expression
62
+
63
+
64
+ @Pipe
65
+ def N(expression: Basic, precision: int = 15) -> Basic:
66
+ return expression.evalf(precision)
67
+
68
+
69
+ @Pipe
70
+ def convert_to(expression: Basic, units=1) -> Basic:
71
+ return sympy_convert_to(expression, target_units=units)
72
+
73
+
74
+ @Pipe
75
+ def doit(expression: Basic) -> Basic:
76
+ return expression.doit()
77
+
78
+
79
+ from sympy.parsing.sympy_parser import T
80
+
81
+
82
+ @Pipe
83
+ def parse_expr(
84
+ expression: Basic, local_dict: dict = None, evaluate=False, **kwargs
85
+ ) -> Basic:
86
+ """
87
+ Parses a mathematical expression into a SymPy expression object.
88
+
89
+ Parameters:
90
+ expression (Basic): The mathematical expression to parse.
91
+ local_dict (dict, optional): A dictionary of local variables to use during parsing. If None is passed, then the current frame's local variables will be used.
92
+ evaluate (bool, optional): Whether to evaluate the expression during parsing. Defaults to False.
93
+ **kwargs: Additional keyword arguments to pass to the SymPy parser.
94
+
95
+ Returns:
96
+ Basic: The parsed SymPy expression object.
97
+ """
98
+
99
+ if not local_dict:
100
+ local_dict = currentframe().f_back.f_back.f_back.f_locals
101
+
102
+ if "transformations" not in kwargs:
103
+ kwargs["transformations"] = T[:11]
104
+
105
+ parsed_expr = sympy_parse_expr(
106
+ expression, evaluate=evaluate, local_dict=local_dict, **kwargs
107
+ )
108
+ return parsed_expr
109
+
110
+
111
+ @Pipe
112
+ def quantity_simplify(
113
+ expression: Basic, across_dimensions=True, unit_system="SI", **kwargs
114
+ ) -> Basic:
115
+ """
116
+ Simplifies a given expression by applying quantity simplification.
117
+
118
+ Parameters:
119
+ expression (Basic): The expression to simplify.
120
+ across_dimensions (bool): Whether to simplify across dimensions. Defaults to True.
121
+ unit_system (str): The unit system to use for simplification. Defaults to "SI".
122
+ **kwargs: Additional keyword arguments to pass to the underlying sympy_quantity_simplify function.
123
+
124
+ Returns:
125
+ Basic: The simplified expression.
126
+ """
127
+
128
+ return sympy_quantity_simplify(
129
+ expression, across_dimensions=across_dimensions, unit_system=unit_system
130
+ )
131
+
132
+
133
+ @Pipe
134
+ def as_two_terms(
135
+ expression: Basic,
136
+ as_mul=False,
137
+ ) -> Basic:
138
+ """
139
+ This function takes in a `Basic` expression and an optional boolean flag `as_mul`.
140
+ It checks if the expression is an instance of `Mul`. If it is, it calls the `as_two_terms()` method on the expression.
141
+ If the expression is not an instance of `Mul`, it checks if it is an instance of `MatrixBase`.
142
+ If it is, it creates a set of units by iterating over the values of the matrix and getting the coefficients dictionary of each element.
143
+ If the set of units has a length of 1, it assigns the only unit to `u`, divides the matrix by `u`, and assigns the result to `att`.
144
+ If the set of units has a length greater than 1, it returns the original expression.
145
+ If the expression is neither a `Mul` nor a `MatrixBase`, it returns the original expression.
146
+ Finally, it returns `att` if `as_mul` is `False`, otherwise it returns `att` combined with `as_Mul`.
147
+ """
148
+ if isinstance(expression, Mul):
149
+ att = expression.as_two_terms()
150
+ elif isinstance(expression, MatrixBase):
151
+ units = {u for e in expression.values() for u in e.as_coefficients_dict()}
152
+ if len(units) == 1:
153
+ u = units.pop()
154
+ att = (expression / u, u)
155
+ else:
156
+ return expression
157
+ else:
158
+ return expression
159
+
160
+ return att | as_Mul if as_mul else att
161
+
162
+
163
+ @Pipe
164
+ def as_Mul(expression: tuple[Basic]) -> Basic:
165
+ """
166
+ Multiplies two expressions together and returns the result as an unevaluated expression. (Ideally to nicely separate the magnitude from the units)
167
+
168
+ Parameters:
169
+ expression (tuple[Basic]): A tuple containing two Basic expressions to be multiplied together.
170
+
171
+ Returns:
172
+ Basic: The result of multiplying the two expressions together as an unevaluated expression.
173
+ """
174
+
175
+ return UnevaluatedExpr(expression[0]) * UnevaluatedExpr(expression[1])
176
+
177
+
178
+ # print(currentframe().f_back.f_locals)
179
+ # %% debug
180
+
181
+ if __name__ == "__main__":
182
+ import sympy as sp
183
+
184
+ x, y = sp.symbols("x y")
185
+
186
+ _d = {
187
+ x: 3,
188
+ y: x * 4,
189
+ }
190
+ print((None) | subs(_d))
191
+
192
+ e = sp.symbols("e", cls=sp.Function)
193
+ _e = {e: "Lambda(j, j+1)" | parse_expr}
194
+ print("e(x)" | parse_expr | subs(_e))
195
+
196
+ # %%
@@ -0,0 +1,96 @@
1
+ # %% inserimento immagini in documento come link markdown
2
+ import os
3
+ from pathlib import Path
4
+ from IPython.display import Markdown
5
+
6
+ # %% flatten dict across yaml
7
+ import flatten_dict as fd
8
+ from ruamel.yaml import YAML
9
+
10
+ yaml = YAML()
11
+ yaml.preserve_quotes = True
12
+
13
+
14
+ def load_data(main: str, updated_value: str) -> dict:
15
+ # Check if main file exists
16
+ if not os.path.exists(main):
17
+ # Create an empty file and return an empty dict
18
+ with open(main, "w") as f:
19
+ pass
20
+
21
+ # caricamento dati esistenti (generati automaticamente)
22
+ with open(main, "r") as m:
23
+ try:
24
+ _main = fd.flatten(yaml.load(m))
25
+ except ValueError:
26
+ _main = {}
27
+
28
+ # caricamento dei metadata (inseriti manualmente)
29
+ with open(updated_value, "r") as m:
30
+ _updated_value = fd.flatten(yaml.load(m))
31
+
32
+ return fd.unflatten(_main | _updated_value)
33
+
34
+
35
+ # %% SYMPY
36
+ def escape_name(symbol_name, dict_of_subs={}):
37
+ name = str(symbol_name)
38
+ for old, new in dict_of_subs.items():
39
+ name = name.replace(old, new)
40
+ return name
41
+
42
+
43
+ def escape_var(names, dict_of_subs=None, **args):
44
+ """estensione di sympy:var() con l'introduzione di una lista di sostituzioni per escapare i nomi dei simboli
45
+
46
+ Args:
47
+ names (_type_): _description_
48
+ dict_of_subs (_type_, optional): _description_. Defaults to None.
49
+ """
50
+
51
+ def traverse(symbols, frame):
52
+ """Recursively inject symbols to the global namespace."""
53
+ for symbol in symbols:
54
+ if isinstance(symbol, Basic):
55
+ frame.f_globals[escape_name(symbol.name, dict_of_subs)] = symbol
56
+ elif isinstance(symbol, FunctionClass):
57
+ frame.f_globals[escape_name(symbol.__name__, dict_of_subs)] = symbol
58
+ else:
59
+ traverse(symbol, frame)
60
+
61
+ from inspect import currentframe
62
+
63
+ frame = currentframe().f_back
64
+
65
+ try:
66
+ if isinstance(names, str):
67
+ syms = symbols(names, **args)
68
+ else:
69
+ syms = names
70
+
71
+ if syms is not None:
72
+ if isinstance(syms, Basic):
73
+ frame.f_globals[escape_name(syms.name, dict_of_subs)] = syms
74
+ elif isinstance(syms, FunctionClass):
75
+ frame.f_globals[escape_name(syms.__name__, dict_of_subs)] = syms
76
+ else:
77
+ traverse(syms, frame)
78
+ finally:
79
+ del frame # break cyclic dependencies as stated in inspect docs
80
+
81
+ return syms
82
+
83
+
84
+ def insert_images(source_path, dest_path=".", fig_opt=""):
85
+ images = []
86
+
87
+ # filtra lista di immagini -> path object
88
+ for root, _, files in os.walk(source_path):
89
+ for f in files:
90
+ if f.lower().endswith((".jpg", ".jpeg", ".png")):
91
+ image = Path(root) / f
92
+ display(
93
+ Markdown(
94
+ f"![{image.stem}](<{image.relative_to(dest_path)}>){{{fig_opt}}}"
95
+ )
96
+ )
@@ -0,0 +1,22 @@
1
+ [tool.poetry]
2
+ name = "keecas"
3
+ version = "0.1.1"
4
+ description = "A set of tools for symbolic math computation done in a jupyter notebook, aimed specifically for Quarto. It is based mostly on sympy."
5
+ authors = ["kompre <s.follador@gmail.com>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = "^3.12"
10
+ pint = "^0.24.3"
11
+ pipe = "^2.2"
12
+ sympy = "^1.13.1"
13
+ regex = "^2024.7.24"
14
+ ipython = "^8.26.0"
15
+ pyyaml = "^6.0.1"
16
+ flatten-dict = "^0.4.2"
17
+ ruamel-yaml = "^0.18.6"
18
+
19
+
20
+ [tool.poetry.group.dev.dependencies]
21
+ pytest = "^8.3.2"
22
+ ipykernel = "^6.29.5"