From 9c59eacd25b7b09bc543347352a186fca7521abe Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Fri, 29 May 2026 16:48:04 +0200 Subject: [PATCH 1/2] chore: move pipelines out of api package pipelines used by the API package still belong in `amorphouspy`, since they can be used by users of the python package as well. --- amorphouspy/src/amorphouspy/__init__.py | 8 + .../src/amorphouspy/pipelines/__init__.py | 24 ++- .../src/amorphouspy/pipelines/meltquench.py | 140 ++++++++++++++++ .../src/amorphouspy/pipelines/structural.py | 86 ++++++++++ .../src/amorphouspy/pipelines/viscosity.py | 155 ++++++++++++++++++ .../amorphouspy_api/workflows/analyses/cte.py | 85 ++-------- .../workflows/analyses/elastic.py | 21 +-- .../workflows/analyses/structure.py | 48 +----- .../workflows/analyses/viscosity.py | 145 +--------------- .../amorphouspy_api/workflows/meltquench.py | 97 +++-------- amorphouspy_api/src/tests/test_jobs.py | 6 +- 11 files changed, 456 insertions(+), 359 deletions(-) create mode 100644 amorphouspy/src/amorphouspy/pipelines/meltquench.py create mode 100644 amorphouspy/src/amorphouspy/pipelines/structural.py create mode 100644 amorphouspy/src/amorphouspy/pipelines/viscosity.py diff --git a/amorphouspy/src/amorphouspy/__init__.py b/amorphouspy/src/amorphouspy/__init__.py index dcd6b949..69f1002c 100644 --- a/amorphouspy/src/amorphouspy/__init__.py +++ b/amorphouspy/src/amorphouspy/__init__.py @@ -21,6 +21,9 @@ from amorphouspy.lammps.md import md_simulation from amorphouspy.lammps.potentials._config import DsfConfig, EwaldConfig, InteractionConfig, PppmConfig, WolfConfig from amorphouspy.lammps.potentials.potential import generate_potential +from amorphouspy.pipelines.meltquench import generate_structure, run_melt_quench +from amorphouspy.pipelines.structural import extract_equilibration_frames, run_structural_analysis +from amorphouspy.pipelines.viscosity import run_viscosity_workflow from amorphouspy.properties.cte import cte_from_fluctuations_simulation, temperature_scan_simulation from amorphouspy.properties.cte_analysis import ( cte_from_npt_fluctuations, @@ -74,12 +77,14 @@ "cte_from_volume_temperature_data", "elastic_simulation", "extract_composition", + "extract_equilibration_frames", "find_rdf_minimum", "fit_vft", "formula_mass_g_per_mol", "frames_from_melt_quench_result", "generate_bond_length_dict", "generate_potential", + "generate_structure", "get_ase_structure", "get_atomic_mass", "get_composition", @@ -93,6 +98,9 @@ "parse_formula", "plan_system", "plot_analysis_results_plotly", + "run_melt_quench", + "run_structural_analysis", + "run_viscosity_workflow", "running_mean", "structure_from_parsed_output", "temperature_scan_simulation", diff --git a/amorphouspy/src/amorphouspy/pipelines/__init__.py b/amorphouspy/src/amorphouspy/pipelines/__init__.py index bf9123ac..64f4dda0 100644 --- a/amorphouspy/src/amorphouspy/pipelines/__init__.py +++ b/amorphouspy/src/amorphouspy/pipelines/__init__.py @@ -1,5 +1,23 @@ -"""Pipelines subpackage of amorphouspy. +"""Pipeline subpackage of amorphouspy. -Provides workflow orchestration utilities built on top of executorlib -for submitting fabrication and characterization pipelines to HPC resources. +High-level workflow functions that compose the lower-level building blocks +(fabrication, LAMMPS, properties) into end-to-end simulation pipelines. + +Each pipeline module accepts plain Python arguments (ASE Atoms, dicts, floats) +and returns plain dicts — no web-framework or pydantic dependency. """ + +from amorphouspy.pipelines.meltquench import generate_structure, run_melt_quench +from amorphouspy.pipelines.structural import ( + extract_equilibration_frames, + run_structural_analysis, +) +from amorphouspy.pipelines.viscosity import run_viscosity_workflow + +__all__ = [ + "extract_equilibration_frames", + "generate_structure", + "run_melt_quench", + "run_structural_analysis", + "run_viscosity_workflow", +] diff --git a/amorphouspy/src/amorphouspy/pipelines/meltquench.py b/amorphouspy/src/amorphouspy/pipelines/meltquench.py new file mode 100644 index 00000000..c374305e --- /dev/null +++ b/amorphouspy/src/amorphouspy/pipelines/meltquench.py @@ -0,0 +1,140 @@ +"""Melt-quench pipeline: structure generation and glass quenching. + +Combines structure generation (composition → random atoms → potential) +with the LAMMPS melt-quench simulation into a two-step pipeline. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import numpy as np + +from amorphouspy.fabrication.geometry import get_ase_structure, get_structure_dict + +if TYPE_CHECKING: + import pandas as pd + from ase import Atoms + + from amorphouspy.lammps.potentials._config import InteractionConfig +from amorphouspy.fabrication.meltquench import melt_quench_simulation +from amorphouspy.fabrication.meltquench_protocols import DEFAULT_MELT_TEMPERATURES +from amorphouspy.lammps.potentials.potential import generate_potential + +logger = logging.getLogger(__name__) + + +def generate_structure( + composition: dict[str, float], + n_atoms: int = 6000, + potential_type: str = "pmmcs", + density: float | None = None, + structure_seed: int = 42, + electrostatics_config: InteractionConfig | None = None, +) -> dict[str, Any]: + """Generate an initial random structure and matching LAMMPS potential. + + Args: + composition: Oxide composition as ``{oxide: mol%}``. + n_atoms: Target number of atoms. + potential_type: Name of the interatomic potential to generate. + density: Target density in g/cm³; ``None`` uses the Fluegel model. + structure_seed: Random seed for atom placement. + electrostatics_config: Electrostatics configuration (e.g. ``DsfConfig``). + + Returns: + Dict with ``atoms_dict``, ``structure`` (ASE Atoms), and ``potential``. + """ + atoms_dict = get_structure_dict( + composition=composition, + target_atoms=n_atoms, + density=density, + random_seed=structure_seed, + ) + structure = get_ase_structure(atoms_dict=atoms_dict) + potential = generate_potential( + atoms_dict=atoms_dict, + potential_type=potential_type, + melt=True, + electrostatics=electrostatics_config, + ) + + return { + "atoms_dict": atoms_dict, + "structure": structure, + "potential": potential, + } + + +def run_melt_quench( + structure: Atoms, + potential: pd.DataFrame, + *, + potential_type: str = "pmmcs", + heating_rate: float = 1e14, + cooling_rate: float = 1e12, + timestep: float = 1.0, + temperature_high: float | None = None, + temperature_low: float = 300.0, + equilibration_steps: int | None = None, + n_print: int = 100_000, + n_averaging_frames: int = 100, + server_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Run a LAMMPS melt-quench simulation. + + Args: + structure: Initial ASE Atoms object. + potential: LAMMPS potential object from ``generate_potential``. + potential_type: Potential name, used for default ``temperature_high`` lookup. + heating_rate: Heating rate in K/ps. + cooling_rate: Cooling rate in K/ps. + timestep: MD timestep in fs. + temperature_high: Melt temperature in K; ``None`` uses the protocol default. + temperature_low: Final quench temperature in K. + equilibration_steps: Override for equilibration step count; ``None`` = protocol default. + n_print: Thermodynamic output frequency (steps). + n_averaging_frames: Number of trajectory frames to keep for structural averaging. + server_kwargs: LAMMPS server/resource configuration. + + Returns: + Dict with ``final_structure``, ``mean_temperature``, ``simulation_steps``, + ``composition``, trajectory data, and simulation parameters. + """ + if server_kwargs is None: + server_kwargs = {} + + if temperature_high is None: + temperature_high = DEFAULT_MELT_TEMPERATURES.get(potential_type, 5000.0) + + mq = melt_quench_simulation( + structure=structure, + potential=potential, + n_print=n_print, + heating_rate=int(heating_rate), + cooling_rate=int(cooling_rate), + timestep=timestep, + temperature_high=temperature_high, + temperature_low=temperature_low, + equilibration_steps=equilibration_steps, + langevin=False, + server_kwargs=server_kwargs, + ) + + last_stage = next((s for s in reversed(mq["result"]) if s is not None), {}) + + return { + "final_structure": mq["structure"], + "mean_temperature": float(np.mean(last_stage["temperature"])), + "simulation_steps": len(last_stage["steps"]), + "temperature_trajectory": [float(t) for t in last_stage["temperature"]], + "steps_trajectory": [int(s) for s in last_stage["steps"]], + "simulation_history": mq["result"], + "timestep": timestep, + "cooling_rate": int(cooling_rate), + "heating_rate": int(heating_rate), + "temperature_high": temperature_high, + "temperature_low": temperature_low, + "n_averaging_frames": n_averaging_frames, + } diff --git a/amorphouspy/src/amorphouspy/pipelines/structural.py b/amorphouspy/src/amorphouspy/pipelines/structural.py new file mode 100644 index 00000000..2239a38b --- /dev/null +++ b/amorphouspy/src/amorphouspy/pipelines/structural.py @@ -0,0 +1,86 @@ +"""Structural analysis pipeline. + +Runs structural characterisation (RDF, coordination, bond angles, etc.) +on a quenched glass structure, optionally averaging over trajectory frames +from the final equilibration stage. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from amorphouspy.properties.structural.all import StructureData, analyze_structure + +if TYPE_CHECKING: + from ase import Atoms + +logger = logging.getLogger(__name__) + + +def extract_equilibration_frames( + final_structure: Atoms, + simulation_history: list[dict[str, Any]] | None = None, +) -> list[Atoms]: + """Reconstruct Atoms snapshots from the final equilibration stage. + + Falls back to a single-element list with *final_structure* when no + simulation history is available or the history contains no position data. + + Args: + final_structure: The quenched structure from the melt-quench pipeline. + simulation_history: Full stage-by-stage MD history (optional). + + Returns: + List of ASE Atoms frames suitable for averaging. + """ + if not simulation_history: + return [final_structure] + + last_stage = next((s for s in reversed(simulation_history) if s is not None), None) + if last_stage is None or "positions" not in last_stage: + return [final_structure] + + positions = last_stage["positions"] + cells = last_stage["cells"] + n_frames = len(positions) + + if n_frames <= 1: + return [final_structure] + + frames: list[Atoms] = [] + for i in range(n_frames): + frame = final_structure.copy() + frame.set_positions(positions[i]) + frame.set_cell(cells[i]) + frame.set_pbc(True) + frame.wrap() + frames.append(frame) + + return frames + + +def run_structural_analysis( + final_structure: Atoms, + simulation_history: list[dict[str, Any]] | None = None, +) -> tuple[StructureData, StructureData | None, int]: + """Run structural analysis, optionally averaging over trajectory frames. + + Args: + final_structure: Quenched ASE Atoms object. + simulation_history: Full stage-by-stage MD history for frame averaging. + + Returns: + Tuple of ``(mean_data, sem_data, n_frames)`` where *sem_data* is + ``None`` when only one frame is used. + """ + frames = extract_equilibration_frames(final_structure, simulation_history) + n_frames = len(frames) + + if n_frames > 1: + logger.info("Frame-averaging structural analysis over %d frames", n_frames) + mean_data, sem_data = analyze_structure(atoms=frames, frame_averaging=True) + else: + mean_data, sem_data = analyze_structure(atoms=frames[0]) + + return mean_data, sem_data, n_frames diff --git a/amorphouspy/src/amorphouspy/pipelines/viscosity.py b/amorphouspy/src/amorphouspy/pipelines/viscosity.py new file mode 100644 index 00000000..fdc933db --- /dev/null +++ b/amorphouspy/src/amorphouspy/pipelines/viscosity.py @@ -0,0 +1,155 @@ +"""Multi-temperature viscosity pipeline. + +Sequentially cools a quenched glass to each target temperature via +melt-quench and runs a Green-Kubo viscosity production simulation +at each one. +""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING, Any, cast + +from amorphouspy.fabrication.meltquench import melt_quench_simulation + +if TYPE_CHECKING: + import pandas as pd + from ase import Atoms +from amorphouspy.properties.viscosity import get_viscosity, viscosity_simulation + +logger = logging.getLogger(__name__) + + +def run_viscosity_workflow( + structure: Atoms, + potential: pd.DataFrame, + temperatures: list[float], + heating_rate: float, + cooling_rate: float, + timestep: float = 1.0, + n_timesteps: int = 10_000_000, + n_print: int = 1, + max_lag: int | None = 1_000_000, + server_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Run viscosity analysis at multiple temperatures. + + Starting from the quenched structure, the workflow sequentially cools + from the highest to the lowest requested temperature. At each step a + melt-quench brings the structure to the target temperature, followed by + a Green-Kubo viscosity production run. + + Args: + structure: Quenched ASE Atoms from the melt-quench pipeline. + potential: LAMMPS potential object. + temperatures: Target temperatures (K) for viscosity runs. + heating_rate: Heating rate in K/ps. + cooling_rate: Cooling rate in K/ps. + timestep: MD timestep in fs. + n_timesteps: Number of MD steps per viscosity production run. + n_print: Thermodynamic output frequency. + max_lag: Maximum correlation lag (steps) for Green-Kubo. + server_kwargs: LAMMPS server/resource configuration. + + Returns: + Result dict with ``temperatures``, ``viscosities``, ``max_lag``, + ``simulation_steps``, ``lag_times_ps``, and ``viscosity_integral``. + """ + if server_kwargs is None: + server_kwargs = {} + + sorted_temps = sorted(temperatures, reverse=True) + logger.info("Viscosity temperatures (high→low): %s", sorted_temps) + + viscosities: list[float] = [] + all_max_lags: list[float] = [] + sim_steps: list[int] = [] + lag_times_ps: list[list[float]] = [] + viscosity_integral: list[list[float]] = [] + + structure_current = structure + + for idx, temp in enumerate(sorted_temps): + temp_high = 5000.0 if idx == 0 else sorted_temps[idx - 1] + + logger.info("Cooling from %.1f K to %.1f K", temp_high, temp) + mq_result = melt_quench_simulation( + structure=structure_current, + potential=potential, + temperature_high=float(temp_high), + temperature_low=float(temp), + timestep=1.0, + heating_rate=float(heating_rate), + cooling_rate=float(cooling_rate), + n_print=1000, + langevin=False, + server_kwargs=server_kwargs, + ) + structure_current = mq_result["structure"] + logger.info("Cooled to %.1f K, %d atoms", temp, len(structure_current)) + + logger.info("Running viscosity simulation at %.1f K", temp) + visc_result = viscosity_simulation( + structure=structure_current, + potential=potential, + temperature_sim=float(temp), + timestep=float(timestep), + initial_production_steps=int(n_timesteps), + n_print=int(n_print), + langevin=False, + seed=12345, + server_kwargs=server_kwargs, + ) + + visc_data = get_viscosity(visc_result, timestep=float(timestep), max_lag=max_lag) + logger.info("Viscosity at %.1f K: %.3e Pa·s", temp, visc_data["viscosity"]) + + viscosities.append(float(visc_data["viscosity"])) + all_max_lags.append(float(visc_data["max_lag"])) + sim_steps.append(int(n_timesteps)) + raw_lag_data = cast("list[float]", visc_data.get("lag_time_ps", [])) + raw_visc_data = cast("list[float]", visc_data.get("viscosity_integral", [])) + lag_times_ps.append(downsample_log(raw_lag_data)) + viscosity_integral.append(downsample_log(raw_visc_data)) + + return { + "temperatures": sorted_temps, + "viscosities": viscosities, + "max_lag": all_max_lags, + "simulation_steps": sim_steps, + "lag_times_ps": lag_times_ps, + "viscosity_integral": viscosity_integral, + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_MAX_PLOT_POINTS = 1000 + + +def downsample_log(arr: list[float], max_points: int = _MAX_PLOT_POINTS) -> list[float]: + """Downsample *arr* to *max_points* using log-spaced indices. + + Useful for reducing large correlation-function arrays while + preserving the shape on a logarithmic x-axis. + """ + n = len(arr) + if n <= max_points: + return arr + indices = sorted({round(v) for v in _logspace(0, n - 1, max_points)}) + return [arr[i] for i in indices] + + +def _logspace(start: float, stop: float, num: int) -> list[float]: + """Return *num* values log-spaced between *start* and *stop* (inclusive).""" + if num <= 0: + return [] + if num == 1: + return [stop] + log_start = math.log10(start + 1) + log_stop = math.log10(stop + 1) + step = (log_stop - log_start) / (num - 1) + return [10 ** (log_start + i * step) - 1 for i in range(num)] diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/cte.py b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/cte.py index 62bfcf07..6bd1d3c7 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/cte.py +++ b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/cte.py @@ -1,7 +1,7 @@ """CTE workflow wrappers for the amorphouspy API. -Thin wrappers around ``amorphouspy.workflows.cte`` that adapt the core -simulation functions to the API pipeline calling convention. +Thin wrappers around the core CTE simulation functions that adapt them +to the API pipeline calling convention. """ from __future__ import annotations @@ -17,6 +17,8 @@ def run_cte(submission: JobSubmission, config: CTEFluctuations | CTETemperatureScan, result: dict) -> dict: """CTE analysis via fluctuations or temperature scan.""" + from amorphouspy.properties.cte import cte_from_fluctuations_simulation, temperature_scan_simulation + from amorphouspy_api.executor import get_lammps_server_kwargs from amorphouspy_api.models import CTEFluctuations, CTETemperatureScan @@ -25,7 +27,7 @@ def run_cte(submission: JobSubmission, config: CTEFluctuations | CTETemperatureS resource_dict = get_lammps_server_kwargs() if isinstance(config, CTEFluctuations): - cte_result = run_cte_fluctuations( + cte_result = cte_from_fluctuations_simulation( structure=structure, potential=potential, temperature=config.temperature, @@ -35,8 +37,8 @@ def run_cte(submission: JobSubmission, config: CTEFluctuations | CTETemperatureS production_steps=config.production_steps, min_production_runs=config.min_production_runs, max_production_runs=config.max_production_runs, - cte_uncertainty_criterion=config.cte_uncertainty_criterion, - lammps_resource_dict=resource_dict, + CTE_uncertainty_criterion=config.cte_uncertainty_criterion, + server_kwargs=resource_dict, ) cte_result["metadata"] = { "temperature": config.temperature, @@ -46,15 +48,15 @@ def run_cte(submission: JobSubmission, config: CTEFluctuations | CTETemperatureS return cte_result assert isinstance(config, CTETemperatureScan) - cte_result = run_cte_temperature_scan( + cte_result = temperature_scan_simulation( structure=structure, potential=potential, - temperatures=config.temperatures, + temperature=config.temperatures, pressure=config.pressure, timestep=config.timestep, equilibration_steps=config.equilibration_steps, production_steps=config.production_steps, - lammps_resource_dict=resource_dict, + server_kwargs=resource_dict, ) cte_result["metadata"] = { "temperatures": config.temperatures, @@ -64,73 +66,6 @@ def run_cte(submission: JobSubmission, config: CTEFluctuations | CTETemperatureS return cte_result -def run_cte_fluctuations( - structure, - potential, - *, - temperature: float = 300.0, - pressure: float = 1e-4, - timestep: float = 1.0, - equilibration_steps: int = 100_000, - production_steps: int = 200_000, - min_production_runs: int = 2, - max_production_runs: int = 25, - cte_uncertainty_criterion: float = 1e-6, - lammps_resource_dict: dict[str, Any] | None = None, -) -> dict[str, Any]: - """Run the enthalpy-volume fluctuations CTE workflow.""" - from amorphouspy import cte_from_fluctuations_simulation - - logger.info( - "Running CTE fluctuations at %.1f K (max %d runs, criterion %.1e)", - temperature, - max_production_runs, - cte_uncertainty_criterion, - ) - - return cte_from_fluctuations_simulation( - structure=structure, - potential=potential, - temperature=temperature, - pressure=pressure, - timestep=timestep, - equilibration_steps=equilibration_steps, - production_steps=production_steps, - min_production_runs=min_production_runs, - max_production_runs=max_production_runs, - CTE_uncertainty_criterion=cte_uncertainty_criterion, - server_kwargs=lammps_resource_dict or {}, - ) - - -def run_cte_temperature_scan( - structure, - potential, - *, - temperatures: list[float], - pressure: float = 1e-4, - timestep: float = 1.0, - equilibration_steps: int = 100_000, - production_steps: int = 200_000, - lammps_resource_dict: dict[str, Any] | None = None, -) -> dict[str, Any]: - """Run the temperature-scan CTE workflow.""" - from amorphouspy import temperature_scan_simulation - - logger.info("Running CTE temperature scan at %s K", temperatures) - - return temperature_scan_simulation( - structure=structure, - potential=potential, - temperature=temperatures, - pressure=pressure, - timestep=timestep, - equilibration_steps=equilibration_steps, - production_steps=production_steps, - server_kwargs=lammps_resource_dict or {}, - ) - - # --------------------------------------------------------------------------- # Visualization helpers # --------------------------------------------------------------------------- diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/elastic.py b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/elastic.py index 442daaae..41a4ec0e 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/elastic.py +++ b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/elastic.py @@ -1,7 +1,7 @@ """Elastic moduli workflow wrapper for the amorphouspy API. -Thin wrapper around ``amorphouspy.workflows.elastic_mod.elastic_simulation`` -that adapts the core simulation function to the API pipeline calling convention. +Thin wrapper around the core ``elastic_simulation`` function +that adapts it to the API calling convention. """ from __future__ import annotations @@ -17,16 +17,9 @@ def run_elastic(submission: JobSubmission, config: ElasticAnalysis, result: dict) -> dict: """Elastic moduli analysis on the quenched glass.""" - from amorphouspy import elastic_simulation - from amorphouspy_api.executor import get_lammps_server_kwargs + from amorphouspy.properties.elastic import elastic_simulation - logger.info( - "Running elastic simulation at %.1f K (strain=%.1e, eq=%d, prod=%d)", - config.temperature, - config.strain, - config.equilibration_steps, - config.production_steps, - ) + from amorphouspy_api.executor import get_lammps_server_kwargs raw = elastic_simulation( structure=result["melt_quench"]["final_structure"], @@ -41,15 +34,11 @@ def run_elastic(submission: JobSubmission, config: ElasticAnalysis, result: dict server_kwargs=get_lammps_server_kwargs(), ) - # Convert Cij ndarray to nested list for JSON serialisation. cij = raw.get("Cij") if cij is not None and hasattr(cij, "tolist"): cij = cij.tolist() - return { - "Cij": cij, - "moduli": raw.get("moduli", {}), - } + return {"Cij": cij, "moduli": raw.get("moduli", {})} # --------------------------------------------------------------------------- diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/structure.py b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/structure.py index eed6ad6a..0b9fe6ae 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/structure.py +++ b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/structure.py @@ -16,58 +16,20 @@ def run_structural_analysis(submission: JobSubmission, config: StructureAnalysis, result: dict) -> dict: """Structural analysis (RDF, coordination, bond angles) on the quenched glass.""" - from amorphouspy.properties.structural.all import analyze_structure + from amorphouspy.pipelines.structural import run_structural_analysis as _run_structural mq = result["melt_quench"] - frames = _extract_equilibration_frames(mq) - n_frames = len(frames) - if n_frames > 1: - logger.info("Frame-averaging structural analysis over %d frames", n_frames) - mean_data, _sem_data = analyze_structure(atoms=frames, frame_averaging=True) - else: - mean_data, _sem_data = analyze_structure(atoms=frames[0]) + mean_data, _sem_data, n_frames = _run_structural( + final_structure=mq["final_structure"], + simulation_history=mq.get("simulation_history"), + ) result_dict = mean_data.model_dump() result_dict["n_averaging_frames"] = n_frames return result_dict -def _extract_equilibration_frames(mq: dict) -> list[Atoms]: - """Reconstruct Atoms snapshots from the final equilibration stage. - - Falls back to a single-element list with ``final_structure`` when no - simulation history is available. - """ - final_structure = mq["final_structure"] - history = mq.get("simulation_history") - if not history: - return [final_structure] - - last_stage = next((s for s in reversed(history) if s is not None), None) - if last_stage is None or "positions" not in last_stage: - return [final_structure] - - positions = last_stage["positions"] - cells = last_stage["cells"] - n_frames = len(positions) - - if n_frames <= 1: - return [final_structure] - - # Use final_structure as a template for chemical symbols, masses, etc. - frames: list[Atoms] = [] - for i in range(n_frames): - frame = final_structure.copy() - frame.set_positions(positions[i]) - frame.set_cell(cells[i]) - frame.set_pbc(True) - frame.wrap() - frames.append(frame) - - return frames - - # --------------------------------------------------------------------------- # Visualization helpers # --------------------------------------------------------------------------- diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/viscosity.py b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/viscosity.py index 279a402e..7d488f79 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/viscosity.py +++ b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/viscosity.py @@ -12,9 +12,7 @@ import math from typing import TYPE_CHECKING, Any -from amorphouspy.properties.viscosity import get_viscosity, viscosity_simulation - -from amorphouspy import melt_quench_simulation +from amorphouspy.pipelines.viscosity import run_viscosity_workflow if TYPE_CHECKING: from amorphouspy_api.models import JobSubmission, ViscosityAnalysis @@ -36,149 +34,10 @@ def run_viscosity(submission: JobSubmission, config: ViscosityAnalysis, result: n_timesteps=config.n_timesteps, n_print=config.n_print, max_lag=config.max_lag, - lammps_resource_dict=get_lammps_server_kwargs(), + server_kwargs=get_lammps_server_kwargs(), ) -def run_viscosity_workflow( - structure, - potential, - temperatures: list[float], - heating_rate: float, - cooling_rate: float, - timestep: float = 1.0, - n_timesteps: int = 10_000_000, - n_print: int = 1, - max_lag: int | None = 1_000_000, - lammps_resource_dict: dict[str, Any] | None = None, -) -> dict[str, Any]: - """Run viscosity analysis at multiple temperatures. - - The workflow starts from the quenched structure produced by the - melt-quench pipeline — it does **not** regenerate the structure or - potential. - - Args: - structure: Quenched ASE Atoms from the melt-quench workflow. - potential: LAMMPS potential (from ``generate_potential``). - temperatures: Target temperatures (K) for viscosity runs. - heating_rate: Heating rate in K/ps (used when cooling between temps). - cooling_rate: Cooling rate in K/ps. - timestep: MD timestep in fs. - n_timesteps: Number of MD steps per viscosity production run. - n_print: Thermodynamic output frequency. - max_lag: Maximum correlation lag (steps) for Green-Kubo. - lammps_resource_dict: Resource dict for LAMMPS (e.g. {"cores": 4}). - - Returns: - Result dict suitable for storing in ``result_data["viscosity"]``. - """ - if lammps_resource_dict is None: - lammps_resource_dict = {} - - # Sequential cooling: highest T → lowest T - sorted_temps = sorted(temperatures, reverse=True) - logger.info("Viscosity temperatures (high→low): %s", sorted_temps) - - viscosities: list[float] = [] - all_max_lags: list[float] = [] - sim_steps: list[int] = [] - lag_times_ps: list[list[float]] = [] - viscosity_integral: list[list[float]] = [] - - structure_current = structure - - for idx, temp in enumerate(sorted_temps): - temp_high = 5000.0 if idx == 0 else sorted_temps[idx - 1] - - # Cool to this temperature via melt-quench - logger.info("Cooling from %.1f K to %.1f K", temp_high, temp) - mq_result = melt_quench_simulation( - structure=structure_current, - potential=potential, - temperature_high=float(temp_high), - temperature_low=float(temp), - timestep=1.0, - heating_rate=float(heating_rate), - cooling_rate=float(cooling_rate), - n_print=1000, - langevin=False, - server_kwargs=lammps_resource_dict, - ) - structure_current = mq_result["structure"] - logger.info("Cooled to %.1f K, %d atoms", temp, len(structure_current)) - - # Run viscosity production simulation - logger.info("Running viscosity simulation at %.1f K", temp) - visc_result = viscosity_simulation( - structure=structure_current, - potential=potential, - temperature_sim=float(temp), - timestep=float(timestep), - initial_production_steps=int(n_timesteps), - n_print=int(n_print), - langevin=False, - seed=12345, - server_kwargs=lammps_resource_dict, - ) - - # Post-process: Green-Kubo analysis - visc_data = get_viscosity(visc_result, timestep=float(timestep), max_lag=max_lag) - logger.info("Viscosity at %.1f K: %.3e Pa·s", temp, visc_data["viscosity"]) - - viscosities.append(float(visc_data["viscosity"])) - all_max_lags.append(float(visc_data["max_lag"])) - sim_steps.append(int(n_timesteps)) - raw_lag: list[float] = list(visc_data.get("lag_time_ps") or []) # type: ignore[ty:invalid-argument-type, ty:invalid-assignment] - raw_visc: list[float] = list(visc_data.get("viscosity_integral") or []) # type: ignore[ty:invalid-argument-type, ty:invalid-assignment] - lag_times_ps.append(_downsample_log(raw_lag)) - viscosity_integral.append(_downsample_log(raw_visc)) - - return { - "temperatures": sorted_temps, - "viscosities": viscosities, - "max_lag": all_max_lags, - "simulation_steps": sim_steps, - "lag_times_ps": lag_times_ps, - "viscosity_integral": viscosity_integral, - } - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_MAX_PLOT_POINTS = 1000 - - -def _downsample_log(arr: list[float], max_points: int = _MAX_PLOT_POINTS) -> list[float]: - """Downsample *arr* to *max_points* using log-spaced indices. - - The convergence plot uses a logarithmic x-axis, so log-spaced - sampling preserves visual fidelity while drastically reducing - the amount of data stored in the database. - """ - n = len(arr) - if n <= max_points: - return arr - # log-spaced indices from 0 to n-1, always including the last point - indices = sorted({round(v) for v in _logspace(0, n - 1, max_points)}) - return [arr[i] for i in indices] - - -def _logspace(start: float, stop: float, num: int) -> list[float]: - """Return *num* values log-spaced between *start* and *stop* (inclusive).""" - if num <= 0: - return [] - if num == 1: - return [stop] - # shift by 1 so log(0) is avoided - log_start = math.log10(start + 1) - log_stop = math.log10(stop + 1) - step = (log_stop - log_start) / (num - 1) - return [10 ** (log_start + i * step) - 1 for i in range(num)] - - # --------------------------------------------------------------------------- # VFT fit: log10(η) = A + B / (T - T0) # --------------------------------------------------------------------------- diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/meltquench.py b/amorphouspy_api/src/amorphouspy_api/workflows/meltquench.py index ded84fad..c4e35e1c 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/meltquench.py +++ b/amorphouspy_api/src/amorphouspy_api/workflows/meltquench.py @@ -14,14 +14,8 @@ import logging from typing import TYPE_CHECKING -import numpy as np - -from amorphouspy import ( - generate_potential, - get_ase_structure, - get_structure_dict, - melt_quench_simulation, -) +from amorphouspy.pipelines.meltquench import generate_structure as _generate_structure +from amorphouspy.pipelines.meltquench import run_melt_quench as _run_melt_quench if TYPE_CHECKING: from pydantic import BaseModel @@ -37,29 +31,14 @@ def generate_structure(submission: "JobSubmission", config: "BaseModel", result: Returns a dict with ``atoms_dict``, ``structure`` (ASE Atoms as dict), and ``potential``. """ - composition = submission.composition.root - n_atoms = submission.simulation.n_atoms - potential_type = submission.potential - density = submission.simulation.target_density - - structure_seed = submission.simulation.structure_seed - - atoms_dict = get_structure_dict( - composition=composition, target_atoms=n_atoms, density=density, random_seed=structure_seed + return _generate_structure( + composition=submission.composition.root, + n_atoms=submission.simulation.n_atoms, + potential_type=submission.potential, + density=submission.simulation.target_density, + structure_seed=submission.simulation.structure_seed, + electrostatics_config=submission.electrostatics.to_electrostatics_config(), ) - structure = get_ase_structure(atoms_dict=atoms_dict) - potential = generate_potential( - atoms_dict=atoms_dict, - potential_type=potential_type, - melt=True, - electrostatics=submission.electrostatics.to_electrostatics_config(), - ) - - return { - "atoms_dict": atoms_dict, - "structure": structure, - "potential": potential, - } def run_melt_quench(submission: "JobSubmission", config: "BaseModel", result: dict) -> dict: @@ -73,52 +52,18 @@ def run_melt_quench(submission: "JobSubmission", config: "BaseModel", result: di """ from amorphouspy_api.executor import get_lammps_server_kwargs - structure = result["structure_generation"]["structure"] - potential = result["structure_generation"]["potential"] - - from amorphouspy.fabrication.meltquench_protocols import DEFAULT_MELT_TEMPERATURES - - heating_rate = int(submission.simulation.quench_rate * 100) - cooling_rate = int(submission.simulation.quench_rate) - - temperature_high = submission.simulation.melt_temperature - temperature_low = 300.0 - timestep = submission.simulation.timestep - - # Resolve protocol default so the stored result always has the actual value - if temperature_high is None: - temperature_high = DEFAULT_MELT_TEMPERATURES.get(submission.potential, 5000.0) - - mq = melt_quench_simulation( - structure=structure, - potential=potential, - n_print=100000, - heating_rate=heating_rate, - cooling_rate=cooling_rate, - timestep=timestep, - temperature_high=temperature_high, - temperature_low=temperature_low, + mq_result = _run_melt_quench( + structure=result["structure_generation"]["structure"], + potential=result["structure_generation"]["potential"], + potential_type=submission.potential, + heating_rate=int(submission.simulation.quench_rate * 100), + cooling_rate=int(submission.simulation.quench_rate), + timestep=submission.simulation.timestep, + temperature_high=submission.simulation.melt_temperature, + temperature_low=300.0, equilibration_steps=submission.simulation.equilibration_steps, - langevin=False, + n_averaging_frames=getattr(submission.simulation, "n_averaging_frames", 100), server_kwargs=get_lammps_server_kwargs(), ) - - last_stage = next((s for s in reversed(mq["result"]) if s is not None), {}) - - return { - "composition": submission.composition.root, - "final_structure": mq["structure"], - "mean_temperature": float(np.mean(last_stage["temperature"])), - "simulation_steps": len(last_stage["steps"]), - "temperature_trajectory": [float(t) for t in last_stage["temperature"]], - "steps_trajectory": [int(s) for s in last_stage["steps"]], - "simulation_history": mq["result"], - "timestep": timestep, - "cooling_rate": cooling_rate, - "heating_rate": heating_rate, - "temperature_high": temperature_high, - "temperature_low": temperature_low, - "n_averaging_frames": submission.simulation.n_averaging_frames - if hasattr(submission.simulation, "n_averaging_frames") - else 100, - } + mq_result["composition"] = submission.composition.root + return mq_result diff --git a/amorphouspy_api/src/tests/test_jobs.py b/amorphouspy_api/src/tests/test_jobs.py index c6b0fbef..aaa1b8ca 100644 --- a/amorphouspy_api/src/tests/test_jobs.py +++ b/amorphouspy_api/src/tests/test_jobs.py @@ -1238,9 +1238,9 @@ def test_generate_structure_passes_structure_seed(): simulation=MeltQuenchParams(structure_seed=777), ) with ( - patch("amorphouspy_api.workflows.meltquench.get_structure_dict") as mock_gsd, - patch("amorphouspy_api.workflows.meltquench.get_ase_structure"), - patch("amorphouspy_api.workflows.meltquench.generate_potential"), + patch("amorphouspy.pipelines.meltquench.get_structure_dict") as mock_gsd, + patch("amorphouspy.pipelines.meltquench.get_ase_structure"), + patch("amorphouspy.pipelines.meltquench.generate_potential"), ): mock_gsd.return_value = {} generate_structure(sub, MagicMock(), {}) From b855f0ef26f00554003c88e1c2e1e2128c28d31b Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Fri, 29 May 2026 17:04:53 +0200 Subject: [PATCH 2/2] chore: drop workflows/ dir in API Move visualization-related functions into separate visualization/ folder. --- .../src/amorphouspy_api/pipeline.py | 312 ++++++++++++++++++ .../src/amorphouspy_api/routers/glasses.py | 2 +- .../src/amorphouspy_api/routers/jobs.py | 2 +- .../amorphouspy_api/routers/jobs_helpers.py | 12 +- .../amorphouspy_api/visualization/__init__.py | 21 ++ .../analyses => visualization}/cte.py | 76 +---- .../analyses => visualization}/elastic.py | 45 +-- .../meltquench.py} | 4 +- .../analyses => visualization}/structure.py | 25 +- .../analyses => visualization}/viscosity.py | 59 +--- .../src/amorphouspy_api/workflows/__init__.py | 136 -------- .../workflows/analyses/__init__.py | 8 - .../amorphouspy_api/workflows/meltquench.py | 69 ---- amorphouspy_api/src/tests/test_jobs.py | 2 +- 14 files changed, 366 insertions(+), 407 deletions(-) create mode 100644 amorphouspy_api/src/amorphouspy_api/pipeline.py create mode 100644 amorphouspy_api/src/amorphouspy_api/visualization/__init__.py rename amorphouspy_api/src/amorphouspy_api/{workflows/analyses => visualization}/cte.py (76%) rename amorphouspy_api/src/amorphouspy_api/{workflows/analyses => visualization}/elastic.py (59%) rename amorphouspy_api/src/amorphouspy_api/{workflows/analyses/meltquench_viz.py => visualization/meltquench.py} (98%) rename amorphouspy_api/src/amorphouspy_api/{workflows/analyses => visualization}/structure.py (83%) rename amorphouspy_api/src/amorphouspy_api/{workflows/analyses => visualization}/viscosity.py (85%) delete mode 100644 amorphouspy_api/src/amorphouspy_api/workflows/__init__.py delete mode 100644 amorphouspy_api/src/amorphouspy_api/workflows/analyses/__init__.py delete mode 100644 amorphouspy_api/src/amorphouspy_api/workflows/meltquench.py diff --git a/amorphouspy_api/src/amorphouspy_api/pipeline.py b/amorphouspy_api/src/amorphouspy_api/pipeline.py new file mode 100644 index 00000000..2f7ef0c8 --- /dev/null +++ b/amorphouspy_api/src/amorphouspy_api/pipeline.py @@ -0,0 +1,312 @@ +"""Pipeline orchestration for amorphouspy API. + +Registers step functions that adapt pydantic models to core library calls, +and provides ``submit_pipeline`` to wire them into an executorlib DAG. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from concurrent.futures import Future + + from executorlib.executor.base import BaseExecutor + from pydantic import BaseModel + + from amorphouspy_api.models import ( + CTEFluctuations, + CTETemperatureScan, + ElasticAnalysis, + JobSubmission, + StructureAnalysis, + ViscosityAnalysis, + ) + +logger = logging.getLogger(__name__) + +AnalysisFn = Callable[..., dict] + + +# --------------------------------------------------------------------------- +# Step wrapper functions +# --------------------------------------------------------------------------- + + +def _generate_structure(submission: JobSubmission, config: BaseModel, result: dict) -> dict: + """Generate initial structure and potential from composition.""" + from amorphouspy.pipelines.meltquench import generate_structure + + return generate_structure( + composition=submission.composition.root, + n_atoms=submission.simulation.n_atoms, + potential_type=submission.potential, + density=submission.simulation.target_density, + structure_seed=submission.simulation.structure_seed, + electrostatics_config=submission.electrostatics.to_electrostatics_config(), + ) + + +def _run_melt_quench(submission: JobSubmission, config: BaseModel, result: dict) -> dict: + """Run the LAMMPS melt-quench simulation.""" + from amorphouspy.pipelines.meltquench import run_melt_quench + + from amorphouspy_api.executor import get_lammps_server_kwargs + + mq_result = run_melt_quench( + structure=result["structure_generation"]["structure"], + potential=result["structure_generation"]["potential"], + potential_type=submission.potential, + heating_rate=int(submission.simulation.quench_rate * 100), + cooling_rate=int(submission.simulation.quench_rate), + timestep=submission.simulation.timestep, + temperature_high=submission.simulation.melt_temperature, + temperature_low=300.0, + equilibration_steps=submission.simulation.equilibration_steps, + n_averaging_frames=getattr(submission.simulation, "n_averaging_frames", 100), + server_kwargs=get_lammps_server_kwargs(), + ) + mq_result["composition"] = submission.composition.root + return mq_result + + +def _run_structural_analysis(submission: JobSubmission, config: StructureAnalysis, result: dict) -> dict: + """Structural analysis (RDF, coordination, bond angles) on the quenched glass.""" + from amorphouspy.pipelines.structural import run_structural_analysis + + mq = result["melt_quench"] + mean_data, _sem_data, n_frames = run_structural_analysis( + final_structure=mq["final_structure"], + simulation_history=mq.get("simulation_history"), + ) + result_dict = mean_data.model_dump() + result_dict["n_averaging_frames"] = n_frames + return result_dict + + +def _run_viscosity(submission: JobSubmission, config: ViscosityAnalysis, result: dict) -> dict: + """Multi-temperature viscosity analysis on the quenched glass.""" + from amorphouspy.pipelines.viscosity import run_viscosity_workflow + + from amorphouspy_api.executor import get_lammps_server_kwargs + + return run_viscosity_workflow( + structure=result["melt_quench"]["final_structure"], + potential=result["structure_generation"]["potential"], + temperatures=config.temperatures, + heating_rate=int(submission.simulation.quench_rate * 100), + cooling_rate=int(submission.simulation.quench_rate), + timestep=config.timestep, + n_timesteps=config.n_timesteps, + n_print=config.n_print, + max_lag=config.max_lag, + server_kwargs=get_lammps_server_kwargs(), + ) + + +def _run_cte(submission: JobSubmission, config: CTEFluctuations | CTETemperatureScan, result: dict) -> dict: + """CTE analysis via fluctuations or temperature scan.""" + from amorphouspy.properties.cte import cte_from_fluctuations_simulation, temperature_scan_simulation + + from amorphouspy_api.executor import get_lammps_server_kwargs + from amorphouspy_api.models import CTEFluctuations, CTETemperatureScan + + potential = result["structure_generation"]["potential"] + structure = result["melt_quench"]["final_structure"] + resource_dict = get_lammps_server_kwargs() + + if isinstance(config, CTEFluctuations): + cte_result = cte_from_fluctuations_simulation( + structure=structure, + potential=potential, + temperature=config.temperature, + pressure=config.pressure, + timestep=config.timestep, + equilibration_steps=config.equilibration_steps, + production_steps=config.production_steps, + min_production_runs=config.min_production_runs, + max_production_runs=config.max_production_runs, + CTE_uncertainty_criterion=config.cte_uncertainty_criterion, + server_kwargs=resource_dict, + ) + cte_result["metadata"] = { + "temperature": config.temperature, + "production_steps": config.production_steps, + "timestep": config.timestep, + } + return cte_result + + assert isinstance(config, CTETemperatureScan) + cte_result = temperature_scan_simulation( + structure=structure, + potential=potential, + temperature=config.temperatures, + pressure=config.pressure, + timestep=config.timestep, + equilibration_steps=config.equilibration_steps, + production_steps=config.production_steps, + server_kwargs=resource_dict, + ) + cte_result["metadata"] = { + "temperatures": config.temperatures, + "production_steps": config.production_steps, + "timestep": config.timestep, + } + return cte_result + + +def _run_elastic(submission: JobSubmission, config: ElasticAnalysis, result: dict) -> dict: + """Elastic moduli analysis on the quenched glass.""" + from amorphouspy.properties.elastic import elastic_simulation + + from amorphouspy_api.executor import get_lammps_server_kwargs + + raw = elastic_simulation( + structure=result["melt_quench"]["final_structure"], + potential=result["structure_generation"]["potential"], + temperature_sim=config.temperature, + pressure=config.pressure, + timestep=config.timestep, + equilibration_steps=config.equilibration_steps, + production_steps=config.production_steps, + n_print=config.n_print, + strain=config.strain, + server_kwargs=get_lammps_server_kwargs(), + ) + + cij = raw.get("Cij") + if cij is not None and hasattr(cij, "tolist"): + cij = cij.tolist() + + return {"Cij": cij, "moduli": raw.get("moduli", {})} + + +# --------------------------------------------------------------------------- +# Step registry +# --------------------------------------------------------------------------- + +STEPS: dict[str, AnalysisFn] = { + "structure_generation": _generate_structure, + "melt_quench": _run_melt_quench, + "structure_characterization": _run_structural_analysis, + "viscosity": _run_viscosity, + "cte": _run_cte, + "elastic": _run_elastic, +} + +BASE_STEPS = {"structure_generation", "melt_quench"} +ANALYSES: dict[str, AnalysisFn] = {k: v for k, v in STEPS.items() if k not in BASE_STEPS} + +__all__ = ["ANALYSES", "BASE_STEPS", "STEPS", "submit_pipeline"] + + +# --------------------------------------------------------------------------- +# DAG orchestration +# --------------------------------------------------------------------------- + + +def _accumulate_step( + step_name: str, + step_fn: AnalysisFn, + submission: JobSubmission, + config: BaseModel | None, + accumulated: dict, +) -> dict: + """Run one pipeline step and merge its output into the accumulated dict.""" + step_result = step_fn(submission, config, accumulated) + return {**accumulated, step_name: step_result} + + +def _run_analysis( + step_name: str, + step_fn: AnalysisFn, + submission: JobSubmission, + config: BaseModel, + base_result: dict, +) -> dict: + """Run a single analysis step. Returns ``{step_name: result}``.""" + step_result = step_fn(submission, config, base_result) + return {step_name: step_result} + + +def _merge_results(base_result: dict, **analysis_results: dict) -> dict: + """Merge the base pipeline result with individual analysis outputs.""" + merged = dict(base_result) + for result_dict in analysis_results.values(): + merged.update(result_dict) + return merged + + +def submit_pipeline( + executor: BaseExecutor, + submission: JobSubmission, + cache_key: str | None = None, +) -> Future: + """Submit all pipeline steps as executor futures. + + Base steps (structure_generation, melt_quench) run sequentially. + Requested analyses then fan out **in parallel** from the base result. + A final merge step collects everything under the bare *cache_key*. + """ + from amorphouspy_api.executor import _is_slurm, get_base_resource_dict, get_lammps_resource_dict + + base_resource_dict = get_base_resource_dict() + lammps_resource_dict = get_lammps_resource_dict() + + # Steps that run LAMMPS simulations and need multi-core SBATCH allocation. + LAMMPS_STEPS = {"melt_quench", "cte", "viscosity", "elastic"} + + # --- Base steps: sequential chain --- + future = None + for name in ("structure_generation", "melt_quench"): + rd = lammps_resource_dict if name in LAMMPS_STEPS else base_resource_dict + resource_dict = dict(rd) + if _is_slurm(): + resource_dict["job_name"] = name + if cache_key is not None: + resource_dict["cache_key"] = f"{cache_key}_{name}" + future = executor.submit( + _accumulate_step, + resource_dict=resource_dict, + step_name=name, + step_fn=STEPS[name], + submission=submission, + config=None, + accumulated=future if future is not None else {}, + ) + + base_future = future # contains structure_generation + melt_quench + + # --- Analysis steps: fan-out in parallel from base_future --- + analysis_configs = {a.type: a for a in submission.analyses} + analysis_futures: dict[str, Future] = {} + for name, config in analysis_configs.items(): + if name in ANALYSES: + rd = lammps_resource_dict if name in LAMMPS_STEPS else base_resource_dict + resource_dict = dict(rd) + if _is_slurm(): + resource_dict["job_name"] = name + if cache_key is not None: + resource_dict["cache_key"] = f"{cache_key}_{name}" + analysis_futures[name] = executor.submit( + _run_analysis, + resource_dict=resource_dict, + step_name=name, + step_fn=ANALYSES[name], + submission=submission, + config=config, + base_result=base_future, + ) + + # --- Merge step: collects base + all analysis results --- + merge_resource: dict[str, Any] = dict(base_resource_dict) + if _is_slurm(): + merge_resource["job_name"] = "merge_results" + if cache_key is not None: + merge_resource["cache_key"] = cache_key + merge_kwargs: dict[str, dict | Future | None] = {"base_result": base_future} + merge_kwargs.update(analysis_futures) + + return executor.submit(_merge_results, resource_dict=merge_resource, **merge_kwargs) diff --git a/amorphouspy_api/src/amorphouspy_api/routers/glasses.py b/amorphouspy_api/src/amorphouspy_api/routers/glasses.py index a7fa147e..bcd358fa 100644 --- a/amorphouspy_api/src/amorphouspy_api/routers/glasses.py +++ b/amorphouspy_api/src/amorphouspy_api/routers/glasses.py @@ -30,12 +30,12 @@ GlassSummary, _job_urls, ) +from amorphouspy_api.pipeline import ANALYSES from amorphouspy_api.routers.jobs_helpers import ( _analyses_list, find_close_matches, oxide_to_elemental_vector, ) -from amorphouspy_api.workflows import ANALYSES logger = logging.getLogger(__name__) diff --git a/amorphouspy_api/src/amorphouspy_api/routers/jobs.py b/amorphouspy_api/src/amorphouspy_api/routers/jobs.py index 5a907e8c..160c7b0b 100644 --- a/amorphouspy_api/src/amorphouspy_api/routers/jobs.py +++ b/amorphouspy_api/src/amorphouspy_api/routers/jobs.py @@ -50,6 +50,7 @@ _job_urls, validate_atoms, ) +from amorphouspy_api.pipeline import ANALYSES from amorphouspy_api.routers.jobs_helpers import ( _analyses_list, _initial_progress, @@ -61,7 +62,6 @@ build_visualization_context, refresh_job_from_cache, ) -from amorphouspy_api.workflows import ANALYSES logger = logging.getLogger(__name__) diff --git a/amorphouspy_api/src/amorphouspy_api/routers/jobs_helpers.py b/amorphouspy_api/src/amorphouspy_api/routers/jobs_helpers.py index 9cd10075..72db7aea 100644 --- a/amorphouspy_api/src/amorphouspy_api/routers/jobs_helpers.py +++ b/amorphouspy_api/src/amorphouspy_api/routers/jobs_helpers.py @@ -21,7 +21,7 @@ JobSubmission, StepStatus, ) -from amorphouspy_api.workflows import ANALYSES, BASE_STEPS, submit_pipeline +from amorphouspy_api.pipeline import ANALYSES, BASE_STEPS, submit_pipeline if TYPE_CHECKING: from amorphouspy_api.database import Job @@ -429,9 +429,9 @@ def _add_optional_analyses(context: dict, result_data: dict, request_data: dict """Populate context with viscosity / CTE / elastic plots if available.""" import json - from amorphouspy_api.workflows.analyses.cte import prepare_cte_plots - from amorphouspy_api.workflows.analyses.elastic import prepare_elastic_plots - from amorphouspy_api.workflows.analyses.viscosity import prepare_viscosity_plots + from amorphouspy_api.visualization.cte import prepare_cte_plots + from amorphouspy_api.visualization.elastic import prepare_elastic_plots + from amorphouspy_api.visualization.viscosity import prepare_viscosity_plots visc_data = result_data.get("viscosity") if visc_data: @@ -588,11 +588,11 @@ def build_visualization_context( available are simply omitted from the context so the template can conditionally skip them. """ - from amorphouspy_api.workflows.analyses.meltquench_viz import ( + from amorphouspy_api.visualization.meltquench import ( build_temperature_time_plot, prepare_timing_context, ) - from amorphouspy_api.workflows.analyses.structure import prepare_structure_context + from amorphouspy_api.visualization.structure import prepare_structure_context mq = result_data.get("melt_quench", {}) diff --git a/amorphouspy_api/src/amorphouspy_api/visualization/__init__.py b/amorphouspy_api/src/amorphouspy_api/visualization/__init__.py new file mode 100644 index 00000000..d3f5b3f1 --- /dev/null +++ b/amorphouspy_api/src/amorphouspy_api/visualization/__init__.py @@ -0,0 +1,21 @@ +"""Visualization helpers for the amorphouspy API. + +Each module provides ``prepare_*_plots()`` entry points that accept raw +result dicts and return JSON-encoded Plotly figure dicts ready for the +front-end. +""" + +from amorphouspy_api.visualization.cte import prepare_cte_plots +from amorphouspy_api.visualization.elastic import prepare_elastic_plots +from amorphouspy_api.visualization.meltquench import build_temperature_time_plot, prepare_timing_context +from amorphouspy_api.visualization.structure import prepare_structure_context +from amorphouspy_api.visualization.viscosity import prepare_viscosity_plots + +__all__ = [ + "build_temperature_time_plot", + "prepare_cte_plots", + "prepare_elastic_plots", + "prepare_structure_context", + "prepare_timing_context", + "prepare_viscosity_plots", +] diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/cte.py b/amorphouspy_api/src/amorphouspy_api/visualization/cte.py similarity index 76% rename from amorphouspy_api/src/amorphouspy_api/workflows/analyses/cte.py rename to amorphouspy_api/src/amorphouspy_api/visualization/cte.py index 6bd1d3c7..f47c0e32 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/cte.py +++ b/amorphouspy_api/src/amorphouspy_api/visualization/cte.py @@ -1,82 +1,15 @@ -"""CTE workflow wrappers for the amorphouspy API. - -Thin wrappers around the core CTE simulation functions that adapt them -to the API pipeline calling convention. -""" +"""CTE visualization helpers (convergence, summary, V-T plots).""" from __future__ import annotations -import logging -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from amorphouspy_api.models import CTEFluctuations, CTETemperatureScan, JobSubmission - -logger = logging.getLogger(__name__) - - -def run_cte(submission: JobSubmission, config: CTEFluctuations | CTETemperatureScan, result: dict) -> dict: - """CTE analysis via fluctuations or temperature scan.""" - from amorphouspy.properties.cte import cte_from_fluctuations_simulation, temperature_scan_simulation - - from amorphouspy_api.executor import get_lammps_server_kwargs - from amorphouspy_api.models import CTEFluctuations, CTETemperatureScan - - potential = result["structure_generation"]["potential"] - structure = result["melt_quench"]["final_structure"] - resource_dict = get_lammps_server_kwargs() - - if isinstance(config, CTEFluctuations): - cte_result = cte_from_fluctuations_simulation( - structure=structure, - potential=potential, - temperature=config.temperature, - pressure=config.pressure, - timestep=config.timestep, - equilibration_steps=config.equilibration_steps, - production_steps=config.production_steps, - min_production_runs=config.min_production_runs, - max_production_runs=config.max_production_runs, - CTE_uncertainty_criterion=config.cte_uncertainty_criterion, - server_kwargs=resource_dict, - ) - cte_result["metadata"] = { - "temperature": config.temperature, - "production_steps": config.production_steps, - "timestep": config.timestep, - } - return cte_result - - assert isinstance(config, CTETemperatureScan) - cte_result = temperature_scan_simulation( - structure=structure, - potential=potential, - temperature=config.temperatures, - pressure=config.pressure, - timestep=config.timestep, - equilibration_steps=config.equilibration_steps, - production_steps=config.production_steps, - server_kwargs=resource_dict, - ) - cte_result["metadata"] = { - "temperatures": config.temperatures, - "production_steps": config.production_steps, - "timestep": config.timestep, - } - return cte_result - - -# --------------------------------------------------------------------------- -# Visualization helpers -# --------------------------------------------------------------------------- +import math +from typing import Any def _cumulative_mean_and_uncertainty( values: list[float], ) -> tuple[list[float], list[float]]: """Compute running mean and standard-error-of-the-mean for a list of values.""" - import math - means: list[float] = [] uncertainties: list[float] = [] running_sum = 0.0 @@ -304,6 +237,9 @@ def prepare_cte_plots(cte_data: dict[str, Any]) -> dict[str, str]: conv_fig = _build_cte_convergence_plot(data, metadata=cte_data.get("metadata")) if conv_fig: plots["convergence"] = json.dumps(conv_fig) + summary_fig = _build_cte_summary_plot(summary) + if summary_fig: + plots["summary"] = json.dumps(summary_fig) else: # Temperature scan method — top-level keys are "01_300K", etc. vt_fig = _build_cte_vt_plot(cte_data) diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/elastic.py b/amorphouspy_api/src/amorphouspy_api/visualization/elastic.py similarity index 59% rename from amorphouspy_api/src/amorphouspy_api/workflows/analyses/elastic.py rename to amorphouspy_api/src/amorphouspy_api/visualization/elastic.py index 41a4ec0e..fee9fdb3 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/elastic.py +++ b/amorphouspy_api/src/amorphouspy_api/visualization/elastic.py @@ -1,49 +1,8 @@ -"""Elastic moduli workflow wrapper for the amorphouspy API. - -Thin wrapper around the core ``elastic_simulation`` function -that adapts it to the API calling convention. -""" +"""Elastic moduli visualization helpers (bar chart, Cij heatmap).""" from __future__ import annotations -import logging -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from amorphouspy_api.models import ElasticAnalysis, JobSubmission - -logger = logging.getLogger(__name__) - - -def run_elastic(submission: JobSubmission, config: ElasticAnalysis, result: dict) -> dict: - """Elastic moduli analysis on the quenched glass.""" - from amorphouspy.properties.elastic import elastic_simulation - - from amorphouspy_api.executor import get_lammps_server_kwargs - - raw = elastic_simulation( - structure=result["melt_quench"]["final_structure"], - potential=result["structure_generation"]["potential"], - temperature_sim=config.temperature, - pressure=config.pressure, - timestep=config.timestep, - equilibration_steps=config.equilibration_steps, - production_steps=config.production_steps, - n_print=config.n_print, - strain=config.strain, - server_kwargs=get_lammps_server_kwargs(), - ) - - cij = raw.get("Cij") - if cij is not None and hasattr(cij, "tolist"): - cij = cij.tolist() - - return {"Cij": cij, "moduli": raw.get("moduli", {})} - - -# --------------------------------------------------------------------------- -# Visualization helpers -# --------------------------------------------------------------------------- +from typing import Any def _build_elastic_moduli_plot(moduli: dict[str, float]) -> dict: diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/meltquench_viz.py b/amorphouspy_api/src/amorphouspy_api/visualization/meltquench.py similarity index 98% rename from amorphouspy_api/src/amorphouspy_api/workflows/analyses/meltquench_viz.py rename to amorphouspy_api/src/amorphouspy_api/visualization/meltquench.py index 24e75ef4..6a849956 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/meltquench_viz.py +++ b/amorphouspy_api/src/amorphouspy_api/visualization/meltquench.py @@ -122,12 +122,12 @@ def build_temperature_time_plot(mq_data: dict[str, Any]) -> str | None: dt_ps = timestep_fs * fs_to_ps # Reconstruct protocol stages (PMMCS-like): - # Stage 1: Heat T_low → T_high + # Stage 1: Heat T_low -> T_high delta_t = t_high - t_low heating_steps = int((delta_t / (timestep_fs * heating_rate)) * seconds_to_fs) # Stage 2: Equilibration at T_high (10k steps) equil_high_steps = 10_000 - # Stage 3: Cool T_high → T_low + # Stage 3: Cool T_high -> T_low cooling_steps = int((delta_t / (timestep_fs * cooling_rate)) * seconds_to_fs) # Stage 4: Pressure release at T_low (10k steps) pressure_release_steps = 10_000 diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/structure.py b/amorphouspy_api/src/amorphouspy_api/visualization/structure.py similarity index 83% rename from amorphouspy_api/src/amorphouspy_api/workflows/analyses/structure.py rename to amorphouspy_api/src/amorphouspy_api/visualization/structure.py index 0b9fe6ae..a0659bd7 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/structure.py +++ b/amorphouspy_api/src/amorphouspy_api/visualization/structure.py @@ -1,4 +1,4 @@ -"""Structural analysis (RDF, coordination, bond angles) on quenched glass.""" +"""Structural analysis visualization helpers (Plotly JSON, 3D viewer XYZ).""" from __future__ import annotations @@ -9,32 +9,9 @@ if TYPE_CHECKING: from ase import Atoms - from amorphouspy_api.models import JobSubmission, StructureAnalysis - logger = logging.getLogger(__name__) -def run_structural_analysis(submission: JobSubmission, config: StructureAnalysis, result: dict) -> dict: - """Structural analysis (RDF, coordination, bond angles) on the quenched glass.""" - from amorphouspy.pipelines.structural import run_structural_analysis as _run_structural - - mq = result["melt_quench"] - - mean_data, _sem_data, n_frames = _run_structural( - final_structure=mq["final_structure"], - simulation_history=mq.get("simulation_history"), - ) - - result_dict = mean_data.model_dump() - result_dict["n_averaging_frames"] = n_frames - return result_dict - - -# --------------------------------------------------------------------------- -# Visualization helpers -# --------------------------------------------------------------------------- - - def _atoms_to_xyz_string(atoms: Atoms | dict | str | None) -> str: """Convert ASE Atoms object to extended XYZ format string for 3Dmol.js.""" if atoms is None: diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/viscosity.py b/amorphouspy_api/src/amorphouspy_api/visualization/viscosity.py similarity index 85% rename from amorphouspy_api/src/amorphouspy_api/workflows/analyses/viscosity.py rename to amorphouspy_api/src/amorphouspy_api/visualization/viscosity.py index 7d488f79..e09e60b8 100644 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/viscosity.py +++ b/amorphouspy_api/src/amorphouspy_api/visualization/viscosity.py @@ -1,45 +1,12 @@ -"""Viscosity workflow for glass simulation. - -Runs Green-Kubo viscosity calculations at multiple temperatures starting -from an already-quenched glass structure. The structure is sequentially -cooled from the highest to the lowest requested temperature, and at each -step a production MD run is performed followed by post-processing. -""" +"""Viscosity visualization helpers (VFT fitting, Arrhenius, convergence plots).""" from __future__ import annotations -import logging import math -from typing import TYPE_CHECKING, Any - -from amorphouspy.pipelines.viscosity import run_viscosity_workflow - -if TYPE_CHECKING: - from amorphouspy_api.models import JobSubmission, ViscosityAnalysis - -logger = logging.getLogger(__name__) - - -def run_viscosity(submission: JobSubmission, config: ViscosityAnalysis, result: dict) -> dict: - """Multi-temperature viscosity analysis on the quenched glass.""" - from amorphouspy_api.executor import get_lammps_server_kwargs - - return run_viscosity_workflow( - structure=result["melt_quench"]["final_structure"], - potential=result["structure_generation"]["potential"], - temperatures=config.temperatures, - heating_rate=int(submission.simulation.quench_rate * 100), - cooling_rate=int(submission.simulation.quench_rate), - timestep=config.timestep, - n_timesteps=config.n_timesteps, - n_print=config.n_print, - max_lag=config.max_lag, - server_kwargs=get_lammps_server_kwargs(), - ) - +from typing import Any # --------------------------------------------------------------------------- -# VFT fit: log10(η) = A + B / (T - T0) +# VFT fit: log10(n) = A + B / (T - T0) # --------------------------------------------------------------------------- @@ -98,7 +65,7 @@ def _vft_curve( def _t_at_log_viscosity(vft: dict[str, float], log_target: float) -> float | None: - """Solve VFT for the temperature (K) at which log10(η) = *log_target*. + """Solve VFT for the temperature (K) at which log10(n) = *log_target*. Returns None if the result is below T0 or non-physical. """ @@ -111,12 +78,12 @@ def _t_at_log_viscosity(vft: dict[str, float], log_target: float) -> float | Non return t -# Reference viscosity points (log10 in dPa·s) +# Reference viscosity points (log10 in dPa*s) _REFERENCE_POINTS = {"T4": 4.0, "T7.6": 7.6} # --------------------------------------------------------------------------- -# Visualization helpers +# Plotly figure builders # --------------------------------------------------------------------------- @@ -125,7 +92,7 @@ def _build_viscosity_vs_temperature_plot( viscosities: list[float], vft: dict[str, float] | None = None, ) -> dict: - """Build Plotly figure dict for viscosity vs temperature in dPa·s and °C.""" + """Build Plotly figure dict for viscosity vs temperature in dPa*s and degC.""" temps_c = [t - 273.15 for t in temperatures] visc_dpas = [v * 10 for v in viscosities] @@ -188,7 +155,7 @@ def _build_viscosity_vs_temperature_plot( { "x": t_ref_c, "y": log_v, - "text": f"{label} = {t_ref_c:.0f} °C", + "text": f"{label} = {t_ref_c:.0f} \u00b0C", "showarrow": True, "arrowhead": 2, "ax": 40, @@ -199,9 +166,9 @@ def _build_viscosity_vs_temperature_plot( layout: dict = { "title": {"text": "Viscosity vs Temperature", "font": {"size": 16}}, - "xaxis": {"title": {"text": "Temperature (°C)", "font": {"size": 14}}}, + "xaxis": {"title": {"text": "Temperature (\u00b0C)", "font": {"size": 14}}}, "yaxis": { - "title": {"text": "Viscosity (dPa·s)", "font": {"size": 14}}, + "title": {"text": "Viscosity (dPa\u00b7s)", "font": {"size": 14}}, "type": "log", "exponentformat": "e", "range": [log_min - 0.2, y_upper], @@ -285,7 +252,7 @@ def _build_arrhenius_plot( { "x": inv_t_ref, "y": log_v, - "text": f"{label} = {t_ref_c:.0f} °C", + "text": f"{label} = {t_ref_c:.0f} \u00b0C", "showarrow": True, "arrowhead": 2, "ax": -40, @@ -298,7 +265,7 @@ def _build_arrhenius_plot( "title": {"text": "Arrhenius Plot", "font": {"size": 16}}, "xaxis": {"title": {"text": "1000 / T (1/K)", "font": {"size": 14}}}, "yaxis": { - "title": {"text": "Viscosity (dPa·s)", "font": {"size": 14}}, + "title": {"text": "Viscosity (dPa\u00b7s)", "font": {"size": 14}}, "type": "log", "exponentformat": "e", "range": [log_min - 0.2, y_upper], @@ -352,7 +319,7 @@ def _build_running_viscosity_plot( } -def prepare_viscosity_plots(visc_data: dict[str, Any]) -> dict[str, str]: +def prepare_viscosity_plots(visc_data: dict[str, Any]) -> dict[str, Any]: """Build JSON-encoded Plotly plots from viscosity result data. Returns: diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/__init__.py b/amorphouspy_api/src/amorphouspy_api/workflows/__init__.py deleted file mode 100644 index 0f465740..00000000 --- a/amorphouspy_api/src/amorphouspy_api/workflows/__init__.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Workflow functions for amorphouspy API.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -from .analyses import run_cte, run_elastic, run_structural_analysis, run_viscosity -from .meltquench import generate_structure, run_melt_quench - -AnalysisFn = Callable[..., dict] - -STEPS: dict[str, AnalysisFn] = { - "structure_generation": generate_structure, - "melt_quench": run_melt_quench, - "structure_characterization": run_structural_analysis, - "viscosity": run_viscosity, - "cte": run_cte, - "elastic": run_elastic, -} - -BASE_STEPS = {"structure_generation", "melt_quench"} -ANALYSES: dict[str, AnalysisFn] = {k: v for k, v in STEPS.items() if k not in BASE_STEPS} - -if TYPE_CHECKING: - from concurrent.futures import Future - - from executorlib.executor.base import BaseExecutor - - from amorphouspy_api.models import JobSubmission - -__all__ = ["ANALYSES", "BASE_STEPS", "STEPS", "generate_structure", "run_melt_quench", "submit_pipeline"] - - -def _accumulate_step(step_name: str, step_fn, submission, config, accumulated: dict) -> dict: - """Run one pipeline step and merge its output into the accumulated dict. - - This function is submitted to executorlib; all arguments must be picklable. - """ - step_result = step_fn(submission, config, accumulated) - return {**accumulated, step_name: step_result} - - -def _run_analysis(step_name: str, step_fn, submission, config, base_result: dict) -> dict: - """Run a single analysis step. Returns ``{step_name: result}``. - - Unlike ``_accumulate_step`` this does **not** carry forward the full - accumulated dict — each analysis only receives the base pipeline result - (structure_generation + melt_quench) and works independently. - """ - step_result = step_fn(submission, config, base_result) - return {step_name: step_result} - - -def _merge_results(base_result: dict, **analysis_results: dict) -> dict: - """Merge the base pipeline result with individual analysis outputs.""" - merged = dict(base_result) - for result_dict in analysis_results.values(): - merged.update(result_dict) - return merged - - -def submit_pipeline( - executor: BaseExecutor, - submission: JobSubmission, - cache_key: str | None = None, -) -> Future: - """Submit all pipeline steps as executor futures. - - Base steps (structure_generation, melt_quench) run sequentially. - Requested analyses then fan out **in parallel** from the base result. - A final merge step collects everything under the bare *cache_key*. - - Each intermediate step gets ``{cache_key}_{step_name}`` so individual - progress can be queried via ``get_future_from_cache``. - """ - from amorphouspy_api.executor import _is_slurm, get_base_resource_dict, get_lammps_resource_dict - - base_resource_dict = get_base_resource_dict() - lammps_resource_dict = get_lammps_resource_dict() - - # Steps that run LAMMPS simulations and need multi-core SBATCH allocation. - LAMMPS_STEPS = {"melt_quench", "cte", "viscosity", "elastic"} - - # --- Base steps: sequential chain --- - future = None - for name in ("structure_generation", "melt_quench"): - rd = lammps_resource_dict if name in LAMMPS_STEPS else base_resource_dict - resource_dict = dict(rd) - if _is_slurm(): - resource_dict["job_name"] = name - if cache_key is not None: - resource_dict["cache_key"] = f"{cache_key}_{name}" - future = executor.submit( - _accumulate_step, - resource_dict=resource_dict, - step_name=name, - step_fn=STEPS[name], - submission=submission, - config=None, - accumulated=future if future is not None else {}, - ) - - base_future = future # contains structure_generation + melt_quench - - # --- Analysis steps: fan-out in parallel from base_future --- - analysis_configs = {a.type: a for a in submission.analyses} - analysis_futures: dict[str, Future] = {} - for name, config in analysis_configs.items(): - if name in ANALYSES: - rd = lammps_resource_dict if name in LAMMPS_STEPS else base_resource_dict - resource_dict = dict(rd) - if _is_slurm(): - resource_dict["job_name"] = name - if cache_key is not None: - resource_dict["cache_key"] = f"{cache_key}_{name}" - analysis_futures[name] = executor.submit( - _run_analysis, - resource_dict=resource_dict, - step_name=name, - step_fn=ANALYSES[name], - submission=submission, - config=config, - base_result=base_future, - ) - - # --- Merge step: collects base + all analysis results --- - merge_resource: dict[str, Any] = dict(base_resource_dict) - if _is_slurm(): - merge_resource["job_name"] = "merge_results" - if cache_key is not None: - merge_resource["cache_key"] = cache_key - merge_kwargs: dict[str, dict | Future | None] = {"base_result": base_future} - merge_kwargs.update(analysis_futures) - - return executor.submit(_merge_results, resource_dict=merge_resource, **merge_kwargs) diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/__init__.py b/amorphouspy_api/src/amorphouspy_api/workflows/analyses/__init__.py deleted file mode 100644 index 20970a95..00000000 --- a/amorphouspy_api/src/amorphouspy_api/workflows/analyses/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Analysis step implementations (structure_characterization, viscosity, cte, elastic).""" - -from amorphouspy_api.workflows.analyses.cte import run_cte -from amorphouspy_api.workflows.analyses.elastic import run_elastic -from amorphouspy_api.workflows.analyses.structure import run_structural_analysis -from amorphouspy_api.workflows.analyses.viscosity import run_viscosity - -__all__ = ["run_cte", "run_elastic", "run_structural_analysis", "run_viscosity"] diff --git a/amorphouspy_api/src/amorphouspy_api/workflows/meltquench.py b/amorphouspy_api/src/amorphouspy_api/workflows/meltquench.py deleted file mode 100644 index c4e35e1c..00000000 --- a/amorphouspy_api/src/amorphouspy_api/workflows/meltquench.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Meltquench workflow for glass simulation. - -This module contains the individual pipeline steps that use executorlib -to submit work with appropriate resources. - -The workflow is structured as: -1. ``generate_structure`` — structure generation and potential setup (lightweight) -2. ``run_melt_quench`` — LAMMPS melt-quench simulation (compute-intensive) - -Analysis (structural, viscosity, etc.) happens *after* the melt-quench -completes, via additional step functions registered in ``workflows.analyses``. -""" - -import logging -from typing import TYPE_CHECKING - -from amorphouspy.pipelines.meltquench import generate_structure as _generate_structure -from amorphouspy.pipelines.meltquench import run_melt_quench as _run_melt_quench - -if TYPE_CHECKING: - from pydantic import BaseModel - - from amorphouspy_api.models import JobSubmission - -logger = logging.getLogger(__name__) - - -def generate_structure(submission: "JobSubmission", config: "BaseModel", result: dict) -> dict: - """Generate initial structure and potential from composition. - - Returns a dict with ``atoms_dict``, ``structure`` (ASE Atoms as dict), - and ``potential``. - """ - return _generate_structure( - composition=submission.composition.root, - n_atoms=submission.simulation.n_atoms, - potential_type=submission.potential, - density=submission.simulation.target_density, - structure_seed=submission.simulation.structure_seed, - electrostatics_config=submission.electrostatics.to_electrostatics_config(), - ) - - -def run_melt_quench(submission: "JobSubmission", config: "BaseModel", result: dict) -> dict: - """Run the LAMMPS melt-quench simulation. - - Expects ``result`` to contain the output of ``generate_structure`` - (keys: ``structure``, ``potential``). - - Returns a dict with ``final_structure``, ``mean_temperature``, - ``simulation_steps``, and ``composition``. - """ - from amorphouspy_api.executor import get_lammps_server_kwargs - - mq_result = _run_melt_quench( - structure=result["structure_generation"]["structure"], - potential=result["structure_generation"]["potential"], - potential_type=submission.potential, - heating_rate=int(submission.simulation.quench_rate * 100), - cooling_rate=int(submission.simulation.quench_rate), - timestep=submission.simulation.timestep, - temperature_high=submission.simulation.melt_temperature, - temperature_low=300.0, - equilibration_steps=submission.simulation.equilibration_steps, - n_averaging_frames=getattr(submission.simulation, "n_averaging_frames", 100), - server_kwargs=get_lammps_server_kwargs(), - ) - mq_result["composition"] = submission.composition.root - return mq_result diff --git a/amorphouspy_api/src/tests/test_jobs.py b/amorphouspy_api/src/tests/test_jobs.py index aaa1b8ca..ef7a9d5e 100644 --- a/amorphouspy_api/src/tests/test_jobs.py +++ b/amorphouspy_api/src/tests/test_jobs.py @@ -1231,7 +1231,7 @@ def test_job_hash_differs_with_structure_seed(): def test_generate_structure_passes_structure_seed(): """generate_structure forwards structure_seed as random_seed to get_structure_dict.""" from amorphouspy_api.models import JobSubmission, MeltQuenchParams - from amorphouspy_api.workflows.meltquench import generate_structure + from amorphouspy_api.pipeline import _generate_structure as generate_structure sub = JobSubmission( composition={"SiO2": 100},