diff --git a/examples/single_track_calibration/experiments.yaml b/examples/single_track_calibration/experiments.yaml new file mode 100644 index 00000000..822fc4f6 --- /dev/null +++ b/examples/single_track_calibration/experiments.yaml @@ -0,0 +1,31 @@ +data: + - process_parameters: + power: 187.5 + scan_speed: 0.5 + spot_size: 0.1 + material: SS316L + depths: [91.0e-3] + - process_parameters: + power: 300.0 + scan_speed: 0.5 + spot_size: 0.1 + material: SS316L + depths: [178.0e-3] + - process_parameters: + power: 412.5 + scan_speed: 0.5 + spot_size: 0.1 + material: SS316L + depths: [266.0e-3] + - process_parameters: + power: 525.0 + scan_speed: 0.5 + spot_size: 0.1 + material: SS316L + depths: [354.0e-3] + - process_parameters: + power: 637.5 + scan_speed: 0.5 + spot_size: 0.1 + material: SS316L + depths: [464.0e-3] diff --git a/examples/single_track_calibration/input.yaml b/examples/single_track_calibration/input.yaml new file mode 100644 index 00000000..54381d35 --- /dev/null +++ b/examples/single_track_calibration/input.yaml @@ -0,0 +1,10 @@ +steps: +- calibrate_additivefoam_heatsource: + class: single_track_calibration + application: additivefoam + configure: + experiments: experiments.yaml + nvalues: [0.0, 2.0, 5.0, 7.0, 9.0] + execute: + np: 1 + batch: True diff --git a/examples/single_track_calibration/readme.md b/examples/single_track_calibration/readme.md new file mode 100644 index 00000000..ff45d69e --- /dev/null +++ b/examples/single_track_calibration/readme.md @@ -0,0 +1,63 @@ +# Example: AdditiveFOAM Single Track Calibration + +This example covers how to calibrate an AdditiveFOAM +[projectedGaussian](https://github.com/ORNL/AdditiveFOAM/blob/main/applications/solvers/additiveFoam/movingHeatSource/heatSourceModels/projectedGaussian/projectedGaussian.C) +heat source using experimental measurements of single track melt pool depths. + +## Myna case setup + +This example requires the following files: + +- `input.yaml`: the Myna input file +- `experiments.yaml`: single track measurements required by the AdditiveFOAM single + track calibration app + +This use case is a little different than other examples, because the +`additivefoam/single_track_calibration` app does not require any build metadata and +is not associated with any build region, part or layer. Instead, the app is entirely +dependent on the file specified in the "configure/experiments" step parameter in the +Myna input file. The experiments file is a list of entries under a "data" entry +following the format: + +```yaml +data: + - process_parameters: + power: 187.5 # Laser power, in Watts + scan_speed: 0.5 # Scan speed of the laser, in meters/second + spot_size: 0.1 # Spot size (diameter), in millimeters + material: SS316L # Name of material, corresponding to Myna material library + depths: [91.0e-3] # List of depth values corresponding to the process parameters, in millimeters + - ... # Repeat for additional process parameters +``` + +Because no build data is requires, the input file does not need to have a "data" entry. +While this entry will be populated in the configured input file, it will have +empty values for all of its fields. The default output directory `myna_output` will +be created for writing the results of the workflow step. If you want to change this +behavior, you have to set the `input["data"]["build"]["name"]` entry to the desired +outputlocation. If you have other steps in the workflow that do require build data, +then you will need to specify an appropriate database entry. + +## Description of the AdditiveFOAM application + +The AdditiveFOAM application workflow consists of the following steps: + +1. *Load experimental data*: Parse the experimental data for depth measurements as a + function of process parameters +2. *Identify missing simulations*: If simulations are already detected in the output + directory, then they will be loaded and only missing simulations will be run. +3. *Run required simulations*: Single track simulations are configured and run in + `sim_output` within the Myna step directory. A hashed "fingerprint" of the process + parametersis used to group simulations with the same process parameters + (but different simulation parameters, i.e., n-values), so simulations will be grouped + into directories with long "random" names. +4. *Performing Bayesian calibration from n-values*: Based on the simulated depth, optimal + n-values for each process parameter will be calculated using a Bayesian calibration + approach leveraging the [pymc](https://www.pymc.io/) package. +5. *Performing Bayesian calibration for heat-source parameters*: All of the calibrated + n-values are assembled to find optimized heat source parameters for the + `projectedGaussian` heat source given the experimentally-measured values, again + using `pymc` methods. + +The results of the calibration are stored in the Myna-style output, which for this +example will be: "myna_output/calibrate_additivefoam_heatsource/single_track_calibration-calibrate_additivefoam_heatsource-FileYAML.yaml" diff --git a/pyproject.toml b/pyproject.toml index 90dc0df8..1a4b8f3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ 'scipy', 'gitpython', 'zarr', - 'docker'] + 'docker', + 'pydantic'] [project.optional-dependencies] dev = [ @@ -62,6 +63,10 @@ cubit = [ deer = [ 'netCDF4', ] +additivefoam = [ + 'pymc', + 'arviz', +] [project.scripts] myna = "myna.core.workflow.all:main" diff --git a/src/myna/application/additivefoam/additivefoam.py b/src/myna/application/additivefoam/additivefoam.py index 7d7a0228..8512fc03 100644 --- a/src/myna/application/additivefoam/additivefoam.py +++ b/src/myna/application/additivefoam/additivefoam.py @@ -11,12 +11,15 @@ import os import shutil import subprocess +from pathlib import Path import yaml import mistlib as mist import pandas as pd import numpy as np +from docker.models.containers import Container from myna.core.app.base import MynaApp from myna.application.openfoam.mesh import update_parameter +from myna.core.utils import working_directory class AdditiveFOAM(MynaApp): @@ -29,9 +32,7 @@ def __init__(self): # Parse app-specific arguments self.parse_known_args() - super().validate_executable( - "additiveFoam", - ) + print(f"{self.args.exec=}") if self.args.exec is None: self.args.exec = "additiveFoam" @@ -58,14 +59,17 @@ def has_matching_template_mesh_dict(self, mesh_path, mesh_dict): matches.append(entry_match) return bool(all(matches)) - def update_material_properties(self, case_dir): + def update_material_properties( + self, case_dir, material: str | None = None + ) -> mist.core.MaterialInformation: """Update the material properties for the AdditiveFOAM case based on Mist data Args: case_dir: path to the case directory to update """ - material = self.settings["data"]["build"]["build_data"]["material"]["value"] + if material is None: + material = self.settings["data"]["build"]["build_data"]["material"]["value"] material_data = os.path.join( os.environ["MYNA_INSTALL_PATH"], "mist_material_data", @@ -105,6 +109,7 @@ def update_material_properties(self, case_dir): if os.path.exists(exaca_dict): liquidus = mat.get_property("liquidus_temperature", None, None) update_parameter(exaca_dict, "ExaCA/isoValue", liquidus) + return mat def get_region_resource_template_dir(self, part, region): """Provides the path to the template directory in the myna_resources folder @@ -123,19 +128,21 @@ def get_region_resource_template_dir(self, part, region): "template", ) - def update_beam_spot_size(self, part, case_dir): + def update_beam_spot_size(self, part, case_dir, spot_size: float | None = None): """Updates the beam spot size in the case directory's constant/heatSourceDict Args: part: name of part to get spot size from case_dir: directory that contains AdditiveFOAM case files to update + spot_size: if specified, 2 sigma spot size (i.e., beam radius) in meters """ # Extract the spot size (diameter -> radius & mm -> m) - spot_size = ( - 0.5 - * self.settings["data"]["build"]["parts"][part]["spot_size"]["value"] - * 1e-3 - ) + if spot_size is None: + spot_size = ( + 0.5 + * self.settings["data"]["build"]["parts"][part]["spot_size"]["value"] + * 1e-3 + ) # Get heatSourceModel heat_source_model = ( @@ -180,7 +187,9 @@ def update_beam_spot_size(self, part, case_dir): heat_source_dim_string, ) - def update_region_start_and_end_times(self, case_dir, bb_dict, scanpath_name): + def update_region_start_and_end_times( + self, case_dir, bb_dict, scanpath_name + ) -> tuple[float, float]: """Updates the start and end times of the specified case based on the scan path's intersection with the domain @@ -188,6 +197,9 @@ def update_region_start_and_end_times(self, case_dir, bb_dict, scanpath_name): case_dir: case directory to update bb_dict: dictionary defining the bounding box of the region scanpath_name: name of the scanpath file in the case's `constant` directory + + Returns: + start_time, end_time """ # Read scan path df = pd.read_csv(f"{case_dir}/constant/{scanpath_name}", sep=r"\s+") @@ -204,12 +216,14 @@ def update_region_start_and_end_times(self, case_dir, bb_dict, scanpath_name): p1 = [row["X(m)"], row["Y(m)"]] xs = np.linspace(p0[0], p1[0], 1000) ys = np.linspace(p0[1], p1[1], 1000) - in_region = any( - (xs > bb_dict["bb_min"][0]) - & (xs < bb_dict["bb_max"][0]) - & (ys > bb_dict["bb_min"][1]) - & (ys < bb_dict["bb_max"][1]) - ) + in_region = True + if bb_dict is not None: + in_region = any( + (xs > bb_dict["bb_min"][0]) + & (xs < bb_dict["bb_max"][0]) + & (ys > bb_dict["bb_min"][1]) + & (ys < bb_dict["bb_max"][1]) + ) if in_region: time_bounds[1] = None if in_region and (time_bounds[0] is None): @@ -233,6 +247,7 @@ def update_region_start_and_end_times(self, case_dir, bb_dict, scanpath_name): time_bounds = np.round(time_bounds, 5) self.update_start_and_end_times(case_dir, time_bounds[0], time_bounds[1]) + return (time_bounds[0], time_bounds[1]) def update_start_and_end_times(self, case_dir, start_time, end_time): """Updates the case to adjust the start and end time by adjusting:" @@ -290,3 +305,37 @@ def update_exaca_region_bounds(self, case_dir, bb): "ExaCA/box", f"( {bb[0][0]} {bb[0][1]} {bb[0][2]} ) ( {bb[1][0]} {bb[1][1]} {bb[1][2]} )", ) + + def run_case(self, case_dir: str | Path) -> subprocess.Popen | Container: + """Launch the AdditiveFOAM case directory using the MynaApp settings specified""" + with working_directory(case_dir): + # Determine if parallel execution + parallel = self.args.np > 1 + + # Decompose case + if parallel: + update_parameter( + "system/decomposeParDict", + "numberOfSubdomains", + self.args.np, + ) + with open("decomposePar.log", "w", encoding="utf-8") as f: + process = self.start_subprocess( + ["decomposePar", "-force"], + stdout=f, + stderr=subprocess.STDOUT, + ) + self.wait_for_process_success(process) + + # Launch job, using MPI arguments if specified + with open("additiveFoam.log", "w", encoding="utf-8") as f: + cmd_args = [self.args.exec] + if parallel: + cmd_args.append("-parallel") + process = self.start_subprocess_with_mpi_args( + cmd_args, + stdout=f, + stderr=subprocess.STDOUT, + ) + + return process diff --git a/src/myna/application/additivefoam/path.py b/src/myna/application/additivefoam/path.py index 501e4e40..f27c5279 100644 --- a/src/myna/application/additivefoam/path.py +++ b/src/myna/application/additivefoam/path.py @@ -8,7 +8,9 @@ # """Module for functions related to AdditiveFOAM scan paths""" +from pathlib import Path import pandas as pd +import polars as pl def convert_peregrine_scanpath(filename, export_path, power=1): @@ -49,3 +51,39 @@ def convert_peregrine_scanpath(filename, export_path, power=1): sep="\t", index=False, ) + + +def write_single_line_path( + path: str | Path, + power: float, + speed: float, + start_coord: tuple = (0, 0, 0), + end_coord: tuple = (1e-3, 1e-3, 1e-3), +): + """Writes a single line scan path at the specified file path + + Args: + - path: path to export the scan path + - power: laser power, in Watts + - speed: travel speed, in meters/second + - start_coords: XYZ location of the starting location of the scan, in meters + - end_coords: XYZ location of the ending location of the scan, in meters + """ + dict = { + "Mode": [1, 0], + "X(m)": [start_coord[0], end_coord[0]], + "Y(m)": [start_coord[1], end_coord[1]], + "Z(m)": [start_coord[2], end_coord[2]], + "Power(W)": [0.0, power], + "tParam": [1e-6, speed], + } + schema = { + "Mode": pl.Int8, + "X(m)": pl.Float64, + "Y(m)": pl.Float64, + "Z(m)": pl.Float64, + "Power(W)": pl.Float64, + "tParam": pl.Float64, + } + df = pl.from_dict(dict, schema=schema) + df.write_csv(path, separator="\t") diff --git a/src/myna/application/additivefoam/single_track_calibration/__init__.py b/src/myna/application/additivefoam/single_track_calibration/__init__.py new file mode 100644 index 00000000..b7b66a49 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright (c) Oak Ridge National Laboratory. +# +# This file is part of Myna. For details, see the top-level license +# at https://github.com/ORNL-MDF/Myna/LICENSE.md. +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. +# +"""Application to calibrate simulation parameters for an AdditiveFOAM melt pool +simulation using experimental/reference values of melt pool width and depth +from cross-sections of the melt pool track""" + +from .app import AdditiveFOAMCalibration + +__all__ = ["AdditiveFOAMCalibration"] diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py new file mode 100644 index 00000000..e21f50e5 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -0,0 +1,887 @@ +# +# Copyright (c) Oak Ridge National Laboratory. +# +# This file is part of Myna. For details, see the top-level license +# at https://github.com/ORNL-MDF/Myna/LICENSE.md. +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. +# +import os +import yaml +import shutil +import pathlib +import logging +import subprocess +from typing import Optional +from dataclasses import asdict +from docker.models.containers import Container +import polars as pl +import numpy as np +import pymc as pm +import arviz as az +import pytensor.tensor as pt +import mistlib as mist +from myna.application.additivefoam import AdditiveFOAM +from myna.application.additivefoam.path import write_single_line_path +from myna.application.additivefoam.single_track_calibration.models import ( + ExperimentData, + SimulationData, + ProcessParameters, + CalibrationConfig, + create_row_fingerprint, +) +from myna.core.utils.filesystem import load_json_yaml_file +from myna.core.utils import nested_get +from myna.application.openfoam.mesh import update_parameter + + +# Define main class +class AdditiveFOAMCalibration(AdditiveFOAM): + """Application to generate calibrated heat source parameters for AdditiveFOAM + + This class orchestrates: + 1. Loading experimental and simulation data + 2. Identifying missing simulations + 3. Running required simulations + 4. Performing Bayesian calibration + 5. Saving results + """ + + def __init__( + self, + config: Optional[CalibrationConfig] = None, + ): + super().__init__() + self.class_name = "single_track_calibration" + self.config: CalibrationConfig = config or CalibrationConfig() + self.config_file = "config.yaml" + self.single_track_length = 3e-3 + self.case_dir = "additivefoam_single_track_calibration" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + self.logger = logging.getLogger(f"{self.name}") + + def parse_configure_arguments(self): + """Check for arguments relevant to the configure step and update app settings""" + self.parser.add_argument( + "--experiments", + default="experiments.yaml", + type=str, + help="Path to the experiemnts file", + ) + self.parser.add_argument( + "--simulations", + default=None, + type=str, + help=( + "(optional) Path to an existing simulations file." + "A new file will be created if not given." + ), + ) + self.parser.add_argument( + "--nvalues", + default=[0.0, 2.0, 5.0, 7.0, 9.0], + type=float, + nargs="+", + help="n-value in the AdditiveFOAM projectedGaussian heat source" + " where 0 <= n <= 9 (the calibration assumes A = 0, such that n = B)", + ) + self.parse_known_args() + + def _get_case_dir(self) -> pathlib.Path: + """Gets the Path object for the case directory, making it if it doesn't exist + + This will load the path from the Myna input file if used, otherwise will use the + app default case_dir property.""" + myna_output_dir = nested_get(self.settings, ["data", "build", "name"]) + if myna_output_dir is not None: + case = pathlib.Path(myna_output_dir) / f"{self.step_name}" + else: + case = pathlib.Path(self.case_dir) + os.makedirs(case, exist_ok=True) + return case + + def configure(self): + """Configure all cases + + Note that this workflow step can only apply to a single case, because the + calibration isn't associated with a build/part/region.""" + self.parse_configure_arguments() + case = self._get_case_dir() + self.configure_case(case) + + def configure_case(self, case_dir: str | pathlib.Path): + """Configures the case directory associated with the step""" + # Use pathlib inside function + if not isinstance(case_dir, pathlib.Path): + case_dir = pathlib.Path(case_dir) + + # Get experiments file + experiments_path = self.args.experiments + + # Get/create simulations file + simulations_path = f"{case_dir}/simulations.yaml" + if self.args.simulations is not None: + shutil.copy(self.args.simulations, simulations_path) + + # Get/create n-value calibrations file + calibrated_n_values_path = f"{case_dir}/calibrationed_n_values.yaml" + + # If running as a Myna step, get the output file name from Myna input, otherwise + # set manually to enable calling app from API + calibrated_heatsource_path = nested_get( + self.settings, + ["data", "output_paths", self.step_name], + [f"{case_dir}/calibrated_heatsource.yaml"], + )[0] + + # Set simulation output path + simulation_output_dir = f"{case_dir}/sim_output" + n_values = self.args.nvalues + + # Create configuration object to ensure it is valid + config = CalibrationConfig( + experiments_path=experiments_path, + simulations_path=simulations_path, + calibrated_n_values_path=calibrated_n_values_path, + calibrated_heatsource_path=calibrated_heatsource_path, + simulation_output_dir=simulation_output_dir, + n_values=n_values, + ) + + # Write configuration class to file + config_path = case_dir / self.config_file + with open(config_path, mode="w", encoding="utf-8") as f: + yaml.dump(asdict(config), f, sort_keys=False, default_flow_style=False) + + # Archive experimental data with the case directory + shutil.copy(experiments_path, case_dir / pathlib.Path(experiments_path).name) + + def execute(self): + """Execute all cases + + Note that this workflow step can only apply to a single case, because the + calibration isn't associated with a build/part/region.""" + case = self._get_case_dir() + config_path = case / self.config_file + config = CalibrationConfig( + **load_json_yaml_file(config_path, enforce_type=dict) + ) + self.execute_case(config) + + def execute_case(self, config: CalibrationConfig): + """Main execution flow - orchestrates the calibration workflow""" + self.config = config + self.logger.info("=" * 80) + self.logger.info("Starting AdditiveFOAM Calibration Workflow") + self.logger.info("=" * 80) + + try: + # 1. Load data + self.logger.info("Step 1: Loading data files") + experiments, simulations, calibrations = self._load_all_data() + + # 2. Build simulation matrix and identify missing/invalid simulations + self.logger.info("Step 2: Building simulation queue") + sim_queue = self._build_simulation_queue(experiments, simulations) + + # 3. Run missing simulations + if len(sim_queue) > 0: + self.logger.info(f"Step 3: Running {len(sim_queue)} simulations") + simulations = self._run_simulations(sim_queue, simulations) + self._save_simulations(simulations) + else: + self.logger.info("Step 3: No simulations needed - all up to date") + + # 4. Perform Bayesian calibration of n for each process parameter + self.logger.info("Step 4: Performing Bayesian calibration") + calibrations = self._perform_calibration(experiments, simulations) + self._save_calibrations(calibrations) + + self.logger.info("=" * 80) + self.logger.info("Calibration workflow completed successfully!") + self.logger.info( + f"Results saved to: {self.config.calibrated_n_values_path}" + ) + self.logger.info("=" * 80) + + # 5. Perform Bayesian calibration of n as a function of d/sigma over + # all process parameters, write to calibrated_heat_source.yaml + calibrated_n_values = [c["calibrated_n"] for c in calibrations] + depths = [c["observed_depths"] for c in calibrations] + spot_sizes = [c["process_parameters"]["spot_size"] for c in calibrations] + trace = self._fit_heteroskedastic_model( + depths, spot_sizes, calibrated_n_values + ) + if trace is None: + heatsource_parameters = { + "notes": "Only one experiment detected, so cannot calibrate full function! Must assume that n = B.", + "model_form": "n = A * log2(z/spot_size) + B; with n_std = C * log2(z/spot_size) + D", + "A_mean": 0, + "A_median": 0, + "A_std": 0, + "A_var": 0, + "B_mean": calibrated_n_values[0], + "B_median": 0, + "B_std": 0, + "B_var": 0, + "C_mean": 0, + "C_median": 0, + "C_std": 0, + "C_var": 0, + "D_mean": 0, + "D_median": 0, + "D_std": 0, + "D_var": 0, + } + else: + post = trace.posterior # type: ignore[attr-defined] arviz uses dynamic attributes + heatsource_parameters = { + "notes": f"Calibrated using {len([x for xs in depths for x in xs])} depths from {len(depths)} process parameters", + "model_form": "n = A * log2(z/spot_size) + B; with n_std = C * log2(z/spot_size) + D", + "A_mean": post["A"].mean().item(), + "A_median": post["A"].median().item(), + "A_std": post["A"].std().item(), + "A_var": post["A"].var().item(), + "B_mean": post["B"].mean().item(), + "B_median": post["B"].median().item(), + "B_std": post["B"].std().item(), + "B_var": post["B"].var().item(), + "C_mean": post["C"].mean().item(), + "C_median": post["C"].median().item(), + "C_std": post["C"].std().item(), + "C_var": post["C"].var().item(), + "D_mean": post["D"].mean().item(), + "D_median": post["D"].median().item(), + "D_std": post["D"].std().item(), + "D_var": post["D"].var().item(), + } + with open( + self.config.calibrated_heatsource_path, "w", encoding="utf-8" + ) as f: + yaml.dump( + heatsource_parameters, f, sort_keys=False, default_flow_style=False + ) + + except Exception as e: + self.logger.error(f"Calibration workflow failed: {str(e)}", exc_info=True) + raise + + def _load_all_data(self) -> tuple[ExperimentData, SimulationData, list]: + """Load and validate all input data files""" + # Load experiments + try: + exp_dict = load_json_yaml_file( + self.config.experiments_path, enforce_type=dict + ) + experiments = ExperimentData(**exp_dict) + self.logger.info( + f"Loaded {len(experiments.data)} experimental records from " + f"{self.config.experiments_path}" + ) + except FileNotFoundError: + self.logger.error( + f"Experiment file not found: {self.config.experiments_path}" + ) + raise + except Exception as e: + self.logger.error(f"Failed to load experiments: {str(e)}") + raise + + # Load simulations + try: + sim_dict = load_json_yaml_file( + self.config.simulations_path, enforce_type=dict + ) + simulations = SimulationData(**sim_dict) + self.logger.info( + f"Loaded {len(simulations.data)} simulation records from " + f"{self.config.simulations_path}" + ) + except FileNotFoundError: + self.logger.warning( + f"Simulation file not found: {self.config.simulations_path}. " + f"Starting with empty simulation set." + ) + simulations = SimulationData(data=[]) + except Exception as e: + self.logger.error(f"Failed to load simulations: {str(e)}") + raise + + # Load calibrations + calibrations = [] + + return experiments, simulations, calibrations + + def _build_simulation_queue( + self, experiments: ExperimentData, simulations: SimulationData + ) -> pl.DataFrame: + """Determine which simulations need to be run + + Returns a DataFrame with one row per simulation to run, containing: + - Process parameters (power, scan_speed, spot_size) + - Simulation parameter (n) + - fingerprint for tracking + """ + self.logger.debug("Preparing experiment and simulation DataFrames") + df_exp = self._prepare_experiment_df(experiments) + df_sim = self._prepare_simulation_df(simulations) + + self.logger.debug("Creating required simulation matrix") + required_sims = self._create_required_simulation_matrix(df_exp) + self.logger.info(f"Total required simulations: {len(required_sims)}") + + self.logger.debug("Identifying valid existing simulations") + valid_sims = self._identify_valid_simulations(df_sim) + self.logger.info(f"Valid existing simulations: {len(valid_sims)}") + + # Find missing simulations (anti-join) + sim_queue = required_sims.join( + valid_sims.select(["fingerprint", "n"]), on=["fingerprint", "n"], how="anti" + ) + + self.logger.info(f"Simulations to run: {len(sim_queue)}") + + if len(sim_queue) > 0: + # Log details about what needs to be run + queue_summary = sim_queue.group_by("fingerprint").agg( + pl.col("n").count().alias("n_count") + ) + for row in queue_summary.iter_rows(named=True): + self.logger.debug( + f" Fingerprint {row['fingerprint'][:8]}...: " + f"{row['n_count']} simulations" + ) + + return sim_queue + + def _prepare_experiment_df(self, experiments: ExperimentData) -> pl.DataFrame: + """Convert experiments to DataFrame with fingerprints""" + df = experiments.to_polars_df() + + # Add fingerprints based on process parameters + param_cols = list(ProcessParameters.model_fields.keys()) + self.logger.debug(f"Loading columns: {param_cols}") + df = df.with_columns( + pl.struct([pl.col(k) for k in param_cols]) + .map_elements(create_row_fingerprint, return_dtype=pl.String) + .alias("fingerprint") + ) + + self.logger.debug(f"Prepared experiment DataFrame: {len(df)} rows") + return df + + def _prepare_simulation_df(self, simulations: SimulationData) -> pl.DataFrame: + """Convert simulations to DataFrame and validate fingerprints""" + if len(simulations.data) == 0: + self.logger.debug("No existing simulations - returning empty DataFrame") + return pl.DataFrame( + schema={ + **{k: pl.Float64 for k in ProcessParameters.model_fields}, + "n": pl.Float64, + "depths": pl.Float64, + "fingerprint": pl.String, + "current_fingerprint": pl.String, + } + ) + + df = simulations.to_polars_df() + + # Check for fingerprint mismatches + mismatches = df.filter( + pl.col("fingerprint").is_not_null() + & (pl.col("fingerprint") != pl.col("current_fingerprint")) + ) + + if len(mismatches) > 0: + self.logger.warning( + f"Found {len(mismatches)} simulations with outdated fingerprints " + f"(process parameters changed). These will be re-run." + ) + + self.logger.debug(f"Prepared simulation DataFrame: {len(df)} rows") + return df + + def _create_required_simulation_matrix(self, df_exp: pl.DataFrame) -> pl.DataFrame: + """Create full matrix of all simulations that should exist""" + # Get unique experiment fingerprints + param_cols = list(ProcessParameters.model_fields.keys()) + unique_experiments = df_exp.select([*param_cols, "fingerprint"]).unique() + + self.logger.debug(f"Unique experiments: {len(unique_experiments)}") + + # Cross join with all n values + n_df = pl.DataFrame({"n": self.config.n_values}) + required_sims = unique_experiments.join(n_df, how="cross") + + self.logger.debug( + f"Required simulation matrix: {len(unique_experiments)} experiments x " + f"{len(self.config.n_values) if isinstance(self.config.n_values, list) else None} " + "n-values = {len(required_sims)} simulations" + ) + + return required_sims + + def _identify_valid_simulations(self, df_sim: pl.DataFrame) -> pl.DataFrame: + """Filter simulations to only those that are valid + + Valid simulations must: + 1. Have matching stored and current fingerprints (process params unchanged) + 2. Have non-null depth results + """ + if len(df_sim) == 0: + return df_sim + + valid = df_sim.filter( + (pl.col("fingerprint") == pl.col("current_fingerprint")) + & (pl.col("depths").is_not_null()) + ) + + invalid_count = len(df_sim) - len(valid) + if invalid_count > 0: + self.logger.debug(f"Filtered out {invalid_count} invalid simulations") + + return valid + + def _run_simulations( + self, sim_queue: pl.DataFrame, existing_sims: SimulationData + ) -> SimulationData: + """Execute simulations for all entries in the queue""" + os.makedirs(self.config.simulation_output_dir, exist_ok=True) + self.logger.info(f"Output directory: {self.config.simulation_output_dir}") + + def _extract_output(output_file: pathlib.Path): + """Extracts the output from the simulation""" + if output_file.exists(): + df = pl.read_csv(output_file) + return df["depth(m)"].to_numpy()[-1] * 1e3 + return None + + def _run_single_simulation( + row, + ) -> tuple[float | subprocess.Popen | Container, str | pathlib.Path]: + """Run a single simulation, saving results to a directory with the + process parameter fingerprint + n + """ + # Extract parameters for logging + power = row["power"] + speed = row["scan_speed"] + spot = row["spot_size"] + n = row["n"] + material = row["material"] + fingerprint = row["fingerprint"] + + self.logger.debug( + f"Running simulation:\n\tmaterial={material}\n\tP={power}W\n\tv={speed}m/s" + f"\n\td={spot}mm\n\tn={n}\n\tfingerprint={fingerprint}" + ) + + # Get basic material datamaterial_data = os.path.join( + material_data = os.path.join( + os.environ["MYNA_INSTALL_PATH"], + "mist_material_data", + f"{material}.json", + ) + mat_obj = mist.core.MaterialInformation(material_data) + TS = int(mat_obj.get_property("solidus_eutectic_temperature", None, None)) + TL = int(mat_obj.get_property("liquidus_temperature", None, None)) + + # Set case dir and output file + case_dir = ( + pathlib.Path(self.config.simulation_output_dir) + / f"{fingerprint}" + / f"n_{n}" + ) + output_file = ( + case_dir / "postProcessing" / "meltPoolDimensions" / f"{TS}.csv" + ) + + # Attempt to extract results, if unsuccessful re-run the case + result = _extract_output(output_file) + if result is not None: + return (result, output_file) + if case_dir.exists(): + shutil.rmtree(case_dir) + + # Copy the template and configure the case, + # - Update the beam parameters + # - Generate the scanpath from the laser power and scan speed + # - Write out only the final time-step of the scan path + # - Update the material properties + os.makedirs(case_dir) + self.logger.debug( + f"Copying template ({self.template})\n\t-> case dir ({case_dir})" + ) + self.copy_template_to_case(case_dir) + self.update_beam_spot_size(None, case_dir, 0.5 * spot * 1e-3) + heatsource_model = "projectedGaussian" + update_parameter( + f"{case_dir}/constant/heatSourceDict", + f"beam/{heatsource_model}Coeffs/B", + n, + ) + update_parameter( + f"{case_dir}/constant/heatSourceDict", + f"beam/{heatsource_model}Coeffs/isoValue", + TL, + ) + scan_file = case_dir / "constant" / "scanPath" + write_single_line_path( + scan_file, power, speed, (0, 0, 0), (self.single_track_length, 0, 0) + ) + _, end_time = self.update_region_start_and_end_times( + case_dir, None, scan_file.name + ) + update_parameter( + f"{case_dir}/system/controlDict", + "writeInterval", + end_time, + ) + self.update_material_properties(case_dir, material) + update_parameter( + f"{case_dir}/system/controlDict", + "functions/meltPoolDimensions/isoValues", + f"( {TS} {TL} )", + ) + + # Generate mesh + with subprocess.Popen( + ["blockMesh", "-case", f"{case_dir}"], stdout=subprocess.DEVNULL + ) as p: + p.wait() + + # Run the case and return the process object + process = self.run_case(case_dir) + self.logger.debug("Case submitted") + return (process, output_file) + + # Run simulations using MynaApp job management functions + self.logger.info("Executing simulations...") + results = [] + processes = [] + for row in sim_queue.iter_rows(named=True): + result, output_file = _run_single_simulation(row) + if isinstance(result, (subprocess.Popen, Container)): + processes.append(result) + results.append(output_file) + self.wait_for_open_batch_resources(processes) + else: + results.append(result) + self.wait_for_all_process_success(processes) + + # Extract results + results = [ + r if isinstance(r, (float, int)) else _extract_output(r) for r in results + ] + results = sim_queue.with_columns(pl.Series("depths", results, dtype=pl.Float64)) + + # Mark these simulations as validated + results = results.with_columns( + pl.col("fingerprint").alias("current_fingerprint") + ) + + self.logger.info(f"Completed {len(results)} simulations") + + # Merge with existing valid simulations + df_sim_existing = existing_sims.to_polars_df() + df_sim_valid = self._identify_valid_simulations(df_sim_existing) + + # Combine valid existing + new results + combined = pl.concat([df_sim_valid, results], how="diagonal_relaxed") + + self.logger.debug( + f"Combined simulations: {len(df_sim_valid)} existing + " + f"{len(results)} new = {len(combined)} total" + ) + + # Update the simulation data model + updated_sims = SimulationData(data=[]) + updated_sims.update_from_df(combined) + + self.logger.info( + f"Updated simulation data model with {len(updated_sims.data)} records" + ) + + return updated_sims + + def _perform_calibration( + self, experiments: ExperimentData, simulations: SimulationData + ) -> list[dict]: + """Perform Bayesian calibration for each experiment""" + + def _extract_calibrated_n( + trace: az.InferenceData, n_min: float, n_max: float + ) -> float: + """Extract calibrated n value from posterior samples""" + n_samples = trace.posterior["n"].values.flatten() # type: ignore[attr-defined] arviz uses dynamic attributes + posterior_mean_n = np.mean(n_samples) + lower_bound_region = n_min + (n_max - n_min) * 0.01 + clipping_percentage = ( + np.sum(n_samples <= lower_bound_region) / len(n_samples) * 100 + ) + + if clipping_percentage > 5.0: + self.logger.warning( + f"Posterior is clipped at lower bound ({clipping_percentage:.1f}%). " + f"Using n_min={n_min:.3f}" + ) + return n_min + + return posterior_mean_n + + df_exp = self._prepare_experiment_df(experiments) + df_sim = self._prepare_simulation_df(simulations) + df_sim_valid = self._identify_valid_simulations(df_sim) + + calibration_results = [] + unique_fingerprints = df_exp.select("fingerprint").unique().to_series() + + self.logger.info(f"Calibrating {len(unique_fingerprints)} experiments") + + for i, fingerprint in enumerate(unique_fingerprints, 1): + self.logger.info( + f"Calibration {i}/{len(unique_fingerprints)}: {fingerprint[:16]}..." + ) + + try: + # Get experimental observations + exp_data = df_exp.filter(pl.col("fingerprint") == fingerprint) + observed_depths = exp_data.select("depths").to_series()[0] + if not isinstance(observed_depths, list): + observed_depths = ( + observed_depths.to_list() + if hasattr(observed_depths, "to_list") + else [observed_depths] + ) + + # Get simulation results for all n values + sim_data = df_sim_valid.filter( + pl.col("current_fingerprint") == fingerprint + ).sort("n") + + if len(sim_data) < 2: + self.logger.warning( + f" Skipping: insufficient simulation data " + f"({len(sim_data)} points, need ≥2)" + ) + continue + + n_coords = sim_data.select("n").to_series().to_list() + model_depths = sim_data.select("depths").to_series().to_list() + + self.logger.debug( + f" n range: [{min(n_coords):.1f}, {max(n_coords):.1f}], " + f"depth range: [{min(model_depths):.4f}, {max(model_depths):.4f}]mm" + ) + + # Perform calibration + trace = self._perform_bayesian_calibration( + n_coords=n_coords, + model_values=model_depths, + observed_values=[[d] for d in observed_depths], + ) + + calibrated_n = _extract_calibrated_n( + trace, n_min=min(n_coords), n_max=max(n_coords) + ) + + # Calculate uncertainty metrics + n_samples = trace.posterior["n"].values.flatten() # type: ignore[attr-defined] arviz uses dynamic attributes + n_std = np.std(n_samples) + n_ci_lower = np.percentile(n_samples, 2.5) + n_ci_upper = np.percentile(n_samples, 97.5) + + self.logger.info( + f" ✓ Calibrated n = {calibrated_n:.3f} ± {n_std:.3f} " + f"(95% CI: [{n_ci_lower:.3f}, {n_ci_upper:.3f}])" + ) + + # Store results + process_params = exp_data.select( + list(ProcessParameters.model_fields.keys()) + ).row(0, named=True) + + calibration_results.append( + { + "fingerprint": fingerprint, + "process_parameters": process_params, + "calibrated_n": float(calibrated_n), + "n_std": float(n_std), + "n_ci_lower": float(n_ci_lower), + "n_ci_upper": float(n_ci_upper), + "n_samples": n_samples.tolist(), + "observed_depths": observed_depths, + } + ) + + except Exception as e: + self.logger.error( + f" ✗ Calibration failed for {fingerprint[:16]}: {str(e)}", + exc_info=True, + ) + continue + + self.logger.info( + f"Successfully calibrated {len(calibration_results)} experiments" + ) + return calibration_results + + def _save_simulations(self, simulations: SimulationData): + """Save simulation data to file""" + try: + with open(self.config.simulations_path, mode="w", encoding="utf-8") as f: + payload = simulations.model_dump(mode="json", exclude_none=True) + yaml.dump(payload, f, sort_keys=False, default_flow_style=False) + + self.logger.info(f"Saved simulations to {self.config.simulations_path}") + except Exception as e: + self.logger.error(f"Failed to save simulations: {str(e)}") + raise + + def _save_calibrations(self, calibrations: list[dict]): + """Save calibration results to file""" + try: + # Don't save the full sample arrays to keep file size reasonable + calibrations_to_save = [] + for cal in calibrations: + cal_copy = cal.copy() + # Keep summary statistics but remove full sample array + if "n_samples" in cal_copy: + del cal_copy["n_samples"] + calibrations_to_save.append(cal_copy) + + with open( + self.config.calibrated_n_values_path, mode="w", encoding="utf-8" + ) as f: + yaml.dump( + calibrations_to_save, f, sort_keys=False, default_flow_style=False + ) + + self.logger.info( + f"Saved calibrations to {self.config.calibrated_n_values_path}" + ) + except Exception as e: + self.logger.error(f"Failed to save calibrations: {str(e)}") + raise + + def _perform_bayesian_calibration( + self, + n_coords: list[float | int], + model_values: list[float | int], + observed_values: list[list[float | int]], + ) -> az.InferenceData: + """Calculates the posterior distribution from Bayesian calibration of n to observations + + Assumptions: + - Uniform prior + - 1 Gaussian standard deviation is used as noise if multiple observations are given, + otherwise 15% of the measured value is used as noise + """ + # Convert to numpy arrays + observed_arrays = [np.asarray(x) for x in observed_values] + sigmas = np.array( + [ + max(np.std(arr) if len(arr) > 1 else 0.15 * arr[0], 1e-4) + for arr in observed_arrays + ] + ) + + def _linear_interp_pt(n_val, n_data_pt, column_data_pt): + """Linear interpolation using PyTensor tensors""" + n_data_pt, column_data_pt, n_val = ( + pt.cast(n_data_pt, "float64"), + pt.cast(column_data_pt, "float64"), + pt.cast(n_val, "float64"), + ) + idx, data_len = pt.searchsorted(n_data_pt, n_val), pt.shape(n_data_pt)[0] + idx = pt.clip(idx, 1, data_len - 1) + idx_lower, idx_upper = idx - 1, idx + n_lower, n_upper = n_data_pt[idx_lower], n_data_pt[idx_upper] + val_lower, val_upper = column_data_pt[idx_lower], column_data_pt[idx_upper] + return val_lower + (val_upper - val_lower) * (n_val - n_lower) / ( + n_upper - n_lower + ) + + with pm.Model() as _: + n = pm.Uniform("n", lower=np.min(n_coords), upper=np.max(n_coords)) + n_data_pt, model_values_pt = ( + pt.constant(n_coords), + pt.constant(model_values), + ) + predicted_values = pm.Deterministic( + "predicted_value", _linear_interp_pt(n, n_data_pt, model_values_pt) + ) + pm.Normal( + "likelihood", + mu=predicted_values, + sigma=sigmas, + observed=observed_values, + ) + self.logger.info( + f"Sampling posterior with {len(observed_values)} observations " + f"(σ_est={sigmas})" + ) + trace = pm.sample( + draws=2000, + tune=1000, + cores=self.args.np, + progressbar=True, + target_accept=0.9, + random_seed=42, + ) + return trace + + def _fit_heteroskedastic_model( + self, depth_observations, spot_sizes, calibrated_n_values + ) -> az.InferenceData | None: + """ + Performs a robust heteroskedastic Bayesian regression to model both the mean + and the standard deviation of n as a function of NORMALIZED depth. + """ + if len(calibrated_n_values) == 1: + return None + + normalized_depths_obs = [ + np.mean(ds) / s for ds, s in zip(depth_observations, spot_sizes) + ] + self.logger.debug(f"{normalized_depths_obs=}") + n_obs = calibrated_n_values + + with pm.Model(): + # --- Model Priors --- + A = pm.Normal("A", mu=1.0, sigma=2.0) + B = pm.Normal("B", mu=0.0, sigma=5.0) + C = pm.Normal("C", mu=0.0, sigma=1.0) + D = pm.Normal("D", mu=0.0, sigma=2.0) + nu = pm.Exponential("nu", 1 / 29.0 + 1) + + # --- Model Definition (using normalized depth) --- + mu_model = pm.Deterministic("mu", A * pt.log2(normalized_depths_obs) + B) + log_sigma_model = pm.Deterministic( + "log_sigma", C * pt.log2(normalized_depths_obs) + D + ) + sigma_model = pm.Deterministic("sigma", pt.exp(log_sigma_model)) + + pm.StudentT("n_fit", nu=nu, mu=mu_model, sigma=sigma_model, observed=n_obs) + + trace = pm.sample( + 2000, tune=1500, cores=4, random_seed=42, target_accept=0.95 + ) + return trace + + +# ============================================================================== +# EXAMPLE API USAGE +# ============================================================================== + +if __name__ == "__main__": + # Configure custom settings if needed + app = AdditiveFOAMCalibration() + app.configure() + + app = AdditiveFOAMCalibration() + app.args.np = 1 + app.args.batch = True + app.execute() diff --git a/src/myna/application/additivefoam/single_track_calibration/configure.py b/src/myna/application/additivefoam/single_track_calibration/configure.py new file mode 100644 index 00000000..fdffd39c --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/configure.py @@ -0,0 +1,25 @@ +# +# Copyright (c) Oak Ridge National Laboratory. +# +# This file is part of Myna. For details, see the top-level license +# at https://github.com/ORNL-MDF/Myna/LICENSE.md. +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. +# +"""Script to be executed by the configure stage of `myna.core.workflow.run` to set up +a valid calibration case based on the specified user inputs and template +""" + +from myna.application.additivefoam.single_track_calibration.app import ( + AdditiveFOAMCalibration, +) + + +def configure(): + """Configure all case directories""" + app = AdditiveFOAMCalibration() + app.configure() + + +if __name__ == "__main__": + configure() diff --git a/src/myna/application/additivefoam/single_track_calibration/execute.py b/src/myna/application/additivefoam/single_track_calibration/execute.py new file mode 100644 index 00000000..85c8d5bd --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/execute.py @@ -0,0 +1,25 @@ +# +# Copyright (c) Oak Ridge National Laboratory. +# +# This file is part of Myna. For details, see the top-level license +# at https://github.com/ORNL-MDF/Myna/LICENSE.md. +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. +# +"""Script to be executed by the execute stage of `myna.core.workflow.run` to run +a valid calibration case based on the specified user inputs +""" + +from myna.application.additivefoam.single_track_calibration.app import ( + AdditiveFOAMCalibration, +) + + +def execute(): + """Configure all case directories""" + app = AdditiveFOAMCalibration() + app.execute() + + +if __name__ == "__main__": + execute() diff --git a/src/myna/application/additivefoam/single_track_calibration/models.py b/src/myna/application/additivefoam/single_track_calibration/models.py new file mode 100644 index 00000000..26d2120d --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/models.py @@ -0,0 +1,210 @@ +# +# Copyright (c) Oak Ridge National Laboratory. +# +# This file is part of Myna. For details, see the top-level license +# at https://github.com/ORNL-MDF/Myna/LICENSE.md. +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. +# +import logging +import json +import hashlib +from typing import Optional, Annotated, Any +from dataclasses import dataclass +import polars as pl +import numpy as np +from pydantic import BaseModel, model_validator, model_serializer, BeforeValidator + + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logging.getLogger().setLevel(logging.DEBUG) +logger = logging.getLogger(__name__) + + +# Utility function for fingerprinting of the model parameters +def create_row_fingerprint(row: dict | pl.Series) -> str: + """Creates a hash from the process parameters in a DataFrame row""" + payload = { + k: (np.round(row[k], 6) if isinstance(row[k], float) else row[k]) + for k in ProcessParameters.model_fields.keys() + } + canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical_json.encode()).hexdigest() + + +def _ensure_float_list(v: Any) -> list[Any]: + """Handle floats, lists of floats, and None and convert them all to a list. + + Uses `Any` in the inner list to allow for `None` values + """ + if v is None: + return [] + if isinstance(v, (int, float)): + return [float(v)] + return list(v) + + +FlexibleFloatList = Annotated[ + list[Optional[float]], BeforeValidator(_ensure_float_list) +] + + +class ProcessParameters(BaseModel): + """Defines the process parameters that must be present for each experiment""" + + power: float # heat source power, in W + scan_speed: float # heat source scan speed, in m/s + spot_size: float # diameter of the heat source, in mm + material: str # name of the material, corresponding to Myna/Mist material name + + +class SimulationParameters(BaseModel): + """Defines the simulation parameters that define each simulation""" + + n: FlexibleFloatList # description of heat source distribution, unitless + + +class SingleTrackData(BaseModel): + """Defines the data required for a single track experiment""" + + process_parameters: ProcessParameters + depths: FlexibleFloatList # in millimeters + + @model_serializer(mode="wrap") + def round_floats_serializer(self, handler): + """Recursively rounds floats to 6 decimal places for stable hashing/JSON.""" + data = handler(self) + return self._recursive_round(data) + + def _recursive_round(self, obj): + if isinstance(obj, float): + return round(obj, 6) + if isinstance(obj, dict): + return {k: self._recursive_round(v) for k, v in obj.items()} + if isinstance(obj, list): + return [self._recursive_round(x) for x in obj] + return obj + + +class SimulatedSingleTrackData(SingleTrackData): + """Defines the data required for a single track simulation""" + + simulation_parameters: SimulationParameters + depths: Optional[FlexibleFloatList] = None # in millimeters + fingerprint: Optional[str] = None + + @model_validator(mode="after") + def validate_lengths_match(self): + """Ensure that the length of the `depths` list and the `simulation_parameters.n` + list are the same, padding `depths` with None values if needed. + """ + if self.depths is not None: + max_depths = len(self.simulation_parameters.n) + if len(self.depths) > max_depths: + raise ValueError( + f"Too many depth values. Found {len(self.depths)}, " + f"but only {max_depths} simulation parameters (n) exist." + ) + elif len(self.depths) < max_depths: + self.depths.extend([None] * (max_depths - len(self.depths))) + return self + + +class ExperimentData(BaseModel): + """Defines the format of the experimental data file""" + + data: list[SingleTrackData] + + def to_polars_df(self) -> pl.DataFrame: + """Converts the data model to a polars DataFrame""" + dicts = [ + {**d.process_parameters.model_dump(), "depths": d.depths} for d in self.data + ] + return pl.from_dicts(dicts) + + +class SimulationData(ExperimentData): + """Defines the format of the simulation data file""" + + data: list[SimulatedSingleTrackData] + + def to_polars_df(self) -> pl.DataFrame: + """Converts the data model to a polars DataFrame""" + # Create dictionary of data + dicts = [ + { + **d.process_parameters.model_dump(), + **d.simulation_parameters.model_dump(), + "depths": d.depths, + "fingerprint": d.fingerprint, + } + for d in self.data + ] + + # Explode to get one row per (n, depth) combination + if len(dicts) == 0: + df = pl.from_dicts( + dicts, + schema={ + **{k: pl.Float64 for k in ProcessParameters.model_fields}, + **{k: pl.Float64 for k in SimulationParameters.model_fields}, + "depths": pl.Float64, + "fingerprint": pl.String, + }, + ).explode(["depths", "n"]) + else: + df = pl.from_dicts(dicts).explode(["depths", "n"]) + + # Add current fingerprint to each row + param_cols = list(ProcessParameters.model_fields.keys()) + df = df.with_columns( + pl.struct([pl.col(k) for k in param_cols]) + .map_elements(create_row_fingerprint, return_dtype=pl.String) + .alias("current_fingerprint") + ) + return df + + def update_from_df(self, df: pl.DataFrame): + """Update simulation data from a DataFrame + + Expects df to have one row per n value, with depths as single values + """ + param_keys = list(ProcessParameters.model_fields.keys()) + + # Group by fingerprint to reconstruct records + grouped = df.group_by("fingerprint").agg( + [ + *[pl.col(k).first() for k in param_keys], + pl.col("n").sort(), + pl.col("depths").sort_by("n"), + ] + ) + + self.data = [ + SimulatedSingleTrackData( + process_parameters=ProcessParameters(**{k: r[k] for k in param_keys}), + simulation_parameters=SimulationParameters(n=r["n"]), + depths=r["depths"], + fingerprint=r["fingerprint"], + ) + for r in grouped.iter_rows(named=True) + ] + + +@dataclass +class CalibrationConfig: + """Configuration for the calibration workflow""" + + experiments_path: str = "test_exp.yaml" + simulations_path: str = "test_sim.yaml" + calibrated_n_values_path: str = "calibrated_n_values.yaml" + calibrated_heatsource_path: str = "calibrated_heatsource.yaml" + simulation_output_dir: str = "sim_output" + n_values: Optional[list[float]] = None + + def __post_init__(self): + if self.n_values is None: + self.n_values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] diff --git a/src/myna/application/additivefoam/single_track_calibration/template/0/T b/src/myna/application/additivefoam/single_track_calibration/template/0/T new file mode 100644 index 00000000..a3a1dc2a --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/0/T @@ -0,0 +1,44 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class volScalarField; + object T; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +dimensions [0 0 0 1 0 0 0]; + +internalField uniform 300; + +boundaryField +{ + bottom + { + type zeroGradient; + value uniform 300; + } + top + { + type mixedTemperature; + h 10.0; + emissivity 0.4; + Tinf uniform 300; + value uniform 300; + } + sides + { + type zeroGradient; + value uniform 300; + } +} + + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/0/U b/src/myna/application/additivefoam/single_track_calibration/template/0/U new file mode 100644 index 00000000..4032a320 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/0/U @@ -0,0 +1,41 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class volVectorField; + object U; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +dimensions [0 1 -1 0 0 0 0]; + +internalField uniform (0 0 0); + +boundaryField +{ + bottom + { + type noSlip; + } + + top + { + type marangoni; + dSigmadT -0.11e-3; + value uniform (0 0 0); + } + + sides + { + type noSlip; + } +} + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/0/alpha.solid b/src/myna/application/additivefoam/single_track_calibration/template/0/alpha.solid new file mode 100644 index 00000000..357f400c --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/0/alpha.solid @@ -0,0 +1,39 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class volScalarField; + location "0"; + object alpha.solid; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +dimensions [0 0 0 0 0 0 0]; + +internalField uniform 1; + +boundaryField +{ + bottom + { + type zeroGradient; + } + top + { + type zeroGradient; + } + sides + { + type zeroGradient; + } +} + + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/0/p_rgh b/src/myna/application/additivefoam/single_track_calibration/template/0/p_rgh new file mode 100644 index 00000000..58da4a20 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/0/p_rgh @@ -0,0 +1,45 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class volScalarField; + object p_rgh; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +dimensions [0 2 -2 0 0 0 0]; + +internalField uniform 0; + +boundaryField +{ + bottom + { + type fixedFluxPressure; + rho rhok; + value uniform 0; + } + + top + { + type fixedFluxPressure; + rho rhok; + value uniform 0; + } + + sides + { + type fixedFluxPressure; + rho rhok; + value uniform 0; + } +} + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/constant/g b/src/myna/application/additivefoam/single_track_calibration/template/constant/g new file mode 100644 index 00000000..7171262b --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/constant/g @@ -0,0 +1,22 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class uniformDimensionedVectorField; + location "constant"; + object g; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +dimensions [0 1 -2 0 0 0 0]; +value (0 0 -9.81); + + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/constant/heatSourceDict b/src/myna/application/additivefoam/single_track_calibration/template/constant/heatSourceDict new file mode 100644 index 00000000..7cec7b21 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/constant/heatSourceDict @@ -0,0 +1,52 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object heatSourceProperties; +} + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +sources (beam); + +/*---------------------------------------------------------------------------*\ +Citation: + G.L. Knapp, J. Coleman, M. Rolchigo, M. Stoyanov, A. Plotkowski, + Calibrating uncertain parameters in melt pool simulations of additive + manufacturing (2023), https://doi.org/10.1016/j.commatsci.2022.11190. +\*---------------------------------------------------------------------------*/ +beam +{ + pathName scanPath; + + absorptionModel Kelly; + + KellyCoeffs + { + eta0 0.33; + etaMin 0.33; + geometry cone; + } + + heatSourceModel projectedGaussian; + + projectedGaussianCoeffs + { + A 0.0; + B 0.0; + dimensions (85.0e-6 85.0e-6 30e-6); + nPoints (10 10 10); + transient true; + isoValue 1670; + } +} + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/constant/scanPath b/src/myna/application/additivefoam/single_track_calibration/template/constant/scanPath new file mode 100644 index 00000000..b89b0ced --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/constant/scanPath @@ -0,0 +1,3 @@ +Mode X Y Z Power Param +1 0.000 0.000 0 0 0 +0 0.002 0.000 0 179.2 0.8 diff --git a/src/myna/application/additivefoam/single_track_calibration/template/constant/thermoPath b/src/myna/application/additivefoam/single_track_calibration/template/constant/thermoPath new file mode 100644 index 00000000..fdcd4be0 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/constant/thermoPath @@ -0,0 +1,4 @@ +( +1410.0000 1.0000 +1620.0000 0.0000 +) diff --git a/src/myna/application/additivefoam/single_track_calibration/template/constant/transportProperties b/src/myna/application/additivefoam/single_track_calibration/template/constant/transportProperties new file mode 100644 index 00000000..b9453156 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/constant/transportProperties @@ -0,0 +1,43 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object transportProperties; +} + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +solid +{ + kappa (8.275 0.01472 0.0); + Cp (579.28 0.0 0.0); +} + +liquid +{ + kappa (4.889 0.014743 0.0); + Cp (750.65 0.0 0.0); +} + +powder +{ + kappa (-0.07707 0.00075 0.0); + Cp (747.568 0.0 0.0); +} + +//- fluid flow properties +rho [1 -3 0 0 0 0 0] 7569.92; +mu [1 -1 -1 0 0 0 0] 0.003032; +beta [0 0 0 -1 0 0 0] 1.2e-4; +DAS [0 1 0 0 0 0 0] 10e-6; +Lf [0 2 -2 0 0 0 0] 2.1754e5; + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/system/blockMeshDict b/src/myna/application/additivefoam/single_track_calibration/template/system/blockMeshDict new file mode 100644 index 00000000..171e5007 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/system/blockMeshDict @@ -0,0 +1,80 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object blockMeshDict; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // +xmin -0.0005; +xmax 0.0035; +ymin -0.0003; +ymax 0.0003; +zmin -0.0006; +zmax 0.0; + + +vertices +( + ($xmin $ymin $zmin) //0 + ($xmax $ymin $zmin) //1 + ($xmax $ymax $zmin) //2 + ($xmin $ymax $zmin) //3 + ($xmin $ymin $zmax) //4 + ($xmax $ymin $zmax) //5 + ($xmax $ymax $zmax) //6 + ($xmin $ymax $zmax) //7 +); + +blocks +( + hex (0 1 2 3 4 5 6 7) (200 30 30) simpleGrading (1 1 1) +); + +edges +( +); + +boundary +( + bottom + { + type wall; + faces + ( + (0 3 2 1) + ); + } + top + { + type wall; + faces + ( + (4 5 6 7) + ); + } + sides + { + type wall; + faces + ( + (0 4 7 3) + (2 6 5 1) + (1 5 4 0) + (3 7 6 2) + ); + } +); + +mergePatchPairs +( +); + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/system/controlDict b/src/myna/application/additivefoam/single_track_calibration/template/system/controlDict new file mode 100644 index 00000000..9ad2b4de --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/system/controlDict @@ -0,0 +1,68 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2; + format ascii; + class dictionary; + location "system"; + object controlDict; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +application additiveFoam; + +startFrom startTime; + +startTime 0; + +stopAt endTime; + +endTime 0.0025; + +deltaT 1e-07; + +writeControl adjustableRunTime; + +writeInterval 0.0025; + +purgeWrite 0; + +writeFormat binary; + +writePrecision 8; + +writeCompression off; + +timeFormat general; + +timePrecision 8; + +runTimeModifiable yes; + +adjustTimeStep yes; + +maxCo 0.5; + +maxDi 1; + +maxAlphaCo 1; + +functions +{ + meltPoolDimensions + { + libs ( "libadditiveFoamFunctionObjects.so" ); + type meltPoolDimensions; + isoValues ( 1620 1410 ); + scanPathAngle 0; + } +} + + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/system/decomposeParDict b/src/myna/application/additivefoam/single_track_calibration/template/system/decomposeParDict new file mode 100644 index 00000000..08d37baf --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/system/decomposeParDict @@ -0,0 +1,22 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + location "system"; + object decomposeParDict; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +numberOfSubdomains 6; + +method scotch; + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/system/fvSchemes b/src/myna/application/additivefoam/single_track_calibration/template/system/fvSchemes new file mode 100644 index 00000000..3a757116 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/system/fvSchemes @@ -0,0 +1,50 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + location "system"; + object fvSchemes; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +ddtSchemes +{ + default Euler; +} + +gradSchemes +{ + default Gauss linear; +} + +divSchemes +{ + default Gauss upwind; +} + +laplacianSchemes +{ + default Gauss linear corrected; + laplacian(kappa,T) Gauss harmonic corrected; +} + +interpolationSchemes +{ + default linear; +} + +snGradSchemes +{ + default corrected; +} + + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/single_track_calibration/template/system/fvSolution b/src/myna/application/additivefoam/single_track_calibration/template/system/fvSolution new file mode 100644 index 00000000..e5d484ec --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/template/system/fvSolution @@ -0,0 +1,68 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + ========= | + \\ / F ield | OpenFOAM: The Open Source CFD Toolbox + \\ / O peration | Website: https://openfoam.org + \\ / A nd | Version: 10 + \\/ M anipulation | +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + location "system"; + object fvSolution; +} +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // + +solvers +{ + p_rgh + { + solver GAMG; + tolerance 1e-06; + relTol 0.01; + smoother DIC; + } + + p_rghFinal + { + $p_rgh; + relTol 0; + } + + "T.*" + { + solver PBiCGStab; + preconditioner DILU; + tolerance 1e-15; + relTol 0; + minIter 1; + maxIter 20; + } +} + +PIMPLE +{ + momentumPredictor no; + nOuterCorrectors 0; + nCorrectors 1; + nNonOrthogonalCorrectors 0; + pRefCell 0; + pRefValue 0; + + + nThermoCorrectors 20; + thermoTolerance 1e-8; + explicitSolve true; +} + +relaxationFactors +{ + equations + { + ".*" 1; + } +} + +// ************************************************************************* // diff --git a/src/myna/application/additivefoam/solidification_region_reduced/app.py b/src/myna/application/additivefoam/solidification_region_reduced/app.py index a8fe7586..6effc0f6 100644 --- a/src/myna/application/additivefoam/solidification_region_reduced/app.py +++ b/src/myna/application/additivefoam/solidification_region_reduced/app.py @@ -449,32 +449,7 @@ def execute_case(self, mynafile): self.args.np, ) - with working_directory(case_dict["case_dir"]): - # Determine if parallel execution - parallel = self.args.np > 1 - - # Decompose case - if parallel: - with open("decomposePar.log", "w", encoding="utf-8") as f: - process = self.start_subprocess( - ["decomposePar", "-force"], - stdout=f, - stderr=subprocess.STDOUT, - ) - self.wait_for_process_success(process) - - # Launch job, using MPI arguments if specified - with open("additiveFoam.log", "w", encoding="utf-8") as f: - cmd_args = [self.args.exec] - if parallel: - cmd_args.append("-parallel") - process = self.start_subprocess_with_mpi_args( - cmd_args, - stdout=f, - stderr=subprocess.STDOUT, - ) - - return process + return self.run_case(case_dict["case_dir"]) def postprocess(self): """Postprocesses all cases""" diff --git a/src/myna/core/app/base.py b/src/myna/core/app/base.py index 45dc5ff1..5fef9367 100644 --- a/src/myna/core/app/base.py +++ b/src/myna/core/app/base.py @@ -126,6 +126,14 @@ def __init__(self): help="(str) MPI flags to append for MPI parallel execution" + " (for use with --mpiexec)", ) + self.parser.add_argument( + "--limit-mpi-resources", + dest="limit_mpi_resources", + default=False, + action="store_true", + help="(bool) If True, will limit batch jobs to local resources, if False" + + " will not limit batch job submission rate (for use with --mpiexec)", + ) self.parser.add_argument( "--env", default=None, @@ -299,6 +307,7 @@ def copy_template_to_case(self, case_dir): # Copy if there are no existing files in the case directory or overwrite is specified if (len(case_dir_files) == 0) or (self.args.overwrite): + os.makedirs(case_dir, exist_ok=True) shutil.copytree(self.template, case_dir, dirs_exist_ok=True) else: print(f"Warning: NOT overwriting existing case in: {case_dir}") @@ -341,7 +350,9 @@ def start_subprocess(self, cmd_args, **kwargs) -> subprocess.Popen | Container: ) return process - def start_subprocess_with_mpi_args(self, cmd_args, **kwargs): + def start_subprocess_with_mpi_args( + self, cmd_args, **kwargs + ) -> subprocess.Popen | Container: """Starts a subprocess using `Popen` while taking into account the MynaApp MPI-related options. **kwargs are passed to `subprocess.Popen` """ @@ -427,7 +438,7 @@ def wait_for_open_batch_resources( # `self.args.maxproc` are not accurate and that MPI is responsible for throwing # errors about oversubscription of resources open_resources = False - if self.args.mpiexec is not None: + if (self.args.mpiexec is not None) and (not self.args.limit_mpi_resources): open_resources = True while not open_resources: diff --git a/src/myna/core/components/__init__.py b/src/myna/core/components/__init__.py index cbc9e74a..ed6c9dd3 100644 --- a/src/myna/core/components/__init__.py +++ b/src/myna/core/components/__init__.py @@ -40,6 +40,7 @@ """ from .component_class_lookup import return_step_class +from .component_calibration import SingleTrackCalibrationComponent from .component_cluster import ( ComponentCluster, ComponentClusterSolidification, @@ -88,6 +89,7 @@ __all__ = [ "return_step_class", + "SingleTrackCalibrationComponent", "ComponentCluster", "ComponentClusterSolidification", "ComponentClusterSupervoxel", diff --git a/src/myna/core/components/component.py b/src/myna/core/components/component.py index a583c10e..aff69160 100644 --- a/src/myna/core/components/component.py +++ b/src/myna/core/components/component.py @@ -9,6 +9,7 @@ """Base class for workflow components""" import os +from typing import Literal import subprocess import myna import myna.database @@ -200,7 +201,7 @@ def get_files_from_template(self, template, abspath=True): # Get build name input_dir = os.path.abspath(os.path.dirname(os.environ["MYNA_INPUT"])) - build = self.data["build"]["name"] + build = nested_get(self.data, ["build", "name"], "myna_output") # Get all other names that are set by the component vars = self.types[1:] @@ -422,7 +423,9 @@ def sync_output_files(self): return synced_files - def get_step_args_list(self, operation): + def get_step_args_list( + self, operation: Literal["configure", "execute", "postprocess"] + ): """Get the command list for the configure, execute, or postprocess operation that can be passed to `subprocess.Popen` @@ -434,7 +437,6 @@ def get_step_args_list(self, operation): """ # Initialize - assert operation in set(["configure", "execute", "postprocess"]) arg_dict = getattr(self, f"{operation}_dict") arglist = [] @@ -453,7 +455,30 @@ def check_obsolete_args(dict_key, value, operation): return True return False - # Get values from the workspace + def _get_arglist(key, value, operation) -> list: + """Parse the value to the expected flag format""" + if not check_obsolete_args(key, value, operation): + # Check for boolean flag + if isinstance(value, bool): + # Assume that default flag behavior is False + if value: + return [f"--{key}"] + + # Handle string value + if isinstance(value, str): + if " " in value: + return ["--" + str(key), get_quoted_str(value)] + else: + return ["--" + str(key), str(value)] + + # Handle list value + elif isinstance(value, list): + arglist.extend(["--" + str(key), *[str(x) for x in value]]) + + # If unhandled, return empty + return [] + + # Get values from the workspace, ignoring any that are in the input file if self.workspace is not None: workspace_dict = load_input(self.workspace) workspace_dict = workspace_dict.get(self.component_application, {}) @@ -462,33 +487,11 @@ def check_obsolete_args(dict_key, value, operation): for key in workspace_dict.keys(): if key not in arg_dict.keys(): value = workspace_dict[key] - # Check for flag - if isinstance(workspace_dict[key], bool) and ( - not check_obsolete_args(key, value, operation) - ): - # Assume that default flag behavior is False - if value: - arglist.append(f"--{key}") - - # Else, get value - elif not check_obsolete_args(key, value, operation): - if " " in str(value): - arglist.extend(["--" + str(key), get_quoted_str(value)]) - else: - arglist.extend(["--" + str(key), str(value)]) + arglist.extend(_get_arglist(key, value, operation)) # Overwrite workspace with any values from the input file for key in arg_dict.keys(): value = arg_dict[key] - if isinstance(value, bool) and ( - not check_obsolete_args(key, value, operation) - ): - if value: - arglist.append(f"--{key}") - elif not check_obsolete_args(key, value, operation): - if " " in str(value): - arglist.extend(["--" + str(key), get_quoted_str(value)]) - else: - arglist.extend(["--" + str(key), str(value)]) + arglist.extend(_get_arglist(key, value, operation)) return arglist diff --git a/src/myna/core/components/component_calibration.py b/src/myna/core/components/component_calibration.py new file mode 100644 index 00000000..4210db75 --- /dev/null +++ b/src/myna/core/components/component_calibration.py @@ -0,0 +1,18 @@ +# +# Copyright (c) Oak Ridge National Laboratory. +# +# This file is part of Myna. For details, see the top-level license +# at https://github.com/ORNL-MDF/Myna/LICENSE.md. +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. +# +"""Class for the calibration of model parameters""" + +from .component import Component +from myna.core.files import FileYAML + + +class SingleTrackCalibrationComponent(Component): + def __init__(self): + super().__init__() + self.output_requirement = FileYAML diff --git a/src/myna/core/components/component_class_lookup.py b/src/myna/core/components/component_class_lookup.py index 478b01ac..07dc3982 100644 --- a/src/myna/core/components/component_class_lookup.py +++ b/src/myna/core/components/component_class_lookup.py @@ -50,6 +50,7 @@ def return_step_class(step_name, verbose=True): "creep_timeseries": comp.ComponentCreepTimeSeries(), "creep_timeseries_part": comp.ComponentCreepTimeSeriesPart(), "creep_timeseries_region": comp.ComponentCreepTimeSeriesRegion(), + "single_track_calibration": comp.SingleTrackCalibrationComponent(), } try: step_class = step_class_lookup[step_name] diff --git a/src/myna/core/db/__init__.py b/src/myna/core/db/__init__.py index de2cd3b0..72916abf 100644 --- a/src/myna/core/db/__init__.py +++ b/src/myna/core/db/__init__.py @@ -8,6 +8,6 @@ # """Define the requirements and behavior of Myna databases.""" -from .database import Database +from .database import Database, NoDatabase -__all__ = ["Database"] +__all__ = ["Database", "NoDatabase"] diff --git a/src/myna/core/db/database.py b/src/myna/core/db/database.py index 394c35f0..d898f704 100644 --- a/src/myna/core/db/database.py +++ b/src/myna/core/db/database.py @@ -82,3 +82,17 @@ def write_segment_sync_metadata( sync_dict[segment_type_key][segment_key]["synced_by"] = os.getlogin() with open(sync_metadata_file, "w", encoding="utf-8") as mf: yaml.safe_dump(sync_dict, mf) + + +class NoDatabase(Database): + """Non-existent database used for tasks that don't actually require build data""" + + def __init__(self): + super().__init__() + self.build_segmentation_type = "layer" + + def set_path(self, path): + pass + + def exists(self) -> bool: + return True diff --git a/src/myna/core/files/__init__.py b/src/myna/core/files/__init__.py index 146fea3f..cfb4bfeb 100644 --- a/src/myna/core/files/__init__.py +++ b/src/myna/core/files/__init__.py @@ -29,6 +29,7 @@ from .file_region import FileRegion, FileBuildRegion from .file_temperature import FileTemperature from .file_vtk import FileVTK, FilePVD +from .file_yaml import FileYAML __all__ = [ "File", @@ -45,4 +46,5 @@ "FileTemperature", "FileVTK", "FilePVD", + "FileYAML", ] diff --git a/src/myna/core/files/file_yaml.py b/src/myna/core/files/file_yaml.py new file mode 100644 index 00000000..1c793748 --- /dev/null +++ b/src/myna/core/files/file_yaml.py @@ -0,0 +1,19 @@ +# +# Copyright (c) Oak Ridge National Laboratory. +# +# This file is part of Myna. For details, see the top-level license +# at https://github.com/ORNL-MDF/Myna/LICENSE.md. +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. +# +"""Define a file format class for YAML output data""" + +from .file import File + + +class FileYAML(File): + """File format class for YAML output data""" + + def __init__(self, file): + super().__init__(file) + self.filetype = ".yaml" diff --git a/src/myna/core/utils/filesystem.py b/src/myna/core/utils/filesystem.py index d6ab5891..c0232b54 100644 --- a/src/myna/core/utils/filesystem.py +++ b/src/myna/core/utils/filesystem.py @@ -9,7 +9,10 @@ """Tools for file system operations""" import os +import json +import yaml import shutil +from typing import Any from pathlib import Path import contextlib @@ -37,3 +40,22 @@ def is_executable(executable): def strf_datetime(datetime_obj): """Return the current date and time as a pretty string""" return datetime_obj.strftime("%Y-%m-%d %H:%M:%S") + + +def load_json_yaml_file(filepath: str | Path, enforce_type=None) -> Any: + """Loads a dictionary from a JSON or YAML file, optionally enforcing a top-level + datatype (e.g., dict or list)""" + with open(filepath, "r") as f: + suffix = Path(filepath).suffix + contents = {} + if suffix in [".yml", ".yaml"]: + contents = yaml.safe_load(f) + elif suffix in [".json"]: + contents = json.load(f) + if enforce_type is not None: + if not isinstance(contents, enforce_type): + raise ValueError( + f"Top-level contents of {filepath} are" + f"{type(contents)} but are expected to be {enforce_type}" + ) + return contents diff --git a/src/myna/core/workflow/config.py b/src/myna/core/workflow/config.py index efadc8c5..32b0c7e8 100644 --- a/src/myna/core/workflow/config.py +++ b/src/myna/core/workflow/config.py @@ -83,8 +83,10 @@ def config(input_file, output_file=None, show_avail=False, overwrite=False): settings = load_input(input_file) # Check build directory contains the expected metadata folder - build_path = settings["data"]["build"]["path"] - datatype = database.return_datatype_class(settings["data"]["build"]["datatype"]) + build_path = nested_get(settings, ["data", "build", "path"]) + datatype = database.return_datatype_class( + nested_get(settings, ["data", "build", "datatype"], "None") + ) datatype.set_path(build_path) if not datatype.exists(): print(f"ERROR: Could not find valid {datatype} in" + f" {build_path}") @@ -131,11 +133,6 @@ def config(input_file, output_file=None, show_avail=False, overwrite=False): all_parts.extend(build_region_parts) all_parts = list(set(all_parts)) - # Check that some amount of parts were specified - if len(all_parts) < 1: - print(f"ERROR: No parts specified in {input_file}") - raise ValueError - # Get list of all segments in build parts and build_region parts # Possible options for types segments are: # - datatype.build_segmentation_type == "layer": uses layers diff --git a/src/myna/database/database_types.py b/src/myna/database/database_types.py index 72fe9c03..d140c9a7 100644 --- a/src/myna/database/database_types.py +++ b/src/myna/database/database_types.py @@ -13,6 +13,7 @@ from myna.database.nist_ambench_2022 import AMBench2022 from myna.database.myna_json import MynaJSON from myna.database.pelican import Pelican +from myna.core.db import NoDatabase def return_datatype_class(datatype_str): @@ -51,6 +52,8 @@ def remove_text_format(text): return MynaJSON() elif remove_text_format(datatype_str) in ["pelican"]: return Pelican() + elif remove_text_format(datatype_str) in ["none"]: + return NoDatabase() else: print(f"Error: {datatype_str} does not correspond to any implemented database") raise NotImplementedError