feat(validation): added more of the scripts to make paper figures

This commit is contained in:
2026-04-20 12:41:10 -04:00
parent 3a22792fd1
commit bbd702904a
38 changed files with 130679 additions and 2069 deletions

View File

@@ -0,0 +1,391 @@
import os.path
import shutil
from gridfire.policy import MainSequencePolicy, NetworkPolicy
from gridfire.engine import DynamicEngine, GraphEngine, EngineTypes, MultiscalePartitioningEngineView
from gridfire.solver import PointSolverContext
from gridfire.type import NetIn
from gridfire.policy import ConstructionResults
from typing import Dict
from fourdst.composition import Composition
from testsuite import TestSuite
from utils import init_netIn, init_composition, years_to_seconds
from enum import Enum
EngineNameToType: Dict[str, EngineTypes] = {
"graphengine": EngineTypes.GRAPH_ENGINE,
"multiscalepartitioningengineview": EngineTypes.MULTISCALE_PARTITIONING_ENGINE_VIEW,
}
class SolarLikeStar_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="SolarLikeStar_QSE",
description="GridFire simulation of a roughly solar like star over 10Gyr with QSE enabled.",
temp=1.5e7,
density=1.5e2,
tMax=years_to_seconds(1e10),
composition=initialComposition,
notes="Thermodynamically Static, MultiscalePartitioning Engine View"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 3)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class MetalEnhancedSolarLikeStar_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition(ZZs=1)
super().__init__(
name="MetalEnhancedSolarLikeStar_QSE",
description="GridFire simulation of a star with solar core temp and density but enhanced by 1 dex in Z.",
temp=0.8 * 1.5e7,
density=1.5e2,
tMax=years_to_seconds(1e10),
composition=initialComposition,
notes="Thermodynamically Static, MultiscalePartitioning Engine View, Z enhanced by 1 dex, temperature reduced to 80% of solar core"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 3)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class MetalEnhancedSolarLikeStar_No_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition(ZZs=1)
super().__init__(
name="MetalEnhancedSolarLikeStar_No_QSE",
description="GridFire simulation of a star with solar core temp and density but enhanced by 1 dex in Z.",
temp=0.8 * 1.5e7,
density=1.5e2,
tMax=years_to_seconds(1e10),
composition=initialComposition,
notes="Thermodynamically Static, Z enhanced by 1 dex, temperature reduced to 80% of solar core"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
engine: GraphEngine = GraphEngine(self.composition, 4)
blob = engine.constructStateBlob()
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class MetalDepletedSolarLikeStar_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition(ZZs=-1)
super().__init__(
name="MetalDepletedSolarLikeStar_QSE",
description="GridFire simulation of a star with solar core temp and density but depleted by 1 dex in Z.",
temp=1.2 * 1.5e7,
density=1.5e2,
tMax=years_to_seconds(1e10),
composition=initialComposition,
notes="Thermodynamically Static, MultiscalePartitioning Engine View, Z depleted by 1 dex, temperature increased to 120% of solar core"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 3)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class MetalDepletedSolarLikeStar_No_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition(ZZs=-1)
super().__init__(
name="MetalDepletedSolarLikeStar_No_QSE",
description="GridFire simulation of a star with solar core temp and density but depleted by 1 dex in Z.",
temp=1.2 * 1.5e7,
density=1.5e2,
tMax=years_to_seconds(1e10),
composition=initialComposition,
notes="Thermodynamically Static, Z depleted by 1 dex, temperature increased to 120% of solar core"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
engine: GraphEngine = GraphEngine(self.composition, 3)
blob = engine.constructStateBlob()
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class SolarLikeStar_No_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="SolarLikeStar_No_QSE",
description="GridFire simulation of a roughly solar like star over 10Gyr with QSE disabled.",
temp=1.5e7,
density=1.5e2,
tMax=years_to_seconds(1e10),
composition=initialComposition,
notes="Thermodynamically Static, No MultiscalePartitioning Engine View"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
engine : GraphEngine = GraphEngine(self.composition, 3)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
context : PointSolverContext = PointSolverContext(engine.constructStateBlob())
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class SolarLikeStar_No_QSE_Depth_Suite(TestSuite):
def __init__(self, depth: int = 1):
initialComposition : Composition = init_composition()
self.depth : int = depth
super().__init__(
name=f"SolarLikeStar_No_QSE_Depth_{depth}_Suite",
description="GridFire simulation of a roughly solar like star over 10Gyr with QSE disabled.",
temp=1.5e7,
density=1.5e2,
tMax=years_to_seconds(1e10),
composition=initialComposition,
notes=f"Thermodynamically Static, No MultiscalePartitioning Engine View, configurable depth {depth}"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
engine : GraphEngine = GraphEngine(self.composition, self.depth)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
context : PointSolverContext = PointSolverContext(engine.constructStateBlob())
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class HotStar_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="HotStar_QSE",
description="GridFire simulation of a hot star over 1Gyr with QSE enabled.",
temp=2.5e7,
density=15,
tMax=1e15,
composition=initialComposition,
notes="Thermodynamically Static, MultiscalePartitioning Engine View, B(ish) star conditions"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 3)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class CoolStar_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="CoolStar_QSE",
description="GridFire simulation of a hot star over 1Gyr with QSE enabled.",
temp=6e6,
density=750,
tMax=1e15,
composition=initialComposition,
notes="Thermodynamically Static, MultiscalePartitioning Engine View, M(ish) star conditions"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 3)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class HotStar_No_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="HotStar_No_QSE",
description="GridFire simulation of a hot star over 1Gyr with QSE disabled.",
temp=2.5e7,
density=15,
tMax=1e15,
composition=initialComposition,
notes="Thermodynamically Static, B(ish) star conditions"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 3)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class CoolStar_No_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="CoolStar_No_QSE",
description="GridFire simulation of a hot star over 1Gyr with QSE disabled.",
temp=6e6,
density=750,
tMax=1e15,
composition=initialComposition,
notes="Thermodynamically Static, M(ish) star conditions"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 3)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class VeryCoolStar_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="VeryCoolStar_QSE",
description="GridFire simulation of a hot star over 1Gyr with QSE enabled.",
temp=4e6,
density=1000,
tMax=1e15,
composition=initialComposition,
notes="Thermodynamically Static, MultiscalePartitioning Engine View, M(ish) star conditions"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 3)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class VeryCoolStar_No_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="VeryCoolStar_No_QSE",
description="GridFire simulation of a hot star over 1Gyr with QSE disabled.",
temp=4e6,
density=1000,
tMax=1e19,
composition=initialComposition,
notes="Thermodynamically Static, M(ish) star conditions"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
engine: GraphEngine = GraphEngine(self.composition, 3)
blob = engine.constructStateBlob()
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class VeryHotStar_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="VeryHotStar_QSE",
description="GridFire simulation of a hot star over 1Gyr with QSE enabled.",
temp=4e7,
density=1,
tMax=1e15,
composition=initialComposition,
notes="Thermodynamically Static, MultiscalePartitioning Engine View, M(ish) star conditions"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
base_engine: GraphEngine = GraphEngine(self.composition, 4)
engine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(base_engine)
blob = base_engine.constructStateBlob()
blob = engine.constructStateBlob(blob)
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class VeryHotStar_No_QSE_Suite(TestSuite):
def __init__(self):
initialComposition : Composition = init_composition()
super().__init__(
name="VeryHotStar_No_QSE",
description="GridFire simulation of a hot star over 1Gyr with QSE disabled.",
temp=4e7,
density=1,
tMax=1e15,
composition=initialComposition,
notes="Thermodynamically Static, B(ish) star conditions"
)
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
engine: GraphEngine = GraphEngine(self.composition, 4)
blob = engine.constructStateBlob()
context : PointSolverContext = PointSolverContext(blob)
netIn : NetIn = init_netIn(self.temperature, self.density, self.tMax, self.composition)
self.evolve(engine, context, netIn, pynucastro_compare = pynucastro_compare, engine_type=EngineNameToType[pync_engine.lower()], output=output)
class ValidationSuites(Enum):
SolarLikeStar_QSE = SolarLikeStar_QSE_Suite
SolarLikeStar_No_QSE = SolarLikeStar_No_QSE_Suite
SolarLikeStar_No_QSE_Depth = SolarLikeStar_No_QSE_Depth_Suite
MetalDepletedSolarLikeStar_QSE = MetalDepletedSolarLikeStar_QSE_Suite
MetalEnhancedSolarLikeStar_QSE = MetalEnhancedSolarLikeStar_QSE_Suite
MetalDepletedSolarLikeStar_No_QSE = MetalDepletedSolarLikeStar_No_QSE_Suite
MetalEnhancedSolarLikeStar_No_QSE = MetalEnhancedSolarLikeStar_No_QSE_Suite
HotStar_QSE = HotStar_QSE_Suite
CoolStar_QSE = CoolStar_QSE_Suite
HotStar_No_QSE = HotStar_No_QSE_Suite
CoolStar_No_QSE = CoolStar_No_QSE_Suite
VeryCoolStar_QSE = VeryCoolStar_QSE_Suite
VeryHotStar_QSE = VeryHotStar_QSE_Suite
VeryCoolStar_No_QSE = VeryCoolStar_No_QSE_Suite
VeryHotStar_No_QSE = VeryHotStar_No_QSE_Suite
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Run some subset of the GridFire validation suite.")
parser.add_argument('--suite', type=str, choices=[suite.name for suite in ValidationSuites], nargs="+", help="The validation suite to run.")
parser.add_argument("--all", action="store_true", help="Run all validation suites.")
parser.add_argument("--pynucastro-compare", action="store_true", help="Generate pynucastro comparison data.")
parser.add_argument("--pync-engine", type=str, choices=["GraphEngine", "MultiscalePartitioningEngineView", "AdaptiveEngineView"], default="AdaptiveEngineView", help="The GridFire engine to use to select the reactions for pynucastro comparison.")
parser.add_argument("-o", "--output", type=str, help="Directory to save OKAY results too", default="GF_Validation_Output")
parser.add_argument("--depths", type=int, nargs="+", default=[1, 2, 3, 4, 5, 6, 7], help="Construction depths to test. Must be positive non zero")
args = parser.parse_args()
os.makedirs(args.output, exist_ok=True)
if args.all:
for suite in ValidationSuites:
if suite.name == "SolarLikeStar_No_QSE_Depth":
for depth in args.depths:
instance : TestSuite = suite.value(depth=depth)
instance(args.pynucastro_compare, args.pync_engine, args.output)
else:
instance : TestSuite = suite.value()
instance(args.pynucastro_compare, args.pync_engine, args.output)
else:
for suite_name in args.suite:
suite = ValidationSuites[suite_name]
if suite.name == "SolarLikeStar_No_QSE_Depth":
for depth in args.depths:
instance : TestSuite = suite.value(depth=depth)
instance(args.pynucastro_compare, args.pync_engine, args.output)
else:
instance : TestSuite = suite.value()
instance(args.pynucastro_compare, args.pync_engine, args.output)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,80 @@
from enum import Enum
from typing import Dict, List, Any, SupportsFloat
import json
from datetime import datetime
import os
import sys
from gridfire.solver import PointSolverTimestepContext
from gridfire._gridfire.engine.scratchpads import StateBlob
import gridfire
class LogEntries(Enum):
Step = "Step"
t = "t"
dt = "dt"
eps = "eps"
Composition = "Composition"
ReactionContributions = "ReactionContributions"
class StepLogger:
def __init__(self):
self.num_steps : int = 0
self.steps : List[Dict[LogEntries, Any]] = []
def log_step(self, ctx: PointSolverTimestepContext):
comp_data: Dict[str, SupportsFloat] = {}
for species in ctx.engine.getNetworkSpecies(ctx.state_ctx):
sid = ctx.engine.getSpeciesIndex(ctx.state_ctx, species)
comp_data[species.name()] = ctx.state[sid]
entry : Dict[LogEntries, Any] = {
LogEntries.Step: ctx.num_steps,
LogEntries.t: ctx.t,
LogEntries.dt: ctx.dt,
LogEntries.eps: ctx.state[-1],
LogEntries.Composition: comp_data,
}
self.steps.append(entry)
self.num_steps += 1
def to_json(self, filename: str, **kwargs):
serializable_steps : List[Dict[str, Any]] = [
{
LogEntries.Step.value: step[LogEntries.Step],
LogEntries.t.value: step[LogEntries.t],
LogEntries.dt.value: step[LogEntries.dt],
LogEntries.eps.value: step[LogEntries.eps],
LogEntries.Composition.value: step[LogEntries.Composition],
}
for step in self.steps
]
out_data : Dict[str, Any] = {
"Metadata": {
"NumSteps": self.num_steps,
**kwargs,
"DateCreated": datetime.now().isoformat(),
"GridFireVersion": gridfire.__version__,
"Author": "Emily M. Boudreaux",
"OS": os.uname().sysname,
"ClangVersion": os.popen("clang --version").read().strip(),
"GccVersion": os.popen("gcc --version").read().strip(),
"PythonVersion": sys.version,
},
"Steps": serializable_steps
}
with open(filename, 'w') as f:
json.dump(out_data, f, indent=4)
def summary(self) -> Dict[str, Any]:
if not self.steps:
return {}
final_step = self.steps[-1]
summary_data : Dict[str, Any] = {
"TotalSteps": self.num_steps,
"FinalTime": final_step[LogEntries.t],
"FinalComposition": final_step[LogEntries.Composition],
}
return summary_data

View File

@@ -0,0 +1,351 @@
import argparse
import json
import os
import sys
import math
import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import interp1d
from scipy.integrate import cumulative_trapezoid
from astropy import constants as const
from astropy import units as u
from enum import Enum
from typing import List, Dict, Any, Tuple
from fourdst.atomic import species as spdict
EXTERNAL_STYLE_PATH = "../ManuscriptFigures/utils/pub.mplstyle"
class PlotVariable(Enum):
COMPOSITION = "composition"
EPS = "eps"
DT = "dt"
class OutputFormat(Enum):
INTERACTIVE = "interactive"
PDF = "pdf"
PNG = "png"
JPEG = "jpeg"
def discover_runs(base_dir: str) -> List[str]:
runs = set()
gf_ok_dir = os.path.join(base_dir, "GridFire", "Ok")
if os.path.exists(gf_ok_dir):
for fname in os.listdir(gf_ok_dir):
if fname.endswith("_OKAY.json"):
runs.add(fname.replace("_OKAY.json", ""))
return sorted(list(runs))
def load_run_data(base_dir: str, run_name: str) -> Tuple[Dict[str, Any], Dict[str, Any]]:
gf_data = {}
pynuc_data = {}
gf_ok_path = os.path.join(base_dir, "GridFire", "Ok", f"{run_name}_OKAY.json")
gf_err_path = os.path.join(base_dir, "GridFire", "Err", f"{run_name}_FAIL.json")
pynuc_path = os.path.join(base_dir, "pynucastro", f"{run_name}_pynucastro.json")
if os.path.exists(gf_ok_path):
with open(gf_ok_path, 'r') as f:
gf_data = json.load(f)
elif os.path.exists(gf_err_path):
with open(gf_err_path, 'r') as f:
gf_data = json.load(f)
if os.path.exists(pynuc_path):
with open(pynuc_path, 'r') as f:
pynuc_data = json.load(f)
return gf_data, pynuc_data
def get_pynuc_eps(steps: List[Dict[str, Any]]) -> Tuple[np.ndarray, np.ndarray]:
c_sq = (const.c.cgs.value)**2
Na = const.N_A.cgs.value
amu_to_g = const.u.cgs.value
eps_history = []
time_history = []
last_Y = {}
last_t = None
for step in steps:
t = step["t"]
current_Y = step["Composition"]
if last_t is None:
last_t = t
last_Y = current_Y.copy()
eps_history.append(0.0)
time_history.append(t)
continue
dt = t - last_t
if dt > 0:
dm_dt = 0.0
all_species = set(current_Y.keys()) | set(last_Y.keys())
for sp in all_species:
y_curr = current_Y.get(sp, 0.0)
y_prev = last_Y.get(sp, 0.0)
dy = y_curr - y_prev
if sp in spdict:
mass_g = spdict[sp].mass() * amu_to_g
dm_dt += mass_g * (dy / dt)
rate = -dm_dt * Na * c_sq
eps_history.append(rate)
time_history.append(t)
last_t = t
last_Y = current_Y.copy()
return np.array(time_history), np.array(eps_history)
def _setup_axes(ax_main: plt.Axes, ax_diff: plt.Axes, var: PlotVariable, fig_opts: dict):
ax_diff.set_xlabel("Time (s)")
ax_main.set_xscale(fig_opts['x_scale'])
ax_diff.set_xscale(fig_opts['x_scale'])
ax_main.set_yscale(fig_opts['y_scale'])
if var == PlotVariable.EPS:
ax_main.set_ylabel("Cumulative Energy (eps)")
elif var == PlotVariable.DT:
ax_main.set_ylabel("Timestep Size (dt)")
elif var == PlotVariable.COMPOSITION:
ax_main.set_ylabel("Mass Fraction (X_i)")
ax_diff.set_ylabel(r"$\Delta \log_{10}$")
def _plot_single_run(ax_main: plt.Axes, ax_diff: plt.Axes, run_name: str, var: PlotVariable,
base_dir: str, compare_pynuc: bool):
gf_data, pynuc_data = load_run_data(base_dir, run_name)
if not gf_data or gf_data.get("Metadata", {}).get("Status") == "Error":
return
gf_steps = gf_data.get("Steps", [])
if not gf_steps:
return
if compare_pynuc and not pynuc_data:
print(f"Warning: PyNucastro comparison requested but data not found for '{run_name}'.")
t_gf = np.array([s["t"] for s in gf_steps])
if var == PlotVariable.COMPOSITION:
final_comp = gf_steps[-1]["Composition"]
top_species = sorted(final_comp, key=final_comp.get, reverse=True)[:3]
for spec in top_species:
y_gf = np.array([s["Composition"].get(spec, 1e-30) for s in gf_steps])
line, = ax_main.plot(t_gf, y_gf, label=f"{run_name} {spec} (GF)")
if compare_pynuc and pynuc_data:
pynuc_steps = pynuc_data.get("Steps", [])
if not pynuc_steps:
continue
t_pynuc = np.array([s["t"] for s in pynuc_steps])
y_pynuc = np.array([s["Composition"].get(spec, 1e-30) for s in pynuc_steps])
ax_main.plot(t_pynuc, y_pynuc, '--', color=line.get_color(), label=f"{run_name} {spec} (PyNuc)")
if len(t_pynuc) > 1:
f_interp = interp1d(t_pynuc, y_pynuc, kind='linear', bounds_error=False, fill_value=(y_pynuc[0], y_pynuc[-1]))
y_pynuc_interp = f_interp(t_gf)
log_diff = np.abs(np.log10(np.maximum(y_gf, 1e-30)) - np.log10(np.maximum(y_pynuc_interp, 1e-30)))
ax_diff.plot(t_gf, log_diff, color=line.get_color(), linestyle=':', label=f"Δ {spec}")
elif var == PlotVariable.EPS:
y_gf = np.array([s["eps"] for s in gf_steps])
line, = ax_main.plot(t_gf, y_gf, label=f"{run_name} (GF)")
if compare_pynuc and pynuc_data:
pynuc_steps = pynuc_data.get("Steps", [])
if pynuc_steps:
s_t, s_e = get_pynuc_eps(pynuc_steps)
if len(s_t) > 1:
s_cumE = cumulative_trapezoid(s_e, s_t, initial=0)
ax_main.plot(s_t, s_cumE, '--', color=line.get_color(), label=f"{run_name} (PyNuc)")
f_pynuc_interp = interp1d(s_t[np.isfinite(s_cumE)], s_cumE[np.isfinite(s_cumE)])
f_gf_interp = interp1d(t_gf, y_gf)
t_safe = np.logspace(
8,
np.log10(min(s_t.max(), t_gf.max())),
1000
)
y_pynuc_interp = f_pynuc_interp(t_safe)
y_gf_interp = f_gf_interp(t_safe)
pynuc_safe = np.maximum(np.abs(y_pynuc_interp), 1e-30)
gf_safe = np.maximum(np.abs(y_gf_interp), 1e-30)
log_diff = np.log10(gf_safe) - np.log10(pynuc_safe)
ax_diff.plot(t_safe, log_diff, color=line.get_color(), linestyle=':', label=f"Δ eps")
ax_main.set_xlim(1e8)
elif var == PlotVariable.DT:
y_gf = np.array([s["dt"] for s in gf_steps])
ax_main.plot(t_gf, y_gf, label=f"{run_name} (GF)")
def _finalize_plot(fig: plt.Figure, ax_main: plt.Axes, ax_diff: plt.Axes, format_opt: OutputFormat, filename_base: str, is_subfigure: bool = False):
ax_main.legend(loc='best', fontsize='small')
if len(ax_diff.lines) > 0:
ax_diff.legend(loc='best', fontsize='x-small')
if is_subfigure:
return
fig.tight_layout()
if format_opt != OutputFormat.INTERACTIVE:
out_name = f"{filename_base}.{format_opt.value}"
fig.savefig(out_name, format=format_opt.value, bbox_inches='tight')
print(f"Saved figure: {out_name}")
plt.close(fig)
def plot_data(runs: List[str], plot_vars: List[PlotVariable], base_dir: str,
compare_pynuc: bool, format_opt: OutputFormat, fig_opts: dict):
if not runs:
print("No valid runs to plot.")
return
if fig_opts['use_ext_style']:
try:
plt.style.use(EXTERNAL_STYLE_PATH)
print(f"Using external style sheet: {EXTERNAL_STYLE_PATH}")
except Exception as e:
print(f"Warning: Failed to load external style sheet. Error: {e}")
elif fig_opts['style']:
try:
plt.style.use(fig_opts['style'])
except OSError:
print(f"Warning: Style '{fig_opts['style']}' not found. Using default.")
plt.rcParams["figure.figsize"] = fig_opts['figsize']
plt.rcParams["figure.dpi"] = fig_opts['dpi']
for var in plot_vars:
if fig_opts['merge_runs']:
fig, (ax_main, ax_diff) = plt.subplots(2, 1, sharex=True, gridspec_kw={'height_ratios': [3, 1]})
ax_main.set_title(f"Comparison of {var.value.upper()} (Merged Runs)")
_setup_axes(ax_main, ax_diff, var, fig_opts)
for run_name in runs:
_plot_single_run(ax_main, ax_diff, run_name, var, base_dir, compare_pynuc)
_finalize_plot(fig, ax_main, ax_diff, format_opt, f"ValidationPlot_Merged_{var.value}")
else:
num_runs = len(runs)
cols = math.ceil(math.sqrt(num_runs))
rows = math.ceil(num_runs / cols)
base_w, base_h = fig_opts['figsize']
fig = plt.figure(figsize=(base_w * cols, base_h * rows), layout='constrained')
fig.suptitle(f"{var.value.upper()} Comparison", fontsize=16, fontweight='bold')
subfigs_raw = fig.subfigures(rows, cols)
if hasattr(subfigs_raw, 'flatten'):
subfigs = subfigs_raw.flatten()
else:
subfigs = [subfigs_raw]
for i, run_name in enumerate(runs):
subfig = subfigs[i]
subfig.suptitle(f"{run_name}", fontsize=12)
axes = subfig.subplots(2, 1, sharex=True, gridspec_kw={'height_ratios': [3, 1]})
ax_main, ax_diff = axes[0], axes[1]
_setup_axes(ax_main, ax_diff, var, fig_opts)
_plot_single_run(ax_main, ax_diff, run_name, var, base_dir, compare_pynuc)
_finalize_plot(fig, ax_main, ax_diff, format_opt, "", is_subfigure=True)
for j in range(num_runs, len(subfigs)):
subfigs[j].set_visible(False)
if format_opt != OutputFormat.INTERACTIVE:
out_name = f"ValidationPlot_Grid_{var.value}.{format_opt.value}"
fig.savefig(out_name, format=format_opt.value, bbox_inches='tight')
print(f"Saved grid figure: {out_name}")
plt.close(fig)
if format_opt == OutputFormat.INTERACTIVE:
plt.show()
def main():
parser = argparse.ArgumentParser(description="GridFire Validation Suite Output Parser and Plotter")
parser.add_argument("-d", "--data-dir", type=str, default="GF_Validation_Output",
help="Path to the directory containing the JSON output folders.")
parser.add_argument("--runs", nargs="+", type=str, required=False,
help="Which validation runs to analyze. Use 'all' to process all available runs.")
parser.add_argument("--plot", nargs="*", type=lambda x: PlotVariable[x.upper()], choices=list(PlotVariable), default=[],
help="Variables to plot. Leave empty to skip plotting.")
parser.add_argument("--compare-pynucastro", action="store_true",
help="Include pynucastro data and calculate log residuals.")
parser.add_argument("--merge-runs", action="store_true",
help="Merge all specified runs onto a single figure per variable. (Default: Grid layout of subfigures)")
parser.add_argument("--x-scale", type=str, choices=["log", "linear"], default="log",
help="Scale for the x-axis (time). Default is 'log'.")
parser.add_argument("--y-scale", type=str, choices=["log", "linear"], default="log",
help="Scale for the y-axis (main plots). Default is 'log'.")
parser.add_argument("--format", type=lambda x: OutputFormat[x.upper()], choices=list(OutputFormat), default=OutputFormat.INTERACTIVE,
help="Output format for the plots. Default is interactive window.")
parser.add_argument("--use-external-style", action="store_true",
help="Load the custom style sheet defined in EXTERNAL_STYLE_PATH.")
parser.add_argument("--style", type=str, default=None,
help="Built-in Matplotlib stylesheet name (e.g., 'seaborn-v0_8-whitegrid'). Ignored if --use-external-style is set.")
parser.add_argument("--figsize", nargs=2, type=float, default=[8.0, 6.0],
metavar=("WIDTH", "HEIGHT"), help="Base figure size in inches per subfigure (e.g., --figsize 10 8).")
parser.add_argument("--dpi", type=int, default=150, help="DPI resolution for saved figures.")
parser.add_argument("--list", action="store_true", default=False, help="list available runs")
args = parser.parse_args()
available_runs = discover_runs(args.data_dir)
if not available_runs:
print(f"Error: No successful run data found in {args.data_dir} (Checked GridFire/Ok/).")
sys.exit(1)
if args.list:
for run in available_runs:
print(f"==> {run}")
exit()
if "all" in [r.lower() for r in args.runs]:
target_runs = available_runs
else:
target_runs = [r for r in args.runs if r in available_runs]
missing = [r for r in args.runs if r.lower() != "all" and r not in target_runs]
if missing:
print(f"Warning: The following runs were skipped because they failed or weren't found: {', '.join(missing)}")
if args.plot and target_runs:
fig_opts = {
"figsize": tuple(args.figsize),
"dpi": args.dpi,
"style": args.style,
"use_ext_style": args.use_external_style,
"merge_runs": args.merge_runs,
"x_scale": args.x_scale,
"y_scale": args.y_scale
}
plot_data(target_runs, args.plot, args.data_dir, args.compare_pynucastro, args.format, fig_opts)
elif args.plot:
print("Error: No valid runs matched your selection.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,22 @@
# Pynucastro test suite
Test suite to auto generate pynucastro comparisons to GridFire by copying the GridFire topology to pynucastro.
Note that this may take a while to run as each pynucastro network must run through JIT compilation. The JIT time is not counted to
pynucastro evaluation time
To run use
```bash
python GridFireValidationSuite.py --suite HotStar_No_QSE CoolStar_No_QSE --pynucastro-compare --pync-engine="GraphEngine"
```
to see all options
```bash
python GridFireValidationSuite.py --help
```
Results will be saved in a directory as json files which you may parse to analyze

View File

@@ -0,0 +1,299 @@
import shutil
from abc import ABC, abstractmethod
import fourdst.atomic
import scipy.integrate
import gridfire
from fourdst.composition import Composition
from gridfire.engine import DynamicEngine, GraphEngine, AdaptiveEngineView, MultiscalePartitioningEngineView
from gridfire.engine import EngineTypes
from gridfire.policy import MainSequencePolicy
from gridfire.type import NetIn, NetOut
from gridfire.exceptions import GridFireError
from gridfire.solver import PointSolver, PointSolverContext
from logger import StepLogger
from typing import List
import re
from typing import Dict, Tuple, Any, Union
from datetime import datetime
import pynucastro as pyna
import os
import importlib.util
import sys
import numpy as np
import json
import time
EngineTypeLookup : Dict[EngineTypes, Any] = {
EngineTypes.ADAPTIVE_ENGINE_VIEW: AdaptiveEngineView,
EngineTypes.MULTISCALE_PARTITIONING_ENGINE_VIEW: MultiscalePartitioningEngineView,
EngineTypes.GRAPH_ENGINE: GraphEngine
}
def load_network_module(filepath):
module_name = os.path.basename(filepath).replace(".py", "")
if module_name in sys.modules: # clear any existing module with the same name
del sys.modules[module_name]
spec = importlib.util.spec_from_file_location(module_name, filepath)
if spec is None:
raise FileNotFoundError(f"Could not find module at {filepath}")
network_module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = network_module
spec.loader.exec_module(network_module)
return network_module
def get_pyna_rate(my_rate_str, library):
match = re.match(r"([a-zA-Z0-9]+)\(([^,]+),([^)]*)\)(.*)", my_rate_str)
if not match:
print(f"Could not parse string format: {my_rate_str}")
return None
target = match.group(1)
projectile = match.group(2)
ejectiles = match.group(3)
product = match.group(4)
def expand_species(s_str):
if not s_str or s_str.strip() == "":
return []
# Split by space (handling "p a" or "2p a")
parts = s_str.split()
expanded = []
for p in parts:
# Check for multipliers like 2p, 3a
mult_match = re.match(r"(\d+)([a-zA-Z0-9]+)", p)
if mult_match:
count = int(mult_match.group(1))
spec = mult_match.group(2)
# Map common aliases if necessary (though pyna handles most)
if spec == 'a': spec = 'he4'
expanded.extend([spec] * count)
else:
spec = p
if spec == 'a': spec = 'he4'
expanded.append(spec)
return expanded
reactants_str = [target] + expand_species(projectile)
products_str = expand_species(ejectiles) + [product]
# Convert strings to pyna.Nucleus objects
try:
r_nuc = [pyna.Nucleus(r) for r in reactants_str]
p_nuc = [pyna.Nucleus(p) for p in products_str]
except Exception as e:
print(f"Error converting nuclei for {my_rate_str}: {e}")
return None
rates = library.get_rate_by_nuclei(r_nuc, p_nuc)
if rates:
if isinstance(rates, list):
return rates[0] # Return the first match
return rates
else:
return None
class TestSuite(ABC):
def __init__(self, name: str, description: str, temp: float, density: float, tMax: float, composition: Composition, notes: str = ""):
self.name : str = name
self.description : str = description
self.temperature : float = temp
self.density : float = density
self.tMax : float = tMax
self.composition : Composition = composition
self.notes : str = notes
def evolve_pynucastro(self, engine: DynamicEngine, ctx: PointSolverContext, output: str = "pynucastro"):
print("Evolution complete. Now building equivalent pynucastro network...")
# Build equivalent pynucastro network for comparison
reaclib_library : pyna.ReacLibLibrary = pyna.ReacLibLibrary()
rate_names = [r.id().replace("e+","").replace("e-","").replace(", ", ",") for r in engine.getNetworkReactions(ctx.engine_ctx)]
with open(f"{self.name}_rate_names_pynuc.txt", "w") as f:
for r_name in rate_names:
f.write(f"{r_name}\n")
goodRates : List[pyna.rates.reaclib_rate.ReacLibRate] = []
missingRates = []
for r_str in rate_names:
# Try the exact name match first (fastest)
try:
pyna_rate = reaclib_library.get_rate_by_name(r_str)
if isinstance(pyna_rate, list):
goodRates.append(pyna_rate[0])
else:
goodRates.append(pyna_rate)
except:
# Fallback to the smart parser
pyna_rate = get_pyna_rate(r_str, reaclib_library)
if pyna_rate:
goodRates.append(pyna_rate)
else:
missingRates.append(r_str)
pynet : pyna.PythonNetwork = pyna.PythonNetwork(rates=goodRates)
pynet.write_network(f"{self.name}_pynucastro_network.py")
net = load_network_module(f"{self.name}_pynucastro_network.py")
Y0 = np.zeros(net.nnuc)
Y0[net.jp] = self.composition.getMolarAbundance("H-1")
Y0[net.jhe3] = self.composition.getMolarAbundance("He-3")
Y0[net.jhe4] = self.composition.getMolarAbundance("He-4")
Y0[net.jc12] = self.composition.getMolarAbundance("C-12")
Y0[net.jn14] = self.composition.getMolarAbundance("N-14")
Y0[net.jo16] = self.composition.getMolarAbundance("O-16")
Y0[net.jne20] = self.composition.getMolarAbundance("Ne-20")
Y0[net.jmg24] = self.composition.getMolarAbundance("Mg-24")
print("Starting pynucastro integration...")
startTime = time.time()
sol = scipy.integrate.solve_ivp(
net.rhs,
[0, self.tMax],
Y0,
args=(self.density, self.temperature),
method="BDF",
jac=net.jacobian,
rtol=1e-5,
atol=1e-8
)
endTime = time.time()
initial_duration = endTime - startTime
print("Pynucastro integration complete. Writing results to JSON...")
print("Running pynucastro a second time to account for any JIT compilation overhead...")
startTime = time.time()
sol = scipy.integrate.solve_ivp(
net.rhs,
[0, self.tMax],
Y0,
args=(self.density, self.temperature),
method="BDF",
jac=net.jacobian,
rtol=1e-5,
atol=1e-8
)
endTime = time.time()
final_duration = endTime - startTime
print(f"Pynucastro second integration complete. Initial run time: {initial_duration: .4f} s, Second run time: {final_duration: .4f} s")
data: List[Dict[str, Union[float, Dict[str, float]]]] = []
for time_step, t in enumerate(sol.t):
data.append({"t": t, "Composition": {}})
for j in range(net.nnuc):
A = net.A[j]
Z = net.Z[j]
species: str
try:
species = fourdst.atomic.az_to_species(A, Z).name()
except:
species = f"SP-A_{A}_Z_{Z}"
data[-1]["Composition"][species] = sol.y[j, time_step]
pynucastro_json : Dict[str, Any] = {
"Metadata": {
"Name": f"{self.name}_pynucastro",
"Description": f"pynucastro simulation equivalent to GridFire validation suite: {self.description}",
"Status": "Success",
"Notes": self.notes,
"Temperature": self.temperature,
"Density": self.density,
"tMax": self.tMax,
"RunTime0": initial_duration,
"RunTime1": final_duration,
"DateCreated": datetime.now().isoformat(),
"NumSpecies": net.nnuc
},
"Steps": data
}
filename: str = f"{self.name}_pynucastro.json"
filepath: str = os.path.join(output, filename)
with open(filepath, "w") as f:
json.dump(pynucastro_json, f, indent=4)
def evolve(self, engine: DynamicEngine, solver_ctx: PointSolverContext, netIn: NetIn, pynucastro_compare: bool = True, engine_type: EngineTypes | None = None, output: str = "output"):
solver : PointSolver = PointSolver(engine)
stepLogger : StepLogger = StepLogger()
solver_ctx.callback = lambda ctx: stepLogger.log_step(ctx)
startTime = time.time()
subdir: str = os.path.join(output, "GridFire")
os.makedirs(subdir, exist_ok=True)
try:
startTime = time.time()
netOut : NetOut = solver.evaluate(solver_ctx, netIn)
endTime = time.time()
filename: str = f"{self.name}_OKAY.json"
subdir2: str = os.path.join(subdir, "Ok")
os.makedirs(subdir2, exist_ok=True)
filepath: str = os.path.join(subdir2, filename)
stepLogger.to_json(
filepath,
Name = f"{self.name}_Success",
Description=self.description,
Status="Success",
Notes=self.notes,
Temperature=netIn.temperature,
Density=netIn.density,
tMax=netIn.tMax,
FinalEps = netOut.energy,
FinaldEpsdT = netOut.dEps_dT,
FinaldEpsdRho = netOut.dEps_dRho,
ElapsedTime = endTime - startTime,
NumSpecies = engine.getNetworkSpecies(solver_ctx.engine_ctx).__len__(),
NumReactions = engine.getNetworkReactions(solver_ctx.engine_ctx).__len__()
)
except GridFireError as e:
endTime = time.time()
filename : str = f"{self.name}_FAIL.json"
subdir2 : str = os.path.join(subdir, "Err")
os.makedirs(subdir2, exist_ok=True)
filepath = os.path.join(subdir2, filename)
stepLogger.to_json(
filepath,
Name = f"{self.name}_Failure",
Description=self.description,
Status=f"Error",
ErrorMessage=str(e),
Notes=self.notes,
Temperature=netIn.temperature,
Density=netIn.density,
tMax=netIn.tMax,
ElapsedTime = endTime - startTime
)
if pynucastro_compare:
pynuc_dir = os.path.join(output, "pynucastro")
os.makedirs(pynuc_dir, exist_ok=True)
if engine_type is not None:
if engine_type == EngineTypes.MULTISCALE_PARTITIONING_ENGINE_VIEW:
print("Pynucastro comparison using MultiscalePartitioningEngineView...")
graphEngine : GraphEngine = GraphEngine(self.composition, depth=3)
multiScaleEngine : MultiscalePartitioningEngineView = MultiscalePartitioningEngineView(graphEngine)
self.evolve_pynucastro(multiScaleEngine, solver_ctx, pynuc_dir)
elif engine_type == EngineTypes.GRAPH_ENGINE:
print("Pynucastro comparison using GraphEngine...")
graphEngine : GraphEngine = GraphEngine(self.composition, depth=3)
self.evolve_pynucastro(graphEngine, solver_ctx, pynuc_dir)
else:
print(f"Pynucastro comparison not implemented for engine type: {engine_type}")
@abstractmethod
def __call__(self, pynucastro_compare: bool = False, pync_engine: str = "GraphEngine", output: str = "output"):
pass

View File

@@ -0,0 +1,56 @@
from fourdst.composition import Composition
from fourdst.composition import CanonicalComposition
from fourdst.atomic import Species
from gridfire.type import NetIn
def rescale_composition(comp_ref : Composition, ZZs : float, Y_primordial : float = 0.248) -> Composition:
CC : CanonicalComposition = comp_ref.getCanonicalComposition()
dY_dZ = (CC.Y - Y_primordial) / CC.Z
Z_new = CC.Z * (10**ZZs)
Y_bulk_new = Y_primordial + (dY_dZ * Z_new)
X_new = 1.0 - Z_new - Y_bulk_new
if X_new < 0: raise ValueError(f"ZZs={ZZs} yields unphysical composition (X < 0)")
ratio_H = X_new / CC.X if CC.X > 0 else 0
ratio_He = Y_bulk_new / CC.Y if CC.Y > 0 else 0
ratio_Z = Z_new / CC.Z if CC.Z > 0 else 0
Y_new_list = []
newComp : Composition = Composition()
s: Species
for s in comp_ref.getRegisteredSpecies():
Xi_ref = comp_ref.getMassFraction(s)
if s.el() == "H":
Xi_new = Xi_ref * ratio_H
elif s.el() == "He":
Xi_new = Xi_ref * ratio_He
else:
Xi_new = Xi_ref * ratio_Z
Y = Xi_new / s.mass()
newComp.registerSpecies(s)
newComp.setMolarAbundance(s, Y)
return newComp
def init_composition(ZZs : float = 0) -> Composition:
Y_solar = [7.0262E-01, 9.7479E-06, 6.8955E-02, 2.5000E-04, 7.8554E-05, 6.0144E-04, 8.1031E-05, 2.1513E-05]
S = ["H-1", "He-3", "He-4", "C-12", "N-14", "O-16", "Ne-20", "Mg-24"]
return rescale_composition(Composition(S, Y_solar), ZZs)
def init_netIn(temp: float, rho: float, time: float, comp: Composition) -> NetIn:
n : NetIn = NetIn()
n.temperature = temp
n.density = rho
n.tMax = time
n.dt0 = 1e-12
n.composition = comp
return n
def years_to_seconds(years: float) -> float:
return years * 3.1536e7