modelbase2 0.1.79__py3-none-any.whl → 0.2.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 (58) hide show
  1. modelbase2/__init__.py +138 -26
  2. modelbase2/distributions.py +306 -0
  3. modelbase2/experimental/__init__.py +17 -0
  4. modelbase2/experimental/codegen.py +239 -0
  5. modelbase2/experimental/diff.py +227 -0
  6. modelbase2/experimental/notes.md +4 -0
  7. modelbase2/experimental/tex.py +521 -0
  8. modelbase2/fit.py +284 -0
  9. modelbase2/fns.py +185 -0
  10. modelbase2/integrators/__init__.py +19 -0
  11. modelbase2/integrators/int_assimulo.py +146 -0
  12. modelbase2/integrators/int_scipy.py +147 -0
  13. modelbase2/label_map.py +610 -0
  14. modelbase2/linear_label_map.py +301 -0
  15. modelbase2/mc.py +548 -0
  16. modelbase2/mca.py +280 -0
  17. modelbase2/model.py +1621 -0
  18. modelbase2/npe.py +343 -0
  19. modelbase2/parallel.py +171 -0
  20. modelbase2/parameterise.py +28 -0
  21. modelbase2/paths.py +36 -0
  22. modelbase2/plot.py +829 -0
  23. modelbase2/sbml/__init__.py +14 -0
  24. modelbase2/sbml/_data.py +77 -0
  25. modelbase2/sbml/_export.py +656 -0
  26. modelbase2/sbml/_import.py +585 -0
  27. modelbase2/sbml/_mathml.py +691 -0
  28. modelbase2/sbml/_name_conversion.py +52 -0
  29. modelbase2/sbml/_unit_conversion.py +74 -0
  30. modelbase2/scan.py +616 -0
  31. modelbase2/scope.py +96 -0
  32. modelbase2/simulator.py +635 -0
  33. modelbase2/surrogates/__init__.py +32 -0
  34. modelbase2/surrogates/_poly.py +66 -0
  35. modelbase2/surrogates/_torch.py +249 -0
  36. modelbase2/surrogates.py +316 -0
  37. modelbase2/types.py +352 -11
  38. modelbase2-0.2.0.dist-info/METADATA +81 -0
  39. modelbase2-0.2.0.dist-info/RECORD +42 -0
  40. {modelbase2-0.1.79.dist-info → modelbase2-0.2.0.dist-info}/WHEEL +1 -1
  41. modelbase2/core/__init__.py +0 -29
  42. modelbase2/core/algebraic_module_container.py +0 -130
  43. modelbase2/core/constant_container.py +0 -113
  44. modelbase2/core/data.py +0 -109
  45. modelbase2/core/name_container.py +0 -29
  46. modelbase2/core/reaction_container.py +0 -115
  47. modelbase2/core/utils.py +0 -28
  48. modelbase2/core/variable_container.py +0 -24
  49. modelbase2/ode/__init__.py +0 -13
  50. modelbase2/ode/integrator.py +0 -80
  51. modelbase2/ode/mca.py +0 -270
  52. modelbase2/ode/model.py +0 -470
  53. modelbase2/ode/simulator.py +0 -153
  54. modelbase2/utils/__init__.py +0 -0
  55. modelbase2/utils/plotting.py +0 -372
  56. modelbase2-0.1.79.dist-info/METADATA +0 -44
  57. modelbase2-0.1.79.dist-info/RECORD +0 -22
  58. {modelbase2-0.1.79.dist-info → modelbase2-0.2.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,585 @@
1
+ from __future__ import annotations
2
+
3
+ import math # noqa: F401 # models might need it
4
+ import re
5
+ import warnings
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Self
10
+
11
+ import libsbml
12
+ import numpy as np # noqa: F401 # models might need it
13
+ import sympy
14
+
15
+ from modelbase2.model import Model, _sort_dependencies
16
+ from modelbase2.paths import default_tmp_dir
17
+ from modelbase2.sbml._data import (
18
+ AtomicUnit,
19
+ Compartment,
20
+ CompositeUnit,
21
+ Compound,
22
+ Derived,
23
+ Function,
24
+ Parameter,
25
+ Reaction,
26
+ )
27
+ from modelbase2.sbml._mathml import parse_sbml_math
28
+ from modelbase2.sbml._name_conversion import _name_to_py
29
+ from modelbase2.sbml._unit_conversion import get_operator_mappings, get_unit_conversion
30
+ from modelbase2.types import unwrap
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable
34
+
35
+ __all__ = [
36
+ "INDENT",
37
+ "OPERATOR_MAPPINGS",
38
+ "Parser",
39
+ "UNIT_CONVERSION",
40
+ "import_from_path",
41
+ "read",
42
+ "valid_filename",
43
+ ]
44
+
45
+ UNIT_CONVERSION = get_unit_conversion()
46
+ OPERATOR_MAPPINGS = get_operator_mappings()
47
+ INDENT = " "
48
+
49
+
50
+ def _nan_to_zero(value: float) -> float:
51
+ return 0 if str(value) == "nan" else value
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class Parser:
56
+ # Collections
57
+ boundary_species: set[str] = field(default_factory=set)
58
+ # Parsed stuff
59
+ atomic_units: dict[str, AtomicUnit] = field(default_factory=dict)
60
+ composite_units: dict[str, CompositeUnit] = field(default_factory=dict)
61
+ compartments: dict[str, Compartment] = field(default_factory=dict)
62
+ parameters: dict[str, Parameter] = field(default_factory=dict)
63
+ variables: dict[str, Compound] = field(default_factory=dict)
64
+ derived: dict[str, Derived] = field(default_factory=dict)
65
+ initial_assignment: dict[str, Derived] = field(default_factory=dict)
66
+ functions: dict[str, Function] = field(default_factory=dict)
67
+ reactions: dict[str, Reaction] = field(default_factory=dict)
68
+
69
+ def parse(self, file: str | Path) -> Self:
70
+ if not Path(file).exists():
71
+ msg = "Model file does not exist"
72
+ raise OSError(msg)
73
+ doc = libsbml.readSBMLFromFile(str(file))
74
+
75
+ # Check for unsupported packages
76
+ for i in range(doc.num_plugins):
77
+ if doc.getPlugin(i).getPackageName() == "comp":
78
+ msg = "No support for comp package"
79
+ raise NotImplementedError(msg)
80
+
81
+ sbml_model = doc.getModel()
82
+ if sbml_model is None:
83
+ return self
84
+
85
+ if bool(sbml_model.getConversionFactor()):
86
+ msg = "Conversion factors are currently not supported"
87
+ raise NotImplementedError(msg)
88
+
89
+ self.parse_functions(sbml_model)
90
+ self.parse_units(sbml_model)
91
+ self.parse_compartments(sbml_model)
92
+ self.parse_variables(sbml_model)
93
+ self.parse_parameters(sbml_model)
94
+ self.parse_initial_assignments(sbml_model)
95
+ self.parse_rules(sbml_model)
96
+ self.parse_constraints(sbml_model)
97
+ self.parse_reactions(sbml_model)
98
+ self.parse_events(sbml_model)
99
+
100
+ # Modifications
101
+ self._convert_substance_amount_to_concentration()
102
+ # self._rename_species_references()
103
+ return self
104
+
105
+ ###############################################################################
106
+ # PARSING STAGE
107
+ ###############################################################################
108
+
109
+ def parse_constraints(self, sbml_model: libsbml.Model) -> None:
110
+ if len(sbml_model.getListOfConstraints()) > 0:
111
+ msg = "modelbase does not support model constraints. "
112
+ raise NotImplementedError(msg)
113
+
114
+ def parse_events(self, sbml_model: libsbml.Model) -> None:
115
+ if len(sbml_model.getListOfEvents()) > 0:
116
+ msg = (
117
+ "modelbase does not current support events. "
118
+ "Check the file for how to integrate properly."
119
+ )
120
+ raise NotImplementedError(msg)
121
+
122
+ def parse_units(self, sbml_model: libsbml.Model) -> None:
123
+ for unit_definition in sbml_model.getListOfUnitDefinitions():
124
+ composite_id = unit_definition.getId()
125
+ local_units = []
126
+ for unit in unit_definition.getListOfUnits():
127
+ atomic_unit = AtomicUnit(
128
+ kind=UNIT_CONVERSION[unit.getKind()],
129
+ scale=unit.getScale(),
130
+ exponent=unit.getExponent(),
131
+ multiplier=unit.getMultiplier(),
132
+ )
133
+ local_units.append(atomic_unit.kind)
134
+ self.atomic_units[atomic_unit.kind] = atomic_unit
135
+ self.composite_units[composite_id] = CompositeUnit(
136
+ sbml_id=composite_id,
137
+ units=local_units,
138
+ )
139
+
140
+ def parse_compartments(self, sbml_model: libsbml.Model) -> None:
141
+ for compartment in sbml_model.getListOfCompartments():
142
+ sbml_id = _name_to_py(compartment.getId())
143
+ size = compartment.getSize()
144
+ if str(size) == "nan":
145
+ size = 0
146
+ self.compartments[sbml_id] = Compartment(
147
+ name=compartment.getName(),
148
+ dimensions=compartment.getSpatialDimensions(),
149
+ size=size,
150
+ units=compartment.getUnits(),
151
+ is_constant=compartment.getConstant(),
152
+ )
153
+
154
+ def parse_parameters(self, sbml_model: libsbml.Model) -> None:
155
+ for parameter in sbml_model.getListOfParameters():
156
+ self.parameters[_name_to_py(parameter.getId())] = Parameter(
157
+ value=_nan_to_zero(parameter.getValue()),
158
+ is_constant=parameter.getConstant(),
159
+ )
160
+
161
+ def parse_variables(self, sbml_model: libsbml.Model) -> None:
162
+ for compound in sbml_model.getListOfSpecies():
163
+ compound_id = _name_to_py(compound.getId())
164
+ if bool(compound.getConversionFactor()):
165
+ msg = "Conversion factors are not implemented. "
166
+ raise NotImplementedError(msg)
167
+
168
+ # NOTE: What the shit is this?
169
+ initial_amount = compound.getInitialAmount()
170
+ if str(initial_amount) == "nan":
171
+ initial_amount = compound.getInitialConcentration()
172
+ is_concentration = str(initial_amount) != "nan"
173
+ else:
174
+ is_concentration = False
175
+
176
+ has_boundary_condition = compound.getBoundaryCondition()
177
+ if has_boundary_condition:
178
+ self.boundary_species.add(compound_id)
179
+
180
+ self.variables[compound_id] = Compound(
181
+ compartment=compound.getCompartment(),
182
+ initial_amount=_nan_to_zero(initial_amount),
183
+ substance_units=compound.getSubstanceUnits(),
184
+ has_only_substance_units=compound.getHasOnlySubstanceUnits(),
185
+ has_boundary_condition=has_boundary_condition,
186
+ is_constant=compound.getConstant(),
187
+ is_concentration=is_concentration,
188
+ )
189
+
190
+ def parse_functions(self, sbml_model: libsbml.Model) -> None:
191
+ for func in sbml_model.getListOfFunctionDefinitions():
192
+ func_name = func.getName()
193
+ sbml_id = func.getId()
194
+ if sbml_id is None or sbml_id == "":
195
+ sbml_id = func_name
196
+ elif func_name is None or func_name == "":
197
+ func_name = sbml_id
198
+ func_name = _name_to_py(func_name)
199
+
200
+ if (node := func.getMath()) is None:
201
+ continue
202
+ body, args = parse_sbml_math(node=node)
203
+
204
+ self.functions[func_name] = Function(
205
+ body=body,
206
+ args=args,
207
+ )
208
+
209
+ ###############################################################################
210
+ # Different kinds of derived values
211
+ ###############################################################################
212
+
213
+ def parse_initial_assignments(self, sbml_model: libsbml.Model) -> None:
214
+ for assignment in sbml_model.getListOfInitialAssignments():
215
+ name = _name_to_py(assignment.getSymbol())
216
+
217
+ node = assignment.getMath()
218
+ if node is None:
219
+ warnings.warn(
220
+ f"Unusable math for {name}",
221
+ stacklevel=1,
222
+ )
223
+ continue
224
+
225
+ body, args = parse_sbml_math(node)
226
+ self.initial_assignment[name] = Derived(
227
+ body=body,
228
+ args=args,
229
+ )
230
+
231
+ def _parse_algebraic_rule(self, rule: libsbml.AlgebraicRule) -> None:
232
+ msg = f"Algebraic rules are not implemented for {rule.getId()}"
233
+ raise NotImplementedError(msg)
234
+
235
+ def _parse_assignment_rule(self, rule: libsbml.AssignmentRule) -> None:
236
+ if (node := rule.getMath()) is None:
237
+ return
238
+
239
+ name: str = _name_to_py(rule.getId())
240
+ body, args = parse_sbml_math(node=node)
241
+
242
+ self.derived[name] = Derived(
243
+ body=body,
244
+ args=args,
245
+ )
246
+
247
+ def _parse_rate_rule(self, rule: libsbml.RateRule) -> None:
248
+ msg = f"Skipping rate rule {rule.getId()}"
249
+ raise NotImplementedError(msg)
250
+
251
+ def parse_rules(self, sbml_model: libsbml.Model) -> None:
252
+ """Parse rules and separate them by type."""
253
+ for rule in sbml_model.getListOfRules():
254
+ if rule.element_name == "algebraicRule":
255
+ self._parse_algebraic_rule(rule=rule)
256
+ elif rule.element_name == "assignmentRule":
257
+ self._parse_assignment_rule(rule=rule)
258
+ elif rule.element_name == "rateRule":
259
+ self._parse_rate_rule(rule=rule)
260
+ else:
261
+ msg = "Unknown rate type"
262
+ raise ValueError(msg)
263
+
264
+ def _parse_local_parameters(
265
+ self, reaction_id: str, kinetic_law: libsbml.KineticLaw
266
+ ) -> dict[str, str]:
267
+ """Parse local parameters."""
268
+ parameters_to_update = {}
269
+ for parameter in kinetic_law.getListOfLocalParameters():
270
+ old_id = _name_to_py(parameter.getId())
271
+ if old_id in self.parameters:
272
+ new_id = f"{reaction_id}__{old_id}"
273
+ parameters_to_update[old_id] = new_id
274
+ else:
275
+ new_id = old_id
276
+ self.parameters[new_id] = Parameter(
277
+ value=_nan_to_zero(parameter.getValue()),
278
+ is_constant=parameter.getConstant(),
279
+ )
280
+ # Some models apparently also write local parameters in this
281
+ for parameter in kinetic_law.getListOfParameters():
282
+ old_id = _name_to_py(parameter.getId())
283
+ if old_id in self.parameters:
284
+ new_id = f"{reaction_id}__{old_id}"
285
+ parameters_to_update[old_id] = new_id
286
+ else:
287
+ new_id = old_id
288
+ self.parameters[new_id] = Parameter(
289
+ value=_nan_to_zero(parameter.getValue()),
290
+ is_constant=parameter.getConstant(),
291
+ )
292
+ return parameters_to_update
293
+
294
+ def parse_reactions(self, sbml_model: libsbml.Model) -> None:
295
+ for reaction in sbml_model.getListOfReactions():
296
+ sbml_id = _name_to_py(reaction.getId())
297
+ kinetic_law = reaction.getKineticLaw()
298
+ if kinetic_law is None:
299
+ continue
300
+ parameters_to_update = self._parse_local_parameters(
301
+ reaction_id=sbml_id,
302
+ kinetic_law=kinetic_law,
303
+ )
304
+
305
+ node = reaction.getKineticLaw().getMath()
306
+ # FIXME: convert substance amount to concentration here
307
+ body, args = parse_sbml_math(node=node)
308
+
309
+ # Update parameter references
310
+ for old, new in parameters_to_update.items():
311
+ pat = re.compile(f"({old})" + r"\b")
312
+ body = pat.sub(new, body)
313
+
314
+ for i, arg in enumerate(args):
315
+ args[i] = parameters_to_update.get(arg, arg)
316
+
317
+ dynamic_stoichiometry: dict[str, str] = {}
318
+ parsed_reactants: defaultdict[str, int] = defaultdict(int)
319
+ for substrate in reaction.getListOfReactants():
320
+ species = _name_to_py(substrate.getSpecies())
321
+ if (ref := substrate.getId()) != "":
322
+ dynamic_stoichiometry[species] = ref
323
+ self.parameters[ref] = Parameter(0.0, is_constant=False)
324
+
325
+ elif species not in self.boundary_species:
326
+ factor = substrate.getStoichiometry()
327
+ if str(factor) == "nan":
328
+ msg = f"Cannot parse stoichiometry: {factor}"
329
+ raise ValueError(msg)
330
+ parsed_reactants[species] -= factor
331
+
332
+ parsed_products: defaultdict[str, int] = defaultdict(int)
333
+ for product in reaction.getListOfProducts():
334
+ species = _name_to_py(product.getSpecies())
335
+ if (ref := product.getId()) != "":
336
+ dynamic_stoichiometry[species] = ref
337
+ self.parameters[ref] = Parameter(0.0, is_constant=False)
338
+ elif species not in self.boundary_species:
339
+ factor = product.getStoichiometry()
340
+ if str(factor) == "nan":
341
+ msg = f"Cannot parse stoichiometry: {factor}"
342
+ raise ValueError(msg)
343
+ parsed_products[species] += factor
344
+
345
+ # Combine stoichiometries
346
+ # Hint: you can't just combine the dictionaries, as you have cases like
347
+ # S1 + S2 -> 2S2, which have to be combined to S1 -> S2
348
+ stoichiometries = dict(parsed_reactants)
349
+ for species, value in parsed_products.items():
350
+ if species in stoichiometries:
351
+ stoichiometries[species] = stoichiometries[species] + value
352
+ else:
353
+ stoichiometries[species] = value
354
+
355
+ self.reactions[sbml_id] = Reaction(
356
+ body=body,
357
+ stoichiometry=stoichiometries | dynamic_stoichiometry,
358
+ args=args,
359
+ )
360
+
361
+ def _convert_substance_amount_to_concentration(
362
+ self,
363
+ ) -> None:
364
+ """Convert substance amount to concentration if has_only_substance_units is false.
365
+
366
+ The compounds in the test are supplied in mole if has_only_substance_units is false.
367
+ In that case, the reaction equation has to be reformed like this:
368
+ k1 * S1 * compartment -> k1 * S1
369
+ or in other words the species have to be divided by the compartment to get
370
+ concentration units.
371
+ """
372
+ for reaction in self.reactions.values():
373
+ function_body = reaction.body
374
+ removed_compartments = set()
375
+ for arg in reaction.args:
376
+ # the parsed species part is important to not
377
+ # introduce conversion on things that aren't species
378
+ if (species := self.variables.get(arg, None)) is None:
379
+ continue
380
+
381
+ if not species.has_only_substance_units:
382
+ compartment = species.compartment
383
+ if compartment is not None:
384
+ if self.compartments[compartment].dimensions == 0:
385
+ continue
386
+ if species.is_concentration:
387
+ if compartment not in removed_compartments:
388
+ pattern = f"({compartment})" + r"\b"
389
+ repl = f"({compartment} / {compartment})"
390
+ function_body = re.sub(pattern, repl, function_body)
391
+ removed_compartments.add(compartment)
392
+ else:
393
+ # \b is word boundary
394
+ pattern = f"({arg})" + r"\b"
395
+ repl = f"({arg} / {compartment})"
396
+ function_body = re.sub(pattern, repl, function_body)
397
+ if compartment not in reaction.args:
398
+ reaction.args.append(compartment)
399
+
400
+ # Simplify the function
401
+ try:
402
+ reaction.body = str(sympy.parse_expr(function_body))
403
+ except AttributeError:
404
+ # E.g. when math.factorial is called
405
+ # FIXME: do the sympy conversion before?
406
+ reaction.body = function_body
407
+
408
+
409
+ def _handle_fn(name: str, body: str, args: list[str]) -> Callable[..., float]:
410
+ func_args = ", ".join(args)
411
+ func_str = "\n".join(
412
+ [
413
+ f"def {name}({func_args}):",
414
+ f"{INDENT}return {body}",
415
+ "",
416
+ ]
417
+ )
418
+ try:
419
+ exec(func_str, globals(), None) # noqa: S102
420
+ except SyntaxError as e:
421
+ msg = f"Invalid function definition: {func_str}"
422
+ raise SyntaxError(msg) from e
423
+ python_func = globals()[name]
424
+ python_func.__source__ = func_str
425
+ return python_func # type: ignore
426
+
427
+
428
+ def _codegen_fn(name: str, body: str, args: list[str]) -> str:
429
+ func_args = ", ".join(args)
430
+ return "\n".join(
431
+ [
432
+ f"def {name}({func_args}):",
433
+ f"{INDENT}return {body}",
434
+ "",
435
+ ]
436
+ )
437
+
438
+
439
+ def _codegen_initial_assignment(
440
+ k: str,
441
+ sbml: Parser,
442
+ parameters: dict[str, float],
443
+ ) -> str:
444
+ if k in parameters:
445
+ return f"m.update_parameter('{k}', _init_{k}(*(args[i] for i in {sbml.initial_assignment[k].args})) )"
446
+
447
+ ass = sbml.initial_assignment[k]
448
+ fn = f"_init_{k}(*(args[i] for i in {ass.args}))"
449
+
450
+ if (species := sbml.variables.get(k)) is not None:
451
+ compartment = species.compartment
452
+ if compartment is not None and (
453
+ not species.is_concentration
454
+ or (species.has_only_substance_units and species.is_concentration)
455
+ ):
456
+ size = 1 if (c := sbml.compartments.get(compartment)) is None else c.size
457
+ if size != 0:
458
+ fn = f"{fn} * {size}"
459
+ elif k in sbml.compartments:
460
+ pass
461
+ else:
462
+ msg = "Initial assignment targeting unknown type"
463
+ raise NotImplementedError(msg)
464
+
465
+ return f"m.update_variable('{k}', {fn})"
466
+
467
+
468
+ def _codgen(name: str, sbml: Parser) -> Path:
469
+ import itertools as it
470
+
471
+ functions = {
472
+ k: _codegen_fn(k, body=v.body, args=v.args)
473
+ for k, v in it.chain(
474
+ sbml.functions.items(),
475
+ sbml.derived.items(),
476
+ sbml.reactions.items(),
477
+ {f"_init_{k}": v for k, v in sbml.initial_assignment.items()}.items(),
478
+ )
479
+ }
480
+
481
+ parameters = {
482
+ k: v.value for k, v in sbml.parameters.items() if k not in sbml.derived
483
+ }
484
+ variables = {
485
+ k: v.initial_amount for k, v in sbml.variables.items() if k not in sbml.derived
486
+ }
487
+ for k, v in sbml.compartments.items():
488
+ if k in sbml.derived:
489
+ continue
490
+ if v.is_constant:
491
+ parameters[k] = v.size
492
+ else:
493
+ variables[k] = v.size
494
+
495
+ derived_str = "\n ".join(
496
+ f"m.add_derived('{k}', fn={k}, args={v.args})" for k, v in sbml.derived.items()
497
+ )
498
+ rxn_str = "\n ".join(
499
+ f"m.add_reaction('{k}', fn={k}, args={rxn.args}, stoichiometry={rxn.stoichiometry})"
500
+ for k, rxn in sbml.reactions.items()
501
+ )
502
+
503
+ functions_str = "\n\n".join(functions.values())
504
+
505
+ parameters_str = f"m.add_parameters({parameters})" if len(parameters) > 0 else ""
506
+ variables_str = f"m.add_variables({variables})" if len(variables) > 0 else ""
507
+
508
+ # Initial assignments
509
+ initial_assignment_order = _sort_dependencies(
510
+ available=set(sbml.initial_assignment) ^ set(parameters) ^ set(variables),
511
+ elements=[(k, set(v.args)) for k, v in sbml.initial_assignment.items()],
512
+ )
513
+
514
+ if len(initial_assignment_order) > 0:
515
+ initial_assignment_source = "\n ".join(
516
+ _codegen_initial_assignment(k, sbml, parameters=parameters)
517
+ for k in initial_assignment_order
518
+ )
519
+ else:
520
+ initial_assignment_source = ""
521
+
522
+ file = f"""
523
+ import math
524
+
525
+ import numpy as np
526
+ import scipy
527
+
528
+ from modelbase2 import Model
529
+
530
+ {functions_str}
531
+
532
+ def get_model() -> Model:
533
+ m = Model()
534
+ {parameters_str}
535
+ {variables_str}
536
+ {derived_str}
537
+ {rxn_str}
538
+ args = m.get_args()
539
+ {initial_assignment_source}
540
+ return m
541
+ """
542
+ path = default_tmp_dir(None, remove_old_cache=False) / f"{name}.py"
543
+ with path.open("w+") as f:
544
+ f.write(file)
545
+ return path
546
+
547
+
548
+ def import_from_path(module_name: str, file_path: Path) -> Callable[[], Model]:
549
+ import sys
550
+ from importlib import util
551
+
552
+ spec = unwrap(util.spec_from_file_location(module_name, file_path))
553
+ module = util.module_from_spec(spec)
554
+ sys.modules[module_name] = module
555
+ unwrap(spec.loader).exec_module(module)
556
+ return module.get_model
557
+
558
+
559
+ def valid_filename(value: str) -> str:
560
+ import re
561
+ import unicodedata
562
+
563
+ value = (
564
+ unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
565
+ )
566
+ value = re.sub(r"[^\w\s-]", "", value.lower())
567
+ value = re.sub(r"[-\s]+", "_", value).strip("-_")
568
+ return f"mb_{value}"
569
+
570
+
571
+ def read(file: Path) -> Model:
572
+ """Import a metabolic model from an SBML file.
573
+
574
+ Args:
575
+ file: Path to the SBML file to import.
576
+
577
+ Returns:
578
+ Model: Imported model instance.
579
+
580
+ """
581
+ name = valid_filename(file.stem)
582
+ sbml = Parser().parse(file=file)
583
+
584
+ model_fn = import_from_path(name, _codgen(name, sbml))
585
+ return model_fn()