BASTA 1.5.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.
basta/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.5.0"
basta/__init__.py ADDED
File without changes
basta/bastamain.py ADDED
@@ -0,0 +1,611 @@
1
+ """
2
+ Main module for running BASTA analysis
3
+ """
4
+
5
+ import os
6
+ import gc
7
+ import sys
8
+ import time
9
+ from copy import deepcopy
10
+
11
+ import h5py
12
+ import numpy as np
13
+ from tqdm import tqdm
14
+
15
+ from basta import freq_fit, stats, process_output, priors, distances, plot_driver
16
+ from basta import utils_seismic as su
17
+ from basta import utils_general as util
18
+ from basta.__about__ import __version__
19
+ from basta import fileio as fio
20
+ from basta.constants import freqtypes
21
+
22
+ # Import matplotlib after other plotting modules for proper setup
23
+ # --> Here in main it is only used for clean-up
24
+ import matplotlib.pyplot as plt
25
+
26
+
27
+ # Custom exception
28
+ class LibraryError(Exception):
29
+ pass
30
+
31
+
32
+ def BASTA(
33
+ starid: str,
34
+ gridfile: str,
35
+ inputparams: dict,
36
+ gridid: bool | tuple = False,
37
+ usebayw: bool = True,
38
+ usepriors: tuple = (None,),
39
+ optionaloutputs: bool = False,
40
+ seed: int | None = None,
41
+ debug: bool = False,
42
+ verbose: bool = False,
43
+ developermode: bool = False,
44
+ validationmode: bool = False,
45
+ ):
46
+ """
47
+ The BAyesian STellar Algorithm (BASTA).
48
+ (c) 2024, The BASTA Team
49
+
50
+ For a description of how to use BASTA, please explore the documentation (https://github.com/BASTAcode/BASTA).
51
+ This function is typically called by :func:'xmltools.run_xml()'
52
+
53
+ Parameters
54
+ ----------
55
+ starid : str
56
+ Unique identifier for this target.
57
+ gridfile : str
58
+ Path and name of the hdf5 file containing the isochrones or tracks
59
+ used in the fitting
60
+ inputparams : dict
61
+ Dictionary containing most information needed, e.g. controls, fitparameters,
62
+ output options.
63
+ gridid : bool or tuple
64
+ For isochrones, a tuple containing (overshooting [f],
65
+ diffusion [0 or 1], mass loss [eta], alpha enhancement [0.0 ... 0.4])
66
+ used for selecting a science case / path in the library.
67
+ usebayw : bool or tuple
68
+ If True, bayesian weights are applied in the computation of the
69
+ likelihood. See :func:`interpolation_helpers.bay_weights()` for details.
70
+ usepriors : tuple
71
+ Tuple of strings containing name of priors (e.g., an IMF).
72
+ See :func:`priors` for details.
73
+ optionaloutputs : bool, optional
74
+ If True, saves a 'json' file for each star with the global results and the PDF.
75
+ seed : int, optional
76
+ The seed of randomness
77
+ debug : bool, optional
78
+ Activate additional output for debugging (for developers)
79
+ verbose : bool, optional
80
+ Activate a lot (!) of additional output (for developers)
81
+ developermode : bool, optional
82
+ Activate experimental features (for developers)
83
+ validationmode : bool, optional
84
+ Activate validation mode features (for validation purposes only)
85
+ """
86
+ # Enable legacy printing of NumPy data types
87
+ # --> E.g., print 104.14836386995329 instead of np.float64(104.14836386995329)
88
+ # and 'Teff' instead of np.str_('Teff') to the .log file
89
+ np.set_printoptions(legacy="1.25")
90
+
91
+ # Set output directory and filenames
92
+ t0 = time.localtime()
93
+ outputdir = inputparams.get("output")
94
+ outfilename = os.path.join(outputdir, starid)
95
+
96
+ # Start the log
97
+ stdout = sys.stdout
98
+ sys.stdout = util.Logger(outfilename)
99
+
100
+ # Pretty printing a header
101
+ util.print_bastaheader(t0=t0, seed=seed, developermode=developermode)
102
+
103
+ # Load the desired grid and obtain information from the header
104
+ Grid = h5py.File(gridfile, "r")
105
+ gridtype, gridver, gridtime, grid_is_intpol = util.read_grid_header(Grid)
106
+
107
+ # Verbose information on the grid file
108
+ print(f"\nFitting star id: {starid} .")
109
+
110
+ print(f"* Using the grid '{gridfile}' of type '{gridtype}'.")
111
+ print(f" - Grid built with BASTA version {gridver}, timestamp: {gridtime}.")
112
+
113
+ entryname, defaultpath, difsolarmodel = util.check_gridtype(gridtype, gridid=gridid)
114
+
115
+ # Read available weights if not provided by the user
116
+ bayweights, dweight = (
117
+ util.read_grid_bayweights(Grid, gridtype) if usebayw else (None, None)
118
+ )
119
+
120
+ # Get list of parameters
121
+ cornerplots = inputparams["cornerplots"]
122
+ outparams = inputparams["asciiparams"]
123
+ allparams = list(np.unique(cornerplots + outparams))
124
+
125
+ inputparams, allparams = util.prepare_distancefitting(
126
+ inputparams=inputparams,
127
+ debug=debug,
128
+ debug_dirpath=outfilename,
129
+ allparams=allparams,
130
+ )
131
+
132
+ # Create list of all available input parameters
133
+ fitparams = inputparams.get("fitparams")
134
+ fitfreqs = inputparams["fitfreqs"]
135
+ distparams = inputparams.get("distanceparams", False)
136
+ limits = inputparams.get("limits")
137
+
138
+ # Scale dnu and numax using a solar model or default solar values
139
+ inputparams = su.solar_scaling(Grid, inputparams, diffusion=difsolarmodel)
140
+
141
+ # Prepare asteroseismic quantities if required
142
+ if fitfreqs["active"]:
143
+ if not all(x in freqtypes.alltypes for x in fitfreqs["fittypes"]):
144
+ print(fitfreqs["fittypes"])
145
+ raise ValueError("Unrecognized frequency fitting parameters!")
146
+
147
+ # Obtain/calculate all frequency related quantities
148
+ (
149
+ obskey,
150
+ obs,
151
+ obsfreqdata,
152
+ obsfreqmeta,
153
+ obsintervals,
154
+ ) = su.prepare_obs(inputparams, verbose=verbose, debug=debug)
155
+ # Apply prior on dnufit to mimick the range defined by dnufrac
156
+ if fitfreqs["dnuprior"] and ("dnufit" not in limits):
157
+ dnufit_frac = fitfreqs["dnufrac"] * fitfreqs["dnufit"]
158
+ dnuerr = max(3 * fitfreqs["dnufit_err"], dnufit_frac)
159
+ limits["dnufit"] = [
160
+ fitfreqs["dnufit"] - dnuerr,
161
+ fitfreqs["dnufit"] + dnuerr,
162
+ ]
163
+
164
+ # Check if any specified limit in prior is in header, and can be used to
165
+ # skip computation of models, in order to speed up computation
166
+ tracks_headerpath = "header/"
167
+ if "tracks" in gridtype.lower():
168
+ headerpath: str | bool = tracks_headerpath
169
+ elif "isochrones" in gridtype.lower():
170
+ headerpath = tracks_headerpath + defaultpath
171
+ if "FeHini" in limits:
172
+ del limits["FeHini"]
173
+ print("Warning: Dropping prior in FeHini, redundant for isochrones!")
174
+ else:
175
+ headerpath = False
176
+
177
+ # Gridcut dictionary containing cutting parameters
178
+ gridcut = {}
179
+ if headerpath:
180
+ keys = Grid[headerpath].keys()
181
+ # Compare keys in header and limits
182
+ for key in keys:
183
+ if key in limits:
184
+ gridcut[key] = limits[key]
185
+ # Remove key from limits, to avoid redundant second check
186
+ del limits[key]
187
+
188
+ # Apply the cut on header parameters with a special treatment of diffusion
189
+ if headerpath and gridcut:
190
+ print("\nCutting in grid based on sampling parameters ('gridcut'):")
191
+ noofskips = [0, 0]
192
+ for cpar in gridcut:
193
+ if cpar != "dif":
194
+ print(f"* {cpar}: {gridcut[cpar]}")
195
+
196
+ # Diffusion switch printed in a more readable format
197
+ if "dif" in gridcut:
198
+ # As gridcut['dif'] is always either [-inf, 0.5] or [0.5, inf]
199
+ # The location of 0.5 can be used as the switch
200
+ switch = np.where(np.array(gridcut["dif"]) == 0.5)[0][0]
201
+ print(
202
+ "* Only considering tracks with diffusion turned",
203
+ "{:s}!".format(["on", "off"][switch]),
204
+ )
205
+
206
+ util.print_fitparams(fitparams=fitparams)
207
+ if fitfreqs["active"]:
208
+ util.print_seismic(fitfreqs=fitfreqs, obskey=obskey, obs=obs)
209
+ util.print_distances(distparams, inputparams["asciiparams"])
210
+ util.print_additional(inputparams)
211
+ util.print_weights(bayweights, gridtype)
212
+ util.print_priors(limits, usepriors)
213
+
214
+ # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
215
+ # Start likelihood computation
216
+ # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
217
+
218
+ # Two loop cases for the outer "metal" loop:
219
+ # - For Garstec and MESA grids, the top level contains only one element ("tracks").
220
+ # Here the outer loop will run only once.
221
+ # - For BaSTI, the top level is a list of metallicities and the outer loop will run
222
+ # multiple times.
223
+ metal = util.list_metallicities(Grid, defaultpath, inputparams, limits)
224
+
225
+ # We assume Garstec grid structure. The path will be updated in the loop for BaSTI
226
+ group_name = defaultpath + "tracks/"
227
+
228
+ # Before running the actual loop, all tracks/isochrones are counted to better
229
+ # estimate the progress.
230
+ trackcounter = 0
231
+ for FeH in metal:
232
+ if "grid" not in defaultpath:
233
+ group_name = f"{defaultpath}FeH={FeH:.4f}/"
234
+ assert group_name == defaultpath + "FeH=" + format(FeH, ".4f") + "/"
235
+
236
+ group = Grid[group_name]
237
+ trackcounter += len(group.items())
238
+
239
+ # Prepare the main loop
240
+ shapewarn = 0
241
+ warn = True
242
+ selectedmodels = {}
243
+ noofind = 0
244
+ noofposind = 0
245
+ # In some cases we need to store quantities computed at runtime
246
+ if fitfreqs["active"] and fitfreqs["dnufit_in_ratios"]:
247
+ dnusurfmodels = {}
248
+ if fitfreqs["active"] and fitfreqs["glitchfit"]:
249
+ glitchmodels = {}
250
+
251
+ print(
252
+ f"\n\nComputing likelihood of models in the grid ({trackcounter} {entryname}) ..."
253
+ )
254
+
255
+ # Use a progress bar (with the package tqdm; will write to stderr)
256
+ pbar = tqdm(total=trackcounter, desc="--> Progress", ascii=True)
257
+ for FeH in metal:
258
+ if "grid" not in defaultpath:
259
+ group_name = f"{defaultpath}FeH={FeH:.4f}/"
260
+
261
+ group = Grid[group_name]
262
+ for noingrid, (name, libitem) in enumerate(group.items()):
263
+ # Update progress bar in the start of the loop to count skipped tracks
264
+ pbar.update(1)
265
+
266
+ # For grid with interpolated tracks, skip tracks flagged as empty
267
+ if grid_is_intpol:
268
+ if libitem["IntStatus"][()] < 0:
269
+ continue
270
+
271
+ # Check for diffusion
272
+ if "dif" in inputparams:
273
+ if int(round(libitem["dif"][0])) != int(
274
+ round(float(inputparams["dif"]))
275
+ ):
276
+ continue
277
+
278
+ # Check if mass or age is in limits to efficiently skip
279
+ if "grid" not in defaultpath:
280
+ param, val = name.split("=")
281
+ if param == "mass":
282
+ param += "ini"
283
+ if param in limits:
284
+ # if age or massini is outside limits, skip this iteration
285
+ if float(val) < limits[param][0] or float(val) > limits[param][1]:
286
+ continue
287
+
288
+ # Check if track should be skipped from cut in initial parameters
289
+ if gridcut:
290
+ noofskips[1] += 1
291
+ docut = False
292
+ for param in gridcut:
293
+ if "tracks" in gridtype.lower():
294
+ value = Grid[tracks_headerpath][param][noingrid]
295
+ elif "isochrones" in gridtype.lower():
296
+ # For isochrones, metallicity is already cut from the
297
+ # metal list and lookup of age is simplest and fastest
298
+ if param == "age":
299
+ value = float(name[4:])
300
+ # If value is outside cut limits, skip looking at the rest
301
+ if not (value >= gridcut[param][0] and value <= gridcut[param][1]):
302
+ docut = True
303
+ continue
304
+ # Actually skip this iteration
305
+ if docut:
306
+ noofskips[0] += 1
307
+ continue
308
+
309
+ # Check which models have parameters within limits
310
+ index = np.ones(len(libitem["age"][:]), dtype=bool)
311
+ for param in limits:
312
+ index &= libitem[param][:] >= limits[param][0]
313
+ index &= libitem[param][:] <= limits[param][1]
314
+
315
+ # Check which models have phases as specified
316
+ if "phase" in inputparams:
317
+ # Mapping of verbose input phases to internal numbers
318
+ pmap = {
319
+ "pre-ms": 1,
320
+ "solar": 2,
321
+ "rgb": 3,
322
+ "flash": 4,
323
+ "clump": 5,
324
+ "agb": 6,
325
+ }
326
+
327
+ # Fitting multiple phases or just one
328
+ if isinstance(inputparams["phase"], tuple):
329
+ iphases = [pmap[ip] for ip in inputparams["phase"]]
330
+
331
+ phaseindex = libitem["phase"][:] == iphases[0]
332
+ for j in range(1, len(iphases)):
333
+ phaseindex |= libitem["phase"][:] == iphases[j]
334
+ index &= phaseindex
335
+ else:
336
+ iphase = pmap[inputparams["phase"]]
337
+ index &= libitem["phase"][:] == iphase
338
+
339
+ # Check which models have l=0, lowest n within tolerance
340
+ if fitfreqs["active"]:
341
+ indexf = np.zeros(len(index), dtype=bool)
342
+ for ind in np.where(index)[0]:
343
+ rawmod = libitem["osc"][ind]
344
+ rawmodkey = libitem["osckey"][ind]
345
+ mod = su.transform_obj_array(rawmod)
346
+ modkey = su.transform_obj_array(rawmodkey)
347
+ modkeyl0, modl0 = su.get_givenl(l=0, osc=mod, osckey=modkey)
348
+ # As mod is ordered (stacked in increasing n and l),
349
+ # then [0, 0] is the lowest l=0 mode
350
+ same_n = modkeyl0[1, :] == obskey[1, 0]
351
+ cl0 = modl0[0, same_n]
352
+ if len(cl0) > 1:
353
+ cl0 = cl0[0]
354
+
355
+ # Note to self: This code is pretty hard to read...
356
+ if (
357
+ cl0
358
+ >= (
359
+ obs[0, 0]
360
+ - min(
361
+ (fitfreqs["dnufrac"] / 2 * fitfreqs["dnufit"]),
362
+ (3 * obs[1, 0]),
363
+ )
364
+ )
365
+ ) and (cl0 - obs[0, 0]) <= (
366
+ fitfreqs["dnufrac"] * fitfreqs["dnufit"]
367
+ ):
368
+ indexf[ind] = True
369
+ index &= indexf
370
+
371
+ # If any models are within tolerances, calculate statistics
372
+ if np.any(index):
373
+ chi2 = np.zeros(index.sum())
374
+ paramvalues = {}
375
+ for param in fitparams:
376
+ paramvals = libitem[param][index]
377
+ chi2 += (
378
+ (paramvals - fitparams[param][0]) / fitparams[param][1]
379
+ ) ** 2.0
380
+ if param in allparams:
381
+ paramvalues[param] = paramvals
382
+
383
+ # Add parameters not in fitparams
384
+ for param in allparams:
385
+ if param not in fitparams:
386
+ paramvalues[param] = libitem[param][index]
387
+
388
+ # Frequency (and/or ratio and/or glitch) fitting
389
+ if fitfreqs["active"]:
390
+ if fitfreqs["dnufit_in_ratios"]:
391
+ dnusurf = np.zeros(index.sum())
392
+ if fitfreqs["glitchfit"]:
393
+ glitchpar = np.zeros((index.sum(), 3))
394
+ for indd, ind in enumerate(np.where(index)[0]):
395
+ chi2_freq, warn, shapewarn, addpars = stats.chi2_astero(
396
+ obskey,
397
+ obs,
398
+ obsfreqmeta,
399
+ obsfreqdata,
400
+ obsintervals,
401
+ libitem,
402
+ ind,
403
+ fitfreqs,
404
+ warnings=warn,
405
+ shapewarn=shapewarn,
406
+ debug=debug,
407
+ verbose=verbose,
408
+ )
409
+ chi2[indd] += chi2_freq
410
+
411
+ if fitfreqs["dnufit_in_ratios"]:
412
+ dnusurf[indd] = addpars["dnusurf"]
413
+ if fitfreqs["glitchfit"]:
414
+ glitchpar[indd] = addpars["glitchparams"]
415
+
416
+ # Bayesian weights (across tracks/isochrones)
417
+ logPDF = 0.0
418
+ if debug:
419
+ bayw = 0.0
420
+ magw = 0.0
421
+ IMFw = 0.0
422
+ if bayweights is not None:
423
+ for weight in bayweights:
424
+ logPDF += util.inflog(libitem[weight][()])
425
+ if debug:
426
+ bayw += util.inflog(libitem[weight][()])
427
+
428
+ # Within a given track/isochrone; these are called dweights
429
+ assert dweight is not None
430
+ logPDF += util.inflog(libitem[dweight][index])
431
+ if debug:
432
+ bayw += util.inflog(libitem[dweight][index])
433
+
434
+ # Multiply by absolute magnitudes, if present
435
+ for f in inputparams["magnitudes"]:
436
+ mags = inputparams["magnitudes"][f]["prior"]
437
+ absmags = libitem[f][index]
438
+ interp_mags = mags(absmags)
439
+
440
+ logPDF += util.inflog(interp_mags)
441
+ if debug:
442
+ magw += util.inflog(interp_mags)
443
+
444
+ # Multiply priors into the weight
445
+ for prior in usepriors:
446
+ logPDF += util.inflog(getattr(priors, prior)(libitem, index))
447
+ if debug:
448
+ IMFw += util.inflog(getattr(priors, prior)(libitem, index))
449
+
450
+ # Calculate likelihood from weights, priors and chi2
451
+ # PDF = weights * np.exp(-0.5 * chi2)
452
+ logPDF -= 0.5 * chi2
453
+ if debug and verbose:
454
+ print(
455
+ "DEBUG: Mass with nonzero likelihood:",
456
+ libitem["massini"][index][~np.isinf(logPDF)],
457
+ )
458
+
459
+ # Sum the number indexes and nonzero indexes
460
+ noofind += len(logPDF)
461
+ noofposind += np.count_nonzero(~np.isinf(logPDF))
462
+ if debug and verbose:
463
+ print(
464
+ f"DEBUG: Index found: {group_name + name}, {~np.isinf(logPDF)}"
465
+ )
466
+
467
+ # Store statistical info
468
+ if debug:
469
+ selectedmodels[group_name + name] = stats.priorlogPDF(
470
+ index, logPDF, chi2, bayw, magw, IMFw
471
+ )
472
+ else:
473
+ selectedmodels[group_name + name] = stats.Trackstats(
474
+ index, logPDF, chi2
475
+ )
476
+ if fitfreqs["active"] and fitfreqs["dnufit_in_ratios"]:
477
+ dnusurfmodels[group_name + name] = stats.Trackdnusurf(dnusurf)
478
+ if fitfreqs["active"] and fitfreqs["glitchfit"]:
479
+ glitchmodels[group_name + name] = stats.Trackglitchpar(
480
+ glitchpar[:, 0],
481
+ glitchpar[:, 1],
482
+ glitchpar[:, 2],
483
+ )
484
+ else:
485
+ if debug and verbose:
486
+ print(
487
+ f"DEBUG: Index not found: {group_name + name}, {~np.isinf(logPDF)}"
488
+ )
489
+ # End loop over isochrones/tracks
490
+ #######################################################################
491
+ # End loop over metals
492
+ ###########################################################################
493
+ pbar.close()
494
+ print(
495
+ f"Done! Computed the likelihood of {str(noofind)} models,",
496
+ f"found {str(noofposind)} models with non-zero likelihood!\n",
497
+ )
498
+ if gridcut:
499
+ print(
500
+ f"(Note: The use of 'gridcut' skipped {noofskips[0]} out of {noofskips[1]} {gridtype})\n"
501
+ )
502
+
503
+ # Raise possible warnings
504
+ if shapewarn == 1:
505
+ print(
506
+ "Warning: Found models with fewer frequencies than observed!",
507
+ "These were set to zero likelihood!",
508
+ )
509
+ if "intpol" in gridfile:
510
+ print(
511
+ "This is probably due to the interpolation scheme. Lookup",
512
+ "`interpolate_frequencies` for more details.",
513
+ )
514
+ if shapewarn == 2:
515
+ print(
516
+ "Warning: Models without frequencies overlapping with observed",
517
+ "ignored due to interpolation of ratios being impossible.",
518
+ )
519
+ if shapewarn == 3:
520
+ print(
521
+ "Warning: Models ignored due to phase shift differences being",
522
+ "unapplicable to models with mixed modes.",
523
+ )
524
+ if noofposind == 0:
525
+ fio.no_models(starid, inputparams, "No models found")
526
+ return
527
+
528
+ # Print a header to signal the start of the output section in the log
529
+ print("\n*****************************************")
530
+ print("** **")
531
+ print("** Output and results from the fit **")
532
+ print("** **")
533
+ print("*****************************************\n")
534
+
535
+ # Find and print highest likelihood model info
536
+ maxPDF_path, maxPDF_ind = stats.get_highest_likelihood(
537
+ Grid, selectedmodels, inputparams
538
+ )
539
+ stats.get_lowest_chi2(Grid, selectedmodels, inputparams)
540
+
541
+ # Generate posteriors of ascii- and plotparams
542
+ # --> Print posteriors to console and log
543
+ # --> Generate corner plots
544
+ # --> Generate Kiel diagrams
545
+ print("\n\nComputing posterior distributions for the requested output parameters!")
546
+ print("==> Summary statistics printed below ...\n")
547
+ process_output.compute_posterior(
548
+ starid=starid,
549
+ selectedmodels=selectedmodels,
550
+ Grid=Grid,
551
+ inputparams=inputparams,
552
+ outfilename=outfilename,
553
+ gridtype=gridtype,
554
+ debug=debug,
555
+ developermode=developermode,
556
+ validationmode=validationmode,
557
+ )
558
+
559
+ # Collect additional output for plotting and saving
560
+ addstats = {}
561
+ if fitfreqs["active"] and fitfreqs["dnufit_in_ratios"]:
562
+ addstats["dnusurf"] = dnusurfmodels
563
+ if fitfreqs["active"] and fitfreqs["glitchfit"]:
564
+ addstats["glitchparams"] = glitchmodels
565
+
566
+ # Make frequency-related plots
567
+ freqplots = inputparams.get("freqplots")
568
+ if fitfreqs["active"] and len(freqplots):
569
+ plot_driver.plot_all_seismic(
570
+ freqplots,
571
+ Grid=Grid,
572
+ fitfreqs=fitfreqs,
573
+ obsfreqmeta=obsfreqmeta,
574
+ obsfreqdata=obsfreqdata,
575
+ obskey=obskey,
576
+ obs=obs,
577
+ obsintervals=obsintervals,
578
+ selectedmodels=selectedmodels,
579
+ path=maxPDF_path,
580
+ ind=maxPDF_ind,
581
+ plotfname=outfilename + "_{0}." + inputparams["plotfmt"],
582
+ nameinplot=inputparams["nameinplot"],
583
+ **addstats,
584
+ debug=debug,
585
+ )
586
+ else:
587
+ print(
588
+ "Did not get any frequency file input, skipping ratios and echelle plots."
589
+ )
590
+
591
+ # Save dictionary with full statistics
592
+ if optionaloutputs:
593
+ pfname = outfilename + ".json"
594
+ fio.save_selectedmodels(pfname, selectedmodels)
595
+ print(f"Saved dictionary to {pfname}")
596
+
597
+ # Print time of completion
598
+ t1 = time.localtime()
599
+ print(
600
+ f"\nFinished on {time.strftime('%Y-%m-%d %H:%M:%S', t1)}",
601
+ f"(runtime {time.mktime(t1) - time.mktime(t0)} s).\n",
602
+ )
603
+
604
+ # Save log and recover standard output
605
+ sys.stdout = stdout
606
+ print(f"Saved log to {outfilename}.log")
607
+
608
+ # Close grid, close open plots, and try to free memory between multiple runs
609
+ Grid.close()
610
+ plt.close("all")
611
+ gc.collect()