PyAntiGen 1.0.9__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.
- framework/AntimonyGen.py +48 -0
- framework/RxnDict_to_antimony.py +594 -0
- framework/TelluriumGen.py +16 -0
- framework/__init__.py +0 -0
- framework/antimony_utils.py +294 -0
- framework/cli.py +229 -0
- framework/data_interpolation.py +340 -0
- framework/isotopomer_tools.py +41 -0
- framework/model_generation.py +46 -0
- framework/models.py +189 -0
- framework/module_base.py +42 -0
- framework/pyantigen.py +51 -0
- framework/rate_laws.py +101 -0
- framework/reaction_creation.py +43 -0
- framework/template/Example/AntiGen_paths.py +23 -0
- framework/template/Example/Engine/Anchor_cache.py +193 -0
- framework/template/Example/Engine/Deadline.py +535 -0
- framework/template/Example/Engine/Evaluator.py +1176 -0
- framework/template/Example/Engine/Event_times.py +491 -0
- framework/template/Example/Engine/Fast_profile.py +701 -0
- framework/template/Example/Engine/Fit_cache.py +329 -0
- framework/template/Example/Engine/Identifiability.py +698 -0
- framework/template/Example/Engine/Model_optimize.py +1483 -0
- framework/template/Example/Engine/Model_simulate.py +124 -0
- framework/template/Example/Engine/Nuisance_sensitivity.py +298 -0
- framework/template/Example/Engine/Optimize.py +6862 -0
- framework/template/Example/Engine/Petab_export.py +398 -0
- framework/template/Example/Engine/Preequil_cache.py +361 -0
- framework/template/Example/Engine/Profile_checkpoint.py +399 -0
- framework/template/Example/Engine/Results.py +395 -0
- framework/template/Example/Engine/Sensitivity_analysis.py +320 -0
- framework/template/Example/Engine/Simulate.py +617 -0
- framework/template/Example/Flipflop_reference.py +401 -0
- framework/template/Example/Model_generate.py +37 -0
- framework/template/Example/Model_run.py +261 -0
- framework/template/Example/Modules/Data.py +63 -0
- framework/template/Example/Modules/Events.py +14 -0
- framework/template/Example/Modules/Experiment.py +194 -0
- framework/template/Example/Modules/Loss_config.py +61 -0
- framework/template/Example/Modules/Observed_species.py +3 -0
- framework/template/Example/Modules/Optimizer_settings.py +258 -0
- framework/template/Example/Modules/Plots.py +89 -0
- framework/template/Example/Modules/Solver_settings.py +16 -0
- framework/template/Example/Modules/Update_opt_parameters.py +24 -0
- framework/template/Example/Modules/Update_parameters.py +49 -0
- framework/template/data/ADneg.csv +27 -0
- framework/template/data/ADpos.csv +27 -0
- framework/template/data/Flipflop.csv +29 -0
- framework/template/data/make_flipflop_data.py +174 -0
- pyantigen-1.0.9.dist-info/METADATA +129 -0
- pyantigen-1.0.9.dist-info/RECORD +55 -0
- pyantigen-1.0.9.dist-info/WHEEL +5 -0
- pyantigen-1.0.9.dist-info/entry_points.txt +2 -0
- pyantigen-1.0.9.dist-info/licenses/LICENSE +21 -0
- pyantigen-1.0.9.dist-info/top_level.txt +1 -0
framework/pyantigen.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from framework.isotopomer_tools import ensure_isotopes_format
|
|
3
|
+
from framework.model_generation import generate_model
|
|
4
|
+
|
|
5
|
+
class PyAntiGen:
|
|
6
|
+
def __init__(self, name, isotopes=None):
|
|
7
|
+
self.name = name
|
|
8
|
+
self.isotopes = ensure_isotopes_format(isotopes or [''])
|
|
9
|
+
self.reactions = []
|
|
10
|
+
self.rules = []
|
|
11
|
+
self.counter = 0
|
|
12
|
+
|
|
13
|
+
def add_reaction(
|
|
14
|
+
self,
|
|
15
|
+
name,
|
|
16
|
+
reactants,
|
|
17
|
+
products,
|
|
18
|
+
rate_type,
|
|
19
|
+
rate_eqtn,
|
|
20
|
+
compartment=None,
|
|
21
|
+
compartment_reverse=None,
|
|
22
|
+
):
|
|
23
|
+
from framework.reaction_creation import reaction_creation
|
|
24
|
+
self.counter, self.reactions = reaction_creation(
|
|
25
|
+
self.reactions,
|
|
26
|
+
self.counter,
|
|
27
|
+
name,
|
|
28
|
+
reactants,
|
|
29
|
+
products,
|
|
30
|
+
rate_type,
|
|
31
|
+
rate_eqtn,
|
|
32
|
+
compartment=compartment,
|
|
33
|
+
compartment_reverse=compartment_reverse,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def add_rule(self, rule):
|
|
37
|
+
self.rules.append(rule)
|
|
38
|
+
|
|
39
|
+
def generate(self, calling_file_path, model_name=None):
|
|
40
|
+
"""
|
|
41
|
+
Generates the Antimony model. This wraps the existing model_generation logic.
|
|
42
|
+
"""
|
|
43
|
+
if model_name is None:
|
|
44
|
+
model_name = self.name
|
|
45
|
+
|
|
46
|
+
def build_reactions_wrapper(Isotopes):
|
|
47
|
+
# The isotopes passed in here by generate_model are essentially self.isotopes
|
|
48
|
+
# because we formatted them earlier. So we can just return our state.
|
|
49
|
+
return self.reactions, self.rules
|
|
50
|
+
|
|
51
|
+
generate_model(build_reactions_wrapper, self.isotopes, calling_file_path, model_name)
|
framework/rate_laws.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate law semantics and volumetric scaling for PyAntiGen.
|
|
3
|
+
|
|
4
|
+
Each rate type has defined units and explicit volume scaling behavior so that
|
|
5
|
+
generated Antimony has correct [amount/time] ODE terms.
|
|
6
|
+
|
|
7
|
+
Units summary:
|
|
8
|
+
- MA / RMA: User supplies [concentration]^n / time; framework multiplies by V_compartment
|
|
9
|
+
to yield [amount/time]. Single-compartment reactions.
|
|
10
|
+
- UDF / BDF: User supplies [volume/time] (flow rate); framework uses concentration
|
|
11
|
+
of species (no global V multiplier). Trans-compartment transport.
|
|
12
|
+
- custom_conc_per_time: User equation in [concentration]^n/time; framework multiplies
|
|
13
|
+
by V_compartment. Set species_names_are_conc_per_time=True (default).
|
|
14
|
+
- custom_amt_per_time: User equation already in [amount/time]; no volume scaling.
|
|
15
|
+
- custom: Raw expression as-is; user must include volume scaling if needed.
|
|
16
|
+
"""
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLaw(ABC):
|
|
22
|
+
"""Base for rate law semantics (units and volume scaling)."""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def multiplies_by_volume(self) -> bool:
|
|
27
|
+
"""Whether the framework multiplies the rate expression by compartment volume."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def user_units_description(self) -> str:
|
|
33
|
+
"""Expected units of the user-supplied rate expression."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MassActionLaw(RateLaw):
|
|
38
|
+
"""MA / RMA: [concentration]^n / time; framework multiplies by V_compartment."""
|
|
39
|
+
rate_type = "MA"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def multiplies_by_volume(self) -> bool:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def user_units_description(self) -> str:
|
|
47
|
+
return "[concentration]^n / time → framework multiplies by V_compartment to yield amount/time"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VolumeTransportLaw(RateLaw):
|
|
51
|
+
"""UDF / BDF: [volume/time]; species appear as concentration; no V multiplier on expression."""
|
|
52
|
+
rate_type = "UDF"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def multiplies_by_volume(self) -> bool:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def user_units_description(self) -> str:
|
|
60
|
+
return "[volume/time]; species in equation are concentrations; no V_compartment multiplier"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CustomLaw(RateLaw):
|
|
64
|
+
"""
|
|
65
|
+
custom_conc_per_time / custom_amt_per_time / custom.
|
|
66
|
+
custom_conc_per_time: equation in conc/time → we multiply by V.
|
|
67
|
+
custom_amt_per_time: equation already in amount/time → no scaling.
|
|
68
|
+
custom: as-is; user must include volume scaling if needed.
|
|
69
|
+
"""
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
rate_type: str = "custom",
|
|
73
|
+
species_names_are_conc_per_time: Optional[bool] = None,
|
|
74
|
+
):
|
|
75
|
+
self.rate_type = rate_type
|
|
76
|
+
self._mult_vol = (
|
|
77
|
+
species_names_are_conc_per_time
|
|
78
|
+
if species_names_are_conc_per_time is not None
|
|
79
|
+
else (rate_type == "custom_conc_per_time")
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def multiplies_by_volume(self) -> bool:
|
|
84
|
+
return self._mult_vol
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def user_units_description(self) -> str:
|
|
88
|
+
if self.multiplies_by_volume:
|
|
89
|
+
return "[concentration]^n/time → framework multiplies by V_compartment"
|
|
90
|
+
return "Equation already in [amount/time]; no volume scaling applied"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_rate_law_info(rate_type: str) -> str:
|
|
94
|
+
"""Return a short docstring for the given rate type (units and scaling)."""
|
|
95
|
+
if rate_type in ("MA", "RMA"):
|
|
96
|
+
return MassActionLaw(rate_type).user_units_description
|
|
97
|
+
if rate_type in ("UDF", "BDF"):
|
|
98
|
+
return VolumeTransportLaw(rate_type).user_units_description
|
|
99
|
+
if rate_type in ("custom_conc_per_time", "custom_amt_per_time", "custom"):
|
|
100
|
+
return CustomLaw(rate_type).user_units_description
|
|
101
|
+
return "Unknown rate type"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from framework.models import (
|
|
2
|
+
reaction_from_args,
|
|
3
|
+
VALID_RATE_TYPES,
|
|
4
|
+
RATE_TYPES_TWO_CONSTANTS,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def reaction_creation(
|
|
9
|
+
all_reactions,
|
|
10
|
+
counter,
|
|
11
|
+
Reaction_name,
|
|
12
|
+
Reactants,
|
|
13
|
+
Products,
|
|
14
|
+
Rate_type,
|
|
15
|
+
Rate_eqtn_prototype,
|
|
16
|
+
compartment=None,
|
|
17
|
+
compartment_reverse=None,
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
Create and append a validated reaction to all_reactions.
|
|
21
|
+
Accepts reactants/products as either list of strings or bracket string (e.g. "[A, B]").
|
|
22
|
+
Validates required fields and rate type at call time; raises on invalid input.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
reaction = reaction_from_args(
|
|
26
|
+
name=Reaction_name,
|
|
27
|
+
reactants=Reactants,
|
|
28
|
+
products=Products,
|
|
29
|
+
rate_type=Rate_type,
|
|
30
|
+
rate_eqtn=Rate_eqtn_prototype,
|
|
31
|
+
compartment=compartment,
|
|
32
|
+
compartment_reverse=compartment_reverse,
|
|
33
|
+
)
|
|
34
|
+
except ValueError as e:
|
|
35
|
+
raise ValueError(f"add_reaction validation failed: {e}") from e
|
|
36
|
+
|
|
37
|
+
if reaction.Rate_type in RATE_TYPES_TWO_CONSTANTS:
|
|
38
|
+
counter += 2
|
|
39
|
+
else:
|
|
40
|
+
counter += 1
|
|
41
|
+
|
|
42
|
+
all_reactions.append(reaction.to_dict())
|
|
43
|
+
return counter, all_reactions
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
# Use location: import Modules from the same folder as this script (model folder)
|
|
5
|
+
_project_dir = Path(__file__).resolve().parent
|
|
6
|
+
if str(_project_dir) not in sys.path:
|
|
7
|
+
sys.path.insert(0, str(_project_dir))
|
|
8
|
+
|
|
9
|
+
MODEL_NAME = _project_dir.name
|
|
10
|
+
|
|
11
|
+
# Add PyAntiGen root to sys.path if running from within the framework template
|
|
12
|
+
if _project_dir.parent.name == "template":
|
|
13
|
+
_pyantigen_root = _project_dir.parents[2]
|
|
14
|
+
if str(_pyantigen_root) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_pyantigen_root))
|
|
16
|
+
|
|
17
|
+
if _project_dir.name == "scripts":
|
|
18
|
+
REPO_ROOT = str(_project_dir.parent)
|
|
19
|
+
else:
|
|
20
|
+
REPO_ROOT = str(_project_dir.parents[1])
|
|
21
|
+
|
|
22
|
+
if REPO_ROOT not in sys.path:
|
|
23
|
+
sys.path.insert(0, REPO_ROOT)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Hessian-derived quantities, cached against the fit they belong to.
|
|
2
|
+
|
|
3
|
+
The Wald statistics cost ``1 + 2k + 2k(k-1)`` objective evaluations -- 513 on
|
|
4
|
+
the 16-parameter SILK APP spec -- and they are recomputed from scratch on every
|
|
5
|
+
launch. That was tolerable when an evaluation was assumed to cost seconds. It
|
|
6
|
+
is not: the measured cost on that spec is 116 s, so across 39 workers the
|
|
7
|
+
Hessian alone is about 25 minutes, and it is charged again on every link of a
|
|
8
|
+
chain that may run to a hundred links.
|
|
9
|
+
|
|
10
|
+
On a preemptible partition the number matters for a second and sharper reason.
|
|
11
|
+
Nothing is written until a profile point finishes, so a link only makes
|
|
12
|
+
progress if the node survives setup *plus* one slice. Cutting 25 minutes off
|
|
13
|
+
setup lowers that threshold directly, which is the difference between a
|
|
14
|
+
short-lived node contributing something and contributing nothing at all.
|
|
15
|
+
|
|
16
|
+
Caching is safe here because the Hessian is a pure function of things the run
|
|
17
|
+
already fingerprints: the model, the optimization spec, the parameter scaling
|
|
18
|
+
and the optimum it is taken at. A change in any of them produces a different
|
|
19
|
+
key and a miss, so a stale Hessian cannot be silently reused -- the failure
|
|
20
|
+
mode that would matter, since an SE that does not belong to this fit would set
|
|
21
|
+
the profile's whole grid in the wrong place.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from Engine.Profile_checkpoint import sweep_stale_temp_files
|
|
32
|
+
|
|
33
|
+
# Everything _attach_wald_stats puts in out["stats"]. Cached and restored as a
|
|
34
|
+
# set: a partial restore would leave the CI from one fit beside the SE of
|
|
35
|
+
# another.
|
|
36
|
+
WALD_FIELDS = ("wald_cov", "wald_se", "wald_se_opt", "wald_ci",
|
|
37
|
+
"wald_correlation")
|
|
38
|
+
|
|
39
|
+
# Bumped when the set or meaning of the cached fields changes, so old files
|
|
40
|
+
# miss rather than being misread.
|
|
41
|
+
# v1 held only the linear "wald_se".
|
|
42
|
+
# v2 adds "wald_se_opt", the SE in the optimizer's own space, which is what
|
|
43
|
+
# the profile and slice grids are placed with. A v1 file restored into a
|
|
44
|
+
# v2 run would leave that key absent and silently drop every grid back to
|
|
45
|
+
# the range_factor fallback.
|
|
46
|
+
_FORMAT = "wald-v2"
|
|
47
|
+
|
|
48
|
+
# Without this the block is not worth restoring: the profile grid is placed
|
|
49
|
+
# from it, and "no SE at all" triggers a different, deliberate fallback than
|
|
50
|
+
# "an SE in the wrong units".
|
|
51
|
+
_REQUIRED = ("wald_se", "wald_se_opt")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _encode(obj):
|
|
55
|
+
"""Arrays to nested lists, with non-finite values as null.
|
|
56
|
+
|
|
57
|
+
Non-finite entries are meaningful here -- an SE of nan is how "this
|
|
58
|
+
direction is flat, there is no usable standard error" is reported -- but
|
|
59
|
+
they are not portable JSON. They come back as nan, which is what every
|
|
60
|
+
consumer tests for with ``np.isfinite``.
|
|
61
|
+
"""
|
|
62
|
+
if obj is None:
|
|
63
|
+
return None
|
|
64
|
+
arr = np.asarray(obj, dtype=float)
|
|
65
|
+
out = arr.tolist()
|
|
66
|
+
|
|
67
|
+
def _clean(v):
|
|
68
|
+
if isinstance(v, list):
|
|
69
|
+
return [_clean(x) for x in v]
|
|
70
|
+
return v if np.isfinite(v) else None
|
|
71
|
+
|
|
72
|
+
return _clean(out)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _decode(obj):
|
|
76
|
+
"""The inverse: nulls back to nan, lists back to arrays."""
|
|
77
|
+
if obj is None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def _fill(v):
|
|
81
|
+
if isinstance(v, list):
|
|
82
|
+
return [_fill(x) for x in v]
|
|
83
|
+
return float("nan") if v is None else float(v)
|
|
84
|
+
|
|
85
|
+
return np.asarray(_fill(obj), dtype=float)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def bounds_fingerprint(bounds):
|
|
89
|
+
"""Hash of the declared bounds.
|
|
90
|
+
|
|
91
|
+
Separate from the profile's own spec hash on purpose. Bounds change the
|
|
92
|
+
Wald *interval* (it is clipped to them) without changing the model or the
|
|
93
|
+
optimum, so they belong in this cache's key -- but adding them to
|
|
94
|
+
``spec_fingerprint`` would change every existing profile directory name and
|
|
95
|
+
orphan work already done.
|
|
96
|
+
"""
|
|
97
|
+
if bounds is None:
|
|
98
|
+
return "none"
|
|
99
|
+
try:
|
|
100
|
+
blob = json.dumps(
|
|
101
|
+
[None if b is None else [None if v is None else round(float(v), 12)
|
|
102
|
+
for v in b]
|
|
103
|
+
for b in bounds],
|
|
104
|
+
sort_keys=True,
|
|
105
|
+
)
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
return "unhashable"
|
|
108
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AnchorCache:
|
|
112
|
+
"""Reads and writes the Wald block for one fit."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, root, run_id, model_hash, spec_hash, bounds_hash,
|
|
115
|
+
n_params, enabled=True):
|
|
116
|
+
self.enabled = bool(enabled and root)
|
|
117
|
+
self.model_hash = model_hash
|
|
118
|
+
self.spec_hash = spec_hash
|
|
119
|
+
self.bounds_hash = bounds_hash
|
|
120
|
+
self.n_params = int(n_params)
|
|
121
|
+
self.dir = os.path.join(root, "profiles", run_id) if root else None
|
|
122
|
+
if self.enabled and self.dir:
|
|
123
|
+
try:
|
|
124
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
125
|
+
# A kill between writing a temp file and renaming it leaves the
|
|
126
|
+
# temp behind; this directory is where they collect.
|
|
127
|
+
sweep_stale_temp_files(self.dir)
|
|
128
|
+
except OSError:
|
|
129
|
+
self.enabled = False
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def path(self):
|
|
133
|
+
return os.path.join(self.dir, "anchor.json") if self.dir else None
|
|
134
|
+
|
|
135
|
+
def load(self):
|
|
136
|
+
"""The cached Wald block, or None on any miss.
|
|
137
|
+
|
|
138
|
+
Every failure is a miss rather than an error: a corrupt, truncated or
|
|
139
|
+
stale file must cost the 25 minutes of recomputation it was meant to
|
|
140
|
+
save, never the correctness of the run.
|
|
141
|
+
"""
|
|
142
|
+
if not (self.enabled and self.path and os.path.exists(self.path)):
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
with open(self.path, "r", encoding="utf-8") as fh:
|
|
146
|
+
data = json.load(fh)
|
|
147
|
+
except (OSError, ValueError):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
if (data.get("format") != _FORMAT
|
|
151
|
+
or data.get("model_hash") != self.model_hash
|
|
152
|
+
or data.get("spec_hash") != self.spec_hash
|
|
153
|
+
or data.get("bounds_hash") != self.bounds_hash
|
|
154
|
+
or int(data.get("n_params") or -1) != self.n_params):
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
stats = {}
|
|
158
|
+
for field in WALD_FIELDS:
|
|
159
|
+
if field in data:
|
|
160
|
+
stats[field] = _decode(data[field])
|
|
161
|
+
|
|
162
|
+
for field in _REQUIRED:
|
|
163
|
+
arr = stats.get(field)
|
|
164
|
+
if arr is None or np.asarray(arr).shape != (self.n_params,):
|
|
165
|
+
return None
|
|
166
|
+
return stats
|
|
167
|
+
|
|
168
|
+
def save(self, stats):
|
|
169
|
+
"""Write the Wald block, atomically."""
|
|
170
|
+
if not (self.enabled and self.path):
|
|
171
|
+
return
|
|
172
|
+
payload = {
|
|
173
|
+
"format": _FORMAT,
|
|
174
|
+
"model_hash": self.model_hash,
|
|
175
|
+
"spec_hash": self.spec_hash,
|
|
176
|
+
"bounds_hash": self.bounds_hash,
|
|
177
|
+
"n_params": self.n_params,
|
|
178
|
+
"saved": datetime.now().isoformat(timespec="seconds"),
|
|
179
|
+
}
|
|
180
|
+
for field in WALD_FIELDS:
|
|
181
|
+
if stats.get(field) is not None:
|
|
182
|
+
payload[field] = _encode(stats[field])
|
|
183
|
+
|
|
184
|
+
tmp = f"{self.path}.{os.getpid()}.tmp"
|
|
185
|
+
try:
|
|
186
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
187
|
+
json.dump(payload, fh)
|
|
188
|
+
os.replace(tmp, self.path)
|
|
189
|
+
except (OSError, TypeError, ValueError):
|
|
190
|
+
try:
|
|
191
|
+
os.unlink(tmp)
|
|
192
|
+
except OSError:
|
|
193
|
+
pass
|