Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5b90600
draft: add stubs for calibration app
gknapp1 Oct 21, 2025
c28c794
draft: add pydantic data models and initial tests
gknapp1 Dec 29, 2025
6bb030d
remove default additivefoam executable validation
gknapp1 Dec 31, 2025
2c55f7d
draft: refactor of app structure
gknapp1 Dec 31, 2025
781dd11
draft: fix linting
gknapp1 Dec 31, 2025
e8be7b6
draft: split app and models to separate files
gknapp1 Dec 31, 2025
dbd976c
add configuration argparse
gknapp1 Jan 1, 2026
7c22fc2
formatting
gknapp1 Jan 1, 2026
f33a06b
move json_yaml loader to core utils
gknapp1 Jan 1, 2026
477de4a
ensure that directories exist when copying template
gknapp1 Jan 1, 2026
43671fd
add utilities to additivefoam module
gknapp1 Jan 1, 2026
12fbed5
add simulation case updates and running to execute function
gknapp1 Jan 1, 2026
d5f0e21
fixup: correct template dir behavior in base app
gknapp1 Dec 22, 2025
b7304ab
draft: update app
gknapp1 Jan 6, 2026
6f92ef8
clean up and add template
gknapp1 Jan 6, 2026
1b98152
refactor component runner arg list assembly to handle lists of inputs
gknapp1 Jan 6, 2026
5b928a5
add option to have no database specification in Myna input file
gknapp1 Jan 6, 2026
b804b15
add configure and execute to app
gknapp1 Jan 6, 2026
c2da28d
add core classes for YAML output, single track calibration step
gknapp1 Jan 6, 2026
f3b9efb
add example
gknapp1 Jan 6, 2026
2ca9703
address minor linting issues:
gknapp1 Jan 6, 2026
238f184
update example readme
gknapp1 Jan 6, 2026
a93ffe0
update readme
gknapp1 Jan 6, 2026
8d9a481
update output logic for handling single experiment data point
gknapp1 Jan 6, 2026
e19cd96
ruff format
gknapp1 Feb 4, 2026
8f2739a
fixup: add NoDatabase to submodule definition
gknapp1 Feb 4, 2026
ac3fd95
add additivefoam extra deps
gknapp1 Feb 4, 2026
042dd19
fix errors with MynaApp syntax from rebase
gknapp1 Feb 4, 2026
f527032
fixup: template copy syntax
gknapp1 Feb 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions examples/single_track_calibration/experiments.yaml
Original file line number Diff line number Diff line change
@@ -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]
10 changes: 10 additions & 0 deletions examples/single_track_calibration/input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
steps:
- calibrate_additivefoam_heatsource:
class: single_track_calibration
application: additivefoam
configure:
experiments: experiments.yaml
nvalues: [0.0, 2.0, 5.0, 7.0, 9.0]
execute:
np: 1
batch: True
63 changes: 63 additions & 0 deletions examples/single_track_calibration/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Example: AdditiveFOAM Single Track Calibration

This example covers how to calibrate an AdditiveFOAM
[projectedGaussian](https://github.com/ORNL/AdditiveFOAM/blob/main/applications/solvers/additiveFoam/movingHeatSource/heatSourceModels/projectedGaussian/projectedGaussian.C)
heat source using experimental measurements of single track melt pool depths.

## Myna case setup

This example requires the following files:

- `input.yaml`: the Myna input file
- `experiments.yaml`: single track measurements required by the AdditiveFOAM single
track calibration app

This use case is a little different than other examples, because the
`additivefoam/single_track_calibration` app does not require any build metadata and
is not associated with any build region, part or layer. Instead, the app is entirely
dependent on the file specified in the "configure/experiments" step parameter in the
Myna input file. The experiments file is a list of entries under a "data" entry
following the format:

```yaml
data:
- process_parameters:
power: 187.5 # Laser power, in Watts
scan_speed: 0.5 # Scan speed of the laser, in meters/second
spot_size: 0.1 # Spot size (diameter), in millimeters
material: SS316L # Name of material, corresponding to Myna material library
depths: [91.0e-3] # List of depth values corresponding to the process parameters, in millimeters
- ... # Repeat for additional process parameters
```

Because no build data is requires, the input file does not need to have a "data" entry.
While this entry will be populated in the configured input file, it will have
empty values for all of its fields. The default output directory `myna_output` will
be created for writing the results of the workflow step. If you want to change this
behavior, you have to set the `input["data"]["build"]["name"]` entry to the desired
outputlocation. If you have other steps in the workflow that do require build data,
then you will need to specify an appropriate database entry.

## Description of the AdditiveFOAM application

The AdditiveFOAM application workflow consists of the following steps:

1. *Load experimental data*: Parse the experimental data for depth measurements as a
function of process parameters
2. *Identify missing simulations*: If simulations are already detected in the output
directory, then they will be loaded and only missing simulations will be run.
3. *Run required simulations*: Single track simulations are configured and run in
`sim_output` within the Myna step directory. A hashed "fingerprint" of the process
parametersis used to group simulations with the same process parameters
(but different simulation parameters, i.e., n-values), so simulations will be grouped
into directories with long "random" names.
4. *Performing Bayesian calibration from n-values*: Based on the simulated depth, optimal
n-values for each process parameter will be calculated using a Bayesian calibration
approach leveraging the [pymc](https://www.pymc.io/) package.
5. *Performing Bayesian calibration for heat-source parameters*: All of the calibrated
n-values are assembled to find optimized heat source parameters for the
`projectedGaussian` heat source given the experimentally-measured values, again
using `pymc` methods.

The results of the calibration are stored in the Myna-style output, which for this
example will be: "myna_output/calibrate_additivefoam_heatsource/single_track_calibration-calibrate_additivefoam_heatsource-FileYAML.yaml"
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ dependencies = [
'scipy',
'gitpython',
'zarr',
'docker']
'docker',
'pydantic']

[project.optional-dependencies]
dev = [
Expand Down Expand Up @@ -62,6 +63,10 @@ cubit = [
deer = [
'netCDF4',
]
additivefoam = [
'pymc',
'arviz',
]

[project.scripts]
myna = "myna.core.workflow.all:main"
Expand Down
85 changes: 67 additions & 18 deletions src/myna/application/additivefoam/additivefoam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -29,9 +32,7 @@ def __init__(self):

# Parse app-specific arguments
self.parse_known_args()
super().validate_executable(
"additiveFoam",
)
print(f"{self.args.exec=}")
if self.args.exec is None:
self.args.exec = "additiveFoam"

Expand All @@ -58,14 +59,17 @@ def has_matching_template_mesh_dict(self, mesh_path, mesh_dict):
matches.append(entry_match)
return bool(all(matches))

def update_material_properties(self, case_dir):
def update_material_properties(
self, case_dir, material: str | None = None
) -> mist.core.MaterialInformation:
"""Update the material properties for the AdditiveFOAM case based on Mist data

Args:
case_dir: path to the case directory to update
"""

material = self.settings["data"]["build"]["build_data"]["material"]["value"]
if material is None:
material = self.settings["data"]["build"]["build_data"]["material"]["value"]
material_data = os.path.join(
os.environ["MYNA_INSTALL_PATH"],
"mist_material_data",
Expand Down Expand Up @@ -105,6 +109,7 @@ def update_material_properties(self, case_dir):
if os.path.exists(exaca_dict):
liquidus = mat.get_property("liquidus_temperature", None, None)
update_parameter(exaca_dict, "ExaCA/isoValue", liquidus)
return mat

def get_region_resource_template_dir(self, part, region):
"""Provides the path to the template directory in the myna_resources folder
Expand All @@ -123,19 +128,21 @@ def get_region_resource_template_dir(self, part, region):
"template",
)

def update_beam_spot_size(self, part, case_dir):
def update_beam_spot_size(self, part, case_dir, spot_size: float | None = None):
"""Updates the beam spot size in the case directory's constant/heatSourceDict

Args:
part: name of part to get spot size from
case_dir: directory that contains AdditiveFOAM case files to update
spot_size: if specified, 2 sigma spot size (i.e., beam radius) in meters
"""
# Extract the spot size (diameter -> radius & mm -> m)
spot_size = (
0.5
* self.settings["data"]["build"]["parts"][part]["spot_size"]["value"]
* 1e-3
)
if spot_size is None:
spot_size = (
0.5
* self.settings["data"]["build"]["parts"][part]["spot_size"]["value"]
* 1e-3
)

# Get heatSourceModel
heat_source_model = (
Expand Down Expand Up @@ -180,14 +187,19 @@ def update_beam_spot_size(self, part, case_dir):
heat_source_dim_string,
)

def update_region_start_and_end_times(self, case_dir, bb_dict, scanpath_name):
def update_region_start_and_end_times(
self, case_dir, bb_dict, scanpath_name
) -> tuple[float, float]:
"""Updates the start and end times of the specified case based on the scan path's
intersection with the domain

Args:
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+")
Expand All @@ -204,12 +216,14 @@ def update_region_start_and_end_times(self, case_dir, bb_dict, scanpath_name):
p1 = [row["X(m)"], row["Y(m)"]]
xs = np.linspace(p0[0], p1[0], 1000)
ys = np.linspace(p0[1], p1[1], 1000)
in_region = any(
(xs > bb_dict["bb_min"][0])
& (xs < bb_dict["bb_max"][0])
& (ys > bb_dict["bb_min"][1])
& (ys < bb_dict["bb_max"][1])
)
in_region = True
if bb_dict is not None:
in_region = any(
(xs > bb_dict["bb_min"][0])
& (xs < bb_dict["bb_max"][0])
& (ys > bb_dict["bb_min"][1])
& (ys < bb_dict["bb_max"][1])
)
if in_region:
time_bounds[1] = None
if in_region and (time_bounds[0] is None):
Expand All @@ -233,6 +247,7 @@ def update_region_start_and_end_times(self, case_dir, bb_dict, scanpath_name):

time_bounds = np.round(time_bounds, 5)
self.update_start_and_end_times(case_dir, time_bounds[0], time_bounds[1])
return (time_bounds[0], time_bounds[1])

def update_start_and_end_times(self, case_dir, start_time, end_time):
"""Updates the case to adjust the start and end time by adjusting:"
Expand Down Expand Up @@ -290,3 +305,37 @@ def update_exaca_region_bounds(self, case_dir, bb):
"ExaCA/box",
f"( {bb[0][0]} {bb[0][1]} {bb[0][2]} ) ( {bb[1][0]} {bb[1][1]} {bb[1][2]} )",
)

def run_case(self, case_dir: str | Path) -> subprocess.Popen | Container:
"""Launch the AdditiveFOAM case directory using the MynaApp settings specified"""
with working_directory(case_dir):
# Determine if parallel execution
parallel = self.args.np > 1

# Decompose case
if parallel:
update_parameter(
"system/decomposeParDict",
"numberOfSubdomains",
self.args.np,
)
with open("decomposePar.log", "w", encoding="utf-8") as f:
process = self.start_subprocess(
["decomposePar", "-force"],
stdout=f,
stderr=subprocess.STDOUT,
)
self.wait_for_process_success(process)

# Launch job, using MPI arguments if specified
with open("additiveFoam.log", "w", encoding="utf-8") as f:
cmd_args = [self.args.exec]
if parallel:
cmd_args.append("-parallel")
process = self.start_subprocess_with_mpi_args(
cmd_args,
stdout=f,
stderr=subprocess.STDOUT,
)

return process
38 changes: 38 additions & 0 deletions src/myna/application/additivefoam/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
#
"""Module for functions related to AdditiveFOAM scan paths"""

from pathlib import Path
import pandas as pd
import polars as pl


def convert_peregrine_scanpath(filename, export_path, power=1):
Expand Down Expand Up @@ -49,3 +51,39 @@ def convert_peregrine_scanpath(filename, export_path, power=1):
sep="\t",
index=False,
)


def write_single_line_path(
path: str | Path,
power: float,
speed: float,
start_coord: tuple = (0, 0, 0),
end_coord: tuple = (1e-3, 1e-3, 1e-3),
):
"""Writes a single line scan path at the specified file path

Args:
- path: path to export the scan path
- power: laser power, in Watts
- speed: travel speed, in meters/second
- start_coords: XYZ location of the starting location of the scan, in meters
- end_coords: XYZ location of the ending location of the scan, in meters
"""
dict = {
"Mode": [1, 0],
"X(m)": [start_coord[0], end_coord[0]],
"Y(m)": [start_coord[1], end_coord[1]],
"Z(m)": [start_coord[2], end_coord[2]],
"Power(W)": [0.0, power],
"tParam": [1e-6, speed],
}
schema = {
"Mode": pl.Int8,
"X(m)": pl.Float64,
"Y(m)": pl.Float64,
"Z(m)": pl.Float64,
"Power(W)": pl.Float64,
"tParam": pl.Float64,
}
df = pl.from_dict(dict, schema=schema)
df.write_csv(path, separator="\t")
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#
# Copyright (c) Oak Ridge National Laboratory.
#
# This file is part of Myna. For details, see the top-level license
# at https://github.com/ORNL-MDF/Myna/LICENSE.md.
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause.
#
"""Application to calibrate simulation parameters for an AdditiveFOAM melt pool
simulation using experimental/reference values of melt pool width and depth
from cross-sections of the melt pool track"""

from .app import AdditiveFOAMCalibration

__all__ = ["AdditiveFOAMCalibration"]
Loading
Loading