mxlpy 0.21.0__py3-none-any.whl → 0.22.0__py3-none-any.whl

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.
mxlpy/report.py CHANGED
@@ -3,25 +3,25 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
+ from dataclasses import dataclass
6
7
  from datetime import UTC, datetime
7
8
  from pathlib import Path
8
9
  from typing import cast
9
10
 
10
11
  import sympy
11
12
 
12
- from mxlpy.meta.source_tools import fn_to_sympy
13
+ from mxlpy.meta.sympy_tools import fn_to_sympy, list_of_symbols
13
14
  from mxlpy.model import Model
14
15
 
15
- __all__ = [
16
- "AnalysisFn",
17
- "markdown",
18
- ]
16
+ __all__ = ["AnalysisFn", "MarkdownReport", "markdown"]
19
17
 
20
18
  type AnalysisFn = Callable[[Model, Model, Path], tuple[str, Path]]
21
19
 
22
20
 
23
- def _list_of_symbols(args: list[str]) -> list[sympy.Symbol | sympy.Expr]:
24
- return [sympy.Symbol(arg) for arg in args]
21
+ def _latex_view(expr: sympy.Expr | None) -> str:
22
+ if expr is None:
23
+ return "<span style='color:red'>PARSE ERROR<span>"
24
+ return f"${sympy.latex(expr)}$"
25
25
 
26
26
 
27
27
  def _new_removed_changed[T](
@@ -44,48 +44,71 @@ def _table_header(items: list[str]) -> str:
44
44
  return f"{_table_row(items)}\n{_table_row(['---'] * len(items))}"
45
45
 
46
46
 
47
+ @dataclass
48
+ class MarkdownReport:
49
+ """Report of model comparison."""
50
+
51
+ data: str
52
+
53
+ def __str__(self) -> str:
54
+ """Markdown string representation."""
55
+ return self.data
56
+
57
+ def __repr__(self) -> str:
58
+ """Markdown string representation."""
59
+ return self.data
60
+
61
+ def _repr_markdown_(self) -> str:
62
+ return self.data
63
+
64
+ def write(self, path: Path) -> None:
65
+ """Write report to file."""
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+
68
+ with path.open("w+") as fp:
69
+ fp.write(self.data)
70
+
71
+
47
72
  def markdown(
48
73
  m1: Model,
49
74
  m2: Model,
75
+ *,
50
76
  analyses: list[AnalysisFn] | None = None,
51
77
  rel_change: float = 1e-2,
52
78
  img_path: Path = Path(),
53
- ) -> str:
79
+ m1_name: str = "model 1",
80
+ m2_name: str = "model 2",
81
+ include_rhs: bool = True,
82
+ ) -> MarkdownReport:
54
83
  """Generate a markdown report comparing two models.
55
84
 
56
- Parameters
57
- ----------
58
- m1
59
- The first model to compare
60
- m2
61
- The second model to compare
62
- analyses
63
- A list of functions that analyze both models and return a report section with image
64
- rel_change
65
- The relative change threshold for numerical differences
66
- img_path
67
- The path to save images
68
-
69
- Returns
70
- -------
71
- str
72
- Markdown formatted report comparing the two models
73
-
74
- Examples
75
- --------
76
- >>> from mxlpy import Model
77
- >>> m1 = Model().add_parameter("k1", 0.1).add_variable("S", 1.0)
78
- >>> m2 = Model().add_parameter("k1", 0.2).add_variable("S", 1.0)
79
- >>> report = markdown(m1, m2)
80
- >>> "Parameters" in report and "k1" in report
81
- True
82
-
83
- >>> # With custom analysis function
84
- >>> def custom_analysis(m1, m2, path):
85
- ... return "## Custom analysis", path / "image.png"
86
- >>> report = markdown(m1, m2, analyses=[custom_analysis])
87
- >>> "Custom analysis" in report
88
- True
85
+ Args:
86
+ m1: The first model to compare
87
+ m2: The second model to compare
88
+ analyses: A list of functions that analyze both models and return a report section with image
89
+ rel_change: The relative change threshold for numerical differences
90
+ img_path: The path to save images
91
+ m1_name: Name of the first model
92
+ m2_name: Name of the second model
93
+ include_rhs: Whether to include numerical differences in the right hand side
94
+
95
+ Returns:
96
+ str: Markdown formatted report comparing the two models
97
+
98
+ Examples:
99
+ >>> from mxlpy import Model
100
+ >>> m1 = Model().add_parameter("k1", 0.1).add_variable("S", 1.0)
101
+ >>> m2 = Model().add_parameter("k1", 0.2).add_variable("S", 1.0)
102
+ >>> report = markdown(m1, m2)
103
+ >>> "Parameters" in report and "k1" in report
104
+ True
105
+
106
+ >>> # With custom analysis function
107
+ >>> def custom_analysis(m1, m2, path):
108
+ ... return "## Custom analysis", path / "image.png"
109
+ >>> report = markdown(m1, m2, analyses=[custom_analysis])
110
+ >>> "Custom analysis" in report
111
+ True
89
112
 
90
113
  """
91
114
  content: list[str] = [
@@ -101,20 +124,20 @@ def markdown(
101
124
  # Model stats
102
125
  content.extend(
103
126
  [
104
- "| Model component | Old | New |",
127
+ f"| Model component | {m1_name} | {m2_name} |",
105
128
  "| --- | --- | --- |",
106
- f"| variables | {len(m1.variables)} | {len(m2.variables)}|",
107
- f"| parameters | {len(m1.parameters)} | {len(m2.parameters)}|",
108
- f"| derived parameters | {len(m1.derived_parameters)} | {len(m2.derived_parameters)}|",
109
- f"| derived variables | {len(m1.derived_variables)} | {len(m2.derived_variables)}|",
110
- f"| reactions | {len(m1.reactions)} | {len(m2.reactions)}|",
129
+ f"| variables | {len(m1.get_raw_parameters())} | {len(m2.get_raw_parameters())}|",
130
+ f"| parameters | {len(m1.get_parameter_values())} | {len(m2.get_parameter_values())}|",
131
+ f"| derived parameters | {len(m1.get_derived_parameters())} | {len(m2.get_derived_parameters())}|",
132
+ f"| derived variables | {len(m1.get_derived_variables())} | {len(m2.get_derived_variables())}|",
133
+ f"| reactions | {len(m1.get_raw_reactions())} | {len(m2.get_raw_reactions())}|",
111
134
  f"| surrogates | {len(m1._surrogates)} | {len(m2._surrogates)}|", # noqa: SLF001
112
135
  ]
113
136
  )
114
137
 
115
138
  # Variables
116
139
  new_variables, removed_variables, changed_variables = _new_removed_changed(
117
- m1.variables, m2.variables
140
+ m1.get_initial_conditions(), m2.get_initial_conditions()
118
141
  )
119
142
  variables = []
120
143
  variables.extend(
@@ -131,8 +154,8 @@ def markdown(
131
154
  if len(variables) >= 1:
132
155
  content.extend(
133
156
  (
134
- "## Variables\n\n",
135
- "| Name | Old Value | New Value |",
157
+ "\n## Variables\n",
158
+ f"| Name | {m1_name} | {m2_name} |",
136
159
  "| ---- | --------- | --------- |",
137
160
  )
138
161
  )
@@ -140,7 +163,7 @@ def markdown(
140
163
 
141
164
  # Parameters
142
165
  new_parameters, removed_parameters, changed_parameters = _new_removed_changed(
143
- m1.parameters, m2.parameters
166
+ m1.get_parameter_values(), m2.get_parameter_values()
144
167
  )
145
168
  pars = []
146
169
  pars.extend(
@@ -157,8 +180,8 @@ def markdown(
157
180
  if len(pars) >= 1:
158
181
  content.extend(
159
182
  (
160
- "## Parameters\n\n",
161
- "| Name | Old Value | New Value |",
183
+ "\n## Parameters\n",
184
+ f"| Name | {m1_name} | {m2_name} |",
162
185
  "| ---- | --------- | --------- |",
163
186
  )
164
187
  )
@@ -166,17 +189,37 @@ def markdown(
166
189
 
167
190
  # Derived
168
191
  new_derived, removed_derived, changed_derived = _new_removed_changed(
169
- m1.derived, m2.derived
192
+ m1.get_raw_derived(),
193
+ m2.get_raw_derived(),
170
194
  )
171
195
  derived = []
172
196
  for k, v in new_derived.items():
173
- expr = sympy.latex(fn_to_sympy(v.fn, _list_of_symbols(v.args)))
174
- derived.append(f"| <span style='color:green'>{k}<span> | - | ${expr}$ |")
197
+ expr = _latex_view(
198
+ fn_to_sympy(
199
+ v.fn,
200
+ origin=k,
201
+ model_args=list_of_symbols(v.args),
202
+ )
203
+ )
204
+ derived.append(f"| <span style='color:green'>{k}<span> | - | {expr} |")
205
+
175
206
  for k, (v1, v2) in changed_derived.items():
176
- expr1 = sympy.latex(fn_to_sympy(v1.fn, _list_of_symbols(v1.args)))
177
- expr2 = sympy.latex(fn_to_sympy(v2.fn, _list_of_symbols(v2.args)))
207
+ expr1 = _latex_view(
208
+ fn_to_sympy(
209
+ v1.fn,
210
+ origin=k,
211
+ model_args=list_of_symbols(v1.args),
212
+ )
213
+ )
214
+ expr2 = _latex_view(
215
+ fn_to_sympy(
216
+ v2.fn,
217
+ origin=k,
218
+ model_args=list_of_symbols(v2.args),
219
+ )
220
+ )
178
221
  derived.append(
179
- f"| <span style='color: orange'>{k}</span> | ${expr1}$ | ${expr2}$ |"
222
+ f"| <span style='color: orange'>{k}</span> | {expr1} | {expr2} |"
180
223
  )
181
224
  derived.extend(
182
225
  f"| <span style='color:red'>{k}</span> | - | - |" for k in removed_derived
@@ -184,8 +227,8 @@ def markdown(
184
227
  if len(derived) >= 1:
185
228
  content.extend(
186
229
  (
187
- "## Derived\n\n",
188
- "| Name | Old Value | New Value |",
230
+ "\n## Derived\n",
231
+ f"| Name | {m1_name} | {m2_name} |",
189
232
  "| ---- | --------- | --------- |",
190
233
  )
191
234
  )
@@ -193,17 +236,36 @@ def markdown(
193
236
 
194
237
  # Reactions
195
238
  new_reactions, removed_reactions, changed_reactions = _new_removed_changed(
196
- m1.reactions, m2.reactions
239
+ m1.get_raw_reactions(), m2.get_raw_reactions()
197
240
  )
198
241
  reactions = []
199
242
  for k, v in new_reactions.items():
200
- expr = sympy.latex(fn_to_sympy(v.fn, _list_of_symbols(v.args)))
201
- reactions.append(f"| <span style='color:green'>{k}<span> | - | ${expr}$ |")
243
+ expr = _latex_view(
244
+ fn_to_sympy(
245
+ v.fn,
246
+ origin=k,
247
+ model_args=list_of_symbols(v.args),
248
+ )
249
+ )
250
+ reactions.append(f"| <span style='color:green'>{k}<span> | - | {expr} |")
251
+
202
252
  for k, (v1, v2) in changed_reactions.items():
203
- expr1 = sympy.latex(fn_to_sympy(v1.fn, _list_of_symbols(v1.args)))
204
- expr2 = sympy.latex(fn_to_sympy(v2.fn, _list_of_symbols(v2.args)))
253
+ expr1 = _latex_view(
254
+ fn_to_sympy(
255
+ v1.fn,
256
+ origin=k,
257
+ model_args=list_of_symbols(v1.args),
258
+ )
259
+ )
260
+ expr2 = _latex_view(
261
+ fn_to_sympy(
262
+ v2.fn,
263
+ origin=k,
264
+ model_args=list_of_symbols(v2.args),
265
+ )
266
+ )
205
267
  reactions.append(
206
- f"| <span style='color: orange'>{k}</span> | ${expr1}$ | ${expr2}$ |"
268
+ f"| <span style='color: orange'>{k}</span> | {expr1} | {expr2} |"
207
269
  )
208
270
  reactions.extend(
209
271
  f"| <span style='color:red'>{k}</span> | - | - |" for k in removed_reactions
@@ -212,8 +274,8 @@ def markdown(
212
274
  if len(reactions) >= 1:
213
275
  content.extend(
214
276
  (
215
- "## Reactions\n\n",
216
- "| Name | Old Value | New Value |",
277
+ "\n## Reactions\n",
278
+ f"| Name | {m1_name} | {m2_name} |",
217
279
  "| ---- | --------- | --------- |",
218
280
  )
219
281
  )
@@ -221,8 +283,8 @@ def markdown(
221
283
 
222
284
  # Now check for any numerical differences
223
285
  dependent = []
224
- d1 = m1.get_dependent()
225
- d2 = m2.get_dependent()
286
+ d1 = m1.get_args()
287
+ d2 = m2.get_args()
226
288
  rel_diff = ((d1 - d2) / d1).dropna()
227
289
  for k, v in rel_diff.loc[rel_diff.abs() >= rel_change].items():
228
290
  k = cast(str, k)
@@ -233,30 +295,31 @@ def markdown(
233
295
  content.extend(
234
296
  (
235
297
  "## Numerical differences of dependent values\n\n",
236
- "| Name | Old Value | New Value | Relative Change | ",
298
+ f"| Name | {m1_name} | {m2_name} | Relative Change | ",
237
299
  "| ---- | --------- | --------- | --------------- | ",
238
300
  )
239
301
  )
240
302
  content.append("\n".join(dependent))
241
303
 
242
- rhs = []
243
- r1 = m1.get_right_hand_side()
244
- r2 = m2.get_right_hand_side()
245
- rel_diff = ((r1 - r2) / r1).dropna()
246
- for k, v in rel_diff.loc[rel_diff.abs() >= rel_change].items():
247
- k = cast(str, k)
248
- rhs.append(
249
- f"| <span style='color:orange'>{k}</span> | {r1[k]:.2f} | {r2[k]:.2f} | {v:.1%} |"
250
- )
251
- if len(rhs) >= 1:
252
- content.extend(
253
- (
254
- "## Numerical differences of right hand side values\n\n",
255
- "| Name | Old Value | New Value | Relative Change | ",
256
- "| ---- | --------- | --------- | --------------- | ",
304
+ if include_rhs:
305
+ rhs = []
306
+ r1 = m1.get_right_hand_side()
307
+ r2 = m2.get_right_hand_side()
308
+ rel_diff = ((r1 - r2) / r1).dropna()
309
+ for k, v in rel_diff.loc[rel_diff.abs() >= rel_change].items():
310
+ k = cast(str, k)
311
+ rhs.append(
312
+ f"| <span style='color:orange'>{k}</span> | {r1[k]:.2f} | {r2[k]:.2f} | {v:.1%} |"
257
313
  )
258
- )
259
- content.append("\n".join(rhs))
314
+ if len(rhs) >= 1:
315
+ content.extend(
316
+ (
317
+ "\n## Numerical differences of right hand side values\n",
318
+ f"| Name | {m1_name} | {m2_name} | Relative Change | ",
319
+ "| ---- | --------- | --------- | --------------- | ",
320
+ )
321
+ )
322
+ content.append("\n".join(rhs))
260
323
 
261
324
  # Comparison functions
262
325
  if analyses is not None:
@@ -266,4 +329,4 @@ def markdown(
266
329
  # content.append(f"![{name}]({img_path})")
267
330
  content.append(f"<img src='{img_path}' alt='{name}' width='500'/>")
268
331
 
269
- return "\n".join(content)
332
+ return MarkdownReport(data="\n".join(content))
mxlpy/sbml/_export.py CHANGED
@@ -440,30 +440,32 @@ def _create_sbml_variables(
440
440
  sbml_model : libsbml.Model
441
441
 
442
442
  """
443
- for name, value in model.variables.items():
443
+ for name, variable in model.get_raw_variables().items():
444
444
  cpd = sbml_model.createSpecies()
445
445
  cpd.setId(_convert_id_to_sbml(id_=name, prefix="CPD"))
446
446
 
447
447
  cpd.setConstant(False)
448
448
  cpd.setBoundaryCondition(False)
449
449
  cpd.setHasOnlySubstanceUnits(False)
450
- if isinstance(value, Derived):
450
+ # cpd.setUnit() # FIXME: implement
451
+ if isinstance((init := variable.initial_value), Derived):
451
452
  ar = sbml_model.createInitialAssignment()
452
453
  ar.setId(_convert_id_to_sbml(id_=name, prefix="IA"))
453
454
  ar.setName(_convert_id_to_sbml(id_=name, prefix="IA"))
454
455
  ar.setVariable(_convert_id_to_sbml(id_=name, prefix="IA"))
455
- ar.setMath(_sbmlify_fn(value.fn, value.args))
456
+ ar.setMath(_sbmlify_fn(init.fn, init.args))
456
457
  else:
457
- cpd.setInitialAmount(float(value))
458
+ cpd.setInitialAmount(float(init))
458
459
 
459
460
 
460
461
  def _create_sbml_derived_variables(*, model: Model, sbml_model: libsbml.Model) -> None:
461
- for name, dv in model.derived_variables.items():
462
+ for name, dv in model.get_derived_variables().items():
462
463
  sbml_ar = sbml_model.createAssignmentRule()
463
464
  sbml_ar.setId(_convert_id_to_sbml(id_=name, prefix="AR"))
464
465
  sbml_ar.setName(_convert_id_to_sbml(id_=name, prefix="AR"))
465
466
  sbml_ar.setVariable(_convert_id_to_sbml(id_=name, prefix="AR"))
466
467
  sbml_ar.setMath(_sbmlify_fn(dv.fn, dv.args))
468
+ # cpd.setUnit() # FIXME: implement
467
469
 
468
470
 
469
471
  def _create_derived_parameter(
@@ -477,6 +479,7 @@ def _create_derived_parameter(
477
479
  ar.setName(_convert_id_to_sbml(id_=name, prefix="AR"))
478
480
  ar.setVariable(_convert_id_to_sbml(id_=name, prefix="AR"))
479
481
  ar.setMath(_sbmlify_fn(dp.fn, dp.args))
482
+ # cpd.setUnit() # FIXME: implement
480
483
 
481
484
 
482
485
  def _create_sbml_parameters(
@@ -491,7 +494,7 @@ def _create_sbml_parameters(
491
494
  sbml_model : libsbml.Model
492
495
 
493
496
  """
494
- for parameter_id, value in model.parameters.items():
497
+ for parameter_id, value in model.get_parameter_values().items():
495
498
  k = sbml_model.createParameter()
496
499
  k.setId(_convert_id_to_sbml(id_=parameter_id, prefix="PAR"))
497
500
  k.setConstant(True)
@@ -499,7 +502,7 @@ def _create_sbml_parameters(
499
502
 
500
503
 
501
504
  def _create_sbml_derived_parameters(*, model: Model, sbml_model: libsbml.Model) -> None:
502
- for name, dp in model.derived_parameters.items():
505
+ for name, dp in model.get_derived_parameters().items():
503
506
  _create_derived_parameter(sbml_model, name, dp)
504
507
 
505
508
 
@@ -509,7 +512,7 @@ def _create_sbml_reactions(
509
512
  sbml_model: libsbml.Model,
510
513
  ) -> None:
511
514
  """Create the reactions for the sbml model."""
512
- for name, rxn in model.reactions.items():
515
+ for name, rxn in model.get_raw_reactions().items():
513
516
  sbml_rxn = sbml_model.createReaction()
514
517
  sbml_rxn.setId(_convert_id_to_sbml(id_=name, prefix="RXN"))
515
518
  sbml_rxn.setName(name)
mxlpy/sbml/_import.py CHANGED
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import math # noqa: F401 # models might need it
4
5
  import re
5
- import warnings
6
6
  from collections import defaultdict
7
7
  from dataclasses import dataclass, field
8
8
  from pathlib import Path
@@ -32,6 +32,8 @@ from mxlpy.types import unwrap
32
32
  if TYPE_CHECKING:
33
33
  from collections.abc import Callable
34
34
 
35
+ _LOGGER = logging.getLogger(__name__)
36
+
35
37
  __all__ = [
36
38
  "INDENT",
37
39
  "OPERATOR_MAPPINGS",
@@ -216,10 +218,8 @@ class Parser:
216
218
 
217
219
  node = assignment.getMath()
218
220
  if node is None:
219
- warnings.warn(
220
- f"Unusable math for {name}",
221
- stacklevel=1,
222
- )
221
+ msg = f"Unusable math for {name}"
222
+ _LOGGER.warning(msg)
223
223
  continue
224
224
 
225
225
  body, args = parse_sbml_math(node)
@@ -493,7 +493,7 @@ def _codgen(name: str, sbml: Parser) -> Path:
493
493
  variables[k] = v.size
494
494
 
495
495
  # Ensure non-zero value for initial assignments
496
- # EXPLAIN: we need to do this for the first round of get_dependent to work
496
+ # EXPLAIN: we need to do this for the first round of get_args to work
497
497
  # otherwise we run into a ton of DivisionByZero errors.
498
498
  # Since the values are overwritte afterwards, it doesn't really matter anyways
499
499
  for k in sbml.initial_assignment:
@@ -552,7 +552,7 @@ def get_model() -> Model:
552
552
  {variables_str}
553
553
  {derived_str}
554
554
  {rxn_str}
555
- args = m.get_dependent()
555
+ args = m.get_args()
556
556
  {initial_assignment_source}
557
557
  return m
558
558
  """
mxlpy/scan.py CHANGED
@@ -405,7 +405,7 @@ def _protocol_worker(
405
405
  try:
406
406
  res = (
407
407
  Simulator(model, integrator=integrator, y0=y0)
408
- .simulate_over_protocol(
408
+ .simulate_protocol(
409
409
  protocol=protocol,
410
410
  time_points_per_step=time_points_per_step,
411
411
  )