myokit 1.38.0__py3-none-any.whl → 1.39.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.
Files changed (43) hide show
  1. myokit/__init__.py +5 -0
  2. myokit/_datablock.py +6 -5
  3. myokit/_expressions.py +6 -1
  4. myokit/_model_api.py +44 -18
  5. myokit/_myokit_version.py +1 -1
  6. myokit/_parsing.py +8 -2
  7. myokit/_sim/cvodessim.py +26 -0
  8. myokit/formats/__init__.py +37 -0
  9. myokit/formats/ansic/_ewriter.py +1 -1
  10. myokit/formats/axon/_abf.py +43 -9
  11. myokit/formats/cellml/v1/__init__.py +5 -5
  12. myokit/formats/cellml/v1/_api.py +220 -122
  13. myokit/formats/cellml/v1/_parser.py +91 -87
  14. myokit/formats/cellml/v1/_writer.py +13 -6
  15. myokit/formats/cellml/v2/__init__.py +5 -8
  16. myokit/formats/cellml/v2/_api.py +182 -106
  17. myokit/formats/cellml/v2/_parser.py +68 -64
  18. myokit/formats/cellml/v2/_writer.py +7 -3
  19. myokit/formats/heka/_patchmaster.py +71 -14
  20. myokit/formats/mathml/_parser.py +106 -67
  21. myokit/gui/source.py +18 -12
  22. myokit/lib/hh.py +21 -37
  23. myokit/tests/test_cellml_v1_api.py +227 -33
  24. myokit/tests/test_cellml_v1_parser.py +48 -17
  25. myokit/tests/test_cellml_v1_writer.py +14 -4
  26. myokit/tests/test_cellml_v2_api.py +132 -114
  27. myokit/tests/test_cellml_v2_parser.py +31 -1
  28. myokit/tests/test_cellml_v2_writer.py +8 -1
  29. myokit/tests/test_datalog.py +17 -0
  30. myokit/tests/test_expressions.py +61 -0
  31. myokit/tests/test_formats.py +99 -0
  32. myokit/tests/test_formats_mathml_content.py +97 -37
  33. myokit/tests/test_formats_python.py +1 -1
  34. myokit/tests/test_model_building.py +2 -0
  35. myokit/tests/test_parsing.py +32 -0
  36. myokit/tests/test_simulation_cvodes.py +10 -4
  37. myokit/tests/test_variable.py +10 -7
  38. {myokit-1.38.0.dist-info → myokit-1.39.0.dist-info}/METADATA +22 -7
  39. {myokit-1.38.0.dist-info → myokit-1.39.0.dist-info}/RECORD +43 -43
  40. {myokit-1.38.0.dist-info → myokit-1.39.0.dist-info}/WHEEL +1 -1
  41. {myokit-1.38.0.dist-info → myokit-1.39.0.dist-info}/entry_points.txt +0 -0
  42. {myokit-1.38.0.dist-info → myokit-1.39.0.dist-info/licenses}/LICENSE.txt +0 -0
  43. {myokit-1.38.0.dist-info → myokit-1.39.0.dist-info}/top_level.txt +0 -0
@@ -6,6 +6,7 @@
6
6
  #
7
7
  import myokit
8
8
 
9
+ from myokit.formats import is_integer_string, is_real_number_string
9
10
  from myokit.formats.xml import split
10
11
 
11
12
 
@@ -145,6 +146,13 @@ class MathMLParser:
145
146
  ``<rem>``
146
147
  Becomes a :class:`myokit.Remainder`.
147
148
 
149
+ Minimum and maximum
150
+
151
+ ``<max>`` and ``<min>``
152
+ Are converted to if-statements. Only binary comparisons are supported,
153
+ and the resulting ``if`` will evaluate its operands twice, which may be
154
+ inefficient if either operand is a complex expression.
155
+
148
156
  Trigonometry
149
157
 
150
158
  ``<sin>``, ``<cos>`` and ``<tan>``
@@ -415,6 +423,12 @@ class MathMLParser:
415
423
  elif name == 'leq':
416
424
  return myokit.LessEqual(*self._eat(element, iterator, 2))
417
425
 
426
+ # Min and max
427
+ elif name == 'min':
428
+ return self._parse_minmax(element, iterator, myokit.Less)
429
+ elif name == 'max':
430
+ return self._parse_minmax(element, iterator, myokit.More)
431
+
418
432
  # Trigonometry
419
433
  elif name == 'sin':
420
434
  return myokit.Sin(*self._eat(element, iterator))
@@ -655,6 +669,41 @@ class MathMLParser:
655
669
  else:
656
670
  return myokit.Log(op, base)
657
671
 
672
+ def _parse_minmax(self, element, iterator, operator):
673
+ """
674
+ Parses a ``min`` or ``max`` operation, replacing it with one or more
675
+ :class:`myokit.If` functions.
676
+
677
+ Caveats: Only binary min & max are supported, and if either operand is
678
+ a complex expression if will evaluated twice.
679
+
680
+ Arguments:
681
+
682
+ ``element``
683
+ The element that determined the operator type.
684
+ ``iterator``
685
+ An iterator pointing at the first element after ``element``.
686
+ ``operator``
687
+ Either :class:`myokit.Less` or :class:`myokit.More`, depending on
688
+ the desired operation.
689
+
690
+ """
691
+ # Get all operands
692
+ ops = [self._parse_atomic(x) for x in iterator]
693
+
694
+ # Check the number of operands
695
+ n = len(ops)
696
+ if n != 2:
697
+ raise MathMLError(
698
+ 'Only binary min() and max() operations are supported.',
699
+ element)
700
+
701
+ # Return chain of ifs
702
+ e = ops[0]
703
+ for i in range(1, n):
704
+ e = myokit.If(operator(e, ops[i]), e, ops[i])
705
+ return e
706
+
658
707
  def _parse_nary(self, element, iterator, binary, unary=None):
659
708
  """
660
709
  Parses operands for unary, binary, or n-ary operators (for example
@@ -706,62 +755,51 @@ class MathMLParser:
706
755
 
707
756
  # Get value
708
757
  if kind == 'real':
709
- # Float, specified as 123.123 (no exponent!)
710
- # May be in a different base than 10
711
- base = element.attrib.get('base', '10').strip()
712
- try:
713
- base = float(base)
714
- except (TypeError, ValueError):
715
- raise MathMLError(
716
- 'Invalid base specified on <ci> element.', element)
758
+ # Float, specified as 123.123 (no exponent!). May be in a different
759
+ # base than 10.
760
+ base = element.attrib.get('base', '10')
761
+ if not is_integer_string(base, True):
762
+ raise MathMLError('Invalid base specified on <ci> element:'
763
+ f' {base}.', element)
764
+ base = int(base)
717
765
  if base != 10:
718
- raise MathMLError(
719
- 'Numbers in bases other than 10 are not supported.',
720
- element)
766
+ raise MathMLError('Real numbers in bases other than 10 are not'
767
+ ' supported.', element)
721
768
 
722
769
  # Get value
723
- # Note: We are being tolerant here and allowing e-notation (which
724
- # is not consistent with the spec!)
770
+ # Note: We are being tolerant here and allowing e-notation - which
771
+ # is not consistent with the spec.
725
772
  if element.text is None:
726
773
  raise MathMLError('Empty <cn> element', element)
727
- try:
728
- value = float(element.text.strip())
729
- except ValueError:
730
- raise MathMLError(
731
- 'Unable to convert contents of <cn> to a real number: "'
732
- + str(element.text) + '"', element)
774
+ if not is_real_number_string(element.text, True):
775
+ raise MathMLError('Unable to convert contents of <cn> to a'
776
+ f' real number: "{element.text}".', element)
777
+ value = float(element.text)
733
778
 
734
779
  elif kind == 'integer':
735
780
  # Integer in any given base
736
781
  base = element.attrib.get('base', '10').strip()
737
- try:
738
- base = int(base)
739
- except ValueError:
740
- raise MathMLError(
741
- 'Unable to parse base of <cn> element: "' + base + '"',
742
- element)
782
+ if not is_integer_string(base, True):
783
+ raise MathMLError('Invalid base specified on <ci> element:'
784
+ f' {base}.', element)
785
+ base = int(base)
743
786
 
744
787
  # Get value
745
788
  if element.text is None:
746
789
  raise MathMLError('Empty <cn> element', element)
747
- try:
748
- value = int(element.text.strip(), base)
749
- except ValueError:
750
- raise MathMLError(
751
- 'Unable to convert contents of <cn> to an integer: "'
752
- + str(element.text) + '"', element)
790
+ if not is_integer_string(element.text, True):
791
+ raise MathMLError('Unable to convert contents of <cn> to an'
792
+ f' integer: "{element.text}".', element)
793
+ value = int(element.text, base)
753
794
 
754
795
  elif kind == 'double':
755
796
  # Floating point (positive, negative, exponents, etc)
756
-
757
797
  if element.text is None:
758
798
  raise MathMLError('Empty <cn> element', element)
759
- try:
760
- value = float(element.text.strip())
761
- except ValueError:
762
- raise MathMLError(
763
- 'Unable to convert contents of <cn> to a real number: "'
764
- + str(element.text) + '"', element)
799
+ if not is_real_number_string(element.text, True):
800
+ raise MathMLError('Unable to convert contents of <cn> to a'
801
+ f' real number: "{element.text}".', element)
802
+ value = float(element.text)
765
803
 
766
804
  elif kind == 'e-notation':
767
805
  # 1<sep />3 = 1e3
@@ -769,61 +807,62 @@ class MathMLParser:
769
807
  # Check contents
770
808
  parts = [x for x in element]
771
809
  if len(parts) != 1 or split(parts[0].tag)[1] != 'sep':
772
- raise MathMLError(
773
- 'Number in e-notation should have the format'
774
- ' number<sep />number.', element)
810
+ raise MathMLError('Number in e-notation should have the format'
811
+ ' number<sep />number.', element)
775
812
 
776
813
  # Get parts of number
777
814
  sig = element.text
778
815
  exp = parts[0].tail
779
- if sig is None or not sig.strip():
816
+ if sig is None:
780
817
  raise MathMLError(
781
818
  'Unable to parse number in e-notation: missing part before'
782
819
  ' the separator.', element)
783
- if exp is None or not exp.strip():
820
+ if exp is None:
784
821
  raise MathMLError(
785
822
  'Unable to parse number in e-notation: missing part after'
786
823
  ' the separator.', element)
787
824
 
788
- # Get value
825
+ sig, exp = sig.strip(), exp.strip()
826
+ if not is_integer_string(exp):
827
+ raise MathMLError(
828
+ 'Unable to parse number in e-notation: part after the'
829
+ ' separator should be an integer.', element)
830
+ # For sig, we can't allow e.g. 1e3, so different strategy
789
831
  try:
790
- value = float(sig.strip() + 'e' + exp.strip())
832
+ value = float(f'{sig}e{exp}')
791
833
  except ValueError:
792
834
  raise MathMLError(
793
- 'Unable to parse number in e-notation "' + sig + 'e' + exp
794
- + '".', element)
835
+ 'Unable to parse number in e-notation: part before the'
836
+ ' separator should be a basic real number.', element)
795
837
 
796
838
  elif kind == 'rational':
797
839
  # 1<sep />3 = 1 / 3
798
840
  # Check contents
799
841
  parts = [x for x in element]
800
842
  if len(parts) != 1 or split(parts[0].tag)[1] != 'sep':
801
- raise MathMLError(
802
- 'Rational number should have the format'
803
- ' number<sep />number.', element)
843
+ raise MathMLError('Rational number should have the format'
844
+ ' number<sep />number.', element)
804
845
 
805
846
  # Get parts of number
806
847
  numer = element.text
807
848
  denom = parts[0].tail
808
- if numer is None or not numer.strip():
809
- raise MathMLError(
810
- 'Unable to parse rational number: missing part before the'
811
- ' separator.', element)
812
- if denom is None or not denom.strip():
813
- raise MathMLError(
814
- 'Unable to parse rational number: missing part after the'
815
- ' separator.', element)
816
-
817
- # Get value
818
- try:
819
- value = float(numer.strip()) / float(denom.strip())
820
- except ValueError:
821
- raise MathMLError(
822
- 'Unable to parse rational number "' + numer + ' / ' + denom
823
- + '".', element)
849
+ if numer is None:
850
+ raise MathMLError('Unable to parse rational number: missing'
851
+ ' part before the separator.', element)
852
+ if denom is None:
853
+ raise MathMLError('Unable to parse rational number: missing'
854
+ ' part after the separator.', element)
855
+
856
+ if not is_integer_string(numer, True):
857
+ raise MathMLError('Unable to parse rational : part before the'
858
+ ' separator should be an integer.', element)
859
+ if not is_integer_string(denom, True):
860
+ raise MathMLError('Unable to parse rational : part after the'
861
+ ' separator should be an integer.', element)
862
+ value = int(numer) / int(denom)
824
863
 
825
864
  else:
826
- raise MathMLError('Unsupported <cn> type: ' + kind, element)
865
+ raise MathMLError(f'Unsupported <cn> type: {kind}', element)
827
866
 
828
867
  # Create number and return
829
868
  return self._nfac(value, element)
myokit/gui/source.py CHANGED
@@ -51,6 +51,14 @@ COLOR_BRACKET = QtGui.QColor(240, 100, 0)
51
51
  # Selected line is highlighted
52
52
  COLOR_SELECTED_LINE = QtGui.QColor(238, 238, 238)
53
53
 
54
+ # Real number regex
55
+ # Note 1: The first part is a "lookbehind", that stops it matching just after a
56
+ # word character, so in "a1" it won't match, and in "a.1" it won't start
57
+ # matching until the "1" because "." is not a word character.
58
+ # Note 2: The remainder is the same as in myokit.float
59
+ # Note 3: Deliberately unsigned
60
+ LITERAL = rf'(?<!\w){myokit._RE_UNSIGNED_REAL}'
61
+
54
62
 
55
63
  def _adapt_for_dark_mode(palette):
56
64
  """
@@ -991,24 +999,24 @@ class ModelHighlighter(QtGui.QSyntaxHighlighter):
991
999
  self._rules = []
992
1000
 
993
1001
  # Numbers
994
- pattern = R(r'\b[+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?\b')
995
- self._rules.append((pattern, STYLE_LITERAL))
1002
+ self._rules.append((R(LITERAL), STYLE_LITERAL))
996
1003
  unit = r'\[([a-zA-Z0-9/^-]|\*)+\]'
997
1004
  self._rules.append((R(unit), STYLE_INLINE_UNIT))
998
1005
 
999
1006
  # Keywords
1007
+ # Note: \b is a "word boundary" match
1000
1008
  for keyword in self.KEYWORD_1:
1001
- self._rules.append((R(r'\b' + keyword + r'\b'), STYLE_KEYWORD_1))
1009
+ self._rules.append((R(rf'\b{keyword}\b'), STYLE_KEYWORD_1))
1002
1010
  for keyword in self.KEYWORD_2:
1003
- self._rules.append((R(r'\b' + keyword + r'\b'), STYLE_KEYWORD_2))
1011
+ self._rules.append((R(rf'\b{keyword}\b'), STYLE_KEYWORD_2))
1004
1012
 
1005
1013
  # Meta-data coloring
1006
1014
  self._rules_labels = [
1007
- R(r'(\s*)(bind)\s+(' + name + ')'),
1008
- R(r'(\s*)(label)\s+(' + name + ')'),
1015
+ R(rf'(\s*)(bind)\s+({name})'),
1016
+ R(rf'(\s*)(label)\s+({name})'),
1009
1017
  ]
1010
- self._rule_meta = R(r'^\s*(' + name + r':)(\s*)(.+)')
1011
- self._rule_var_unit = R(r'^(\s*)(in)(\s*)(' + unit + ')')
1018
+ self._rule_meta = R(rf'^\s*({name}:)(\s*)(.+)')
1019
+ self._rule_var_unit = R(rf'^(\s*)(in)(\s*)({unit})')
1012
1020
 
1013
1021
  # Comment
1014
1022
  self._comment = R(r'#')
@@ -1141,8 +1149,7 @@ class ProtocolHighlighter(QtGui.QSyntaxHighlighter):
1141
1149
  self._rules = []
1142
1150
 
1143
1151
  # Numbers
1144
- self._rules.append(
1145
- (R(r'\b[+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?\b'), STYLE_LITERAL))
1152
+ self._rules.append((R(LITERAL), STYLE_LITERAL))
1146
1153
 
1147
1154
  # Keyword "next"
1148
1155
  self._rules.append((R(r'\bnext\b'), STYLE_KEYWORD_1))
@@ -1192,8 +1199,7 @@ class ScriptHighlighter(QtGui.QSyntaxHighlighter):
1192
1199
 
1193
1200
  # Literals: numbers, True, False, None
1194
1201
  # Override some keywords
1195
- self._rules.append((R(r'\b[+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?\b'),
1196
- STYLE_LITERAL))
1202
+ self._rules.append((R(LITERAL), STYLE_LITERAL))
1197
1203
  self._rules.append((R(r'\bTrue\b'), STYLE_LITERAL))
1198
1204
  self._rules.append((R(r'\bFalse\b'), STYLE_LITERAL))
1199
1205
  self._rules.append((R(r'\bNone\b'), STYLE_LITERAL))
myokit/lib/hh.py CHANGED
@@ -123,13 +123,12 @@ class HHModel:
123
123
  try:
124
124
  state = self._model.get(str(state), myokit.Variable)
125
125
  except KeyError:
126
- raise HHModelError('Unknown state: <' + str(state) + '>.')
126
+ raise HHModelError(f'Unknown state: <{state}>.')
127
127
  if not state.is_state():
128
128
  raise HHModelError(
129
- 'Variable <' + state.qname() + '> is not a state.')
129
+ f'Variable <{state.qname()}> is not a state.')
130
130
  if state in self._states:
131
- raise HHModelError(
132
- 'State <' + state.qname() + '> was added twice.')
131
+ raise HHModelError(f'State <{state.qname()}> was added twice.')
133
132
  self._states.append(state)
134
133
  del states
135
134
 
@@ -144,14 +143,11 @@ class HHModel:
144
143
  try:
145
144
  parameter = self._model.get(parameter, myokit.Variable)
146
145
  except KeyError:
147
- raise HHModelError(
148
- 'Unknown parameter: <' + str(parameter) + '>.')
146
+ raise HHModelError(f'Unknown parameter: <{parameter}>.')
149
147
  if not parameter.is_literal():
150
- raise HHModelError(
151
- 'Unsuitable parameter: <' + str(parameter) + '>.')
148
+ raise HHModelError(f'Unsuitable parameter: <{parameter}>.')
152
149
  if parameter in unique:
153
- raise HHModelError(
154
- 'Parameter listed twice: <' + str(parameter) + '>.')
150
+ raise HHModelError(f'Parameter listed twice: <{parameter}>.')
155
151
  unique.add(parameter)
156
152
  self._parameters.append(parameter)
157
153
  del unique
@@ -217,7 +213,7 @@ class HHModel:
217
213
  for state in self._states:
218
214
  if not has_inf_tau_form(state, self._membrane_potential):
219
215
  raise HHModelError(
220
- 'State `' + state.qname() + '` must have "inf-tau form" or'
216
+ f'State `{state.qname()}` must have "inf-tau form" or'
221
217
  ' "alpha-beta form". See'
222
218
  ' `myokit.lib.hh.has_inf_tau_form()` and'
223
219
  ' `myokit.lib.hh.has_alpha_beta_form()`.'
@@ -294,13 +290,9 @@ class HHModel:
294
290
  inf = w.ex(myokit.Name(inf))
295
291
  tau = w.ex(myokit.Name(tau))
296
292
  k = str(k)
297
- f.append(
298
- '_y[' + k + '] = ' + state.uname() + ' = '
299
- + inf + ' + (_y0[' + k + '] - ' + inf
300
- + ') * numpy.exp(-_t / ' + tau + ')')
301
- f.append(
302
- '_y[' + k + '][_t == 0] = ' + state.uname() + '[_t == 0] = '
303
- + '_y0[' + k + ']')
293
+ f.append(f'_y[{k}] = {state.uname()} ='
294
+ f' {inf} + (_y0[{k}] - {inf}) * numpy.exp(-_t / {tau})')
295
+ f.append(f'_y[{k}][_t == 0] = {state.uname()}[_t == 0] = _y0[{k}]')
304
296
 
305
297
  # Add current calculation
306
298
  if self._current is not None:
@@ -313,7 +305,6 @@ class HHModel:
313
305
  for i in range(1, len(f)):
314
306
  f[i] = ' ' + f[i]
315
307
  f = '\n'.join(f)
316
- #print(f)
317
308
  local = {}
318
309
  exec(f, {'numpy': np}, local)
319
310
  self._function = local['_f']
@@ -331,7 +322,7 @@ class HHModel:
331
322
  k = str(k)
332
323
  inf, tau = get_inf_and_tau(state, self._membrane_potential)
333
324
  inf = inf.rhs().clone(expand=True, retain=self._inputs)
334
- g.append('_y[' + str(k) + '] = ' + w.ex(inf))
325
+ g.append(f'_y[{k}] = {w.ex(inf)}')
335
326
 
336
327
  # Create python function from g
337
328
  g.append('return _y')
@@ -431,8 +422,7 @@ class HHModel:
431
422
  raise HHModelError(
432
423
  'The given component has more than one variable that could'
433
424
  ' be a current: '
434
- + ', '.join(['<' + x.qname() + '>' for x in currents])
435
- + '.')
425
+ + ', '.join([f'<{x.qname()}>' for x in currents]) + '.')
436
426
  try:
437
427
  current = currents[0]
438
428
  except IndexError:
@@ -494,9 +484,8 @@ class HHModel:
494
484
  if parameters is not None:
495
485
  if len(parameters) != len(self._parameters):
496
486
  raise ValueError(
497
- 'Illegal parameter vector size: '
498
- + str(len(self._parameters)) + ' required, '
499
- + str(len(parameters)) + ' provided.')
487
+ f'Illegal parameter vector size: f{len(self._parameters)}'
488
+ f' required, {len(parameters)} provided.')
500
489
  inputs[1:] = [float(x) for x in parameters]
501
490
  return inputs
502
491
 
@@ -764,8 +753,7 @@ class AnalyticalSimulation:
764
753
  log[key] = np.concatenate((
765
754
  log[key], np.zeros(log_times.shape)))
766
755
  except KeyError:
767
- raise ValueError(
768
- 'Invalid log: missing entry for <' + str(key) + '>.')
756
+ raise ValueError(f'Invalid log: missing entry for <{key}>.')
769
757
 
770
758
  # Run simulation
771
759
  if self._protocol is None:
@@ -855,8 +843,7 @@ class AnalyticalSimulation:
855
843
  'Wrong size state vector, expecing (' + str(len(self._state))
856
844
  + ') values.')
857
845
  if np.any(state < 0) or np.any(state > 1):
858
- raise ValueError(
859
- 'All states must be in the range [0, 1].')
846
+ raise ValueError('All states must be in the range [0, 1].')
860
847
  self._default_state = state
861
848
 
862
849
  def set_membrane_potential(self, v):
@@ -873,9 +860,8 @@ class AnalyticalSimulation:
873
860
  Changes the parameter values used in this simulation.
874
861
  """
875
862
  if len(parameters) != len(self._parameters):
876
- raise ValueError(
877
- 'Wrong size parameter vector, expecting ('
878
- + str(len(self._parameters)) + ') values.')
863
+ raise ValueError('Wrong size parameter vector, expecting'
864
+ f' ({len(self._parameters)}) values.')
879
865
  self._parameters = np.array(parameters, copy=True, dtype=float)
880
866
 
881
867
  def set_state(self, state):
@@ -884,12 +870,10 @@ class AnalyticalSimulation:
884
870
  """
885
871
  state = np.array(state, copy=True, dtype=float)
886
872
  if len(state) != len(self._state):
887
- raise ValueError(
888
- 'Wrong size state vector, expecing (' + str(len(self._state))
889
- + ') values.')
873
+ raise ValueError('Wrong size state vector, expecting'
874
+ f' ({len(self._state)}) values.')
890
875
  if np.any(state < 0) or np.any(state > 1):
891
- raise ValueError(
892
- 'All states must be in the range [0, 1].')
876
+ raise ValueError('All states must be in the range [0, 1].')
893
877
  self._state = state
894
878
 
895
879
  def solve(self, times):