From 5b90600b18ce440f01b2c30a595378ac585d7296 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 21 Oct 2025 11:38:28 -0400 Subject: [PATCH 01/29] draft: add stubs for calibration app --- examples/single_track_calibration/input.yaml | 13 + .../single_track_calibration/__init__.py | 3 + .../single_track_calibration/app.py | 519 ++++++++++++++++++ .../core/components/component_class_lookup.py | 1 + 4 files changed, 536 insertions(+) create mode 100644 examples/single_track_calibration/input.yaml create mode 100644 src/myna/application/additivefoam/single_track_calibration/__init__.py create mode 100644 src/myna/application/additivefoam/single_track_calibration/app.py diff --git a/examples/single_track_calibration/input.yaml b/examples/single_track_calibration/input.yaml new file mode 100644 index 00000000..bbdf9e3d --- /dev/null +++ b/examples/single_track_calibration/input.yaml @@ -0,0 +1,13 @@ +steps: +- calibrate_additivefoam: + class: single_track_calibration + application: additivefoam + execute: + experiment-file: /path/to/experiment.yaml + simulation-file: /path/to/simulation.yaml # optional + state-file: /path/to/state.yaml # optional +data: + build: + datatype: Peregrine + name: myna_output + path: .. 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..1f844485 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/__init__.py @@ -0,0 +1,3 @@ +"""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""" 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..3d76a07e --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -0,0 +1,519 @@ +from myna.application.additivefoam import AdditiveFOAM + +import os +import yaml +import hashlib +import pandas as pd +import numpy as np +import pymc as pm +import arviz as az +import pytensor.tensor as pt +import matplotlib.pyplot as plt +import seaborn as sns + + +# ============================================================================== +# SECTION 1: DATA I/O AND PARSING +# ============================================================================== +def _create_data_fingerprint(data_list: list) -> str: + sorted_data_str = str(sorted(data_list)) + return hashlib.sha256(sorted_data_str.encode()).hexdigest() + + +def load_yaml_file(filepath: str) -> list | None: + if not os.path.exists(filepath): + print(f"Info: File not found at '{filepath}'. Proceeding with empty data.") + return [] + try: + with open(filepath, "r") as f: + return yaml.safe_load(f) or [] + except Exception as e: + print(f"Error: Could not parse YAML file '{filepath}': {e}") + return None + + +def parse_experiments(raw_experiments: list) -> pd.DataFrame: + if not raw_experiments: + return pd.DataFrame( + columns=[ + "parameters", + "depths_list", + "normalized_depths_list", + "fingerprint", + ] + ) + records = [] + for exp in raw_experiments: + params = exp["parameters"] + spot_size = params.get("Spot_Size_microns") + if spot_size is None or spot_size <= 0: + print( + f"Warning: Invalid or missing 'Spot_Size_microns' in parameters {params}. Skipping this experiment." + ) + continue + + depths = exp["Measured_Depth_microns"] + normalized_depths = [d / spot_size for d in depths] + + records.append( + { + "parameters": params, + "depths_list": depths, + "normalized_depths_list": normalized_depths, + "fingerprint": _create_data_fingerprint(depths), + } + ) + return pd.DataFrame(records) + + +def parse_simulations(raw_simulations: list) -> pd.DataFrame: + if not raw_simulations: + return pd.DataFrame() + records = [] + for sim_run in raw_simulations: + params, n_values, depths = ( + sim_run["parameters"], + sim_run["n"], + sim_run["Simulated_Depth_microns"], + ) + spot_size = params.get("Spot_Size_microns") + if spot_size is None or spot_size <= 0: + print( + f"Warning: Invalid or missing 'Spot_Size_microns' in parameters {params}. Skipping this simulation set." + ) + continue + + for n, depth in zip(n_values, depths): + records.append( + { + **params, + "n": n, + "Simulated_Depth_microns": depth, + "Normalized_Simulated_Depth": depth / spot_size, + } + ) + return pd.DataFrame(records) + + +def save_state_file(state_data: list, filepath: str): + print(f"\nSaving updated state to '{filepath}'...") + try: + with open(filepath, "w") as f: + yaml.dump( + state_data, + f, + default_flow_style=False, + sort_keys=False, + indent=2, + ) + print("Save complete.") + except Exception as e: + print(f"Error saving state file '{filepath}': {e}") + + +def save_simulation_queue(queue_df: pd.DataFrame, filepath: str): + print(f"Saving pending simulation queue to '{filepath}'...") + try: + queue_df.to_csv(filepath, index=False) + print("Queue saved successfully.") + except Exception as e: + print(f"Error saving simulation queue file '{filepath}': {e}") + + +# ============================================================================== +# SECTION 2: CORE CALIBRATION LOGIC +# ============================================================================== +def linear_interp_pt(n_val, n_data_pt, column_data_pt): + 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) + + +def perform_bayesian_calibration( + n_coords: np.ndarray, model_values: np.ndarray, observed_values: list[float] +) -> az.InferenceData: + sigma_est = ( + np.std(observed_values) + if len(observed_values) > 1 + else 0.15 * observed_values[0] + ) + sigma_est = max( + sigma_est, 1e-4 + ) # Adjusted minimum sigma for smaller normalized values + with pm.Model() as model: + n = pm.Uniform("n", lower=n_coords.min(), upper=n_coords.max()) + n_data_pt, model_values_pt = pt.constant(n_coords), pt.constant(model_values) + predicted_value = pm.Deterministic( + "predicted_value", linear_interp_pt(n, n_data_pt, model_values_pt) + ) + pm.Normal( + "likelihood", + mu=predicted_value, + sigma=sigma_est, + observed=observed_values, + ) + print( + f" Sampling posterior with {len(observed_values)} observations (σ_est={sigma_est:.3f})..." + ) + trace = pm.sample( + draws=2000, + tune=1000, + cores=8, + progressbar=True, + target_accept=0.9, + random_seed=42, + ) + return trace + + +def extract_calibrated_n(trace: az.InferenceData, n_min: float, n_max: float) -> float: + n_samples = trace.posterior["n"].values.flatten() + 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: + print( + f" Warning: Posterior is clipped at lower bound ({clipping_percentage:.1f}%). Using n_min." + ) + return n_min + return posterior_mean_n + + +# ============================================================================== +# SECTION 3: PLOTTING AND ANALYSIS FUNCTIONS +# ============================================================================== +def plot_calibration_overview(results_df, simulations_df): + if results_df.empty: + return + num_plots = len(results_df) + fig, axes = plt.subplots( + 1, num_plots, figsize=(6 * num_plots, 5), sharey=True, squeeze=False + ) + axes = axes.flatten() + for i, (_, row) in enumerate(results_df.iterrows()): + params_dict = row["parameters"] + ax = axes[i] + query_string = " & ".join([f"`{k}`=={v}" for k, v in params_dict.items()]) + sim_curve = simulations_df.query(query_string).sort_values("n") + + # Plot normalized simulation curve + ax.plot( + sim_curve["n"], + sim_curve["Normalized_Simulated_Depth"], + "k-", + label="Simulation Curve", + zorder=1, + ) + + # Plot calibrated point against mean normalized experimental depth + ax.errorbar( + x=row["calibrated_n"], + y=row["mean_normalized_depth"], + xerr=row["calibrated_n_std"], + fmt="o", + color="red", + capsize=5, + markersize=8, + label="Calibrated n (Mean & Std Dev)", + zorder=3, + ) + + # Plot individual normalized experimental data points + ax.scatter( + np.full(len(row["normalized_depths_list"]), row["calibrated_n"]), + row["normalized_depths_list"], + edgecolor="blue", + facecolor="none", + s=50, + label="Experimental Data", + zorder=2, + ) + + title = ", ".join([f"{k.split('_')[0]}={v}" for k, v in params_dict.items()]) + ax.set_title(title), ax.set_xlabel("Shape Factor (n)"), ax.grid( + True, linestyle="--", alpha=0.6 + ) + + axes[0].set_ylabel("Normalized Melt Pool Depth (Depth / Spot Size)"), axes[ + 0 + ].legend() + fig.suptitle( + "Calibration Overview of Newly Processed Experiments", + fontsize=16, + y=1.02, + ), plt.tight_layout(), plt.show() + + +def plot_posterior_distributions(traces_dict): + if not traces_dict: + return + plt.figure(figsize=(10, 6)) + colors = plt.cm.viridis(np.linspace(0, 1, len(traces_dict))) + for i, (param_key, trace) in enumerate(traces_dict.items()): + params_dict = dict(eval(param_key)) + label = ", ".join([f"{k.split('_')[0]}={v}" for k, v in params_dict.items()]) + sns.histplot( + trace.posterior["n"].values.flatten(), + label=label, + color=colors[i], + kde=True, + alpha=0.5, + stat="probability", + ) + plt.title("Posterior Distributions for Newly Calibrated 'n'"), plt.xlabel( + "Calibrated n Value" + ), plt.ylabel("Density") + plt.legend( + title="Process Parameters", bbox_to_anchor=(1.05, 1), loc="upper left" + ), plt.grid(True, linestyle="--", alpha=0.6), plt.tight_layout(), plt.show() + + +def fit_and_plot_heteroskedastic_model(calibrated_results_df): + """ + Performs a robust heteroskedastic Bayesian regression to model both the mean + and the standard deviation of n as a function of NORMALIZED depth. + """ + if calibrated_results_df.empty or len(calibrated_results_df) < 2: + print("Info: Not enough newly calibrated points to fit a final relationship.") + return + + normalized_depths_obs = calibrated_results_df["mean_normalized_depth"].values + n_obs = calibrated_results_df["calibrated_n"].values + n_stds_obs = calibrated_results_df["calibrated_n_std"].values + + with pm.Model() as hetero_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) + + print("\n--- Performing Robust Heteroskedastic Fit ---") + hetero_trace = pm.sample( + 2000, tune=1500, cores=4, random_seed=42, target_accept=0.95 + ) + + print("\n--- Heteroskedastic Fit Summary ---") + print(az.summary(hetero_trace, var_names=["A", "B", "C", "D", "nu"])) + + # --- Plotting the Results --- + fig, ax = plt.subplots(figsize=(10, 6)) + + post = hetero_trace.posterior + A_m, B_m = post["A"].mean().item(), post["B"].mean().item() + C_m, D_m = post["C"].mean().item(), post["D"].mean().item() + + # Plot the observed data points with their Stage 1 uncertainty + ax.errorbar( + normalized_depths_obs, + n_obs, + yerr=n_stds_obs, + fmt="o", + color="C0", + ecolor="C0", + capsize=3, + label="Calibrated n (from Stage 1)", + ) + + # Calculate and plot the model's prediction for the mean + x_range = np.linspace( + normalized_depths_obs.min() * 0.9, + normalized_depths_obs.max() * 1.1, + 100, + ) + mean_pred = A_m * np.log2(x_range) + B_m + ax.plot(x_range, mean_pred, color="C1", lw=2, label=f"Mean Model: n(d_norm)") + + # Calculate and plot the model's prediction for the uncertainty (±2 sigma) + sigma_pred = np.exp(C_m * np.log2(x_range) + D_m) + ax.fill_between( + x_range, + mean_pred - 2.0 * 0.1 * mean_pred, # sigma_pred, + mean_pred + 2.0 * 0.1 * mean_pred, # sigma_pred, + color="C1", + alpha=0.3, + label=f"Uncertainty Model: ±2σ(d_norm)", + ) + + ax.set_title("Heteroskedastic Fit of 'n' vs. Mean Normalized Experimental Depth") + ax.set_xlabel( + "Mean Normalized Experimental Depth (Depth / Spot Size)" + ), ax.set_ylabel("Calibrated Shape Factor (n)") + ax.legend(), ax.grid(True, linestyle="--", alpha=0.6), plt.tight_layout() + ax.set_xscale("log", base=2) + plt.show() + + print("\n--- Final Predictive Equations (Using Normalized Depth d_norm) ---") + print( + f"To predict the mean n: n_mean(d_norm) = {A_m:.4f} * log2(d_norm) + {B_m:.4f}" + ) + print( + f"To predict the uncertainty (σ): σ_n(d_norm) = exp({C_m:.4f} * log2(d_norm) + {D_m:.4f})" + ) + + +class AdditiveFOAMCalibration(AdditiveFOAM): + """Application to generated calibrated heat source parameters for AdditiveFOAM""" + + def execute(self): + # Set paths + EXPERIMENTS_PATH, SIMULATIONS_PATH, STATE_PATH = ( + "experiments.yml", + "simulations.yml", + "calibration_state.yml", + ) + SIMULATION_QUEUE_PATH = "pending_simulations.csv" + + # Load data from YAML dictionaries + raw_experiments, raw_simulations, current_state_data = ( + load_yaml_file(EXPERIMENTS_PATH), + load_yaml_file(SIMULATIONS_PATH), + load_yaml_file(STATE_PATH), + ) + + # Check for empty files + # - if experiments aren't there -> exit + # - if state files or simulations aren't there -> + if ( + raw_experiments is None + or raw_simulations is None + or current_state_data is None + ): + print("\nAborting due to file parsing errors."), exit() + exp_df, sim_df, state_df = ( + parse_experiments(raw_experiments), + parse_simulations(raw_simulations), + pd.DataFrame(current_state_data), + ) + if exp_df.empty: + print("\nNo experimental data found. Exiting."), exit() + to_process_list, fresh_states, state_lookup = [], [], {} + if not state_df.empty: + for i, row in state_df.iterrows(): + state_lookup[str(sorted(row["parameters"].items()))] = row.to_dict() + for _, exp_row in exp_df.iterrows(): + param_key = str(sorted(exp_row["parameters"].items())) + if ( + param_key in state_lookup + and state_lookup[param_key].get("fingerprint") == exp_row["fingerprint"] + ): + fresh_states.append(state_lookup[param_key]) + else: + to_process_list.append(exp_row) + to_process_df = pd.DataFrame(to_process_list) + print("\n--- Adaptive Run Summary ---"), print( + f"Found {len(exp_df)} total experimental parameter sets." + ) + print(f"Found {len(fresh_states)} up-to-date calibrations."), print( + f"Found {len(to_process_df)} new or stale experiments to process." + ) + newly_calibrated_states, needs_simulation_list, posterior_traces = ( + [], + [], + {}, + ) + newly_calibrated_results_for_plotting = [] + if not to_process_df.empty and sim_df.empty: + print( + "\nWarning: Experiments need processing, but simulation data is empty. Flagging all as 'needs simulation'." + ) + needs_simulation_list = to_process_df["parameters"].tolist() + elif not to_process_df.empty: + for _, job_row in to_process_df.iterrows(): + params = job_row["parameters"] + print(f"\n--- Processing parameters: {params} ---") + query_string = " & ".join([f"`{k}`=={v}" for k, v in params.items()]) + model_subset = sim_df.query(query_string) + if model_subset.empty: + print( + " FLAGGED: No matching simulation data found." + ), needs_simulation_list.append(params) + continue + print(" Found matching simulation data. Proceeding with calibration.") + + model_subset_sorted = model_subset.sort_values(by="n") + n_coords = model_subset_sorted["n"].values + model_normalized_depths = model_subset_sorted[ + "Normalized_Simulated_Depth" + ].values + + trace = perform_bayesian_calibration( + n_coords, + model_normalized_depths, + job_row["normalized_depths_list"], + ) + + calibrated_n_mean = extract_calibrated_n( + trace, n_min=n_coords.min(), n_max=n_coords.max() + ) + calibrated_n_std = trace.posterior["n"].values.std() + new_state = { + "parameters": params, + "calibrated_n": float(calibrated_n_mean), + "calibrated_n_std": float(calibrated_n_std), + "fingerprint": job_row["fingerprint"], + } + newly_calibrated_states.append(new_state) + posterior_traces[str(sorted(params.items()))] = trace + + newly_calibrated_results_for_plotting.append( + { + **new_state, + "mean_normalized_depth": np.mean( + job_row["normalized_depths_list"] + ), + "normalized_depths_list": job_row["normalized_depths_list"], + } + ) + + print( + f" -> Calibrated n: {calibrated_n_mean:.3f} ± {calibrated_n_std:.3f}" + ) + final_state = fresh_states + newly_calibrated_states + save_state_file(final_state, STATE_PATH) + final_state_df = pd.DataFrame(final_state) + print("\n\n" + "=" * 30), print(" FINAL CALIBRATION STATE "), print("=" * 30) + if not final_state_df.empty: + print( + final_state_df[ + ["parameters", "calibrated_n", "calibrated_n_std"] + ].to_string(index=False, float_format="%.4f") + ) + else: + print("No calibrated results exist in the state file.") + print("\n\n" + "=" * 35), print( + " ACTION REQUIRED: PENDING SIMULATIONS " + ), print("=" * 35) + needs_simulation_df = pd.DataFrame(needs_simulation_list) + if not needs_simulation_df.empty: + print( + f"The following parameter sets require a simulation run (saved to '{SIMULATION_QUEUE_PATH}'):" + ), print(needs_simulation_df.to_string(index=False)) + save_simulation_queue(needs_simulation_df, SIMULATION_QUEUE_PATH) + else: + print("No new simulations are required at this time.") + if newly_calibrated_results_for_plotting: + print("\nGenerating plots for newly calibrated data...") + plot_df = pd.DataFrame(newly_calibrated_results_for_plotting) + plot_calibration_overview(plot_df, sim_df) + plot_posterior_distributions(posterior_traces) + fit_and_plot_heteroskedastic_model(plot_df) + print("\nDone.") diff --git a/src/myna/core/components/component_class_lookup.py b/src/myna/core/components/component_class_lookup.py index 478b01ac..63ac4c1a 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.Component(), } try: step_class = step_class_lookup[step_name] From c28c794427754c7b1edbfc7b25a3fb0f68ff14ed Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Mon, 29 Dec 2025 17:22:56 -0500 Subject: [PATCH 02/29] draft: add pydantic data models and initial tests --- pyproject.toml | 3 +- .../single_track_calibration/__init__.py | 8 + .../single_track_calibration/app.py | 334 +++++++++++++----- .../single_track_calibration/configure.py | 8 + .../single_track_calibration/execute.py | 8 + .../single_track_calibration/test.py | 29 ++ .../single_track_calibration/test_exp.yaml | 34 ++ .../single_track_calibration/test_sim.yaml | 89 +++++ 8 files changed, 416 insertions(+), 97 deletions(-) create mode 100644 src/myna/application/additivefoam/single_track_calibration/configure.py create mode 100644 src/myna/application/additivefoam/single_track_calibration/execute.py create mode 100644 src/myna/application/additivefoam/single_track_calibration/test.py create mode 100644 src/myna/application/additivefoam/single_track_calibration/test_exp.yaml create mode 100644 src/myna/application/additivefoam/single_track_calibration/test_sim.yaml diff --git a/pyproject.toml b/pyproject.toml index 90dc0df8..1b5d1b64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ 'scipy', 'gitpython', 'zarr', - 'docker'] + 'docker', + 'pydantic'] [project.optional-dependencies] dev = [ diff --git a/src/myna/application/additivefoam/single_track_calibration/__init__.py b/src/myna/application/additivefoam/single_track_calibration/__init__.py index 1f844485..6aec2fd6 100644 --- a/src/myna/application/additivefoam/single_track_calibration/__init__.py +++ b/src/myna/application/additivefoam/single_track_calibration/__init__.py @@ -1,3 +1,11 @@ +# +# 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""" diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index 3d76a07e..ab48d9bc 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -1,98 +1,236 @@ -from myna.application.additivefoam import AdditiveFOAM - +# +# 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 json import yaml import hashlib +import pathlib +from typing import Optional, Annotated import pandas as pd +import polars as pl import numpy as np import pymc as pm import arviz as az import pytensor.tensor as pt import matplotlib.pyplot as plt import seaborn as sns +from pydantic import BaseModel, model_validator, model_serializer, BeforeValidator +from myna.application.additivefoam import AdditiveFOAM -# ============================================================================== -# SECTION 1: DATA I/O AND PARSING -# ============================================================================== -def _create_data_fingerprint(data_list: list) -> str: - sorted_data_str = str(sorted(data_list)) - return hashlib.sha256(sorted_data_str.encode()).hexdigest() +# Define data models and validation: +# - Experimental data model is the primary mode, with simulation data derived as a +# subclass. This is intended to maintain that simulations are representations of the +# experiments, so a subset of the simulation data model should directly correspond +# to the experimental data +# - Before-validation gives some flexibility to the user, e.g., will not fail if user enters +# a single value instead of a single-valued list +# - After-validation enforces expected data structure for the rest of the application -def load_yaml_file(filepath: str) -> list | None: - if not os.path.exists(filepath): - print(f"Info: File not found at '{filepath}'. Proceeding with empty data.") - return [] - try: - with open(filepath, "r") as f: - return yaml.safe_load(f) or [] - except Exception as e: - print(f"Error: Could not parse YAML file '{filepath}': {e}") - return None - - -def parse_experiments(raw_experiments: list) -> pd.DataFrame: - if not raw_experiments: - return pd.DataFrame( - columns=[ - "parameters", - "depths_list", - "normalized_depths_list", - "fingerprint", - ] - ) - records = [] - for exp in raw_experiments: - params = exp["parameters"] - spot_size = params.get("Spot_Size_microns") - if spot_size is None or spot_size <= 0: - print( - f"Warning: Invalid or missing 'Spot_Size_microns' in parameters {params}. Skipping this experiment." + +def _ensure_float_list(value): + """BeforeValidator function to ensure that the value is a list[float]""" + if isinstance(value, (int, float)): + return [float(value)] + return value + + +def _ensure_str_list(value): + """BeforeValidator function to ensure that the value is a list[str]""" + if isinstance(value, (str)): + return [str(value)] + return value + + +FlexibleFloatList = Annotated[list[float], BeforeValidator(_ensure_float_list)] +FlexibleStringList = Annotated[list[float], BeforeValidator(_ensure_str_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 + + +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 + comments: Optional[FlexibleStringList] = None + + # Serialize floats to 6 digits (0.001 micron precision for millimeters values) + # to ensure for stable hashing + @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 + fingerprint: Optional[str] = None + + @model_validator(mode="after") + def validate_lengths_match(self): + if len(self.depths) != len(self.simulation_parameters.n): + raise ValueError( + "depths and simulation_parameters.n must have the same length" ) - continue + return self + + +class ExperimentData(BaseModel): + """Defines the format of the experimental data file""" + + data: list[SingleTrackData] + comments: Optional[FlexibleStringList] = None + + 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) + - depths = exp["Measured_Depth_microns"] - normalized_depths = [d / spot_size for d in depths] +class SimulationData(ExperimentData): + """Defines the format of the simulation data file""" - records.append( + data: list[SimulatedSingleTrackData] + + def to_polars_df(self) -> pl.DataFrame: + """Converts the data model to a polars DataFrame""" + dicts = [ { - "parameters": params, - "depths_list": depths, - "normalized_depths_list": normalized_depths, - "fingerprint": _create_data_fingerprint(depths), + **d.process_parameters.model_dump(), + **d.simulation_parameters.model_dump(), + "depths": d.depths, + "fingerprint": d.fingerprint, } - ) - return pd.DataFrame(records) - - -def parse_simulations(raw_simulations: list) -> pd.DataFrame: - if not raw_simulations: - return pd.DataFrame() - records = [] - for sim_run in raw_simulations: - params, n_values, depths = ( - sim_run["parameters"], - sim_run["n"], - sim_run["Simulated_Depth_microns"], - ) - spot_size = params.get("Spot_Size_microns") - if spot_size is None or spot_size <= 0: - print( - f"Warning: Invalid or missing 'Spot_Size_microns' in parameters {params}. Skipping this simulation set." - ) - continue - - for n, depth in zip(n_values, depths): - records.append( - { - **params, - "n": n, - "Simulated_Depth_microns": depth, - "Normalized_Simulated_Depth": depth / spot_size, - } - ) - return pd.DataFrame(records) + for d in self.data + ] + # TODO: Consider using the `explode(["depths", "n"])` feature to get a single + # row per datum. This may simplify the logic for fingerprinting and + # simulation queueing + return pl.from_dicts(dicts) + + +def create_data_fingerprint(data: ExperimentData | SimulationData) -> str: + """Create a hash from the experiment or simulation data for quick comparison of + sameness to previous datasets. + + While this approach ensures that the fields are in a consistent order, this will + return a different hash if the listed values, e.g., `depths`, change order.""" + # Format payload, ensuring consistent ordering and removing whitespace + payload = data.model_dump(mode="json", exclude={"fingerprint"}, exclude_none=True) + canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical_json.encode()).hexdigest() + + +def load_dict_file(filepath: str | pathlib.Path) -> dict: + """Loads a dictionary from a JSON or YAML file""" + # Check if the file exists + if not os.path.exists(filepath): + print(f"Info: File not found at '{filepath}'. Proceeding with empty data.") + return {} + with open(filepath, "r") as f: + suffix = pathlib.Path(filepath).suffix + if suffix in [".yml", ".yaml"]: + return yaml.safe_load(f) + elif suffix in [".json"]: + return json.load(f) + return {} + + +# def parse_experiments(raw_experiments: list) -> pd.DataFrame: +# if not raw_experiments: +# return pd.DataFrame( +# columns=[ +# "parameters", +# "depths_list", +# "normalized_depths_list", +# "fingerprint", +# ] +# ) +# records = [] +# for exp in raw_experiments: +# params = exp["parameters"] +# spot_size = params.get("Spot_Size_microns") +# if spot_size is None or spot_size <= 0: +# print( +# f"Warning: Invalid or missing 'Spot_Size_microns' in parameters {params}. Skipping this experiment." +# ) +# continue + +# depths = exp["Measured_Depth_microns"] +# normalized_depths = [d / spot_size for d in depths] + +# records.append( +# { +# "parameters": params, +# "depths_list": depths, +# "normalized_depths_list": normalized_depths, +# "fingerprint": create_data_fingerprint(depths), +# } +# ) +# return pd.DataFrame(records) + + +# def parse_simulations(raw_simulations: list) -> pd.DataFrame: +# if not raw_simulations: +# return pd.DataFrame() +# records = [] +# for sim_run in raw_simulations: +# params, n_values, depths = ( +# sim_run["parameters"], +# sim_run["n"], +# sim_run["Simulated_Depth_microns"], +# ) +# spot_size = params.get("Spot_Size_microns") +# if spot_size is None or spot_size <= 0: +# print( +# f"Warning: Invalid or missing 'Spot_Size_microns' in parameters {params}. Skipping this simulation set." +# ) +# continue + +# for n, depth in zip(n_values, depths): +# records.append( +# { +# **params, +# "n": n, +# "Simulated_Depth_microns": depth, +# "Normalized_Simulated_Depth": depth / spot_size, +# } +# ) +# return pd.DataFrame(records) def save_state_file(state_data: list, filepath: str): @@ -370,34 +508,37 @@ def fit_and_plot_heteroskedastic_model(calibrated_results_df): ) +######################################################################################## +# # +################### Myna Class for AdditiveFOAM Calibration ############################ +# # +######################################################################################## + + class AdditiveFOAMCalibration(AdditiveFOAM): """Application to generated calibrated heat source parameters for AdditiveFOAM""" def execute(self): # Set paths - EXPERIMENTS_PATH, SIMULATIONS_PATH, STATE_PATH = ( - "experiments.yml", - "simulations.yml", - "calibration_state.yml", - ) + EXPERIMENTS_PATH = "experiments.yml" + SIMULATIONS_PATH = "simulations.yml" + STATE_PATH = "calibration_state.yml" SIMULATION_QUEUE_PATH = "pending_simulations.csv" - # Load data from YAML dictionaries - raw_experiments, raw_simulations, current_state_data = ( - load_yaml_file(EXPERIMENTS_PATH), - load_yaml_file(SIMULATIONS_PATH), - load_yaml_file(STATE_PATH), - ) + # Load and validate data models from dictionaries + experiments = ExperimentData(**load_dict_file(EXPERIMENTS_PATH)) + simulations = SimulationData(**load_dict_file(SIMULATIONS_PATH)) - # Check for empty files - # - if experiments aren't there -> exit - # - if state files or simulations aren't there -> - if ( - raw_experiments is None - or raw_simulations is None - or current_state_data is None - ): - print("\nAborting due to file parsing errors."), exit() + # TODO: delete below old code (3 lines) + raw_experiments = load_dict_file(EXPERIMENTS_PATH) + raw_simulations = load_dict_file(SIMULATIONS_PATH) + current_state_data = load_dict_file(STATE_PATH) + + # Load data to dataframe + df_exp = experiments.to_polars_df() + df_sim = simulations.to_polars_df() + + # TODO: repalce `exp_df` and `sim_df` with newer `df_exp` and `df_sim` implementations exp_df, sim_df, state_df = ( parse_experiments(raw_experiments), parse_simulations(raw_simulations), @@ -405,6 +546,7 @@ def execute(self): ) if exp_df.empty: print("\nNo experimental data found. Exiting."), exit() + to_process_list, fresh_states, state_lookup = [], [], {} if not state_df.empty: for i, row in state_df.iterrows(): 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..948fd2ba --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/configure.py @@ -0,0 +1,8 @@ +# +# 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. +# 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..948fd2ba --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/execute.py @@ -0,0 +1,8 @@ +# +# 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. +# diff --git a/src/myna/application/additivefoam/single_track_calibration/test.py b/src/myna/application/additivefoam/single_track_calibration/test.py new file mode 100644 index 00000000..63900317 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/test.py @@ -0,0 +1,29 @@ +# +# 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. +# +from myna.application.additivefoam.single_track_calibration.app import ( + ExperimentData, + SimulationData, + ProcessParameters, + load_dict_file, +) + + +def test_experiment_data(): + data = ExperimentData(**load_dict_file("test_exp.yaml")) + print(data) + + +def test_simulation_data(): + data = SimulationData(**load_dict_file("test_sim.yaml")) + print(data.to_polars_df()) + + +if __name__ == "__main__": + print("SIMULATION DATA:") + test_simulation_data() diff --git a/src/myna/application/additivefoam/single_track_calibration/test_exp.yaml b/src/myna/application/additivefoam/single_track_calibration/test_exp.yaml new file mode 100644 index 00000000..2ea0e2d7 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/test_exp.yaml @@ -0,0 +1,34 @@ +## +## 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. +## +data: + - process_parameters: + power: 187.5 + scan_speed: 0.5 + spot_size: 0.1 + depths: [91.0e-3] + - process_parameters: + power: 300.0 + scan_speed: 0.5 + spot_size: 0.1 + depths: [178.0e-3] + - process_parameters: + power: 412.5 + scan_speed: 0.5 + spot_size: 0.1 + depths: [266.0e-3] + - process_parameters: + power: 525.0 + scan_speed: 0.5 + spot_size: 0.1 + depths: [354.0e-3] + - process_parameters: + power: 637.5 + scan_speed: 0.5 + spot_size: 0.1 + depths: [464.0e-3, 480.0e-3] diff --git a/src/myna/application/additivefoam/single_track_calibration/test_sim.yaml b/src/myna/application/additivefoam/single_track_calibration/test_sim.yaml new file mode 100644 index 00000000..1b932e71 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/test_sim.yaml @@ -0,0 +1,89 @@ +## +## 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. +## +data: + - process_parameters: + power: 187.5 + scan_speed: 0.5 + spot_size: 0.1 + simulation_parameters: + n: [1,2,3,4,5,6,7,8,9] + depths: + - 103.0e-3 + - 111.0e-3 + - 124.0e-3 + - 144.0e-3 + - 165.0e-3 + - 184.0e-3 + - 184.0e-3 + - 184.0e-3 + - 184.0e-3 + - process_parameters: + power: 300.0 + scan_speed: 0.5 + spot_size: 0.1 + simulation_parameters: + n: [1,2,3,4,5,6,7,8,9] + depths: + - 141.0e-3 + - 154.0e-3 + - 179.0e-3 + - 214.0e-3 + - 254.0e-3 + - 294.0e-3 + - 315.0e-3 + - 324.0e-3 + - 324.0e-3 + - process_parameters: + power: 412.5 + scan_speed: 0.5 + spot_size: 0.1 + simulation_parameters: + n: [1,2,3,4,5,6,7,8,9] + depths: + - 170.0e-3 + - 189.0e-3 + - 223.0e-3 + - 274.0e-3 + - 334.0e-3 + - 394.0e-3 + - 444.0e-3 + - 465.0e-3 + - 465.0e-3 + - process_parameters: + power: 525.0 + scan_speed: 0.5 + spot_size: 0.1 + simulation_parameters: + n: [1,2,3,4,5,6,7,8,9] + depths: + - 194.0e-3 + - 219.0e-3 + - 262.0e-3 + - 324.0e-3 + - 404.0e-3 + - 484.0e-3 + - 563.0e-3 + - 604.0e-3 + - 614.0e-3 + - process_parameters: + power: 637.5 + scan_speed: 0.5 + spot_size: 0.1 + simulation_parameters: + n: [1,2,3,4,5,6,7,8,9] + depths: + - 218.0e-3 + - 245.0e-3 + - 295.0e-3 + - 370.0e-3 + - 464.0e-3 + - 565.0e-3 + - 666.0e-3 + - 710.0e-3 + - 755.0e-3 From 6bb030db2fa71c1f0a6ad542b25d59a821dfd581 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Wed, 31 Dec 2025 12:45:41 -0500 Subject: [PATCH 03/29] remove default additivefoam executable validation --- src/myna/application/additivefoam/additivefoam.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/myna/application/additivefoam/additivefoam.py b/src/myna/application/additivefoam/additivefoam.py index 7d7a0228..0b30d1ff 100644 --- a/src/myna/application/additivefoam/additivefoam.py +++ b/src/myna/application/additivefoam/additivefoam.py @@ -29,9 +29,6 @@ def __init__(self): # Parse app-specific arguments self.parse_known_args() - super().validate_executable( - "additiveFoam", - ) if self.args.exec is None: self.args.exec = "additiveFoam" From 2c55f7db9674a8497a19538ffa4f3cd4226590ab Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Wed, 31 Dec 2025 12:46:00 -0500 Subject: [PATCH 04/29] draft: refactor of app structure --- .../single_track_calibration/app.py | 1042 ++++++++++------- 1 file changed, 598 insertions(+), 444 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index ab48d9bc..34a6f751 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -9,48 +9,48 @@ import os import json import yaml +import shutil import hashlib import pathlib -from typing import Optional, Annotated -import pandas as pd +import logging +from typing import Optional, Annotated, Any +from dataclasses import dataclass, asdict import polars as pl import numpy as np import pymc as pm import arviz as az import pytensor.tensor as pt -import matplotlib.pyplot as plt -import seaborn as sns from pydantic import BaseModel, model_validator, model_serializer, BeforeValidator - from myna.application.additivefoam import AdditiveFOAM -# Define data models and validation: -# - Experimental data model is the primary mode, with simulation data derived as a -# subclass. This is intended to maintain that simulations are representations of the -# experiments, so a subset of the simulation data model should directly correspond -# to the experimental data -# - Before-validation gives some flexibility to the user, e.g., will not fail if user enters -# a single value instead of a single-valued list -# - After-validation enforces expected data structure for the rest of the application +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) -def _ensure_float_list(value): - """BeforeValidator function to ensure that the value is a list[float]""" - if isinstance(value, (int, float)): - return [float(value)] - return value +# ============================================================================== +# SECTION 1: DATA MODELS AND VALIDATION +# ============================================================================== -def _ensure_str_list(value): - """BeforeValidator function to ensure that the value is a list[str]""" - if isinstance(value, (str)): - return [str(value)] - return value +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[float], BeforeValidator(_ensure_float_list)] -FlexibleStringList = Annotated[list[float], BeforeValidator(_ensure_str_list)] + +FlexibleFloatList = Annotated[ + list[Optional[float]], BeforeValidator(_ensure_float_list) +] class ProcessParameters(BaseModel): @@ -72,10 +72,7 @@ class SingleTrackData(BaseModel): process_parameters: ProcessParameters depths: FlexibleFloatList # in millimeters - comments: Optional[FlexibleStringList] = None - # Serialize floats to 6 digits (0.001 micron precision for millimeters values) - # to ensure for stable hashing @model_serializer(mode="wrap") def round_floats_serializer(self, handler): """Recursively rounds floats to 6 decimal places for stable hashing/JSON.""" @@ -96,14 +93,23 @@ 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): - if len(self.depths) != len(self.simulation_parameters.n): - raise ValueError( - "depths and simulation_parameters.n must have the same length" - ) + """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 @@ -111,7 +117,6 @@ class ExperimentData(BaseModel): """Defines the format of the experimental data file""" data: list[SingleTrackData] - comments: Optional[FlexibleStringList] = None def to_polars_df(self) -> pl.DataFrame: """Converts the data model to a polars DataFrame""" @@ -137,30 +142,78 @@ def to_polars_df(self) -> pl.DataFrame: } for d in self.data ] - # TODO: Consider using the `explode(["depths", "n"])` feature to get a single - # row per datum. This may simplify the logic for fingerprinting and - # simulation queueing - return pl.from_dicts(dicts) + kwargs = {} + if len(dicts) == 0: + kwargs = { + "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 to get one row per (n, depth) combination + return pl.from_dicts(dicts, **kwargs).explode(["depths", "n"]) + + 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()) + sim_keys = list(SimulationParameters.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) + ] -def create_data_fingerprint(data: ExperimentData | SimulationData) -> str: - """Create a hash from the experiment or simulation data for quick comparison of - sameness to previous datasets. - While this approach ensures that the fields are in a consistent order, this will - return a different hash if the listed values, e.g., `depths`, change order.""" - # Format payload, ensuring consistent ordering and removing whitespace +@dataclass +class CalibrationConfig: + """Configuration for the calibration workflow""" + + experiments_path: str = "test_exp.yaml" + simulations_path: str = "test_sim.yaml" + calibrations_path: str = "calibration.yaml" + simulation_output_dir: str = "sim_output" + n_values: 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] + + +def create_data_fingerprint(data: ExperimentData | SimulationData) -> str: + """Create a hash from the experiment or simulation data for quick comparison""" payload = data.model_dump(mode="json", exclude={"fingerprint"}, exclude_none=True) canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical_json.encode()).hexdigest() +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) for k in ProcessParameters.model_fields.keys()} + canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical_json.encode()).hexdigest() + + def load_dict_file(filepath: str | pathlib.Path) -> dict: """Loads a dictionary from a JSON or YAML file""" - # Check if the file exists - if not os.path.exists(filepath): - print(f"Info: File not found at '{filepath}'. Proceeding with empty data.") - return {} with open(filepath, "r") as f: suffix = pathlib.Path(filepath).suffix if suffix in [".yml", ".yaml"]: @@ -170,98 +223,13 @@ def load_dict_file(filepath: str | pathlib.Path) -> dict: return {} -# def parse_experiments(raw_experiments: list) -> pd.DataFrame: -# if not raw_experiments: -# return pd.DataFrame( -# columns=[ -# "parameters", -# "depths_list", -# "normalized_depths_list", -# "fingerprint", -# ] -# ) -# records = [] -# for exp in raw_experiments: -# params = exp["parameters"] -# spot_size = params.get("Spot_Size_microns") -# if spot_size is None or spot_size <= 0: -# print( -# f"Warning: Invalid or missing 'Spot_Size_microns' in parameters {params}. Skipping this experiment." -# ) -# continue - -# depths = exp["Measured_Depth_microns"] -# normalized_depths = [d / spot_size for d in depths] - -# records.append( -# { -# "parameters": params, -# "depths_list": depths, -# "normalized_depths_list": normalized_depths, -# "fingerprint": create_data_fingerprint(depths), -# } -# ) -# return pd.DataFrame(records) - - -# def parse_simulations(raw_simulations: list) -> pd.DataFrame: -# if not raw_simulations: -# return pd.DataFrame() -# records = [] -# for sim_run in raw_simulations: -# params, n_values, depths = ( -# sim_run["parameters"], -# sim_run["n"], -# sim_run["Simulated_Depth_microns"], -# ) -# spot_size = params.get("Spot_Size_microns") -# if spot_size is None or spot_size <= 0: -# print( -# f"Warning: Invalid or missing 'Spot_Size_microns' in parameters {params}. Skipping this simulation set." -# ) -# continue - -# for n, depth in zip(n_values, depths): -# records.append( -# { -# **params, -# "n": n, -# "Simulated_Depth_microns": depth, -# "Normalized_Simulated_Depth": depth / spot_size, -# } -# ) -# return pd.DataFrame(records) - - -def save_state_file(state_data: list, filepath: str): - print(f"\nSaving updated state to '{filepath}'...") - try: - with open(filepath, "w") as f: - yaml.dump( - state_data, - f, - default_flow_style=False, - sort_keys=False, - indent=2, - ) - print("Save complete.") - except Exception as e: - print(f"Error saving state file '{filepath}': {e}") - - -def save_simulation_queue(queue_df: pd.DataFrame, filepath: str): - print(f"Saving pending simulation queue to '{filepath}'...") - try: - queue_df.to_csv(filepath, index=False) - print("Queue saved successfully.") - except Exception as e: - print(f"Error saving simulation queue file '{filepath}': {e}") - - # ============================================================================== -# SECTION 2: CORE CALIBRATION LOGIC +# SECTION 2: BAYESIAN CALIBRATION LOGIC # ============================================================================== + + 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"), @@ -276,30 +244,41 @@ def linear_interp_pt(n_val, n_data_pt, column_data_pt): def perform_bayesian_calibration( - n_coords: np.ndarray, model_values: np.ndarray, observed_values: list[float] + n_coords: list[float | int], + model_values: list[float | int], + observed_values: list[list[float | int]], ) -> az.InferenceData: - sigma_est = ( - np.std(observed_values) - if len(observed_values) > 1 - else 0.15 * observed_values[0] + """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 to handle both lists and Polars Series + 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 + ] ) - sigma_est = max( - sigma_est, 1e-4 - ) # Adjusted minimum sigma for smaller normalized values - with pm.Model() as model: - n = pm.Uniform("n", lower=n_coords.min(), upper=n_coords.max()) + + 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_value = pm.Deterministic( + predicted_values = pm.Deterministic( "predicted_value", linear_interp_pt(n, n_data_pt, model_values_pt) ) pm.Normal( "likelihood", - mu=predicted_value, - sigma=sigma_est, + mu=predicted_values, + sigma=sigmas, observed=observed_values, ) - print( - f" Sampling posterior with {len(observed_values)} observations (σ_est={sigma_est:.3f})..." + logger.info( + f"Sampling posterior with {len(observed_values)} observations " + f"(σ_est={sigmas})" ) trace = pm.sample( draws=2000, @@ -313,349 +292,524 @@ def perform_bayesian_calibration( 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() 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: - print( - f" Warning: Posterior is clipped at lower bound ({clipping_percentage:.1f}%). Using n_min." + 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 # ============================================================================== -# SECTION 3: PLOTTING AND ANALYSIS FUNCTIONS +# SECTION 3: MAIN CALIBRATION APPLICATION # ============================================================================== -def plot_calibration_overview(results_df, simulations_df): - if results_df.empty: - return - num_plots = len(results_df) - fig, axes = plt.subplots( - 1, num_plots, figsize=(6 * num_plots, 5), sharey=True, squeeze=False - ) - axes = axes.flatten() - for i, (_, row) in enumerate(results_df.iterrows()): - params_dict = row["parameters"] - ax = axes[i] - query_string = " & ".join([f"`{k}`=={v}" for k, v in params_dict.items()]) - sim_curve = simulations_df.query(query_string).sort_values("n") - - # Plot normalized simulation curve - ax.plot( - sim_curve["n"], - sim_curve["Normalized_Simulated_Depth"], - "k-", - label="Simulation Curve", - zorder=1, + + +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, + name: str = "single_track_calibration", + config: Optional[CalibrationConfig] = None, + ): + super().__init__(name) + self.config = config or CalibrationConfig() + self.config_file = "config.yaml" + self.logger = logging.getLogger(f"{__name__}.{name}") + + def configure(self): + """Configure all cases""" + cases = ["./test_dir"] + cases = [pathlib.Path(case) for case in cases] + for case in cases: + os.makedirs(case, exist_ok=True) + 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) + + # These will be argparse arguments, hardcoded for testing + experiments_path = "experiments.yaml" + simulations_path = f"{case_dir}/simulations.yaml" + calibrations_path = f"{case_dir}/calibrations.yaml" + simulation_output_dir = f"{case_dir}/sim_output" + n_values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + + # Create configuration object to ensure it is valid + config = CalibrationConfig( + experiments_path=experiments_path, + simulations_path=simulations_path, + calibrations_path=calibrations_path, + simulation_output_dir=simulation_output_dir, + n_values=n_values, ) - # Plot calibrated point against mean normalized experimental depth - ax.errorbar( - x=row["calibrated_n"], - y=row["mean_normalized_depth"], - xerr=row["calibrated_n_std"], - fmt="o", - color="red", - capsize=5, - markersize=8, - label="Calibrated n (Mean & Std Dev)", - zorder=3, + # 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""" + cases = ["./test_dir"] + for case in cases: + config_path = pathlib.Path(case) / self.config_file + config = CalibrationConfig(**load_dict_file(config_path)) + 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 + 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.calibrations_path}") + self.logger.info("=" * 80) + + 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_dict_file(self.config.experiments_path) + 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_dict_file(self.config.simulations_path) + 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 + try: + calibrations = load_dict_file(self.config.calibrations_path) + self.logger.info( + f"Loaded existing calibrations from {self.config.calibrations_path}" + ) + except FileNotFoundError: + self.logger.info("No existing calibrations found. Will create new file.") + 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" ) - # Plot individual normalized experimental data points - ax.scatter( - np.full(len(row["normalized_depths_list"]), row["calibrated_n"]), - row["normalized_depths_list"], - edgecolor="blue", - facecolor="none", - s=50, - label="Experimental Data", - zorder=2, + 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()) + 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") ) - title = ", ".join([f"{k.split('_')[0]}={v}" for k, v in params_dict.items()]) - ax.set_title(title), ax.set_xlabel("Shape Factor (n)"), ax.grid( - True, linestyle="--", alpha=0.6 + 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() + + # Calculate what the fingerprint SHOULD be based on current process params + 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") ) - axes[0].set_ylabel("Normalized Melt Pool Depth (Depth / Spot Size)"), axes[ - 0 - ].legend() - fig.suptitle( - "Calibration Overview of Newly Processed Experiments", - fontsize=16, - y=1.02, - ), plt.tight_layout(), plt.show() - - -def plot_posterior_distributions(traces_dict): - if not traces_dict: - return - plt.figure(figsize=(10, 6)) - colors = plt.cm.viridis(np.linspace(0, 1, len(traces_dict))) - for i, (param_key, trace) in enumerate(traces_dict.items()): - params_dict = dict(eval(param_key)) - label = ", ".join([f"{k.split('_')[0]}={v}" for k, v in params_dict.items()]) - sns.histplot( - trace.posterior["n"].values.flatten(), - label=label, - color=colors[i], - kde=True, - alpha=0.5, - stat="probability", + # Check for fingerprint mismatches + mismatches = df.filter( + pl.col("fingerprint").is_not_null() + & (pl.col("fingerprint") != pl.col("current_fingerprint")) ) - plt.title("Posterior Distributions for Newly Calibrated 'n'"), plt.xlabel( - "Calibrated n Value" - ), plt.ylabel("Density") - plt.legend( - title="Process Parameters", bbox_to_anchor=(1.05, 1), loc="upper left" - ), plt.grid(True, linestyle="--", alpha=0.6), plt.tight_layout(), plt.show() + 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 fit_and_plot_heteroskedastic_model(calibrated_results_df): - """ - Performs a robust heteroskedastic Bayesian regression to model both the mean - and the standard deviation of n as a function of NORMALIZED depth. - """ - if calibrated_results_df.empty or len(calibrated_results_df) < 2: - print("Info: Not enough newly calibrated points to fit a final relationship.") - return - - normalized_depths_obs = calibrated_results_df["mean_normalized_depth"].values - n_obs = calibrated_results_df["calibrated_n"].values - n_stds_obs = calibrated_results_df["calibrated_n_std"].values - - with pm.Model() as hetero_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 + 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 × " + f"{len(self.config.n_values)} n-values = {len(required_sims)} simulations" ) - 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) + return required_sims + + def _identify_valid_simulations(self, df_sim: pl.DataFrame) -> pl.DataFrame: + """Filter simulations to only those that are valid - print("\n--- Performing Robust Heteroskedastic Fit ---") - hetero_trace = pm.sample( - 2000, tune=1500, cores=4, random_seed=42, target_accept=0.95 + 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()) ) - print("\n--- Heteroskedastic Fit Summary ---") - print(az.summary(hetero_trace, var_names=["A", "B", "C", "D", "nu"])) - - # --- Plotting the Results --- - fig, ax = plt.subplots(figsize=(10, 6)) - - post = hetero_trace.posterior - A_m, B_m = post["A"].mean().item(), post["B"].mean().item() - C_m, D_m = post["C"].mean().item(), post["D"].mean().item() - - # Plot the observed data points with their Stage 1 uncertainty - ax.errorbar( - normalized_depths_obs, - n_obs, - yerr=n_stds_obs, - fmt="o", - color="C0", - ecolor="C0", - capsize=3, - label="Calibrated n (from Stage 1)", - ) + invalid_count = len(df_sim) - len(valid) + if invalid_count > 0: + self.logger.debug(f"Filtered out {invalid_count} invalid simulations") - # Calculate and plot the model's prediction for the mean - x_range = np.linspace( - normalized_depths_obs.min() * 0.9, - normalized_depths_obs.max() * 1.1, - 100, - ) - mean_pred = A_m * np.log2(x_range) + B_m - ax.plot(x_range, mean_pred, color="C1", lw=2, label=f"Mean Model: n(d_norm)") - - # Calculate and plot the model's prediction for the uncertainty (±2 sigma) - sigma_pred = np.exp(C_m * np.log2(x_range) + D_m) - ax.fill_between( - x_range, - mean_pred - 2.0 * 0.1 * mean_pred, # sigma_pred, - mean_pred + 2.0 * 0.1 * mean_pred, # sigma_pred, - color="C1", - alpha=0.3, - label=f"Uncertainty Model: ±2σ(d_norm)", - ) + return valid - ax.set_title("Heteroskedastic Fit of 'n' vs. Mean Normalized Experimental Depth") - ax.set_xlabel( - "Mean Normalized Experimental Depth (Depth / Spot Size)" - ), ax.set_ylabel("Calibrated Shape Factor (n)") - ax.legend(), ax.grid(True, linestyle="--", alpha=0.6), plt.tight_layout() - ax.set_xscale("log", base=2) - plt.show() - - print("\n--- Final Predictive Equations (Using Normalized Depth d_norm) ---") - print( - f"To predict the mean n: n_mean(d_norm) = {A_m:.4f} * log2(d_norm) + {B_m:.4f}" - ) - print( - f"To predict the uncertainty (σ): σ_n(d_norm) = exp({C_m:.4f} * log2(d_norm) + {D_m:.4f})" - ) + 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 _run_single_simulation(row) -> float: + """Run a single simulation + TODO: Replace with actual AdditiveFOAM simulation call + """ + # Extract parameters for logging + power = row["power"] + speed = row["scan_speed"] + spot = row["spot_size"] + n = row["n"] -######################################################################################## -# # -################### Myna Class for AdditiveFOAM Calibration ############################ -# # -######################################################################################## + self.logger.debug( + f"Running simulation: P={power}W, v={speed}m/s, " f"d={spot}mm, n={n}" + ) + # Placeholder - replace with actual simulation + result = np.random.rand() * 0.5 + 0.1 # Random depth between 0.1-0.6 mm -class AdditiveFOAMCalibration(AdditiveFOAM): - """Application to generated calibrated heat source parameters for AdditiveFOAM""" + self.logger.debug(f" Result: depth={result:.4f}mm") + return result - def execute(self): - # Set paths - EXPERIMENTS_PATH = "experiments.yml" - SIMULATIONS_PATH = "simulations.yml" - STATE_PATH = "calibration_state.yml" - SIMULATION_QUEUE_PATH = "pending_simulations.csv" - - # Load and validate data models from dictionaries - experiments = ExperimentData(**load_dict_file(EXPERIMENTS_PATH)) - simulations = SimulationData(**load_dict_file(SIMULATIONS_PATH)) - - # TODO: delete below old code (3 lines) - raw_experiments = load_dict_file(EXPERIMENTS_PATH) - raw_simulations = load_dict_file(SIMULATIONS_PATH) - current_state_data = load_dict_file(STATE_PATH) - - # Load data to dataframe - df_exp = experiments.to_polars_df() - df_sim = simulations.to_polars_df() - - # TODO: repalce `exp_df` and `sim_df` with newer `df_exp` and `df_sim` implementations - exp_df, sim_df, state_df = ( - parse_experiments(raw_experiments), - parse_simulations(raw_simulations), - pd.DataFrame(current_state_data), + # Run simulations with progress tracking + self.logger.info("Executing simulations...") + results = sim_queue.with_columns( + pl.struct(pl.all()) + .map_elements(_run_single_simulation, return_dtype=pl.Float64) + .alias("depths") ) - if exp_df.empty: - print("\nNo experimental data found. Exiting."), exit() - - to_process_list, fresh_states, state_lookup = [], [], {} - if not state_df.empty: - for i, row in state_df.iterrows(): - state_lookup[str(sorted(row["parameters"].items()))] = row.to_dict() - for _, exp_row in exp_df.iterrows(): - param_key = str(sorted(exp_row["parameters"].items())) - if ( - param_key in state_lookup - and state_lookup[param_key].get("fingerprint") == exp_row["fingerprint"] - ): - fresh_states.append(state_lookup[param_key]) - else: - to_process_list.append(exp_row) - to_process_df = pd.DataFrame(to_process_list) - print("\n--- Adaptive Run Summary ---"), print( - f"Found {len(exp_df)} total experimental parameter sets." + + # Mark these simulations as validated + results = results.with_columns( + pl.col("fingerprint").alias("current_fingerprint") ) - print(f"Found {len(fresh_states)} up-to-date calibrations."), print( - f"Found {len(to_process_df)} new or stale experiments to process." + + 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" ) - newly_calibrated_states, needs_simulation_list, posterior_traces = ( - [], - [], - {}, + + # 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" ) - newly_calibrated_results_for_plotting = [] - if not to_process_df.empty and sim_df.empty: - print( - "\nWarning: Experiments need processing, but simulation data is empty. Flagging all as 'needs simulation'." + + return updated_sims + + def _perform_calibration( + self, experiments: ExperimentData, simulations: SimulationData + ) -> list[dict]: + """Perform Bayesian calibration for each experiment""" + 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]}..." ) - needs_simulation_list = to_process_df["parameters"].tolist() - elif not to_process_df.empty: - for _, job_row in to_process_df.iterrows(): - params = job_row["parameters"] - print(f"\n--- Processing parameters: {params} ---") - query_string = " & ".join([f"`{k}`=={v}" for k, v in params.items()]) - model_subset = sim_df.query(query_string) - if model_subset.empty: - print( - " FLAGGED: No matching simulation data found." - ), needs_simulation_list.append(params) + + 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 - print(" Found matching simulation data. Proceeding with calibration.") - model_subset_sorted = model_subset.sort_values(by="n") - n_coords = model_subset_sorted["n"].values - model_normalized_depths = model_subset_sorted[ - "Normalized_Simulated_Depth" - ].values + 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 = perform_bayesian_calibration( - n_coords, - model_normalized_depths, - job_row["normalized_depths_list"], + n_coords=n_coords, + model_values=model_depths, + observed_values=[[d] for d in observed_depths], ) - calibrated_n_mean = extract_calibrated_n( - trace, n_min=n_coords.min(), n_max=n_coords.max() + calibrated_n = extract_calibrated_n( + trace, n_min=min(n_coords), n_max=max(n_coords) ) - calibrated_n_std = trace.posterior["n"].values.std() - new_state = { - "parameters": params, - "calibrated_n": float(calibrated_n_mean), - "calibrated_n_std": float(calibrated_n_std), - "fingerprint": job_row["fingerprint"], - } - newly_calibrated_states.append(new_state) - posterior_traces[str(sorted(params.items()))] = trace - newly_calibrated_results_for_plotting.append( + # Calculate uncertainty metrics + n_samples = trace.posterior["n"].values.flatten() + 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( { - **new_state, - "mean_normalized_depth": np.mean( - job_row["normalized_depths_list"] - ), - "normalized_depths_list": job_row["normalized_depths_list"], + "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, } ) - print( - f" -> Calibrated n: {calibrated_n_mean:.3f} ± {calibrated_n_std:.3f}" + except Exception as e: + self.logger.error( + f" ✗ Calibration failed for {fingerprint[:16]}: {str(e)}", + exc_info=True, ) - final_state = fresh_states + newly_calibrated_states - save_state_file(final_state, STATE_PATH) - final_state_df = pd.DataFrame(final_state) - print("\n\n" + "=" * 30), print(" FINAL CALIBRATION STATE "), print("=" * 30) - if not final_state_df.empty: - print( - final_state_df[ - ["parameters", "calibrated_n", "calibrated_n_std"] - ].to_string(index=False, float_format="%.4f") - ) - else: - print("No calibrated results exist in the state file.") - print("\n\n" + "=" * 35), print( - " ACTION REQUIRED: PENDING SIMULATIONS " - ), print("=" * 35) - needs_simulation_df = pd.DataFrame(needs_simulation_list) - if not needs_simulation_df.empty: - print( - f"The following parameter sets require a simulation run (saved to '{SIMULATION_QUEUE_PATH}'):" - ), print(needs_simulation_df.to_string(index=False)) - save_simulation_queue(needs_simulation_df, SIMULATION_QUEUE_PATH) - else: - print("No new simulations are required at this time.") - if newly_calibrated_results_for_plotting: - print("\nGenerating plots for newly calibrated data...") - plot_df = pd.DataFrame(newly_calibrated_results_for_plotting) - plot_calibration_overview(plot_df, sim_df) - plot_posterior_distributions(posterior_traces) - fit_and_plot_heteroskedastic_model(plot_df) - print("\nDone.") + 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.calibrations_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.calibrations_path}") + except Exception as e: + self.logger.error(f"Failed to save calibrations: {str(e)}") + raise + + +# ============================================================================== +# EXAMPLE USAGE +# ============================================================================== + +if __name__ == "__main__": + # Configure custom settings if needed + app = AdditiveFOAMCalibration() + app.configure() + app.execute() From 781dd110e7e641703a920e0aeb9378174911df18 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Wed, 31 Dec 2025 16:57:12 -0500 Subject: [PATCH 05/29] draft: fix linting --- .../single_track_calibration/app.py | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index 34a6f751..d0fe6fa8 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -142,18 +142,19 @@ def to_polars_df(self) -> pl.DataFrame: } for d in self.data ] - kwargs = {} + # Explode to get one row per (n, depth) combination if len(dicts) == 0: - kwargs = { - "schema": { + return 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 to get one row per (n, depth) combination - return pl.from_dicts(dicts, **kwargs).explode(["depths", "n"]) + }, + ).explode(["depths", "n"]) + else: + return pl.from_dicts(dicts).explode(["depths", "n"]) def update_from_df(self, df: pl.DataFrame): """Update simulation data from a DataFrame @@ -161,7 +162,6 @@ def update_from_df(self, df: pl.DataFrame): Expects df to have one row per n value, with depths as single values """ param_keys = list(ProcessParameters.model_fields.keys()) - sim_keys = list(SimulationParameters.model_fields.keys()) # Group by fingerprint to reconstruct records grouped = df.group_by("fingerprint").agg( @@ -191,7 +191,7 @@ class CalibrationConfig: simulations_path: str = "test_sim.yaml" calibrations_path: str = "calibration.yaml" simulation_output_dir: str = "sim_output" - n_values: list[float] = None + n_values: Optional[list[float]] = None def __post_init__(self): if self.n_values is None: @@ -212,15 +212,22 @@ def create_row_fingerprint(row: dict | pl.Series) -> str: return hashlib.sha256(canonical_json.encode()).hexdigest() -def load_dict_file(filepath: str | pathlib.Path) -> dict: +def load_json_yaml_file(filepath: str | pathlib.Path, enforce_type=None) -> Any: """Loads a dictionary from a JSON or YAML file""" with open(filepath, "r") as f: suffix = pathlib.Path(filepath).suffix + contents = {} if suffix in [".yml", ".yaml"]: - return yaml.safe_load(f) + contents = yaml.safe_load(f) elif suffix in [".json"]: - return json.load(f) - return {} + 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" + "{type(contents)} but are expected to be {enforce_type}" + ) + return contents # ============================================================================== @@ -293,7 +300,7 @@ def perform_bayesian_calibration( 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() + 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 @@ -377,7 +384,9 @@ def execute(self): cases = ["./test_dir"] for case in cases: config_path = pathlib.Path(case) / self.config_file - config = CalibrationConfig(**load_dict_file(config_path)) + config = CalibrationConfig( + **load_json_yaml_file(config_path, enforce_type=dict) + ) self.execute_case(config) def execute_case(self, config: CalibrationConfig): @@ -422,7 +431,9 @@ def _load_all_data(self) -> tuple[ExperimentData, SimulationData, list]: """Load and validate all input data files""" # Load experiments try: - exp_dict = load_dict_file(self.config.experiments_path) + 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 " @@ -439,7 +450,9 @@ def _load_all_data(self) -> tuple[ExperimentData, SimulationData, list]: # Load simulations try: - sim_dict = load_dict_file(self.config.simulations_path) + 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 " @@ -457,7 +470,9 @@ def _load_all_data(self) -> tuple[ExperimentData, SimulationData, list]: # Load calibrations try: - calibrations = load_dict_file(self.config.calibrations_path) + calibrations = list( + load_json_yaml_file(self.config.calibrations_path, enforce_type=list) + ) self.logger.info( f"Loaded existing calibrations from {self.config.calibrations_path}" ) @@ -576,8 +591,9 @@ def _create_required_simulation_matrix(self, df_exp: pl.DataFrame) -> pl.DataFra required_sims = unique_experiments.join(n_df, how="cross") self.logger.debug( - f"Required simulation matrix: {len(unique_experiments)} experiments × " - f"{len(self.config.n_values)} n-values = {len(required_sims)} simulations" + 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 @@ -729,7 +745,7 @@ def _perform_calibration( ) # Calculate uncertainty metrics - n_samples = trace.posterior["n"].values.flatten() + 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) From e8be7b6e99107208ac48e858be8ffb8c91bdc025 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Wed, 31 Dec 2025 17:44:56 -0500 Subject: [PATCH 06/29] draft: split app and models to separate files --- .../single_track_calibration/app.py | 174 +--------------- .../single_track_calibration/models.py | 187 ++++++++++++++++++ 2 files changed, 190 insertions(+), 171 deletions(-) create mode 100644 src/myna/application/additivefoam/single_track_calibration/models.py diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index d0fe6fa8..dde1dd3f 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -13,16 +13,15 @@ import hashlib import pathlib import logging -from typing import Optional, Annotated, Any -from dataclasses import dataclass, asdict +from typing import Optional, Any +from dataclasses import asdict import polars as pl import numpy as np import pymc as pm import arviz as az import pytensor.tensor as pt -from pydantic import BaseModel, model_validator, model_serializer, BeforeValidator from myna.application.additivefoam import AdditiveFOAM - +from .models import ExperimentData, SimulationData, ProcessParameters, CalibrationConfig # Configure logging logging.basicConfig( @@ -31,173 +30,6 @@ logger = logging.getLogger(__name__) -# ============================================================================== -# SECTION 1: DATA MODELS AND VALIDATION -# ============================================================================== - - -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 - - -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""" - 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: - return 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: - return pl.from_dicts(dicts).explode(["depths", "n"]) - - 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" - calibrations_path: str = "calibration.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] - - def create_data_fingerprint(data: ExperimentData | SimulationData) -> str: """Create a hash from the experiment or simulation data for quick comparison""" payload = data.model_dump(mode="json", exclude={"fingerprint"}, exclude_none=True) 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..9c2d26c0 --- /dev/null +++ b/src/myna/application/additivefoam/single_track_calibration/models.py @@ -0,0 +1,187 @@ +# +# 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 +from typing import Optional, Annotated, Any +from dataclasses import dataclass +import polars as pl +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" +) +logger = logging.getLogger(__name__) + + +# ============================================================================== +# SECTION 1: DATA MODELS AND VALIDATION +# ============================================================================== + + +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 + + +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""" + 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: + return 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: + return pl.from_dicts(dicts).explode(["depths", "n"]) + + 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" + calibrations_path: str = "calibration.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] From dbd976ca516837b85ae7fea98bfd46b958893aca Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Thu, 1 Jan 2026 07:18:52 -0500 Subject: [PATCH 07/29] add configuration argparse --- .../single_track_calibration/app.py | 94 ++++++++++++------- .../single_track_calibration/models.py | 26 ++++- 2 files changed, 86 insertions(+), 34 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index dde1dd3f..052e7050 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -21,7 +21,13 @@ import arviz as az import pytensor.tensor as pt from myna.application.additivefoam import AdditiveFOAM -from .models import ExperimentData, SimulationData, ProcessParameters, CalibrationConfig +from myna.application.additivefoam.single_track_calibration.models import ( + ExperimentData, + SimulationData, + ProcessParameters, + CalibrationConfig, + create_row_fingerprint, +) # Configure logging logging.basicConfig( @@ -30,20 +36,6 @@ logger = logging.getLogger(__name__) -def create_data_fingerprint(data: ExperimentData | SimulationData) -> str: - """Create a hash from the experiment or simulation data for quick comparison""" - payload = data.model_dump(mode="json", exclude={"fingerprint"}, exclude_none=True) - canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(canonical_json.encode()).hexdigest() - - -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) for k in ProcessParameters.model_fields.keys()} - canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(canonical_json.encode()).hexdigest() - - def load_json_yaml_file(filepath: str | pathlib.Path, enforce_type=None) -> Any: """Loads a dictionary from a JSON or YAML file""" with open(filepath, "r") as f: @@ -57,16 +49,12 @@ def load_json_yaml_file(filepath: str | pathlib.Path, enforce_type=None) -> Any: if not isinstance(contents, enforce_type): raise ValueError( f"Top-level contents of {filepath} are" - "{type(contents)} but are expected to be {enforce_type}" + f"{type(contents)} but are expected to be {enforce_type}" ) return contents -# ============================================================================== -# SECTION 2: BAYESIAN CALIBRATION LOGIC -# ============================================================================== - - +# Define calibration logic 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 = ( @@ -173,10 +161,48 @@ def __init__( self.config_file = "config.yaml" self.logger = logging.getLogger(f"{__name__}.{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( + "--calibrations", + default=None, + type=str, + help=( + "(optional) Path to an existing calibrations file." + "A new file will be created if not given." + ), + ) + self.parser.add_argument( + "--nvalues", + default=[1.0, 5.0, 9.0], + type=float, + nargs="+", + help="", + ) + self.parse_known_args() + def configure(self): """Configure all cases""" + # TODO: update to inherit/determine case list from app type cases = ["./test_dir"] cases = [pathlib.Path(case) for case in cases] + self.parse_configure_arguments() + print(f"{self.args=}") for case in cases: os.makedirs(case, exist_ok=True) self.configure_case(case) @@ -187,12 +213,22 @@ def configure_case(self, case_dir: str | pathlib.Path): if not isinstance(case_dir, pathlib.Path): case_dir = pathlib.Path(case_dir) - # These will be argparse arguments, hardcoded for testing - experiments_path = "experiments.yaml" + # 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 calibrations file calibrations_path = f"{case_dir}/calibrations.yaml" + if self.args.calibrations is not None: + shutil.copy(self.args.calibrations, calibrations_path) + + # Set simulation output path simulation_output_dir = f"{case_dir}/sim_output" - n_values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + n_values = self.args.nvalues # Create configuration object to ensure it is valid config = CalibrationConfig( @@ -213,6 +249,7 @@ def configure_case(self, case_dir: str | pathlib.Path): def execute(self): """Execute all cases""" + # TODO: update to inherit/determine case list from app type cases = ["./test_dir"] for case in cases: config_path = pathlib.Path(case) / self.config_file @@ -387,14 +424,6 @@ def _prepare_simulation_df(self, simulations: SimulationData) -> pl.DataFrame: df = simulations.to_polars_df() - # Calculate what the fingerprint SHOULD be based on current process params - 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") - ) - # Check for fingerprint mismatches mismatches = df.filter( pl.col("fingerprint").is_not_null() @@ -658,6 +687,7 @@ def _save_calibrations(self, calibrations: list[dict]): if __name__ == "__main__": # Configure custom settings if needed + print(f'{"/".join(pathlib.Path(__name__).parts[-2:])=}') app = AdditiveFOAMCalibration() app.configure() app.execute() diff --git a/src/myna/application/additivefoam/single_track_calibration/models.py b/src/myna/application/additivefoam/single_track_calibration/models.py index 9c2d26c0..61426900 100644 --- a/src/myna/application/additivefoam/single_track_calibration/models.py +++ b/src/myna/application/additivefoam/single_track_calibration/models.py @@ -7,9 +7,12 @@ # 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 @@ -20,6 +23,14 @@ 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) for k in ProcessParameters.model_fields.keys()} + canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical_json.encode()).hexdigest() + + # ============================================================================== # SECTION 1: DATA MODELS AND VALIDATION # ============================================================================== @@ -122,6 +133,7 @@ class SimulationData(ExperimentData): 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(), @@ -131,9 +143,10 @@ def to_polars_df(self) -> pl.DataFrame: } for d in self.data ] + # Explode to get one row per (n, depth) combination if len(dicts) == 0: - return pl.from_dicts( + df = pl.from_dicts( dicts, schema={ **{k: pl.Float64 for k in ProcessParameters.model_fields}, @@ -143,7 +156,16 @@ def to_polars_df(self) -> pl.DataFrame: }, ).explode(["depths", "n"]) else: - return pl.from_dicts(dicts).explode(["depths", "n"]) + 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 From 7c22fc27c16c1599c12cad1b8d5ae2c33b4bea5f Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Thu, 1 Jan 2026 07:19:15 -0500 Subject: [PATCH 08/29] formatting --- .../additivefoam/single_track_calibration/models.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/models.py b/src/myna/application/additivefoam/single_track_calibration/models.py index 61426900..873b0570 100644 --- a/src/myna/application/additivefoam/single_track_calibration/models.py +++ b/src/myna/application/additivefoam/single_track_calibration/models.py @@ -31,11 +31,6 @@ def create_row_fingerprint(row: dict | pl.Series) -> str: return hashlib.sha256(canonical_json.encode()).hexdigest() -# ============================================================================== -# SECTION 1: DATA MODELS AND VALIDATION -# ============================================================================== - - def _ensure_float_list(v: Any) -> list[Any]: """Handle floats, lists of floats, and None and convert them all to a list. From f33a06b6c8f7badbd89b66cb86549c3e769b9377 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Thu, 1 Jan 2026 10:25:52 -0500 Subject: [PATCH 09/29] move json_yaml loader to core utils --- .../single_track_calibration/app.py | 23 ++----------------- src/myna/core/utils/filesystem.py | 22 ++++++++++++++++++ 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index 052e7050..e1156b0c 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -7,13 +7,11 @@ # License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. # import os -import json import yaml import shutil -import hashlib import pathlib import logging -from typing import Optional, Any +from typing import Optional from dataclasses import asdict import polars as pl import numpy as np @@ -28,6 +26,7 @@ CalibrationConfig, create_row_fingerprint, ) +from myna.core.utils.filesystem import load_json_yaml_file # Configure logging logging.basicConfig( @@ -36,24 +35,6 @@ logger = logging.getLogger(__name__) -def load_json_yaml_file(filepath: str | pathlib.Path, enforce_type=None) -> Any: - """Loads a dictionary from a JSON or YAML file""" - with open(filepath, "r") as f: - suffix = pathlib.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 - - # Define calibration logic def linear_interp_pt(n_val, n_data_pt, column_data_pt): """Linear interpolation using PyTensor tensors""" 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 From 477de4a9d74fd546039634f9ad85ff4994fe4cc4 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Thu, 1 Jan 2026 10:40:04 -0500 Subject: [PATCH 10/29] ensure that directories exist when copying template --- src/myna/core/app/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/myna/core/app/base.py b/src/myna/core/app/base.py index 45dc5ff1..3e6fda6a 100644 --- a/src/myna/core/app/base.py +++ b/src/myna/core/app/base.py @@ -299,6 +299,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}") From 43671fd3c615b08034d433b2bbdf19c505000f46 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Thu, 1 Jan 2026 11:25:59 -0500 Subject: [PATCH 11/29] add utilities to additivefoam module --- .../application/additivefoam/additivefoam.py | 65 +++++++++++++++---- src/myna/application/additivefoam/path.py | 39 ++++++++++- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/src/myna/application/additivefoam/additivefoam.py b/src/myna/application/additivefoam/additivefoam.py index 0b30d1ff..c51585af 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): @@ -55,14 +58,15 @@ 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): """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", @@ -120,7 +124,7 @@ 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: @@ -128,11 +132,12 @@ def update_beam_spot_size(self, part, case_dir): case_dir: directory that contains AdditiveFOAM case files to update """ # 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 = ( @@ -201,12 +206,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): @@ -287,3 +294,33 @@ 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: + 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..25bf3b08 100644 --- a/src/myna/application/additivefoam/path.py +++ b/src/myna/application/additivefoam/path.py @@ -7,8 +7,9 @@ # License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. # """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 +50,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") From 12fbed56da77cad9c9f84142a2ea7f520bb67328 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Thu, 1 Jan 2026 11:26:27 -0500 Subject: [PATCH 12/29] add simulation case updates and running to execute function --- .../single_track_calibration/app.py | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index e1156b0c..0c6f01f0 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -11,6 +11,7 @@ import shutil import pathlib import logging +import subprocess from typing import Optional from dataclasses import asdict import polars as pl @@ -19,6 +20,7 @@ import arviz as az import pytensor.tensor as pt 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, @@ -27,6 +29,7 @@ create_row_fingerprint, ) from myna.core.utils.filesystem import load_json_yaml_file +from myna.application.openfoam.mesh import update_parameter # Configure logging logging.basicConfig( @@ -116,11 +119,7 @@ def extract_calibrated_n(trace: az.InferenceData, n_min: float, n_max: float) -> return posterior_mean_n -# ============================================================================== -# SECTION 3: MAIN CALIBRATION APPLICATION -# ============================================================================== - - +# Define main class class AdditiveFOAMCalibration(AdditiveFOAM): """Application to generate calibrated heat source parameters for AdditiveFOAM @@ -140,6 +139,7 @@ def __init__( super().__init__(name) self.config = config or CalibrationConfig() self.config_file = "config.yaml" + self.single_track_length = 3e-3 self.logger = logging.getLogger(f"{__name__}.{name}") def parse_configure_arguments(self): @@ -469,15 +469,15 @@ def _run_simulations( self.logger.info(f"Output directory: {self.config.simulation_output_dir}") def _run_single_simulation(row) -> float: - """Run a single simulation - - TODO: Replace with actual AdditiveFOAM simulation call + """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"] + fingerprint = row["current_fingerprint"] self.logger.debug( f"Running simulation: P={power}W, v={speed}m/s, " f"d={spot}mm, n={n}" @@ -486,6 +486,35 @@ def _run_single_simulation(row) -> float: # Placeholder - replace with actual simulation result = np.random.rand() * 0.5 + 0.1 # Random depth between 0.1-0.6 mm + # Copy the template and configure the case, + # including generating the scanpath from the laser power and scan speed + case_dir = pathlib.Path(self.config.simulation_output_dir) / fingerprint / n + self.copy(case_dir) + self.update_beam_spot_size(None, case_dir, spot) + scan_file = case_dir / "constant" / "scanPath" + write_single_line_path( + scan_file, power, speed, (0, 0, 0), (self.single_track_length, 0, 0) + ) + self.update_region_start_and_end_times(case_dir, None, scan_file.name) + # TODO: What heat source do I use in the template and what does "n" correspond to? + heatsource_model = ( + subprocess.check_output( + "foamDictionary -entry beam/heatSourceModel -value " + + f"{case_dir}/constant/heatSourceDict", + shell=True, + ) + .decode("utf-8") + .strip() + ) + # update_parameter( + # f"{case_dir}/constant/heatSourceDict", + # f"beam/{heatsource_model}Coeffs/eta0", + # absorption, + # ) + # TODO: update the template to actually output the melt pool dimension + # somewhere readable using the function object + process = self.run_case(case_dir) + self.logger.debug(f" Result: depth={result:.4f}mm") return result From d5f0e21d5fa1811fcb2645db1d95a70d6f0a5111 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Mon, 22 Dec 2025 12:37:24 -0500 Subject: [PATCH 13/29] fixup: correct template dir behavior in base app --- src/myna/core/app/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/myna/core/app/base.py b/src/myna/core/app/base.py index 3e6fda6a..8c81bc0c 100644 --- a/src/myna/core/app/base.py +++ b/src/myna/core/app/base.py @@ -50,6 +50,7 @@ def __init__(self): self.input_file = os.environ.get(self.ENV_SETTINGS_FILE) self.settings = {} self.step_number = None + self.template = None if self.input_file is not None: self.settings = load_input(self.input_file) self.step_number = [ From b7304ab834fcc58c07199aff461903067368e86b Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 12:05:15 -0500 Subject: [PATCH 14/29] draft: update app --- .../application/additivefoam/additivefoam.py | 22 +- .../single_track_calibration/app.py | 390 ++++++++++++------ .../single_track_calibration/models.py | 10 +- .../solidification_region_reduced/app.py | 27 +- src/myna/core/app/base.py | 14 +- 5 files changed, 308 insertions(+), 155 deletions(-) diff --git a/src/myna/application/additivefoam/additivefoam.py b/src/myna/application/additivefoam/additivefoam.py index c51585af..90183629 100644 --- a/src/myna/application/additivefoam/additivefoam.py +++ b/src/myna/application/additivefoam/additivefoam.py @@ -32,6 +32,7 @@ def __init__(self): # Parse app-specific arguments self.parse_known_args() + print(f"{self.args.exec=}") if self.args.exec is None: self.args.exec = "additiveFoam" @@ -58,7 +59,9 @@ 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, material: str | None = None): + 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: @@ -106,6 +109,7 @@ def update_material_properties(self, case_dir, material: str | None = None): 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 @@ -130,6 +134,7 @@ def update_beam_spot_size(self, part, case_dir, spot_size: float | None = None): 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) if spot_size is None: @@ -182,7 +187,9 @@ def update_beam_spot_size(self, part, case_dir, spot_size: float | None = None): 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 @@ -190,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+") @@ -237,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:" @@ -304,6 +315,12 @@ def run_case(self, case_dir: str | Path) -> subprocess.Popen | Container: # Decompose case if parallel: + self.logger.debug("Decomposing case") + update_parameter( + "system/decomposeParDict", + "numberOfSubdomains", + self.args.np, + ) with open("decomposePar.log", "w", encoding="utf-8") as f: process = self.start_subprocess( ["decomposePar", "-force"], @@ -317,6 +334,7 @@ def run_case(self, case_dir: str | Path) -> subprocess.Popen | Container: cmd_args = [self.args.exec] if parallel: cmd_args.append("-parallel") + self.logger.debug(f"Launching case with command {cmd_args}") process = self.start_subprocess_with_mpi_args( cmd_args, stdout=f, diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index 0c6f01f0..c2c03c8d 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -14,11 +14,13 @@ 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 ( @@ -35,6 +37,7 @@ logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) +logging.getLogger().setLevel(logging.DEBUG) logger = logging.getLogger(__name__) @@ -54,54 +57,6 @@ def linear_interp_pt(n_val, n_data_pt, column_data_pt): return val_lower + (val_upper - val_lower) * (n_val - n_lower) / (n_upper - n_lower) -def perform_bayesian_calibration( - 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 to handle both lists and Polars Series - 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 - ] - ) - - 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, - ) - logger.info( - f"Sampling posterior with {len(observed_values)} observations " - f"(σ_est={sigmas})" - ) - trace = pm.sample( - draws=2000, - tune=1000, - cores=8, - progressbar=True, - target_accept=0.9, - random_seed=42, - ) - return trace - - 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 @@ -137,9 +92,10 @@ def __init__( config: Optional[CalibrationConfig] = None, ): super().__init__(name) - self.config = config or CalibrationConfig() + self.config: CalibrationConfig = config or CalibrationConfig() self.config_file = "config.yaml" - self.single_track_length = 3e-3 + self.single_track_length = 2e-3 + self.case_dir = "additivefoam_single_track_calibration" self.logger = logging.getLogger(f"{__name__}.{name}") def parse_configure_arguments(self): @@ -159,29 +115,22 @@ def parse_configure_arguments(self): "A new file will be created if not given." ), ) - self.parser.add_argument( - "--calibrations", - default=None, - type=str, - help=( - "(optional) Path to an existing calibrations file." - "A new file will be created if not given." - ), - ) self.parser.add_argument( "--nvalues", - default=[1.0, 5.0, 9.0], + default=[0.0, 2.0, 5.0, 7.0, 9.0], type=float, nargs="+", - help="", + 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 configure(self): - """Configure all cases""" - # TODO: update to inherit/determine case list from app type - cases = ["./test_dir"] - cases = [pathlib.Path(case) for case in cases] + """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.""" + cases = [pathlib.Path(str(self.input_file)).parent / self.case_dir] self.parse_configure_arguments() print(f"{self.args=}") for case in cases: @@ -203,9 +152,8 @@ def configure_case(self, case_dir: str | pathlib.Path): shutil.copy(self.args.simulations, simulations_path) # Get/create calibrations file - calibrations_path = f"{case_dir}/calibrations.yaml" - if self.args.calibrations is not None: - shutil.copy(self.args.calibrations, calibrations_path) + calibrated_n_values_path = f"{case_dir}/calibrationed_n_values.yaml" + calibrated_heatsource_path = f"{case_dir}/calibrated_heatsource.yaml" # Set simulation output path simulation_output_dir = f"{case_dir}/sim_output" @@ -215,7 +163,8 @@ def configure_case(self, case_dir: str | pathlib.Path): config = CalibrationConfig( experiments_path=experiments_path, simulations_path=simulations_path, - calibrations_path=calibrations_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, ) @@ -229,9 +178,11 @@ def configure_case(self, case_dir: str | pathlib.Path): shutil.copy(experiments_path, case_dir / pathlib.Path(experiments_path).name) def execute(self): - """Execute all cases""" - # TODO: update to inherit/determine case list from app type - cases = ["./test_dir"] + """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.""" + cases = [pathlib.Path(str(self.input_file)).parent / self.case_dir] for case in cases: config_path = pathlib.Path(case) / self.config_file config = CalibrationConfig( @@ -263,16 +214,53 @@ def execute_case(self, config: CalibrationConfig): else: self.logger.info("Step 3: No simulations needed - all up to date") - # 4. Perform Bayesian calibration + # 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.calibrations_path}") + self.logger.info( + f"Results saved to: {self.config.calibrated_n_values_path}" + ) self.logger.info("=" * 80) + # 5. TODO: 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 + ) + post = trace.posterior # type: ignore[attr-defined] arviz uses dynamic attributes + heatsource_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 @@ -319,16 +307,7 @@ def _load_all_data(self) -> tuple[ExperimentData, SimulationData, list]: raise # Load calibrations - try: - calibrations = list( - load_json_yaml_file(self.config.calibrations_path, enforce_type=list) - ) - self.logger.info( - f"Loaded existing calibrations from {self.config.calibrations_path}" - ) - except FileNotFoundError: - self.logger.info("No existing calibrations found. Will create new file.") - calibrations = [] + calibrations = [] return experiments, simulations, calibrations @@ -380,6 +359,7 @@ def _prepare_experiment_df(self, experiments: ExperimentData) -> pl.DataFrame: # 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) @@ -468,7 +448,16 @@ def _run_simulations( os.makedirs(self.config.simulation_output_dir, exist_ok=True) self.logger.info(f"Output directory: {self.config.simulation_output_dir}") - def _run_single_simulation(row) -> float: + 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 """ @@ -477,54 +466,112 @@ def _run_single_simulation(row) -> float: speed = row["scan_speed"] spot = row["spot_size"] n = row["n"] - fingerprint = row["current_fingerprint"] + material = row["material"] + fingerprint = row["fingerprint"] self.logger.debug( - f"Running simulation: P={power}W, v={speed}m/s, " f"d={spot}mm, n={n}" + 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}" ) - # Placeholder - replace with actual simulation - result = np.random.rand() * 0.5 + 0.1 # Random depth between 0.1-0.6 mm + # 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, - # including generating the scanpath from the laser power and scan speed - case_dir = pathlib.Path(self.config.simulation_output_dir) / fingerprint / n + # - 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(case_dir) - self.update_beam_spot_size(None, case_dir, spot) + 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) ) - self.update_region_start_and_end_times(case_dir, None, scan_file.name) - # TODO: What heat source do I use in the template and what does "n" correspond to? - heatsource_model = ( - subprocess.check_output( - "foamDictionary -entry beam/heatSourceModel -value " - + f"{case_dir}/constant/heatSourceDict", - shell=True, - ) - .decode("utf-8") - .strip() - ) - # update_parameter( - # f"{case_dir}/constant/heatSourceDict", - # f"beam/{heatsource_model}Coeffs/eta0", - # absorption, - # ) - # TODO: update the template to actually output the melt pool dimension - # somewhere readable using the function object - process = self.run_case(case_dir) + _, 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} )", + ) - self.logger.debug(f" Result: depth={result:.4f}mm") - return result + # Generate mesh + with subprocess.Popen( + ["blockMesh", "-case", f"{case_dir}"], stdout=subprocess.DEVNULL + ) as p: + p.wait() - # Run simulations with progress tracking + # 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 = sim_queue.with_columns( - pl.struct(pl.all()) - .map_elements(_run_single_simulation, return_dtype=pl.Float64) - .alias("depths") - ) + 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( @@ -605,7 +652,7 @@ def _perform_calibration( ) # Perform calibration - trace = perform_bayesian_calibration( + trace = self._perform_bayesian_calibration( n_coords=n_coords, model_values=model_depths, observed_values=[[d] for d in observed_depths], @@ -680,16 +727,110 @@ def _save_calibrations(self, calibrations: list[dict]): del cal_copy["n_samples"] calibrations_to_save.append(cal_copy) - with open(self.config.calibrations_path, mode="w", encoding="utf-8") as f: + 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.calibrations_path}") + 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 to handle both lists and Polars Series + 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 + ] + ) + + 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, + ) + 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: + """ + 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) < 2: + raise ValueError( + "Not enough calibrated points to fit a final relationship." + ) + + 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 USAGE @@ -697,7 +838,10 @@ def _save_calibrations(self, calibrations: list[dict]): if __name__ == "__main__": # Configure custom settings if needed - print(f'{"/".join(pathlib.Path(__name__).parts[-2:])=}') 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/models.py b/src/myna/application/additivefoam/single_track_calibration/models.py index 873b0570..26d2120d 100644 --- a/src/myna/application/additivefoam/single_track_calibration/models.py +++ b/src/myna/application/additivefoam/single_track_calibration/models.py @@ -20,13 +20,17 @@ 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) for k in ProcessParameters.model_fields.keys()} + 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() @@ -54,6 +58,7 @@ class ProcessParameters(BaseModel): 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): @@ -195,7 +200,8 @@ class CalibrationConfig: experiments_path: str = "test_exp.yaml" simulations_path: str = "test_sim.yaml" - calibrations_path: str = "calibration.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 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 8c81bc0c..c4bb30fe 100644 --- a/src/myna/core/app/base.py +++ b/src/myna/core/app/base.py @@ -127,6 +127,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, @@ -343,7 +351,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` """ @@ -429,7 +439,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: From 6f92ef883f91b91606a83ef0882527636b395287 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 12:34:52 -0500 Subject: [PATCH 15/29] clean up and add template --- .../single_track_calibration/app.py | 78 ++++++++-------- .../single_track_calibration/template/0/T | 44 +++++++++ .../single_track_calibration/template/0/U | 41 +++++++++ .../template/0/alpha.solid | 39 ++++++++ .../single_track_calibration/template/0/p_rgh | 45 ++++++++++ .../template/constant/g | 22 +++++ .../template/constant/heatSourceDict | 52 +++++++++++ .../template/constant/scanPath | 3 + .../template/constant/thermoPath | 4 + .../template/constant/transportProperties | 43 +++++++++ .../template/system/blockMeshDict | 80 +++++++++++++++++ .../template/system/controlDict | 68 ++++++++++++++ .../template/system/decomposeParDict | 22 +++++ .../template/system/fvSchemes | 50 +++++++++++ .../template/system/fvSolution | 68 ++++++++++++++ .../single_track_calibration/test.py | 29 ------ .../single_track_calibration/test_exp.yaml | 34 ------- .../single_track_calibration/test_sim.yaml | 89 ------------------- 18 files changed, 622 insertions(+), 189 deletions(-) create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/0/T create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/0/U create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/0/alpha.solid create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/0/p_rgh create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/constant/g create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/constant/heatSourceDict create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/constant/scanPath create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/constant/thermoPath create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/constant/transportProperties create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/system/blockMeshDict create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/system/controlDict create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/system/decomposeParDict create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/system/fvSchemes create mode 100644 src/myna/application/additivefoam/single_track_calibration/template/system/fvSolution delete mode 100644 src/myna/application/additivefoam/single_track_calibration/test.py delete mode 100644 src/myna/application/additivefoam/single_track_calibration/test_exp.yaml delete mode 100644 src/myna/application/additivefoam/single_track_calibration/test_sim.yaml diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index c2c03c8d..9b91d266 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -41,39 +41,6 @@ logger = logging.getLogger(__name__) -# Define calibration logic -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) - - -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: - 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 - - # Define main class class AdditiveFOAMCalibration(AdditiveFOAM): """Application to generate calibrated heat source parameters for AdditiveFOAM @@ -94,7 +61,7 @@ def __init__( super().__init__(name) self.config: CalibrationConfig = config or CalibrationConfig() self.config_file = "config.yaml" - self.single_track_length = 2e-3 + self.single_track_length = 3e-3 self.case_dir = "additivefoam_single_track_calibration" self.logger = logging.getLogger(f"{__name__}.{name}") @@ -606,6 +573,27 @@ 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: + 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) @@ -658,7 +646,7 @@ def _perform_calibration( observed_values=[[d] for d in observed_depths], ) - calibrated_n = extract_calibrated_n( + calibrated_n = _extract_calibrated_n( trace, n_min=min(n_coords), n_max=max(n_coords) ) @@ -754,7 +742,7 @@ def _perform_bayesian_calibration( - 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 to handle both lists and Polars Series + # Convert to numpy arrays observed_arrays = [np.asarray(x) for x in observed_values] sigmas = np.array( [ @@ -763,13 +751,29 @@ def _perform_bayesian_calibration( ] ) + 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) + "predicted_value", _linear_interp_pt(n, n_data_pt, model_values_pt) ) pm.Normal( "likelihood", 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/single_track_calibration/test.py b/src/myna/application/additivefoam/single_track_calibration/test.py deleted file mode 100644 index 63900317..00000000 --- a/src/myna/application/additivefoam/single_track_calibration/test.py +++ /dev/null @@ -1,29 +0,0 @@ -# -# 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. -# -from myna.application.additivefoam.single_track_calibration.app import ( - ExperimentData, - SimulationData, - ProcessParameters, - load_dict_file, -) - - -def test_experiment_data(): - data = ExperimentData(**load_dict_file("test_exp.yaml")) - print(data) - - -def test_simulation_data(): - data = SimulationData(**load_dict_file("test_sim.yaml")) - print(data.to_polars_df()) - - -if __name__ == "__main__": - print("SIMULATION DATA:") - test_simulation_data() diff --git a/src/myna/application/additivefoam/single_track_calibration/test_exp.yaml b/src/myna/application/additivefoam/single_track_calibration/test_exp.yaml deleted file mode 100644 index 2ea0e2d7..00000000 --- a/src/myna/application/additivefoam/single_track_calibration/test_exp.yaml +++ /dev/null @@ -1,34 +0,0 @@ -## -## 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. -## -data: - - process_parameters: - power: 187.5 - scan_speed: 0.5 - spot_size: 0.1 - depths: [91.0e-3] - - process_parameters: - power: 300.0 - scan_speed: 0.5 - spot_size: 0.1 - depths: [178.0e-3] - - process_parameters: - power: 412.5 - scan_speed: 0.5 - spot_size: 0.1 - depths: [266.0e-3] - - process_parameters: - power: 525.0 - scan_speed: 0.5 - spot_size: 0.1 - depths: [354.0e-3] - - process_parameters: - power: 637.5 - scan_speed: 0.5 - spot_size: 0.1 - depths: [464.0e-3, 480.0e-3] diff --git a/src/myna/application/additivefoam/single_track_calibration/test_sim.yaml b/src/myna/application/additivefoam/single_track_calibration/test_sim.yaml deleted file mode 100644 index 1b932e71..00000000 --- a/src/myna/application/additivefoam/single_track_calibration/test_sim.yaml +++ /dev/null @@ -1,89 +0,0 @@ -## -## 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. -## -data: - - process_parameters: - power: 187.5 - scan_speed: 0.5 - spot_size: 0.1 - simulation_parameters: - n: [1,2,3,4,5,6,7,8,9] - depths: - - 103.0e-3 - - 111.0e-3 - - 124.0e-3 - - 144.0e-3 - - 165.0e-3 - - 184.0e-3 - - 184.0e-3 - - 184.0e-3 - - 184.0e-3 - - process_parameters: - power: 300.0 - scan_speed: 0.5 - spot_size: 0.1 - simulation_parameters: - n: [1,2,3,4,5,6,7,8,9] - depths: - - 141.0e-3 - - 154.0e-3 - - 179.0e-3 - - 214.0e-3 - - 254.0e-3 - - 294.0e-3 - - 315.0e-3 - - 324.0e-3 - - 324.0e-3 - - process_parameters: - power: 412.5 - scan_speed: 0.5 - spot_size: 0.1 - simulation_parameters: - n: [1,2,3,4,5,6,7,8,9] - depths: - - 170.0e-3 - - 189.0e-3 - - 223.0e-3 - - 274.0e-3 - - 334.0e-3 - - 394.0e-3 - - 444.0e-3 - - 465.0e-3 - - 465.0e-3 - - process_parameters: - power: 525.0 - scan_speed: 0.5 - spot_size: 0.1 - simulation_parameters: - n: [1,2,3,4,5,6,7,8,9] - depths: - - 194.0e-3 - - 219.0e-3 - - 262.0e-3 - - 324.0e-3 - - 404.0e-3 - - 484.0e-3 - - 563.0e-3 - - 604.0e-3 - - 614.0e-3 - - process_parameters: - power: 637.5 - scan_speed: 0.5 - spot_size: 0.1 - simulation_parameters: - n: [1,2,3,4,5,6,7,8,9] - depths: - - 218.0e-3 - - 245.0e-3 - - 295.0e-3 - - 370.0e-3 - - 464.0e-3 - - 565.0e-3 - - 666.0e-3 - - 710.0e-3 - - 755.0e-3 From 1b981525288af509a63ea687d4e69c8de726893f Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 14:40:55 -0500 Subject: [PATCH 16/29] refactor component runner arg list assembly to handle lists of inputs --- src/myna/core/components/component.py | 57 ++++++++++++++------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/myna/core/components/component.py b/src/myna/core/components/component.py index a583c10e..e618437c 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 @@ -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 From 5b928a55a17ef22fef22efe478cace8849ebafc7 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 15:09:35 -0500 Subject: [PATCH 17/29] add option to have no database specification in Myna input file - Needed if an application does not require build data, like the additivefoam/single_track_calibration --- src/myna/core/components/component.py | 2 +- src/myna/core/db/database.py | 14 ++++++++++++++ src/myna/core/workflow/config.py | 11 ++++------- src/myna/database/database_types.py | 3 +++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/myna/core/components/component.py b/src/myna/core/components/component.py index e618437c..aff69160 100644 --- a/src/myna/core/components/component.py +++ b/src/myna/core/components/component.py @@ -201,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:] 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/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 From b804b158a4eb8c5c4c52531e3de3067095e6608b Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 15:10:06 -0500 Subject: [PATCH 18/29] add configure and execute to app --- .../single_track_calibration/app.py | 34 ++++++++++++------- .../single_track_calibration/configure.py | 16 +++++++++ .../single_track_calibration/execute.py | 16 +++++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index 9b91d266..17f1cb3b 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -31,6 +31,7 @@ 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 # Configure logging @@ -92,17 +93,27 @@ def parse_configure_arguments(self): ) 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.""" - cases = [pathlib.Path(str(self.input_file)).parent / self.case_dir] self.parse_configure_arguments() - print(f"{self.args=}") - for case in cases: - os.makedirs(case, exist_ok=True) - self.configure_case(case) + 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""" @@ -149,13 +160,12 @@ def execute(self): Note that this workflow step can only apply to a single case, because the calibration isn't associated with a build/part/region.""" - cases = [pathlib.Path(str(self.input_file)).parent / self.case_dir] - for case in cases: - config_path = pathlib.Path(case) / self.config_file - config = CalibrationConfig( - **load_json_yaml_file(config_path, enforce_type=dict) - ) - self.execute_case(config) + 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""" diff --git a/src/myna/application/additivefoam/single_track_calibration/configure.py b/src/myna/application/additivefoam/single_track_calibration/configure.py index 948fd2ba..41fce2ce 100644 --- a/src/myna/application/additivefoam/single_track_calibration/configure.py +++ b/src/myna/application/additivefoam/single_track_calibration/configure.py @@ -6,3 +6,19 @@ # # 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 index 948fd2ba..8bb9404a 100644 --- a/src/myna/application/additivefoam/single_track_calibration/execute.py +++ b/src/myna/application/additivefoam/single_track_calibration/execute.py @@ -6,3 +6,19 @@ # # 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() From c2da28ded02c88fc7559523f170dffed5513d4cf Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 15:38:00 -0500 Subject: [PATCH 19/29] add core classes for YAML output, single track calibration step --- .../single_track_calibration/app.py | 11 +++++++++-- src/myna/core/components/__init__.py | 2 ++ .../core/components/component_calibration.py | 19 +++++++++++++++++++ .../core/components/component_class_lookup.py | 2 +- src/myna/core/files/__init__.py | 2 ++ src/myna/core/files/file_yaml.py | 19 +++++++++++++++++++ 6 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/myna/core/components/component_calibration.py create mode 100644 src/myna/core/files/file_yaml.py diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index 17f1cb3b..d5f3cf13 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -129,9 +129,16 @@ def configure_case(self, case_dir: str | pathlib.Path): if self.args.simulations is not None: shutil.copy(self.args.simulations, simulations_path) - # Get/create calibrations file + # Get/create n-value calibrations file calibrated_n_values_path = f"{case_dir}/calibrationed_n_values.yaml" - calibrated_heatsource_path = f"{case_dir}/calibrated_heatsource.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" 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_calibration.py b/src/myna/core/components/component_calibration.py new file mode 100644 index 00000000..996d5afe --- /dev/null +++ b/src/myna/core/components/component_calibration.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. +# +"""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 63ac4c1a..07dc3982 100644 --- a/src/myna/core/components/component_class_lookup.py +++ b/src/myna/core/components/component_class_lookup.py @@ -50,7 +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.Component(), + "single_track_calibration": comp.SingleTrackCalibrationComponent(), } try: step_class = step_class_lookup[step_name] 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" From f3b9efbcb5950a85c878a3472339dd6f4514b34d Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 15:38:24 -0500 Subject: [PATCH 20/29] add example --- .../single_track_calibration/experiments.yaml | 31 +++++++++++++++++ examples/single_track_calibration/input.yaml | 15 ++++----- examples/single_track_calibration/readme.md | 33 +++++++++++++++++++ 3 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 examples/single_track_calibration/experiments.yaml create mode 100644 examples/single_track_calibration/readme.md 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 index bbdf9e3d..54381d35 100644 --- a/examples/single_track_calibration/input.yaml +++ b/examples/single_track_calibration/input.yaml @@ -1,13 +1,10 @@ steps: -- calibrate_additivefoam: +- 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: - experiment-file: /path/to/experiment.yaml - simulation-file: /path/to/simulation.yaml # optional - state-file: /path/to/state.yaml # optional -data: - build: - datatype: Peregrine - name: myna_output - path: .. + 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..13b4b913 --- /dev/null +++ b/examples/single_track_calibration/readme.md @@ -0,0 +1,33 @@ +# Example: AdditiveFOAM Single Track Calibration + +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. From 2ca970325be6a4212236e18a2525311d1a117661 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 15:51:06 -0500 Subject: [PATCH 21/29] address minor linting issues: - add app class to init - move logging into app class --- .../application/additivefoam/additivefoam.py | 2 -- .../single_track_calibration/__init__.py | 4 ++++ .../single_track_calibration/app.py | 17 +++++++---------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/myna/application/additivefoam/additivefoam.py b/src/myna/application/additivefoam/additivefoam.py index 90183629..4ee025ee 100644 --- a/src/myna/application/additivefoam/additivefoam.py +++ b/src/myna/application/additivefoam/additivefoam.py @@ -315,7 +315,6 @@ def run_case(self, case_dir: str | Path) -> subprocess.Popen | Container: # Decompose case if parallel: - self.logger.debug("Decomposing case") update_parameter( "system/decomposeParDict", "numberOfSubdomains", @@ -334,7 +333,6 @@ def run_case(self, case_dir: str | Path) -> subprocess.Popen | Container: cmd_args = [self.args.exec] if parallel: cmd_args.append("-parallel") - self.logger.debug(f"Launching case with command {cmd_args}") process = self.start_subprocess_with_mpi_args( cmd_args, stdout=f, diff --git a/src/myna/application/additivefoam/single_track_calibration/__init__.py b/src/myna/application/additivefoam/single_track_calibration/__init__.py index 6aec2fd6..b7b66a49 100644 --- a/src/myna/application/additivefoam/single_track_calibration/__init__.py +++ b/src/myna/application/additivefoam/single_track_calibration/__init__.py @@ -9,3 +9,7 @@ """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 index d5f3cf13..e2c58592 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -34,13 +34,6 @@ from myna.core.utils import nested_get from myna.application.openfoam.mesh import update_parameter -# 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__) - # Define main class class AdditiveFOAMCalibration(AdditiveFOAM): @@ -64,6 +57,10 @@ def __init__( 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"{__name__}.{name}") def parse_configure_arguments(self): @@ -603,7 +600,7 @@ def _extract_calibrated_n( ) if clipping_percentage > 5.0: - logger.warning( + self.logger.warning( f"Posterior is clipped at lower bound ({clipping_percentage:.1f}%). " f"Using n_min={n_min:.3f}" ) @@ -798,7 +795,7 @@ def _linear_interp_pt(n_val, n_data_pt, column_data_pt): sigma=sigmas, observed=observed_values, ) - logger.info( + self.logger.info( f"Sampling posterior with {len(observed_values)} observations " f"(σ_est={sigmas})" ) @@ -854,7 +851,7 @@ def _fit_heteroskedastic_model( # ============================================================================== -# EXAMPLE USAGE +# EXAMPLE API USAGE # ============================================================================== if __name__ == "__main__": From 238f184c5ca5a83eb9278808b2aa497f995dc38b Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 16:04:33 -0500 Subject: [PATCH 22/29] update example readme --- examples/single_track_calibration/readme.md | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/examples/single_track_calibration/readme.md b/examples/single_track_calibration/readme.md index 13b4b913..b658e113 100644 --- a/examples/single_track_calibration/readme.md +++ b/examples/single_track_calibration/readme.md @@ -1,5 +1,11 @@ # 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 @@ -31,3 +37,27 @@ be created for writing the results of the workflow step. If you want to change t 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" From a93ffe0aa9ec935e01782cd683ebe8ca13100e16 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 16:13:56 -0500 Subject: [PATCH 23/29] update readme --- examples/single_track_calibration/readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/single_track_calibration/readme.md b/examples/single_track_calibration/readme.md index b658e113..ff45d69e 100644 --- a/examples/single_track_calibration/readme.md +++ b/examples/single_track_calibration/readme.md @@ -25,9 +25,9 @@ data: 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 + 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. From 8d9a481cf5fb8eb453ca82fa3e730c269f3ecf21 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Tue, 6 Jan 2026 16:32:18 -0500 Subject: [PATCH 24/29] update output logic for handling single experiment data point --- .../single_track_calibration/app.py | 73 ++++++++++++------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index e2c58592..aff684ea 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -207,7 +207,7 @@ def execute_case(self, config: CalibrationConfig): ) self.logger.info("=" * 80) - # 5. TODO: Perform Bayesian calibration of n as a function of d/sigma over + # 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] @@ -215,26 +215,49 @@ def execute_case(self, config: CalibrationConfig): trace = self._fit_heteroskedastic_model( depths, spot_sizes, calibrated_n_values ) - post = trace.posterior # type: ignore[attr-defined] arviz uses dynamic attributes - heatsource_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(), - } + 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: @@ -811,15 +834,13 @@ def _linear_interp_pt(n_val, n_data_pt, column_data_pt): def _fit_heteroskedastic_model( self, depth_observations, spot_sizes, calibrated_n_values - ) -> az.InferenceData: + ) -> 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) < 2: - raise ValueError( - "Not enough calibrated points to fit a final relationship." - ) + if len(calibrated_n_values) == 1: + return None normalized_depths_obs = [ np.mean(ds) / s for ds, s in zip(depth_observations, spot_sizes) From e19cd96b915203b5b6de60caefdb8181abddb45f Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Wed, 4 Feb 2026 15:09:09 -0500 Subject: [PATCH 25/29] ruff format --- src/myna/application/additivefoam/additivefoam.py | 1 - src/myna/application/additivefoam/path.py | 1 + .../application/additivefoam/single_track_calibration/app.py | 5 +++-- .../additivefoam/single_track_calibration/configure.py | 1 + .../additivefoam/single_track_calibration/execute.py | 1 + src/myna/core/components/component_calibration.py | 1 - 6 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/myna/application/additivefoam/additivefoam.py b/src/myna/application/additivefoam/additivefoam.py index 4ee025ee..8512fc03 100644 --- a/src/myna/application/additivefoam/additivefoam.py +++ b/src/myna/application/additivefoam/additivefoam.py @@ -309,7 +309,6 @@ def update_exaca_region_bounds(self, case_dir, bb): 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 diff --git a/src/myna/application/additivefoam/path.py b/src/myna/application/additivefoam/path.py index 25bf3b08..f27c5279 100644 --- a/src/myna/application/additivefoam/path.py +++ b/src/myna/application/additivefoam/path.py @@ -7,6 +7,7 @@ # License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause. # """Module for functions related to AdditiveFOAM scan paths""" + from pathlib import Path import pandas as pd import polars as pl diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index aff684ea..5d83ecc7 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -806,8 +806,9 @@ def _linear_interp_pt(n_val, n_data_pt, column_data_pt): 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 + 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) diff --git a/src/myna/application/additivefoam/single_track_calibration/configure.py b/src/myna/application/additivefoam/single_track_calibration/configure.py index 41fce2ce..fdffd39c 100644 --- a/src/myna/application/additivefoam/single_track_calibration/configure.py +++ b/src/myna/application/additivefoam/single_track_calibration/configure.py @@ -9,6 +9,7 @@ """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, ) diff --git a/src/myna/application/additivefoam/single_track_calibration/execute.py b/src/myna/application/additivefoam/single_track_calibration/execute.py index 8bb9404a..85c8d5bd 100644 --- a/src/myna/application/additivefoam/single_track_calibration/execute.py +++ b/src/myna/application/additivefoam/single_track_calibration/execute.py @@ -9,6 +9,7 @@ """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, ) diff --git a/src/myna/core/components/component_calibration.py b/src/myna/core/components/component_calibration.py index 996d5afe..4210db75 100644 --- a/src/myna/core/components/component_calibration.py +++ b/src/myna/core/components/component_calibration.py @@ -13,7 +13,6 @@ class SingleTrackCalibrationComponent(Component): - def __init__(self): super().__init__() self.output_requirement = FileYAML From 8f2739a027cc1f1eb91925e9fe500e663095be3d Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Wed, 4 Feb 2026 15:21:44 -0500 Subject: [PATCH 26/29] fixup: add NoDatabase to submodule definition --- src/myna/core/db/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"] From ac3fd951694d308e0ad58eda9326a4851d1d9e08 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Wed, 4 Feb 2026 16:53:29 -0500 Subject: [PATCH 27/29] add additivefoam extra deps --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1b5d1b64..1a4b8f3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,10 @@ cubit = [ deer = [ 'netCDF4', ] +additivefoam = [ + 'pymc', + 'arviz', +] [project.scripts] myna = "myna.core.workflow.all:main" From 042dd1987c3c99b535cf070bcd190330a894dd9c Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Wed, 4 Feb 2026 16:53:53 -0500 Subject: [PATCH 28/29] fix errors with MynaApp syntax from rebase --- .../additivefoam/single_track_calibration/app.py | 6 +++--- src/myna/core/app/base.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index 5d83ecc7..5eea6456 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -49,10 +49,10 @@ class AdditiveFOAMCalibration(AdditiveFOAM): def __init__( self, - name: str = "single_track_calibration", config: Optional[CalibrationConfig] = None, ): - super().__init__(name) + 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 @@ -61,7 +61,7 @@ def __init__( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) - self.logger = logging.getLogger(f"{__name__}.{name}") + self.logger = logging.getLogger(f"{self.name}") def parse_configure_arguments(self): """Check for arguments relevant to the configure step and update app settings""" diff --git a/src/myna/core/app/base.py b/src/myna/core/app/base.py index c4bb30fe..5fef9367 100644 --- a/src/myna/core/app/base.py +++ b/src/myna/core/app/base.py @@ -50,7 +50,6 @@ def __init__(self): self.input_file = os.environ.get(self.ENV_SETTINGS_FILE) self.settings = {} self.step_number = None - self.template = None if self.input_file is not None: self.settings = load_input(self.input_file) self.step_number = [ From f5270326fff77fc8e93934fb6fd42be691c1b3c2 Mon Sep 17 00:00:00 2001 From: Gerry Knapp Date: Fri, 6 Feb 2026 16:29:02 -0500 Subject: [PATCH 29/29] fixup: template copy syntax --- .../application/additivefoam/single_track_calibration/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/myna/application/additivefoam/single_track_calibration/app.py b/src/myna/application/additivefoam/single_track_calibration/app.py index 5eea6456..e21f50e5 100644 --- a/src/myna/application/additivefoam/single_track_calibration/app.py +++ b/src/myna/application/additivefoam/single_track_calibration/app.py @@ -514,7 +514,7 @@ def _run_single_simulation( self.logger.debug( f"Copying template ({self.template})\n\t-> case dir ({case_dir})" ) - self.copy(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(