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/model.py CHANGED
@@ -15,9 +15,23 @@ from typing import TYPE_CHECKING, Self, cast
15
15
 
16
16
  import numpy as np
17
17
  import pandas as pd
18
+ import sympy
18
19
 
19
20
  from mxlpy import fns
20
- from mxlpy.types import AbstractSurrogate, Array, Derived, Reaction, Readout
21
+ from mxlpy.meta.source_tools import fn_to_sympy
22
+ from mxlpy.meta.sympy_tools import (
23
+ list_of_symbols,
24
+ stoichiometries_to_sympy,
25
+ )
26
+ from mxlpy.types import (
27
+ AbstractSurrogate,
28
+ Array,
29
+ Derived,
30
+ Parameter,
31
+ Reaction,
32
+ Readout,
33
+ Variable,
34
+ )
21
35
 
22
36
  if TYPE_CHECKING:
23
37
  from collections.abc import Iterable, Mapping
@@ -32,9 +46,38 @@ __all__ = [
32
46
  "MissingDependenciesError",
33
47
  "Model",
34
48
  "ModelCache",
49
+ "TableView",
35
50
  ]
36
51
 
37
52
 
53
+ def _latex_view(expr: sympy.Expr | None) -> str:
54
+ if expr is None:
55
+ return "PARSE-ERROR"
56
+ return f"${sympy.latex(expr)}$"
57
+
58
+
59
+ @dataclass(kw_only=True, slots=True)
60
+ class TableView:
61
+ """Markdown view of pandas Dataframe.
62
+
63
+ Mostly used to get nice LaTeX rendering of sympy expressions.
64
+ """
65
+
66
+ data: pd.DataFrame
67
+
68
+ def __repr__(self) -> str:
69
+ """Normal Python shell output."""
70
+ return self.data.to_markdown()
71
+
72
+ def _repr_markdown_(self) -> str:
73
+ """Fancy IPython shell output.
74
+
75
+ Looks the same as __repr__, but is handled by IPython to output
76
+ `IPython.display.Markdown`, so looks nice
77
+ """
78
+ return self.data.to_markdown()
79
+
80
+
38
81
  @dataclass
39
82
  class Dependency:
40
83
  """Container class for building dependency tree."""
@@ -275,8 +318,8 @@ class Model:
275
318
  """
276
319
 
277
320
  _ids: dict[str, str] = field(default_factory=dict)
278
- _variables: dict[str, float | Derived] = field(default_factory=dict)
279
- _parameters: dict[str, float] = field(default_factory=dict)
321
+ _variables: dict[str, Variable] = field(default_factory=dict)
322
+ _parameters: dict[str, Parameter] = field(default_factory=dict)
280
323
  _derived: dict[str, Derived] = field(default_factory=dict)
281
324
  _readouts: dict[str, Readout] = field(default_factory=dict)
282
325
  _reactions: dict[str, Reaction] = field(default_factory=dict)
@@ -300,7 +343,7 @@ class Model:
300
343
  ModelCache: An instance of ModelCache containing the initialized cache data.
301
344
 
302
345
  """
303
- all_parameter_values: dict[str, float] = self._parameters.copy()
346
+ all_parameter_values: dict[str, float] = self.get_parameter_values()
304
347
  all_parameter_names: set[str] = set(all_parameter_values)
305
348
 
306
349
  # Sanity checks
@@ -317,11 +360,19 @@ class Model:
317
360
  self._derived
318
361
  | self._reactions
319
362
  | self._surrogates
320
- | {k: v for k, v in self._variables.items() if isinstance(v, Derived)}
363
+ | {
364
+ k: init
365
+ for k, v in self._variables.items()
366
+ if isinstance(init := v.initial_value, Derived)
367
+ }
321
368
  )
322
369
  order = _sort_dependencies(
323
370
  available=all_parameter_names
324
- | {k for k, v in self._variables.items() if not isinstance(v, Derived)}
371
+ | {
372
+ k
373
+ for k, v in self._variables.items()
374
+ if not isinstance(v.initial_value, Derived)
375
+ }
325
376
  | set(self._data)
326
377
  | {"time"},
327
378
  elements=[
@@ -337,7 +388,11 @@ class Model:
337
388
  dependent = (
338
389
  all_parameter_values
339
390
  | self._data
340
- | {k: v for k, v in self._variables.items() if not isinstance(v, Derived)}
391
+ | {
392
+ k: init
393
+ for k, v in self._variables.items()
394
+ if not isinstance(init := v.initial_value, Derived)
395
+ }
341
396
  | {"time": 0.0}
342
397
  )
343
398
  for name in order:
@@ -393,7 +448,9 @@ class Model:
393
448
  dxdt = pd.Series(np.zeros(len(var_names), dtype=float), index=var_names)
394
449
 
395
450
  initial_conditions: dict[str, float] = {
396
- k: v for k, v in self._variables.items() if not isinstance(v, Derived)
451
+ k: init
452
+ for k, v in self._variables.items()
453
+ if not isinstance(init := v.initial_value, Derived)
397
454
  }
398
455
  for name in static_order:
399
456
  if name in self._variables:
@@ -465,29 +522,88 @@ class Model:
465
522
  del self._ids[name]
466
523
 
467
524
  ##########################################################################
468
- # Parameters
525
+ # Parameters - views
469
526
  ##########################################################################
470
527
 
528
+ @property
529
+ def parameters(self) -> TableView:
530
+ """Return view of parameters."""
531
+ index = list(self._parameters.keys())
532
+ data = [
533
+ {
534
+ "value": el.value,
535
+ "unit": _latex_view(unit) if (unit := el.unit) is not None else "",
536
+ # "source": ...,
537
+ }
538
+ for el in self._parameters.values()
539
+ ]
540
+ return TableView(data=pd.DataFrame(data, index=index))
541
+
542
+ def get_raw_parameters(self, *, as_copy: bool = True) -> dict[str, Parameter]:
543
+ """Returns the parameters of the model."""
544
+ if as_copy:
545
+ return copy.deepcopy(self._parameters)
546
+ return self._parameters
547
+
548
+ def get_parameter_values(self) -> dict[str, float]:
549
+ """Returns the parameters of the model.
550
+
551
+ Examples:
552
+ >>> model.parameters
553
+ {"k1": 0.1, "k2": 0.2}
554
+
555
+ Returns:
556
+ parameters: A dictionary where the keys are parameter names (as strings)
557
+ and the values are parameter values (as floats).
558
+
559
+ """
560
+ return {k: v.value for k, v in self._parameters.items()}
561
+
562
+ def get_parameter_names(self) -> list[str]:
563
+ """Retrieve the names of the parameters.
564
+
565
+ Examples:
566
+ >>> model.get_parameter_names()
567
+ ['k1', 'k2']
568
+
569
+ Returns:
570
+ parametes: A list containing the names of the parameters.
571
+
572
+ """
573
+ return list(self._parameters)
574
+
575
+ #####################################
576
+ # Parameters - create
577
+ #####################################
578
+
471
579
  @_invalidate_cache
472
- def add_parameter(self, name: str, value: float) -> Self:
580
+ def add_parameter(
581
+ self,
582
+ name: str,
583
+ value: float,
584
+ unit: sympy.Expr | None = None,
585
+ source: str | None = None,
586
+ ) -> Self:
473
587
  """Adds a parameter to the model.
474
588
 
475
589
  Examples:
476
590
  >>> model.add_parameter("k1", 0.1)
477
591
 
478
592
  Args:
479
- name (str): The name of the parameter.
480
- value (float): The value of the parameter.
593
+ name: The name of the parameter.
594
+ value: The value of the parameter.
595
+ unit: unit of the parameter
596
+ source: source of the information given
481
597
 
482
598
  Returns:
483
599
  Self: The instance of the model with the added parameter.
484
600
 
485
601
  """
486
602
  self._insert_id(name=name, ctx="parameter")
487
- self._parameters[name] = value
603
+ self._parameters[name] = Parameter(value=value, unit=unit, source=source)
488
604
  return self
489
605
 
490
- def add_parameters(self, parameters: dict[str, float]) -> Self:
606
+ def add_parameters(self, parameters: Mapping[str, float | Parameter]) -> Self:
491
607
  """Adds multiple parameters to the model.
492
608
 
493
609
  Examples:
@@ -502,36 +618,15 @@ class Model:
502
618
 
503
619
  """
504
620
  for k, v in parameters.items():
505
- self.add_parameter(k, v)
621
+ if isinstance(v, Parameter):
622
+ self.add_parameter(k, v.value, unit=v.unit, source=v.source)
623
+ else:
624
+ self.add_parameter(k, v)
506
625
  return self
507
626
 
508
- @property
509
- def parameters(self) -> dict[str, float]:
510
- """Returns the parameters of the model.
511
-
512
- Examples:
513
- >>> model.parameters
514
- {"k1": 0.1, "k2": 0.2}
515
-
516
- Returns:
517
- parameters: A dictionary where the keys are parameter names (as strings)
518
- and the values are parameter values (as floats).
519
-
520
- """
521
- return self._parameters.copy()
522
-
523
- def get_parameter_names(self) -> list[str]:
524
- """Retrieve the names of the parameters.
525
-
526
- Examples:
527
- >>> model.get_parameter_names()
528
- ['k1', 'k2']
529
-
530
- Returns:
531
- parametes: A list containing the names of the parameters.
532
-
533
- """
534
- return list(self._parameters)
627
+ #####################################
628
+ # Parameters - delete
629
+ #####################################
535
630
 
536
631
  @_invalidate_cache
537
632
  def remove_parameter(self, name: str) -> Self:
@@ -568,8 +663,19 @@ class Model:
568
663
  self.remove_parameter(name)
569
664
  return self
570
665
 
666
+ #####################################
667
+ # Parameters - update
668
+ #####################################
669
+
571
670
  @_invalidate_cache
572
- def update_parameter(self, name: str, value: float) -> Self:
671
+ def update_parameter(
672
+ self,
673
+ name: str,
674
+ value: float | None = None,
675
+ *,
676
+ unit: sympy.Expr | None = None,
677
+ source: str | None = None,
678
+ ) -> Self:
573
679
  """Update the value of a parameter.
574
680
 
575
681
  Examples:
@@ -578,6 +684,8 @@ class Model:
578
684
  Args:
579
685
  name: The name of the parameter to update.
580
686
  value: The new value for the parameter.
687
+ unit: Unit of the parameter
688
+ source: Source of the information
581
689
 
582
690
  Returns:
583
691
  Self: The instance of the class with the updated parameter.
@@ -589,10 +697,17 @@ class Model:
589
697
  if name not in self._parameters:
590
698
  msg = f"'{name}' not found in parameters"
591
699
  raise KeyError(msg)
592
- self._parameters[name] = value
700
+
701
+ parameter = self._parameters[name]
702
+ if value is not None:
703
+ parameter.value = value
704
+ if unit is not None:
705
+ parameter.unit = unit
706
+ if source is not None:
707
+ parameter.source = source
593
708
  return self
594
709
 
595
- def update_parameters(self, parameters: dict[str, float]) -> Self:
710
+ def update_parameters(self, parameters: Mapping[str, float | Parameter]) -> Self:
596
711
  """Update multiple parameters of the model.
597
712
 
598
713
  Examples:
@@ -606,7 +721,10 @@ class Model:
606
721
 
607
722
  """
608
723
  for k, v in parameters.items():
609
- self.update_parameter(k, v)
724
+ if isinstance(v, Parameter):
725
+ self.update_parameter(k, value=v.value, unit=v.unit, source=v.source)
726
+ else:
727
+ self.update_parameter(k, v)
610
728
  return self
611
729
 
612
730
  def scale_parameter(self, name: str, factor: float) -> Self:
@@ -623,7 +741,7 @@ class Model:
623
741
  Self: The instance of the class with the updated parameter.
624
742
 
625
743
  """
626
- return self.update_parameter(name, self._parameters[name] * factor)
744
+ return self.update_parameter(name, self._parameters[name].value * factor)
627
745
 
628
746
  def scale_parameters(self, parameters: dict[str, float]) -> Self:
629
747
  """Scales the parameters of the model.
@@ -667,7 +785,7 @@ class Model:
667
785
  Self: The instance of the model with the parameter converted to a variable.
668
786
 
669
787
  """
670
- value = self._parameters[name] if initial_value is None else initial_value
788
+ value = self._parameters[name].value if initial_value is None else initial_value
671
789
  self.remove_parameter(name)
672
790
  self.add_variable(name, value)
673
791
 
@@ -708,7 +826,7 @@ class Model:
708
826
  ##########################################################################
709
827
 
710
828
  @property
711
- def variables(self) -> dict[str, float | Derived]:
829
+ def variables(self) -> TableView:
712
830
  """Returns a copy of the variables dictionary.
713
831
 
714
832
  Examples:
@@ -722,10 +840,79 @@ class Model:
722
840
  dict[str, float]: A copy of the variables dictionary.
723
841
 
724
842
  """
725
- return self._variables.copy()
843
+ index = list(self._variables.keys())
844
+ data = []
845
+ for name, el in self._variables.items():
846
+ if isinstance(init := el.initial_value, Derived):
847
+ value_str = _latex_view(
848
+ fn_to_sympy(
849
+ init.fn,
850
+ origin=name,
851
+ model_args=list_of_symbols(init.args),
852
+ )
853
+ )
854
+ else:
855
+ value_str = str(init)
856
+ data.append(
857
+ {
858
+ "value": value_str,
859
+ "unit": _latex_view(unit) if (unit := el.unit) is not None else "",
860
+ # "source"
861
+ }
862
+ )
863
+ return TableView(data=pd.DataFrame(data, index=index))
864
+
865
+ def get_raw_variables(self, *, as_copy: bool = True) -> dict[str, Variable]:
866
+ """Retrieve the initial conditions of the model.
867
+
868
+ Examples:
869
+ >>> model.get_initial_conditions()
870
+ {"x1": 1.0, "x2": 2.0}
871
+
872
+ Returns:
873
+ initial_conditions: A dictionary where the keys are variable names and the values are their initial conditions.
874
+
875
+ """
876
+ if as_copy:
877
+ return copy.deepcopy(self._variables)
878
+ return self._variables
879
+
880
+ def get_initial_conditions(self) -> dict[str, float]:
881
+ """Retrieve the initial conditions of the model.
882
+
883
+ Examples:
884
+ >>> model.get_initial_conditions()
885
+ {"x1": 1.0, "x2": 2.0}
886
+
887
+ Returns:
888
+ initial_conditions: A dictionary where the keys are variable names and the values are their initial conditions.
889
+
890
+ """
891
+ if (cache := self._cache) is None:
892
+ cache = self._create_cache()
893
+ return cache.initial_conditions
894
+
895
+ def get_variable_names(self) -> list[str]:
896
+ """Retrieve the names of all variables.
897
+
898
+ Examples:
899
+ >>> model.get_variable_names()
900
+ ["x1", "x2"]
901
+
902
+ Returns:
903
+ variable_names: A list containing the names of all variables.
904
+
905
+ """
906
+ return list(self._variables)
726
907
 
727
908
  @_invalidate_cache
728
- def add_variable(self, name: str, initial_condition: float | Derived) -> Self:
909
+ def add_variable(
910
+ self,
911
+ name: str,
912
+ initial_value: float | Derived,
913
+ unit: sympy.Expr | None = None,
914
+ source: str | None = None,
915
+ ) -> Self:
729
916
  """Adds a variable to the model with the given name and initial condition.
730
917
 
731
918
  Examples:
@@ -733,17 +920,23 @@ class Model:
733
920
 
734
921
  Args:
735
922
  name: The name of the variable to add.
736
- initial_condition: The initial condition value for the variable.
923
+ initial_value: The initial condition value for the variable.
924
+ unit: unit of the variable
925
+ source: source of the information given
737
926
 
738
927
  Returns:
739
928
  Self: The instance of the model with the added variable.
740
929
 
741
930
  """
742
931
  self._insert_id(name=name, ctx="variable")
743
- self._variables[name] = initial_condition
932
+ self._variables[name] = Variable(
933
+ initial_value=initial_value, unit=unit, source=source
934
+ )
744
935
  return self
745
936
 
746
- def add_variables(self, variables: Mapping[str, float | Derived]) -> Self:
937
+ def add_variables(
938
+ self, variables: Mapping[str, float | Variable | Derived]
939
+ ) -> Self:
747
940
  """Adds multiple variables to the model with their initial conditions.
748
941
 
749
942
  Examples:
@@ -757,8 +950,16 @@ class Model:
757
950
  Self: The instance of the model with the added variables.
758
951
 
759
952
  """
760
- for name, y0 in variables.items():
761
- self.add_variable(name=name, initial_condition=y0)
953
+ for name, v in variables.items():
954
+ if isinstance(v, Variable):
955
+ self.add_variable(
956
+ name=name,
957
+ initial_value=v.initial_value,
958
+ unit=v.unit,
959
+ source=v.source,
960
+ )
961
+ else:
962
+ self.add_variable(name=name, initial_value=v)
762
963
  return self
763
964
 
764
965
  @_invalidate_cache
@@ -797,7 +998,13 @@ class Model:
797
998
  return self
798
999
 
799
1000
  @_invalidate_cache
800
- def update_variable(self, name: str, initial_condition: float | Derived) -> Self:
1001
+ def update_variable(
1002
+ self,
1003
+ name: str,
1004
+ initial_value: float | Derived,
1005
+ unit: sympy.Expr | None = None,
1006
+ source: str | None = None,
1007
+ ) -> Self:
801
1008
  """Updates the value of a variable in the model.
802
1009
 
803
1010
  Examples:
@@ -805,7 +1012,9 @@ class Model:
805
1012
 
806
1013
  Args:
807
1014
  name: The name of the variable to update.
808
- initial_condition: The initial condition or value to set for the variable.
1015
+ initial_value: The initial condition or value to set for the variable.
1016
+ unit: Unit of the variable
1017
+ source: Source of the information
809
1018
 
810
1019
  Returns:
811
1020
  Self: The instance of the model with the updated variable.
@@ -814,10 +1023,20 @@ class Model:
814
1023
  if name not in self._variables:
815
1024
  msg = f"'{name}' not found in variables"
816
1025
  raise KeyError(msg)
817
- self._variables[name] = initial_condition
1026
+
1027
+ variable = self._variables[name]
1028
+
1029
+ if initial_value is not None:
1030
+ variable.initial_value = initial_value
1031
+ if unit is not None:
1032
+ variable.unit = unit
1033
+ if source is not None:
1034
+ variable.source = source
818
1035
  return self
819
1036
 
820
- def update_variables(self, variables: Mapping[str, float | Derived]) -> Self:
1037
+ def update_variables(
1038
+ self, variables: Mapping[str, float | Derived | Variable]
1039
+ ) -> Self:
821
1040
  """Updates multiple variables in the model.
822
1041
 
823
1042
  Examples:
@@ -831,37 +1050,17 @@ class Model:
831
1050
 
832
1051
  """
833
1052
  for k, v in variables.items():
834
- self.update_variable(k, v)
1053
+ if isinstance(v, Variable):
1054
+ self.update_variable(
1055
+ k,
1056
+ initial_value=v.initial_value,
1057
+ unit=v.unit,
1058
+ source=v.source,
1059
+ )
1060
+ else:
1061
+ self.update_variable(k, v)
835
1062
  return self
836
1063
 
837
- def get_variable_names(self) -> list[str]:
838
- """Retrieve the names of all variables.
839
-
840
- Examples:
841
- >>> model.get_variable_names()
842
- ["x1", "x2"]
843
-
844
- Returns:
845
- variable_names: A list containing the names of all variables.
846
-
847
- """
848
- return list(self._variables)
849
-
850
- def get_initial_conditions(self) -> dict[str, float]:
851
- """Retrieve the initial conditions of the model.
852
-
853
- Examples:
854
- >>> model.get_initial_conditions()
855
- {"x1": 1.0, "x2": 2.0}
856
-
857
- Returns:
858
- initial_conditions: A dictionary where the keys are variable names and the values are their initial conditions.
859
-
860
- """
861
- if (cache := self._cache) is None:
862
- cache = self._create_cache()
863
- return cache.initial_conditions
864
-
865
1064
  def make_variable_static(self, name: str, value: float | None = None) -> Self:
866
1065
  """Converts a variable to a static parameter.
867
1066
 
@@ -881,8 +1080,12 @@ class Model:
881
1080
  Self: The instance of the class for method chaining.
882
1081
 
883
1082
  """
884
- value_or_derived = self._variables[name] if value is None else value
1083
+ value_or_derived = (
1084
+ self._variables[name].initial_value if value is None else value
1085
+ )
885
1086
  self.remove_variable(name)
1087
+
1088
+ # FIXME: better handling of unit
886
1089
  if isinstance(value_or_derived, Derived):
887
1090
  self.add_derived(name, value_or_derived.fn, args=value_or_derived.args)
888
1091
  else:
@@ -901,12 +1104,12 @@ class Model:
901
1104
  return self
902
1105
 
903
1106
  ##########################################################################
904
- # Derived
1107
+ # Derived - views
905
1108
  ##########################################################################
906
1109
 
907
1110
  @property
908
- def derived(self) -> dict[str, Derived]:
909
- """Returns a copy of the derived quantities.
1111
+ def derived(self) -> TableView:
1112
+ """Returns a view of the derived quantities.
910
1113
 
911
1114
  Examples:
912
1115
  >>> model.derived
@@ -917,10 +1120,30 @@ class Model:
917
1120
  dict[str, Derived]: A copy of the derived dictionary.
918
1121
 
919
1122
  """
920
- return self._derived.copy()
1123
+ index = list(self._derived.keys())
1124
+ data = [
1125
+ {
1126
+ "value": _latex_view(
1127
+ fn_to_sympy(
1128
+ el.fn,
1129
+ origin=name,
1130
+ model_args=list_of_symbols(el.args),
1131
+ )
1132
+ ),
1133
+ "unit": _latex_view(unit) if (unit := el.unit) is not None else "",
1134
+ }
1135
+ for name, el in self._derived.items()
1136
+ ]
921
1137
 
922
- @property
923
- def derived_variables(self) -> dict[str, Derived]:
1138
+ return TableView(data=pd.DataFrame(data, index=index))
1139
+
1140
+ def get_raw_derived(self, *, as_copy: bool = True) -> dict[str, Derived]:
1141
+ """Get copy of derived values."""
1142
+ if as_copy:
1143
+ return copy.deepcopy(self._derived)
1144
+ return self._derived
1145
+
1146
+ def get_derived_variables(self) -> dict[str, Derived]:
924
1147
  """Returns a dictionary of derived variables.
925
1148
 
926
1149
  Examples:
@@ -940,8 +1163,7 @@ class Model:
940
1163
 
941
1164
  return {k: v for k, v in derived.items() if k not in cache.all_parameter_values}
942
1165
 
943
- @property
944
- def derived_parameters(self) -> dict[str, Derived]:
1166
+ def get_derived_parameters(self) -> dict[str, Derived]:
945
1167
  """Returns a dictionary of derived parameters.
946
1168
 
947
1169
  Examples:
@@ -966,6 +1188,7 @@ class Model:
966
1188
  fn: RateFn,
967
1189
  *,
968
1190
  args: list[str],
1191
+ unit: sympy.Expr | None = None,
969
1192
  ) -> Self:
970
1193
  """Adds a derived attribute to the model.
971
1194
 
@@ -976,13 +1199,14 @@ class Model:
976
1199
  name: The name of the derived attribute.
977
1200
  fn: The function used to compute the derived attribute.
978
1201
  args: The list of arguments to be passed to the function.
1202
+ unit: Unit of the derived value
979
1203
 
980
1204
  Returns:
981
1205
  Self: The instance of the model with the added derived attribute.
982
1206
 
983
1207
  """
984
1208
  self._insert_id(name=name, ctx="derived")
985
- self._derived[name] = Derived(fn=fn, args=args)
1209
+ self._derived[name] = Derived(fn=fn, args=args, unit=unit)
986
1210
  return self
987
1211
 
988
1212
  def get_derived_parameter_names(self) -> list[str]:
@@ -996,7 +1220,7 @@ class Model:
996
1220
  A list of names of the derived parameters.
997
1221
 
998
1222
  """
999
- return list(self.derived_parameters)
1223
+ return list(self.get_derived_parameters())
1000
1224
 
1001
1225
  def get_derived_variable_names(self) -> list[str]:
1002
1226
  """Retrieve the names of derived variables.
@@ -1009,7 +1233,7 @@ class Model:
1009
1233
  A list of names of derived variables.
1010
1234
 
1011
1235
  """
1012
- return list(self.derived_variables)
1236
+ return list(self.get_derived_variables())
1013
1237
 
1014
1238
  @_invalidate_cache
1015
1239
  def update_derived(
@@ -1018,6 +1242,7 @@ class Model:
1018
1242
  fn: RateFn | None = None,
1019
1243
  *,
1020
1244
  args: list[str] | None = None,
1245
+ unit: sympy.Expr | None = None,
1021
1246
  ) -> Self:
1022
1247
  """Updates the derived function and its arguments for a given name.
1023
1248
 
@@ -1026,16 +1251,21 @@ class Model:
1026
1251
 
1027
1252
  Args:
1028
1253
  name: The name of the derived function to update.
1029
- fn: The new derived function. If None, the existing function is retained. Defaults to None.
1030
- args: The new arguments for the derived function. If None, the existing arguments are retained. Defaults to None.
1254
+ fn: The new derived function. If None, the existing function is retained.
1255
+ args: The new arguments for the derived function. If None, the existing arguments are retained.
1256
+ unit: Unit of the derived value
1031
1257
 
1032
1258
  Returns:
1033
1259
  Self: The instance of the class with the updated derived function and arguments.
1034
1260
 
1035
1261
  """
1036
1262
  der = self._derived[name]
1037
- der.fn = der.fn if fn is None else fn
1038
- der.args = der.args if args is None else args
1263
+ if fn is not None:
1264
+ der.fn = fn
1265
+ if args is not None:
1266
+ der.args = args
1267
+ if unit is not None:
1268
+ der.unit = unit
1039
1269
  return self
1040
1270
 
1041
1271
  @_invalidate_cache
@@ -1061,7 +1291,27 @@ class Model:
1061
1291
  ###########################################################################
1062
1292
 
1063
1293
  @property
1064
- def reactions(self) -> dict[str, Reaction]:
1294
+ def reactions(self) -> TableView:
1295
+ """Get view of reactions."""
1296
+ index = list(self._reactions.keys())
1297
+ data = [
1298
+ {
1299
+ "value": _latex_view(
1300
+ fn_to_sympy(
1301
+ rxn.fn,
1302
+ origin=name,
1303
+ model_args=list_of_symbols(rxn.args),
1304
+ )
1305
+ ),
1306
+ "stoichiometry": stoichiometries_to_sympy(name, rxn.stoichiometry),
1307
+ "unit": _latex_view(unit) if (unit := rxn.unit) is not None else "",
1308
+ # "source"
1309
+ }
1310
+ for name, rxn in self._reactions.items()
1311
+ ]
1312
+ return TableView(data=pd.DataFrame(data, index=index))
1313
+
1314
+ def get_raw_reactions(self, *, as_copy: bool = True) -> dict[str, Reaction]:
1065
1315
  """Retrieve the reactions in the model.
1066
1316
 
1067
1317
  Examples:
@@ -1072,7 +1322,9 @@ class Model:
1072
1322
  dict[str, Reaction]: A deep copy of the reactions dictionary.
1073
1323
 
1074
1324
  """
1075
- return copy.deepcopy(self._reactions)
1325
+ if as_copy:
1326
+ return copy.deepcopy(self._reactions)
1327
+ return self._reactions
1076
1328
 
1077
1329
  def get_stoichiometries(
1078
1330
  self, variables: dict[str, float] | None = None, time: float = 0.0
@@ -1091,7 +1343,7 @@ class Model:
1091
1343
  """
1092
1344
  if (cache := self._cache) is None:
1093
1345
  cache = self._create_cache()
1094
- args = self.get_dependent(variables=variables, time=time)
1346
+ args = self.get_args(variables=variables, time=time)
1095
1347
 
1096
1348
  stoich_by_cpds = copy.deepcopy(cache.stoich_by_cpds)
1097
1349
  for cpd, stoich in cache.dyn_stoich_by_cpds.items():
@@ -1121,7 +1373,7 @@ class Model:
1121
1373
  """
1122
1374
  if (cache := self._cache) is None:
1123
1375
  cache = self._create_cache()
1124
- args = self.get_dependent(variables=variables, time=time)
1376
+ args = self.get_args(variables=variables, time=time)
1125
1377
 
1126
1378
  stoich = copy.deepcopy(cache.stoich_by_cpds[variable])
1127
1379
  for rxn, derived in cache.dyn_stoich_by_cpds.get(variable, {}).items():
@@ -1155,6 +1407,8 @@ class Model:
1155
1407
  *,
1156
1408
  args: list[str],
1157
1409
  stoichiometry: Mapping[str, float | str | Derived],
1410
+ unit: sympy.Expr | None = None,
1411
+ # source: str | None = None,
1158
1412
  ) -> Self:
1159
1413
  """Adds a reaction to the model.
1160
1414
 
@@ -1170,6 +1424,7 @@ class Model:
1170
1424
  fn: The function representing the reaction.
1171
1425
  args: A list of arguments for the reaction function.
1172
1426
  stoichiometry: The stoichiometry of the reaction, mapping species to their coefficients.
1427
+ unit: Unit of the rate
1173
1428
 
1174
1429
  Returns:
1175
1430
  Self: The instance of the model with the added reaction.
@@ -1181,7 +1436,12 @@ class Model:
1181
1436
  k: Derived(fn=fns.constant, args=[v]) if isinstance(v, str) else v
1182
1437
  for k, v in stoichiometry.items()
1183
1438
  }
1184
- self._reactions[name] = Reaction(fn=fn, stoichiometry=stoich, args=args)
1439
+ self._reactions[name] = Reaction(
1440
+ fn=fn,
1441
+ stoichiometry=stoich,
1442
+ args=args,
1443
+ unit=unit,
1444
+ )
1185
1445
  return self
1186
1446
 
1187
1447
  def get_reaction_names(self) -> list[str]:
@@ -1205,6 +1465,7 @@ class Model:
1205
1465
  *,
1206
1466
  args: list[str] | None = None,
1207
1467
  stoichiometry: Mapping[str, float | Derived | str] | None = None,
1468
+ unit: sympy.Expr | None = None,
1208
1469
  ) -> Self:
1209
1470
  """Updates the properties of an existing reaction in the model.
1210
1471
 
@@ -1220,6 +1481,7 @@ class Model:
1220
1481
  fn: The new function for the reaction. If None, the existing function is retained.
1221
1482
  args: The new arguments for the reaction. If None, the existing arguments are retained.
1222
1483
  stoichiometry: The new stoichiometry for the reaction. If None, the existing stoichiometry is retained.
1484
+ unit: Unit of the reaction
1223
1485
 
1224
1486
  Returns:
1225
1487
  Self: The instance of the model with the updated reaction.
@@ -1235,6 +1497,7 @@ class Model:
1235
1497
  }
1236
1498
  rxn.stoichiometry = stoich
1237
1499
  rxn.args = rxn.args if args is None else args
1500
+ rxn.unit = rxn.unit if unit is None else unit
1238
1501
  return self
1239
1502
 
1240
1503
  @_invalidate_cache
@@ -1285,7 +1548,14 @@ class Model:
1285
1548
  # Think of something like NADPH / (NADP + NADPH) as a proxy for energy state
1286
1549
  ##########################################################################
1287
1550
 
1288
- def add_readout(self, name: str, fn: RateFn, *, args: list[str]) -> Self:
1551
+ def add_readout(
1552
+ self,
1553
+ name: str,
1554
+ fn: RateFn,
1555
+ *,
1556
+ args: list[str],
1557
+ unit: sympy.Expr | None = None,
1558
+ ) -> Self:
1289
1559
  """Adds a readout to the model.
1290
1560
 
1291
1561
  Examples:
@@ -1298,13 +1568,14 @@ class Model:
1298
1568
  name: The name of the readout.
1299
1569
  fn: The function to be used for the readout.
1300
1570
  args: The list of arguments for the function.
1571
+ unit: Unit of the readout
1301
1572
 
1302
1573
  Returns:
1303
1574
  Self: The instance of the model with the added readout.
1304
1575
 
1305
1576
  """
1306
1577
  self._insert_id(name=name, ctx="readout")
1307
- self._readouts[name] = Readout(fn=fn, args=args)
1578
+ self._readouts[name] = Readout(fn=fn, args=args, unit=unit)
1308
1579
  return self
1309
1580
 
1310
1581
  def get_readout_names(self) -> list[str]:
@@ -1320,6 +1591,12 @@ class Model:
1320
1591
  """
1321
1592
  return list(self._readouts)
1322
1593
 
1594
+ def get_raw_readouts(self, *, as_copy: bool = True) -> dict[str, Readout]:
1595
+ """Get copy of readouts in the model."""
1596
+ if as_copy:
1597
+ return copy.deepcopy(self._readouts)
1598
+ return self._readouts
1599
+
1323
1600
  def remove_readout(self, name: str) -> Self:
1324
1601
  """Remove a readout by its name.
1325
1602
 
@@ -1428,6 +1705,21 @@ class Model:
1428
1705
  self._surrogates.pop(name)
1429
1706
  return self
1430
1707
 
1708
+ def get_raw_surrogates(
1709
+ self, *, as_copy: bool = True
1710
+ ) -> dict[str, AbstractSurrogate]:
1711
+ """Get direct copies of model surrogates."""
1712
+ if as_copy:
1713
+ return copy.deepcopy(self._surrogates)
1714
+ return self._surrogates
1715
+
1716
+ def get_surrogate_output_names(self) -> list[str]:
1717
+ """Return output names by surrogates."""
1718
+ names = []
1719
+ for i in self._surrogates.values():
1720
+ names.extend(i.outputs)
1721
+ return names
1722
+
1431
1723
  def get_surrogate_reaction_names(self) -> list[str]:
1432
1724
  """Return reaction names by surrogates."""
1433
1725
  names = []
@@ -1464,7 +1756,39 @@ class Model:
1464
1756
  # - readouts
1465
1757
  ##########################################################################
1466
1758
 
1467
- def _get_dependent(
1759
+ def get_arg_names(
1760
+ self,
1761
+ *,
1762
+ include_time: bool,
1763
+ include_variables: bool,
1764
+ include_parameters: bool,
1765
+ include_derived_parameters: bool,
1766
+ include_derived_variables: bool,
1767
+ include_reactions: bool,
1768
+ include_surrogate_outputs: bool,
1769
+ include_readouts: bool,
1770
+ ) -> list[str]:
1771
+ """Get names of all kinds of model components."""
1772
+ names = []
1773
+ if include_time:
1774
+ names.append("time")
1775
+ if include_variables:
1776
+ names.extend(self.get_variable_names())
1777
+ if include_parameters:
1778
+ names.extend(self.get_parameter_names())
1779
+ if include_derived_variables:
1780
+ names.extend(self.get_derived_variable_names())
1781
+ if include_derived_parameters:
1782
+ names.extend(self.get_derived_parameter_names())
1783
+ if include_reactions:
1784
+ names.extend(self.get_reaction_names())
1785
+ if include_surrogate_outputs:
1786
+ names.extend(self.get_surrogate_output_names())
1787
+ if include_readouts:
1788
+ names.extend(self.get_readout_names())
1789
+ return names
1790
+
1791
+ def _get_args(
1468
1792
  self,
1469
1793
  variables: dict[str, float],
1470
1794
  time: float = 0.0,
@@ -1474,7 +1798,7 @@ class Model:
1474
1798
  """Generate a dictionary of model components dependent on other components.
1475
1799
 
1476
1800
  Examples:
1477
- >>> model._get_dependent({"x1": 1.0, "x2": 2.0}, time=0.0)
1801
+ >>> model._get_args({"x1": 1.0, "x2": 2.0}, time=0.0)
1478
1802
  {"x1": 1.0, "x2": 2.0, "k1": 0.1, "time": 0.0}
1479
1803
 
1480
1804
  Args:
@@ -1502,11 +1826,18 @@ class Model:
1502
1826
 
1503
1827
  return cast(dict[str, float], args)
1504
1828
 
1505
- def get_dependent(
1829
+ def get_args(
1506
1830
  self,
1507
1831
  variables: dict[str, float] | None = None,
1508
1832
  time: float = 0.0,
1509
1833
  *,
1834
+ include_time: bool = True,
1835
+ include_variables: bool = True,
1836
+ include_parameters: bool = True,
1837
+ include_derived_parameters: bool = True,
1838
+ include_derived_variables: bool = True,
1839
+ include_reactions: bool = True,
1840
+ include_surrogate_outputs: bool = True,
1510
1841
  include_readouts: bool = False,
1511
1842
  ) -> pd.Series:
1512
1843
  """Generate a pandas Series of arguments for the model.
@@ -1514,20 +1845,27 @@ class Model:
1514
1845
  Examples:
1515
1846
  # Using initial conditions
1516
1847
  >>> model.get_args()
1517
- {"x1": 1.get_dependent, "x2": 2.0, "k1": 0.1, "time": 0.0}
1848
+ {"x1": 1.get_args, "x2": 2.0, "k1": 0.1, "time": 0.0}
1518
1849
 
1519
1850
  # With custom concentrations
1520
- >>> model.get_dependent({"x1": 1.0, "x2": 2.0})
1851
+ >>> model.get_args({"x1": 1.0, "x2": 2.0})
1521
1852
  {"x1": 1.0, "x2": 2.0, "k1": 0.1, "time": 0.0}
1522
1853
 
1523
1854
  # With custom concentrations and time
1524
- >>> model.get_dependent({"x1": 1.0, "x2": 2.0}, time=1.0)
1855
+ >>> model.get_args({"x1": 1.0, "x2": 2.0}, time=1.0)
1525
1856
  {"x1": 1.0, "x2": 2.0, "k1": 0.1, "time": 1.0}
1526
1857
 
1527
1858
  Args:
1528
1859
  variables: A dictionary where keys are the names of the concentrations and values are their respective float values.
1529
- time: The time point at which the arguments are generated (default is 0.0).
1530
- include_readouts: Whether to include readouts in the arguments (default is False).
1860
+ time: The time point at which the arguments are generated.
1861
+ include_time: Whether to include the time as an argument
1862
+ include_variables: Whether to include variables
1863
+ include_parameters: Whether to include parameters
1864
+ include_derived_parameters: Whether to include derived parameters
1865
+ include_derived_variables: Whether to include derived variables
1866
+ include_reactions: Whether to include reactions
1867
+ include_surrogate_outputs: Whether to include surrogate outputs
1868
+ include_readouts: Whether to include readouts
1531
1869
 
1532
1870
  Returns:
1533
1871
  A pandas Series containing the generated arguments with float dtype.
@@ -1535,111 +1873,60 @@ class Model:
1535
1873
  """
1536
1874
  if (cache := self._cache) is None:
1537
1875
  cache = self._create_cache()
1538
-
1539
- args = self._get_dependent(
1876
+ raw = self._get_args(
1540
1877
  variables=self.get_initial_conditions() if variables is None else variables,
1541
1878
  time=time,
1542
1879
  cache=cache,
1543
1880
  )
1544
-
1545
1881
  if include_readouts:
1546
1882
  for name, ro in self._readouts.items(): # FIXME: order?
1547
- ro.calculate_inpl(name, args)
1548
-
1549
- return pd.Series(args, dtype=float)
1883
+ ro.calculate_inpl(name, raw)
1884
+ args = pd.Series(raw, dtype=float)
1885
+ return args.loc[
1886
+ self.get_arg_names(
1887
+ include_time=include_time,
1888
+ include_variables=include_variables,
1889
+ include_parameters=include_parameters,
1890
+ include_derived_parameters=include_derived_parameters,
1891
+ include_derived_variables=include_derived_variables,
1892
+ include_reactions=include_reactions,
1893
+ include_surrogate_outputs=include_surrogate_outputs,
1894
+ include_readouts=include_readouts,
1895
+ )
1896
+ ]
1550
1897
 
1551
- def get_dependent_time_course(
1898
+ def _get_args_time_course(
1552
1899
  self,
1553
- variables: pd.DataFrame,
1554
1900
  *,
1555
- include_readouts: bool = False,
1556
- ) -> pd.DataFrame:
1557
- """Generate a DataFrame containing time course arguments for model evaluation.
1558
-
1559
- Examples:
1560
- >>> model.get_dependent_time_course(
1561
- ... pd.DataFrame({"x1": [1.0, 2.0], "x2": [2.0, 3.0]}
1562
- ... )
1563
- pd.DataFrame({
1564
- "x1": [1.0, 2.0],
1565
- "x2": [2.0, 3.0],
1566
- "k1": [0.1, 0.1],
1567
- "time": [0.0, 1.0]},
1568
- )
1569
-
1570
- Args:
1571
- variables: A DataFrame containing concentration data with time as the index.
1572
- include_readouts: If True, include readout variables in the resulting DataFrame.
1573
-
1574
- Returns:
1575
- A DataFrame containing the combined concentration data, parameter values,
1576
- derived variables, and optionally readout variables, with time as an additional column.
1901
+ variables: pd.DataFrame,
1902
+ include_readouts: bool,
1903
+ ) -> dict[float, dict[str, float]]:
1904
+ if (cache := self._cache) is None:
1905
+ cache = self._create_cache()
1577
1906
 
1578
- """
1579
- args = {
1580
- time: self.get_dependent(
1907
+ args_by_time = {}
1908
+ for time, values in variables.iterrows():
1909
+ args = self._get_args(
1581
1910
  variables=values.to_dict(),
1582
1911
  time=cast(float, time),
1583
- include_readouts=include_readouts,
1912
+ cache=cache,
1584
1913
  )
1585
- for time, values in variables.iterrows()
1586
- }
1587
-
1588
- return pd.DataFrame(args, dtype=float).T
1589
-
1590
- ##########################################################################
1591
- # Get args
1592
- ##########################################################################
1593
-
1594
- def get_args(
1595
- self,
1596
- variables: dict[str, float] | None = None,
1597
- time: float = 0.0,
1598
- *,
1599
- include_derived: bool = True,
1600
- include_readouts: bool = False,
1601
- ) -> pd.Series:
1602
- """Generate a pandas Series of arguments for the model.
1603
-
1604
- Examples:
1605
- # Using initial conditions
1606
- >>> model.get_args()
1607
- {"x1": 1.0, "x2": 2.0, "k1": 0.1, "time": 0.0}
1608
-
1609
- # With custom concentrations
1610
- >>> model.get_args({"x1": 1.0, "x2": 2.0})
1611
- {"x1": 1.0, "x2": 2.0, "k1": 0.1, "time": 0.0}
1612
-
1613
- # With custom concentrations and time
1614
- >>> model.get_args({"x1": 1.0, "x2": 2.0}, time=1.0)
1615
- {"x1": 1.0, "x2": 2.0, "k1": 0.1, "time": 1.0}
1616
-
1617
- Args:
1618
- variables: A dictionary where keys are the names of the concentrations and values are their respective float values.
1619
- time: The time point at which the arguments are generated.
1620
- include_derived: Whether to include derived variables in the arguments.
1621
- include_readouts: Whether to include readouts in the arguments.
1622
-
1623
- Returns:
1624
- A pandas Series containing the generated arguments with float dtype.
1625
-
1626
- """
1627
- names = self.get_variable_names()
1628
- if include_derived:
1629
- names.extend(self.get_derived_variable_names())
1630
- if include_readouts:
1631
- names.extend(self._readouts)
1632
-
1633
- args = self.get_dependent(
1634
- variables=variables, time=time, include_readouts=include_readouts
1635
- )
1636
- return args.loc[names]
1914
+ if include_readouts:
1915
+ for name, ro in self._readouts.items(): # FIXME: order?
1916
+ ro.calculate_inpl(name, args)
1917
+ args_by_time[time] = args
1918
+ return args_by_time
1637
1919
 
1638
1920
  def get_args_time_course(
1639
1921
  self,
1640
1922
  variables: pd.DataFrame,
1641
1923
  *,
1642
- include_derived: bool = True,
1924
+ include_variables: bool = True,
1925
+ include_parameters: bool = True,
1926
+ include_derived_parameters: bool = True,
1927
+ include_derived_variables: bool = True,
1928
+ include_reactions: bool = True,
1929
+ include_surrogate_outputs: bool = True,
1643
1930
  include_readouts: bool = False,
1644
1931
  ) -> pd.DataFrame:
1645
1932
  """Generate a DataFrame containing time course arguments for model evaluation.
@@ -1657,22 +1944,40 @@ class Model:
1657
1944
 
1658
1945
  Args:
1659
1946
  variables: A DataFrame containing concentration data with time as the index.
1660
- include_derived: Whether to include derived variables in the arguments.
1661
- include_readouts: If True, include readout variables in the resulting DataFrame.
1947
+ include_variables: Whether to include variables
1948
+ include_parameters: Whether to include parameters
1949
+ include_derived_parameters: Whether to include derived parameters
1950
+ include_derived_variables: Whether to include derived variables
1951
+ include_reactions: Whether to include reactions
1952
+ include_surrogate_outputs: Whether to include surrogate outputs
1953
+ include_readouts: Whether to include readouts
1662
1954
 
1663
1955
  Returns:
1664
1956
  A DataFrame containing the combined concentration data, parameter values,
1665
1957
  derived variables, and optionally readout variables, with time as an additional column.
1666
1958
 
1667
1959
  """
1668
- names = self.get_variable_names()
1669
- if include_derived:
1670
- names.extend(self.get_derived_variable_names())
1671
-
1672
- args = self.get_dependent_time_course(
1673
- variables=variables, include_readouts=include_readouts
1674
- )
1675
- return args.loc[:, names]
1960
+ args = pd.DataFrame(
1961
+ self._get_args_time_course(
1962
+ variables=variables,
1963
+ include_readouts=include_readouts,
1964
+ ),
1965
+ dtype=float,
1966
+ ).T
1967
+
1968
+ return args.loc[
1969
+ :,
1970
+ self.get_arg_names(
1971
+ include_time=False,
1972
+ include_variables=include_variables,
1973
+ include_parameters=include_parameters,
1974
+ include_derived_parameters=include_derived_parameters,
1975
+ include_derived_variables=include_derived_variables,
1976
+ include_reactions=include_reactions,
1977
+ include_surrogate_outputs=include_surrogate_outputs,
1978
+ include_readouts=include_readouts,
1979
+ ),
1980
+ ]
1676
1981
 
1677
1982
  ##########################################################################
1678
1983
  # Get fluxes
@@ -1707,10 +2012,9 @@ class Model:
1707
2012
 
1708
2013
  """
1709
2014
  names = self.get_reaction_names()
1710
- for surrogate in self._surrogates.values():
1711
- names.extend(surrogate.stoichiometries)
2015
+ names.extend(self.get_surrogate_reaction_names())
1712
2016
 
1713
- args = self.get_dependent(
2017
+ args = self.get_args(
1714
2018
  variables=variables,
1715
2019
  time=time,
1716
2020
  include_readouts=False,
@@ -1739,11 +2043,11 @@ class Model:
1739
2043
  the index of the input arguments.
1740
2044
 
1741
2045
  """
1742
- names = self.get_reaction_names()
2046
+ names: list[str] = self.get_reaction_names()
1743
2047
  for surrogate in self._surrogates.values():
1744
2048
  names.extend(surrogate.stoichiometries)
1745
2049
 
1746
- variables = self.get_dependent_time_course(
2050
+ variables = self.get_args_time_course(
1747
2051
  variables=variables,
1748
2052
  include_readouts=False,
1749
2053
  )
@@ -1781,7 +2085,7 @@ class Model:
1781
2085
  strict=True,
1782
2086
  )
1783
2087
  )
1784
- dependent: dict[str, float] = self._get_dependent(
2088
+ dependent: dict[str, float] = self._get_args(
1785
2089
  variables=vars_d,
1786
2090
  time=time,
1787
2091
  cache=cache,
@@ -1829,7 +2133,7 @@ class Model:
1829
2133
  if (cache := self._cache) is None:
1830
2134
  cache = self._create_cache()
1831
2135
  var_names = self.get_variable_names()
1832
- dependent = self._get_dependent(
2136
+ dependent = self._get_args(
1833
2137
  variables=self.get_initial_conditions() if variables is None else variables,
1834
2138
  time=time,
1835
2139
  cache=cache,