# -*- coding: utf-8 -*-
"""Non-graphical part of the Thermal Conductivity step in a SEAMM flowchart.
The thermal conductivity is calculated from the fluctuations of the heat flux in
equilibrium MD, by the Green-Kubo integral of its autocorrelation function and by the
Helfand moments. The MD is run before this step -- in a Loop for independent runs --
or read from files; the step analyzes it.
"""
import importlib
import logging
from math import floor, log10
from pathlib import Path
import warnings
import numpy as np
from seamm_md_analysis import (
Estimate,
collective_helfand_moments,
component_check,
fit_green_kubo_integral,
fit_helfand_moment,
helfand_slope,
isotropic_average,
isotropy,
effective_correlation_functions,
flux_correlation_matrices,
flux_helfand_matrices,
mean_and_stderr,
open_md,
schur_complement,
tensor_correlation_functions,
)
from seamm_md_analysis.requirements import Need, Requirements
from seamm_md_analysis.step_support import (
check_md_flowchart,
md_inputs,
print_details,
read_files,
report_state,
size_warnings,
state_data,
)
from seamm_md_analysis.plots import (
plot_correlation_functions,
plot_GK_integrals,
plot_helfand_moments,
plot_helfand_slopes,
)
from tabulate import tabulate
from .thermal_conductivity_parameters import ThermalConductivityParameters
import thermal_conductivity_step
import molsystem
import seamm
import seamm_util
from seamm_util import Q_
import seamm_util.printing as printing
from seamm_util.printing import FormattedText as __
# In addition to the normal logger, two logger-like printing facilities are
# defined: "job" and "printer". "job" send output to the main job.out file for
# the job, and should be used very sparingly, typically to echo what this step
# will do in the initial summary of the job.
#
# "printer" sends output to the file "step.out" in this steps working
# directory, and is used for all normal output from this step.
logger = logging.getLogger(__name__)
#: The shortest run, or block of a run, that can be analyzed: the correlation
#: functions are kept to a tenth of it, and fitting them needs a little over 0.8 ps.
MIN_RUN_TIME = Q_(10.0, "ps")
#: The methods, in the order of the tables
METHODS = ("Green-Kubo", "Helfand moments", "Helfand derivative")
#: The names of the diagonal and off-diagonal components
DIAGONAL = ("κ_xx", "κ_yy", "κ_zz")
OFF_DIAGONAL = ("κ_xy", "κ_xz", "κ_yz")
#: The results stored for each method: the key of κ and its standard error
RESULT_KEYS = {
"Green-Kubo": "kappa",
"Helfand moments": "kappa (Helfand)",
"Helfand derivative": "kappa (Helfand derivative)",
}
job = printing.getPrinter()
printer = printing.getPrinter("Thermal Conductivity")
# Add this module's properties to the standard properties
path = importlib.resources.files("thermal_conductivity_step") / "data"
csv_file = path / "properties.csv"
if path.exists():
molsystem.add_properties_from_file(csv_file)
[docs]
def external_engine(path):
"""The fix through which an external engine supplied the forces of the MD in
``path`` -- e.g. "mdi/qm" for a machine-learned forcefield or QM code over MDI --
or None. Such an engine gives LAMMPS no per-atom energies or stresses, so the
heat flux has only its kinetic term (2026-10-21: ChemAI job 5357 gave 72 ± 913
W/m/K for a carbonate electrolyte)."""
with open_md(path) as md:
fixes = md.parameters.get("fixes")
if fixes is None:
return None
if isinstance(fixes, str):
fixes = fixes.split()
for fix in fixes:
if str(fix).startswith("mdi"):
return str(fix)
return None
def _fit(function, *args, **kwargs):
"""The value of a fit, or NaN if it fails."""
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
result = function(*args, **kwargs)
except Exception:
return float("nan")
value = result.value if hasattr(result, "value") else result.slope
return float(value)
[docs]
class ThermalConductivity(seamm.Node):
"""
The non-graphical part of a Thermal Conductivity step in a flowchart.
Attributes
----------
parameters : ThermalConductivityParameters
The control parameters for Thermal Conductivity.
See Also
--------
TkThermalConductivity,
ThermalConductivity, ThermalConductivityParameters
"""
def __init__(
self,
flowchart=None,
title="Thermal Conductivity",
extension=None,
logger=logger,
):
"""A step for Thermal Conductivity in a SEAMM flowchart.
Parameters
----------
flowchart: seamm.Flowchart
The non-graphical flowchart that contains this step.
title: str
The name displayed in the flowchart.
extension: None
Not yet implemented
logger : Logger = logger
The logger to use and pass to parent classes
"""
logger.debug(f"Creating Thermal Conductivity {self}")
super().__init__(
flowchart=flowchart,
title="Thermal Conductivity",
extension=extension,
module=__name__,
logger=logger,
)
self._metadata = thermal_conductivity_step.metadata
self.parameters = ThermalConductivityParameters()
self.tensor_labels = [
("xx", "red", "rgba(255,0,0,0.1)"),
("yy", "green", "rgba(0,255,0,0.1)"),
("zz", "blue", "rgba(0,0,255,0.1)"),
("xy", "rgb(127,127,0)", "rgba(127,127,0,0.1)"),
("xz", "rgb(255,0,255)", "rgba(255,0,255,0.1)"),
("yz", "rgb(0,255,255)", "rgba(0,255,255,0.1)"),
]
self._reset()
# Set our citation level to 1!
self.citation_level = 1
def _reset(self):
"""Clear the data of earlier runs, e.g. from an earlier loop iteration."""
self.paths = [] # The md.h5md file of each run
self.V = [] # The volume of each run or block
self.T = [] # The temperature of each run or block
self.timestep = [] # The interval between samples of the heat flux, fs
self.Ms = [] # The Helfand moments of each run or block, 6 x m
self.M = None # The mean Helfand moments, 6 x m
self.M_err = None # Their spread over the runs or blocks, 6 x m
self.Jcfs = [] # The correlation functions of J, 6 x m, of each
self.Jcf = None # Their mean, 6 x m
self.Jcf_err = None # Their spread, 6 x m
self.GK_integrals = [] # The running integrals of the Jcf's, of each
self.GK_integral = None # Their mean
self.GK_integral_err = None # Their spread
# For a mixture, the Green-Kubo integrals without the correction for the
# interdiffusion of the species, of each run or block
self.raw_GK_integrals = []
self._species = None # The species of a mixture whose fluxes were sampled
# For a mixture, the matrices of the heat flux and species fluxes of each run
# or block: the correlation functions, their integrals, and Helfand moments
self._C_matrices = []
self._L_matrices = []
self._M_matrices = []
self._total_time = Q_(0.0, "ps") # The length of all the runs
self._isotropy = None # Whether the runs are isotropic, and why
@property
def version(self):
"""The semantic version of this module."""
return thermal_conductivity_step.__version__
@property
def git_revision(self):
"""The git version of this module."""
return thermal_conductivity_step.__git_revision__
[docs]
def create_parser(self):
"""Setup the command-line / config file parser"""
parser_name = "thermal-conductivity-step"
parser = seamm_util.getParser()
# Remember if the parser exists ... this type of step may have been
# found before
parser_exists = parser.exists(parser_name)
# Create the standard options, e.g. log-level
super().create_parser(name=parser_name)
if not parser_exists:
# Any options for thermal conductivity itself
parser.add_argument(
parser_name,
"--html",
action="store_true",
help="whether to write out html files for graphs, etc.",
)
return self.next()
[docs]
def description_text(self, P=None, short=False):
"""Create the text description of what this step will do.
Parameters
----------
P: dict
An optional dictionary of the current values of the control
parameters.
short : bool
Unused: the description is always short.
Returns
-------
str
A description of the current step.
"""
if P is None:
P = self.parameters.values_to_dict()
source = P["md source"]
text = (
"Calculate the thermal conductivity from the heat flux, by the Green-Kubo "
"and Helfand methods, from "
)
if source == read_files:
text += f"the MD in {P['md files']}."
else:
text += f"{source} earlier in the flowchart."
return self.header + "\n" + __(text, indent=4 * " ").__str__()
[docs]
def run(self):
"""Run a Thermal Conductivity step.
Returns
-------
seamm.Node
The next node object in the flowchart.
"""
next_node = super().run(printer)
# Get the values of the parameters, dereferencing any variables
P = self.parameters.current_values_to_dict(
context=seamm.flowchart_variables._data
)
# Print what we are doing
printer.important(__(self.description_text(P), indent=self.indent))
_, configuration = self.get_system_configuration()
self._reset()
self.paths = md_inputs(self, P, name="heat_flux")
if len(self.paths) == 0:
raise RuntimeError("No MD run finished, so there is nothing to analyze.")
fix = external_engine(self.paths[0])
if fix is not None:
printer.important(
__(
f"The forces in the MD came from an external engine (fix {fix}), "
"such as a machine-learned forcefield over MDI, which gives LAMMPS "
"no per-atom energies or stresses. The heat flux then lacks its "
"potential-energy and virial terms, so there is no thermal "
"conductivity to calculate: skipped.",
indent=self.indent + 4 * " ",
)
)
printer.important("")
return next_node
runs = [self._read_md_file(path) for path in self.paths]
if self._species is not None:
self.references.cite(
raw=self._bibliography["Bertossa2019"],
alias="Bertossa2019",
module="thermal_conductivity_step",
level=1,
note="The thermal conductivity of a mixture.",
)
text = f"Analyzing {len(runs)} MD run{'s' if len(runs) > 1 else ''}"
if len(runs) == 1:
source = "blocks"
samples = self._split_into_blocks(runs[0], P["number of blocks"])
text += f", divided into {len(samples)} blocks to estimate the errors"
else:
source = "runs"
samples = runs
text += ": " + ", ".join(str(p) for p in self.paths)
printer.job(__(text, indent=self.indent + 4 * " "))
printer.job("")
for i, data in enumerate(samples, start=1):
self.process_run(i, data, what="Block" if source == "blocks" else "Run")
self._combine()
self.analyze(
P=P,
configuration=configuration,
source=source,
uncorrected=self._uncorrected_mixture(configuration, runs),
)
return next_node
[docs]
def analyze(
self,
indent="",
P=None,
configuration=None,
source="runs",
uncorrected=None,
**kwargs,
):
"""Fit, print, plot and store the thermal conductivity.
The value of each method is the average over the runs (or blocks) of the
average of the three diagonal components fitted to each run; its error is
the standard error of these averages. The curves averaged over the runs are
fitted too, for the graphs.
Parameters
----------
indent: str
An extra indentation for the output
P : dict
The values of the parameters.
configuration : molsystem._Configuration
The configuration for the results.
source : str
What the samples are: "runs" or "blocks".
uncorrected : [str]
The species of a mixture whose mass fluxes were not sampled, so the
interdiffusion could not be removed; None for a pure substance.
"""
n_samples = len(self.Ms)
dt = Q_(self.timestep[0], "fs")
ts = (np.arange(self.M.shape[1]) * dt.m_as("ps")).tolist()
notes = []
# The derivative of the averaged Helfand moments, fitted like the Green-Kubo
# integral. Its time constants set where the Helfand moments are fitted.
slopes = []
xs = []
errs = []
fit0 = []
for i in range(6):
slope, x, err = helfand_slope(self.M[i], ts, sigma=self.M_err[i])
slopes.append(slope)
xs.append(x)
errs.append(err)
if i < 3:
kappa, fit_err, a, a_err, tau, tau_err, tf, yf = (
fit_green_kubo_integral(slope, x, sigma=err)
)
fit0.append(
{
"kappa": kappa,
"stderr": fit_err,
"xs": tf,
"ys": yf,
"a": a,
"a_err": a_err,
"tau": tau,
"tau_err": tau_err,
}
)
self._plot_helfand_derivatives(slopes, xs, errs, fit0)
starts = [max(1.0, 3 * max(f["tau"])) for f in fit0]
starts.extend([max(starts)] * 3)
# The slopes of the averaged Helfand moments
fit = []
for i in range(6):
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
slope, err, fxs, fys = fit_helfand_moment(
self.M[i],
ts,
sigma=self.M_err[i],
start=starts[i],
logger=self.logger,
)
fit.append({"kappa": slope, "stderr": err, "xs": fxs, "ys": fys})
except Exception as e:
logger.warning(f"The fit of the Helfand moments failed: {e}")
if i == 0:
notes.append(
"The Helfand moments could not be fitted. The fit starts at "
f"{starts[i]:.2f} ps, but the moments extend only to "
f"{ts[-1]:.2f} ps, a tenth of the length of each run (or "
"block). Use longer runs, or fewer blocks."
)
break
self.plot_helfand_moment(self.M, ts, M_err=self.M_err, fit=fit)
helfand_fitted = len(fit) > 0
# The Green-Kubo integrals averaged over the runs
cf_ts = (np.arange(self.Jcf.shape[1]) * dt.m_as("ps")).tolist()
end = len(cf_ts) // 4
self.plot_JJ_correlation(
self.Jcf[:, :end], cf_ts[:end], err=self.Jcf_err[:, :end]
)
gk_ts = (np.arange(self.GK_integral.shape[1]) * dt.m_as("ps")).tolist()
fit = []
for i in range(3):
kappa, fit_err, a, a_err, tau, tau_err, tf, yf = fit_green_kubo_integral(
self.GK_integral[i], gk_ts, sigma=self.GK_integral_err[i]
)
fit.append(
{
"kappa": kappa,
"stderr": fit_err,
"xs": tf,
"ys": yf,
"a": a,
"a_err": a_err,
"tau": tau,
"tau_err": tau_err,
}
)
self.plot_GK_integral(
self.GK_integral, gk_ts, err=self.GK_integral_err, fit=fit
)
# The values fitted to each run or block
per_sample, off_diagonal = self._per_sample(ts, gk_ts, starts)
per_sample = self._pseudo_values(per_sample)
estimates = {}
components = {}
what = "run" if source == "runs" else "block"
for method in METHODS:
values = per_sample[method]
estimate = isotropic_average(values)
estimate.source = source
estimate.units = "W/m/K"
estimates[method] = estimate
components[method] = [
Estimate.from_samples(values[:, i], units="W/m/K", source=source)
for i in range(3)
]
failed = int(np.sum(~np.all(np.isfinite(values), axis=1)))
if failed == n_samples:
# The failure of the Helfand moments is explained above
if method != "Helfand moments" or helfand_fitted:
notes.append(f"The {method} fit failed for every {what}.")
elif failed > 0:
notes.append(
f"The {method} fit failed for {failed} of the {n_samples} "
f"{source}, which are left out."
)
if self._species is not None:
notes.append(self._mixture_note(gk_ts, source))
elif uncorrected is not None:
notes.append(
f"The system is a mixture of {len(uncorrected)} species, but the MD "
"did not sample their mass fluxes, so the energy carried by their "
"interdiffusion could not be removed: κ is too large, by as much as "
"40% for 1 m NaCl in water. Run the MD again with a current version "
"of the LAMMPS step, which samples them with the heat flux."
)
printer.important(
__(
"Warning: the thermal conductivity of this mixture is too large: "
"the MD did not sample the mass fluxes of the species.",
indent=self.indent + 4 * " ",
)
)
isotropic, why = self._isotropy
table = {"Method": [], "κ": [], "± (95%)": [], "Units": []}
def row(label, estimate):
if estimate.n == 0 or not np.isfinite(estimate.value):
v, e = "--", ""
else:
v, e = estimate.formatted()
table["Method"].append(label)
table["κ"].append(v)
table["± (95%)"].append(e)
table["Units"].append("W/m/K")
for method in METHODS:
row(method, estimates[method])
if not isotropic:
for name, estimate in zip(DIAGONAL, components[method]):
row(f"{method}, {name}", estimate)
text = tabulate(
table,
headers="keys",
tablefmt="rounded_outline",
disable_numparse=True,
colalign=("left", "decimal", "decimal", "left"),
)
length = len(text.splitlines()[0])
printer.normal(
__(
"\n" + "Thermal Conductivity".center(length) + "\n" + text,
indent=8 * " ",
wrap=False,
dedent=False,
)
)
# What the table is from, and the checks
nruns = len(self.paths)
total = self._total_time.m_as("ps")
line = (
f"From {nruns} run{'s' if nruns > 1 else ''} of MD, {total:.0f} ps in "
f"all, with the heat flux every {dt.m_as('fs'):.3g} fs"
)
if source == "blocks":
line += f", divided into {n_samples} blocks"
lines = [line + ". " + estimates["Green-Kubo"].describe_error()]
if isotropic:
check = component_check(per_sample["Green-Kubo"], names=DIAGONAL)
line = check.text
off = self._off_diagonal_check(off_diagonal)
if off != "":
line += " " + off
lines.append(line)
else:
lines.append(
f"The {'runs are' if nruns > 1 else 'run is'} not treated as "
f"isotropic ({why}), so the diagonal components are given; κ is a "
"third of their sum."
)
for line in lines + notes:
printer.normal(__(line, indent=8 * " "))
printer.normal("")
self._print_samples(per_sample, off_diagonal, source)
state = report_state(self, printer, self.paths)
# Store the results
self.model = self._find_model()
data = {"nruns": nruns}
for method in METHODS:
estimate = estimates[method]
if estimate.n == 0 or not np.isfinite(estimate.value):
continue
key = RESULT_KEYS[method]
data[key] = estimate.value
if estimate.stderr is not None:
data[f"{key},stderr"] = estimate.stderr
for name, estimate in zip(("xx", "yy", "zz"), components["Green-Kubo"]):
if estimate.n == 0:
continue
data[f"kappa_{name}"] = estimate.value
if estimate.stderr is not None:
data[f"kappa_{name},stderr"] = estimate.stderr
if state is not None:
data.update(state_data(state))
self.store_results(configuration=configuration, data=data)
def _mixture_note(self, gk_ts, source):
"""What the correction for the interdiffusion did, for the notes."""
values = np.full((len(self.raw_GK_integrals), 3), np.nan)
for s, integral in enumerate(self.raw_GK_integrals):
for i in range(3):
values[s, i] = _fit(fit_green_kubo_integral, integral[i], gk_ts)
raw = isotropic_average(values)
raw.source = source
raw.units = "W/m/K"
n = len(self._species)
text = (
f"The system is a mixture of {n} species, so the energy carried by "
"their interdiffusion, which is not conduction of heat, is removed from "
"the heat flux using the mass fluxes of the species (Bertossa et al., "
"Phys. Rev. Lett. 122, 255901 (2019))."
)
if len(self._L_matrices) > 1:
text += (
f" The correction uses the averages over the {source}, since that "
f"for a single one is too noisy, so the errors are from the {source} "
"by the jackknife (leaving out each in turn), and the values by "
f"{source[:-1]} in the detailed output are jackknife pseudo-values."
)
if raw.n > 0 and np.isfinite(raw.value):
value, error = raw.formatted()
if error != "":
value += f" ± {error}"
text += f" Without this correction Green-Kubo would give {value} W/m/K."
return text
def _uncorrected_mixture(self, configuration, runs):
"""The species of a mixture whose MD has no species fluxes, or None.
Parameters
----------
configuration : molsystem._Configuration
The system.
runs : [tuple]
The data of the runs.
Returns
-------
[str] or None
"""
if self._species is not None or configuration is None:
return None
try:
species = sorted(set(configuration.get_molecule_smiles()))
except Exception:
return None
return species if len(species) > 1 else None
def _per_sample(self, ts, gk_ts, starts):
"""κ fitted to each run or block, by each method.
Parameters
----------
ts : [float]
The times of the Helfand moments, in ps.
gk_ts : [float]
The times of the Green-Kubo integrals, in ps.
starts : [float]
Where to start the fit of each component of the Helfand moments, in ps.
Returns
-------
{str: numpy.ndarray(n, 3)}
The diagonal components by each method; NaN where a fit failed.
numpy.ndarray(n, 3)
The off-diagonal components from the Helfand moments.
"""
n = len(self.Ms)
result = {method: np.full((n, 3), np.nan) for method in METHODS}
off_diagonal = np.full((n, 3), np.nan)
zeros = np.zeros(len(ts))
for s, (M, integral) in enumerate(zip(self.Ms, self.GK_integrals)):
for i in range(3):
result["Green-Kubo"][s, i] = _fit(
fit_green_kubo_integral, integral[i], gk_ts
)
result["Helfand moments"][s, i] = _fit(
fit_helfand_moment, M[i], ts, start=starts[i]
)
slope, x, _ = helfand_slope(M[i], ts, zeros)
result["Helfand derivative"][s, i] = _fit(
fit_green_kubo_integral, slope, x
)
for i in range(3, 6):
off_diagonal[s, i - 3] = _fit(
fit_helfand_moment, M[i], ts, start=starts[i]
)
return result, off_diagonal
@staticmethod
def _off_diagonal_check(off_diagonal):
"""One sentence: whether the off-diagonal terms are zero within their
errors, as they should be for an isotropic system; empty if it cannot be
told.
Each term is tested against zero with Student's t (n - 1 degrees of freedom,
since the errors come from only n runs or blocks), and the smallest p-value
is corrected for the three tests (Bonferroni).
"""
values = np.asarray(off_diagonal, dtype=float)
values = values[np.all(np.isfinite(values), axis=1)]
n = len(values)
if n < 2:
return ""
means, stderrs = mean_and_stderr(values)
if not np.all(stderrs > 0):
return ""
from scipy import stats
t = np.abs(means / stderrs)
worst = int(np.argmax(t))
p = min(1.0, len(t) * float(2 * stats.t.sf(t[worst], n - 1)))
name = OFF_DIAGONAL[worst]
how_far = f"{t[worst]:.1f} standard errors from zero; p = {p:.2g}"
if p >= 0.01:
return (
"The off-diagonal terms are zero within their errors (the largest, "
f"{name}, is {how_far})."
)
return (
f"The off-diagonal terms are not zero within their errors ({name} is "
f"{how_far}): the system may not be isotropic, or the runs too short."
)
def _print_samples(self, per_sample, off_diagonal, source):
"""The values fitted to each run or block, in the detailed output."""
what = "Run" if source == "runs" else "Block"
diagonal = np.concatenate([per_sample[m] for m in METHODS])
largest = np.nanmax(np.abs(diagonal)) if np.any(np.isfinite(diagonal)) else 1
decimals = max(0, 3 - floor(log10(largest))) if largest > 0 else 3
def fmt(value):
return f"{value:.{decimals}f}" if np.isfinite(value) else "--"
for method in METHODS:
values = per_sample[method]
table = {what: [], "κ": []}
for name in DIAGONAL:
table[name] = []
if method == "Helfand moments":
for name in OFF_DIAGONAL:
table[name] = []
for s, components in enumerate(values):
table[what].append(str(s + 1))
table["κ"].append(fmt(components.mean()))
for name, value in zip(DIAGONAL, components):
table[name].append(fmt(value))
if method == "Helfand moments":
for name, value in zip(OFF_DIAGONAL, off_diagonal[s]):
table[name].append(fmt(value))
text = tabulate(
table,
headers="keys",
tablefmt="rounded_outline",
disable_numparse=True,
colalign=("center",) + ("decimal",) * (len(table) - 1),
)
length = len(text.splitlines()[0])
title = f"{method} by {what.lower()} (W/m/K)".center(length)
print_details(
printer,
str(__("\n" + title + "\n" + text + "\n", indent=8 * " ", wrap=False)),
)
def _find_model(self):
"""The model (forcefield or model chemistry) of the MD, for properties."""
try:
if self.variable_exists("_model_chemistry"):
return self.get_variable("_model_chemistry")["level"]
ff = self.get_variable("_forcefield")
return ff.current_forcefield
except Exception:
return "unknown"
def _plot_helfand_derivatives(self, slopes, xs, errs, fit):
"""Plot the derivatives of the Helfand moments and their fits."""
figure = self.create_figure(
module_path=("seamm",),
template="line.graph_template",
title="Helfand Derivatives",
)
plot_helfand_slopes(
figure, slopes, xs[0], err=errs, fit=fit, labels=self.tensor_labels
)
figure.grid_plots("Slope")
path = Path(self.directory) / "HelfandDerivatives.graph"
figure.dump(path)
if "html" in self.options and self.options["html"]:
path = path.with_suffix(".html")
figure.template = "line.html_template"
figure.dump(path)
[docs]
def plot_GK_integral(self, x, ts, err=None, fit=None, filename="GK_integral.graph"):
"""Create a plot of the GK integrals and any fit to them.
Parameters
----------
x : numpy.ndarray(6, m)
The cumulative integrals
ts : [float]
List of times for the points, in ps
err : numpy.ndarray(6, m)
The standard errors of the integrals (optional)
fit :
The fit parameters for the integrals (optional)
filename : str
The filename to write
"""
figure = self.create_figure(
module_path=("seamm",),
template="line.graph_template",
title="Green-Kubo Integrals",
)
max_tau = 0
for i in range(3):
tau = fit[i]["tau"][-1]
if tau > max_tau:
max_tau = tau
if max_tau * 4 > ts[-1]:
rng = None
else:
rng = [0, max_tau * 4]
plot_GK_integrals(
figure, x, ts, err=err, fit=fit, labels=self.tensor_labels, _range=rng
)
figure.grid_plots("GKI")
# Write to disk
path = Path(self.directory) / filename
figure.dump(path)
if "html" in self.options and self.options["html"]:
path = path.with_suffix(".html")
figure.template = "line.html_template"
figure.dump(path)
[docs]
def plot_helfand_moment(
self, M, ts, M_err=None, fit=None, filename="HelfandMoments.graph"
):
"""Create a plot of the Helfand moments and any fit to them.
Parameters
----------
M : numpy.ndarray(6, m)
The Helfand moments.
ts : [float]
List of times for the points, in ps
"""
figure = self.create_figure(
module_path=("seamm",),
template="line.graph_template",
title="Helfand Moments",
)
plot_helfand_moments(
figure, M, ts, err=M_err, fit=fit, labels=self.tensor_labels
)
figure.grid_plots("M")
# Write to disk
path = Path(self.directory) / filename
figure.dump(path)
if "html" in self.options and self.options["html"]:
path = path.with_suffix(".html")
figure.template = "line.html_template"
figure.dump(path)
[docs]
def plot_JJ_correlation(self, CCF, ts, err=None, fit=None, filename="JJ.graph"):
"""Create a plot of the correlation functions and any fit to them.
Parameters
----------
CCF : numpy.ndarray(6, m)
The cross-correlation functions.
ts : [float]
List of times for the points, in ps
err : numpy.ndarray(6, m)
The standard errors of the CCF (optional)
fit :
The fit parameters for any fit of the CFF (optional)
filename : str
The filename to write
"""
figure = self.create_figure(
module_path=("seamm",),
template="line.graph_template",
title="Heat Flux Correlation Functions",
)
plot_correlation_functions(
figure, CCF, ts, err=err, fit=fit, labels=self.tensor_labels
)
figure.grid_plots("CCF")
# Write to disk
path = Path(self.directory) / filename
figure.dump(path)
if "html" in self.options and self.options["html"]:
path = path.with_suffix(".html")
figure.template = "line.html_template"
figure.dump(path)
[docs]
def process_run(self, run, data, what="Run"):
"""Calculate the Helfand moments and correlation functions of one run or
block.
Parameters
----------
run : int
The number of the run or block, for messages.
data : (numpy.ndarray, pint.Quantity, float, float, numpy.ndarray)
The heat flux (3 × n, W/m^2), the sampling interval, the temperature
(K), the volume (Å^3), and for a mixture the mass fluxes of all the
species but one (k × 3 × n), or None.
what : str
"Run" or "Block", for messages.
"""
J, dt, T, V, S = data
self.V.append(V)
self.T.append(T)
self.timestep.append(dt.m_as("fs"))
T = Q_(T, "K")
V = Q_(V, "Å^3")
k_B = Q_("k_B")
Jsq = Q_("W^2/m^4")
# Limit the lengths of the data
n = J.shape[1]
m = min(n // 10, 10000)
if n * dt < MIN_RUN_TIME:
raise RuntimeError(
f"{what} {run} is only {(n * dt).to('ps'):~P} long, but at least "
f"{MIN_RUN_TIME:~P} is needed for the correlation functions to be "
"fitted."
)
# The Helfand moments
constants = (Jsq * V * dt**2 / (2 * k_B * T**2)).m_as("W/m/K*ps")
M = collective_helfand_moments(J, m=m) * constants
self.Ms.append(M)
if S is not None:
# For a mixture, also the matrices with the species' mass fluxes, from
# which _combine removes their interdiffusion
self._M_matrices.append(flux_helfand_matrices(J, S, m=m) * constants)
# And correlation functions for Green-Kubo method
constants = (V / (k_B * T**2) * Jsq * dt).m_as("W/m/K")
Jcf, integral = tensor_correlation_functions(J, m=m)
self.Jcfs.append(Jcf)
self.GK_integrals.append(integral * constants)
if S is not None:
self.raw_GK_integrals.append(integral * constants)
C, L = flux_correlation_matrices(J, S, m=m)
self._C_matrices.append(C)
self._L_matrices.append(L * constants)
def _combine(self):
"""The averages of the curves over the runs or blocks, and their spread.
For a mixture the thermal conductivity is the Schur complement of the matrix
of the heat flux and the species' mass fluxes. The complement of a single
run or block is very noisy -- the species' part is small and poorly known --
so the matrices are averaged first. The diagonal curves of each run or block
are then those of the others (leave-one-out, or jackknife), whose fits
:meth:`analyze` turns into pseudo-values; their spread is scaled to match.
"""
if len(self._L_matrices) > 0:
self._remove_interdiffusion()
for name in ("M", "Jcf", "GK_integral"):
curves = np.stack(getattr(self, name + "s"))
setattr(self, name, np.average(curves, axis=0))
setattr(self, name + "_err", np.std(curves, axis=0))
if len(self._L_matrices) > 0:
# The averages are the complements of the averaged matrices, and the
# spread of the leave-one-out curves is (n - 1) times too small
n = len(self._L_matrices)
C = np.mean(self._C_matrices, axis=0)
L = np.mean(self._L_matrices, axis=0)
self.M[:3] = schur_complement(np.mean(self._M_matrices, axis=0))
self.GK_integral[:3] = schur_complement(L)
self.Jcf[:3] = effective_correlation_functions(C, L)
for name in ("M", "Jcf", "GK_integral"):
getattr(self, name + "_err")[:3] *= max(1, n - 1)
def _remove_interdiffusion(self):
"""Replace the diagonal curves of each run or block of a mixture by those
without the interdiffusion of the species: the Schur complements of the
matrices averaged over the other runs or blocks, or of the one there is."""
n = len(self._L_matrices)
averages = {}
for name in ("_C_matrices", "_L_matrices", "_M_matrices"):
averages[name] = np.mean(getattr(self, name), axis=0)
def others(name, i):
if n == 1:
return averages[name]
return (n * averages[name] - getattr(self, name)[i]) / (n - 1)
for i in range(n):
L = others("_L_matrices", i)
self.GK_integrals[i][:3] = schur_complement(L)
self.Jcfs[i][:3] = effective_correlation_functions(
others("_C_matrices", i), L
)
self.Ms[i][:3] = schur_complement(others("_M_matrices", i))
def _pseudo_values(self, per_sample):
"""The jackknife pseudo-values from the fits to the leave-one-out curves of
a mixture: mean - (n - 1) (value - mean), whose mean and standard error are
the jackknife estimate and its error. Unchanged for a pure substance or a
single run or block."""
n = len(self._L_matrices)
if n < 2:
return per_sample
result = {}
for method, values in per_sample.items():
mean = np.nanmean(values, axis=0) if np.any(np.isfinite(values)) else 0.0
result[method] = mean - (n - 1) * (values - mean)
return result
[docs]
def md_requirements(self, P=None):
"""What the analysis needs from the MD: the heat flux at least every 4 fs, in
a periodic cell. It analyzes the dynamics.
Sampling every dt adds the flux's power at 1/dt and its multiples to the
Green-Kubo integral. The flux of molecules with X-H bonds has a strong band
at twice the X-H stretch, up to ~7400 cm^-1 for O-H: 4 fs (8340 cm^-1) stays
above it, 5 fs (6670 cm^-1) does not for water, and 10 fs aliases ~20 % of
the conductivity for an electrolyte (SEAMM job 5368, 2026-10-22)."""
return Requirements(
needs=[
Need(
"heat_flux",
max_time="4 fs",
why=(
"the thermal conductivity, sampled finely enough not to alias "
"its fastest oscillations"
),
)
],
periodic=True,
dynamics=True,
)
def _static(self, key):
"""A parameter's value without evaluating variables, or None."""
parameter = self.parameters[key]
if parameter.is_expr:
return None
return parameter.get()
[docs]
def check_flowchart(self, preceding):
"""Check, before the job runs, that the MD will sample what is needed and is
set up for the analysis.
Parameters
----------
preceding : [seamm.Node]
The steps that run before this one.
Returns
-------
[str]
A description of each problem; empty if none.
"""
source = self._static("md source")
if source is None:
return []
return check_md_flowchart(self, preceding, self.md_requirements(), source)
[docs]
def check_configuration(self, configuration):
"""Called by an MD step before it runs MD that this step will analyze.
Returns
-------
[str]
Warnings about the structure, e.g. that it is small.
Raises
------
RuntimeError
If the system is not periodic.
"""
if configuration.periodicity != 3:
raise RuntimeError(
f"'{self.title}' will analyze the MD, but the system is not "
"periodic: the heat flux is divided by the volume of the cell."
)
return [f"'{self.title}': {w}" for w in size_warnings(configuration)]
def _read_md_file(self, path):
"""The heat flux, interval, temperature, volume and species fluxes of one
md.h5md, noting its length, its species and whether it is isotropic."""
result = self._read_h5md(path)
if result is None:
raise RuntimeError(f"There is no heat flux in {path}.")
J, dt, _, _, S, species = result
result = result[:5]
# Every run must be corrected for the interdiffusion of the species, or none
first = self._total_time.m == 0
if first:
self._species = species
elif (species is None) != (self._species is None):
raise RuntimeError(
f"Some of the runs sampled the mass fluxes of the species of the "
f"mixture, but not all of them ({path} "
f"{'did not' if species is None else 'did'}). Analyze runs made the "
"same way."
)
elif species != self._species:
raise RuntimeError(
f"The species of the MD in {path}, {', '.join(species)}, are not those "
f"of the other runs, {', '.join(self._species)}."
)
self._total_time += J.shape[1] * dt
with open_md(path) as md:
isotropic, why = isotropy(md)
# The runs are isotropic only if all are; otherwise say why not
if self._isotropy is None or (self._isotropy[0] and not isotropic):
self._isotropy = (isotropic, why)
return result
@staticmethod
def _split_into_blocks(data, n_blocks):
"""Divide one run into consecutive blocks, analyzed as separate runs."""
J, dt, T, V, S = data
n_blocks = max(1, int(n_blocks))
n = J.shape[1] // n_blocks
if n * dt < MIN_RUN_TIME:
raise RuntimeError(
f"The run is only {(J.shape[1] * dt).to('ps'):~P} long, too short for "
f"{n_blocks} blocks: each block needs at least {MIN_RUN_TIME:~P} for "
"the correlation functions to be fitted. Use fewer blocks or a longer "
"run."
)
if n < 2000:
raise RuntimeError(
f"The run has only {J.shape[1]} samples of the heat flux, too few for "
f"{n_blocks} blocks: each block needs at least 2000 for the "
"correlation functions to be fitted. Use fewer blocks or a longer run."
)
return [
(
J[:, i * n : (i + 1) * n],
dt,
T,
V,
None if S is None else S[..., i * n : (i + 1) * n],
)
for i in range(n_blocks)
]
@staticmethod
def _read_h5md(path):
"""The heat flux, temperature, volume and species fluxes in an md.h5md file.
Returns
-------
(numpy.ndarray(3, n), pint.Quantity, float, float, numpy.ndarray, [str])
The heat flux in W/m^2, the interval between samples, the mean
temperature in K, the mean volume in Å^3, and for a mixture the mass
fluxes of all the species but the first (k × 3 × n, in any units) and
the species, or None and None; or None if the file has no heat flux.
"""
with open_md(path) as md:
if not md.has_observable("heat_flux"):
return None
if not md.periodic:
raise RuntimeError(
f"The MD in {path} is not periodic, so it has no thermal "
"conductivity."
)
flux = md.observable("heat_flux", units="W/m^2", time_units="fs")
T = float(md.observable("temperature", units="K").values.mean())
if md.has_observable("volume"):
V = float(md.observable("volume", units="Å^3").values.mean())
else:
V = float(abs(np.linalg.det(md.cell(units="Å"))))
J = flux.values.T
S = None
species = None
if md.has_observable("species_mass_flux"):
fluxes = md.observable("species_mass_flux")
species = list(fluxes.attrs.get("species", []))
if fluxes.values.shape[1] > 1:
# The first species, with the most atoms, is fixed by the others
# and the total momentum
_, i, j = np.intersect1d(
flux.steps, fluxes.steps, return_indices=True
)
J = J[:, i]
S = fluxes.values[j, 1:, :].transpose(1, 2, 0)
else:
species = None
return J, flux.dt, T, V, S, species