import pandas as pd
import numpy as np
import copy
import json
import ast
import warnings
from scipy.optimize import minimize, brent, least_squares, minimize_scalar, brentq
from scipy.signal import savgol_filter, find_peaks
from scipy.special import erf, owens_t
from scipy.interpolate import UnivariateSpline
import matplotlib.pyplot as plt
import matplotlib.colors as colors
import matplotlib.patches as patches
from pmagpy.pmag import _resolve_rng
from pmagpy.version import version as pmagpy_version
try:
import ipywidgets as widgets
from ipywidgets import HBox, VBox, Output, Dropdown, RadioButtons, Checkbox, IntSlider, FloatSlider, IntRangeSlider
from IPython.display import HTML, display
except ImportError:
widgets = None
display = None
try:
from bokeh.plotting import figure, show
from bokeh.layouts import gridplot
from bokeh.models import HoverTool, ColumnDataSource, PointDrawTool, CustomJS, Div
from bokeh.embed import components
from bokeh.palettes import Category10
from bokeh.models.widgets import DataTable, TableColumn
from bokeh.layouts import column
_HAS_BOKEH = True
except ImportError:
_HAS_BOKEH = False
try:
import statsmodels.api as sm
lowess = sm.nonparametric.lowess
except ImportError:
sm = None
lowess = None
def _check_ipywidgets():
if widgets is None:
raise ImportError(
"ipywidgets is required for interactive functions. "
"Install it with: pip install ipywidgets"
)
def _check_bokeh():
if not _HAS_BOKEH:
raise ImportError(
"bokeh is required for interactive plotting. "
"Install it with: pip install bokeh"
)
def _check_statsmodels():
if lowess is None:
raise ImportError(
"statsmodels is required for LOWESS smoothing. "
"Install it with: pip install statsmodels"
)
mpl_to_bokeh_markers = {
".": "dot",
",": "dot",
"o": "circle",
"v": "inverted_triangle",
"^": "triangle",
"<": "triangle",
">": "triangle",
"1": "triangle",
"2": "inverted_triangle",
"3": "triangle",
"4": "inverted_triangle",
"s": "square",
"p": "square",
"*": "asterisk",
"h": "hex",
"H": "hex",
"+": "plus",
"x": "x",
"X": "x",
"D": "diamond",
"d": "diamond",
"|": "dash",
"_": "dash",
}
# general I/O functions
# ------------------------------------------------------------------------------------------------------------------
def _to_native_python(d):
"""Convert NumPy scalar values in a dict to native Python scalars.
Parameters
----------
d : dict
Dictionary whose values may include NumPy scalar types
(e.g. np.float64, np.int64, np.bool_).
Returns
-------
dict
A new dictionary with the same keys as `d` but with any
NumPy scalar values replaced by their native Python
equivalents (float, int, bool). Non‐NumPy values are
left unchanged.
"""
return {k: v.item() if isinstance(v, np.generic) else v for k, v in d.items()}
def _map_legend_location(matplotlib_loc):
"""
Maps a Matplotlib legend location to a Bokeh legend location.
Falls back to 'top_left' if no direct mapping exists.
Parameters
----------
matplotlib_loc : str
Matplotlib legend location (e.g., 'upper right', 'lower left').
Returns
-------
str
Corresponding Bokeh legend location.
"""
mapping = {
'best': 'top_left',
'upper right': 'top_right',
'upper left': 'top_left',
'lower left': 'bottom_left',
'lower right': 'bottom_right',
'right': 'right',
'center left': 'left',
'center right': 'right',
'lower center': 'bottom_center',
'upper center': 'top_center',
'center': 'center',
}
return mapping.get(matplotlib_loc, 'top_left')
def _widget_value(widget_or_value):
"""
Return ``widget.value`` for an ipywidgets widget, or the input unchanged.
Lets interactive analysis functions accept either a selection widget
(e.g. from ``specimen_selection_interactive``) or a plain string, so the
same function can be driven by dropdowns or scripted reproducibly.
"""
return getattr(widget_or_value, "value", widget_or_value)
[docs]
def specimen_selection_interactive(measurements):
"""
Creates and displays a dropdown widget for selecting a specimen from a given
DataFrame of measurements.
Parameters
----------
measurements : pd.DataFrame
The DataFrame containing measurement data with a column 'specimen'. It is
expected to have at least this column where 'specimen' identifies the
specimen name.
Returns
-------
ipywidgets.Dropdown
A dropdown widget allowing for the selection of a specimen. The initial
selection in the dropdown is set to the first specimen option.
"""
_check_ipywidgets()
# Extract unique specimen names from the measurements DataFrame
specimen_options = measurements['specimen'].unique().tolist()
# Set the initial selection to the first specimen option, if available
selected_specimen_name = specimen_options[0] if specimen_options else None
# Create a dropdown for specimen selection
specimen_dropdown = widgets.Dropdown(
options=specimen_options,
description='Specimen:',
value=selected_specimen_name
)
# Display the dropdown widget
display(specimen_dropdown)
return specimen_dropdown
[docs]
def specimen_experiment_selection_interactive(measurements):
"""
Creates interactive dropdown widgets for selecting a specimen and its associated
experiment from a measurements DataFrame.
Parameters
----------
measurements : pd.DataFrame
DataFrame containing measurement data with at least two columns: 'specimen' and
'experiment'. The 'specimen' column holds the specimen names while the 'experiment'
column holds the experiment identifiers associated with each specimen.
Returns
-------
tuple of ipywidgets.Dropdown
A tuple containing two dropdown widgets. The first widget allows for selecting a
specimen, and the second widget allows for selecting an experiment associated with
the chosen specimen. The experiment dropdown is dynamically updated based on the
specimen selection.
"""
_check_ipywidgets()
specimen_dropdown = widgets.Dropdown(
options = measurements['specimen'].unique(),
description = 'specimen:',
disabled = False,
)
experiment_dropdown = widgets.Dropdown(
options = measurements['experiment'].unique(),
description = 'Experiment:',
disabled = False,
)
# make sure to set the default value of the experiment dropdown to the first experiment in the specimen dropdown
experiment_dropdown.options = measurements[measurements['specimen']==specimen_dropdown.value]['experiment'].unique()
# make sure to update the experiment dropdown based on the specimen selected
def update_experiment(*args):
experiment_dropdown.options = measurements[measurements['specimen']==specimen_dropdown.value]['experiment'].unique()
specimen_dropdown.observe(update_experiment, 'value')
# display the dropdowns
display(specimen_dropdown, experiment_dropdown)
return specimen_dropdown, experiment_dropdown
[docs]
def make_experiment_df(measurements, exclude_method_codes=None):
"""
Creates a DataFrame of unique experiments from the measurements DataFrame.
Parameters
----------
measurements : pd.DataFrame
The DataFrame containing measurement data with columns 'specimen',
'method_codes', and 'experiment'.
exclude_method_codes : list of str, optional
List of method codes to exclude from the output DataFrame. Rows with
'method_codes' containing any of these substrings will be removed.
Returns
-------
pd.DataFrame
A DataFrame containing unique combinations of 'specimen', 'method_codes',
and 'experiment'.
"""
if exclude_method_codes is not None:
mask = ~measurements["method_codes"].apply(
lambda x: any(code in x for code in exclude_method_codes)
)
measurements = measurements.loc[mask]
experiments = (
measurements.groupby(["specimen", "method_codes", "experiment"])
.size()
.reset_index()
.iloc[:, :3]
)
return experiments
[docs]
def experiment_selection(measurements, experiment_name):
"""
This function filters a measurements DataFrame to return only the rows
that correspond to the specified experiment name.
Parameters
----------
measurements : pd.DataFrame
The DataFrame containing measurement data with an 'experiment' column.
experiment_name : str
The name of the experiment to select from the DataFrame.
Returns
-------
pd.DataFrame
A DataFrame containing only the rows corresponding to the specified experiment.
"""
if 'experiment' not in measurements.columns:
raise ValueError("The measurements DataFrame must contain an 'experiment' column.")
if not isinstance(experiment_name, str):
raise TypeError("The experiment_name must be a string.")
if experiment_name not in measurements['experiment'].unique():
raise ValueError(f"Experiment '{experiment_name}' not found in the measurements DataFrame.")
# Filter the DataFrame for the specified experiment
selected_experiment = measurements[measurements['experiment'] == experiment_name].reset_index(drop=True)
selected_experiment = clean_out_na(selected_experiment)
return selected_experiment
[docs]
def clean_out_na(dataframe):
"""
Cleans a DataFrame by removing columns and rows that contain only NaN values.
Args:
dataframe (pd.DataFrame): The DataFrame to be cleaned.
Returns:
pd.DataFrame: A cleaned DataFrame with all-NaN columns and rows removed.
"""
cleaned_df = dataframe.dropna(axis=1, how='all')
return cleaned_df
[docs]
def convert_temperature(temp_array, input_unit, output_unit):
"""
Convert temperatures between Kelvin and Celsius.
Parameters
----------
temp_array : array-like
Temperatures in `input_unit`.
input_unit : {'K', 'C'}
Unit of the input temperatures.
output_unit : {'K', 'C'}
Desired unit for output temperatures.
Returns
-------
numpy.ndarray
Temperatures converted to `output_unit`.
Raises
------
ValueError
If `input_unit` or `output_unit` is not 'K' or 'C'.
"""
temps = np.asarray(temp_array, dtype=float)
if input_unit == output_unit:
return temps
if input_unit == "K" and output_unit == "C":
return temps - 273.15
if input_unit == "C" and output_unit == "K":
return temps + 273.15
raise ValueError(f"Unsupported unit conversion: {input_unit} -> {output_unit}")
[docs]
def plot_M_T(
data,
temperature_column="meas_temp",
magnetization_column="magn_mass",
input_unit="K",
plot_unit="K",
interactive=False,
return_figure=False,
show_plot=True,
size=(6, 3),
legend_location="upper left",
):
"""
Plot magnetization versus temperature in static or interactive mode.
Parameters
----------
data : pandas.DataFrame or array-like
Table or array containing temperature and magnetization data.
temperature_column : str, default 'meas_temp'
Name of the temperature column in `data`.
magnetization_column : str, default 'magn_mass'
Name of the magnetization column in `data`.
input_unit : {'K', 'C'}, default 'K'
Unit of the input temperature data.
plot_unit : {'K', 'C'}, default 'K'
Unit for the x-axis display.
interactive : bool, default False
If True, use Bokeh for an interactive plot.
return_figure : bool, default False
If True, return the figure object(s). Assign to capture, e.g.:
fig, ax = plot_M_T(..., return_figure=True)
show_plot : bool, default True
If True, display the plot immediately.
size : tuple(float, float), default (6, 3)
Figure size in inches (Matplotlib) or height for Bokeh.
legend_location : str, default 'upper left'
Legend location in Matplotlib terms.
Returns
-------
tuple or layout or None
- If `return_figure=True` and `interactive=False`, returns (fig, ax).
- If `return_figure=True` and `interactive=True`, returns the Bokeh
layout object.
- Otherwise, returns None.
"""
# raw data arrays
T_raw = np.asarray(data[temperature_column], dtype=float)
M = np.asarray(data[magnetization_column], dtype=float)
# convert to desired plotting unit
T = convert_temperature(T_raw, input_unit, plot_unit)
x_label = "Temperature (°C)" if plot_unit == "C" else "Temperature (K)"
# plotting
if interactive:
_check_bokeh()
bokeh_loc = _map_legend_location(legend_location)
tools = [
HoverTool(tooltips=[("T", "@x"), ("M", "@y")]),
"pan,box_zoom,wheel_zoom,reset,save",
]
height = int(size[1] * 96)
p = figure(
title="M vs T",
x_axis_label=x_label,
y_axis_label="Magnetization",
tools=tools,
sizing_mode="stretch_width",
height=height,
)
p.line(T, M, legend_label="M(T)", line_width=2)
p.scatter(T, M, size=6, alpha=0.6, legend_label="M(T)")
p.legend.location = bokeh_loc
p.legend.click_policy = "hide"
layout = gridplot([[p]], sizing_mode="stretch_width")
if show_plot:
show(layout)
if return_figure:
return layout
return None
fig, ax = plt.subplots(figsize=size)
ax.plot(T, M, "o-", label="M(T)")
ax.set_xlabel(x_label)
ax.set_ylabel("Magnetization")
ax.set_title("M vs T")
ax.legend(loc=legend_location)
ax.grid(True)
if show_plot:
plt.show()
if return_figure:
return fig, ax
return None
# MPMS functions
# ------------------------------------------------------------------------------------------------------------------
[docs]
def plot_mpms_dc(
fc_data=None,
zfc_data=None,
rtsirm_cool_data=None,
rtsirm_warm_data=None,
fc_color="#1f77b4",
zfc_color="#ff7f0e",
rtsirm_cool_color="#17becf",
rtsirm_warm_color="#d62728",
fc_marker="d",
zfc_marker="p",
rtsirm_cool_marker="s",
rtsirm_warm_marker="o",
symbol_size=4,
interactive=False,
plot_derivative=False,
return_figure=False,
show_plot=True,
drop_first=False,
drop_last=False,
):
"""
Plots MPMS DC data and optional derivatives, omitting empty panels.
Parameters:
fc_data (DataFrame or None): Field-cooled data.
zfc_data (DataFrame or None): Zero-field-cooled data.
rtsirm_cool_data (DataFrame or None): RTSIRM cooling data.
rtsirm_warm_data (DataFrame or None): RTSIRM warming data.
fc_color, zfc_color, rtsirm_cool_color, rtsirm_warm_color (str):
HEX color codes.
fc_marker, zfc_marker, rtsirm_cool_marker, rtsirm_warm_marker (str):
Matplotlib-style markers.
symbol_size (int): Marker size.
interactive (bool): If True, use Bokeh.
plot_derivative (bool): If True, plot dM/dT curves.
return_figure (bool): If True, return the figure/grid.
show_plot (bool): If True, display the plot.
drop_first (bool): If True, drop first row of each series.
drop_last (bool): If True, drop last row of each series.
Returns:
Bokeh grid or Matplotlib fig/axes tuple, or None.
"""
def trim(df):
if df is None or df.empty:
return None
df = df.reset_index(drop=True)
if drop_first:
df = df.iloc[1:].reset_index(drop=True)
if drop_last:
df = df.iloc[:-1].reset_index(drop=True)
return df
fc = trim(fc_data)
zfc = trim(zfc_data)
rc = trim(rtsirm_cool_data)
rw = trim(rtsirm_warm_data)
if plot_derivative:
def deriv(df):
return None if df is None else thermomag_derivative(
df["meas_temp"], df["magn_mass"]
)
fcd = deriv(fc)
zfcd = deriv(zfc)
rcd = deriv(rc)
rwd = deriv(rw)
fc_zfc_present = (fc is not None) or (zfc is not None)
rtsirm_present = (rc is not None) or (rw is not None)
if interactive:
_check_bokeh()
tools = [HoverTool(tooltips=[("T","@x"),("M","@y")]), "pan,box_zoom,wheel_zoom,reset,save"]
figs = []
if fc_zfc_present:
p0 = figure(title="LTSIRM Data", x_axis_label="Temperature (K)",
y_axis_label="Magnetization (Am²/kg)", tools=tools,
sizing_mode="stretch_width",height=400)
if fc is not None:
p0.line(fc["meas_temp"], fc["magn_mass"], color=fc_color, legend_label="FC")
p0.scatter(fc["meas_temp"], fc["magn_mass"], marker=mpl_to_bokeh_markers.get(fc_marker),
size=symbol_size, color=fc_color, legend_label="FC")
if zfc is not None:
p0.line(zfc["meas_temp"], zfc["magn_mass"], color=zfc_color, legend_label="ZFC")
p0.scatter(zfc["meas_temp"], zfc["magn_mass"], marker=mpl_to_bokeh_markers.get(zfc_marker),
size=symbol_size, color=zfc_color, legend_label="ZFC")
p0.legend.click_policy="hide"
p0.xaxis.axis_label_text_font_style = "normal"
p0.yaxis.axis_label_text_font_style = "normal"
figs.append(p0)
if rtsirm_present:
p1 = figure(title="RTSIRM Data", x_axis_label="Temperature (K)",
y_axis_label="Magnetization (Am²/kg)", tools=tools,
sizing_mode="stretch_width",height=400)
if rc is not None:
p1.line(rc["meas_temp"], rc["magn_mass"], color=rtsirm_cool_color, legend_label="cool")
p1.scatter(rc["meas_temp"], rc["magn_mass"], marker=mpl_to_bokeh_markers.get(rtsirm_cool_marker),
size=symbol_size, color=rtsirm_cool_color, legend_label="cool")
if rw is not None:
p1.line(rw["meas_temp"], rw["magn_mass"], color=rtsirm_warm_color, legend_label="warm")
p1.scatter(rw["meas_temp"], rw["magn_mass"], marker=mpl_to_bokeh_markers.get(rtsirm_warm_marker),
size=symbol_size, color=rtsirm_warm_color, legend_label="warm")
p1.legend.click_policy="hide"
p1.xaxis.axis_label_text_font_style = "normal"
p1.yaxis.axis_label_text_font_style = "normal"
figs.append(p1)
# separate derivative panels
if plot_derivative and fc_zfc_present:
p2 = figure(title="LTSIRM Derivative", x_axis_label="Temperature (K)",
y_axis_label="dM/dT", tools=tools,
sizing_mode="stretch_width",height=400)
if fcd is not None:
p2.line(fcd["T"], fcd["dM_dT"], color=fc_color, legend_label="FC dM/dT")
p2.scatter(fcd["T"], fcd["dM_dT"], marker=mpl_to_bokeh_markers.get(fc_marker),
size=symbol_size, color=fc_color, legend_label="FC dM/dT")
if zfcd is not None:
p2.line(zfcd["T"], zfcd["dM_dT"], color=zfc_color, legend_label="ZFC dM/dT")
p2.scatter(zfcd["T"], zfcd["dM_dT"], marker=mpl_to_bokeh_markers.get(zfc_marker),
size=symbol_size, color=zfc_color, legend_label="ZFC dM/dT")
p2.legend.click_policy="hide"
p2.xaxis.axis_label_text_font_style = "normal"
p2.yaxis.axis_label_text_font_style = "normal"
figs.append(p2)
if plot_derivative and rtsirm_present:
p3 = figure(title="RTSIRM Derivative", x_axis_label="Temperature (K)",
y_axis_label="dM/dT", tools=tools,
sizing_mode="stretch_width",height=400)
if rcd is not None:
p3.line(rcd["T"], rcd["dM_dT"], color=rtsirm_cool_color, legend_label="cool dM/dT")
p3.scatter(rcd["T"], rcd["dM_dT"], marker=mpl_to_bokeh_markers.get(rtsirm_cool_marker),
size=symbol_size, color=rtsirm_cool_color, legend_label="cool dM/dT")
if rwd is not None:
p3.line(rwd["T"], rwd["dM_dT"], color=rtsirm_warm_color, legend_label="warm dM/dT")
p3.scatter(rwd["T"], rwd["dM_dT"], marker=mpl_to_bokeh_markers.get(rtsirm_warm_marker),
size=symbol_size, color=rtsirm_warm_color, legend_label="warm dM/dT")
p3.legend.click_policy="hide"
p3.xaxis.axis_label_text_font_style = "normal"
p3.yaxis.axis_label_text_font_style = "normal"
figs.append(p3)
layout = gridplot([figs[:2], figs[2:]], sizing_mode="stretch_width")
if show_plot:
show(layout)
return layout if return_figure else None
# Matplotlib branch
rows = 1 + (1 if plot_derivative else 0)
cols = 2
fig, axes = plt.subplots(rows, cols, figsize=(5*cols, 4*rows))
axes = axes.reshape(rows, cols)
if not fc_zfc_present:
axes[0,0].set_visible(False)
if plot_derivative:
axes[1,0].set_visible(False)
if not rtsirm_present:
axes[0,1].set_visible(False)
if plot_derivative:
axes[1,1].set_visible(False)
if fc_zfc_present:
ax = axes[0,0]
if fc is not None:
ax.plot(fc["meas_temp"], fc["magn_mass"], color=fc_color, marker=fc_marker, label="FC")
if zfc is not None:
ax.plot(zfc["meas_temp"], zfc["magn_mass"], color=zfc_color, marker=zfc_marker, label="ZFC")
ax.set_title("LTSIRM Data")
ax.set_xlabel("Temperature (K)")
ax.set_ylabel("Magnetization")
ax.legend()
ax.grid(True)
if rtsirm_present:
ax = axes[0,1]
if rc is not None:
ax.plot(rc["meas_temp"], rc["magn_mass"], color=rtsirm_cool_color, marker=rtsirm_cool_marker, label="cool")
if rw is not None:
ax.plot(rw["meas_temp"], rw["magn_mass"], color=rtsirm_warm_color, marker=rtsirm_warm_marker, label="warm")
ax.set_title("RTSIRM Data")
ax.set_xlabel("Temperature (K)")
ax.set_ylabel("Magnetization")
ax.legend()
ax.grid(True)
if plot_derivative and fc_zfc_present:
ax = axes[1,0]
if fcd is not None:
ax.plot(fcd["T"], fcd["dM_dT"], color=fc_color, marker=fc_marker, label="FC dM/dT")
if zfcd is not None:
ax.plot(zfcd["T"], zfcd["dM_dT"], color=zfc_color, marker=zfc_marker, label="ZFC dM/dT")
ax.set_title("LTSIRM Derivative")
ax.set_xlabel("Temperature (K)")
ax.set_ylabel("dM/dT")
ax.legend()
ax.grid(True)
if plot_derivative and rtsirm_present:
ax = axes[1,1]
if rcd is not None:
ax.plot(rcd["T"], rcd["dM_dT"], color=rtsirm_cool_color, marker=rtsirm_cool_marker, label="cool dM/dT")
if rwd is not None:
ax.plot(rwd["T"], rwd["dM_dT"], color=rtsirm_warm_color, marker=rtsirm_warm_marker, label="warm dM/dT")
ax.set_title("RTSIRM Derivative")
ax.set_xlabel("Temperature (K)")
ax.set_ylabel("dM/dT")
ax.legend()
ax.grid(True)
fig.tight_layout()
if show_plot:
plt.show()
return fig if return_figure else None
[docs]
def plot_mpms_dc_interactive(measurements):
"""Create a UI to select specimen and plot MPMS data in Matplotlib or Bokeh.
Parameters:
measurements (pandas.DataFrame): DataFrame with 'specimen' and
'method_codes'.
"""
_check_ipywidgets()
experiments = make_experiment_df(measurements)
filtered = experiments[
experiments["method_codes"].isin(
["LP-FC", "LP-ZFC", "LP-CW-SIRM:LP-MC", "LP-CW-SIRM:LP-MW"]
)
]
specimen_options = filtered["specimen"].unique().tolist()
specimen_dd = widgets.Dropdown(
options=specimen_options, description="Specimen:"
)
library_rb = widgets.RadioButtons(
options=["Bokeh", "Matplotlib"], description="Plot with:"
)
out = widgets.Output()
def _update(change=None):
spec = specimen_dd.value
fc_data, zfc_data, rts_c, rts_w = extract_mpms_data_dc(
measurements, spec
)
with out:
out.clear_output(wait=True)
if library_rb.value == "Matplotlib":
plot_mpms_dc(
fc_data,
zfc_data,
rts_c,
rts_w,
interactive=False,
plot_derivative=True,
show_plot=True,
)
else:
grid = plot_mpms_dc(
fc_data,
zfc_data,
rts_c,
rts_w,
interactive=True,
plot_derivative=True,
return_figure=True,
show_plot=False,
)
script, div = components(grid)
display(HTML(div + script))
specimen_dd.observe(_update, names="value")
library_rb.observe(_update, names="value")
ui = widgets.VBox([widgets.HBox([specimen_dd, library_rb]), out])
display(ui)
_update()
[docs]
def calc_verwey_estimate(temps, mags,
t_range_background_min=50,
t_range_background_max=250,
excluded_t_min=75,
excluded_t_max=150,
poly_deg=3):
"""
Estimate the Verwey transition temperature and remanence loss of magnetite from MPMS data.
Plots the magnetization data, background fit, and resulting magnetite curve, and
optionally the zero-crossing.
Parameters
----------
temps : pd.Series
Series representing the temperatures at which magnetization measurements were taken.
mags : pd.Series
Series representing the magnetization measurements.
t_range_background_min : int or float, optional
Minimum temperature for the background fitting range. Default is 50.
t_range_background_max : int or float, optional
Maximum temperature for the background fitting range. Default is 250.
excluded_t_min : int or float, optional
Minimum temperature to exclude from the background fitting range. Default is 75.
excluded_t_max : int or float, optional
Maximum temperature to exclude from the background fitting range. Default is 150.
poly_deg : int, optional
Degree of the polynomial for background fitting. Default is 3.
"""
temps.reset_index(drop=True, inplace=True)
mags.reset_index(drop=True, inplace=True)
dM_dT_df = thermomag_derivative(temps, mags)
temps_dM_dT = dM_dT_df['T']
temps_dM_dT_filtered_indices = [i for i in np.arange(len(temps_dM_dT)) if ((float(temps_dM_dT[i]) > float(t_range_background_min)) and (float(temps_dM_dT[i]) < float(excluded_t_min)) ) or ((float(temps_dM_dT[i]) > float(excluded_t_max)) and (float(temps_dM_dT[i]) < float(t_range_background_max)))]
temps_dM_dT_filtered = dM_dT_df['T'][temps_dM_dT_filtered_indices]
dM_dT_filtered = dM_dT_df['dM_dT'][temps_dM_dT_filtered_indices]
poly_background_fit = np.polyfit(temps_dM_dT_filtered, dM_dT_filtered, poly_deg)
dM_dT_filtered_polyfit = np.poly1d(poly_background_fit)(temps_dM_dT_filtered)
residuals = dM_dT_filtered - dM_dT_filtered_polyfit
ss_tot = np.sum((dM_dT_filtered - np.mean(dM_dT_filtered)) ** 2)
ss_res = np.sum(residuals ** 2)
r_squared = 1 - (ss_res / ss_tot)
temps_dM_dT_background_indices = [i for i in np.arange(len(temps_dM_dT)) if ((float(temps_dM_dT[i]) > float(t_range_background_min)) and (float(temps_dM_dT[i]) < float(t_range_background_max)))]
temps_dM_dT_background = dM_dT_df['T'][temps_dM_dT_background_indices]
temps_dM_dT_background.reset_index(drop=True, inplace=True)
dM_dT_background = dM_dT_df['dM_dT'][temps_dM_dT_background_indices]
dM_dT_polyfit = np.poly1d(poly_background_fit)(temps_dM_dT_background)
mgt_dM_dT = dM_dT_polyfit - dM_dT_background
mgt_dM_dT.reset_index(drop = True, inplace=True)
temps_background_indices = [i for i in np.arange(len(temps)) if ((float(temps[i]) > float(t_range_background_min)) and (float(temps[i]) < float(t_range_background_max)))]
temps_background = temps[temps_background_indices]
poly_func = np.poly1d(poly_background_fit)
background_curve = np.cumsum(poly_func(temps_background) * np.gradient(temps_background))
last_background_temp = temps_background.iloc[-1]
last_background_mag = background_curve[-1]
target_temp_index = np.argmin(np.abs(temps - last_background_temp))
mags_value = mags[target_temp_index]
background_curve_adjusted = background_curve + (mags_value - last_background_mag)
mags_background = mags[temps_background_indices]
mgt_curve = mags_background - background_curve_adjusted
verwey_estimate = calc_zero_crossing(temps_dM_dT_background, mgt_dM_dT)[-1]
remanence_loss = np.trapezoid(mgt_dM_dT, temps_dM_dT_background)
return dM_dT_df, verwey_estimate, remanence_loss, r_squared, temps_background, temps_dM_dT_background, mgt_dM_dT, dM_dT_polyfit, background_curve_adjusted, mgt_curve
[docs]
def verwey_estimate(temps, mags,
t_range_background_min=50,
t_range_background_max=250,
excluded_t_min=75,
excluded_t_max=150,
poly_deg=3,
plot_zero_crossing=False,
plot_title=None,
measurement_marker='o', measurement_color='FireBrick',
background_fit_marker='s', background_fit_color='Teal',
magnetite_marker='d', magnetite_color='RoyalBlue',
verwey_marker='*', verwey_color='Pink',
verwey_size=10,
markersize=3.5):
"""
Estimate the Verwey transition temperature and remanence loss of magnetite from MPMS data.
Plots the magnetization data, background fit, and resulting magnetite curve, and
optionally the zero-crossing.
Parameters
----------
temps : pd.Series
Series representing the temperatures at which magnetization measurements were taken.
mags : pd.Series
Series representing the magnetization measurements.
t_range_background_min : int or float, optional
Minimum temperature for the background fitting range. Default is 50.
t_range_background_max : int or float, optional
Maximum temperature for the background fitting range. Default is 250.
excluded_t_min : int or float, optional
Minimum temperature to exclude from the background fitting range. Default is 75.
excluded_t_max : int or float, optional
Maximum temperature to exclude from the background fitting range. Default is 150.
poly_deg : int, optional
Degree of the polynomial for background fitting. Default is 3.
plot_zero_crossing : bool, optional
If True, plots the zero-crossing of the second derivative. Default is False.
plot_title : str, optional
Title for the plot. Default is None.
measurement_marker : str, optional
Marker symbol for measurement data. Default is 'o'.
measurement_color : str, optional
Color for measurement data. Default is 'black'.
background_fit_marker : str, optional
Marker symbol for background fit data. Default is 's'.
background_fit_color : str, optional
Color for background fit data. Default is 'C1'.
magnetite_marker : str, optional
Marker symbol for magnetite data. Default is 'd'.
magnetite_color : str, optional
Color for magnetite data. Default is 'C0'.
verwey_marker : str, optional
Marker symbol used to denote the Verwey transition estimate on the plot. Default is '*'.
verwey_color : str, optional
Color of the marker representing the Verwey transition estimate. Default is 'Pink'.
verwey_size : int, optional
Size of the marker used for the Verwey transition estimate. Default is 10.
markersize : float, optional
Size of the markers. Default is 3.5.
Returns
-------
verwey_estimate : float
Estimated Verwey transition temperature.
remanence_loss : float
Estimated remanence loss.
Examples
--------
>>> temps = pd.Series([10, 20, 30, 40, 50, 60, 70, 80, 90, 100])
>>> mags = pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
>>> verwey_estimate(temps, mags)
(75.0, 0.5)
"""
dM_dT_df, verwey_estimate, remanence_loss, r_squared, temps_background, temps_dM_dT_background, mgt_dM_dT, dM_dT_polyfit, background_curve_adjusted, mgt_curve = calc_verwey_estimate(temps, mags,
t_range_background_min=t_range_background_min,
t_range_background_max=t_range_background_max,
excluded_t_min=excluded_t_min,
excluded_t_max=excluded_t_max,
poly_deg=poly_deg)
fig = plt.figure(figsize=(12,5))
fig.canvas.header_visible = False
ax0 = fig.add_subplot(1,2,1)
ax0.plot(temps, mags, marker=measurement_marker, markersize=markersize, color=measurement_color,
label='measurement')
ax0.plot(temps_background, background_curve_adjusted, marker=background_fit_marker, markersize=markersize, color=background_fit_color,
label='background fit')
ax0.plot(temps_background, mgt_curve, marker=magnetite_marker, markersize=markersize, color=magnetite_color,
label='magnetite (meas. minus background)')
verwey_y_value = np.interp(verwey_estimate, temps_background, mgt_curve)
ax0.plot(verwey_estimate, verwey_y_value, verwey_marker, color=verwey_color, markersize=verwey_size,
markeredgecolor='black', markeredgewidth=1,
label='Verwey estimate' + ' (' + str(round(verwey_estimate,1)) + ' K)')
ax0.set_ylabel('M (Am$^2$/kg)')
ax0.set_xlabel('T (K)')
ax0.legend(loc='upper right')
ax0.grid(True)
ax0.ticklabel_format(axis='y', style='scientific', scilimits=(0,0))
if plot_title is not None:
ax0.set_title(plot_title)
ax1 = fig.add_subplot(1,2,2)
ax1.plot(dM_dT_df['T'], dM_dT_df['dM_dT'], marker=measurement_marker, markersize=markersize, color=measurement_color,
label='measurement')
ax1.plot(temps_dM_dT_background, dM_dT_polyfit, marker=background_fit_marker, markersize=markersize, color=background_fit_color,
label='background fit'+ ' (r$^2$ = ' + str(round(r_squared,3)) + ')' )
ax1.plot(temps_dM_dT_background, mgt_dM_dT, marker=magnetite_marker, markersize=markersize, color=magnetite_color,
label='magnetite (background fit minus measurement)')
verwey_y_value = np.interp(verwey_estimate, temps_dM_dT_background, mgt_dM_dT)
ax1.plot(verwey_estimate, verwey_y_value, verwey_marker, color=verwey_color, markersize=verwey_size,
markeredgecolor='black', markeredgewidth=1,
label='Verwey estimate' + ' (' + str(round(verwey_estimate,1)) + ' K)')
rectangle = patches.Rectangle((excluded_t_min, ax1.get_ylim()[0]), excluded_t_max - excluded_t_min,
ax1.get_ylim()[1] - ax1.get_ylim()[0],
linewidth=0, edgecolor=None, facecolor='gray',
alpha=0.3)
ax1.add_patch(rectangle)
rect_legend_patch = patches.Patch(color='gray', alpha=0.3, label='excluded from background fit')
handles, labels = ax1.get_legend_handles_labels()
handles.append(rect_legend_patch) # Add the rectangle legend patch
ax1.legend(handles=handles, loc='lower right')
ax1.set_ylabel('dM/dT (Am$^2$/kg/K)')
ax1.set_xlabel('T (K)')
ax1.grid(True)
ax1.ticklabel_format(axis='y', style='scientific', scilimits=(0,0))
if plot_title is not None:
ax1.set_title(plot_title)
if plot_zero_crossing:
ax2 = zero_crossing(temps_dM_dT_background, mgt_dM_dT, make_plot=True)
return verwey_estimate, remanence_loss
[docs]
def verwey_estimate_interactive(measurements, specimen, method, figsize=(11, 5)):
"""
Create an interactive widget for estimating the Verwey transition temperature from low temperature remanence measurements.
This function displays interactive sliders and controls for adjusting background fitting parameters
and temperature ranges, allowing the user to visually estimate the Verwey transition temperature (T_v)
for a selected specimen and measurement method. The function updates plots in real-time according to user input,
enabling exploration of parameter effects on the calculated transition.
Parameters
----------
measurements : pandas.DataFrame
low temperature remanence measurement data containing temperature and magnetization columns for multiple specimens.
specimen : str or ipywidgets.Dropdown
Specimen to analyze, given either as a plain specimen name or as a
selection widget (e.g. from
``verwey_specimen_method_selection_interactive``); for a widget the
current ``.value`` is read when this function runs, so rerun the cell
after changing the dropdown.
method : str or ipywidgets.Dropdown
Measurement method ('LP-FC' or 'LP-ZFC'), as a plain string or a
selection widget.
figsize : tuple of (float, float), optional
Size of the matplotlib figure, by default (11, 5).
Notes
-----
- The function uses `ipywidgets` for interactive controls and `matplotlib` for visualization.
- The background fit and excluded temperature ranges can be adjusted using sliders.
- The polynomial degree of the background fit is also adjustable.
- A reset button restores the default slider values.
- The function relies on supporting functions such as `extract_mpms_data_dc`, `thermomag_derivative`, and `calc_verwey_estimate`.
Returns
-------
None
This function is intended for use in Jupyter notebooks or environments that support interactive widgets and inline plotting.
It displays interactive sliders and plots but does not return a value.
Examples
--------
>>> verwey_estimate_interactive(measurements_df, specimen_dropdown, method_dropdown)
>>> verwey_estimate_interactive(measurements_df, 'NED2-8c', 'LP-FC')
Displays an interactive interface for estimating the Verwey transition temperature.
"""
_check_ipywidgets()
selected_specimen_name = _widget_value(specimen)
selected_method = _widget_value(method)
fc_data, zfc_data, rtsirm_cool_data, rtsirm_warm_data = extract_mpms_data_dc(measurements, selected_specimen_name)
if selected_method == 'LP-FC':
temps = fc_data['meas_temp']
mags = fc_data['magn_mass']
elif selected_method == 'LP-ZFC':
temps = zfc_data['meas_temp']
mags = zfc_data['magn_mass']
temps.reset_index(drop=True, inplace=True)
mags.reset_index(drop=True, inplace=True)
dM_dT_df = thermomag_derivative(temps, mags)
temps_dM_dT = dM_dT_df['T']
# Determine a fixed width for the descriptions to align the sliders
description_width = '250px' # Adjust this based on the longest description
slider_total_width = '600px' # Total width of the slider widget including the description
description_style = {'description_width': description_width}
slider_layout = widgets.Layout(width=slider_total_width) # Set the total width of the slider widget
# Update sliders to use IntRangeSlider
background_temp_range_slider = IntRangeSlider(
value=[60, 250], min=0, max=300, step=1,
description='Background Temperature Range (K):',
layout=slider_layout, style=description_style
)
excluded_temp_range_slider = IntRangeSlider(
value=[75, 150], min=0, max=300, step=1,
description='Excluded Temperature Range (K):',
layout=slider_layout, style=description_style
)
poly_deg_slider = IntSlider(
value=3, min=1, max=5, step=1,
description='Background Fit Polynomial Degree:',
layout=slider_layout, style=description_style
)
# Function to reset sliders to initial values
def reset_sliders(b):
background_temp_range_slider.value = (60, 250)
excluded_temp_range_slider.value = (75, 150)
poly_deg_slider.value = 3
# Create reset button
reset_button = widgets.Button(description="Reset to Default Values", layout=widgets.Layout(width='200px'))
reset_button.on_click(reset_sliders)
# title_label = widgets.Label(value='Adjust Parameters for ' + selected_specimen_name + ' ' + selected_method + ' fit')
# Add the reset button to the UI
ui = widgets.VBox([
# title_label,
background_temp_range_slider,
excluded_temp_range_slider,
poly_deg_slider,
reset_button
])
display(ui)
fig, ax = plt.subplots(ncols=2, nrows=1, figsize=figsize)
fig.canvas.header_visible = False
def update_plot(*args):
ax0, ax1 = ax
ax0.clear()
ax1.clear()
# get values from sliders
t_range_background_min = background_temp_range_slider.value[0]
t_range_background_max = background_temp_range_slider.value[1]
excluded_t_min = excluded_temp_range_slider.value[0]
excluded_t_max = excluded_temp_range_slider.value[1]
poly_deg = poly_deg_slider.value
# recalculate verwey estimate
dM_dT_df, verwey_estimate, remanence_loss, r_squared, temps_background, temps_dM_dT_background, mgt_dM_dT, dM_dT_polyfit, background_curve_adjusted, mgt_curve = calc_verwey_estimate(temps, mags,
t_range_background_min=t_range_background_min,
t_range_background_max=t_range_background_max,
excluded_t_min=excluded_t_min,
excluded_t_max=excluded_t_max,
poly_deg=poly_deg)
ax0.plot(temps, mags, marker='o', markersize=3.5, color='FireBrick',
label='measurement')
ax0.plot(temps_background, background_curve_adjusted, marker='s', markersize=3.5, color='Teal',
label='background fit')
ax0.plot(temps_background, mgt_curve, marker='d', markersize=3.5, color='RoyalBlue',
label='magnetite (meas. minus background)')
verwey_y_value = np.interp(verwey_estimate, temps_background, mgt_curve)
ax0.plot(verwey_estimate, verwey_y_value, '*', color='Pink', markersize=10,
markeredgecolor='black', markeredgewidth=1,
label='Verwey estimate' + ' (' + str(round(verwey_estimate,1)) + ' K)')
ax0.set_ylabel('M (Am$^2$/kg)')
ax0.set_xlabel('T (K)')
ax0.legend(loc='upper right')
ax0.grid(True)
ax0.ticklabel_format(axis='y', style='scientific', scilimits=(0,0))
ax1.plot(dM_dT_df['T'], dM_dT_df['dM_dT'], marker='o', markersize=3.5, color='FireBrick',
label='measurement')
ax1.plot(temps_dM_dT_background, dM_dT_polyfit, marker='s', markersize=3.5, color='Teal',
label='background fit'+ ' (r$^2$ = ' + str(round(r_squared,3)) + ')' )
ax1.plot(temps_dM_dT_background, mgt_dM_dT, marker='d', markersize=3.5, color='RoyalBlue',
label='magnetite (background fit minus measurement)')
verwey_y_value = np.interp(verwey_estimate, temps_dM_dT_background, mgt_dM_dT)
ax1.plot(verwey_estimate, verwey_y_value, '*', color='Pink', markersize=10,
markeredgecolor='black', markeredgewidth=1,
label='Verwey estimate' + ' (' + str(round(verwey_estimate,1)) + ' K)')
rectangle = patches.Rectangle((excluded_t_min, ax1.get_ylim()[0]), excluded_t_max - excluded_t_min,
ax1.get_ylim()[1] - ax1.get_ylim()[0],
linewidth=0, edgecolor=None, facecolor='gray',
alpha=0.3)
ax1.add_patch(rectangle)
rect_legend_patch = patches.Patch(color='gray', alpha=0.3, label='excluded from background fit')
handles, labels = ax1.get_legend_handles_labels()
handles.append(rect_legend_patch) # Add the rectangle legend patch
ax1.legend(handles=handles, loc='lower right')
ax1.set_ylabel('dM/dT (Am$^2$/kg/K)')
ax1.set_xlabel('T (K)')
ax1.grid(True)
ax1.ticklabel_format(axis='y', style='scientific', scilimits=(0,0))
# Attach observers
background_temp_range_slider.observe(update_plot, names='value')
excluded_temp_range_slider.observe(update_plot, names='value')
poly_deg_slider.observe(update_plot, names='value')
reset_button.on_click(update_plot)
update_plot()
[docs]
def verwey_specimen_method_selection_interactive(measurements):
"""
Creates and displays dropdown widgets for selecting a specimen and the corresponding
available method codes (specifically 'LP-FC' and 'LP-ZFC') from a given DataFrame of measurements.
This function filters the measurements to include only those with desired method codes,
dynamically updates the method dropdown based on the selected specimen, and organizes
the dropdowns vertically in the UI.
Parameters:
measurements (pd.DataFrame): The DataFrame containing measurement data with columns
'specimen' and 'method_codes'. It is expected to have
at least these two columns where 'specimen' identifies
the specimen name and 'method_codes' contains the method
codes associated with each measurement.
Returns:
tuple: A tuple containing the specimen dropdown widget (`ipywidgets.Dropdown`)
and the method dropdown widget (`ipywidgets.Dropdown`). The specimen dropdown
allows for the selection of a specimen, and the method dropdown updates to
display only the methods available for the selected specimen. The initial
selection in the specimen dropdown is set to the first specimen option.
Note:
The method dropdown is initially populated based on the methods available for the
first selected specimen. The available methods are specifically filtered for 'LP-FC'
and 'LP-ZFC' codes.
"""
_check_ipywidgets()
# Filter to get specimens with desired method codes
experiments = make_experiment_df(measurements)
filtered_experiments = experiments[experiments['method_codes'].isin(['LP-FC', 'LP-ZFC'])]
specimen_options = filtered_experiments['specimen'].unique().tolist()
selected_specimen_name = specimen_options[0] # Example initial selection
# Dropdown for specimen selection
specimen_dropdown = widgets.Dropdown(
options=specimen_options,
description='Specimen:',
value=selected_specimen_name
)
# Method dropdown initialized with placeholder options
method_dropdown = widgets.Dropdown(
description='Method:',
)
# Function to update method options based on selected specimen
def update_method_options(change):
selected_specimen = change['new']
# Filter experiments to get methods available for the selected specimen
available_methods = filtered_experiments[filtered_experiments['specimen'] == selected_specimen]['method_codes'].unique().tolist()
# Update method dropdown options and reset its value
method_dropdown.options = available_methods
if available_methods:
method_dropdown.value = available_methods[0]
else:
method_dropdown.value = None
# Register the update function with specimen dropdown
specimen_dropdown.observe(update_method_options, names='value')
# Initially populate method dropdown based on the first selected specimen
update_method_options({'new': selected_specimen_name})
# Creating a UI layout using VBox to organize the dropdowns vertically
ui_layout = widgets.VBox([specimen_dropdown, method_dropdown])
# Display the UI layout
display(ui_layout)
return specimen_dropdown, method_dropdown
[docs]
def verwey_estimate_multiple_specimens(specimens_with_params, measurements):
"""
Analyze Verwey transitions for a list of specimens with unique parameters.
This function uses either field-cooled (FC) or zero-field cooled (ZFC) data depending on the
method_codes provided in each specimen's parameters. If "LP-FC" is found in the colon-delimited
method_codes, FC data is used; if "LP-ZFC" is found, ZFC data is used.
Parameters
----------
specimens_with_params : list of dict
List of specimen dictionaries. Each dictionary should contain:
- 'specimen_name' : str
The name of the specimen.
- 'params' : dict
Dictionary containing:
- 't_range_background_min' : int or float
- 't_range_background_max' : int or float
- 'excluded_t_min' : int or float
- 'excluded_t_max' : int or float
- 'poly_deg' : int
- 'method_codes' : str
Colon-delimited string that must include either "LP-FC" or "LP-ZFC".
measurements : object
Measurements dataframe in MagIC format.
Returns
-------
pd.DataFrame
DataFrame containing the Verwey transition estimates and the input parameters for each specimen.
Columns include:
- 'specimen'
- 'critical_temp'
- 'critical_temp_type'
- 'remanence_loss'
plus the additional parameters from the input.
Raises
------
ValueError
If neither "LP-FC" nor "LP-ZFC" is found in the method_codes for a specimen.
Exception
Propagates exceptions raised during data extraction or analysis.
"""
verwey_estimates_and_params = []
# Process each specimen with its unique parameters
for specimen in specimens_with_params:
specimen_name = specimen['specimen_name']
params = specimen['params']
# Extract method codes and determine whether to use FC or ZFC data
method_codes = params.get('method_codes', '')
codes = method_codes.split(':')
# Extract the measurement data for the specimen
fc_data, zfc_data, rtsirm_cool_data, rtsirm_warm_data = extract_mpms_data_dc(measurements, specimen_name)
if "LP-FC" in codes:
data = fc_data
elif "LP-ZFC" in codes:
data = zfc_data
else:
raise ValueError(f"Specimen {specimen_name} does not have a valid method code ('LP-FC' or 'LP-ZFC').")
temps = data['meas_temp']
mags = data['magn_mass']
# Estimate Verwey transition using selected data
vt_estimate, rem_loss = verwey_estimate(
temps,
mags,
t_range_background_min=params['t_range_background_min'],
t_range_background_max=params['t_range_background_max'],
excluded_t_min=params['excluded_t_min'],
excluded_t_max=params['excluded_t_max'],
poly_deg=params['poly_deg'],
plot_title=specimen_name
)
record = {
'specimen': specimen_name,
'critical_temp': vt_estimate,
'critical_temp_type': 'Verwey',
'remanence_loss': rem_loss
}
record.update(params)
verwey_estimates_and_params.append(record)
return pd.DataFrame(verwey_estimates_and_params)
[docs]
def thermomag_derivative(temps, mags, drop_first=False, drop_last=False):
"""
Calculates the derivative of magnetization with respect to temperature and optionally
drops the data corresponding to the highest and/or lowest temperature.
Parameters:
temps (pd.Series): A pandas Series representing the temperatures at which
magnetization measurements were taken.
mags (pd.Series): A pandas Series representing the magnetization measurements.
drop_last (bool): Optional; whether to drop the last row from the resulting
DataFrame. Defaults to False. Useful when there is an
artifact associated with the end of the experiment.
drop_first (bool): Optional; whether to drop the first row from the resulting
DataFrame. Defaults to False. Useful when there is an
artifact associated with the start of the experiment.
Returns:
pd.DataFrame: A pandas DataFrame with two columns:
'T' - Midpoint temperatures for each temperature interval.
'dM_dT' - The derivative of magnetization with respect to temperature.
If drop_last is True, the last temperature point is excluded.
If drop_first is True, the first temperature point is excluded.
"""
temps = temps.reset_index(drop=True)
mags = mags.reset_index(drop=True)
dT = temps.diff()
dM = mags.diff()
dM_dT = dM / dT
dM_dT_real = dM_dT[1:]
dM_dT_real.reset_index(drop=True, inplace=True)
temps_dM_dT = [temps[n] + dT[n + 1] / 2 for n in range(len(temps) - 1)]
temps_dM_dT = pd.Series(temps_dM_dT)
dM_dT_df = pd.DataFrame({'T': temps_dM_dT, 'dM_dT': dM_dT_real})
# Drop the last row if specified
if drop_last:
dM_dT_df = dM_dT_df[:-1].reset_index(drop=True)
# Drop the first row if specified
if drop_first:
dM_dT_df = dM_dT_df[1:].reset_index(drop=True)
return dM_dT_df
[docs]
def calc_zero_crossing(dM_dT_temps, dM_dT):
"""
Calculate the temperature at which the second derivative of magnetization with respect to
temperature crosses zero. This value provides an estimate of the peak of the derivative
curve that is more precise than the maximum value.
The function computes the second derivative of magnetization (dM/dT) with respect to
temperature, identifies the nearest points around the maximum value of the derivative,
and then calculates the temperature at which this second derivative crosses zero using
linear interpolation.
Parameters:
dM_dT_temps (pd.Series): A pandas Series representing temperatures corresponding to
the first derivation of magnetization with respect to temperature.
dM_dT (pd.Series): A pandas Series representing the first derivative of
magnetization with respect to temperature.
Returns:
float: The estimated temperature at which the second derivative of magnetization
with respect to temperature crosses zero.
Note:
The function assumes that the input series `dM_dT_temps` and `dM_dT` are related to
each other and are of equal length.
"""
max_dM_dT_temp = dM_dT_temps[dM_dT.idxmax()]
d2M_dT2 = thermomag_derivative(dM_dT_temps, dM_dT)
d2M_dT2_T_array = d2M_dT2['T'].to_numpy()
max_index = np.searchsorted(d2M_dT2_T_array, max_dM_dT_temp)
d2M_dT2_T_before = d2M_dT2['T'][max_index-1]
d2M_dT2_before = d2M_dT2['dM_dT'][max_index-1]
d2M_dT2_T_after = d2M_dT2['T'][max_index]
d2M_dT2_after = d2M_dT2['dM_dT'][max_index]
zero_cross_temp = d2M_dT2_T_before + ((d2M_dT2_T_after - d2M_dT2_T_before) / (d2M_dT2_after - d2M_dT2_before)) * (0 - d2M_dT2_before)
return d2M_dT2, d2M_dT2_T_before, d2M_dT2_before, d2M_dT2_T_after, d2M_dT2_after, zero_cross_temp
[docs]
def zero_crossing(dM_dT_temps, dM_dT, make_plot=False, xlim=None,
verwey_marker='*', verwey_color='Pink',
verwey_size=10,):
"""
Calculate the temperature at which the second derivative of magnetization with respect to
temperature crosses zero. This value provides an estimate of the peak of the derivative
curve that is more precise than the maximum value.
The function computes the second derivative of magnetization (dM/dT) with respect to
temperature, identifies the nearest points around the maximum value of the derivative,
and then calculates the temperature at which this second derivative crosses zero using
linear interpolation.
Parameters:
dM_dT_temps (pd.Series): A pandas Series representing temperatures corresponding to
the first derivation of magnetization with respect to temperature.
dM_dT (pd.Series): A pandas Series representing the first derivative of
magnetization with respect to temperature.
make_plot (bool, optional): If True, a plot will be generated. Defaults to False.
xlim (tuple, optional): A tuple specifying the x-axis limits for the plot. Defaults to None.
verwey_marker : str, optional
Marker symbol used to denote the Verwey transition estimate on the plot. Default is '*'.
verwey_color : str, optional
Color of the marker representing the Verwey transition estimate. Default is 'Pink'.
verwey_size : int, optional
Size of the marker used for the Verwey transition estimate. Default is 10.
Returns:
float: The estimated temperature at which the second derivative of magnetization
with respect to temperature crosses zero.
Note:
The function assumes that the input series `dM_dT_temps` and `dM_dT` are related to
each other and are of equal length.
"""
d2M_dT2, d2M_dT2_T_before, d2M_dT2_before, d2M_dT2_T_after, d2M_dT2_after, zero_cross_temp = calc_zero_crossing(dM_dT_temps, dM_dT)
if make_plot:
fig = plt.figure(figsize=(12,4))
ax0 = fig.add_subplot(1,1,1)
ax0.plot(d2M_dT2['T'], d2M_dT2['dM_dT'], '.-', color='purple', label='magnetite (background fit minus measurement)')
ax0.plot(d2M_dT2_T_before, d2M_dT2_before, marker='o', markerfacecolor='none', markeredgecolor='red')
ax0.plot(d2M_dT2_T_after, d2M_dT2_after, marker='o', markerfacecolor='none', markeredgecolor='red')
ax0.plot(zero_cross_temp, 0, verwey_marker, color=verwey_color, markersize=verwey_size, markeredgecolor='black')
label = f'{zero_cross_temp:.1f} K'
ax0.text(zero_cross_temp+2, 0, label, color='black',
verticalalignment='center', horizontalalignment='left',
bbox=dict(facecolor='white', alpha=0.7, edgecolor='none'))
ax0.set_ylabel('d$^2$M/dT$^2$')
ax0.set_xlabel('T (K)')
ax0.grid(True)
ax0.ticklabel_format(axis='y', style='scientific', scilimits=(0,0))
if xlim is not None:
ax0.set_xlim(xlim)
plt.show()
return zero_cross_temp
[docs]
def goethite_removal(rtsirm_warm_data,
rtsirm_cool_data,
t_min=150, t_max=290, poly_deg=2,
rtsirm_cool_color='#17becf', rtsirm_warm_color='#d62728',
symbol_size=4, return_data=False):
"""
Analyzes and visualizes the removal of goethite signal from Room Temperature Saturation
Isothermal Remanent Magnetization (RTSIRM) warming and cooling data. The function fits
a polynomial to the RTSRIM warming curve between specified temperature bounds to model
the goethite contribution, then subtracts this fit from the original data. The corrected
and uncorrected magnetizations are plotted, along with their derivatives, to assess the
effect of goethite removal.
Parameters:
rtsirm_warm_data (pd.DataFrame): DataFrame containing 'meas_temp' and 'magn_mass' columns
for RTSIRM warming data.
rtsirm_cool_data (pd.DataFrame): DataFrame containing 'meas_temp' and 'magn_mass' columns
for RTSIRM cooling data.
t_min (int, optional): Minimum temperature for polynomial fitting. Default is 150.
t_max (int, optional): Maximum temperature for polynomial fitting. Default is 290.
poly_deg (int, optional): Degree of the polynomial to fit. Default is 2.
rtsirm_cool_color (str, optional): Color code for plotting cooling data. Default is '#17becf'.
rtsirm_warm_color (str, optional): Color code for plotting warming data. Default is '#d62728'.
symbol_size (int, optional): Size of the markers in the plots. Default is 4.
return_data (bool, optional): If True, returns the corrected magnetization data for both
warming and cooling. Default is False.
Returns:
Tuple[pd.Series, pd.Series]: Only if return_data is True. Returns two pandas Series
containing the corrected magnetization data for the warming
and cooling sequences, respectively.
"""
rtsirm_warm_temps = rtsirm_warm_data['meas_temp']
rtsirm_warm_mags = rtsirm_warm_data['magn_mass']
rtsirm_cool_temps = rtsirm_cool_data['meas_temp']
rtsirm_cool_mags = rtsirm_cool_data['magn_mass']
rtsirm_warm_temps.reset_index(drop=True, inplace=True)
rtsirm_warm_mags.reset_index(drop=True, inplace=True)
rtsirm_cool_temps.reset_index(drop=True, inplace=True)
rtsirm_cool_mags.reset_index(drop=True, inplace=True)
rtsirm_warm_temps_filtered_indices = [i for i in np.arange(len(rtsirm_warm_temps)) if ((float(rtsirm_warm_temps[i]) > float(t_min)) and (float(rtsirm_warm_temps[i]) < float(t_max)) )]
rtsirm_warm_temps_filtered = rtsirm_warm_temps[rtsirm_warm_temps_filtered_indices]
rtsirm_warm_mags_filtered = rtsirm_warm_mags[rtsirm_warm_temps_filtered_indices]
geothite_fit = np.polyfit(rtsirm_warm_temps_filtered, rtsirm_warm_mags_filtered, poly_deg)
rtsirm_warm_mags_polyfit = np.poly1d(geothite_fit)(rtsirm_warm_temps)
rtsirm_cool_mags_polyfit = np.poly1d(geothite_fit)(rtsirm_cool_temps)
rtsirm_warm_mags_corrected = rtsirm_warm_mags - rtsirm_warm_mags_polyfit
rtsirm_cool_mags_corrected = rtsirm_cool_mags - rtsirm_cool_mags_polyfit
fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(12, 8))
axs[0, 0].plot(rtsirm_warm_temps, rtsirm_warm_mags, color=rtsirm_warm_color,
marker='o', linestyle='-', markersize=symbol_size, label='RTSIRM Warming')
axs[0, 0].plot(rtsirm_cool_temps, rtsirm_cool_mags, color=rtsirm_cool_color,
marker='o', linestyle='-', markersize=symbol_size, label='RTSIRM Cooling')
axs[0, 0].plot(rtsirm_warm_temps, rtsirm_warm_mags_polyfit, color=rtsirm_warm_color,
linestyle='--', label='goethite fit')
axs[0, 1].plot(rtsirm_warm_temps, rtsirm_warm_mags_corrected, color=rtsirm_warm_color,
marker='s', linestyle='-', markersize=symbol_size, label='RTSIRM Warming (goethite removed)')
axs[0, 1].plot(rtsirm_cool_temps, rtsirm_cool_mags_corrected, color=rtsirm_cool_color,
marker='s', linestyle='-', markersize=symbol_size, label='RTSIRM Cooling (goethite removed)')
ax0 = axs[0, 0]
rectangle = patches.Rectangle((t_min, ax0.get_ylim()[0]), t_max - t_min,
ax0.get_ylim()[1] - ax0.get_ylim()[0],
linewidth=0, edgecolor=None, facecolor='gray',
alpha=0.3)
ax0.add_patch(rectangle)
rect_legend_patch = patches.Patch(color='gray', alpha=0.3, label='excluded from background fit')
handles, labels = ax0.get_legend_handles_labels()
handles.append(rect_legend_patch) # Add the rectangle legend patch
for ax in axs[0, :]:
ax.set_xlabel("Temperature (K)")
ax.set_ylabel("Magnetization (Am$^2$/kg)")
ax.legend()
ax.grid(True)
ax.set_xlim(0, 300)
rtsirm_cool_derivative = thermomag_derivative(rtsirm_cool_data['meas_temp'],
rtsirm_cool_data['magn_mass'], drop_first=True)
rtsirm_warm_derivative = thermomag_derivative(rtsirm_warm_data['meas_temp'],
rtsirm_warm_data['magn_mass'], drop_last=True)
rtsirm_cool_derivative_corrected = thermomag_derivative(rtsirm_cool_data['meas_temp'],
rtsirm_cool_mags_corrected, drop_first=True)
rtsirm_warm_derivative_corrected = thermomag_derivative(rtsirm_warm_data['meas_temp'],
rtsirm_warm_mags_corrected, drop_last=True)
axs[1, 0].plot(rtsirm_cool_derivative['T'], rtsirm_cool_derivative['dM_dT'],
marker='o', linestyle='-', color=rtsirm_cool_color, markersize=symbol_size, label='RTSIRM Cooling Derivative')
axs[1, 0].plot(rtsirm_warm_derivative['T'], rtsirm_warm_derivative['dM_dT'],
marker='o', linestyle='-', color=rtsirm_warm_color, markersize=symbol_size, label='RTSIRM Warming Derivative')
axs[1, 1].plot(rtsirm_cool_derivative_corrected['T'], rtsirm_cool_derivative_corrected['dM_dT'],
marker='s', linestyle='-', color=rtsirm_cool_color, markersize=symbol_size, label='RTSIRM Cooling Derivative\n(goethite removed)')
axs[1, 1].plot(rtsirm_warm_derivative_corrected['T'], rtsirm_warm_derivative_corrected['dM_dT'],
marker='s', linestyle='-', color=rtsirm_warm_color, markersize=symbol_size, label='RTSIRM Warming Derivative\n(goethite removed)')
for ax in axs[1, :]:
ax.set_xlabel("Temperature (K)")
ax.set_ylabel("dM/dT")
ax.legend()
ax.grid(True)
ax.set_xlim(0, 300)
fig.tight_layout()
plt.show()
if return_data:
rtsirm_warm_adjusted = pd.DataFrame({'meas_temp': rtsirm_warm_temps, 'corrected_magn_mass': rtsirm_warm_mags_corrected})
rtsirm_cool_adjusted = pd.DataFrame({'meas_temp': rtsirm_cool_temps, 'corrected_magn_mass': rtsirm_cool_mags_corrected})
return rtsirm_warm_adjusted, rtsirm_cool_adjusted
[docs]
def goethite_removal_interactive(measurements, specimen):
"""
Display an interactive widget for fitting and visualizing goethite removal from low temperature remanence data.
This function creates an interactive interface that allows the user to select a specimen and adjust parameters
(temperature range and polynomial degree) for fitting the goethite component in RTSIRM (Room Temperature Saturation Isothermal Remanent Magnetization) warming and cooling curves.
The user can visually explore the effect of these parameters on the fit and resulting goethite removal, with real-time updated plots.
Parameters
----------
measurements : pandas.DataFrame
Low temperature remanence measurement data containing temperature and magnetization information for multiple specimens.
specimen : str or ipywidgets.Dropdown
Specimen to analyze, given either as a plain specimen name or as a
selection widget (e.g. from ``specimen_selection_interactive``); for
a widget the current ``.value`` is read when this function runs, so
rerun the cell after changing the dropdown.
Notes
-----
- Uses `ipywidgets` for interactive controls and `matplotlib` for plotting.
- The temperature range for the goethite fit and the polynomial degree of the fit can be adjusted via sliders.
- A reset button allows restoration of default parameter values.
- Supporting functions such as `extract_mpms_data_dc` and `goethite_removal` are required for this function to operate.
- This function is intended to be used in a Jupyter notebook or similar interactive environment.
Returns
-------
None
The function displays interactive widgets and plots for goethite removal but does not return a value.
Examples
--------
>>> goethite_removal_interactive(measurements_df, specimen_dropdown)
>>> goethite_removal_interactive(measurements_df, 'A73-7-1350-4B-01a')
Displays interactive sliders and plots for fitting goethite removal to the selected specimen's data.
"""
_check_ipywidgets()
selected_specimen_name = _widget_value(specimen)
fc_data, zfc_data, rtsirm_cool_data, rtsirm_warm_data = extract_mpms_data_dc(measurements, selected_specimen_name)
# Determine a fixed width for the descriptions to align the sliders
description_width = '250px' # Adjust this based on the longest description
slider_total_width = '600px' # Total width of the slider widget including the description
description_style = {'description_width': description_width}
slider_layout = widgets.Layout(width=slider_total_width) # Set the total width of the slider widget
# Update sliders to use IntRangeSlider
temp_range_slider = widgets.IntRangeSlider(
value=[150, 290], min=0, max=300, step=1,
description='Geothite Fit Temperature Range (K):',
layout=slider_layout, style=description_style
)
poly_deg_slider = widgets.IntSlider(
value=2, min=1, max=3, step=1,
description='Goethite Fit Polynomial Degree:',
layout=slider_layout, style=description_style
)
# Function to reset sliders to initial values
def reset_sliders(b):
temp_range_slider.value = (150, 290)
poly_deg_slider.value = 2
# Create reset button
reset_button = widgets.Button(description="Reset to Default Values", layout=widgets.Layout(width='200px'))
reset_button.on_click(reset_sliders)
title_label = widgets.Label(value='Adjust Parameters for ' + selected_specimen_name + ' ' + 'goethite' + ' fit')
# Add the reset button to the UI
ui = widgets.VBox([
title_label,
temp_range_slider,
poly_deg_slider,
reset_button
])
out = widgets.interactive_output(
lambda temp_range, poly_deg: goethite_removal(
rtsirm_warm_data, rtsirm_cool_data,
temp_range[0], temp_range[1],
poly_deg
), {
'temp_range': temp_range_slider,
'poly_deg': poly_deg_slider,
}
)
out.layout.height = '500px'
display(ui, out)
[docs]
def plot_mpms_ac(
experiment,
frequency=None,
phase='in',
figsize=(6, 6),
interactive=False,
return_figure=False,
show_plot=True,
legend_location='upper left'):
"""
Plot AC susceptibility data from MPMS-X, optionally as interactive Bokeh.
Parameters
----------
experiment : pandas.DataFrame
The experiment table from the MagIC contribution.
frequency : float or None
Frequency of AC measurement in Hz; None plots all frequencies.
phase : {'in','out','both'}
Which phase to plot.
figsize : tuple of float
Figure size for Matplotlib (width, height).
interactive : bool
If True, render with Bokeh for interactive exploration.
return_figure : bool
If True, return the figure object(s).
show_plot : bool
If True, display the plot.
legend_location : str, default 'upper left'
Location of the legend in Matplotlib terms.
Returns
-------
fig, ax or (fig, axes) or Bokeh layout or None
"""
bokeh_legend_location = _map_legend_location(legend_location)
if phase not in ['in', 'out', 'both']:
raise ValueError('phase must be "in", "out", or "both"')
freqs = ([frequency] if frequency is not None
else experiment['meas_freq'].unique().tolist())
if frequency is not None and frequency not in freqs:
raise ValueError(f'frequency must be one of {freqs}')
if interactive:
_check_bokeh()
tools = [
HoverTool(tooltips=[('T', '@x'), ('χ', '@y')]),
'pan,box_zoom,wheel_zoom,reset,save']
n = len(freqs)
# Category10 is only keyed for 3-10 entries; clamp and cycle so any
# number of frequencies (including a single one) gets a color
palette = Category10[min(max(n, 3), 10)]
figs = []
bokeh_height = int(figsize[1] * 96)
if phase in ['in', 'out']:
p = figure(
title=f'AC χ ({phase} phase)',
x_axis_label='Temperature (K)',
y_axis_label='χ (m³/kg)',
tools=tools,
sizing_mode='stretch_width',
height=bokeh_height
)
p.xaxis.axis_label_text_font_style = "normal"
p.yaxis.axis_label_text_font_style = "normal"
for i, f in enumerate(freqs):
d = experiment[experiment['meas_freq'] == f]
col = 'susc_chi_mass' if phase == 'in' else 'susc_chi_qdr_mass'
color = palette[i % len(palette)]
p.line(
d['meas_temp'], d[col],
legend_label=f'{f} Hz',
line_width=2,
color=color)
p.scatter(
d['meas_temp'], d[col],
size=6,
alpha=0.6,
fill_color=color,
line_color=color,
legend_label=f'{f} Hz')
p.legend.location = bokeh_legend_location
p.legend.click_policy = "hide"
figs = [p]
else:
p1 = figure(
title='AC χ in phase',
x_axis_label='Temperature (K)',
y_axis_label='χ (m³/kg)',
tools=tools,
sizing_mode='stretch_width',
height=bokeh_height
)
p2 = figure(
title='AC χ out phase',
x_axis_label='Temperature (K)',
y_axis_label='χ (m³/kg)',
tools=tools,
sizing_mode='stretch_width',
height=bokeh_height
)
for p in (p1, p2):
p.xaxis.axis_label_text_font_style = "normal"
p.yaxis.axis_label_text_font_style = "normal"
for i, f in enumerate(freqs):
d = experiment[experiment['meas_freq'] == f]
color = palette[i % len(palette)]
p1.line(
d['meas_temp'], d['susc_chi_mass'],
legend_label=f'{f} Hz',
line_width=2,
color=color)
p1.scatter(
d['meas_temp'], d['susc_chi_mass'],
size=6,
alpha=0.6,
fill_color=color,
line_color=color,
legend_label=f'{f} Hz')
p2.line(
d['meas_temp'], d['susc_chi_qdr_mass'],
legend_label=f'{f} Hz',
line_width=2,
color=color)
p2.scatter(
d['meas_temp'], d['susc_chi_qdr_mass'],
size=6,
alpha=0.6,
fill_color=color,
line_color=color,
legend_label=f'{f} Hz')
p1.legend.location = bokeh_legend_location
p2.legend.location = bokeh_legend_location
p1.legend.click_policy = p2.legend.click_policy = "hide"
figs = [p1, p2]
layout = gridplot([figs], sizing_mode='stretch_width')
if show_plot:
show(layout)
if return_figure:
return layout
return None
# static Matplotlib
if phase in ['in', 'out']:
fig, ax = plt.subplots(figsize=figsize)
col = 'susc_chi_mass' if phase == 'in' else 'susc_chi_qdr_mass'
for f in freqs:
d = experiment[experiment['meas_freq'] == f]
ax.plot(d['meas_temp'], d[col], 'o-', label=f'{f} Hz')
ax.set_xlabel('Temperature (K)')
ax.set_ylabel('χ (m³/kg)')
ax.set_title(f'AC χ ({phase} phase)')
ax.legend(loc=legend_location)
if show_plot:
plt.show()
if return_figure:
return fig, ax
return None
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
for f in freqs:
d = experiment[experiment['meas_freq'] == f]
ax1.plot(d['meas_temp'], d['susc_chi_mass'], 'o-', label=f'{f} Hz')
ax2.plot(d['meas_temp'], d['susc_chi_qdr_mass'], 'o-', label=f'{f} Hz')
ax1.set_xlabel('Temperature (K)')
ax1.set_ylabel('χ (m³/kg)')
ax1.set_title('AC χ in phase')
ax1.legend(loc=legend_location)
ax2.set_xlabel('Temperature (K)')
ax2.set_ylabel('χ (m³/kg)')
ax2.set_title('AC χ out phase')
ax2.legend(loc=legend_location)
if show_plot:
plt.show()
if return_figure:
return fig, (ax1, ax2)
[docs]
def mpms_signal_blender(measurement_1, measurement_2,
spec_1, spec_2,
experiments=['LP-ZFC', 'LP-FC', 'LP-CW-SIRM:LP-MC', 'LP-CW-SIRM:LP-MW'],
temp_col='meas_temp', moment_col='magn_mass',
fraction=0.5):
'''
function for simulating simple mixtures of MPMS dc remanence measurements using the Insitute for Rock Magnetism's
rock magnetism bestiary data
Parameters
----------
measurement_1 : pandas.DataFrame
MagIC formatted dataframe containing the first set of measurements.
measurement_2 : pandas.DataFrame
MagIC formatted dataframe containing the second set of measurements.
spec_1 : str
Specimen name for the first set of measurements.
spec_2 : str
Specimen name for the second set of measurements.
experiments : list of str, optional
List of experiment method codes to consider for blending. Default is
['LP-ZFC', 'LP-FC', 'LP-CW-SIRM:LP-MC', 'LP-CW-SIRM:LP-MW'].
temp_col : str, optional
Column name for temperature in the measurement dataframes. Default is 'meas_temp'.
moment_col : str, optional
Column name for magnetization in the measurement dataframes. Default is 'magn_mass'.
fraction : float, optional
Fraction of the first specimen's magnetization to blend with the second specimen's magnetization. Default is 0.5.
Returns
-------
dict
A dictionary where keys are experiment method codes and values are dictionaries containing:
'''
spec_1_meas = measurement_1[measurement_1['specimen']==spec_1]
spec_2_meas = measurement_2[measurement_2['specimen']==spec_2]
output_dict = {}
for experiment in experiments:
exp_1 = spec_1_meas[spec_1_meas['method_codes']==experiment]
exp_2 = spec_2_meas[spec_2_meas['method_codes']==experiment]
if exp_1.empty or exp_2.empty:
continue
T1 = exp_1[temp_col].values
T2 = exp_2[temp_col].values
T_min = max(T1.min(), T2.min())
T_max = min(T1.max(), T2.max())
n = max(len(T1), len(T2))
T_common = np.linspace(T_min, T_max, n)
M1 = exp_1[moment_col].values
M2 = exp_2[moment_col].values
# sort M1 and M2 based on sorted T1 and T2
M1_sorted = M1[np.argsort(T1)]
M2_sorted = M2[np.argsort(T2)]
T1_sorted = np.sort(T1)
T2_sorted = np.sort(T2)
M1_interp = np.interp(T_common, T1_sorted, M1_sorted)
M2_interp = np.interp(T_common, T2_sorted, M2_sorted)
M_blend = fraction * M1_interp + (1 - fraction) * M2_interp
output_dict[experiment] = {
'T': T_common,
'M_blend': M_blend,
}
return output_dict
[docs]
def mpms_signal_blender_interactive(measurement_1, measurement_2,
experiments=['LP-ZFC', 'LP-FC', 'LP-CW-SIRM:LP-MC', 'LP-CW-SIRM:LP-MW'],
temp_col='meas_temp', moment_col='magn_mass',
figsize=(12, 6)):
'''
function for making interactive blender of MPMS dc remanence measurements using the Institute for Rock Magnetism's
rock magnetism bestiary data
Parameters
----------
measurement_1 : pandas.DataFrame
MagIC formatted dataframe containing the first set of measurements.
measurement_2 : pandas.DataFrame
MagIC formatted dataframe containing the second set of measurements.
experiments : list of str, optional
List of experiment method codes to consider for blending. Default is
['LP-ZFC', 'LP-FC', 'LP-CW-SIRM:LP-MC', 'LP-CW-SIRM:LP-MW'].
temp_col : str, optional
Column name for temperature in the measurement dataframes. Default is 'meas_temp'.
moment_col : str, optional
Column name for magnetization in the measurement dataframes. Default is 'magn_mass'.
figsize : tuple of float, optional
Size of the figure for plotting. Default is (12, 6).
'''
_check_ipywidgets()
slider = FloatSlider(
value=0.5, min=0, max=1, step=0.01,
description='fraction', continuous_update=False
)
display(HBox([slider]))
spec_1_dropdown = widgets.Dropdown(
options=measurement_1['specimen'].unique(),
description='Specimen 1:',
disabled=False,
)
spec_2_dropdown = widgets.Dropdown(
options=measurement_2['specimen'].unique(),
description='Specimen 2:',
disabled=False,
)
display(spec_1_dropdown, spec_2_dropdown)
fig, ax = plt.subplots(ncols=2, nrows=1, figsize=figsize)
fig.canvas.header_visible = False
def update(*args):
ax[0].clear()
ax[1].clear()
blender_result = mpms_signal_blender(
measurement_1, measurement_2,
spec_1_dropdown.value, spec_2_dropdown.value,
experiments=experiments,
temp_col=temp_col, moment_col=moment_col,
fraction=slider.value
)
for experiment, data in blender_result.items():
if 'LP-FC' in experiment:
ax[0].plot(data['T'], data['M_blend'], marker='o', markersize=5, color='blue', alpha=0.6, label=experiment)
elif 'LP-ZFC' in experiment:
ax[0].plot(data['T'], data['M_blend'], marker='o', markersize=5, color='red', alpha=0.6, label=experiment)
elif 'LP-CW-SIRM:LP-MC' in experiment:
ax[1].plot(data['T'], data['M_blend'], marker='o', markersize=5, color='green', alpha=0.6, label=experiment)
elif 'LP-CW-SIRM:LP-MW' in experiment:
ax[1].plot(data['T'], data['M_blend'], marker='o', markersize=5, color='black', alpha=0.6, label=experiment)
ax[0].set_xlabel('Temperature (K)', fontsize=12)
ax[0].set_ylabel('Magnetization (Am$^2$/kg)', fontsize=12)
ax[0].set_title('FC and ZFC')
ax[0].legend()
ax[0].grid()
ax[1].set_xlabel('Temperature (K)', fontsize=12)
ax[1].set_ylabel('Magnetization (Am$^2$/kg)', fontsize=12)
ax[1].set_title('RTSIRM cycling')
ax[1].legend()
ax[1].grid()
# fig.canvas.draw()
fig.canvas.flush_events()
plt.tight_layout()
slider.observe(update, names='value')
spec_1_dropdown.observe(update, names='value')
spec_2_dropdown.observe(update, names='value')
update()
# hysteresis functions
# ------------------------------------------------------------------------------------------------------------------
[docs]
def plot_hyst_loop(field, magnetization, specimen_name, p=None, interactive=True, show_plot=True, return_figure=False, line_color='grey', line_width=1, label='', legend_location='bottom_right'):
'''
function to plot a hysteresis loop
Parameters
----------
field : numpy array or list
hysteresis loop field values
magnetization : numpy array or list
hysteresis loop magnetization values
Returns
-------
p : bokeh.plotting.figure
'''
_check_bokeh()
assert len(field) == len(magnetization), 'Field and magnetization arrays must be the same length'
if interactive:
if p is None:
p = figure(title=f'{specimen_name} hysteresis loop',
x_axis_label='Field (T)',
y_axis_label='Magnetization (Am\u00B2/kg)',
width=600,
height=600, aspect_ratio=1)
p.axis.axis_label_text_font_size = '12pt'
p.axis.axis_label_text_font_style = 'normal'
p.title.text_font_size = '14pt'
p.title.text_font_style = 'bold'
p.title.align = 'center'
p.line(field, magnetization, line_width=line_width, color=line_color, legend_label=label)
p.legend.click_policy="hide"
p.legend.location = legend_location
else:
p.line(field, magnetization, line_width=line_width, color=line_color, legend_label=label)
p.legend.location = legend_location
if show_plot:
show(p)
if return_figure:
return p
return None
# static Matplotlib
fig, ax = plt.subplots(figsize=(6, 6))
ax.plot(field, magnetization, color=line_color, linewidth=line_width, label=label)
ax.set_title(f'{specimen_name} hysteresis loop')
ax.set_xlabel('Field (T)')
ax.set_ylabel('Magnetization (Am²/kg)')
ax.legend(loc=legend_location)
ax.grid(True)
if show_plot:
plt.show()
if return_figure:
return fig, ax
return None
[docs]
def collapse_hyst_field_plateaus(field, magnetization):
"""Average consecutive repeated field steps into a single point."""
field = np.asarray(field, dtype=float)
magnetization = np.asarray(magnetization, dtype=float)
if field.size == 0:
return field, magnetization
collapsed_field = []
collapsed_magnetization = []
start = 0
for index in range(1, len(field) + 1):
if index == len(field) or field[index] != field[start]:
collapsed_field.append(field[start])
collapsed_magnetization.append(np.mean(magnetization[start:index]))
start = index
return np.asarray(collapsed_field, dtype=float), np.asarray(collapsed_magnetization, dtype=float)
[docs]
def find_hyst_turning_point(field):
"""Find the single loop reversal, tolerating repeated plateaus and minor field glitches.
Returns the index of the last point of the first field sweep, such that
field[:index+1] is the first branch and field[index+1:] is the second
branch. Zero field steps (plateaus from repeated field values) are ignored
when detecting the reversal, and if field glitches produce multiple sign
changes, the reversal closest to the global field extremum opposite the
initial sweep direction is chosen.
"""
field = np.asarray(field, dtype=float)
if field.size < 3:
raise ValueError('At least three field steps are required to split a hysteresis loop')
field_diff = np.diff(field)
nonzero_indices = np.where(field_diff != 0)[0]
if nonzero_indices.size == 0:
raise ValueError('Applied field does not vary and cannot be split into loop branches')
diff_sign = np.sign(field_diff[nonzero_indices])
# sign changes between consecutive non-zero field steps mark reversals;
# the turning point is the last index of the sweep ending at the reversal
sign_changes = np.where(diff_sign[:-1] * diff_sign[1:] < 0)[0]
if sign_changes.size == 0:
raise ValueError('No turning point found in the applied field. The data may not be a hysteresis loop.')
candidates = nonzero_indices[sign_changes] + 1
if candidates.size == 1:
return int(candidates[0])
initial_direction = np.sign(np.median(diff_sign[:min(10, len(diff_sign))]))
if initial_direction == 0:
initial_direction = diff_sign[0]
extremum_index = int(np.argmin(field) if initial_direction < 0 else np.argmax(field))
return int(candidates[np.argmin(np.abs(candidates - extremum_index))])
[docs]
def build_symmetric_hyst_grid(upper_branch, lower_branch):
"""Build a symmetric field grid over the overlap shared by the two loop branches."""
upper_field = np.asarray(upper_branch[0], dtype=float)
lower_field = np.asarray(lower_branch[0], dtype=float)
branch_steps = np.concatenate([
np.abs(np.diff(upper_field)),
np.abs(np.diff(lower_field)),
])
branch_steps = branch_steps[branch_steps > 0]
if branch_steps.size == 0:
raise ValueError('Cannot determine a non-zero field step for hysteresis gridding')
field_step = np.median(branch_steps)
max_field = min(
np.max(upper_field),
np.max(lower_field),
np.abs(np.min(upper_field)),
np.abs(np.min(lower_field)),
)
if max_field <= 0:
raise ValueError('Hysteresis branches do not overlap symmetrically about zero field')
positive_field = np.arange(max_field, 0, -field_step, dtype=float)
if positive_field.size == 0:
positive_field = np.asarray([max_field], dtype=float)
upper_grid = np.concatenate([positive_field, -positive_field[::-1]])
lower_grid = upper_grid[::-1]
return upper_grid, lower_grid
[docs]
def split_hyst_loop(field, magnetization):
'''
function to split a hysteresis loop into upper and lower branches
at the reversal of the applied field sweep
The loop reversal is located with `find_hyst_turning_point`, which
tolerates repeated field plateaus and minor field glitches. Loops measured
in either sweep order are supported: the branch measured from the positive
field extreme downward is returned as the upper branch regardless of
whether it was measured first or second.
Parameters
----------
field : numpy array or list
hysteresis loop field values
magnetization : numpy array or list
hysteresis loop magnetization values
Returns
-------
upper_branch : list
[field, magnetization] for the upper branch, in ascending field order
lower_branch : list
[field, magnetization] for the lower branch, in ascending field order
'''
assert len(field) == len(magnetization), 'Field and magnetization arrays must be the same length'
field = np.asarray(field, dtype=float)
magnetization = np.asarray(magnetization, dtype=float)
turning_point = find_hyst_turning_point(field)
first_branch = [field[:turning_point+1], magnetization[:turning_point+1]]
second_branch = [field[turning_point+1:], magnetization[turning_point+1:]]
if field[0] > field[turning_point]:
# sweep starts at the positive extreme: the first segment is the
# descending upper branch; reverse it into ascending field order
upper_branch = [first_branch[0][::-1], first_branch[1][::-1]]
lower_branch = second_branch
else:
# sweep starts at the negative extreme: the first segment is the
# ascending lower branch and the second is the descending upper branch
lower_branch = first_branch
upper_branch = [second_branch[0][::-1], second_branch[1][::-1]]
return upper_branch, lower_branch
[docs]
def measured_descending_first(field):
"""Whether the first-measured branch sweeps downward from positive field.
Gridding canonicalizes a loop to descending-upper-branch-first array
order regardless of how it was measured, so the original sweep order must
be detected from the raw field values before gridding and passed to the
time-order-sensitive drift corrections.
Parameters
----------
field : array_like
Raw (ungridded) applied field values in measurement order.
Returns
-------
bool
True if the sweep starts at the positive field extreme (descending
branch measured first), False if it starts at the negative extreme.
"""
field = np.asarray(field, dtype=float)
turning_point = find_hyst_turning_point(field)
return bool(field[0] > field[turning_point])
def _correct_in_measurement_order(H, M, correction):
"""Apply a time-order-sensitive drift correction to a canonically ordered
loop that was measured ascending-first.
The corrections assume array order equals measurement-time order with the
descending upper branch first. For a loop measured ascending-first the
canonical (gridded) arrays violate that, so the loop is mapped onto its
equivalent descending-first loop in true time order -- a hysteresis loop
is odd under (H, M) -> (-H, -M), and the corrections estimate the drift
from the branch mismatch without assuming its sign -- corrected there,
and mapped back to canonical order.
"""
H = np.asarray(H, dtype=float)
M = np.asarray(M, dtype=float)
boundary = find_hyst_turning_point(H) + 1
H_equivalent = np.concatenate([-H[boundary:], -H[:boundary]])
M_equivalent = np.concatenate([-M[boundary:], -M[:boundary]])
M_cor_equivalent = np.asarray(correction(H_equivalent, M_equivalent))
n_second = len(H) - boundary
return np.concatenate([-M_cor_equivalent[n_second:],
-M_cor_equivalent[:n_second]])
[docs]
def grid_hyst_loop(field, magnetization):
'''
function to grid a hysteresis loop into a regular grid
with grid intervals equal to the average field step size calculated from the data
Parameters
----------
field : numpy array or list
hysteresis loop field values
magnetization : numpy array or list
hysteresis loop magnetization values
Returns
-------
grid_field : numpy array
gridded field values
grid_magnetization : numpy array
gridded magnetization values
'''
assert len(field) == len(magnetization), 'Field and magnetization arrays must be the same length'
field = np.asarray(field, dtype=float)
magnetization = np.asarray(magnetization, dtype=float)
if not (np.isfinite(field).all() and np.isfinite(magnetization).all()):
raise ValueError('Non-finite field or magnetization values present; '
'clean the inputs first (see sanitize_hyst_inputs)')
upper_branch, lower_branch = split_hyst_loop(field, magnetization)
# average any exactly repeated field steps within each branch (e.g.
# instrument plateaus at the loop tips) so duplicate fields do not enter
# the interpolation; small non-monotonic field glitches are not repaired
# here and np.interp will locally smooth over them
upper_branch = collapse_hyst_field_plateaus(upper_branch[0], upper_branch[1])
lower_branch = collapse_hyst_field_plateaus(lower_branch[0], lower_branch[1])
upper_field, lower_field = build_symmetric_hyst_grid(upper_branch, lower_branch)
grid_field = np.concatenate([upper_field, lower_field])
upper_branch_itp = np.interp(upper_field, upper_branch[0], upper_branch[1])
lower_branch_itp = np.interp(lower_field, lower_branch[0], lower_branch[1])
grid_magnetization = np.concatenate([upper_branch_itp, lower_branch_itp])
return grid_field, grid_magnetization
[docs]
def ANOVA(xs, ys):
'''
ANOVA statistics for linear regression
Parameters
----------
xs : numpy array
x values
ys : numpy array
y values
Returns
-------
results : dict
dictionary of the results of the ANOVA calculation
and intermediate statistics for the ANOVA calculation
'''
xs = np.array(xs)
ys = np.array(ys)
ys_mean = np.mean(ys)
# fit the gridded data by a straight line
slope, intercept = np.polyfit(xs, ys, 1)
# AVOVA calculation
# total sum of squares for the dependent variable (magnetization)
SST = np.sum((ys - ys_mean)**2)
# sum of squares due to regression
SSR = np.sum((slope * xs + intercept - ys_mean)**2)
# the remaining unexplained variation (noise and lack of fit)
SSD = np.sum((ys - (slope * xs + intercept)) ** 2)
R_squared = SSR/SST
results = {'slope':slope,
'intercept':intercept,
'SST':SST,
'SSR':SSR,
'SSD':SSD,
'R_squared': R_squared}
return results
[docs]
def hyst_linearity_test(grid_field, grid_magnetization):
'''
function for testing the linearity of a hysteresis loop
Parameters
----------
grid_field : numpy array
gridded field values
grid_magnetization : numpy array
gridded magnetization values
Returns
-------
results : dict
dictionary of the results of the linearity test
and intermediate statistics for the ANOVA calculation
'''
grid_field = np.array(grid_field)
grid_magnetization = np.array(grid_magnetization)
upper_branch, lower_branch = split_hyst_loop(grid_field, grid_magnetization)
anova_results = ANOVA(grid_field, grid_magnetization)
# fit the gridded data by a straight line
slope, intercept = anova_results['slope'], anova_results['intercept']
# AVOVA calculation
# total sum of squares for the dependent variable (magnetization)
SST = anova_results['SST']
# sum of squares due to regression
SSR = anova_results['SSR']
# the remaining unexplained variation (noise and lack of fit)
SSD = anova_results['SSD']
R_squared = anova_results['R_squared']
# invert the lower branch to match the upper branch
# and calculate the differences between the upper and the inverted lower branch
# for any loop shifts and drift that are due to noise alone
SSPE = np.sum((upper_branch[1] - (-lower_branch[1][::-1])) ** 2) / 2
# calculate the lack of fit statistic
SSLF = SSD - SSPE
# mean square pure error
MSPE = SSPE / (len(grid_field) / 2)
# mean square error due to lack of fit
MSLF = SSLF / (len(grid_field)/2 - 2)
# mean squares due to regression
MSR = SSR
# mean squares due to noise
MSD = SSD / (len(grid_field) - 2)
# F-ratio for the linear component
FL = MSR / MSD
# F-ratio for the non-linear component
FNL = MSLF / MSPE
results = {
'SST': float(SST),
'SSR': float(SSR),
'SSD': float(SSD),
'R_squared': float(R_squared),
'SSPE': float(SSPE),
'SSLF': float(SSLF),
'MSPE': float(MSPE),
'MSLF': float(MSLF),
'MSR': float(MSR),
'MSD': float(MSD),
'FL': float(FL),
'FNL': float(FNL),
'slope': float(slope),
'intercept': float(intercept),
'loop_is_linear': bool(FNL < 1.25),
}
return results
def _linefit(xarr, yarr):
"""
Perform a simple linear regression (least squares fit) on two arrays.
Parameters
----------
xarr : array_like
Array of x-values (independent variable).
yarr : array_like
Array of y-values (dependent variable), must be the same shape as `xarr`.
Returns
-------
intercept : float
The intercept of the best-fit line.
slope : float
The slope of the best-fit line.
r2 : float
The coefficient of determination (R²), a measure of how well the regression line fits the data.
R² = 1 indicates a perfect fit, lower values indicate a poorer fit.
Examples
--------
>>> x = [0, 1, 2, 3, 4]
>>> y = [1, 3, 5, 7, 9]
>>> intercept, slope, r2 = _linefit(x, y)
>>> print(f"Intercept: {intercept:.2f}, Slope: {slope:.2f}, R^2: {r2:.2f}")
Intercept: 1.00, Slope: 2.00, R^2: 1.00
"""
xarr = np.asarray(xarr)
yarr = np.asarray(yarr)
# Fit a line y = slope * x + intercept
slope, intercept = np.polyfit(xarr, yarr, 1)
# Predict y using the fitted line
y_pred = intercept + slope * xarr
# Total sum of squares
ss_tot = np.sum((yarr - np.mean(yarr))**2)
# Residual sum of squares
ss_res = np.sum((yarr - y_pred)**2)
# R^2 score
r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 1
return intercept, slope, r2
def _branch_symmetry_mismatch(loop_fields, loop_moments, H_shift=0.0, M_shift=None,
low_field_fraction=0.35, weight_power=4):
"""Measure branch inversion symmetry with optional low-field weighting."""
corrected_fields = np.asarray(loop_fields, dtype=float) - H_shift
corrected_moments = np.asarray(loop_moments, dtype=float)
upper_branch, lower_branch = split_hyst_loop(corrected_fields, corrected_moments)
upper_field = np.asarray(upper_branch[0], dtype=float)
upper_moment = np.asarray(upper_branch[1], dtype=float)
lower_field = np.asarray(lower_branch[0], dtype=float)
lower_moment = np.asarray(lower_branch[1], dtype=float)
mirror_field = -upper_field
valid = (mirror_field >= np.min(lower_field)) & (mirror_field <= np.max(lower_field))
if not np.any(valid):
raise ValueError('Loop branches do not overlap after applying the proposed field shift')
overlap_field = upper_field[valid]
overlap_upper_moment = upper_moment[valid]
overlap_lower_moment = np.interp(-overlap_field, lower_field, lower_moment)
max_field = np.max(np.abs(overlap_field))
field_scale = max(low_field_fraction * max_field, np.finfo(float).eps)
weights = 1.0 / (1.0 + (np.abs(overlap_field) / field_scale) ** weight_power)
offset_samples = (overlap_upper_moment + overlap_lower_moment) / 2.0
if M_shift is None:
M_shift = np.average(offset_samples, weights=weights)
mismatch = overlap_upper_moment + overlap_lower_moment - 2.0 * M_shift
weighted_rms = np.sqrt(np.average(mismatch ** 2, weights=weights))
return {
'field': overlap_field,
'mismatch': mismatch,
'weights': weights,
'weighted_rms': float(weighted_rms),
'M_shift': float(M_shift),
}
def _loop_H_off(loop_fields, loop_moments, H_shift):
"""
Estimate the vertical shift (M_shift) and symmetry (R²) of a magnetic hysteresis loop after applying a horizontal field shift.
This function shifts the field data by a specified amount, finds symmetrically equivalent points in the second half of the loop,
and performs a linear regression between the original and reflected/negated data. It then estimates the vertical offset (M_shift)
based on the intercept of the regression and returns additional regression results.
Parameters
----------
loop_fields : array_like
Array of magnetic field values for the hysteresis loop.
loop_moments : array_like
Array of corresponding magnetic moment values.
H_shift : float
Horizontal (field) shift to apply to the loop_fields before symmetry calculation.
Returns
-------
result : dict
Dictionary containing:
- 'slope': float, slope of the linear regression between the original and reflected moments.
- 'M_shift': float, estimated vertical shift (half the regression intercept).
- 'r2': float, coefficient of determination (R²) for the regression, indicating symmetry.
Notes
-----
- The function is typically used to estimate vertical offsets and assess symmetry in magnetic hysteresis loops.
- Returns zeros if not enough symmetrical points are found for regression.
Examples
--------
>>> res = _loop_H_off(fields, moments, H_shift=10)
>>> print(res['M_shift'], res['r2'])
"""
n = len(loop_fields)
# Apply horizontal shift
loop_fields = loop_fields - H_shift
# Define bounds for symmetrical comparison
min2 = np.min(loop_fields)
max2 = np.max(loop_fields)
min1 = -max2
max1 = -min2
n1 = n // 2
i2 = 0 # Python uses 0-based indexing
x1 = []
y1 = []
for i in range(n1, n):
x = loop_fields[i]
if min1 < x < max1:
while -loop_fields[i2] < x and i2 < n - 1:
i2 += 1
if i2 > 0:
dx = (-loop_fields[i2] - x) / (-loop_fields[i2] + loop_fields[i2 - 1])
dy = dx * (-loop_moments[i2] + loop_moments[i2 - 1])
y = -loop_moments[i2] - dy
x1.append(loop_moments[i])
y1.append(-y)
if len(x1) < 2:
return {'slope': 0.0, 'M_shift': 0.0, 'r2': 0.0}
intercept, slope, r2 = _linefit(x1, y1)
M_shift = intercept / 2
result = {'slope': slope, 'M_shift': M_shift, 'r2': r2}
return result
def _loop_Hshift_brent(loop_fields, loop_moments, shift_bound_fraction=0.1):
"""
Optimize the horizontal (field) shift of a magnetic hysteresis loop using Brent's method to maximize symmetry.
This function determines the optimal horizontal field shift (H_shift) to apply to a hysteresis loop,
such that the R² value (symmetry) of the loop, as calculated by `_loop_H_off`, is maximized.
It uses the Brent optimization algorithm to efficiently search for the H_shift that gives the highest R².
The function returns the optimal R², the corresponding field shift, and the vertical offset (M_shift) at this position.
Parameters
----------
loop_fields : array_like
Array of magnetic field values for the hysteresis loop.
loop_moments : array_like
Array of corresponding magnetic moment values.
Returns
-------
opt_r2 : float
The maximum R² value achieved by shifting the loop.
opt_H_off : float
The optimal horizontal (field) shift applied to maximize symmetry.
opt_M_off : float
The estimated vertical shift (M_shift) at the optimal field shift.
Notes
-----
- Uses Brent's method for optimization via `scipy.optimize.minimize_scalar` with a bracket based on the loop field range.
- Calls `_loop_H_off` to compute symmetry and vertical shift for each candidate field shift.
- Useful for correcting field and moment offsets in hysteresis loop analysis.
Examples
--------
>>> r2, H_off, M_off = _loop_Hshift_brent(fields, moments)
>>> print(f"Optimal field shift: {H_off:.2f}, R²: {r2:.3f}, M_shift: {M_off:.3e}")
"""
def objective(H_shift):
return -_loop_H_off(loop_fields, loop_moments, H_shift)['r2']
shift_bound = shift_bound_fraction * np.max(np.abs(loop_fields))
if shift_bound == 0:
return 0.0, 0.0, 0.0
coarse_grid = np.linspace(-shift_bound, shift_bound, 41)
coarse_scores = np.asarray([_loop_H_off(loop_fields, loop_moments, shift)['r2'] for shift in coarse_grid])
best_index = int(np.argmax(coarse_scores))
left_index = max(best_index - 1, 0)
right_index = min(best_index + 1, len(coarse_grid) - 1)
if left_index == right_index:
opt_H_off = float(coarse_grid[best_index])
else:
result = minimize_scalar(
objective,
bounds=(float(coarse_grid[left_index]), float(coarse_grid[right_index])),
method='bounded',
options={'xatol': 1e-6},
)
opt_H_off = float(result.x)
opt_shift = _loop_H_off(loop_fields, loop_moments, opt_H_off)
opt_r2 = opt_shift['r2']
opt_M_off = opt_shift['M_shift']
return opt_r2, opt_H_off, opt_M_off
def _loop_Hshift_weighted(loop_fields, loop_moments, low_field_fraction=0.35,
shift_bound_fraction=0.1, weight_power=4):
"""Optimize loop centering using a low-field-weighted inversion-symmetry mismatch."""
loop_fields = np.asarray(loop_fields, dtype=float)
loop_moments = np.asarray(loop_moments, dtype=float)
shift_bound = shift_bound_fraction * np.max(np.abs(loop_fields))
if shift_bound == 0:
mismatch = _branch_symmetry_mismatch(
loop_fields,
loop_moments,
low_field_fraction=low_field_fraction,
weight_power=weight_power,
)
return mismatch['weighted_rms'], 0.0, mismatch['M_shift'], mismatch
def objective(H_shift):
return _branch_symmetry_mismatch(
loop_fields,
loop_moments,
H_shift=H_shift,
low_field_fraction=low_field_fraction,
weight_power=weight_power,
)['weighted_rms']
result = minimize_scalar(
objective,
bounds=(-shift_bound, shift_bound),
method='bounded',
options={'xatol': 1e-6},
)
opt_H_off = float(result.x)
mismatch = _branch_symmetry_mismatch(
loop_fields,
loop_moments,
H_shift=opt_H_off,
low_field_fraction=low_field_fraction,
weight_power=weight_power,
)
return mismatch['weighted_rms'], opt_H_off, mismatch['M_shift'], mismatch
[docs]
def calc_Q(H, M, type='Q'):
"""
Calculate the quality factor (Q) for a magnetic hysteresis loop.
The Q factor is a logarithmic measure (base 10) of the signal-to-noise ratio for a
hysteresis loop, following Jackson and Solheid (2010): the upper and inverted lower
branches are treated as replicate measurements, so their mean squared moment relative
to the mean squared mismatch between them (the err(H) curve) quantifies signal/noise.
Q = log10(s/n); loops with Q >= 2 have small deviations from inversion symmetry while
loops with Q below ~0.3 (s/n ~ 2) are too noisy for meaningful parameter estimation.
The quality factor of the ferromagnetic component (Q_f of Jackson and Solheid, 2010)
is obtained by calling this function on the slope-corrected loop.
The calculation can be performed in two modes:
- 'Q': Uses the mean squared magnetization of both the upper and lower branches.
- 'Qf': Uses only the upper branch.
Parameters
----------
H : array_like
Array of applied magnetic field values.
M : array_like
Array of measured magnetization (moment) values, corresponding to `H`.
type : {'Q', 'Qf'}, optional
Type of Q calculation to perform:
- 'Q' (default): Uses both upper and lower branches of the loop.
- 'Qf': Uses only the upper branch.
Returns
-------
M_sn : float
The calculated signal-to-noise ratio (before applying the logarithm).
Q : float
The quality factor, defined as log10(M_sn).
Notes
-----
- The function splits the hysteresis loop into upper and lower branches using `split_hyst_loop`.
- For type 'Q', the numerator is the average of the sum of squares of the upper and lower branches; for 'Qf', only the upper branch is used.
- The denominator is the sum of squares of err(H), the mismatch between the upper branch
and the inverted lower branch, so M_sn is equivalent to the 1/(1 - R^2) signal/noise
measure of Jackson and Solheid (2010, equation 3).
- Higher Q values indicate a higher signal-to-noise ratio in the hysteresis loop data.
Examples
--------
>>> H = np.linspace(-1, 1, 200)
>>> M = np.tanh(3 * H) + 0.05 * np.random.randn(200)
>>> M_sn, Q = calc_Q(H, M, type='Q')
>>> print(f"Signal-to-noise ratio: {M_sn:.3f}, Q: {Q:.2f}")
"""
assert type in ['Q', 'Qf'], 'type must be either Q or Qf'
H = np.array(H)
M = np.array(M)
upper_branch, lower_branch = split_hyst_loop(H, M)
Me = upper_branch[1] + lower_branch[1][::-1]
# the square root follows the convention of the IRM software and HystLab
# (Paterson et al., 2018, equation 4): Q = log10(1/sqrt(1 - R^2)); the
# equation as printed in Jackson and Solheid (2010) omits the square root
# but their reported values include it (see Paterson et al., 2018)
if type == 'Q':
M_sn = np.sqrt((np.sum(upper_branch[1]**2) + np.sum(lower_branch[1][::-1]**2))/2/np.sum(Me**2))
elif type == 'Qf':
M_sn = np.sqrt(np.sum(upper_branch[1]**2)/np.sum(Me**2))
Q = np.log10(M_sn)
return M_sn, Q
[docs]
def hyst_loop_centering(grid_field, grid_magnetization):
'''
function for finding the optimum applied field offset value for minimizing a linear fit through
the Me based on the R2 value. The idea is maximizing the residual noise in the Me gives the best centered loop.
Parameters
----------
grid_field : numpy array
gridded field values
grid_magnetization : numpy array
gridded magnetization values
Returns
-------
opt_H_offset : float
optimized applied field offset value for the loop
opt_M_offset : float
calculated magnetization offset value for the loop based on the optimized applied field offset
(intercept of the fitted line using the upper branch and the inverted and optimally offsetted lower branch)
R_squared : float
R-squared value of the linear fit between the upper branch and the inverted and offsetted lower branch
'''
grid_field = np.array(grid_field)
grid_magnetization = np.array(grid_magnetization)
R_squared, H_offset, M_offset = _loop_Hshift_brent(grid_field, grid_magnetization)
# re-gridding after offset correction to ensure symmetry
centered_H, centered_M = grid_hyst_loop(grid_field-H_offset, grid_magnetization-M_offset)
# quality factor from the offset-corrected loop (Jackson and Solheid, 2010,
# section 3: Q reflects noise and drift after the effects of loop offsets
# are removed; HystLab likewise computes Q on the offset-corrected curves).
# Computing Q on the uncorrected loop would let a loop offset depress Q
# below the decision-tree gate that decides whether to apply the offset
# correction itself.
M_sn, Q = calc_Q(centered_H, centered_M)
results = {'centered_H':centered_H,
'centered_M': centered_M,
'opt_H_offset':float(H_offset),
'opt_M_offset':float(M_offset),
'R_squared':float(R_squared),
'M_sn':float(M_sn),
'Q':float(Q),
}
return results
[docs]
def hyst_loop_centering_iterative(grid_field, grid_magnetization, hf_cutoff=0.8,
low_field_fraction=0.35, shift_bound_fraction=0.1,
weight_power=4, max_iterations=5,
field_tolerance=1e-5, moment_tolerance=1e-8):
"""
Center a hysteresis loop by iterating between provisional slope removal and offset fitting.
The routine alternates between fitting a provisional high-field slope and optimizing horizontal
and vertical offsets on the residual loop using a low-field-weighted inversion-symmetry metric.
This is designed for weak ferromagnetic loops superimposed on a strong linear background.
"""
centered_H, centered_M = grid_hyst_loop(grid_field, grid_magnetization)
total_H_offset = 0.0
total_M_offset = 0.0
iteration_history = []
provisional_slope = 0.0
symmetry_score = np.nan
for iteration in range(max_iterations):
try:
provisional_slope, _ = linear_HF_fit(centered_H, centered_M, HF_cutoff=hf_cutoff)
except Exception:
provisional_slope = 0.0
ferro_like_M = hyst_slope_correction(centered_H, centered_M, provisional_slope)
symmetry_score, delta_H, delta_M, mismatch = _loop_Hshift_weighted(
centered_H,
ferro_like_M,
low_field_fraction=low_field_fraction,
shift_bound_fraction=shift_bound_fraction,
weight_power=weight_power,
)
centered_H, centered_M = grid_hyst_loop(centered_H - delta_H, centered_M - delta_M)
total_H_offset += delta_H
total_M_offset += delta_M
iteration_history.append({
'iteration': iteration + 1,
'provisional_slope': float(provisional_slope),
'delta_H_offset': float(delta_H),
'delta_M_offset': float(delta_M),
'symmetry_score': float(symmetry_score),
'low_field_fraction': float(low_field_fraction),
'matched_points': int(len(mismatch['field'])),
})
if abs(delta_H) <= field_tolerance and abs(delta_M) <= moment_tolerance:
break
M_sn, Q = calc_Q(centered_H, centered_M)
results = {
'centered_H': centered_H,
'centered_M': centered_M,
'opt_H_offset': float(total_H_offset),
'opt_M_offset': float(total_M_offset),
'M_sn': float(M_sn),
'Q': float(Q),
'provisional_slope': float(provisional_slope),
'symmetry_score': float(symmetry_score),
'iterations': iteration_history,
'method': 'iterative_low_field_weighted',
}
return results
[docs]
def linear_HF_fit(field, magnetization, HF_cutoff=0.8):
'''
function to fit a linear function to the high field portion of a hysteresis loop
Parameters
----------
field : numpy array or list
raw hysteresis loop field values
magnetization : numpy array or list
raw hysteresis loop magnetization values
Returns
-------
chi_HF : float
high-field susceptibility of the paramagnetic/diamagnetic contribution
in SI units (the raw fitted slope in field units of Tesla multiplied
by mu_0 = 4*pi*1e-7); `hyst_slope_correction` performs the inverse
conversion when removing this contribution from a loop
intercept : float
y-intercept of the linear fit
can be interpreted to be the saturation magnetization of the ferromagnetic component
'''
assert len(field) == len(magnetization), 'Field and magnetization arrays must be the same length'
assert HF_cutoff > 0 and HF_cutoff < 1, 'Portion must be between 0 and 1'
# adopting IRM's max field cutoff at 97% of the max field
max_field_cutoff = 0.97
field = np.array(field)
magnetization = np.array(magnetization)
# filter for the high field portion of each branch
high_field_index = np.where((np.abs(field) >= HF_cutoff*np.max(np.abs(field))) & (np.abs(field) <= max_field_cutoff*np.max(np.abs(field))))[0]
# invert points in the negative high fields
high_field = np.abs(field[high_field_index])
high_field_magnetization = np.where(field[high_field_index] >= 0, magnetization[high_field_index], -magnetization[high_field_index])
# the slope would be the paramagnetic/diamagnetic susceptibility
# the y-intercept would be the Ms value (saturation magnetization of the ferromagnetic component)
slope, intercept = np.polyfit(high_field, high_field_magnetization, 1)
chi_HF = slope * (4*np.pi/1e7)
return chi_HF, intercept
[docs]
def hyst_slope_correction(grid_field, grid_magnetization, chi_HF):
'''
function for subtracting the paramagnetic/diamagnetic slope from a hysteresis loop
the input should be gridded field and magnetization values
Parameters
----------
grid_field : numpy array
gridded field values
grid_magnetization : numpy array
gridded magnetization values
chi_HF : float
X_HF
Returns
-------
grid_magnetization_ferro: numpy array
corrected ferromagnetic component of the magnetization
'''
slope = chi_HF / (4*np.pi/1e7)
assert len(grid_field) == len(grid_magnetization), 'Field and magnetization arrays must be the same length'
grid_field = np.array(grid_field)
grid_magnetization = np.array(grid_magnetization)
grid_magnetization_ferro = grid_magnetization - slope*grid_field
return grid_magnetization_ferro
def _find_y_crossing(x, y, y_target=0.0):
"""
Finds the x-value where y crosses a given y_target, taking the first
crossing encountered in array order. Uses linear interpolation between
adjacent points that bracket y_target.
Parameters:
x (array-like): x-values
y (array-like): y-values
y_target (float): y-value at which to find crossing (default: 0)
Returns:
x_cross (float or None): interpolated x at y = y_target for the first
crossing in array order, or None if not found
"""
x = np.asarray(x)
y = np.asarray(y)
for i in range(len(x) - 1):
y0, y1 = y[i], y[i + 1]
if (y0 - y_target) * (y1 - y_target) < 0: # sign change => crossing
x0, x1 = x[i], x[i + 1]
# Linear interpolation to find x at y = y_target
x_cross = x0 + (y_target - y0) * (x1 - x0) / (y1 - y0)
return x_cross
return None
[docs]
def calc_Mr_Mrh_Mih_Brh(grid_field, grid_magnetization):
'''
function to calculate the Mrh and Mih values from a hysteresis loop
Parameters
----------
grid_field : numpy array
gridded field values
grid_magnetization : numpy array
gridded magnetization values
Returns
-------
H : numpy array
field values of the upper branch (the two branches should have the same field values)
Mr : float
remanent magnetization (Mrh interpolated at zero field)
Mrh : numpy array
remanent hysteretic magnetization, (upper - lower)/2
Mih : numpy array
induced hysteretic magnetization, (upper + lower)/2
Me : numpy array
error curve err(H), the mismatch between the upper branch and the inverted lower branch
Brh : float
median field of Mrh (field at which Mrh falls to half of Mr)
'''
# calculate Mrh by subtracting the upper and lower branches of a hysteresis loop
grid_field = np.array(grid_field)
grid_magnetization = np.array(grid_magnetization)
upper_branch, lower_branch = split_hyst_loop(grid_field, grid_magnetization)
Mrh = (upper_branch[1] - lower_branch[1])/2
Mih = (upper_branch[1] + lower_branch[1])/2
Me = upper_branch[1] + lower_branch[1][::-1]
H = upper_branch[0]
Mr = np.interp(0, H, Mrh)
# Brh is the field corresponding to the m=Mr/2
pos_H = H[np.where(H > 0)]
pos_Mrh = Mrh[np.where(H > 0)]
neg_H = H[np.where(H < 0)]
neg_Mrh = Mrh[np.where(H < 0)]
Brh_pos = _find_y_crossing(pos_H, pos_Mrh, Mr/2)
Brh_neg = _find_y_crossing(neg_H, neg_Mrh, Mr/2)
# Mrh may never fall to Mr/2 within the measured field range (e.g. a loop
# dominated by an unsaturated high-coercivity phase such as hematite);
# report Brh as NaN rather than failing, so such loops still process and
# the closure test downstream can flag them as open
if Brh_pos is None and Brh_neg is None:
warnings.warn(
'Mrh does not fall to Mr/2 within the measured field range, so '
'the median remanent field Brh cannot be determined (NaN); the '
'loop likely contains an unsaturated high-coercivity component',
RuntimeWarning, stacklevel=2)
Brh = np.nan
elif Brh_pos is None or Brh_neg is None:
found = Brh_pos if Brh_pos is not None else Brh_neg
warnings.warn(
'the Mr/2 crossing of Mrh was found for only one field polarity; '
'Brh is taken from that crossing alone',
RuntimeWarning, stacklevel=2)
Brh = float(np.abs(found))
else:
Brh = np.abs((Brh_pos - Brh_neg)/2)
return H, Mr, Mrh, Mih, Me, Brh
[docs]
def calc_Bc(H, M):
'''
function for calculating the coercivity of the ferromagnetic component of a hysteresis loop
the final Bc value is calculated as the average of the positive and negative Bc values
Parameters
----------
H : numpy array
field values
M : numpy array
magnetization values
Returns
-------
Bc : float
coercivity of the ferromagnetic component of the hysteresis loop
'''
upper_branch, lower_branch = split_hyst_loop(H, M)
upper_Bc = _find_y_crossing(upper_branch[0], upper_branch[1])
lower_Bc = _find_y_crossing(lower_branch[0], lower_branch[1])
# a branch that never crosses zero within the measured field range (a
# loop measured far below the coercivity of a hard component) has no
# defined Bc; report NaN rather than failing
if upper_Bc is None and lower_Bc is None:
warnings.warn(
'neither loop branch crosses zero magnetization within the '
'measured field range, so Bc cannot be determined (NaN)',
RuntimeWarning, stacklevel=2)
return np.nan
if upper_Bc is None or lower_Bc is None:
found = upper_Bc if upper_Bc is not None else lower_Bc
warnings.warn(
'only one loop branch crosses zero magnetization; Bc is taken '
'from that crossing alone',
RuntimeWarning, stacklevel=2)
return float(np.abs(found))
Bc = np.abs((upper_Bc - lower_Bc) / 2)
return Bc
[docs]
def loop_saturation_stats(field, magnetization, HF_cutoff=0.8, max_field_cutoff=0.97):
'''
ANOVA statistics for the high field portion of a hysteresis loop
Parameters
----------
field : numpy array
field values
magnetization : numpy array
magnetization values
HF_cutoff : float
high field cutoff value
default is 0.8
Returns
-------
results : dict
dictionary of the results of the ANOVA calculation
and intermediate statistics for the ANOVA calculation
'''
field = np.array(field)
magnetization = np.array(magnetization)
upper_branch, lower_branch = split_hyst_loop(field, magnetization)
# filter for the high field portion of each branch
pos_high_field_index = np.where((field >= HF_cutoff*np.max(np.abs(field))) & (field <= max_field_cutoff*np.max(np.abs(field))))[0]
neg_high_field_index = np.where((field <= -HF_cutoff*np.max(np.abs(field))) & (field >= -max_field_cutoff*np.max(np.abs(field))))[0]
# invert points in the third quadrant to the first
pos_high_field = field[pos_high_field_index]
pos_high_field_magnetization = magnetization[pos_high_field_index]
neg_high_field = field[neg_high_field_index]
neg_high_field_magnetization = magnetization[neg_high_field_index]
neg_high_field = -np.array(neg_high_field)
neg_high_field_magnetization = -np.array(neg_high_field_magnetization)
high_field = np.concatenate([pos_high_field, neg_high_field])
high_field_magnetization = np.concatenate([pos_high_field_magnetization, neg_high_field_magnetization])
anova_results = ANOVA(high_field, high_field_magnetization)
SST = anova_results['SST']
SSR = anova_results['SSR']
SSD = anova_results['SSD']
R_squared = anova_results['R_squared']
# pure error from the mismatch between symmetrically equivalent points,
# restricted to the same high-field window used for the lack-of-fit SSD
# (err(H) indexed by the upper branch field covers each pair once)
err_field = np.asarray(upper_branch[0], dtype=float)
err = np.asarray(upper_branch[1], dtype=float) + np.asarray(lower_branch[1], dtype=float)[::-1]
max_abs_field = np.max(np.abs(field))
hf_pairs = (np.abs(err_field) >= HF_cutoff * max_abs_field) & \
(np.abs(err_field) <= max_field_cutoff * max_abs_field)
SSPE = np.sum(err[hf_pairs] ** 2) / 2
SSLF = SSD - SSPE
n_pairs = int(np.sum(hf_pairs))
if n_pairs <= 2:
raise ValueError('Too few high-field points for the lack-of-fit test; '
'lower HF_cutoff or measure with finer field steps')
MSR = SSR
MSD = SSD / (len(high_field) - 2)
MSPE = SSPE / n_pairs
MSLF = SSLF / (n_pairs - 2)
FL = MSR / MSD
FNL = MSLF / MSPE
results = {'SST':SST,
'SSR':SSR,
'SSD':SSD,
'R_squared': R_squared,
'SSPE':SSPE,
'SSLF':SSLF,
'MSPE':MSPE,
'MSR':MSR,
'MSD':MSD,
'FL':FL,
'FNL':FNL}
return results
[docs]
def hyst_loop_saturation_test(grid_field, grid_magnetization, max_field_cutoff=0.97):
"""
Assess the saturation state of a magnetic hysteresis loop based on linearity at high-field segments.
This function evaluates the degree of saturation in a hysteresis loop by calculating the F statistic
for nonlinearity (FNL, the lack-of-fit F ratio of Jackson and Solheid, 2010) over high-field windows
starting at 60%, 70%, and 80% of the maximum field (up to a specified cutoff). A significant FNL
(above the 2.5 threshold) indicates reproducible curvature in that window, i.e. the ferromagnetic
moment has not saturated and a linear high-field fit is inappropriate there.
Parameters
----------
grid_field : array_like
Array of applied magnetic field values for the hysteresis loop.
grid_magnetization : array_like
Array of magnetization (moment) values corresponding to `grid_field`.
max_field_cutoff : float, optional
Fraction of the maximum field to use as an upper cutoff for the analysis (default is 0.97).
Returns
-------
results_dict : dict
Dictionary containing:
- 'FNL60': float, FNL for the window from 60% of the maximum field.
- 'FNL70': float, FNL for the window from 70% of the maximum field.
- 'FNL80': float, FNL for the window from 80% of the maximum field.
- 'saturation_cutoff': float, lowest field fraction (0.6, 0.7, or 0.8) at which the
high-field segment is statistically linear (saturated); 0.92 (the IRM default for
a nonlinear fit window) if no tested window is linear.
- 'loop_is_saturated': bool, True if the loop is saturated (linear) in at least one
tested high-field window; False if all windows show significant nonlinearity,
in which case an approach-to-saturation fit should be used.
Notes
-----
- The function uses `loop_saturation_stats` to compute FNL values for each field fraction.
- FNL values below 2.5 indicate statistically linear (saturated) high-field behavior;
values above 2.5 indicate significant nonlinearity (nonsaturation).
- The result is converted to standard Python types using `_to_native_python`.
Examples
--------
>>> results = hyst_loop_saturation_test(fields, magnetizations)
>>> print(results['saturation_cutoff'], results['loop_is_saturated'])
0.8 False
"""
# a window with too few high-field pairs for the lack-of-fit test (sparse
# quick-scan loops) reports FNL as NaN with a warning rather than aborting
# the whole processing pipeline
FNL_by_cutoff = {}
for HF_cutoff in (0.6, 0.7, 0.8):
try:
FNL_by_cutoff[HF_cutoff] = loop_saturation_stats(
grid_field, grid_magnetization, HF_cutoff=HF_cutoff,
max_field_cutoff=max_field_cutoff)['FNL']
except ValueError as error:
warnings.warn(
f'saturation test window starting at {HF_cutoff:.0%} of the '
f'maximum field skipped ({error}); its FNL is NaN',
RuntimeWarning, stacklevel=2)
FNL_by_cutoff[HF_cutoff] = np.nan
FNL60, FNL70, FNL80 = (FNL_by_cutoff[c] for c in (0.6, 0.7, 0.8))
# lowest tested window with statistically linear (FNL < 2.5) high-field
# behavior; 0.92 (the IRM default nonlinear-fit window) if none is linear
# or no window could be tested
saturation_cutoff = 0.92
for HF_cutoff in (0.8, 0.7, 0.6):
FNL = FNL_by_cutoff[HF_cutoff]
if np.isfinite(FNL) and FNL < 2.5:
saturation_cutoff = HF_cutoff
results = {'FNL60':FNL60, 'FNL70':FNL70, 'FNL80':FNL80, 'saturation_cutoff':saturation_cutoff, 'loop_is_saturated':(saturation_cutoff != 0.92)}
results_dict = _to_native_python(results)
return results_dict
[docs]
def loop_closure_test(H, Mrh, HF_cutoff=0.8, *, Me=None, max_field_cutoff=0.99):
'''
function for testing whether a hysteresis loop is closed at high fields
Mrh should be an even function of field for a well-behaved loop
(Mrh(-H) = Mrh(H)), so its field-reflection average (even part, with
unphysical negative values set to zero) is taken as the signal. A loop
that remains open at high fields (e.g. due to unsaturated high-coercivity
phases such as hematite or goethite) retains a significant Mrh signal in
the high-field window, giving a high signal-to-noise ratio (SNR) and a
high ratio of high-field Mrh area to total Mrh area (HAR). The noise is
estimated from the high-field portion of the err(H) curve when `Me` is
provided (matching the HystLab implementation of this test; Paterson et
al., 2018, section 4.5), or from the odd part of Mrh otherwise. Fields
above max_field_cutoff (default 99%) of the maximum field are excluded
from the high-field windows to avoid extreme-tip artifacts.
Parameters
----------
H: array-like
field values of the upper branch (ascending)
Mrh: array-like
remanent hysteretic magnetization Mrh(H)
HF_cutoff: float
high field cutoff value taken as fraction of the max field value
Me: array-like, optional, keyword-only
error curve err(H) on the same field axis (as returned by
calc_Mr_Mrh_Mih_Brh); used as the noise estimate when provided
max_field_cutoff: float, keyword-only
upper trim of the high-field windows as fraction of the max field
Returns
-------
results : dict
Dictionary containing:
- 'SNR': float, high-field signal-to-noise ratio in dB
- 'HAR': float, high-field to total Mrh area ratio in dB
- 'loop_is_closed': bool, True if SNR < 8 dB or HAR < -48 dB
'''
assert len(H) == len(Mrh), 'H, Mrh must have the same length'
H = np.asarray(H, dtype=float)
Mrh = np.asarray(Mrh, dtype=float)
max_H = np.max(np.abs(H))
pos_H_index = np.where(H > 0)
neg_H_index = np.where(H < 0)
pos_H = H[pos_H_index]
pos_Mrh = Mrh[pos_H_index]
neg_Mrh = Mrh[neg_H_index]
pos_HF_index = np.where((H > HF_cutoff*max_H) & (H <= max_field_cutoff*max_H))
neg_HF_index = np.where((H < -HF_cutoff*max_H) & (H >= -max_field_cutoff*max_H))
pos_HF = H[pos_HF_index]
pos_HF_Mrh = Mrh[pos_HF_index]
neg_HF_Mrh = Mrh[neg_HF_index]
# field-reflection average of Mrh (signal); negative values are noise
# excursions and are set to 0 so that only positive signal is counted
average_Mrh = (pos_Mrh + neg_Mrh[::-1])/2
average_Mrh[average_Mrh < 0] = 0
average_HF_Mrh = (pos_HF_Mrh + neg_HF_Mrh[::-1])/2
average_HF_Mrh[average_HF_Mrh < 0] = 0
if Me is not None:
# noise from the high-field portion of the err(H) curve, over both
# field polarities (the HystLab convention)
Me = np.asarray(Me, dtype=float)
assert len(Me) == len(H), 'H, Me must have the same length'
hf_noise = Me[(np.abs(H) > HF_cutoff*max_H) & (np.abs(H) <= max_field_cutoff*max_H)]
else:
# fall back to the odd part of Mrh (the residual between the field
# polarities); for white noise this runs ~3 dB below the err(H)-based
# estimate, biasing slightly toward classifying loops as open
hf_noise = pos_HF_Mrh - neg_HF_Mrh[::-1]
HF_Mrh_signal_RMS = np.sqrt(np.mean(average_HF_Mrh**2))
HF_Mrh_noise_RMS = np.sqrt(np.mean(hf_noise**2))
SNR = 20*np.log10(HF_Mrh_signal_RMS/HF_Mrh_noise_RMS)
total_Mrh_area = np.trapezoid(average_Mrh, pos_H)
HF_Mrh_area = np.trapezoid(average_HF_Mrh, pos_HF)
HAR = 20*np.log10(HF_Mrh_area/total_Mrh_area)
loop_is_closed = (SNR < 8) or (HAR < -48)
results = {'SNR':float(SNR),
'HAR':float(HAR),
'loop_is_closed':bool(loop_is_closed),
}
return results
[docs]
def Me_drift_correction(H, M, descending_first=True):
"""
Perform default IRM drift correction for a hysteresis loop based on the Me method.
This function applies a drift correction algorithm to magnetization data (M) measured as a function of applied field (H),
commonly used for IRM (Isothermal Remanent Magnetization) experiments. The correction is based on the Me signal,
which is the sum of the upper and reversed lower branches of the hysteresis loop.
The correction method adapts depending on whether significant drift is detected in the high-field region.
The drift estimate depends on measurement-time order, and the arrays are
expected in canonical order (descending upper branch first, as produced
by `grid_hyst_loop`). For a loop originally measured from negative
saturation, pass descending_first=False (detected from the raw field
values with `measured_descending_first`) so the correction is applied in
true time order rather than with the opposite time sense.
Parameters
----------
H : numpy.ndarray
Array of magnetic field values, in canonical (descending-upper-
branch-first) order.
M : numpy.ndarray
Array of measured magnetization values corresponding to `H`.
descending_first : bool, optional
Whether the loop was originally measured with the descending branch
first (default True). Use `measured_descending_first` on the raw
field values to determine this for a gridded loop.
Returns
-------
M_cor : numpy.ndarray
Corrected magnetization values after drift correction.
Examples
--------
>>> H = np.linspace(-1, 1, 200)
>>> M = measure_hysteresis(H)
>>> M_cor = Me_drift_correction(H, M)
>>> plot(H, M, label='Original')
>>> plot(H, M_cor, label='Drift Corrected')
"""
if not descending_first:
return _correct_in_measurement_order(H, M, Me_drift_correction)
# split loop branches
upper_branch, lower_branch = split_hyst_loop(H, M)
# calculate Me
Me = upper_branch[1][::-1] + lower_branch[1]
loop_size = len(H) -1
half_loop_size = loop_size // 2
quarter_loop_size = loop_size // 4
# calculate the smoothed Me using Savitzky-Golay filter
# which allows inplementation of a polynomial fit to the data within each window
smoothed_Me = savgol_filter(Me, window_length=11, polyorder=2, mode='interp')
# determine whether the main drift field region
main_drift_region = H[np.argmax(np.abs(smoothed_Me[:half_loop_size]))]
M_cor = copy.deepcopy(M)
positive_field_cor = abs(main_drift_region) > np.max(H) * 0.75
if positive_field_cor:
# if the ratio of drift in the high-field range (≥75% of the peak field) to the low-field range.
# is high, then the positive field correction is applied
for i in range(0, quarter_loop_size):
M_cor[i] -= smoothed_Me[i]
M_cor[loop_size - i] -= smoothed_Me[half_loop_size - i]
return M_cor
else:
# if positive field correctionis not preferred, we do upper branch drift correction
window_size = 7
# calculate running mean of the upper branch with a window size of 2k+1
kernel = np.ones(window_size) / window_size
Me_running_mean = np.convolve(Me, kernel, mode='same')
for i in range(len(Me_running_mean)):
M_cor[i] = M[i] - Me_running_mean[i]
return M_cor
[docs]
def prorated_drift_correction(field, magnetization, descending_first=True):
'''
function to correct for the linear drift of a hysteresis loop
take the difference between the magnetization measured at the maximum field on the upper and lower branches
apply linearly prorated correction of M(H)
this should be applied to the gridded data
The prorated ramp runs in measurement-time order, and the arrays are
expected in canonical order (descending upper branch first, as produced
by `grid_hyst_loop`). For a loop originally measured from negative
saturation, pass descending_first=False (detected from the raw field
values with `measured_descending_first`) so the ramp is applied in true
time order rather than with the opposite time sense.
Parameters
----------
field : numpy array
field values, in canonical (descending-upper-branch-first) order
magnetization : numpy array
magnetization values
descending_first : bool, optional
Whether the loop was originally measured with the descending branch
first (default True). Use `measured_descending_first` on the raw
field values to determine this for a gridded loop.
Returns
-------
corrected_magnetization : numpy array
corrected magnetization values
'''
if not descending_first:
return _correct_in_measurement_order(field, magnetization,
prorated_drift_correction)
field = np.array(field)
magnetization = np.array(magnetization)
upper_branch, lower_branch = split_hyst_loop(field, magnetization)
# find the maximum field values for the upper and lower branches
upper_branch_max_idx = np.argmax(upper_branch[0])
lower_branch_max_idx = np.argmax(lower_branch[0])
# find the difference between the magnetization values at the maximum field values
M_ce = upper_branch[1][upper_branch_max_idx] - lower_branch[1][lower_branch_max_idx]
# apply linearly prorated correction of M(H)
# delta_M_i = M_ce * (i/(N-1) - 1/2) with 0-based i, which averages to zero
# over the loop and removes the closure error M_ce between first and last points
corrected_magnetization = [M_ce * (i/(len(field)-1) - 1/2) + magnetization[i] for i in range(len(field))]
return np.array(corrected_magnetization)
[docs]
def symmetric_averaging_drift_correction(field, magnetization):
"""
Apply symmetric averaging drift correction to a hysteresis loop.
This function corrects drift in magnetic hysteresis loop data by averaging the upper branch and
the inverted lower branch of the magnetization curve, then adjusting for tip-to-tip separation.
The corrected magnetization is constructed by concatenating the reversed, drift-corrected upper branch
and its inverted counterpart, restoring symmetry to the loop.
Parameters
----------
field : array_like
Array of applied magnetic field values for the hysteresis loop.
magnetization : array_like
Array of measured magnetization values corresponding to `field`.
Returns
-------
corrected_magnetization : numpy.ndarray
Array of drift-corrected magnetization values, symmetrically constructed for the full loop.
Examples
--------
>>> field = np.linspace(-1, 1, 200)
>>> magnetization = some_hysteresis_measurement(field)
>>> corrected = symmetric_averaging_drift_correction(field, magnetization)
"""
field = np.array(field)
magnetization = np.array(magnetization)
upper_branch, lower_branch = split_hyst_loop(field, magnetization)
# average the upper and inverted lower branches
averaged_upper_branch = (upper_branch[1] - lower_branch[1][::-1]) / 2
# calculate tip-to-tip separation from both the upper and lower branches
tip_to_tip_separation = (upper_branch[1][0] - lower_branch[1][0] + upper_branch[1][-1] - lower_branch[1][-1]) / 4
# apply the tip-to-tip separation to the upper branch
corrected_magnetization = averaged_upper_branch - tip_to_tip_separation
# append back in the lower branch which should just be the inverted corrected upper branch
corrected_magnetization = np.concatenate([corrected_magnetization[::-1], -corrected_magnetization[::-1]])
return corrected_magnetization
[docs]
def IRM_nonlinear_fit(H, chi_HF, Ms, a_1, a_2):
"""
Calculate the non-linear fit for Isothermal Remanent Magnetization (IRM) as a function of applied field.
This function models the IRM signal as a sum of high-field linear susceptibility,
saturation magnetization, and non-linear correction terms with inverse field dependence.
The model is commonly used for fitting high-field IRM data, especially for extracting
parameters such as high-field susceptibility (chi_HF) and saturation magnetization (Ms).
Parameters
----------
H : numpy.ndarray
Array of applied magnetic field values (in Tesla).
chi_HF : float
High-field magnetic susceptibility. Converted to Tesla to match the unit of the field.
Ms : float
Saturation magnetization (in the same units as IRM).
a_1 : float
Coefficient for the H^(-1) non-linear correction term. Should be negative.
a_2 : float
Coefficient for the H^(-2) non-linear correction term. Should be negative.
Returns
-------
IRM_fit : numpy.ndarray
Array of fitted IRM values corresponding to each field value in `H`.
Examples
--------
>>> H = np.linspace(0.1, 3, 100) # field in Tesla, avoid zero for stability
>>> fit = IRM_nonlinear_fit(H, chi_HF=0.02, Ms=1.2, a_1=-0.03, a_2=-0.01)
>>> import matplotlib.pyplot as plt
>>> plt.plot(H, fit)
>>> plt.xlabel('Field (T)')
>>> plt.ylabel('IRM fit')
>>> plt.show()
"""
chi_HF = chi_HF/(4*np.pi/1e7)
return chi_HF * H + Ms + a_1 * H**(-1) + a_2 * H**(-2)
def _IRM_nonlinear_fit_cost_function(params, H, M_obs):
'''
Cost function for the IRM non-linear least squares fit optimization
Parameters
----------
params : numpy array
array of parameters to optimize
H : numpy array
field values
M_obs : numpy array
observed magnetization values
Returns
-------
residual : numpy array
residual between the observed and predicted magnetization values
'''
chi_HF, Ms, a_1, a_2 = params
prediction = IRM_nonlinear_fit(H, chi_HF, Ms, a_1, a_2)
return M_obs - prediction
[docs]
def Fabian_nonlinear_fit(H, chi_HF, Ms, alpha, beta):
'''
function for calculating the Fabian non-linear fit
Parameters
----------
H : numpy array
field values
chi_HF : float
high field susceptibility
Ms : float
saturation magnetization
alpha : float
coefficient of the H^(beta) term, needs to be negative
beta : float
exponent of the H^(beta) term, needs to be negative
Returns
-------
numpy array
fitted magnetization values for each field value in `H`
(same shape as `H`)
'''
chi_HF = chi_HF/(4*np.pi/1e7) # convert to Tesla
return chi_HF * H + Ms + alpha * H**beta
def _Fabian_nonlinear_fit_cost_function(params, H, M_obs):
'''
cost function for the Fabian non-linear least squares fit optimization
Parameters
----------
params : numpy array
array of parameters to optimize
H : numpy array
field values
M_obs : numpy array
observed magnetization values
Returns
-------
residual : numpy array
residual between the observed and predicted magnetization values
'''
chi_HF, Ms, alpha, beta = params
prediction = Fabian_nonlinear_fit(H, chi_HF, Ms, alpha, beta)
return M_obs - prediction
def _Fabian_nonlinear_fit_fix_beta_cost_function(params, H, M_obs):
'''
cost function for the Fabian non-linear least squares fit optimization
with beta fixed at -2
Parameters
----------
params : numpy array
array of parameters to optimize
H : numpy array
field values
M_obs : numpy array
observed magnetization values
Returns
-------
residual : numpy array
residual between the observed and predicted magnetization values
'''
beta = -2
chi_HF, Ms, alpha = params
prediction = Fabian_nonlinear_fit(H, chi_HF, Ms, alpha, beta)
return M_obs - prediction
[docs]
def hyst_HF_nonlinear_optimization(H, M, HF_cutoff, fit_type, initial_guess=[1, 1, -0.1, -0.1], bounds=([0, 0, -np.inf, -np.inf], [np.inf, np.inf, 0, 0])):
'''
Optimize a high-field nonlinear fit
Parameters
----------
H : numpy.ndarray
Array of field values.
M : numpy.ndarray
Array of magnetization values.
HF_cutoff : float
Fraction of max(|H|) defining the lower bound of the high-field region.
fit_type : {'IRM', 'Fabian', 'Fabian_fixed_beta'}
Type of nonlinear model to fit.
initial_guess : list of float, optional
Initial parameter guess for the optimizer.
Defaults to [1, 1, -0.1, -0.1]:
χ_HF = 1, Mₛ = 1, a₁ = –0.1, a₂ = –0.1 (or α, β for Fabian).
bounds : tuple of array-like, optional
Lower and upper bounds for each parameter.
Defaults to ([0, 0, -∞, -∞], [∞, ∞, 0, 0]):
- Lower: χ_HF ≥ 0, Mₛ ≥ 0, a₁ ≥ –∞, a₂ ≥ –∞
- Upper: χ_HF ≤ ∞, Mₛ ≤ ∞, a₁ ≤ 0, a₂ ≤ 0
(for Fabian, α and β follow the same positions/limits).
Returns
-------
dict
Fit results with keys:
- 'chi_HF', 'Ms', 'a_1', 'a_2' (for IRM) or
'chi_HF', 'Ms', 'alpha', 'beta' (for Fabian variants)
- 'Fnl_lin': float, F statistic for the improvement of the nonlinear fit
over a linear fit (Jackson and Solheid, 2010, equation 21); values above
~3-3.5 indicate a statistically significant improvement for the
4-parameter models (for 'Fabian_fixed_beta' the degrees of freedom are
(1, N-3) and the 5% critical value is ~3.9-4.0). Because the nonlinear
coefficients are constrained non-positive, the statistic is conservative
under the null (saturated loops give values well below the critical
value, occasionally marginally negative when the bounded fit is a hair
worse than unconstrained least squares).
'''
HF_index = np.where((np.abs(H) >= HF_cutoff*np.max(np.abs(H))) & (np.abs(H) <= 0.97*np.max(np.abs(H))))[0]
HF_field = np.abs(H[HF_index])
HF_magnetization = np.where(H[HF_index] >= 0, M[HF_index], -M[HF_index])
if fit_type == 'IRM':
cost_function = _IRM_nonlinear_fit_cost_function
results = least_squares(cost_function, initial_guess, bounds=bounds, args=(HF_field, HF_magnetization))
elif fit_type == 'Fabian':
cost_function = _Fabian_nonlinear_fit_cost_function
results = least_squares(cost_function, initial_guess, bounds=bounds, args=(HF_field, HF_magnetization))
elif fit_type == 'Fabian_fixed_beta':
cost_function = _Fabian_nonlinear_fit_fix_beta_cost_function
results = least_squares(cost_function, initial_guess[:3], bounds=(bounds[0][:3], bounds[1][:3]), args=(HF_field, HF_magnetization))
else:
raise ValueError('Fit type must be either IRM or Fabian')
if fit_type == 'IRM':
final_result = {'chi_HF': results.x[0], 'Ms': results.x[1], 'a_1': results.x[2], 'a_2': results.x[3]}
chi_HF, Ms, a_1, a_2 = results.x
nonlinear_fit = IRM_nonlinear_fit(HF_field, chi_HF, Ms, a_1, a_2)
elif fit_type == 'Fabian':
final_result = {'chi_HF': results.x[0], 'Ms': results.x[1], 'alpha': results.x[2], 'beta': results.x[3]}
chi_HF, Ms, alpha, beta = results.x
nonlinear_fit = Fabian_nonlinear_fit(HF_field, chi_HF, Ms, alpha, beta)
elif fit_type == 'Fabian_fixed_beta':
final_result = {'chi_HF': results.x[0], 'Ms': results.x[1], 'alpha': results.x[2], 'beta': -2}
chi_HF, Ms, alpha = results.x
nonlinear_fit = Fabian_nonlinear_fit(HF_field, chi_HF, Ms, alpha, -2)
# Fnl_lin (Jackson and Solheid, 2010, equation 21) tests whether the nonlinear fit
# significantly improves on a linear fit:
# Fnl_lin = [(SSD_lin - SSD_nl)/(p_nl - p_lin)] / [SSD_nl/(N - p_nl)]
# where p are the number of model parameters. Values above ~3-3.5 indicate a
# statistically significant improvement from the nonlinear term(s).
linear_fit_ANOVA = ANOVA(HF_field, HF_magnetization)
SSD_lin = linear_fit_ANOVA['SSD']
SSD_nl = np.sum((HF_magnetization - nonlinear_fit) ** 2)
n_points = len(HF_magnetization)
p_lin = 2
p_nl = 3 if fit_type == 'Fabian_fixed_beta' else 4
Fnl_lin = ((SSD_lin - SSD_nl) / (p_nl - p_lin)) / (SSD_nl / (n_points - p_nl))
final_result['Fnl_lin'] = Fnl_lin
final_result_dict = _to_native_python(final_result)
return final_result_dict
# Values reported by process_hyst_loop for quantities that are undefined at a
# decision-tree exit (statistically linear loop, or loop that remains open at
# the highest fields). The full key set is always present in the result so
# batch tables (process_hyst_loops) and the specimens-table writer keep a
# stable schema across all three outcomes.
def _show_hyst_summary_table(summary, width):
"""Display a one-row Bokeh table of hysteresis summary parameters.
Shared by the full processing path and the decision-tree exits of
process_hyst_loop, each of which passes only the parameters defined for
its outcome.
"""
source = ColumnDataSource({name: [value] for name, value in summary.items()})
columns = [TableColumn(field=name, title=name) for name in summary]
data_table = DataTable(source=source, columns=columns,
width=width, height=100)
data_table.index_position = None
show(column(data_table))
_HYST_UNDEFINED_RESULTS = {
'loop_centering_results': None, 'centered_H': None, 'centered_M': None,
'drift_corrected_M': None, 'slope_corrected_M': None,
'loop_closure_test_results': None, 'loop_is_closed': None,
'loop_saturation_stats': None, 'loop_is_saturated': None,
'M_sn': np.nan, 'Q': np.nan,
'H': None, 'Mr': np.nan, 'Mrh': None, 'Mih': None, 'Me': None,
'Brh': np.nan, 'sigma': np.nan, 'chi_HF': np.nan,
'FNL60': np.nan, 'FNL70': np.nan, 'FNL80': np.nan,
'Ms': np.nan, 'Bc': np.nan, 'M_sn_f': np.nan, 'Qf': np.nan,
'Fnl_lin': None, 'plot': None,
}
[docs]
def process_hyst_loop(field, magnetization, specimen_name='', show_results_table=True, show_plot=True,
NL_fit=False, centering_protocol='legacy',
fit_open_loop=False, fit_linear_loop=False):
"""
Process a magnetic hysteresis loop using the IRM decision tree workflow.
This function performs a complete analysis of a hysteresis loop, including gridding, centering, drift correction,
high-field correction, and extraction of key magnetic parameters. The workflow follows best practices in rock magnetism
and outputs both a summary of results and a Bokeh plot visualizing the various processing steps.
The inputs need not come from a MagIC measurements table: any pair of
field and magnetization sequences (lists, arrays, or dataframe columns)
can be processed. Inputs are passed through `sanitize_hyst_inputs`,
so non-finite measurement pairs are dropped with a report, numeric
strings are converted, and either field sweep order (starting from
positive or negative saturation) is accepted.
Two decision-tree exits terminate processing early, in both cases
returning the full result key set with the undefined quantities reported
as NaN/None so batch tables keep a stable schema:
- a loop that is statistically linear (whole-loop lack-of-fit test) is
dominated by paramagnetic or diamagnetic material; only the high-field
susceptibility (from the whole-loop regression) is reported. Passing
fit_linear_loop=True overrides this exit and processes the loop in
full;
- a loop that remains open at the highest fields (closure test) contains
unsaturated high-coercivity phases, so Ms and chi_HF cannot be
separated; the slope-independent parameters (Mr and Brh, computed from
Mrh in which linear-in-field contributions cancel) and the data
quality statistics are reported. Passing fit_open_loop=True overrides
this exit and proceeds with the high-field fitting.
Parameters
----------
field : array_like
Array of applied magnetic field values in tesla (the chi_HF unit
conversions assume tesla; a warning is printed if the values appear
to be in mT or Oe).
magnetization : array_like
Array of magnetization values (same length as `field`), in any
consistent unit; mass-normalized Am²/kg matches MagIC conventions.
specimen_name : str, optional
Identifier for the specimen, used for labeling plots.
show_results_table : bool, optional
If True (default), display a summary table of key parameters using Bokeh.
show_plot : bool, optional
If True (default), display the Bokeh plot of the hysteresis loop and processing steps.
NL_fit : bool, optional
If True, force non-linear high-field fitting regardless of the
saturation test result (default is False). Because the
approach-to-saturation fit exists precisely for unsaturated loops,
NL_fit=True also proceeds through the open-loop exit (it implies
fit_open_loop=True).
centering_protocol : {'legacy', 'iterative'}, optional
Centering workflow to apply before drift and high-field corrections.
Defaults to 'legacy' for backward compatibility.
fit_open_loop : bool, optional
If True, proceed with the high-field fitting (and the Ms estimate)
even when the closure test flags the loop as open. Default False:
open loops exit with the slope-independent parameters and data
quality statistics, since Ms and chi_HF cannot be separated for an
unsaturated loop. NL_fit=True implies this behavior. Note that
residual instrument drift can leave a spurious positive high-field
Mrh signal that trips the closure test on a visually closed loop
(particularly for loops measured from negative saturation); inspect
the loop and pass fit_open_loop=True in such cases.
fit_linear_loop : bool, optional
If True, process a statistically linear loop in full rather than
terminating with chi_HF only (default False). Useful when a weak
ferromagnetic signal near the noise level is of interest despite
the loop passing the whole-loop linearity test; the ferromagnetic
parameters from such a loop should be interpreted alongside the
quality statistics.
Returns
-------
results : dict
Dictionary containing the following keys:
- 'gridded_H': gridded field values
- 'gridded_M': gridded magnetization values
- 'linearity_test_results': results of the initial linearity test
- 'loop_is_linear': whether the loop passes the linearity test
- 'FNL': F statistic for whole-loop nonlinearity (lack-of-fit F ratio)
- 'loop_centering_results': results of centering optimization
- 'centered_H': centered field values
- 'centered_M': centered magnetization values
- 'drift_corrected_M': drift-corrected magnetization
- 'slope_corrected_M': slope-corrected magnetization
- 'loop_closure_test_results': results of closure test
- 'loop_is_closed': whether the loop is closed
- 'loop_saturation_stats': saturation test results
- 'loop_is_saturated': whether the loop is saturated
- 'M_sn', 'Q': quality metrics from centering
- 'H', 'Mr', 'Mrh', 'Mih', 'Me', 'Brh': characteristic field and moment parameters
- 'sigma': shape parameter (Fabian, 2003)
- 'chi_HF': high-field susceptibility
- 'FNL60', 'FNL70', 'FNL80': high-field nonlinearity F statistics for windows
starting at 60%, 70%, and 80% of the maximum field
- 'Ms': saturation magnetization
- 'Bc': coercive field
- 'M_sn_f', 'Qf': quality metrics for ferromagnetic component
- 'Fnl_lin': F statistic for improvement of the nonlinear over the linear
high-field fit (None if the loop is saturated and no nonlinear fit is made)
- 'plot': Bokeh figure with overlaid processing steps
"""
# clean the inputs (accepts lists/Series/text columns, drops non-finite
# pairs, warns on apparent non-tesla field units)
field, magnetization = sanitize_hyst_inputs(field, magnetization)
# record the original sweep order before gridding canonicalizes it: the
# drift correction is time-order sensitive and needs to know whether the
# loop was measured from positive or negative saturation
descending_first = measured_descending_first(field)
# first grid the data into symmetric field values
grid_fields, grid_magnetizations = grid_hyst_loop(field, magnetization)
# test linearity of the gridded original loop
loop_linearity_test_results = hyst_linearity_test(grid_fields, grid_magnetizations)
if loop_linearity_test_results['loop_is_linear'] and not fit_linear_loop:
# decision-tree exit (Jackson & Solheid, 2010): a statistically
# linear loop is dominated by paramagnetic or diamagnetic material,
# so the ferromagnetic parameters are undefined; processing
# terminates with the high-field susceptibility from the whole-loop
# regression (the same mu_0 conversion as linear_HF_fit) as the
# reported result
chi_HF = loop_linearity_test_results['slope'] * (4 * np.pi / 1e7)
print(f'-I- loop is statistically linear (FNL = '
f'{loop_linearity_test_results["FNL"]:.2f}): the loop is '
'dominated by paramagnetic or diamagnetic material and only '
'the high-field susceptibility is reported')
p = None
if _HAS_BOKEH:
p = plot_hyst_loop(grid_fields, grid_magnetizations,
specimen_name, line_color='orange',
label='raw loop (statistically linear)',
return_figure=True, show_plot=show_plot)
if show_results_table and p is not None:
_show_hyst_summary_table(
{'chi_HF': chi_HF,
'FNL': loop_linearity_test_results['FNL']}, p.width)
return {**_HYST_UNDEFINED_RESULTS,
'gridded_H': grid_fields,
'gridded_M': grid_magnetizations,
'measured_descending_first': descending_first,
'linearity_test_results': loop_linearity_test_results,
'loop_is_linear': True,
'FNL': loop_linearity_test_results['FNL'],
'centering_protocol': centering_protocol,
'chi_HF': chi_HF,
'plot': p}
# loop centering
if centering_protocol == 'legacy':
loop_centering_results = hyst_loop_centering(grid_fields, grid_magnetizations)
elif centering_protocol == 'iterative':
loop_centering_results = hyst_loop_centering_iterative(grid_fields, grid_magnetizations)
else:
raise ValueError("centering_protocol must be either 'legacy' or 'iterative'")
# check if the quality factor Q is < 2
if loop_centering_results['Q'] < 2:
# in case the loop quality is bad, no field correction is applied
loop_centering_results['opt_H_offset'] = 0
loop_centering_results['centered_H'] = grid_fields
loop_centering_results['centered_M'] = grid_magnetizations - loop_centering_results['opt_M_offset']
centered_H, centered_M = loop_centering_results['centered_H'], loop_centering_results['centered_M']
# drift correction, applied in the loop's true measurement-time order
drift_corr_M = Me_drift_correction(centered_H, centered_M,
descending_first=descending_first)
# calculate Mr, Mrh, Mih, Me, Brh
H, Mr, Mrh, Mih, Me, Brh = calc_Mr_Mrh_Mih_Brh(centered_H, drift_corr_M)
# check if the loop is closed
loop_closure_test_results = loop_closure_test(H, Mrh, Me=Me)
# check if the loop is saturated (high field linearity test)
loop_saturation_stats = hyst_loop_saturation_test(centered_H, drift_corr_M)
if (not loop_closure_test_results['loop_is_closed']
and not (fit_open_loop or NL_fit)):
# decision-tree exit (Jackson & Solheid, 2010): the loop remains open
# at the highest fields (unsaturated high-coercivity phases such as
# hematite or goethite), so the ferromagnetic and paramagnetic
# contributions cannot be separated and Ms, chi_HF, and the
# slope-dependent parameters cannot be estimated. The
# slope-independent parameters (Mr and Brh, from Mrh, in which any
# linear-in-field contribution cancels) and the data quality
# statistics are reported. Pass fit_open_loop=True to proceed with
# the high-field fitting despite the open loop.
warnings.warn(
'loop remains open at the highest fields (closure test: '
f"SNR = {loop_closure_test_results['SNR']:.1f} dB, "
f"HAR = {loop_closure_test_results['HAR']:.1f} dB), so Ms and "
'chi_HF cannot be estimated; reporting the slope-independent '
'parameters (Mr, Brh) and data quality statistics only. If the '
'loop appears closed on inspection, residual instrument drift '
'may be triggering the test; pass fit_open_loop=True to force '
'the high-field fit.',
RuntimeWarning, stacklevel=2)
p = None
if _HAS_BOKEH:
p = plot_hyst_loop(grid_fields, grid_magnetizations, specimen_name, line_color='orange', label='raw loop',
return_figure=True, show_plot=False)
p = plot_hyst_loop(centered_H, centered_M, specimen_name, p=p, line_color='red', label=specimen_name+' offset corrected',
return_figure=True, show_plot=False)
p = plot_hyst_loop(centered_H, drift_corr_M, specimen_name, p=p, line_color='pink', label=specimen_name+' drift corrected (open loop)',
return_figure=True, show_plot=False)
p.line(H, Mrh, line_color='green', legend_label='Mrh', line_width=1)
p.line(H, Mih, line_color='purple', legend_label='Mih', line_width=1)
p.line(H, Me, line_color='brown', legend_label='Me', line_width=1)
if show_plot:
show(p)
if show_results_table:
_show_hyst_summary_table({
'Mr': Mr, 'Brh': Brh,
'Q': loop_centering_results['Q'],
'FNL60': loop_saturation_stats['FNL60'],
'FNL70': loop_saturation_stats['FNL70'],
'FNL80': loop_saturation_stats['FNL80'],
'SNR': loop_closure_test_results['SNR'],
'HAR': loop_closure_test_results['HAR'],
}, p.width)
return {**_HYST_UNDEFINED_RESULTS,
'gridded_H': grid_fields,
'gridded_M': grid_magnetizations,
'measured_descending_first': descending_first,
'linearity_test_results': loop_linearity_test_results,
'loop_is_linear': loop_linearity_test_results['loop_is_linear'],
'FNL': loop_linearity_test_results['FNL'],
'loop_centering_results': loop_centering_results,
'centering_protocol': centering_protocol,
'centered_H': centered_H,
'centered_M': centered_M,
'drift_corrected_M': drift_corr_M,
'loop_closure_test_results': loop_closure_test_results,
'loop_is_closed': False,
'loop_saturation_stats': loop_saturation_stats,
'loop_is_saturated': loop_saturation_stats['loop_is_saturated'],
'M_sn': loop_centering_results['M_sn'],
'Q': loop_centering_results['Q'],
'H': H, 'Mr': Mr, 'Mrh': Mrh,
'Mih': Mih, 'Me': Me, 'Brh': Brh,
'FNL60': loop_saturation_stats['FNL60'],
'FNL70': loop_saturation_stats['FNL70'],
'FNL80': loop_saturation_stats['FNL80'],
'plot': p}
if NL_fit:
loop_saturation_stats['loop_is_saturated'] = False # force non-linear high-field fitting
if loop_saturation_stats['loop_is_saturated']:
# linear high field correction
chi_HF, Ms = linear_HF_fit(centered_H, drift_corr_M, loop_saturation_stats['saturation_cutoff'])
Fnl_lin = None
else:
# do non linear approach to saturation fit
NL_fit_result = hyst_HF_nonlinear_optimization(centered_H, drift_corr_M, 0.6, 'IRM')
chi_HF, Ms, Fnl_lin = NL_fit_result['chi_HF'], NL_fit_result['Ms'], NL_fit_result['Fnl_lin']
# apply high field correction
slope_corr_M = hyst_slope_correction(centered_H, drift_corr_M, chi_HF)
# calculate the Msn and Q factor for the ferromagentic component
M_sn_f, Qf = calc_Q(centered_H, slope_corr_M)
# calculate the coercivity Bc
Bc = calc_Bc(centered_H, slope_corr_M)
# calculate the shape parameter of Fabian 2003
E_hyst = np.trapezoid(Mrh, H)
sigma = np.log(E_hyst / 2 / Bc / Ms)
p = None
p_slope_corr = None
if _HAS_BOKEH:
# plot original loop
p = plot_hyst_loop(grid_fields, grid_magnetizations, specimen_name, line_color='orange', label='raw loop',
return_figure=True, show_plot=False)
# plot centered loop
p_centered = plot_hyst_loop(centered_H, centered_M, specimen_name, p=p, line_color='red', label=specimen_name+' offset corrected',
return_figure=True, show_plot=False)
# plot drift corrected loop
p_drift_corr = plot_hyst_loop(centered_H, drift_corr_M, specimen_name, p=p_centered, line_color='pink', label=specimen_name+' drift corrected',
return_figure=True, show_plot=False)
# plot slope corrected loop
p_slope_corr = plot_hyst_loop(centered_H, slope_corr_M, specimen_name, p=p_drift_corr, line_color='blue', label=specimen_name+' slope corrected',
return_figure=True, show_plot=False)
# plot Mrh
p_slope_corr.line(H, Mrh, line_color='green', legend_label='Mrh', line_width=1)
p_slope_corr.line(H, Mih, line_color='purple', legend_label='Mih', line_width=1)
p_slope_corr.line(H, Me, line_color='brown', legend_label='Me', line_width=1)
if show_plot:
show(p_slope_corr)
results = {'gridded_H': grid_fields,
'gridded_M': grid_magnetizations,
'measured_descending_first': descending_first,
'linearity_test_results': loop_linearity_test_results,
'loop_is_linear': loop_linearity_test_results['loop_is_linear'],
'FNL': loop_linearity_test_results['FNL'],
'loop_centering_results': loop_centering_results,
'centering_protocol': centering_protocol,
'centered_H': centered_H,
'centered_M': centered_M,
'drift_corrected_M': drift_corr_M,
'slope_corrected_M': slope_corr_M,
'loop_closure_test_results': loop_closure_test_results,
'loop_is_closed': loop_closure_test_results['loop_is_closed'],
'loop_saturation_stats': loop_saturation_stats,
'loop_is_saturated': loop_saturation_stats['loop_is_saturated'],
'M_sn':loop_centering_results['M_sn'],
'Q': loop_centering_results['Q'],
'H': H, 'Mr': Mr, 'Mrh': Mrh,
'Mih': Mih, 'Me': Me, 'Brh': Brh, 'sigma': sigma,
'chi_HF': chi_HF,
'FNL60': loop_saturation_stats['FNL60'],
'FNL70': loop_saturation_stats['FNL70'],
'FNL80': loop_saturation_stats['FNL80'],
'Ms': Ms, 'Bc': Bc, 'M_sn_f': M_sn_f,
'Qf': Qf, 'Fnl_lin': Fnl_lin,
'plot': p}
if show_results_table and _HAS_BOKEH and p_slope_corr is not None:
_show_hyst_summary_table({
'Mr': Mr, 'Ms': Ms, 'Bc': Bc, 'Brh': Brh, 'sigma': sigma,
'Q': loop_centering_results['Q'], 'Qf': Qf, 'chi_HF': chi_HF,
'FNL60': loop_saturation_stats['FNL60'],
'FNL70': loop_saturation_stats['FNL70'],
'FNL80': loop_saturation_stats['FNL80'],
}, p_slope_corr.width)
return results
[docs]
def process_hyst_loops(
hyst_experiments,
measurements,
field_col="meas_field_dc",
magn_col="magn_mass",
show_results_table=True,
show_plots=True,
centering_protocol='legacy',
fit_open_loop=False,
fit_linear_loop=False,
):
"""
Process multiple hysteresis loops in batch.
Parameters
----------
hyst_experiments : DataFrame
Must contain columns "experiment" and "specimen".
measurements : DataFrame
Must contain an "experiment" column and the data columns.
field_col : str, optional
Name of the column in `measurements` holding field values.
Defaults to "meas_field_dc".
magn_col : str, optional
Name of the column in `measurements` holding magnetization values.
Defaults to "magn_mass".
show_results_table : bool, optional
If True, display the summary table below each plot.
show_plots : bool, optional
If True, display the hysteresis plots for each specimen.
centering_protocol : {'legacy', 'iterative'}, optional
Centering workflow to pass through to process_hyst_loop.
Defaults to 'legacy' for backward compatibility.
fit_open_loop : bool, optional
Passed through to process_hyst_loop: if True, high-field fitting
proceeds even for loops the closure test flags as open (default
False).
fit_linear_loop : bool, optional
Passed through to process_hyst_loop: if True, statistically linear
loops are processed in full rather than terminating with chi_HF
only (default False).
Returns
-------
results_df : pandas.DataFrame
DataFrame with hysteresis results for each experiment.
Has a numeric index with 'specimen' and 'experiment' as columns.
"""
results = []
for _, row in hyst_experiments.iterrows():
exp = row["experiment"]
spec = row["specimen"]
df = (
measurements[measurements["experiment"] == exp]
.reset_index(drop=True)
)
res = process_hyst_loop(
df[field_col].values,
df[magn_col].values,
spec,
show_results_table=show_results_table,
show_plot=show_plots,
centering_protocol=centering_protocol,
fit_open_loop=fit_open_loop,
fit_linear_loop=fit_linear_loop,
)
res['specimen'] = spec
res['experiment'] = exp
res['processed_by'] = pmagpy_version
results.append(res)
results_df = pd.DataFrame(results)
return results_df
[docs]
def add_hyst_stats_to_specimens_table(specimens_df, hyst_results, overwrite=True):
'''
Return a copy of the specimens table with hysteresis results added.
The input DataFrame is not modified. Assign the return value to
update your table, e.g.:
specimens = add_hyst_stats_to_specimens_table(specimens, hyst_results)
Parameters
----------
specimens_df : pandas.DataFrame
dataframe with the specimens data
hyst_results : pandas.DataFrame
DataFrame with hysteresis results including 'specimen' and
'experiment' columns, as output from rmag.process_hyst_loops.
Has a numeric index (one row per experiment).
overwrite : bool, optional
If True (default), existing MagIC column values and description stats
are replaced with new values from hyst_results. If False, existing
rows are preserved as-is and new rows are appended with the
hyst results.
Returns
-------
specimens_df : pandas.DataFrame
A new DataFrame with hysteresis results added.
If a specimen has multiple experiments, its row is duplicated
so that each experiment gets its own row.
'''
specimens_df = specimens_df.copy()
result_keys_MagIC = ['Ms', 'Mr', 'Bc', 'chi_HF']
MagIC_columns = ['hyst_ms_mass', 'hyst_mr_mass', 'hyst_bc', 'hyst_xhf']
additional_keys = ['Q', 'Qf', 'sigma',
'Brh', 'FNL', 'FNL60', 'FNL70', 'FNL80',
'Fnl_lin', 'loop_is_linear', 'loop_is_closed', 'loop_is_saturated',
'processed_by']
# ensure MagIC columns exist in specimens_df
for col in MagIC_columns:
if col not in specimens_df.columns:
specimens_df[col] = np.nan
if 'description' not in specimens_df.columns:
specimens_df['description'] = np.nan
# Coerce target columns so scalar writes succeed across pandas versions.
# MagIC tables are read as text, so these arrive as string dtype under
# pandas >= 3.0 (future.infer_string), which rejects assigning a float
# (numeric stats) or a description string into a string-dtype column.
for col in MagIC_columns:
specimens_df[col] = pd.to_numeric(specimens_df[col], errors='coerce')
specimens_df['description'] = specimens_df['description'].astype(object)
for _, row in hyst_results.iterrows():
specimen_name = row['specimen']
experiment_name = row['experiment']
# colon-delimited 'experiments' cells (several experiments per row)
# must match the single experiment name, so use the shared token
# matching rather than exact equality
mask = _match_specimen_rows(specimens_df, experiment_name, None)
if overwrite:
if not mask.any():
# no row for this experiment — create one from specimen template
spec_mask = specimens_df['specimen'] == specimen_name
if spec_mask.any():
new_row = specimens_df.loc[spec_mask].iloc[0].copy()
else:
new_row = pd.Series(dtype='object')
new_row['specimen'] = specimen_name
new_row['experiments'] = experiment_name
specimens_df = pd.concat(
[specimens_df, new_row.to_frame().T],
ignore_index=True,
)
mask = _match_specimen_rows(specimens_df, experiment_name,
None)
ipos = mask.values.nonzero()[0][0]
else:
# overwrite=False: leave existing row alone, always add a new row
spec_mask = specimens_df['specimen'] == specimen_name
if spec_mask.any():
new_row = specimens_df.loc[spec_mask].iloc[0].copy()
else:
new_row = pd.Series(dtype='object')
new_row['specimen'] = specimen_name
new_row['experiments'] = experiment_name
specimens_df = pd.concat(
[specimens_df, new_row.to_frame().T],
ignore_index=True,
)
ipos = len(specimens_df) - 1
# write MagIC columns
for result_key, col in zip(result_keys_MagIC, MagIC_columns):
specimens_df.iloc[ipos, specimens_df.columns.get_loc(col)] = row[result_key]
# merge the additional stats into the description cell using the
# shared 'free text | JSON' convention, so hysteresis and unmixing
# writers can round-trip each other's cells. parse_specimen_description
# also reads legacy Python-dict cells written by older versions.
additional_stats_dict = {key: row[key] for key in additional_keys
if key in row.index}
desc_col = specimens_df.columns.get_loc('description')
text, description_dict = parse_specimen_description(
specimens_df.iloc[ipos, desc_col])
description_dict.update(_to_native_python(additional_stats_dict))
payload = json.dumps(description_dict)
specimens_df.iloc[ipos, desc_col] = (f'{text} | {payload}' if text
else payload)
return specimens_df
# X-T functions
# ------------------------------------------------------------------------------------------------------------------
[docs]
def split_heating_cooling(experiment, temperature_column='meas_temp',
magnetic_column='susc_chi_mass'):
"""
Split a thermomagnetic curve into heating and cooling portions.
The sequence is split at the temperature turning point (the global
maximum): measurements up to and including the peak form the heating
branch and the descending remainder forms the cooling branch. A run whose
temperature never descends after its peak returns an empty cooling branch,
and a run that descends from its first measurement returns an empty heating
branch. Rows with non-finite temperature or magnetic values are dropped.
Splitting at the turning point rather than on the sign of each local step
keeps repeated furnace-stabilization temperatures (where the step is zero)
and noisy readings on the heating ramp within the heating branch. A
point-by-point classification instead misroutes those points into a
spurious cooling branch, so a heating-only run with duplicated or noisy
temperatures would otherwise produce a phantom cooling curve. This assumes
a single heat-then-cool trajectory, the standard thermomagnetic protocol.
Parameters
----------
experiment : pandas.DataFrame
the experiment data (rows in measurement order)
temperature_column : str, optional
name of the temperature column (default 'meas_temp')
magnetic_column : str, optional
name of the magnetization/susceptibility column
(default 'susc_chi_mass')
Returns
-------
warm_T : numpy.ndarray
temperatures for the heating cycle (measurement order)
warm_X : numpy.ndarray
magnetization/susceptibility for the heating cycle
cool_T : numpy.ndarray
temperatures for the cooling cycle (measurement order)
cool_X : numpy.ndarray
magnetization/susceptibility for the cooling cycle
"""
T = np.asarray(experiment[temperature_column], dtype=float)
X = np.asarray(experiment[magnetic_column], dtype=float)
finite = np.isfinite(T) & np.isfinite(X)
T = T[finite]
X = X[finite]
if T.size == 0:
empty = np.array([], dtype=float)
return empty, empty.copy(), empty.copy(), empty.copy()
peak_index = int(np.argmax(T))
descends_after_peak = bool(np.any(T[peak_index + 1:] < T[peak_index]))
if not descends_after_peak:
is_heating = np.ones(T.size, dtype=bool)
elif peak_index == 0:
is_heating = np.zeros(T.size, dtype=bool)
else:
is_heating = np.arange(T.size) <= peak_index
warm_T = T[is_heating]
warm_X = X[is_heating]
cool_T = T[~is_heating]
cool_X = X[~is_heating]
return warm_T, warm_X, cool_T, cool_X
[docs]
def prepare_thermomag_branches(
experiment,
temperature_column="meas_temp",
magnetic_column="susc_chi_mass",
temp_unit="C",
input_unit="K",
smooth_window=0,
remove_holder=True,
window_type="hanning",
):
"""
Preprocess a thermomagnetic experiment into clean heating/cooling branches.
This is the shared preprocessing step for the Curie temperature estimators
and thermomagnetic plots. It splits the measurement sequence into heating
and cooling branches (``split_heating_cooling``), converts temperatures to the
requested unit, optionally subtracts the per-branch minimum as an estimate
of the sample-holder background, sorts each branch by ascending
temperature, and optionally smooths each branch with an x-space moving
window (``smooth_moving_average``).
Subtracting the per-branch minimum assumes that the magnetic signal decays
to the holder background at the highest temperatures (i.e., the experiment
passes above the Curie temperature of all ferromagnetic phases). When that
assumption does not hold (e.g., a run that ends below the Curie
temperature), set ``remove_holder=False``.
Parameters
----------
experiment : pandas.DataFrame
MagIC-formatted experiment DataFrame (rows in measurement order).
temperature_column : str, optional
Name of the temperature column (default 'meas_temp').
magnetic_column : str, optional
Name of the magnetization/susceptibility column
(default 'susc_chi_mass').
temp_unit : {'C', 'K'}, optional
Unit for the returned temperatures (default 'C').
input_unit : {'K', 'C'}, optional
Unit of the temperatures in ``experiment`` (default 'K', the MagIC
convention for ``meas_temp``).
smooth_window : float, optional
Width of the smoothing window in units of ``temp_unit``. If 0
(default), no smoothing is applied and the smoothed arrays equal the
raw arrays.
remove_holder : bool, optional
Subtract the per-branch minimum value from each branch (default True).
window_type : {'flat', 'hanning', 'hamming', 'bartlett', 'blackman'}, optional
Weighting function applied within each smoothing window by
``smooth_moving_average`` (default 'hanning'). Only used when
``smooth_window > 0``. The options are:
- 'flat': uniform weights, i.e. a simple unweighted running mean.
- 'hanning': raised-cosine (Hann) taper; weights fall smoothly to
zero at the window edges. A good general-purpose default that
suppresses edge/ringing artifacts.
- 'hamming': raised-cosine taper similar to 'hanning' but with
nonzero end weights, giving slightly less edge attenuation.
- 'bartlett': triangular taper; weights decrease linearly from the
window center to zero at the edges.
- 'blackman': three-term cosine taper that is more strongly peaked
than 'hanning'/'hamming', giving the heaviest smoothing (widest
effective averaging) of the tapered options.
All options other than 'flat' are the correspondingly named
``numpy`` window functions.
Returns
-------
dict
``{'heating': branch or None, 'cooling': branch or None}`` where each
branch is a dict with keys ``'T'`` and ``'y'`` (smoothed arrays,
ascending temperature) and ``'raw_T'`` and ``'raw_y'`` (unsmoothed
arrays, ascending temperature). A branch with no measurements is
``None``.
"""
warm_T, warm_X, cool_T, cool_X = split_heating_cooling(
experiment,
temperature_column=temperature_column,
magnetic_column=magnetic_column,
)
branches = {}
for name, T, y in (("heating", warm_T, warm_X), ("cooling", cool_T, cool_X)):
if T.size == 0:
branches[name] = None
continue
T = convert_temperature(T, input_unit, temp_unit)
if remove_holder:
y = y - np.min(y)
order = np.argsort(T, kind="stable")
T = T[order]
y = y[order]
# average duplicate temperatures once here so that every estimator
# receives strictly increasing temperatures and repeated logged
# points (furnace stabilization) do not over-weight the fits
analysis_T, analysis_y = _dedupe_temperatures(T, y)
sm_T, sm_y = smooth_moving_average(analysis_T, analysis_y, smooth_window,
window_type=window_type)
branches[name] = {
"T": np.asarray(sm_T, dtype=float),
"y": np.asarray(sm_y, dtype=float),
"raw_T": T,
"raw_y": y,
}
return branches
[docs]
def plot_chi_T(
experiment,
temperature_column="meas_temp",
magnetic_column="susc_chi_mass",
temp_unit="C",
smooth_window=0,
remove_holder=True,
plot_derivative=True,
plot_inverse=False,
interactive=True,
return_figure=False,
figsize=(6, 6),
window_type="hanning",
):
"""
Plot the high-temperature susceptibility curve, and optionally its derivative
and reciprocal using Bokeh or Matplotlib.
Parameters:
experiment (pandas.DataFrame): MagIC-formatted experiment DataFrame.
temperature_column (str): Name of temperature column.
magnetic_column (str): Name of susceptibility column.
temp_unit (str): "C" or "K" for the plotted temperatures (input
temperatures are assumed to be in Kelvin, the MagIC convention).
smooth_window (int): Window for smoothing, if 0, no smoothing is applied.
remove_holder (bool): Subtract holder signal.
plot_derivative (bool): Plot derivative.
plot_inverse (bool): Plot inverse.
interactive (bool): True for Bokeh, False for Matplotlib.
return_figure (bool): Return figure objects if True.
figsize (tuple): (width, height) in inches.
window_type (str): Weighting function applied within each smoothing
window, one of 'flat', 'hanning', 'hamming', 'bartlett', or
'blackman' (default 'hanning'). Only used when smooth_window > 0.
See prepare_thermomag_branches for a description of each option.
Returns:
tuple or None: If return_figure is True, a tuple of the figure
objects created (Matplotlib Figures when interactive is False, Bokeh
figures when interactive is True). Otherwise the figures are displayed
and None is returned.
"""
branches = prepare_thermomag_branches(
experiment,
temperature_column=temperature_column,
magnetic_column=magnetic_column,
temp_unit=temp_unit,
smooth_window=smooth_window,
remove_holder=remove_holder,
window_type=window_type,
)
empty = np.array([], dtype=float)
heating = branches["heating"]
cooling = branches["cooling"]
warm_T = heating["raw_T"] if heating else empty
warm_X = heating["raw_y"] if heating else empty
cool_T = cooling["raw_T"] if cooling else empty
cool_X = cooling["raw_y"] if cooling else empty
swT = heating["T"] if heating else empty
swX = heating["y"] if heating else empty
scT = cooling["T"] if cooling else empty
scX = cooling["y"] if cooling else empty
title = experiment["specimen"].unique()[0]
figs = []
if interactive:
_check_bokeh()
bokeh_height = int(figsize[1] * 96)
# Main plot
p = figure(
title=title,
sizing_mode="stretch_width",
height=bokeh_height,
x_axis_label=f"Temperature ({_temp_unit_label(temp_unit)})",
y_axis_label="χ (m³ kg⁻¹)",
tools="pan,wheel_zoom,box_zoom,reset,save",
)
p.xaxis.axis_label_text_font_style = "normal"
p.yaxis.axis_label_text_font_style = "normal"
r_warm_c = p.scatter(
warm_T, warm_X, legend_label="Heating",
color="red", alpha=0.5, size=6,
)
r_warm_l = p.line(
swT, swX, legend_label="Heating",
line_width=2, color="red",
)
r_cool_c = p.scatter(
cool_T, cool_X, legend_label="Cooling",
color="blue", alpha=0.5, size=6,
)
r_cool_l = p.line(
scT, scX, legend_label="Cooling",
line_width=2, color="blue",
)
p.add_tools(
HoverTool(renderers=[r_warm_c, r_warm_l],
tooltips=[("T", "@x"), ("Heating χ", "@y")])
)
p.add_tools(
HoverTool(renderers=[r_cool_c, r_cool_l],
tooltips=[("T", "@x"), ("Cooling χ", "@y")])
)
p.grid.grid_line_color = "lightgray"
p.outline_line_color = "black"
p.background_fill_color = "white"
p.legend.location = "top_left"
p.legend.click_policy = "hide"
figs.append(p)
# Derivative
if plot_derivative:
p_dx = figure(
title=f"{title} – dχ/dT",
sizing_mode="stretch_width",
height=bokeh_height,
x_axis_label=f"Temperature ({_temp_unit_label(temp_unit)})",
y_axis_label="dχ/dT",
tools="pan,wheel_zoom,box_zoom,reset,save",
)
p_dx.xaxis.axis_label_text_font_style = "normal"
p_dx.yaxis.axis_label_text_font_style = "normal"
# average duplicate temperatures before differencing so that
# repeated logged temperatures do not produce zero spacing
swT_d, swX_d = _dedupe_temperatures(swT, swX)
scT_d, scX_d = _dedupe_temperatures(scT, scX)
dx_w = np.gradient(swX_d, swT_d) if swT_d.size > 1 else empty
dx_c = np.gradient(scX_d, scT_d) if scT_d.size > 1 else empty
swT_d = swT_d if swT_d.size > 1 else empty
scT_d = scT_d if scT_d.size > 1 else empty
r_dx_w = p_dx.line(
swT_d, dx_w, legend_label="Heating – dχ/dT",
line_width=2, color="red"
)
r_dx_w_c = p_dx.scatter(
swT_d, dx_w, legend_label="Heating – dχ/dT",
color="red", alpha=0.5, size=6
)
r_dx_c = p_dx.line(
scT_d, dx_c, legend_label="Cooling – dχ/dT",
line_width=2, color="blue"
)
r_dx_c_c = p_dx.scatter(
scT_d, dx_c, legend_label="Cooling – dχ/dT",
color="blue", alpha=0.5, size=6
)
p_dx.add_tools(
HoverTool(renderers=[r_dx_w, r_dx_w_c],
tooltips=[("T", "@x"), ("dχ/dT (heat)", "@y")])
)
p_dx.add_tools(
HoverTool(renderers=[r_dx_c, r_dx_c_c],
tooltips=[("T", "@x"), ("dχ/dT (cool)", "@y")])
)
p_dx.grid.grid_line_color = "lightgray"
p_dx.outline_line_color = "black"
p_dx.background_fill_color = "white"
p_dx.legend.location = "top_left"
p_dx.legend.click_policy = "hide"
figs.append(p_dx)
# Inverse
if plot_inverse:
p_inv = figure(
title=f"{title} – 1/χ",
sizing_mode="stretch_width",
height=bokeh_height,
x_axis_label=f"Temperature ({_temp_unit_label(temp_unit)})",
y_axis_label="1/χ",
tools="pan,wheel_zoom,box_zoom,reset,save",
)
p_inv.xaxis.axis_label_text_font_style = "normal"
p_inv.yaxis.axis_label_text_font_style = "normal"
inv_w = _inverse_susceptibility(swX)
inv_c = _inverse_susceptibility(scX)
mask_w = np.isfinite(inv_w)
mask_c = np.isfinite(inv_c)
r_inv_w = p_inv.line(
np.array(swT)[mask_w], inv_w[mask_w],
legend_label="Heating – 1/χ",
line_width=2, color="red",
)
r_inv_w_c = p_inv.scatter(
np.array(swT)[mask_w], inv_w[mask_w],
color="red", alpha=0.5, size=6
)
r_inv_c = p_inv.line(
np.array(scT)[mask_c], inv_c[mask_c],
legend_label="Cooling – 1/χ",
line_width=2, color="blue",
)
r_inv_c_c = p_inv.scatter(
np.array(scT)[mask_c], inv_c[mask_c],
color="blue", alpha=0.5, size=6
)
p_inv.add_tools(
HoverTool(renderers=[r_inv_w, r_inv_w_c],
tooltips=[("T", "@x"), ("1/χ (heat)", "@y")])
)
p_inv.add_tools(
HoverTool(renderers=[r_inv_c, r_inv_c_c],
tooltips=[("T", "@x"), ("1/χ (cool)", "@y")])
)
p_inv.grid.grid_line_color = "lightgray"
p_inv.outline_line_color = "black"
p_inv.background_fill_color = "white"
p_inv.legend.location = "top_left"
p_inv.legend.click_policy = "hide"
figs.append(p_inv)
for fig in figs:
show(fig)
else:
fig_kwargs = {"figsize": figsize}
fig1, ax1 = plt.subplots(**fig_kwargs)
ax1.scatter(warm_T, warm_X, label="Heating", alpha=0.5)
ax1.plot(swT, swX, label="Heating – smoothed", linewidth=2)
ax1.scatter(cool_T, cool_X, label="Cooling", alpha=0.5)
ax1.plot(scT, scX, label="Cooling – smoothed", linewidth=2)
ax1.set_title(title)
ax1.set_xlabel(f"Temperature ({_temp_unit_label(temp_unit)})")
ax1.set_ylabel("χ (m³ kg⁻¹)")
ax1.grid(True)
ax1.legend(loc="upper left")
figs.append(fig1)
if plot_derivative:
# average duplicate temperatures before differencing so that
# repeated logged temperatures do not produce zero spacing
swT_d, swX_d = _dedupe_temperatures(swT, swX)
scT_d, scX_d = _dedupe_temperatures(scT, scX)
dx_w = np.gradient(swX_d, swT_d) if swT_d.size > 1 else empty
dx_c = np.gradient(scX_d, scT_d) if scT_d.size > 1 else empty
swT_d = swT_d if swT_d.size > 1 else empty
scT_d = scT_d if scT_d.size > 1 else empty
fig2, ax2 = plt.subplots(**fig_kwargs)
ax2.plot(swT_d, dx_w, label="Heating – dχ/dT", linewidth=2, marker="o")
ax2.plot(scT_d, dx_c, label="Cooling – dχ/dT", linewidth=2, marker="o")
ax2.set_title(f"{title} – dχ/dT")
ax2.set_xlabel(f"Temperature ({_temp_unit_label(temp_unit)})")
ax2.set_ylabel("dχ/dT")
ax2.grid(True)
ax2.legend(loc="upper left")
figs.append(fig2)
if plot_inverse:
inv_w = _inverse_susceptibility(swX)
inv_c = _inverse_susceptibility(scX)
mask_w = np.isfinite(inv_w)
mask_c = np.isfinite(inv_c)
fig3, ax3 = plt.subplots(**fig_kwargs)
ax3.plot(np.array(swT)[mask_w], inv_w[mask_w], label="Heating – 1/χ", linewidth=2, marker="o")
ax3.plot(np.array(scT)[mask_c], inv_c[mask_c], label="Cooling – 1/χ", linewidth=2, marker="o")
ax3.set_title(f"{title} – 1/χ")
ax3.set_xlabel(f"Temperature ({_temp_unit_label(temp_unit)})")
ax3.set_ylabel("1/χ")
ax3.grid(True)
ax3.legend(loc="upper left")
figs.append(fig3)
plt.show()
if return_figure:
return tuple(figs)
return None
# Curie temperature estimation
# ------------------------------------------------------------------------------------------------------------------
# The estimators below implement the methods reviewed by Fabian et al. (2013,
# doi:10.1029/2012GC004440). Method applicability depends on the type of
# thermomagnetic data:
#
# * in-field magnetization M(T): the Curie temperature corresponds to the
# inflection point of M(T) (minimum of dM/dT); the classical
# maximum-curvature (second-derivative maximum) and two-tangent estimates
# coincide with each other and lie systematically above the inflection-point
# Tc (typically by 10-15 degrees C, increasing with applied field).
# * low-field susceptibility X(T): several mechanisms (para-effect, rotation
# against anisotropy, domain-wall motion, superparamagnetism) contribute
# near the ordering temperature; the Hopkinson peak marks blocking, not Tc,
# and the maximum-curvature and two-tangent constructions lack a rigorous
# physical basis (Petrovsky & Kapicka, 2006, doi:10.1029/2006JB004507) --
# both are robust, transition-based estimators but lie above the inflection
# point. The inverse-susceptibility
# (Curie-Weiss) extrapolation yields the paramagnetic Curie temperature
# theta, an upper bound on Tc.
#
# Systematic inter-method offsets of several to tens of degrees are expected
# and documented on synthetic titanomagnetites by Lattard et al. (2006,
# doi:10.1029/2006JB004591); comparing estimates from multiple methods is
# therefore diagnostic rather than redundant.
def _inverse_susceptibility(chi):
"""
Compute 1/chi with non-positive values masked to NaN.
Non-positive susceptibilities (possible after holder over-correction)
have no meaningful inverse for Curie-Weiss purposes; masking them here
keeps the displayed and fitted inverse-susceptibility curves consistent.
"""
chi = np.asarray(chi, dtype=float)
return np.divide(1.0, chi, out=np.full_like(chi, np.nan), where=chi > 0)
def _repeated_value_fraction(values):
"""
Fraction of points that repeat an identical neighbor.
Runs of identical values within a fitting window are the signature of a
resolution-limited (quantized) susceptibility tail or a flattened/altered
magnetization tail; either flattens a fitted slope and biases the
extrapolated Curie temperature. Returns a value in [0, 1].
"""
values = np.asarray(values, dtype=float)
if values.size < 2:
return 0.0
repeated = np.concatenate([[False], values[1:] == values[:-1]])
repeated[:-1] |= repeated[1:]
return float(repeated.mean())
def _dedupe_temperatures(T, y):
"""
Average y-values measured at duplicate temperatures.
Finite-difference derivatives require strictly increasing abscissae;
thermomagnetic instruments commonly log repeated temperatures while the
furnace stabilizes. Returns (unique ascending T, mean y per T).
"""
T = np.asarray(T, dtype=float)
y = np.asarray(y, dtype=float)
unique_T, inverse = np.unique(T, return_inverse=True)
if unique_T.size == T.size:
order = np.argsort(T, kind="stable")
return T[order], y[order]
sums = np.bincount(inverse, weights=y)
counts = np.bincount(inverse)
return unique_T, sums / counts
def _interpolated_zero_crossings(x, y):
"""
Find zero crossings of y(x) by linear interpolation.
Parameters
----------
x : numpy.ndarray
Monotonically ordered abscissa values.
y : numpy.ndarray
Ordinate values.
Returns
-------
numpy.ndarray
Interpolated x-positions where y changes sign (empty if none).
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
crossings = []
for i in range(y.size - 1):
y0, y1 = y[i], y[i + 1]
if not (np.isfinite(y0) and np.isfinite(y1)):
continue
if y0 == 0.0:
crossings.append(x[i])
elif y0 * y1 < 0.0:
crossings.append(x[i] - y0 * (x[i + 1] - x[i]) / (y1 - y0))
return np.array(crossings, dtype=float)
[docs]
def curie_derivative_estimates(T, y, t_range=None, smooth_window=0):
"""
Derivative-based Curie temperature estimates from one thermomagnetic branch.
Two estimates are returned:
* ``inflection_temp`` — the inflection point of the curve, located as the
zero crossing of the second derivative between its extrema (refined by
linear interpolation), with the minimum of the first derivative as
fallback. For in-field magnetization curves M(T), Landau theory places
the Curie temperature at this inflection point, independent of the
applied field (Fabian et al., 2013, doi:10.1029/2012GC004440).
* ``max_curvature_temp`` — the maximum of the second derivative
("maximum curvature" of the concave part of the heating curve). This is
the classical practical definition of Ade-Hall et al. (1965) that is
implemented in many software packages (including the legacy
``ipmag.curie``). On M(T) curves it lies systematically above the
inflection-point Tc (typically 10-15 degrees C) and shifts with the
strength of the applied field (Fabian et al., 2013). It is searched on
the concave shoulder above the steepest descent, consistent with this
definition.
Non-finite temperature or magnetization values are dropped before
differentiation, and the steepest descent is located over the interior of
the branch (the one-sided derivatives at the first and last points are the
most noise-prone). Both estimates are anchored to that steepest-descent
point, so isolated noise or structure in the flat tails does not capture
them.
Differentiation amplifies noise, so even a smoothed signal can yield a
ragged first derivative whose global minimum is a noise spike within a
broad, flat-bottomed transition rather than the true steepest descent.
Set ``smooth_window`` to smooth the first and second derivatives on the
same temperature scale used to smooth the signal (as the legacy
``ipmag.curie`` does), which locates the estimate at the center of the
transition instead of an arbitrary spike; ``curie_temperature_estimates``
passes its smoothing window through automatically. For multi-phase curves,
additionally use ``t_range`` to isolate the transition of interest, and
check the estimate against ``first_derivative_min_temp`` and the
``diagnostics`` arrays.
Parameters
----------
T : array-like
Temperatures, ascending (Celsius or Kelvin; the returned temperatures
are in the same unit as the input).
y : array-like
Magnetization or susceptibility values.
t_range : tuple of (float, float), optional
Restrict the analysis to temperatures within ``(t_min, t_max)``.
Useful for isolating one Curie transition in a multi-phase curve or
excluding low-temperature structure (e.g., a Hopkinson peak).
smooth_window : float, optional
Width, in the temperature units of ``T``, of a moving-average window
applied to the first and second derivatives before their extrema are
located (default 0, no derivative smoothing). Recommended for noisy
data; a good choice is the window used to smooth the signal. The
``diagnostics`` arrays reflect the smoothed derivatives actually used.
Returns
-------
dict
``inflection_temp``, ``max_curvature_temp``,
``first_derivative_min_temp`` (the discrete minimum of dy/dT),
``zero_crossing_temps`` (all interpolated zero crossings of the second
derivative between its extrema), and ``diagnostics`` with the arrays
``T``, ``dy_dT``, and ``d2y_dT2``.
"""
T = np.asarray(T, dtype=float)
y = np.asarray(y, dtype=float)
if t_range is not None:
mask = (T >= t_range[0]) & (T <= t_range[1])
T = T[mask]
y = y[mask]
finite = np.isfinite(T) & np.isfinite(y)
T = T[finite]
y = y[finite]
T, y = _dedupe_temperatures(T, y)
if T.size < 4:
nan = np.nan
return {
"inflection_temp": nan,
"max_curvature_temp": nan,
"first_derivative_min_temp": nan,
"zero_crossing_temps": np.array([]),
"diagnostics": {"T": T, "dy_dT": np.array([]), "d2y_dT2": np.array([])},
}
dy = np.gradient(y, T)
if smooth_window and smooth_window > 0:
# np.gradient's one-sided boundary values are unreliable; replace them
# before smoothing so edge padding cannot propagate an outlier into a
# spurious plateau that captures the steepest-descent search
dy[0], dy[-1] = dy[1], dy[-2]
_, dy = smooth_moving_average(T, dy, smooth_window)
d2y = np.gradient(dy, T)
if smooth_window and smooth_window > 0:
d2y[0], d2y[-1] = d2y[1], d2y[-2]
_, d2y = smooth_moving_average(T, d2y, smooth_window)
# locate the steepest descent over the interior only; np.gradient forms
# one-sided differences at the array boundaries, which are the most
# noise-prone points and can otherwise anchor the estimate to an edge
interior = np.arange(1, T.size - 1)
i_steep = int(interior[np.argmin(dy[interior])])
first_derivative_min_temp = T[i_steep]
# restrict the curvature analysis to the transition zone around the
# steepest descent: from where the descent last began (slope within 5
# percent of zero relative to the steepest slope) to where the curve first
# returns to baseline above it; outside this zone, noise in the flat
# segments dominates the second derivative of real data
slope_floor = 0.05 * np.abs(dy[i_steep])
near_zero = np.abs(dy) <= slope_floor
before = np.nonzero(near_zero[:i_steep])[0]
j_start = int(before[-1]) if before.size else 0
after = np.nonzero(near_zero[i_steep:])[0]
j_end = int(after[0]) + i_steep if after.size else T.size - 1
# the maximum-curvature estimate lies on the concave shoulder above the
# inflection (Ade-Hall et al., 1965; Fabian et al., 2013), so search the
# second derivative from the steepest descent upward; a noise feature on
# the low-temperature side of the transition cannot then capture it
upper_zone = slice(i_steep, j_end + 1)
max_curvature_temp = T[upper_zone][np.argmax(d2y[upper_zone])]
# the inflection is the zero of the second derivative at the steepest
# descent; take the zone crossing nearest that point so the estimate stays
# anchored to the transition rather than to tail structure
zero_crossing_temps = _interpolated_zero_crossings(
T[j_start:j_end + 1], d2y[j_start:j_end + 1]
)
if zero_crossing_temps.size:
inflection_temp = zero_crossing_temps[
np.argmin(np.abs(zero_crossing_temps - first_derivative_min_temp))
]
else:
inflection_temp = first_derivative_min_temp
return {
"inflection_temp": float(inflection_temp),
"max_curvature_temp": float(max_curvature_temp),
"first_derivative_min_temp": float(first_derivative_min_temp),
"zero_crossing_temps": zero_crossing_temps,
"diagnostics": {"T": T, "dy_dT": dy, "d2y_dT2": d2y},
}
[docs]
def curie_two_tangent(T, y, lower_range=None, upper_range=None, min_points=3):
"""
Two-tangent (intersecting tangents) Curie temperature estimate.
Straight lines are fit to a segment of the steeply descending limb below
the Curie temperature and to the near-flat baseline above it; the
temperature of their intersection is returned. The construction follows
Gromme et al. (1969, doi:10.1029/JB074i022p05277), who applied it to
strong-field J-T curves.
Applicability and bias: the method is intended for magnetization curves
M(T). Even there it coincides with the maximum-curvature estimate and
therefore lies systematically above the inflection-point Curie temperature
(Fabian et al., 2013, doi:10.1029/2012GC004440). Applied to low-field
susceptibility X(T) it lacks a rigorous physical basis and can overestimate
Tc (Petrovsky & Kapicka, 2006, doi:10.1029/2006JB004507); it remains a
robust, transition-based estimator useful for mineral identification and
for comparison with legacy results.
Parameters
----------
T : array-like
Temperatures, ascending (Celsius or Kelvin; the returned intersection
temperature is in the same unit as the input).
y : array-like
Magnetization (preferred) or susceptibility values.
lower_range : tuple of (float, float), optional
Temperature interval for the descending-limb tangent. If None, the
contiguous region around the steepest descent where the slope is at
least half the steepest slope is used.
upper_range : tuple of (float, float), optional
Temperature interval for the baseline tangent. If None, points above
the steepest descent where the slope has decayed to within 5 percent
of the steepest slope are used (falling back to the uppermost decile
of points).
min_points : int, optional
Minimum number of points required in each tangent segment (default 3).
Returns
-------
dict
``curie_temp`` (intersection temperature, NaN if the tangents are
parallel or a segment has too few points), ``params`` with the two
(slope, intercept) pairs, the temperature ranges actually used, point
counts, and ``reliable`` (False when no near-flat baseline was found
above the transition and the upper tangent fell back to the uppermost
points, i.e. the curve may end below Tc), and ``diagnostics`` with the
segment masks for plotting.
"""
T = np.asarray(T, dtype=float)
y = np.asarray(y, dtype=float)
finite = np.isfinite(T) & np.isfinite(y)
T, y = _dedupe_temperatures(T[finite], y[finite])
result = {
"curie_temp": np.nan,
"params": {"reliable": False},
"diagnostics": {},
}
if T.size < 2 * min_points:
result["params"]["note"] = "insufficient points for two-tangent fit"
return result
dy = np.gradient(y, T)
i_steep = int(np.argmin(dy))
steepest = dy[i_steep]
if lower_range is None:
# contiguous run around the steepest descent where the slope is at
# least half the steepest slope
steep_enough = dy <= 0.5 * steepest
i0 = i_steep
while i0 > 0 and steep_enough[i0 - 1]:
i0 -= 1
i1 = i_steep
while i1 < T.size - 1 and steep_enough[i1 + 1]:
i1 += 1
lower_mask = np.zeros(T.size, dtype=bool)
lower_mask[i0:i1 + 1] = True
lower_range = (float(T[i0]), float(T[i1]))
else:
lower_mask = (T >= lower_range[0]) & (T <= lower_range[1])
baseline_fallback = False
if upper_range is None:
baseline = (np.arange(T.size) > i_steep) & (np.abs(dy) <= 0.05 * np.abs(steepest))
if baseline.sum() < min_points:
# no near-flat baseline exists above the steepest descent: the
# curve may not reach above the Curie temperature, so the
# uppermost points used here can still be on the descending limb
baseline_fallback = True
n_tail = max(min_points, T.size // 10)
baseline = np.zeros(T.size, dtype=bool)
baseline[-n_tail:] = True
upper_mask = baseline
upper_range = (float(T[upper_mask].min()), float(T[upper_mask].max()))
else:
upper_mask = (T >= upper_range[0]) & (T <= upper_range[1])
if lower_mask.sum() < min_points or upper_mask.sum() < min_points:
result["params"]["note"] = "insufficient points in a tangent segment"
return result
lower_slope, lower_intercept = np.polyfit(T[lower_mask], y[lower_mask], 1)
upper_slope, upper_intercept = np.polyfit(T[upper_mask], y[upper_mask], 1)
if np.isclose(lower_slope, upper_slope):
result["params"]["note"] = "tangents are parallel"
return result
curie_temp = (upper_intercept - lower_intercept) / (lower_slope - upper_slope)
result["curie_temp"] = float(curie_temp)
result["params"] = {
"lower_slope": float(lower_slope),
"lower_intercept": float(lower_intercept),
"upper_slope": float(upper_slope),
"upper_intercept": float(upper_intercept),
"lower_range": lower_range,
"upper_range": upper_range,
"n_lower": int(lower_mask.sum()),
"n_upper": int(upper_mask.sum()),
}
if baseline_fallback:
result["params"]["note"] = (
"no near-flat baseline found above the steepest descent; the "
"upper tangent was fit to the uppermost points, which may still "
"be on the descending limb if the curve ends below the Curie "
"temperature — verify or set upper_range explicitly"
)
result["params"]["reliable"] = not baseline_fallback
result["diagnostics"] = {
"T": T,
"y": y,
"lower_mask": lower_mask,
"upper_mask": upper_mask,
}
return result
[docs]
def curie_inverse_susceptibility(T, chi, fit_range=None, min_points=5,
min_chi=None):
"""
Curie-Weiss (inverse susceptibility) estimate of the ordering temperature.
Above the Curie temperature the susceptibility of the paramagnetic phase
follows the Curie-Weiss law chi = C / (T - theta), so 1/chi is linear in T
and extrapolates to zero at the paramagnetic Curie temperature theta. A
straight line is fit to 1/chi within ``fit_range`` and
``curie_temp = -intercept/slope`` is returned.
This is the recommended quantitative approach for low-field
susceptibility X(T) curves (Petrovsky & Kapicka, 2006,
doi:10.1029/2006JB004507). Two caveats apply: (1) theta is an upper bound
on the Curie temperature (theta >= Tc, with the difference depending on
the strength of magnetic interactions); (2) the estimate is sensitive to
the choice of fitting window — the fit must be restricted to temperatures
where the signal is fully paramagnetic, above the steep decrease, and
where the (holder-corrected) susceptibility is still resolved above the
instrument's measurement resolution. Well above the transition the
paramagnetic signal commonly becomes so weak that successive readings
repeat identical values and 1/chi shows flat plateaus; including
such quantized points flattens the fitted slope and biases theta low —
a theta below an independently estimated Tc (e.g., the inflection point)
is a red flag for this. The function detects repeated quantized values in
the fitting window and attaches a warning; use ``min_chi`` or tighten
``fit_range`` to exclude resolution-limited points. Report the fitting
window with the estimate.
Parameters
----------
T : array-like
Temperatures, ascending (Celsius or Kelvin; theta is returned in the
same unit as the input).
chi : array-like
Susceptibility values (holder-corrected).
fit_range : tuple of (float, float), optional
Temperature interval for the linear fit of 1/chi. If None, the upper
20 percent of the temperature span is used — a starting guess only;
inspect the fit and set the window explicitly for reported values.
min_points : int, optional
Minimum number of usable points in the window (default 5).
min_chi : float, optional
Exclude points with susceptibility below this value from the fit
(same units as ``chi``). Useful for screening out
resolution-limited values in the high-temperature tail.
Returns
-------
dict
``curie_temp`` (theta), ``curie_temp_stderr`` (1-sigma from the fit
covariance), ``params`` with slope, intercept, ``r_squared``,
``curie_constant`` (1/slope), ``n_points``, ``fit_range``, and a
``warning`` when the fitted points are dominated by repeated
(quantized) susceptibility values, and ``diagnostics`` with the
1/chi arrays and fitted line.
"""
T = np.asarray(T, dtype=float)
chi = np.asarray(chi, dtype=float)
# a straight-line covariance fit needs more than two points; below three
# np.polyfit(..., cov=True) raises instead of returning gracefully
min_points = max(int(min_points), 3)
if fit_range is None:
t_span = T.max() - T.min()
fit_range = (T.min() + 0.8 * t_span, T.max())
mask = (
(T >= fit_range[0])
& (T <= fit_range[1])
& np.isfinite(chi)
& (chi > 0)
)
if min_chi is not None:
mask &= chi >= min_chi
result = {
"curie_temp": np.nan,
"curie_temp_stderr": np.nan,
"params": {"fit_range": (float(fit_range[0]), float(fit_range[1])),
"n_points": int(mask.sum())},
"diagnostics": {},
}
if mask.sum() < min_points:
result["params"]["note"] = (
f"only {int(mask.sum())} usable points in fit_range; "
f"at least {min_points} required"
)
return result
T_fit = T[mask]
inv_chi = 1.0 / chi[mask]
(slope, intercept), cov = np.polyfit(T_fit, inv_chi, 1, cov=True)
if slope <= 0:
# 1/chi must increase with T above the transition; a non-positive
# slope means the window is not sampling a paramagnetic Curie-Weiss
# tail (wrong window, over-subtracted holder, or altered signal)
result["params"]["note"] = (
"non-positive slope in the 1/chi fit; the fitting window does "
"not sample a paramagnetic Curie-Weiss tail — adjust fit_range"
)
return result
theta = -intercept / slope
# 1-sigma uncertainty on theta from the fit covariance
d_theta_d_slope = intercept / slope**2
d_theta_d_intercept = -1.0 / slope
var_theta = (
d_theta_d_slope**2 * cov[0, 0]
+ d_theta_d_intercept**2 * cov[1, 1]
+ 2.0 * d_theta_d_slope * d_theta_d_intercept * cov[0, 1]
)
theta_stderr = float(np.sqrt(var_theta)) if var_theta >= 0 else np.nan
predicted = slope * T_fit + intercept
ss_res = float(np.sum((inv_chi - predicted) ** 2))
ss_tot = float(np.sum((inv_chi - inv_chi.mean()) ** 2))
r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else np.nan
result["curie_temp"] = float(theta)
result["curie_temp_stderr"] = theta_stderr
result["params"].update({
"slope": float(slope),
"intercept": float(intercept),
"r_squared": float(r_squared),
"curie_constant": float(1.0 / slope),
})
# repeated identical susceptibility values in the window are the
# signature of a resolution-limited (quantized) tail, which flattens the
# fitted slope and biases theta low
quantized_fraction = _repeated_value_fraction(chi[mask])
if quantized_fraction > 1.0 / 3.0:
result["params"]["warning"] = (
f"{quantized_fraction:.0%} of the fitted points repeat identical "
"susceptibility values (weak, resolution-limited signal); theta "
"is likely biased low — tighten fit_range or set min_chi"
)
result["diagnostics"] = {
"T": T,
"inv_chi": _inverse_susceptibility(chi),
"T_fit": T_fit,
"inv_chi_fit": inv_chi,
"line_T": np.array([theta, T_fit.max()]),
"line_inv_chi": np.array([0.0, slope * T_fit.max() + intercept]),
}
return result
[docs]
def landau_magnetization(tau, h):
"""
Reduced magnetization from the Landau equation of state with field term.
Solves ``m**3 + tau*m = h`` for the physical (largest real) root, where
``tau = (T - Tc)/Tc`` is the reduced temperature and ``h`` is the reduced
field (Fabian et al., 2013, doi:10.1029/2012GC004440, eqs. 3-5). For
``h = 0`` this reduces to ``m = sqrt(-tau)`` below the Curie temperature
and ``m = 0`` above it; for ``h > 0`` the transition is rounded and a
field-induced tail persists above Tc.
Parameters
----------
tau : array-like
Reduced temperatures (T - Tc)/Tc; requires absolute temperatures
(Kelvin) in the ratio.
h : float
Reduced field, >= 0.
Returns
-------
numpy.ndarray
Reduced magnetization m at each tau.
"""
tau = np.atleast_1d(np.asarray(tau, dtype=float))
p = tau
q = -float(h)
m = np.empty_like(p)
disc = q**2 / 4.0 + p**3 / 27.0
# one real root (Cardano); q <= 0 so -q/2 >= 0 and this root is >= 0
single = disc >= 0
if np.any(single):
sq = np.sqrt(disc[single])
m[single] = np.cbrt(-q / 2.0 + sq) + np.cbrt(-q / 2.0 - sq)
# three real roots (trigonometric form); the k=0 root is the largest
triple = ~single
if np.any(triple):
pt = p[triple]
arg = (3.0 * q) / (2.0 * pt) * np.sqrt(-3.0 / pt)
arg = np.clip(arg, -1.0, 1.0)
m[triple] = 2.0 * np.sqrt(-pt / 3.0) * np.cos(np.arccos(arg) / 3.0)
return m
[docs]
def curie_landau_fit(T, M, fit_range=None, temp_unit="C", tc_bounds=None,
n_grid=40):
"""
Fit the in-field Landau equation of state to a magnetization curve M(T).
The model is ``M(T) = M0 * m(tau; h)`` where m is the physical root of the
Landau equation of state ``m**3 + tau*m = h`` with ``tau = (T - Tc)/Tc``
in absolute temperature (Fabian et al., 2013, doi:10.1029/2012GC004440,
eqs. 3-5). The reduced field h rounds the transition and produces the
field-induced tail above Tc, so the fit uses the full curve without an
ad-hoc baseline. In the ``h = 0`` limit the model is
``M = M0*sqrt(1 - T/Tc)`` below Tc — the mean-field form underlying the
extrapolation method of Moskowitz (1981,
doi:10.1016/0012-821X(81)90028-5).
The fit provides the physically grounded Curie temperature (at the
inflection point of the in-field curve), with a formal 1-sigma
uncertainty. When ``fit_range`` excludes the transition (all data below
Tc), the fit operates in Moskowitz-style extrapolation mode: this is the
only option for runs that end below Tc, but the reliability of the
extrapolated Tc decays rapidly with the distance between the highest
measured temperature and Tc, and the formal uncertainty then
underestimates the true (model-dependence dominated) uncertainty.
Caveats: the model describes a single ferromagnetic phase near its
transition; admixed paramagnetic signal, multiple phases, or alteration
during heating violate it — restrict ``fit_range`` to isolate one
transition. Applied to low-field susceptibility the model does not
describe the dominant susceptibility mechanisms (see Fabian et al., 2013)
and results should be treated as qualitative.
Parameters
----------
T : array-like
Temperatures, ascending, in ``temp_unit``.
M : array-like
Magnetization values.
fit_range : tuple of (float, float), optional
Temperature interval (in ``temp_unit``) used in the fit. Default uses
all points.
temp_unit : {'C', 'K'}, optional
Unit of the input temperatures (default 'C'). The reduced temperature
is always formed in Kelvin internally; results are returned in
``temp_unit``.
tc_bounds : tuple of (float, float), optional
Bounds for Tc in Kelvin (default: (min(T)+1 K, 2000 K)). Tighten for
extrapolation fits when independent constraints exist.
n_grid : int, optional
Number of Tc values in the coarse initialization grid (default 40).
Returns
-------
dict
``curie_temp`` and ``curie_temp_stderr`` in ``temp_unit``, ``params``
with ``M0``, ``h``, ``rss``, ``n_points``, ``fit_range``,
``tc_bounds``, and ``extrapolation`` (True when the highest fitted
temperature is below the fitted Tc), and ``diagnostics`` with the
fitted data and a dense model curve for plotting.
"""
T = np.asarray(T, dtype=float)
M = np.asarray(M, dtype=float)
finite = np.isfinite(T) & np.isfinite(M)
T = T[finite]
M = M[finite]
T_K = convert_temperature(T, temp_unit, "K")
if fit_range is not None:
fit_mask = (T >= fit_range[0]) & (T <= fit_range[1])
else:
fit_mask = np.ones(T.size, dtype=bool)
fit_range = (float(T.min()), float(T.max())) if T.size else (np.nan, np.nan)
T_fit = T_K[fit_mask]
M_fit = M[fit_mask]
result = {
"curie_temp": np.nan,
"curie_temp_stderr": np.nan,
"params": {"fit_range": (float(fit_range[0]), float(fit_range[1])),
"n_points": int(T_fit.size)},
"diagnostics": {},
}
if T_fit.size < 5:
result["params"]["note"] = "insufficient points for Landau fit"
return result
if tc_bounds is None:
tc_bounds = (float(T_fit.min()) + 1.0, 2000.0)
m_scale = float(np.max(np.abs(M_fit)))
if m_scale == 0:
result["params"]["note"] = "magnetization is identically zero"
return result
M_norm = M_fit / m_scale
h_init = 1e-3
def coarse_rss(tc):
m = landau_magnetization((T_fit - tc) / tc, h_init)
denom = float(np.sum(m**2))
if denom == 0:
return np.inf, 0.0
m0 = float(np.sum(m * M_norm)) / denom
return float(np.sum((M_norm - m0 * m) ** 2)), m0
tc_grid = np.linspace(tc_bounds[0], tc_bounds[1], n_grid)
grid_results = [coarse_rss(tc) for tc in tc_grid]
best_i = int(np.argmin([r[0] for r in grid_results]))
tc0 = float(tc_grid[best_i])
m00 = max(grid_results[best_i][1], 1e-6)
def residuals(params):
m0, tc, h = params
m = landau_magnetization((T_fit - tc) / tc, h)
return m0 * m - M_norm
fit = least_squares(
residuals,
x0=[m00, tc0, h_init],
bounds=([0.0, tc_bounds[0], 0.0], [np.inf, tc_bounds[1], 10.0]),
)
m0, tc_K, h = fit.x
n, n_params = T_fit.size, 3
rss = 2.0 * fit.cost # least_squares cost is 0.5 * sum(residuals**2)
tc_stderr_K = np.nan
if n > n_params:
try:
cov = np.linalg.pinv(fit.jac.T @ fit.jac) * rss / (n - n_params)
tc_stderr_K = float(np.sqrt(cov[1, 1]))
except np.linalg.LinAlgError:
pass
curie_temp = float(convert_temperature(np.array([tc_K]), "K", temp_unit)[0])
# dense model curve across the data range, extended past Tc for
# extrapolation fits
T_dense_K = np.linspace(T_K.min(), max(T_K.max(), tc_K * 1.05), 500)
M_dense = m0 * m_scale * landau_magnetization((T_dense_K - tc_K) / tc_K, h)
result["curie_temp"] = curie_temp
result["curie_temp_stderr"] = tc_stderr_K # kelvin- and celsius-degree intervals are equal
result["params"].update({
"M0": float(m0 * m_scale),
"h": float(h),
"rss": float(rss * m_scale**2),
"tc_bounds": (float(tc_bounds[0]), float(tc_bounds[1])),
"extrapolation": bool(T_fit.max() < tc_K),
})
result["diagnostics"] = {
"T": T,
"M": M,
"T_fit": convert_temperature(T_fit, "K", temp_unit),
"M_fit": M_fit,
"model_T": convert_temperature(T_dense_K, "K", temp_unit),
"model_M": M_dense,
}
return result
_CURIE_CHI_METHOD_CAVEATS = {
"max_curvature": (
"maximum-curvature estimate on susceptibility lacks a rigorous "
"physical basis and lies above the inflection-point Tc; it "
"coincides with the two-tangent estimate and can overestimate Tc "
"(Fabian et al., 2013; Petrovsky & Kapicka, 2006)"
),
"two_tangent": (
"two-tangent intersection on susceptibility lacks a rigorous "
"physical basis and lies above the inflection-point Tc; it "
"coincides with the maximum-curvature estimate and can overestimate "
"Tc (Fabian et al., 2013; Petrovsky & Kapicka, 2006)"
),
"landau": (
"the Landau M(T) model does not describe the mechanisms that "
"dominate low-field susceptibility near Tc (Fabian et al., 2013); "
"treat as qualitative"
),
}
_CURIE_METHOD_NOTES = {
"inflection": (
"inflection point (minimum of dy/dT); for in-field M(T) this is the "
"Curie temperature (Fabian et al., 2013)"
),
"max_curvature": (
"maximum of d2y/dT2 (Ade-Hall et al., 1965; legacy ipmag.curie); "
"lies systematically above the inflection-point Tc on M(T) curves "
"(Fabian et al., 2013)"
),
"two_tangent": (
"intersecting tangents (Gromme et al., 1969); coincides with the "
"max-curvature estimate and overestimates Tc on M(T) (Fabian et "
"al., 2013)"
),
"inverse_susceptibility": (
"Curie-Weiss extrapolation of 1/chi; yields the paramagnetic Curie "
"temperature theta >= Tc (Petrovsky & Kapicka, 2006)"
),
"landau": (
"fit of the in-field Landau equation of state (Fabian et al., 2013); "
"Tc at the inflection point, with formal uncertainty"
),
"ms_squared_extrapolation": (
"Ms^2 extrapolation (Moskowitz, 1981); mean-field extrapolation of "
"Ms^2 to zero for M(T) curves that end below Tc, e.g. titanomaghemites "
"that invert on heating; recovers only the highest-Tc phase"
),
}
def _temp_unit_label(temp_unit):
"""
Axis/report label for a temperature unit: '°C' for Celsius, 'K' for
kelvin (which takes no degree sign).
"""
return "K" if temp_unit == "K" else f"°{temp_unit}"
def _branch_shade(color, branch):
"""
Branch-dependent shade of a method color: the heating branch keeps the
base color and the cooling branch is lightened (blended halfway toward
white), so heating/cooling artists drawn in the same method color remain
distinguishable even where solid and dashed lines nearly coincide.
"""
if branch != "cooling":
return color
r, g, b = colors.to_rgb(color)
return (r + (1.0 - r) * 0.5, g + (1.0 - g) * 0.5, b + (1.0 - b) * 0.5)
def _resolve_thermomag_data_type(data_type, magnetic_column):
"""
Resolve whether a magnetic column holds susceptibility or magnetization.
Parameters
----------
data_type : {'susceptibility', 'magnetization', None}
Explicit data type; when None it is inferred from whether
``magnetic_column`` contains 'susc' or 'chi'.
magnetic_column : str
Name of the magnetic data column.
Returns
-------
str
'susceptibility' or 'magnetization'.
"""
if data_type is None:
column = magnetic_column.lower()
return ("susceptibility" if ("susc" in column or "chi" in column)
else "magnetization")
if data_type not in ("susceptibility", "magnetization"):
raise ValueError(
f"data_type must be 'susceptibility', 'magnetization', or None "
f"(got '{data_type}')"
)
return data_type
[docs]
def curie_temperature_estimates(
experiment,
methods=None,
temperature_column="meas_temp",
magnetic_column="susc_chi_mass",
temp_unit="C",
input_unit="K",
smooth_window=0,
remove_holder=True,
branches=("heating", "cooling"),
method_kwargs=None,
print_estimates=False,
data_type=None,
branch_data=None,
return_method_results=False,
):
"""
Estimate the Curie temperature of a thermomagnetic experiment with
multiple methods and return a tidy comparison table.
The experiment is preprocessed with ``prepare_thermomag_branches`` and
each requested method is applied to each requested branch. Systematic
differences between the estimates are expected and diagnostic: see the
module notes above and Lattard et al. (2006, doi:10.1029/2006JB004591)
for the magnitude of inter-method offsets on synthetic titanomagnetites.
The available methods are:
* ``'inflection'`` — inflection point (``curie_derivative_estimates``);
the recommended estimator for in-field M(T).
* ``'max_curvature'`` — second-derivative maximum
(``curie_derivative_estimates``); classical, biased high on M(T).
* ``'two_tangent'`` — intersecting tangents (``curie_two_tangent``);
for M(T), discouraged on susceptibility.
* ``'inverse_susceptibility'`` — Curie-Weiss extrapolation
(``curie_inverse_susceptibility``); for susceptibility, heating branch.
* ``'landau'`` — in-field Landau equation-of-state fit
(``curie_landau_fit``); for M(T), supports extrapolation from runs
that end below Tc.
* ``'ms_squared_extrapolation'`` — mean-field extrapolation of Ms^2 to
zero (``curie_Ms_squared_extrapolation``; Moskowitz, 1981); for M(T)
curves that end below Tc. Not selectable for susceptibility data.
Parameters
----------
experiment : pandas.DataFrame
MagIC-formatted experiment DataFrame.
methods : sequence of str, optional
Methods to apply. Default depends on the data type (see
``data_type``): susceptibility data use
``('inflection', 'max_curvature', 'inverse_susceptibility')``;
magnetization data use
``('inflection', 'max_curvature', 'two_tangent', 'landau')``.
temperature_column : str, optional
Name of the temperature column (default 'meas_temp').
magnetic_column : str, optional
Name of the magnetization/susceptibility column
(default 'susc_chi_mass').
temp_unit : {'C', 'K'}, optional
Unit for reported temperatures (default 'C').
input_unit : {'K', 'C'}, optional
Unit of the temperatures in ``experiment`` (default 'K', the MagIC
convention).
smooth_window : float, optional
Smoothing window width in ``temp_unit`` (default 0, no smoothing).
Derivative-based methods generally require smoothing of noisy data;
see ``optimize_moving_average_window``.
remove_holder : bool, optional
Subtract the per-branch minimum (default True). Disable for runs that
end below the Curie temperature.
branches : sequence of str, optional
Branches to analyze, from ('heating', 'cooling').
method_kwargs : dict, optional
Per-method keyword arguments, e.g.
``{'inverse_susceptibility': {'fit_range': (620, 700)},
'landau': {'fit_range': (300, 650)}}``. The keys ``'inflection'``
and ``'max_curvature'`` (or the shared key ``'derivative'``) forward
options such as ``t_range`` to ``curie_derivative_estimates``.
Unknown keys raise a ValueError rather than being silently ignored.
print_estimates : bool, optional
Print a one-line summary per estimate (default False).
data_type : {'susceptibility', 'magnetization', None}, optional
Explicit data type, controlling the default method set and the
caveat notes. When None (default), inferred from whether
``magnetic_column`` contains 'susc' or 'chi'.
branch_data : dict, optional
Precomputed output of ``prepare_thermomag_branches`` (with matching
preprocessing arguments). When provided, preprocessing is skipped —
used by ``plot_curie_estimates`` to avoid recomputation.
return_method_results : bool, optional
If True, also return a dict keyed by ``(branch, method)`` holding
each estimator's full result (including ``diagnostics``), for
plotting or further analysis (default False).
Returns
-------
pandas.DataFrame or (pandas.DataFrame, dict)
One row per (branch, method) with columns ``specimen``,
``experiment``, ``branch``, ``method``, ``curie_temp``,
``curie_temp_stderr``, ``temp_unit``, ``params`` (dict of
method-specific parameters), and ``notes``. With
``return_method_results=True``, additionally the per-(branch,
method) result dicts.
"""
if method_kwargs is None:
method_kwargs = {}
data_type = _resolve_thermomag_data_type(data_type, magnetic_column)
is_susceptibility = data_type == "susceptibility"
if methods is None:
if is_susceptibility:
methods = ("inflection", "max_curvature", "inverse_susceptibility")
else:
methods = ("inflection", "max_curvature", "two_tangent", "landau")
known = {"inflection", "max_curvature", "two_tangent",
"inverse_susceptibility", "landau", "ms_squared_extrapolation"}
unknown = set(methods) - known
if unknown:
raise ValueError(f"unknown method(s): {sorted(unknown)}; "
f"choose from {sorted(known)}")
if is_susceptibility and "ms_squared_extrapolation" in methods:
raise ValueError(
"ms_squared_extrapolation is a magnetization (Ms-T) method and "
"does not apply to susceptibility data; use data_type="
"'magnetization' for Ms(T) experiments"
)
unknown_kwargs = set(method_kwargs) - known - {"derivative"}
if unknown_kwargs:
raise ValueError(
f"unknown method_kwargs key(s): {sorted(unknown_kwargs)}; "
f"choose from {sorted(known | {'derivative'})}"
)
if isinstance(branches, str):
branches = (branches,)
unknown_branches = set(branches) - {"heating", "cooling"}
if unknown_branches:
raise ValueError(
f"unknown branch(es): {sorted(unknown_branches)}; "
f"choose from ['cooling', 'heating']"
)
derivative_kwargs = {
"smooth_window": smooth_window,
**method_kwargs.get("derivative", {}),
**method_kwargs.get("inflection", {}),
**method_kwargs.get("max_curvature", {}),
}
if branch_data is None:
branch_data = prepare_thermomag_branches(
experiment,
temperature_column=temperature_column,
magnetic_column=magnetic_column,
temp_unit=temp_unit,
input_unit=input_unit,
smooth_window=smooth_window,
remove_holder=remove_holder,
)
specimen = (experiment["specimen"].unique()[0]
if "specimen" in experiment else "")
experiment_name = (experiment["experiment"].unique()[0]
if "experiment" in experiment else "")
rows = []
method_results = {}
for branch in branches:
data = branch_data.get(branch)
if data is None:
continue
T, y = data["T"], data["y"]
derivative_result = None
if "inflection" in methods or "max_curvature" in methods:
derivative_result = curie_derivative_estimates(
T, y, **derivative_kwargs
)
for method in methods:
curie_temp = np.nan
stderr = np.nan
params = {}
if method == "inflection":
curie_temp = derivative_result["inflection_temp"]
params = {"first_derivative_min_temp":
derivative_result["first_derivative_min_temp"]}
method_results[(branch, method)] = derivative_result
elif method == "max_curvature":
curie_temp = derivative_result["max_curvature_temp"]
method_results[(branch, method)] = derivative_result
elif method == "two_tangent":
r = curie_two_tangent(T, y, **method_kwargs.get("two_tangent", {}))
curie_temp, params = r["curie_temp"], r["params"]
method_results[(branch, method)] = r
elif method == "inverse_susceptibility":
r = curie_inverse_susceptibility(
T, y, **method_kwargs.get("inverse_susceptibility", {})
)
curie_temp, stderr, params = (r["curie_temp"],
r["curie_temp_stderr"],
r["params"])
method_results[(branch, method)] = r
elif method == "landau":
landau_kwargs = dict(method_kwargs.get("landau", {}))
landau_kwargs.setdefault("temp_unit", temp_unit)
r = curie_landau_fit(T, y, **landau_kwargs)
curie_temp, stderr, params = (r["curie_temp"],
r["curie_temp_stderr"],
r["params"])
method_results[(branch, method)] = r
elif method == "ms_squared_extrapolation":
r = curie_Ms_squared_extrapolation(
T, y, **method_kwargs.get("ms_squared_extrapolation", {})
)
curie_temp, stderr, params = (r["curie_temp"],
r["curie_temp_stderr"],
r["params"])
method_results[(branch, method)] = r
notes = _CURIE_METHOD_NOTES[method]
if is_susceptibility and method in _CURIE_CHI_METHOD_CAVEATS:
notes = _CURIE_CHI_METHOD_CAVEATS[method]
rows.append({
"specimen": specimen,
"experiment": experiment_name,
"branch": branch,
"method": method,
"curie_temp": curie_temp,
"curie_temp_stderr": stderr,
"temp_unit": temp_unit,
"params": params,
"notes": notes,
})
if print_estimates and np.isfinite(curie_temp):
stderr_text = (f" ± {stderr:.1f}" if np.isfinite(stderr) else "")
print(f"{branch:<8} {method:<24} "
f"Tc = {curie_temp:.1f}{stderr_text} "
f"{_temp_unit_label(temp_unit)}")
estimates = pd.DataFrame(rows, columns=[
"specimen", "experiment", "branch", "method", "curie_temp",
"curie_temp_stderr", "temp_unit", "params", "notes",
])
if return_method_results:
return estimates, method_results
return estimates
# Okabe & Ito (2008) colorblind-safe colors used to distinguish Curie
# temperature estimation methods in plots
_CURIE_METHOD_COLORS = {
"inflection": "#0072B2", # blue
"max_curvature": "#E69F00", # orange
"two_tangent": "#009E73", # green
"inverse_susceptibility": "#D55E00", # vermillion
"landau": "#CC79A7", # purple
}
def _autoscale_y_to_xlim(ax, xlim, margin=0.05):
"""
Rescale the y axis of ``ax`` to the plotted data falling within ``xlim``.
Vertical guide lines drawn with ``axvline`` live in blended (data x,
axes y) coordinates and are excluded, so only curves and scatter points
contribute to the limits.
Parameters
----------
ax : matplotlib.axes.Axes
Axis to rescale.
xlim : tuple of float
(xmin, xmax) window over which to gather y values.
margin : float, optional
Fractional padding added above and below the data (default 0.05).
"""
lo, hi = sorted(xlim)
bounds = []
for line in ax.get_lines():
if line.get_transform() is not ax.transData:
continue
x = np.asarray(line.get_xdata(), dtype=float)
y = np.asarray(line.get_ydata(), dtype=float)
in_window = (x >= lo) & (x <= hi) & np.isfinite(y)
if in_window.any():
bounds.append((y[in_window].min(), y[in_window].max()))
for collection in ax.collections:
offsets = np.asarray(collection.get_offsets(), dtype=float)
if offsets.ndim == 2 and offsets.shape[0]:
x, y = offsets[:, 0], offsets[:, 1]
in_window = (x >= lo) & (x <= hi) & np.isfinite(y)
if in_window.any():
bounds.append((y[in_window].min(), y[in_window].max()))
if not bounds:
return
ymin = min(b[0] for b in bounds)
ymax = max(b[1] for b in bounds)
pad = margin * (ymax - ymin)
if pad == 0:
pad = margin * abs(ymax) if ymax != 0 else margin
ax.set_ylim(ymin - pad, ymax + pad)
[docs]
def plot_curie_estimates(
experiment,
methods=None,
temperature_column="meas_temp",
magnetic_column="susc_chi_mass",
temp_unit="C",
input_unit="K",
smooth_window=0,
remove_holder=True,
branches=("heating", "cooling"),
method_kwargs=None,
figsize=(10, 10),
legend_loc="lower left",
xlim=None,
ylim=None,
return_figure=False,
save_path=None,
data_type=None,
):
"""
Plot a thermomagnetic curve with Curie temperature estimates from
multiple methods overlain.
Produces a static matplotlib figure with up to three stacked panels:
(a) the (holder-corrected) curve per branch with a vertical line at each
method's estimate, the two-tangent construction, and the Landau model
curve where those methods are requested; (b) the first and second
derivatives with the inflection point and curvature maximum marked; and
(c) 1/chi with the Curie-Weiss fit when ``'inverse_susceptibility'`` is
requested. Method colors follow the colorblind-safe palette of Okabe &
Ito (2008); heating estimates are drawn with solid lines and cooling
estimates with dashed lines.
Parameters mirror ``curie_temperature_estimates``; see that function for
the estimation details and method caveats.
Parameters
----------
experiment : pandas.DataFrame
MagIC-formatted experiment DataFrame.
methods : sequence of str, optional
Methods to display (default as in ``curie_temperature_estimates``).
temperature_column, magnetic_column, temp_unit, input_unit,
smooth_window, remove_holder, branches, method_kwargs, data_type :
As in ``curie_temperature_estimates``.
figsize : tuple, optional
Figure size in inches (default (10, 10)).
legend_loc : str, optional
Legend location for the main panel, passed to
``matplotlib.axes.Axes.legend`` (default 'lower left').
xlim : tuple of float, optional
Temperature-axis limits ``(tmin, tmax)`` in ``temp_unit``, applied
to all panels (they share the x axis). Each panel's y axis is then
autoscaled to the data within the window. Useful for zooming in on
a transition temperature.
ylim : tuple of float, optional
y-axis limits for the main panel only; overrides the ``xlim``
autoscaling there.
return_figure : bool, optional
If True, return ``(fig, axes)`` (default False).
save_path : str, optional
If given, save the figure to this path.
Returns
-------
(matplotlib.figure.Figure, numpy.ndarray of Axes) or None
"""
if method_kwargs is None:
method_kwargs = {}
data_type = _resolve_thermomag_data_type(data_type, magnetic_column)
is_susceptibility = data_type == "susceptibility"
if methods is None:
if is_susceptibility:
methods = ("inflection", "max_curvature", "inverse_susceptibility")
else:
methods = ("inflection", "max_curvature", "two_tangent", "landau")
branch_data = prepare_thermomag_branches(
experiment,
temperature_column=temperature_column,
magnetic_column=magnetic_column,
temp_unit=temp_unit,
input_unit=input_unit,
smooth_window=smooth_window,
remove_holder=remove_holder,
)
# single computation: the estimators run once here and both the tidy
# table and the plotted constructions come from the same results
estimates, method_results = curie_temperature_estimates(
experiment,
methods=methods,
temperature_column=temperature_column,
magnetic_column=magnetic_column,
temp_unit=temp_unit,
input_unit=input_unit,
smooth_window=smooth_window,
remove_holder=remove_holder,
branches=branches,
method_kwargs=method_kwargs,
data_type=data_type,
branch_data=branch_data,
return_method_results=True,
)
want_derivative_panel = ("inflection" in methods
or "max_curvature" in methods)
want_inverse_panel = "inverse_susceptibility" in methods
n_panels = 1 + int(want_derivative_panel) + int(want_inverse_panel)
fig, axes = plt.subplots(n_panels, 1, figsize=figsize, sharex=True)
axes = np.atleast_1d(axes)
ax_main = axes[0]
ax_deriv = axes[1] if want_derivative_panel else None
ax_inv = axes[-1] if want_inverse_panel else None
branch_styles = {
"heating": {"data_color": "#bbbbbb", "line_color": "#333333",
"linestyle": "-", "label": "heating"},
"cooling": {"data_color": "#dddddd", "line_color": "#888888",
"linestyle": "--", "label": "cooling"},
}
y_label = ("χ (m³ kg⁻¹)" if is_susceptibility else "M (Am² kg⁻¹)")
unit_label = _temp_unit_label(temp_unit)
for branch in branches:
data = branch_data.get(branch)
if data is None:
continue
style = branch_styles[branch]
T, y = data["T"], data["y"]
ax_main.scatter(data["raw_T"], data["raw_y"], s=8,
color=style["data_color"], zorder=1)
ax_main.plot(T, y, color=style["line_color"],
linestyle=style["linestyle"], linewidth=1.5,
label=style["label"], zorder=2)
# per-method annotations
branch_estimates = estimates[estimates["branch"] == branch]
for _, row in branch_estimates.iterrows():
if not np.isfinite(row["curie_temp"]):
continue
ax_main.axvline(
row["curie_temp"],
color=_CURIE_METHOD_COLORS[row["method"]],
linestyle=style["linestyle"], linewidth=1.2, alpha=0.9,
label=f"{row['method']} ({branch}): "
f"{row['curie_temp']:.0f} {unit_label}",
zorder=3,
)
two_tangent_result = method_results.get((branch, "two_tangent"))
if two_tangent_result is not None and np.isfinite(
two_tangent_result["curie_temp"]):
p = two_tangent_result["params"]
curie_temp = two_tangent_result["curie_temp"]
T_lower = np.array([p["lower_range"][0], curie_temp])
T_upper = np.array([curie_temp, T.max()])
ax_main.plot(
T_lower, p["lower_slope"] * T_lower + p["lower_intercept"],
color=_CURIE_METHOD_COLORS["two_tangent"],
linestyle=":", linewidth=1.2, zorder=3,
)
ax_main.plot(
T_upper, p["upper_slope"] * T_upper + p["upper_intercept"],
color=_CURIE_METHOD_COLORS["two_tangent"],
linestyle=":", linewidth=1.2, zorder=3,
)
landau_result = method_results.get((branch, "landau"))
if landau_result is not None and np.isfinite(
landau_result["curie_temp"]):
d = landau_result["diagnostics"]
ax_main.plot(
d["model_T"], d["model_M"],
color=_CURIE_METHOD_COLORS["landau"],
linestyle="-", linewidth=1.2, alpha=0.9, zorder=3,
)
derivative_result = (method_results.get((branch, "inflection"))
or method_results.get((branch, "max_curvature")))
if ax_deriv is not None and derivative_result is not None:
r = derivative_result
d = r["diagnostics"]
if d["dy_dT"].size:
ax_deriv.plot(d["T"], d["dy_dT"],
color=style["line_color"],
linestyle=style["linestyle"], linewidth=1.2,
label=f"dy/dT ({branch})")
if "inflection" in methods and np.isfinite(r["inflection_temp"]):
ax_deriv.axvline(r["inflection_temp"],
color=_CURIE_METHOD_COLORS["inflection"],
linestyle=style["linestyle"],
linewidth=1.0, alpha=0.9)
if ("max_curvature" in methods
and np.isfinite(r["max_curvature_temp"])):
ax_deriv.axvline(r["max_curvature_temp"],
color=_CURIE_METHOD_COLORS["max_curvature"],
linestyle=style["linestyle"],
linewidth=1.0, alpha=0.9)
inverse_result = method_results.get((branch, "inverse_susceptibility"))
if ax_inv is not None and inverse_result is not None:
r = inverse_result
d = r["diagnostics"]
if d:
ax_inv.plot(d["T"], d["inv_chi"], color=style["line_color"],
linestyle="none", marker="o", markersize=3,
label=f"1/χ ({branch})")
if np.isfinite(r["curie_temp"]):
fit_color = _branch_shade(
_CURIE_METHOD_COLORS["inverse_susceptibility"], branch)
ax_inv.plot(
d["line_T"], d["line_inv_chi"],
color=fit_color,
linestyle=style["linestyle"], linewidth=1.2,
label=f"Curie-Weiss fit ({branch}): θ = "
f"{r['curie_temp']:.0f} {unit_label}",
)
ax_inv.axvline(
r["curie_temp"], color=fit_color,
linestyle=style["linestyle"], linewidth=1.0, alpha=0.9,
)
title = (experiment["specimen"].unique()[0]
if "specimen" in experiment else "")
ax_main.set_title(title)
ax_main.set_ylabel(y_label)
ax_main.legend(fontsize=8, loc=legend_loc)
if ax_deriv is not None:
ax_deriv.set_ylabel("dy/dT")
ax_deriv.legend(fontsize=8, loc="lower left")
if ax_inv is not None and ax_inv.has_data():
ax_inv.set_ylabel("1/χ")
ax_inv.legend(fontsize=8, loc="upper left")
axes[-1].set_xlabel(f"Temperature ({unit_label})")
if xlim is not None:
ax_main.set_xlim(xlim)
# rescale each panel's y axis to the data inside the zoom window;
# an explicit ylim takes precedence on the main panel
for ax in axes:
if ax is ax_main and ylim is not None:
continue
_autoscale_y_to_xlim(ax, xlim)
if ylim is not None:
ax_main.set_ylim(ylim)
for ax in axes:
ax.grid(True, alpha=0.4)
fig.tight_layout()
if save_path is not None:
fig.savefig(save_path, dpi=300, bbox_inches="tight")
if return_figure:
return fig, axes
plt.show()
return None
[docs]
def curie_inverse_susceptibility_interactive(
experiment,
temperature_column="meas_temp",
magnetic_column="susc_chi_mass",
temp_unit="C",
input_unit="K",
smooth_window=0,
remove_holder=True,
branch="heating",
initial_fit_range=None,
figsize=(6, 6),
):
"""
Interactive (Bokeh) Curie-Weiss fit to inverse susceptibility.
Displays 1/chi versus temperature with a two-point fit line whose
endpoints can be dragged (Bokeh ``PointDrawTool``); the extrapolated
temperature where 1/chi reaches zero — the paramagnetic Curie temperature
theta (Petrovsky & Kapicka, 2006, doi:10.1029/2006JB004507) — updates
live below the plot.
This tool is for exploration: it helps identify the temperature interval
over which 1/chi is linear (fully paramagnetic). For reported values, use
``curie_inverse_susceptibility`` with the ``fit_range`` identified here,
so that the fit is reproducible.
Parameters
----------
experiment : pandas.DataFrame
MagIC-formatted experiment DataFrame.
temperature_column, magnetic_column, temp_unit, input_unit,
smooth_window, remove_holder :
As in ``curie_temperature_estimates``.
branch : {'heating', 'cooling'}, optional
Branch to display (default 'heating').
initial_fit_range : tuple of (float, float), optional
Initial temperatures (in ``temp_unit``) for the two fit-line
endpoints. Defaults to 70 percent and 100 percent of the branch's
temperature range — a starting position only, meant to be dragged.
figsize : tuple, optional
(width, height) in inches; height sets the Bokeh plot height.
Returns
-------
None
The interactive Bokeh application is displayed as a side effect; no
value is returned. For a reproducible, reportable estimate use
``curie_inverse_susceptibility`` with the ``fit_range`` identified
here.
Raises
------
ValueError
If the requested ``branch`` is not present in the experiment, or if it
has fewer than two usable (finite, positive-susceptibility) points.
"""
_check_bokeh()
branch_data = prepare_thermomag_branches(
experiment,
temperature_column=temperature_column,
magnetic_column=magnetic_column,
temp_unit=temp_unit,
input_unit=input_unit,
smooth_window=smooth_window,
remove_holder=remove_holder,
)
data = branch_data.get(branch)
if data is None:
raise ValueError(f"no {branch} data in this experiment")
T_all, chi = data["T"], data["y"]
inv_chi = _inverse_susceptibility(chi)
mask = np.isfinite(inv_chi)
T = T_all[mask]
inv_chi = inv_chi[mask]
if T.size < 2:
raise ValueError("not enough usable points for the 1/chi plot")
if initial_fit_range is None:
i0 = int(T.size * 0.7)
i1 = T.size - 1
else:
i0 = int(np.argmin(np.abs(T - initial_fit_range[0])))
i1 = int(np.argmin(np.abs(T - initial_fit_range[1])))
if i0 == i1:
i0 = max(i1 - 1, 0)
fit_source = ColumnDataSource(
data=dict(x=[T[i0], T[i1]], y=[inv_chi[i0], inv_chi[i1]])
)
data_source = ColumnDataSource(data=dict(x=T, y=inv_chi))
title = (experiment["specimen"].unique()[0]
if "specimen" in experiment else "")
bokeh_height = int(figsize[1] * 96)
p_inv = figure(
title=f"{title} – 1/χ ({branch})",
height=bokeh_height,
x_axis_label=f"Temperature ({_temp_unit_label(temp_unit)})",
y_axis_label="1/χ",
tools="pan,wheel_zoom,box_zoom,reset,save",
)
p_inv.scatter("x", "y", source=data_source, size=8, color="red",
legend_label=f"{branch} – 1/χ")
renderer = p_inv.scatter("x", "y", source=fit_source, size=12,
color="blue", legend_label="Fit endpoints")
p_inv.line("x", "y", source=fit_source, line_width=2, color="blue",
legend_label="Fit line")
draw_tool = PointDrawTool(renderers=[renderer], add=False)
p_inv.add_tools(draw_tool)
p_inv.toolbar.active_tap = draw_tool
p_inv.legend.location = "top_left"
curie_estimate = Div(
text="Curie temperature: --",
styles={"font-size": "16px", "color": "darkred"},
)
callback = CustomJS(args=dict(source=fit_source, div=curie_estimate,
unit=_temp_unit_label(temp_unit)), code="""
var x = source.data.x;
var y = source.data.y;
if (x.length == 2) {
var slope = (y[1] - y[0]) / (x[1] - x[0]);
var intercept = y[0] - slope * x[0];
var Tc = -intercept / slope;
div.text = "Curie temperature estimate: " + Tc.toFixed(2) +
" " + unit;
}
""")
fit_source.js_on_change("data", callback)
show(column(p_inv, curie_estimate))
[docs]
def add_curie_estimates_to_specimens_table(
specimens_df,
experiment_name,
estimates,
method="inflection",
branch="heating",
critical_temp_type="Curie",
):
"""
Write a Curie temperature estimate to a MagIC specimens table.
Sets ``critical_temp`` (in Kelvin, per the MagIC data model) and
``critical_temp_type`` (controlled vocabulary; 'Curie' by default) for
the rows whose ``experiments`` column matches ``experiment_name``, and
records the estimation method, branch, and uncertainty in the
``description`` column so the processing choice is archived with the
result. Updates ``specimens_df`` in place.
Parameters
----------
specimens_df : pandas.DataFrame
MagIC specimens table (with an 'experiments' column).
experiment_name : str
Experiment the estimate belongs to.
estimates : pandas.DataFrame
Tidy table from ``curie_temperature_estimates``.
method : str, optional
Which method's estimate to write (default 'inflection').
branch : str, optional
Which branch's estimate to write (default 'heating').
critical_temp_type : str, optional
MagIC controlled-vocabulary temperature type (default 'Curie').
"""
selection = estimates[(estimates["method"] == method)
& (estimates["branch"] == branch)]
if selection.empty:
raise ValueError(
f"no estimate with method='{method}' and branch='{branch}' "
f"in the estimates table"
)
row = selection.iloc[0]
if not np.isfinite(row["curie_temp"]):
raise ValueError(
f"the {method}/{branch} estimate is NaN; nothing to write"
)
curie_temp_K = float(
convert_temperature(np.array([row["curie_temp"]]),
row["temp_unit"], "K")[0]
)
if "critical_temp" not in specimens_df.columns:
specimens_df["critical_temp"] = np.nan
for column_name in ("critical_temp_type", "description"):
if column_name not in specimens_df.columns:
specimens_df[column_name] = pd.Series(
[None] * len(specimens_df), dtype=object
)
elif specimens_df[column_name].dtype != object:
specimens_df[column_name] = specimens_df[column_name].astype(object)
# match on the experiments column (which may hold colon-delimited lists
# of experiment names in real contributions), falling back to the
# specimen name recorded in the estimates table; exact/token matching is
# used rather than substring matching so that an experiment name cannot
# match a longer sibling name (e.g., '...-MST-1' matching '...-MST-10')
experiments_column = specimens_df["experiments"]
target = (experiments_column == experiment_name).fillna(False).to_numpy(
dtype=bool)
if not target.any():
target = np.array([
isinstance(cell, str) and experiment_name in cell.split(":")
for cell in experiments_column
])
if not target.any() and row["specimen"]:
target = (specimens_df["specimen"] == row["specimen"]).fillna(
False).to_numpy(dtype=bool)
if target.any():
warnings.warn(
f"experiment '{experiment_name}' not found in the specimens "
f"table's experiments column; falling back to matching "
f"specimen '{row['specimen']}', which writes the estimate to "
f"all {int(target.sum())} row(s) for that specimen. Check "
f"that the experiment name matches the specimens table.",
UserWarning,
stacklevel=2,
)
if not target.any():
raise ValueError(
f"experiment '{experiment_name}' (and specimen "
f"'{row['specimen']}') not found in the specimens table"
)
specimens_df.loc[target, "critical_temp"] = curie_temp_K
specimens_df.loc[target, "critical_temp_type"] = critical_temp_type
curie_description = {
"curie_method": method,
"curie_branch": branch,
"curie_temp_K": round(curie_temp_K, 2),
}
if np.isfinite(row["curie_temp_stderr"]):
curie_description["curie_temp_stderr"] = round(
float(row["curie_temp_stderr"]), 2
)
# merge with any existing description without destroying it: a dict
# literal is updated in place, while free text is preserved and the
# Curie results appended after it
existing = specimens_df.loc[target, "description"].iloc[0]
prefix = ""
description_dict = {}
if isinstance(existing, str) and existing.strip():
try:
parsed = ast.literal_eval(existing)
if isinstance(parsed, dict):
description_dict = parsed
else:
prefix = existing.strip()
except (ValueError, SyntaxError):
prefix = existing.strip()
description_dict.update(curie_description)
new_description = str(description_dict)
if prefix:
new_description = f"{prefix}; {new_description}"
specimens_df.loc[target, "description"] = new_description
return
[docs]
def smooth_moving_average(
x,
y,
x_window,
window_type="hanning",
pad_mode="edge",
return_variance=False,
):
"""
Smooth y vs x using an x-space moving window and numpy window functions.
Parameters:
x (array-like):
1-D sequence of independent variable values.
y (array-like):
1-D sequence of dependent variable values.
x_window (float):
Width of the x-window centered on each point; must be >= 0.
If zero, no smoothing is applied.
window_type (str, optional):
One of ['flat', 'hanning', 'hamming', 'bartlett', 'blackman'].
'flat' is a simple running mean. Defaults to 'hanning'.
pad_mode (str, optional):
Mode for numpy.pad to reduce edge artifacts (e.g., 'edge',
'constant', 'nearest'). Defaults to 'edge'.
return_variance (bool, optional):
If True, return weighted variances of x and y as well.
Otherwise, only return smoothed x and y. Defaults to False.
Returns:
tuple: ``(smoothed_x, smoothed_y)`` by default, or
``(smoothed_x, smoothed_y, x_var, y_var)`` when return_variance is
True. ``smoothed_x`` and ``smoothed_y`` are the window-averaged arrays
(same length as the inputs); ``x_var`` and ``y_var`` are the
corresponding per-point weighted variances within each window (in the
squared units of x and y), a measure of local spread. When
x_window is 0 the inputs are returned unchanged and the variances are
zero.
"""
# convert to numpy arrays
x = np.asarray(x)
y = np.asarray(y)
# validate dimensions
if x.ndim != 1 or y.ndim != 1 or x.size != y.size:
raise ValueError("`x` and `y` must be 1-D arrays of equal length.")
# handle non-positive window
if x_window < 0:
raise ValueError("`x_window` must be non-negative.")
if x_window == 0:
if return_variance:
x_var = np.zeros_like(x, dtype=float)
y_var = np.zeros_like(y, dtype=float)
return x, y, x_var, y_var
return x, y
# always pad to handle edge effects
pad_n = x.size
x_arr = np.pad(x, pad_n, mode=pad_mode)
y_arr = np.pad(y, pad_n, mode=pad_mode)
n = x.size
sm_x = np.empty(n)
sm_y = np.empty(n)
if return_variance:
x_var = np.empty(n)
y_var = np.empty(n)
half = x_window / 2.0
for i, center in enumerate(x):
mask = (x_arr >= center - half) & (x_arr <= center + half)
idx = np.nonzero(mask)[0]
if idx.size:
xx = x_arr[idx]
yy = y_arr[idx]
m = idx.size
if window_type == "flat":
w = np.ones(m)
else:
w = getattr(np, window_type)(m)
wsum = w.sum()
if wsum <= 0:
# window functions like hanning are identically zero for
# m <= 2; fall back to a flat window
w = np.ones(m)
wsum = float(m)
mean_x = (w * xx).sum() / wsum
mean_y = (w * yy).sum() / wsum
if return_variance:
vx = (w * (xx - mean_x) ** 2).sum() / wsum
vy = (w * (yy - mean_y) ** 2).sum() / wsum
else:
mean_x = center
mean_y = y[i]
if return_variance:
vx = vy = 0.0
sm_x[i] = mean_x
sm_y[i] = mean_y
if return_variance:
x_var[i] = vx
y_var[i] = vy
if return_variance:
return sm_x, sm_y, x_var, y_var
return sm_x, sm_y
[docs]
def optimize_moving_average_window(experiment, min_temp_window=0, max_temp_window=50, steps=50, colormapwarm='tab20b', colormapcool='tab20c'):
"""
Visualize and optimize the moving average window size for smoothing experimental temperature-dependent data.
This function evaluates the effect of different moving average window sizes on the smoothing of both the warm and cool cycles
of an experiment (such as low temperature remanence or thermal demagnetization data). It iterates over a range of window sizes,
applies smoothing, and computes the average variance and root mean square (RMS) for each window. These metrics are plotted
to help the user visually identify the optimal window size for minimizing variance and RMS, balancing noise reduction and signal fidelity.
Parameters
----------
experiment : object or structured array
Experimental data containing temperature and measurement values. It must be compatible with the `split_heating_cooling` function.
min_temp_window : float, optional
Minimum window size (in degrees Celsius) for the moving average. Default is 0.
max_temp_window : float, optional
Maximum window size (in degrees Celsius) for the moving average. Default is 50.
steps : int, optional
Number of window size steps to evaluate between the minimum and maximum. Default is 50.
colormapwarm : str, optional
Matplotlib colormap name for the warm cycle plot. Default is 'tab20b'.
colormapcool : str, optional
Matplotlib colormap name for the cool cycle plot. Default is 'tab20c'.
Returns
-------
fig : matplotlib.figure.Figure
The matplotlib Figure object containing the optimization plots.
axs : numpy.ndarray of matplotlib.axes.Axes
Array of Axes objects (one for the warm cycle, one for the cool cycle).
Examples
--------
>>> fig, axs = optimize_moving_average_window(my_experiment, min_temp_window=5, max_temp_window=30, steps=20)
>>> fig.show()
"""
warm_T, warm_X, cool_T, cool_X = split_heating_cooling(experiment)
windows = np.linspace(min_temp_window, max_temp_window, steps)
fig, axs = plt.subplots(ncols=2, nrows=1, figsize=(12, 6))
# Normalize the colormap
norm = colors.Normalize(vmin=min_temp_window, vmax=max_temp_window)
for window in windows:
_, warm_avg_chis, _, warm_chi_vars = smooth_moving_average(warm_T, warm_X, window, return_variance=True)
warm_avg_rms, warm_avg_variance = _calc_avg_variance_and_rms(warm_X, warm_avg_chis, warm_chi_vars)
_, cool_avg_chis, _, cool_chi_vars = smooth_moving_average(cool_T, cool_X, window, return_variance=True)
cool_avg_rms, cool_avg_variance = _calc_avg_variance_and_rms(cool_X, cool_avg_chis, cool_chi_vars)
axs[0].scatter(warm_avg_variance, warm_avg_rms, c=window, cmap=colormapwarm, norm=norm)
axs[1].scatter(cool_avg_variance, cool_avg_rms, c=window, cmap=colormapcool, norm=norm)
# ax.text(warm_avg_variance, warm_avg_rms, f'{window:.2f}°C', fontsize=12, ha='right')
# ax.text(cool_avg_variance, cool_avg_rms, f'{window:.2f}°C', fontsize=12, ha='right')
for ax in axs:
ax.set_xlabel('Average Variance', fontsize=14)
ax.set_ylabel('Average RMS', fontsize=14)
ax.invert_yaxis()
# show the colormaps and make sure the range is correct
warm_cbar = plt.colorbar(plt.cm.ScalarMappable(cmap=colormapwarm, norm=norm), orientation='horizontal', ax=axs[0])
warm_cbar.set_label('Warm cycle window size (°C)')
cool_cbar = plt.colorbar(plt.cm.ScalarMappable(cmap=colormapcool, norm=norm), orientation='horizontal', ax=axs[1])
cool_cbar.set_label('Cool cycle window size (°C)')
plt.suptitle('Optimization of running average window size', fontsize=16)
return fig, axs
def _calc_avg_variance_and_rms(chi_list, avg_chis, chi_vars):
"""
Calculate the average root mean square (RMS) deviation and average variance for a set of measurements.
This function computes two statistical metrics for a given list of measurement values and their corresponding
moving averages and variances:
1. The average RMS deviation, which quantifies the typical deviation between each measurement and its local average.
2. The average variance, representing the mean of the provided variances for the measurements.
Parameters
----------
chi_list : array-like
List or array of measurement values (e.g., susceptibility, magnetization).
avg_chis : array-like
List or array of moving average values corresponding to `chi_list`.
chi_vars : array-like
List or array of variance values for each measurement.
Returns
-------
avg_rms : float
The average root mean square deviation between each value in `chi_list` and its corresponding `avg_chis`.
avg_variance : float
The average of all values in `chi_vars`.
Examples
--------
>>> chi = [1.0, 2.0, 3.0]
>>> avg_chi = [0.9, 2.1, 2.9]
>>> vars = [0.01, 0.02, 0.03]
>>> avg_rms, avg_var = _calc_avg_variance_and_rms(chi, avg_chi, vars)
>>> print(f"Average RMS: {avg_rms:.3f}, Average Variance: {avg_var:.3f}")
"""
rms_list = np.sqrt([(chi - avg_chi)**2 for chi, avg_chi in zip(chi_list, avg_chis)])
total_rms = np.sum(rms_list)
avg_rms = total_rms / len(rms_list)
total_variance = np.sum(chi_vars)
avg_variance = total_variance / len(chi_vars)
return avg_rms, avg_variance
# backfield data processing functions
# ------------------------------------------------------------------------------------------------------------------
[docs]
def process_backfield_data(experiment, field='treat_dc_field', magnetization='magn_mass', smooth_mode='lowess', smooth_frac=0.0, drop_first=False):
'''
Function to process the backfield data including shifting the magnetic
moment to be positive values taking the log base 10 of the magnetic
field values and writing these new fields into the experiment attribute
table
Parameters
----------
experiment : DataFrame
DataFrame containing the backfield data
field : str
The name of the treatment field column in the DataFrame
magnetization : str
The name of the magnetization column in the DataFrame
smooth_mode : str
The smoothing mode to be used, either 'lowess' or 'spline'
smooth_frac : float
Fraction of the data to be used for LOWESS smoothing, value must be between 0 and 1
drop_first : bool
Whether to drop the first data point or not
in some cases you may want to drop the first data point to avoid negative log values
Returns
-------
DataFrame
The processed experiment DataFrame with new attributes.
'''
assert smooth_mode in ['lowess', 'spline'], 'smooth_mode must be either lowess or spline'
assert smooth_frac >= 0 and smooth_frac <= 1, 'smooth_frac must be between 0 and 1'
assert isinstance(drop_first, bool), 'drop_first must be a boolean'
experiment = experiment.reset_index(drop=True)
# check and make sure to force drop first row if the first treat field is in the wrong direction
if experiment[field].iloc[0] > 0:
drop_first = True
if drop_first:
experiment = experiment.iloc[1:].reset_index(drop=1)
if _find_y_crossing(experiment[field], experiment[magnetization]) is not None:
Bcr = np.abs(_find_y_crossing(experiment[field], experiment[magnetization]))
else:
Bcr = np.nan
# to plot the backfield data in the conventional way, we need to shift the magnetization to be positive
experiment['magn_mass_shift'] = [i - experiment[magnetization].min() for i in experiment[magnetization]]
# we then calculate the log10 of the treatment fields
experiment['log_dc_field'] = np.log10(-experiment[field]*1e3)
if smooth_mode == 'spline':
# spline smoothing
x = experiment['log_dc_field']
y = experiment['magn_mass_shift']
y_mean = np.mean(y)
y_std = np.std(y)
y_scaled = (y - y_mean) / y_std
# Map it to actual s value
s = smooth_frac * len(x) * y_mean
spl = UnivariateSpline(x, y_scaled, s=s)
experiment['smoothed_magn_mass_shift'] = spl(x) * y_std + y_mean
experiment['smoothed_log_dc_field'] = x
elif smooth_mode == 'lowess':
_check_statsmodels()
spl = lowess(experiment['magn_mass_shift'], experiment['log_dc_field'], frac=smooth_frac)
experiment['smoothed_magn_mass_shift'] = spl[:, 1]
experiment['smoothed_log_dc_field'] = spl[:, 0]
return experiment, Bcr
[docs]
def plot_backfield_data(
experiment,
field="treat_dc_field",
magnetization="magn_mass",
Bcr=None,
figsize=(5, 10),
plot_raw=True,
plot_processed=True,
plot_spectrum=True,
interactive=False,
return_figure=False,
show_plot=True,
y_axis_units="Am²/kg",
legend_location="upper left"
):
"""
Plot backfield data: raw, processed, and coercivity spectrum.
Parameters
----------
experiment : DataFrame
Must contain raw and, if requested, processed columns.
field : str
Name of the magnetic field column.
magnetization : str
Name of the magnetization column.
Bcr : float, optional
Calculated Bcr (T). If provided, will be plotted as a pink star.
figsize : tuple(float, float)
Figure size (in inches).
plot_raw : bool
plot_processed : bool
plot_spectrum : bool
interactive : bool
return_figure : bool
show_plot : bool
y_axis_units : str, optional
Units to display on the y-axis labels of raw and processed panels.
legend_location : str, optional
Location of the legend in Matplotlib terms.
Returns
-------
Matplotlib (fig, axes) or Bokeh grid or None
"""
# Check columns
req = []
if plot_raw:
req += [field, magnetization]
if plot_processed or plot_spectrum:
req += [
"log_dc_field",
"magn_mass_shift",
"smoothed_log_dc_field",
"smoothed_magn_mass_shift",
]
missing = [c for c in req if c not in experiment.columns]
if missing:
raise KeyError(f"Missing columns: {missing}")
# Prepare spectrum
if plot_spectrum:
log_b = experiment["log_dc_field"]
shift_m = experiment["magn_mass_shift"]
raw_dy = -np.diff(shift_m) / np.diff(log_b)
raw_dx_log = log_b.rolling(2).mean().dropna()
smooth_dy = -np.diff(experiment["smoothed_magn_mass_shift"]) / np.diff(
experiment["smoothed_log_dc_field"]
)
smooth_dx_log = experiment["smoothed_log_dc_field"].rolling(2).mean().dropna()
raw_dx = 10 ** raw_dx_log
smooth_dx = 10 ** smooth_dx_log
# Interactive: Bokeh
if interactive:
_check_bokeh()
tools = [
HoverTool(tooltips=[("Field (T)", "@x"), ("Mag", "@y")]),
"pan,box_zoom,wheel_zoom,reset,save"
]
figs = []
palette = Category10[4]
bokeh_height = int(figsize[1] / 3 * 96)
if plot_raw:
p0 = figure(
title="Raw backfield",
x_axis_label="Field (T)",
y_axis_label=f"Magnetization ({y_axis_units})",
tools=tools,
sizing_mode="stretch_width",
height=bokeh_height,
)
p0.scatter(
experiment[field],
experiment[magnetization],
legend_label="raw",
color=palette[0],
size=6,
)
p0.line(experiment[field], experiment[magnetization], color=palette[0])
if Bcr is not None and not np.isnan(Bcr):
p0.scatter(
[-Bcr],
0,
size=15,
color="pink",
marker="star",
line_color="black",
legend_label=f"Bcr = {Bcr:.5f} T",
)
p0.xaxis.axis_label_text_font_style = "normal"
p0.yaxis.axis_label_text_font_style = "normal"
p0.legend.location = _map_legend_location(legend_location)
p0.legend.click_policy = "hide"
figs.append(p0)
if plot_processed:
x_shifted = 10 ** experiment["log_dc_field"]
x_smooth = 10 ** experiment["smoothed_log_dc_field"]
p1 = figure(
title="Processed backfield",
x_axis_label="Field (mT)",
y_axis_label=f"Magnetization ({y_axis_units})",
x_axis_type="log",
tools=tools,
sizing_mode="stretch_width",
height=bokeh_height,
)
p1.scatter(
x_shifted,
experiment["magn_mass_shift"],
legend_label="shifted",
color=palette[1],
size=6,
)
p1.line(
x_smooth,
experiment["smoothed_magn_mass_shift"],
color=palette[1],
legend_label="smoothed",
)
p1.xaxis.axis_label_text_font_style = "normal"
p1.yaxis.axis_label_text_font_style = "normal"
p1.legend.location = _map_legend_location(legend_location)
p1.legend.click_policy = "hide"
figs.append(p1)
if plot_spectrum:
p2 = figure(
title="Coercivity spectrum",
x_axis_label="Field (mT)",
y_axis_label="dM/dlog(B)",
x_axis_type="log",
tools=tools,
sizing_mode="stretch_width",
height=bokeh_height,
)
p2.scatter(raw_dx, raw_dy, legend_label="raw spectrum",
color=palette[2], size=6)
p2.line(smooth_dx, smooth_dy, color=palette[2],
legend_label="smoothed spectrum")
p2.xaxis.axis_label_text_font_style = "normal"
p2.yaxis.axis_label_text_font_style = "normal"
p2.legend.location = _map_legend_location(legend_location)
p2.legend.click_policy = "hide"
figs.append(p2)
grid = gridplot(figs, ncols=1, sizing_mode="stretch_width")
if show_plot:
show(grid)
if return_figure:
return grid
return None
# Static: Matplotlib
panels = []
if plot_raw:
panels.append("raw")
if plot_processed:
panels.append("processed")
if plot_spectrum:
panels.append("spectrum")
n = len(panels)
fig, axes = plt.subplots(nrows=n, ncols=1, figsize=figsize)
if n == 1:
axes = [axes]
for ax, panel in zip(axes, panels):
if panel == "raw":
ax.scatter(
experiment[field], experiment[magnetization], c="k", s=10, label="raw"
)
ax.plot(experiment[field], experiment[magnetization], c="k")
if Bcr is not None and not np.isnan(Bcr):
y_min = experiment[magnetization].min()
y_max = experiment[magnetization].max()
y_mid = y_min + 0.5 * (y_max - y_min)
ax.scatter(
-Bcr,
y_mid,
marker="*",
s=150,
c="pink",
edgecolors="black",
label=f"Bcr = {Bcr:.5f} T",
zorder=10,
)
ax.set(
title="raw backfield",
xlabel="field (T)",
ylabel=f"magnetization ({y_axis_units})",
)
ax.legend(loc=legend_location)
elif panel == "processed":
ax.scatter(
experiment["log_dc_field"],
experiment["magn_mass_shift"],
c="gray",
s=10,
label="shifted",
)
ax.plot(
experiment["smoothed_log_dc_field"],
experiment["smoothed_magn_mass_shift"],
c="k",
label="smoothed",
)
ticks = ax.get_xticks()
ax.set_xticklabels([f"{round(10**t, 1)}" for t in ticks])
ax.set(
title="processed",
xlabel="field (mT)",
ylabel=f"magnetization ({y_axis_units})",
)
ax.legend(loc=legend_location)
else: # spectrum
ax.scatter(raw_dx_log, raw_dy, c="gray", s=10, label="raw spectrum")
ax.plot(smooth_dx_log, smooth_dy, c="k", label="smoothed spectrum")
ticks = ax.get_xticks()
ax.set_xticklabels([f"{round(10**t, 1)}" for t in ticks])
ax.set(title="spectrum", xlabel="field (mT)", ylabel="dM/dlog(B)")
ax.legend(loc=legend_location)
fig.tight_layout()
if show_plot:
plt.show()
if return_figure:
return fig, axes
return None
[docs]
def add_Bcr_to_specimens_table(specimens_df, experiment_name, Bcr):
"""
Add the Bcr value to the MagIC specimens table
the controled vocabulary for backfield derived Bcr is rem_bcr
Parameters
----------
specimens_df : pandas.DataFrame
The specimens table from the MagIC database
experiment_name : str
The name of the experiment to which the Bcr value belongs
Bcr : float
The Bcr value to be added to the specimens table
"""
# first check if the rem_bcr column exists
if 'rem_bcr' not in specimens_df.columns:
# add the rem_bcr column to the specimens table
specimens_df['rem_bcr'] = np.nan
# match with the shared contains-logic so a colon-delimited 'experiments'
# cell is found, and fail loudly rather than silently writing nothing
mask = _match_specimen_rows(specimens_df, experiment_name, None)
if not mask.any():
raise ValueError(
f"no specimens row matches experiment '{experiment_name}'; the "
"Bcr value was not recorded. Check the name against the "
"specimens table's 'experiments' column.")
# add the Bcr value to the specimens table
specimens_df.loc[mask, 'rem_bcr'] = Bcr
return
# coercivity spectrum unmixing functions
# ------------------------------------------------------------------------------------------------------------------
# Toolkit for decomposing remanence curves (backfield demagnetization or IRM
# acquisition) into coercivity components. Every component is a skew-normal
# distribution in x = log10(B/mT) parameterized by its integrated area
# ('contribution', in the units of the magnetization data), location, scale
# ('dp'), and shape ('skew'). With skew = 0 the component reduces exactly to
# the log-Gaussian of Robertson & France (1994). Because the skew-normal has
# an analytic CDF (via Owen's T function), the same component model can be
# fit in two data spaces:
#
# 1. spectrum space -- fitting the coercivity spectrum |dM/dlog10(B)|,
# following Kruiver et al. (2001), Egli (2003), and the MAX UnMix
# program of Maxbauer et al. (2016).
# 2. measurement space -- fitting the measured remanence curve M(B)
# directly with cumulative (CDF) components, which avoids numerical
# differentiation and smoothing of the data altogether.
#
# Model selection is supported through AIC/BIC and F-tests, and parameter
# uncertainties through both linearized (covariance) standard errors and
# bootstrap resampling.
_SQRT2 = np.sqrt(2.0)
_SQRT2PI = np.sqrt(2.0 * np.pi)
UNMIX_PARAM_COLUMNS = ['contribution', 'location', 'dp', 'skew']
# Shared defaults for the unmixing entry points, so the sibling functions
# (fitting, model selection, multistart, batch processing) agree on the model
# class by default and a select_n_components -> unmix_coercivity workflow
# fits the same model throughout. unmix_coercivity_bayes deliberately
# deviates with vary_skew=False (free skew greatly increases the nested-
# sampling cost and is usually better constrained through mineral priors;
# see its docstring).
DEFAULT_UNMIX_METHOD = 'spectrum'
DEFAULT_UNMIX_VARY_SKEW = True
def _trapz(y, x):
"""Trapezoidal integration compatible with numpy 1.x and 2.x."""
trapezoid = getattr(np, 'trapezoid', None)
if trapezoid is None:
trapezoid = np.trapz
return trapezoid(y, x)
[docs]
def skewnormal_pdf(x, location, dp, skew=0.0):
"""
Skew-normal probability density function (unit area).
With skew = 0 this is a Gaussian with mean = location and standard
deviation = dp; in log10(B) coordinates that Gaussian is the log-Gaussian
coercivity distribution of Robertson & France (1994). Nonzero skew
follows the Azzalini (1985) formulation: negative values skew the
distribution toward low values (tail to the left).
Parameters
----------
x : array-like
Points at which to evaluate the density (log10 of field in mT).
location : float
Location parameter (log10 mT). Equal to the mean only when skew = 0.
dp : float
Scale parameter (log10 units); must be positive. Equal to the
standard deviation only when skew = 0.
skew : float
Shape parameter alpha of the Azzalini skew-normal (default 0).
Returns
-------
numpy.ndarray
Density values with unit integrated area.
"""
x = np.asarray(x, dtype=float)
z = (x - location) / dp
return np.exp(-0.5 * z * z) / (_SQRT2PI * dp) * (1.0 + erf(skew * z / _SQRT2))
[docs]
def skewnormal_cdf(x, location, dp, skew=0.0):
"""
Skew-normal cumulative distribution function.
Evaluated analytically as Phi(z) - 2*T(z, skew) where T is Owen's T
function (scipy.special.owens_t).
Parameters
----------
x : array-like
Points at which to evaluate the CDF (log10 of field in mT).
location : float
Location parameter (log10 mT).
dp : float
Scale parameter (log10 units); must be positive.
skew : float
Shape parameter alpha (default 0).
Returns
-------
numpy.ndarray
CDF values between 0 and 1.
"""
x = np.asarray(x, dtype=float)
z = (x - location) / dp
Phi = 0.5 * (1.0 + erf(z / _SQRT2))
if skew == 0:
return Phi
return Phi - 2.0 * owens_t(z, skew)
[docs]
def skewnormal_stats(location, dp, skew=0.0):
"""
Moments and characteristic points of a skew-normal distribution.
Parameters
----------
location : float
Location parameter (log10 mT).
dp : float
Scale parameter (log10 units).
skew : float
Shape parameter alpha.
Returns
-------
dict
With keys 'mean', 'std', 'median', and 'mode', all in the same
(log10) units as location and dp. For skew = 0 all of mean, median,
and mode equal location and std equals dp.
"""
delta = skew / np.sqrt(1.0 + skew ** 2)
mean = location + dp * delta * np.sqrt(2.0 / np.pi)
std = dp * np.sqrt(1.0 - 2.0 * delta ** 2 / np.pi)
if skew == 0:
return {'mean': mean, 'std': std, 'median': location, 'mode': location}
median = brentq(lambda t: skewnormal_cdf(t, location, dp, skew) - 0.5,
location - 12 * dp, location + 12 * dp)
mode_res = minimize_scalar(lambda t: -skewnormal_pdf(t, location, dp, skew),
bounds=(location - 5 * dp, location + 5 * dp),
method='bounded')
return {'mean': mean, 'std': std, 'median': median, 'mode': float(mode_res.x)}
def _skewnormal_median_z(skew, n_iter=60):
"""Standardized median z of the skew-normal, vectorized over skew.
Solves Phi(z) - 2*T(z, skew) = 1/2 by bisection so per-draw medians can
be computed for whole posterior/bootstrap sample arrays at once; the
median in data units is location + dp * z.
"""
skew = np.asarray(skew, dtype=float)
if np.all(skew == 0):
return np.zeros_like(skew)
lo = np.full_like(skew, -12.0)
hi = np.full_like(skew, 12.0)
for _ in range(n_iter):
mid = 0.5 * (lo + hi)
cdf = 0.5 * (1.0 + erf(mid / _SQRT2)) - 2.0 * owens_t(mid, skew)
below = cdf < 0.5
lo = np.where(below, mid, lo)
hi = np.where(below, hi, mid)
return 0.5 * (lo + hi)
def _skewnormal_mode_z(skew, n_iter=100):
"""Standardized mode z of the skew-normal, vectorized over skew.
Ternary search on the (unimodal) standardized density
phi(z) * (1 + erf(skew*z/sqrt(2))); the mode in data units is
location + dp * z.
"""
skew = np.asarray(skew, dtype=float)
if np.all(skew == 0):
return np.zeros_like(skew)
def density(z):
return np.exp(-0.5 * z * z) * (1.0 + erf(skew * z / _SQRT2))
lo = np.full_like(skew, -5.0)
hi = np.full_like(skew, 5.0)
for _ in range(n_iter):
m1 = lo + (hi - lo) / 3.0
m2 = hi - (hi - lo) / 3.0
right = density(m1) < density(m2)
lo = np.where(right, m1, lo)
hi = np.where(right, hi, m2)
return 0.5 * (lo + hi)
def _summarize_component_samples(samples, n_components,
percentiles=(2.5, 50, 97.5)):
"""Per-component summary table of replicate/posterior samples.
Shared by the MAX UnMix resampling, unmixing_bootstrap, and the Bayesian
sampler so the three uncertainty flavors report identical column sets.
Parameters
----------
samples : dict
Maps quantity name -> array-like of shape (n_draws, n_components).
n_components : int
Number of components (columns of each sample array).
percentiles : sequence of float
Percentiles to tabulate alongside mean and std.
Returns
-------
pandas.DataFrame
One row per component with '<name>_mean', '<name>_std', and
'<name>_p<percentile>' columns for every quantity.
"""
summary_rows = []
for comp in range(n_components):
row = {'component': comp + 1}
for name, values in samples.items():
column = np.asarray(values)[:, comp]
row[f'{name}_mean'] = column.mean()
row[f'{name}_std'] = column.std()
for pct in percentiles:
row[f'{name}_p{str(pct).replace(".", "_")}'] = \
np.percentile(column, pct)
summary_rows.append(row)
return pd.DataFrame(summary_rows).set_index('component')
def _percentile_curve_bands(x_grid, total_curves, component_curves,
percentiles=(2.5, 50, 97.5)):
"""Percentile bands of replicate/posterior model curves on x_grid.
Shared by the same three uncertainty paths as
_summarize_component_samples; plot_coercivity_unmixing consumes the
returned keys ('total_p2_5', 'components_p50', ...).
"""
total_curves = np.asarray(total_curves)
component_curves = np.asarray(component_curves)
bands = {'x_grid': x_grid}
for pct in percentiles:
key = f'p{str(pct).replace(".", "_")}'
bands[f'total_{key}'] = np.percentile(total_curves, pct, axis=0)
bands[f'components_{key}'] = np.percentile(component_curves, pct,
axis=0)
return bands
def _unmix_parameters_to_array(parameters):
"""Coerce a parameters DataFrame/array to an (n_components, 4) float array."""
if isinstance(parameters, pd.DataFrame):
missing = [c for c in UNMIX_PARAM_COLUMNS if c not in parameters.columns]
if missing:
raise KeyError(f"parameters table is missing columns: {missing}")
return parameters[UNMIX_PARAM_COLUMNS].to_numpy(dtype=float)
arr = np.atleast_2d(np.asarray(parameters, dtype=float))
if arr.shape[1] != 4:
raise ValueError("parameters must have four columns: "
f"{UNMIX_PARAM_COLUMNS}")
return arr
[docs]
def coercivity_spectrum_components(x, parameters):
"""
Evaluate each unmixing component in spectrum space (dM/dlog10 B).
Parameters
----------
x : array-like
log10 of field values (mT).
parameters : pandas.DataFrame or array-like
One row per component with columns 'contribution' (area under the
component in magnetization units), 'location', 'dp', 'skew'.
Returns
-------
numpy.ndarray
Array of shape (n_components, len(x)).
"""
arr = _unmix_parameters_to_array(parameters)
x = np.asarray(x, dtype=float)
return np.array([c * skewnormal_pdf(x, loc, dp, skew)
for c, loc, dp, skew in arr])
[docs]
def coercivity_spectrum_model(x, parameters):
"""
Evaluate the summed unmixing model in spectrum space.
Parameters
----------
x : array-like
log10 of field values (mT).
parameters : pandas.DataFrame or array-like
Component parameters (see coercivity_spectrum_components).
Returns
-------
numpy.ndarray
Total model spectrum at x.
"""
return coercivity_spectrum_components(x, parameters).sum(axis=0)
[docs]
def coercivity_curve_components(x, parameters, curve_type='backfield'):
"""
Evaluate each component in measurement space (cumulative curves).
For 'backfield' curves (processed so that magnetization decays from a
maximum toward zero with increasing field magnitude) each component is
contribution * (1 - CDF); for 'acquisition' curves each component is
contribution * CDF.
Parameters
----------
x : array-like
log10 of field values (mT).
parameters : pandas.DataFrame or array-like
Component parameters (see coercivity_spectrum_components).
curve_type : str
'backfield' or 'acquisition'.
Returns
-------
numpy.ndarray
Array of shape (n_components, len(x)).
"""
assert curve_type in ('backfield', 'acquisition'), \
"curve_type must be 'backfield' or 'acquisition'"
arr = _unmix_parameters_to_array(parameters)
x = np.asarray(x, dtype=float)
comps = []
for c, loc, dp, skew in arr:
cdf = skewnormal_cdf(x, loc, dp, skew)
comps.append(c * (1.0 - cdf) if curve_type == 'backfield' else c * cdf)
return np.array(comps)
[docs]
def coercivity_curve_model(x, parameters, offset=0.0, curve_type='backfield'):
"""
Evaluate the summed unmixing model in measurement space.
Parameters
----------
x : array-like
log10 of field values (mT).
parameters : pandas.DataFrame or array-like
Component parameters (see coercivity_spectrum_components).
offset : float
Constant baseline added to the model (accounts for a small
unsaturated or instrumental offset; default 0).
curve_type : str
'backfield' or 'acquisition'.
Returns
-------
numpy.ndarray
Total model curve at x.
"""
return coercivity_curve_components(x, parameters, curve_type).sum(axis=0) + offset
[docs]
def coercivity_spectrum_from_curve(x, magnetization, curve_type='backfield'):
"""
Compute a finite-difference coercivity spectrum from a remanence curve.
Parameters
----------
x : array-like
log10 of field values (mT), monotonically increasing.
magnetization : array-like
Magnetization values at x (shifted to positive for backfield data,
e.g. the 'magn_mass_shift' column from process_backfield_data).
curve_type : str
'backfield' (decaying curve, spectrum = -dM/dx) or 'acquisition'
(growing curve, spectrum = dM/dx).
Returns
-------
tuple
(x_mid, spectrum) where x_mid are midpoints between successive x
values and spectrum is the centered finite-difference derivative.
"""
assert curve_type in ('backfield', 'acquisition'), \
"curve_type must be 'backfield' or 'acquisition'"
x = np.asarray(x, dtype=float)
M = np.asarray(magnetization, dtype=float)
dM = np.diff(M) / np.diff(x)
x_mid = 0.5 * (x[1:] + x[:-1])
return x_mid, (-dM if curve_type == 'backfield' else dM)
[docs]
def estimate_coercivity_components(x, spectrum, n_components, smooth_window=None):
"""
Automatic initial-guess estimation for unmixing components.
The spectrum is interpolated onto a uniform grid, lightly smoothed
(Savitzky-Golay), and searched for peaks. The n_components most
prominent peaks seed the component locations; widths at half maximum
seed dp; peak heights seed the contributions. If fewer peaks than
components are found, the remaining components are placed at evenly
spaced quantiles of the cumulative spectrum.
Initial choices matter for nonlinear fitting: these automatic estimates
are a starting point that can (and often should) be refined by the user,
e.g. with coercivity_unmixing_interactive.
Parameters
----------
x : array-like
log10 of field values (mT).
spectrum : array-like
Coercivity spectrum values at x.
n_components : int
Number of components to estimate.
smooth_window : int, optional
Savitzky-Golay window length (grid points). Defaults to ~1/10 of
the grid (minimum 5).
Returns
-------
pandas.DataFrame
Initial parameters with columns 'contribution', 'location', 'dp',
'skew' (skew = 0), sorted by location.
"""
assert isinstance(n_components, (int, np.integer)) and n_components > 0, \
'n_components must be a positive integer'
x = np.asarray(x, dtype=float)
y = np.asarray(spectrum, dtype=float)
order = np.argsort(x)
x, y = x[order], y[order]
n_grid = max(200, len(x))
xg = np.linspace(x.min(), x.max(), n_grid)
yg = np.interp(xg, x, y)
if smooth_window is None:
smooth_window = max(5, (n_grid // 10) | 1) # odd
else:
smooth_window = max(5, int(smooth_window) | 1)
yg_smooth = savgol_filter(yg, smooth_window, polyorder=3)
yg_smooth = np.clip(yg_smooth, 0, None)
dx = xg[1] - xg[0]
peak_idx, props = find_peaks(yg_smooth, prominence=0.02 * yg_smooth.max(),
width=1)
if len(peak_idx) > 0:
# keep the n most prominent peaks
keep = np.argsort(props['prominences'])[::-1][:n_components]
peak_idx = peak_idx[keep]
widths = props['widths'][keep] * dx
else:
peak_idx = np.array([], dtype=int)
widths = np.array([])
locations = list(xg[peak_idx])
dps = [max(w / 2.355, 0.05) for w in widths] # FWHM -> sigma
heights = list(yg_smooth[peak_idx])
# fill any missing components at quantiles of the cumulative spectrum
if len(locations) < n_components:
cumulative = np.cumsum(yg_smooth)
cumulative = cumulative / cumulative[-1]
n_missing = n_components - len(locations)
quantiles = np.linspace(0.15, 0.85, n_components)
candidates = [xg[np.searchsorted(cumulative, q)] for q in quantiles]
# prefer candidate positions away from already-found peaks
for cand in sorted(candidates,
key=lambda c: -min([abs(c - loc) for loc in locations],
default=np.inf)):
if n_missing == 0:
break
locations.append(cand)
dps.append(0.25)
heights.append(np.interp(cand, xg, yg_smooth))
n_missing -= 1
initial = pd.DataFrame({
'contribution': [h * dp * _SQRT2PI for h, dp in zip(heights, dps)],
'location': locations,
'dp': dps,
'skew': 0.0,
})
return initial.sort_values('location').reset_index(drop=True)
def _unmix_fit_engine(x, y, initial, method, curve_type='backfield',
vary_skew=True, fit_offset=True, weights=None,
dp_bounds=(0.01, 2.0), skew_bounds=(-10.0, 10.0),
max_nfev=20000):
"""
Shared bounded least-squares engine for both unmixing data spaces.
Returns the scipy result plus unpacked, physically scaled parameter
arrays, standard errors, and offset. Used by unmix_coercivity_spectrum
and unmix_backfield_curve; not intended to be called directly.
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
assert len(x) == len(y), 'x and y must have the same length'
finite = np.isfinite(x) & np.isfinite(y)
x, y = x[finite], y[finite]
if weights is not None:
weights = np.asarray(weights, dtype=float)[finite]
init_arr = _unmix_parameters_to_array(initial)
K = init_arr.shape[0]
if len(x) <= 3 * K + 1:
raise ValueError(f'too few data points ({len(x)}) to fit '
f'{K} components')
y_scale = np.max(np.abs(y))
if y_scale == 0:
raise ValueError('y data are all zero')
ys = y / y_scale
c0 = np.clip(init_arr[:, 0] / y_scale, 1e-6, 20.0)
loc0 = np.clip(init_arr[:, 1], x.min() - 0.5, x.max() + 0.5)
dp0 = np.clip(init_arr[:, 2], dp_bounds[0], dp_bounds[1])
skew0 = np.clip(init_arr[:, 3], skew_bounds[0], skew_bounds[1])
fit_offset = fit_offset and method == 'curve'
p0 = np.concatenate([c0, loc0, dp0])
lb = np.concatenate([np.zeros(K), np.full(K, x.min() - 0.5),
np.full(K, dp_bounds[0])])
ub = np.concatenate([np.full(K, 20.0), np.full(K, x.max() + 0.5),
np.full(K, dp_bounds[1])])
if vary_skew:
p0 = np.concatenate([p0, skew0])
lb = np.concatenate([lb, np.full(K, skew_bounds[0])])
ub = np.concatenate([ub, np.full(K, skew_bounds[1])])
if fit_offset:
p0 = np.concatenate([p0, [0.0]])
lb = np.concatenate([lb, [-0.5]])
ub = np.concatenate([ub, [0.5]])
def unpack(p):
c = p[:K]
loc = p[K:2 * K]
dp = p[2 * K:3 * K]
skew = p[3 * K:4 * K] if vary_skew else skew0
offset = p[-1] if fit_offset else 0.0
return c, loc, dp, skew, offset
def model_scaled(p):
c, loc, dp, skew, offset = unpack(p)
params = np.column_stack([c, loc, dp, skew])
if method == 'spectrum':
return coercivity_spectrum_model(x, params)
return coercivity_curve_model(x, params, offset=offset,
curve_type=curve_type)
def residuals(p):
r = model_scaled(p) - ys
return r * weights if weights is not None else r
res = least_squares(residuals, p0, bounds=(lb, ub), method='trf',
x_scale='jac', max_nfev=max_nfev)
# covariance-based standard errors (linearized approximation)
n_pts, n_par = len(x), len(res.x)
dof = n_pts - n_par
se = np.full(n_par, np.nan)
covariance_ok = True
if dof > 0:
try:
JtJ = res.jac.T @ res.jac
cov = 2.0 * res.cost / dof * np.linalg.pinv(JtJ)
se = np.sqrt(np.clip(np.diag(cov), 0, None))
except np.linalg.LinAlgError:
# covariance failed on an otherwise-converged fit: warn and flag it
# so the resulting NaN standard errors are not mistaken for those of
# a degenerate (dof <= 0) fit
covariance_ok = False
warnings.warn(
'covariance matrix could not be computed (singular Jacobian); '
'the linearized standard errors are NaN. The parameter '
'estimates are still valid -- use bootstrap or Bayesian '
'uncertainty for this fit instead.',
RuntimeWarning, stacklevel=2)
c, loc, dp, skew, offset = unpack(res.x)
se_c = se[:K] * y_scale
se_loc = se[K:2 * K]
se_dp = se[2 * K:3 * K]
se_skew = se[3 * K:4 * K] if vary_skew else np.zeros(K)
se_offset = se[-1] * y_scale if fit_offset else 0.0
# order components by mean coercivity
delta = skew / np.sqrt(1.0 + skew ** 2)
means = loc + dp * delta * np.sqrt(2.0 / np.pi)
order = np.argsort(means)
return {
'scipy_result': res,
'contribution': c[order] * y_scale, 'se_contribution': se_c[order],
'location': loc[order], 'se_location': se_loc[order],
'dp': dp[order], 'se_dp': se_dp[order],
'skew': np.asarray(skew)[order], 'se_skew': np.asarray(se_skew)[order],
'offset': offset * y_scale, 'se_offset': se_offset,
'x': x, 'y': y, 'weights': weights, 'y_scale': y_scale,
'covariance_ok': covariance_ok,
}
def _build_unmix_result(engine, method, curve_type, vary_skew, fit_offset,
initial):
"""Assemble the standardized result dictionary from the fit engine output."""
K = len(engine['contribution'])
rows = []
for i in range(K):
stats_i = skewnormal_stats(engine['location'][i], engine['dp'][i],
engine['skew'][i])
rows.append({
'contribution': engine['contribution'][i],
'se_contribution': engine['se_contribution'][i],
'location': engine['location'][i],
'se_location': engine['se_location'][i],
'dp': engine['dp'][i],
'se_dp': engine['se_dp'][i],
'skew': engine['skew'][i],
'se_skew': engine['se_skew'][i],
'sd_log': stats_i['std'],
'log10_B_mean': stats_i['mean'],
'B_mean_mT': 10 ** stats_i['mean'],
'B_median_mT': 10 ** stats_i['median'],
'B_peak_mT': 10 ** stats_i['mode'],
})
params = pd.DataFrame(rows, index=pd.RangeIndex(1, K + 1,
name='component'))
total = params['contribution'].sum()
params.insert(1, 'proportion',
params['contribution'] / total if total > 0 else np.nan)
x, y = engine['x'], engine['y']
param_arr = params[UNMIX_PARAM_COLUMNS].to_numpy()
if method == 'spectrum':
y_fit = coercivity_spectrum_model(x, param_arr)
else:
y_fit = coercivity_curve_model(x, param_arr, offset=engine['offset'],
curve_type=curve_type)
residuals = y - y_fit
n = len(x)
n_params = (3 + int(vary_skew)) * K + int(fit_offset and method == 'curve')
rss = float(np.sum(residuals ** 2))
tss = float(np.sum((y - y.mean()) ** 2))
dof = n - n_params
# Gaussian-likelihood information criteria (constant terms omitted);
# comparable only between fits to the same data
with np.errstate(divide='ignore'):
log_term = np.log(rss / n) if rss > 0 else -np.inf
stats = {
'n': n,
'n_params': n_params,
'dof': dof,
'rss': rss,
'r_squared': 1.0 - rss / tss if tss > 0 else np.nan,
'reduced_chi_square': rss / dof if dof > 0 else np.nan,
'aic': n * log_term + 2 * n_params,
'bic': n * log_term + n_params * np.log(n),
# True when the covariance/standard-error computation failed (singular
# Jacobian): the se_* fields are NaN for that reason rather than
# because the fit was degenerate. Normally False.
'covariance_singular': not engine.get('covariance_ok', True),
}
scipy_result = engine['scipy_result']
initial_df = (initial.copy() if isinstance(initial, pd.DataFrame)
else pd.DataFrame(_unmix_parameters_to_array(initial),
columns=UNMIX_PARAM_COLUMNS))
return {
'method': method,
'curve_type': curve_type,
'n_components': K,
'success': bool(scipy_result.success),
'message': scipy_result.message,
'params': params,
'offset': engine['offset'],
'se_offset': engine['se_offset'],
'x': x,
'y': y,
'y_fit': y_fit,
'residuals': residuals,
'weights': engine['weights'],
'stats': stats,
'initial_parameters': initial_df,
'vary_skew': vary_skew,
}
[docs]
def unmix_coercivity_spectrum(x, spectrum, n_components=None,
initial_parameters=None,
vary_skew=DEFAULT_UNMIX_VARY_SKEW,
weights=None, dp_bounds=(0.01, 2.0),
skew_bounds=(-10.0, 10.0)):
"""
Unmix a coercivity spectrum into skew-normal (log-Gaussian) components.
Fits the derivative spectrum dM/dlog10(B) with a sum of skew-normal
densities, following the approach popularized by Kruiver et al. (2001)
and the MAX UnMix program (Maxbauer et al., 2016). Compared to fitting
the measured curve directly (unmix_backfield_curve) this operates on a
numerically differentiated (and possibly smoothed) version of the data,
so the choice of smoothing can influence the result; the advantage is
that components are fit in the space where they are most readily
interpreted visually.
Parameters
----------
x : array-like
log10 of field values (mT), e.g. midpoints from
coercivity_spectrum_from_curve.
spectrum : array-like
Coercivity spectrum values at x (magnetization per decade).
n_components : int, optional
Number of components. Required if initial_parameters is None.
initial_parameters : pandas.DataFrame, optional
Initial guesses with columns 'contribution', 'location', 'dp',
'skew' (one row per component). If None, automatic estimates from
estimate_coercivity_components are used.
vary_skew : bool
If False, skew values are fixed at their initial values (default 0,
i.e. symmetric log-Gaussian components).
weights : array-like, optional
Multiplicative weights applied to the residuals.
dp_bounds : tuple
(min, max) bounds on the dp scale parameter in log10 units.
skew_bounds : tuple
(min, max) bounds on the skew shape parameter.
Returns
-------
dict
Standardized result dictionary with keys including 'params' (a
DataFrame of fitted parameters, linearized standard errors, and
derived quantities such as B_mean_mT and proportion), 'y_fit',
'residuals', 'stats' (rss, r_squared, aic, bic, ...), 'success',
and 'initial_parameters'.
"""
if initial_parameters is None:
if n_components is None:
raise ValueError('specify n_components or initial_parameters')
initial_parameters = estimate_coercivity_components(x, spectrum,
n_components)
elif (n_components is not None
and len(initial_parameters) != n_components):
raise ValueError('n_components does not match the number of rows in '
'initial_parameters')
engine = _unmix_fit_engine(x, spectrum, initial_parameters,
method='spectrum', vary_skew=vary_skew,
weights=weights, dp_bounds=dp_bounds,
skew_bounds=skew_bounds)
return _build_unmix_result(engine, 'spectrum', 'backfield', vary_skew,
False, initial_parameters)
[docs]
def unmix_backfield_curve(x, magnetization, n_components=None,
initial_parameters=None, curve_type='backfield',
vary_skew=DEFAULT_UNMIX_VARY_SKEW, fit_offset=True,
weights=None, dp_bounds=(0.01, 2.0),
skew_bounds=(-10.0, 10.0)):
"""
Unmix a remanence curve by fitting cumulative components directly.
Fits the measured curve M(log10 B) with a sum of skew-normal CDF
components (plus an optional constant offset), avoiding numerical
differentiation and smoothing entirely. The component parameterization
is identical to unmix_coercivity_spectrum, so results from the two
data spaces are directly comparable. Fitting in measurement space uses
the raw measurements with their original noise structure; fitting in
spectrum space can be more visually intuitive. Agreement between the
two approaches is a good indication of a robust unmixing model.
For backfield data processed with process_backfield_data, pass
x = 'log_dc_field' and magnetization = 'magn_mass_shift'. Note that the
shifted backfield curve spans twice the saturation remanence, so each
fitted 'contribution' is twice the remanence carried by that component;
'proportion' values are unaffected.
Parameters
----------
x : array-like
log10 of field values (mT).
magnetization : array-like
Remanence curve values at x (shifted positive for backfield data).
n_components : int, optional
Number of components. Required if initial_parameters is None.
initial_parameters : pandas.DataFrame, optional
Initial guesses (see unmix_coercivity_spectrum). If None, automatic
estimates are derived from the finite-difference spectrum.
curve_type : str
'backfield' (decaying curve) or 'acquisition' (growing curve).
vary_skew : bool
If False, skew values are fixed at their initial values.
fit_offset : bool
Whether to fit a constant baseline offset (default True).
weights : array-like, optional
Multiplicative weights applied to the residuals.
dp_bounds, skew_bounds : tuple
Bounds as in unmix_coercivity_spectrum.
Returns
-------
dict
Standardized result dictionary (see unmix_coercivity_spectrum),
additionally including the fitted 'offset' and 'se_offset'.
"""
if initial_parameters is None:
if n_components is None:
raise ValueError('specify n_components or initial_parameters')
x_mid, spec = coercivity_spectrum_from_curve(x, magnetization,
curve_type)
initial_parameters = estimate_coercivity_components(x_mid, spec,
n_components)
elif (n_components is not None
and len(initial_parameters) != n_components):
raise ValueError('n_components does not match the number of rows in '
'initial_parameters')
engine = _unmix_fit_engine(x, magnetization, initial_parameters,
method='curve', curve_type=curve_type,
vary_skew=vary_skew, fit_offset=fit_offset,
weights=weights, dp_bounds=dp_bounds,
skew_bounds=skew_bounds)
return _build_unmix_result(engine, 'curve', curve_type, vary_skew,
fit_offset, initial_parameters)
def _unmix_method_spectrum(x, magnetization, n_components=None,
initial_parameters=None, curve_type='backfield',
vary_skew=DEFAULT_UNMIX_VARY_SKEW, **kwargs):
"""Registered 'spectrum' method: finite-difference spectrum fit."""
x_mid, spectrum = coercivity_spectrum_from_curve(x, magnetization,
curve_type)
return unmix_coercivity_spectrum(x_mid, spectrum,
n_components=n_components,
initial_parameters=initial_parameters,
vary_skew=vary_skew, **kwargs)
def _unmix_method_curve(x, magnetization, n_components=None,
initial_parameters=None, curve_type='backfield',
vary_skew=DEFAULT_UNMIX_VARY_SKEW, **kwargs):
"""Registered 'curve' method: direct measurement-space fit."""
return unmix_backfield_curve(x, magnetization, n_components=n_components,
initial_parameters=initial_parameters,
curve_type=curve_type, vary_skew=vary_skew,
**kwargs)
def _unmix_method_maxunmix(x, magnetization, n_components=None,
initial_parameters=None, curve_type='backfield',
vary_skew=DEFAULT_UNMIX_VARY_SKEW,
n_boot=100, proportion=0.95,
param_noise=0.02, random_seed=None, n_grid=200,
**kwargs):
"""
Registered 'maxunmix' method: a faithful reproduction of the MAX UnMix
resampling workflow (Maxbauer et al., 2016), for direct comparison with
that program.
The coercivity spectrum dM/dlog10(B) is fit with skew-normal components,
and uncertainties are estimated with the MAX UnMix resampling scheme as
described by Maxbauer et al. (2016) (see the perturbation-step comment
in the code for one deliberate divergence from the R source): each of `n_boot`
replicates (1) recomputes the spectrum from a random `proportion`
(default 0.95) subset of the magnetization points drawn WITHOUT
replacement, (2) restarts the fit from the best-fit parameters perturbed
by `param_noise` (default 0.02, i.e. 2%) Gaussian noise, and (3)
re-optimizes; percentile intervals over the converged replicates are
reported (attached as the 'bootstrap' entry of the result).
Two representational differences from MAX UnMix remain because they are
intrinsic to the rockmagpy component model rather than to the resampling:
skewness follows the Azzalini rather than the Fernandez-Steel (fGarch)
parameterization, so skew values are not numerically comparable, and
components are parameterized by integrated area rather than by peak
height. Recovered coercivities, dispersions, and relative contributions
are directly comparable to the MAX UnMix web application.
For the more flexible rockmagpy resampling -- case or residual resampling
of the fitted data, optionally with added measurement noise, and usable
with the measurement-space and Bayesian fits as well -- fit with
method='spectrum' or 'curve' and call unmixing_bootstrap directly.
n_boot=0 skips the resampling entirely and returns the point fit with no
'bootstrap' entry; unmixing_multistart and select_n_components use this
for their trial fits, whose uncertainty output would be discarded.
"""
rng = _resolve_rng(random_seed)
x = np.asarray(x, dtype=float)
M = np.asarray(magnetization, dtype=float)
finite = np.isfinite(x) & np.isfinite(M)
x, M = x[finite], M[finite]
x_mid, spectrum = coercivity_spectrum_from_curve(x, M, curve_type)
result = unmix_coercivity_spectrum(x_mid, spectrum,
n_components=n_components,
initial_parameters=initial_parameters,
vary_skew=vary_skew, **kwargs)
if n_boot == 0:
return result
best = result['params'][UNMIX_PARAM_COLUMNS].to_numpy()
K = result['n_components']
n = len(x)
n_keep = max(int(round(n * proportion)), 3 * K + 3)
# forward the same fit settings (e.g. dp_bounds, skew_bounds) to every
# replicate so it runs under the identical constraints as the main fit.
# 'weights' is excluded: each replicate recomputes the spectrum from a
# random subset of the curve, so per-point weights of the full spectrum
# cannot align with the shorter resampled spectrum.
replicate_kwargs = {k: v for k, v in kwargs.items() if k != 'weights'}
tracked = ['contribution', 'proportion', 'location', 'dp', 'skew',
'sd_log', 'B_mean_mT', 'B_median_mT', 'B_peak_mT']
samples = {name: [] for name in tracked}
x_grid = np.linspace(x_mid.min(), x_mid.max(), n_grid)
total_curves, comp_curves = [], []
n_success = 0
for _ in range(n_boot):
# (1) recompute the spectrum from a 95% subset of the curve, w/o replacement
idx = np.sort(rng.choice(n, size=n_keep, replace=False))
x_mid_b, spectrum_b = coercivity_spectrum_from_curve(x[idx], M[idx],
curve_type)
# (2) restart from the best fit perturbed by ~2% Gaussian noise.
# Note: the MAX UnMix R source intends this per parameter but passes
# the whole parameter vector as the sd argument of rnorm(1, ...),
# which silently uses only its first element, so there every
# parameter is actually perturbed by 2% of component 1's location.
# Here the algorithm is implemented as described in Maxbauer et al.
# (2016) -- each parameter perturbed relative to its own magnitude --
# rather than as that quirk executes.
init = best.copy()
init[:, 0] *= 1.0 + rng.normal(0.0, param_noise, K) # contribution
init[:, 1] *= 1.0 + rng.normal(0.0, param_noise, K) # location
init[:, 2] *= 1.0 + rng.normal(0.0, param_noise, K) # dispersion
if vary_skew:
init[:, 3] += rng.normal(0.0, 5.0 * param_noise, K) # skew jitter
init[:, 0] = np.abs(init[:, 0])
try:
fit = unmix_coercivity_spectrum(
x_mid_b, spectrum_b, vary_skew=vary_skew,
initial_parameters=pd.DataFrame(init,
columns=UNMIX_PARAM_COLUMNS),
**replicate_kwargs)
except (ValueError, RuntimeError):
continue
if not fit['success']:
continue
p = fit['params']
for name in tracked:
samples[name].append(p[name].to_numpy())
arr = p[UNMIX_PARAM_COLUMNS].to_numpy()
total_curves.append(coercivity_spectrum_model(x_grid, arr))
comp_curves.append(coercivity_spectrum_components(x_grid, arr))
n_success += 1
if n_success < max(10, n_boot // 10):
raise RuntimeError(f'only {n_success} of {n_boot} MAX UnMix '
'replicates converged; check the fit or initial '
'parameters')
param_summary = _summarize_component_samples(
{name: np.array(vals) for name, vals in samples.items()}, K)
curves = _percentile_curve_bands(x_grid, total_curves, comp_curves)
result['bootstrap'] = {
'method': 'maxunmix',
'n_boot': n_boot,
'n_success': n_success,
'resample': 'maxunmix: 95% subsample without replacement '
'+ 2% restart-parameter perturbation',
'proportion': proportion,
'param_noise': param_noise,
'param_summary': param_summary,
'param_samples': {name: np.array(vals)
for name, vals in samples.items()},
'curves': curves,
}
return result
UNMIXING_METHODS = {
'spectrum': _unmix_method_spectrum,
'curve': _unmix_method_curve,
'maxunmix': _unmix_method_maxunmix,
}
[docs]
def register_unmixing_method(name, function):
"""
Register a custom coercivity unmixing method.
Registered methods become available through unmix_coercivity and
unmix_backfield_experiments alongside the built-in 'spectrum', 'curve',
and 'maxunmix' methods. The function must accept
(x, magnetization, n_components=None, initial_parameters=None,
curve_type='backfield', vary_skew=True, **kwargs) and return the
standardized result dictionary (see unmix_coercivity_spectrum); the
component model helpers (skewnormal_pdf, coercivity_spectrum_model,
coercivity_curve_model, ...) can be reused when implementing new
methods.
Parameters
----------
name : str
Name under which the method is registered.
function : callable
The method implementation.
Returns
-------
None
"""
if not callable(function):
raise TypeError('function must be callable')
UNMIXING_METHODS[name] = function
[docs]
def unmix_coercivity(x, magnetization, method=DEFAULT_UNMIX_METHOD,
n_components=None, initial_parameters=None,
curve_type='backfield',
vary_skew=DEFAULT_UNMIX_VARY_SKEW, **kwargs):
"""
Unmix a remanence curve into coercivity components with a named method.
This is the common entry point to the unmixing approaches implemented
in rockmagpy (and to any user-registered methods; see
register_unmixing_method). All methods take the measured remanence
curve -- not a precomputed derivative -- and share the same skew-normal
component parameterization, so their results are directly comparable.
Built-in methods:
- 'spectrum': fits the finite-difference coercivity spectrum
dM/dlog10(B) with skew-normal components (Kruiver et al., 2001;
Egli, 2003 lineage). Point estimates only; combine with
unmixing_bootstrap for uncertainties.
- 'curve': fits the measured curve directly with cumulative
(CDF) components, avoiding numerical differentiation entirely.
- 'maxunmix': the 'spectrum' fit plus the MAX UnMix resampling
uncertainty scheme (Maxbauer et al., 2016): 95% case resampling with
2% noise, 100 replicates by default.
Parameters
----------
x : array-like
log10 of field values (mT), e.g. 'log_dc_field' from
process_backfield_data.
magnetization : array-like
Remanence curve values at x (e.g. 'magn_mass_shift').
method : str
Name of a registered unmixing method (default 'spectrum').
n_components : int, optional
Number of components (required if initial_parameters is None).
initial_parameters : pandas.DataFrame, optional
Initial guesses with columns 'contribution', 'location', 'dp',
'skew'; automatic estimates are used when omitted.
curve_type : str
'backfield' or 'acquisition'.
vary_skew : bool
Whether skew parameters vary during fitting.
**kwargs
Passed through to the method implementation (e.g. n_boot,
proportion, param_noise for 'maxunmix'; fit_offset for 'curve';
dp_bounds, skew_bounds for the spectrum-based methods).
Returns
-------
dict
Standardized result dictionary (see unmix_coercivity_spectrum).
"""
if method not in UNMIXING_METHODS:
raise ValueError(f"unknown unmixing method '{method}'; available: "
f'{sorted(UNMIXING_METHODS)}')
return UNMIXING_METHODS[method](x, magnetization,
n_components=n_components,
initial_parameters=initial_parameters,
curve_type=curve_type,
vary_skew=vary_skew, **kwargs)
[docs]
def compare_unmixing_models(results):
"""
Compare unmixing fits with different numbers of components.
Builds a comparison table with information criteria and sequential
F-tests. All results must be fits of the same data with the same
method ('spectrum' or 'curve'); AIC/BIC values are only meaningful
relative to one another under that condition. The F-test compares each
model to the previous (simpler) one; a small p-value indicates the
additional component produces a statistically significant improvement.
As emphasized by Maxbauer et al. (2016) and Egli (2003), statistical
significance alone should not decide the number of components --
independent knowledge of the likely magnetic mineralogy should inform
the choice.
Parameters
----------
results : list of dict
Result dictionaries from unmix_coercivity_spectrum or
unmix_backfield_curve, typically with increasing n_components.
Returns
-------
pandas.DataFrame
One row per model with n_components, n_params, rss, r_squared,
aic, bic, delta_aic, delta_bic, F, and p_value columns.
"""
from scipy.stats import f as f_distribution
if len(results) == 0:
raise ValueError('results list is empty')
methods = {r['method'] for r in results}
lengths = {len(r['x']) for r in results}
if len(methods) > 1 or len(lengths) > 1:
raise ValueError('all results must fit the same data with the same '
'method for a meaningful comparison')
ordered = sorted(results, key=lambda r: r['stats']['n_params'])
rows = []
for i, r in enumerate(ordered):
s = r['stats']
row = {
'n_components': r['n_components'],
'n_params': s['n_params'],
'rss': s['rss'],
'r_squared': s['r_squared'],
'aic': s['aic'],
'bic': s['bic'],
'F': np.nan,
'p_value': np.nan,
}
if i > 0:
s0 = ordered[i - 1]['stats']
dp_extra = s['n_params'] - s0['n_params']
if dp_extra > 0 and s['dof'] > 0 and s['rss'] > 0:
F = ((s0['rss'] - s['rss']) / dp_extra) / (s['rss'] / s['dof'])
row['F'] = F
row['p_value'] = float(f_distribution.sf(F, dp_extra,
s['dof']))
rows.append(row)
table = pd.DataFrame(rows)
table['delta_aic'] = table['aic'] - table['aic'].min()
table['delta_bic'] = table['bic'] - table['bic'].min()
return table
[docs]
def estimate_measurement_noise(x, magnetization, curve_type='backfield'):
"""
Robustly estimate the measurement noise of a remanence curve.
Suppresses the smooth signal and returns a robust standard deviation of
the residual scatter, without any smoothing or model fitting. This
provides the noise scale needed to judge when an unmixing model fits "to
within the measurement noise" (see select_n_components).
For each interior point the smooth signal is removed by comparing the
point to the value predicted for it by cubic interpolation through its
four nearest neighbours (two on each side) at their actual field
positions; the residual is scaled by the standard deviation that
combination would have under white noise, and a robust (median-absolute)
scale is taken. Because the interpolation is exact for any polynomial of
degree <= 3, this removes not just the local slope but the curvature of
the sigmoidal curve, so the estimator is little biased even on the coarse
and non-uniform (typically log-spaced) field grids of backfield/IRM
measurements -- unlike a plain second difference, which cancels only a
linear trend and inflates the noise where the curve bends.
Parameters
----------
x : array-like
log10 of field values (mT); the data are sorted by x internally.
magnetization : array-like
Remanence curve values at x.
curve_type : str
Unused; accepted for signature consistency with the unmixing
functions.
Returns
-------
float
Estimated measurement noise standard deviation in magnetization
units.
"""
x = np.asarray(x, dtype=float)
M = np.asarray(magnetization, dtype=float)
order = np.argsort(x)
x, M = x[order], M[order]
if len(M) < 5:
return float(np.std(M))
# predict each interior point by cubic interpolation through its four
# neighbours (i-2, i-1, i+1, i+2) at their true positions; the residual
# cancels any signal up to a local cubic, so both slope and curvature of
# the smooth curve are removed for arbitrary spacing
c = np.arange(2, len(x) - 2)
x0 = x[c]
xa, xb, xc, xd = x[c - 2], x[c - 1], x[c + 1], x[c + 2]
Ma, Mb, Mc, Md = M[c - 2], M[c - 1], M[c + 1], M[c + 2]
def lagrange(xi, xj, xk, xl):
# weight of the point at xi in the cubic interpolant evaluated at x0
return ((x0 - xj) * (x0 - xk) * (x0 - xl)
/ ((xi - xj) * (xi - xk) * (xi - xl)))
with np.errstate(divide='ignore', invalid='ignore'):
La = lagrange(xa, xb, xc, xd)
Lb = lagrange(xb, xa, xc, xd)
Lc = lagrange(xc, xa, xb, xd)
Ld = lagrange(xd, xa, xb, xc)
residual = M[c] - (La * Ma + Lb * Mb + Lc * Mc + Ld * Md)
# under white noise var(residual) = sigma^2 (1 + La^2 + Lb^2 + ...)
scale = np.sqrt(1.0 + La ** 2 + Lb ** 2 + Lc ** 2 + Ld ** 2)
normalized = np.abs(residual / scale)
normalized = normalized[np.isfinite(normalized)]
if normalized.size == 0:
return float(np.std(M))
return float(1.482602 * np.median(normalized))
[docs]
def select_n_components(x, magnetization, method=DEFAULT_UNMIX_METHOD,
min_components=1,
max_components=4, criterion='parsimony',
min_improvement=0.02, noise_level=None,
reduced_chi2_target=1.0, curve_type='backfield',
vary_skew=DEFAULT_UNMIX_VARY_SKEW, verbose=False,
**kwargs):
"""
Choose the number of coercivity components by a parsimony rule.
Fits models with a range of component counts and selects the simplest
one that adequately describes the data. This is deliberately different
from minimizing an information criterion or maximizing the Bayesian
evidence: with high-resolution, low-noise curves those measures tend to
keep favoring more components indefinitely, because a real coercivity
distribution is never exactly log-Gaussian and each added component
removes a little more systematic misfit. Following the parsimony
principle emphasized by Egli (2003) and Heslop (2015), an extra
component is accepted only when it produces a large enough improvement,
so a simpler adequate model is preferred.
Two selection criteria are provided:
- 'parsimony' (default): an added component is retained only if it
reduces the residual sum of squares by at least `min_improvement`
times the baseline (single-component) residual. Because the second
component typically removes most of the baseline misfit while a
spurious third component removes only a tiny fraction of it, this
robustly stops at the mineralogically meaningful count regardless of
the noise level.
- 'chi2': the simplest model whose reduced chi-square (using
`noise_level`, estimated with estimate_measurement_noise if not
given) falls at or below `reduced_chi2_target`, i.e. the simplest
model that fits to within the measurement noise. The noise estimator
is spacing-aware and unbiased on the coarse, log-spaced field grids of
backfield/IRM data, but 'chi2' remains the less robust criterion: like
the information criteria and the Bayesian evidence it tends to
over-select on high-resolution, low-noise curves (once the noise is
not over-estimated, any small departure of the data from a log-Gaussian
pushes the reduced chi-square of a real-count model just above one), and
it is sensitive to the residual scatter of the noise estimate. Prefer
'parsimony', or supply a trusted `noise_level` and a `reduced_chi2_target`
slightly above 1, when using it. For spectrum-space methods ('spectrum',
'maxunmix', or a Bayesian fit with space='spectrum') the residuals and
hence the noise are in dM/dlog10(B) units, so the noise is estimated on
the finite-difference spectrum rather than the measured curve; that
spectrum noise is mildly correlated, which the estimator does not model,
making the spectrum-space chi2 more approximate still.
In all cases the returned table reports the fit statistics, the
fractional RSS improvement per added component, and (when a noise level
is available) the reduced chi-square, so the selection can be inspected
and overridden.
Parameters
----------
x : array-like
log10 of field values (mT).
magnetization : array-like
Remanence curve values at x.
method : str
Registered unmixing method used for each fit (default
DEFAULT_UNMIX_METHOD, i.e. 'spectrum').
min_components, max_components : int
Range of component counts to consider.
criterion : str
'parsimony' or 'chi2'.
min_improvement : float
For 'parsimony': minimum fraction of the baseline residual an added
component must explain to be retained (default 0.02).
noise_level : float, optional
Measurement noise standard deviation for 'chi2'; estimated from the
data if not given.
reduced_chi2_target : float
For 'chi2': the reduced chi-square at or below which a model is
considered adequate (default 1.0).
curve_type : str
'backfield' or 'acquisition'.
vary_skew : bool
Whether skew varies during fitting.
verbose : bool
Print the selection outcome.
**kwargs
Passed to the unmixing method.
Returns
-------
tuple
(selected_n, table, results) where selected_n is the chosen number
of components, table is a DataFrame of per-model statistics (with a
boolean 'selected' column), and results maps component count ->
result dictionary.
"""
assert criterion in ('parsimony', 'chi2'), \
"criterion must be 'parsimony' or 'chi2'"
if method not in UNMIXING_METHODS:
raise ValueError(f"unknown unmixing method '{method}'; available: "
f'{sorted(UNMIXING_METHODS)}')
counts = list(range(min_components, max_components + 1))
if len(counts) == 0:
raise ValueError('max_components must be >= min_components')
# The chi2 adequacy test compares the fit residual sum of squares to the
# noise level, so the noise must be estimated in the SAME data space the
# residuals live in. Spectrum-space methods ('spectrum', 'maxunmix', and
# Bayesian fits with space='spectrum') fit dM/dlog10(B), not the measured
# curve, so their rss is in spectrum units; estimating the noise on the
# measured curve instead would mix units and inflate reduced_chi2 by the
# (large) finite-difference amplification factor, defeating the criterion.
# The spacing-aware estimator applied to the finite-difference spectrum
# returns the spectrum-space noise scale directly, accounting for the
# non-uniform log-grid spacing. (The finite-difference spectrum noise is
# mildly correlated, which this white-noise estimator does not model, so
# the spectrum-space chi2 is approximate -- consistent with chi2 being the
# less robust criterion; see the note below.)
fits_spectrum = (method in ('spectrum', 'maxunmix')
or (method == 'bayes' and kwargs.get('space') == 'spectrum'))
if noise_level is None:
if fits_spectrum:
noise_x, noise_y = coercivity_spectrum_from_curve(
x, magnetization, curve_type)
else:
noise_x, noise_y = x, magnetization
noise_level = estimate_measurement_noise(noise_x, noise_y, curve_type)
# trial fits only need the point fit: for method='maxunmix' suppress the
# per-count resampling (n_boot=0), whose uncertainty output plays no role
# in the selection; the selected count is re-fit with the requested
# resampling after the selection below
trial_kwargs = dict(kwargs)
if method == 'maxunmix':
trial_kwargs['n_boot'] = 0
results = {}
rows = []
for K in counts:
result = unmix_coercivity(x, magnetization, method=method,
n_components=K, curve_type=curve_type,
vary_skew=vary_skew, **trial_kwargs)
results[K] = result
stats = result['stats']
rows.append({
'n_components': K,
'n_params': stats['n_params'],
'rss': stats['rss'],
'r_squared': stats['r_squared'],
'aic': stats['aic'],
'bic': stats['bic'],
'reduced_chi2': (stats['rss'] / (noise_level ** 2 * stats['dof'])
if stats['dof'] > 0 else np.nan),
})
table = pd.DataFrame(rows)
baseline_rss = table['rss'].iloc[0]
improvement = [np.nan]
for i in range(1, len(table)):
improvement.append((table['rss'].iloc[i - 1] - table['rss'].iloc[i])
/ baseline_rss if baseline_rss > 0 else np.nan)
table['rss_improvement_fraction'] = improvement
if criterion == 'parsimony':
selected = counts[0]
for i in range(1, len(table)):
if table['rss_improvement_fraction'].iloc[i] >= min_improvement:
selected = counts[i]
else:
break
else: # chi2 adequacy
adequate = table[table['reduced_chi2'] <= reduced_chi2_target]
selected = (int(adequate['n_components'].iloc[0]) if len(adequate)
else counts[-1])
table['selected'] = table['n_components'] == selected
if method == 'maxunmix' and kwargs.get('n_boot', 100) != 0:
# restore the full MAX UnMix resampling uncertainty for the selected
# count only
results[selected] = unmix_coercivity(
x, magnetization, method=method,
initial_parameters=results[selected]['params'][
UNMIX_PARAM_COLUMNS],
curve_type=curve_type, vary_skew=vary_skew, **kwargs)
if verbose:
print(f"selected {selected} component(s) by '{criterion}' criterion "
f'(noise ~ {noise_level:.3g})')
return selected, table, results
[docs]
def unmixing_bootstrap(result, n_boot=500, resample='cases', proportion=1.0,
noise_level=None, random_seed=None, n_grid=200,
verbose=False):
"""
Bootstrap uncertainty estimation for an unmixing result.
Repeatedly refits the model to resampled data, starting each fit from
the best-fit parameters, and summarizes the distributions of parameters
and model curves. Two resampling schemes are available:
- 'cases': data points are drawn with replacement ('proportion'
controls the resample size relative to the data). With
proportion=0.95 and noise_level=0.02 this emulates the resampling
scheme of the MAX UnMix program (Maxbauer et al., 2016).
- 'residuals': the best-fit curve is perturbed with resampled fit
residuals, preserving the field spacing of the original data.
Bootstrap distributions capture the full nonlinearity of the model and
are generally more trustworthy than the linearized standard errors in
the 'params' table, especially for strongly overlapping components.
Parameters
----------
result : dict
Result from unmix_coercivity_spectrum or unmix_backfield_curve.
n_boot : int
Number of bootstrap replicates (default 500).
resample : str
'cases' or 'residuals'.
proportion : float
Fraction of the data resampled per replicate for 'cases' (default 1).
noise_level : float, optional
If given, multiplicative Gaussian noise with this relative standard
deviation is added to each resampled dataset (MAX UnMix uses 0.02).
random_seed : None, int, or numpy.random.Generator
Seed for reproducibility.
n_grid : int
Number of grid points for the model-curve confidence bands.
verbose : bool
Print a progress summary.
Returns
-------
dict
A copy of the input result with an added 'bootstrap' entry
containing 'param_summary' (per-component mean/std/percentiles for
each parameter and derived quantity), 'curves' (percentile bands of
the total and per-component model on 'x_grid'), 'n_success', and
'param_samples' (the raw bootstrap parameter arrays).
"""
assert resample in ('cases', 'residuals'), \
"resample must be 'cases' or 'residuals'"
assert 0 < proportion <= 1, 'proportion must be in (0, 1]'
rng = _resolve_rng(random_seed)
method = result['method']
if method == 'bayes':
raise ValueError(
"unmixing_bootstrap does not apply to Bayesian results, which "
"already carry full posterior uncertainty in "
"result['bayes']['param_summary'] (credible intervals). To "
"bootstrap a least-squares fit, pass a 'curve', 'spectrum', or "
"'maxunmix' result instead.")
# dispatch the refit on the stored fitting space, not the method name:
# 'spectrum' and 'maxunmix' both fit the derivative spectrum, so both
# must be refit with the spectrum model (keying on method == 'spectrum'
# alone sent maxunmix results through the cumulative-curve branch)
fits_spectrum = _result_is_spectrum(result)
x, y = result['x'], result['y']
curve_type = result['curve_type']
vary_skew = result['vary_skew']
K = result['n_components']
best = result['params'][UNMIX_PARAM_COLUMNS].copy().reset_index(drop=True)
n = len(x)
n_sample = max(int(n * proportion), 3 * K + 2)
x_grid = np.linspace(x.min(), x.max(), n_grid)
tracked = ['contribution', 'proportion', 'location', 'dp', 'skew',
'sd_log', 'B_mean_mT', 'B_median_mT', 'B_peak_mT']
samples = {name: [] for name in tracked}
total_curves = []
comp_curves = []
n_success = 0
for _ in range(n_boot):
if resample == 'cases':
idx = rng.choice(n, size=n_sample, replace=True)
xb, yb = x[idx], y[idx]
else:
perturbation = rng.choice(result['residuals'], size=n,
replace=True)
xb, yb = x, result['y_fit'] + perturbation
if noise_level is not None:
yb = yb * (1.0 + rng.normal(0.0, noise_level, size=len(yb)))
try:
if fits_spectrum:
fit = unmix_coercivity_spectrum(xb, yb,
initial_parameters=best,
vary_skew=vary_skew)
else:
fit = unmix_backfield_curve(xb, yb, initial_parameters=best,
curve_type=curve_type,
vary_skew=vary_skew)
except (ValueError, RuntimeError):
continue
if not fit['success']:
continue
# components are sorted by mean coercivity within each fit, which
# aligns replicate components with the reference ordering
p = fit['params']
for name in tracked:
samples[name].append(p[name].to_numpy())
arr = p[UNMIX_PARAM_COLUMNS].to_numpy()
if fits_spectrum:
total_curves.append(coercivity_spectrum_model(x_grid, arr))
comp_curves.append(coercivity_spectrum_components(x_grid, arr))
else:
total_curves.append(coercivity_curve_model(
x_grid, arr, offset=fit['offset'], curve_type=curve_type))
comp_curves.append(coercivity_curve_components(
x_grid, arr, curve_type=curve_type))
n_success += 1
if n_success < max(10, n_boot // 10):
raise RuntimeError(f'only {n_success} of {n_boot} bootstrap '
'replicates converged; check the fit or initial '
'parameters')
if verbose:
print(f'{n_success}/{n_boot} bootstrap replicates converged')
param_summary = _summarize_component_samples(
{name: np.array(vals) for name, vals in samples.items()}, K)
curves = _percentile_curve_bands(x_grid, total_curves, comp_curves)
result_out = copy.deepcopy(result)
result_out['bootstrap'] = {
'n_boot': n_boot,
'n_success': n_success,
'resample': resample,
'proportion': proportion,
'noise_level': noise_level,
'param_summary': param_summary,
'param_samples': {name: np.array(vals)
for name, vals in samples.items()},
'curves': curves,
}
return result_out
[docs]
def unmixing_multistart(x, magnetization, method=DEFAULT_UNMIX_METHOD,
n_components=2, n_starts=100,
vary_skew=DEFAULT_UNMIX_VARY_SKEW,
curve_type='backfield',
random_seed=None, location_tolerance=0.1,
proportion_tolerance=0.05, verbose=False, **kwargs):
"""
Map the distinct unmixing solutions reachable from many initializations.
Coercivity unmixing is a non-convex problem: fits started from
different initial parameters can converge to different local minima
that describe the data almost equally well. A single fit (and a
bootstrap of it, which restarts every replicate from the best-fit
values) is conditioned on one solution basin and therefore hides this
non-uniqueness. This function launches the fit from n_starts dispersed
random initializations (plus the automatic peak-detection estimate),
clusters the converged solutions, and reports each distinct solution
with its fit statistics and Akaike weight, making the degeneracy of
the decomposition explicit.
Parameters
----------
x : array-like
log10 of field values (mT).
magnetization : array-like
Remanence curve values at x (e.g. 'magn_mass_shift').
method : str
Registered unmixing method used for each fit (default
DEFAULT_UNMIX_METHOD, i.e. 'spectrum').
n_components : int
Number of components (default 2).
n_starts : int
Number of random initializations (default 100).
vary_skew : bool
Whether skew varies during fitting; random starts draw skew from
[-5, 5] when True.
curve_type : str
'backfield' or 'acquisition'.
random_seed : None, int, or numpy.random.Generator
Seed for reproducible starting points.
location_tolerance : float
Two solutions are considered the same when all component
locations agree within this tolerance (log10 units, default 0.1)
and all proportions agree within proportion_tolerance.
proportion_tolerance : float
Proportion agreement tolerance (default 0.05).
verbose : bool
Print a summary of the distinct solutions.
**kwargs
Passed through to the unmixing method.
Returns
-------
dict
The best (lowest RSS) result dictionary, augmented with a
'multistart' entry containing 'solutions' (a DataFrame with one
row per distinct solution: n_hits, rss, r_squared, aic,
delta_aic, akaike_weight, and per-component B_mean_mT / sd_log /
proportion columns), 'results' (the representative result
dictionary for each solution, in the same order), 'n_starts', and
'n_converged'.
"""
if method not in UNMIXING_METHODS:
raise ValueError(f"unknown unmixing method '{method}'; available: "
f'{sorted(UNMIXING_METHODS)}')
rng = _resolve_rng(random_seed)
x = np.asarray(x, dtype=float)
M = np.asarray(magnetization, dtype=float)
x_mid, spectrum = coercivity_spectrum_from_curve(x, M, curve_type)
total_area = np.abs(_trapz(spectrum, x_mid))
K = n_components
starting_tables = [None] # None -> automatic estimation inside the method
lo, hi = x.min() + 0.05, x.max() - 0.05
for _ in range(n_starts):
locations = np.sort(rng.uniform(lo, hi, size=K))
dps = np.exp(rng.uniform(np.log(0.03), np.log(0.8), size=K))
proportions = rng.dirichlet(np.ones(K))
skews = rng.uniform(-5, 5, size=K) if vary_skew else np.zeros(K)
starting_tables.append(pd.DataFrame({
'contribution': proportions * total_area,
'location': locations,
'dp': dps,
'skew': skews,
}))
# trial fits only need the point fit: for method='maxunmix' suppress the
# per-trial resampling (n_boot=0) -- otherwise every trial runs the full
# MAX UnMix bootstrap whose uncertainty output is discarded (only rss and
# params feed the clustering) -- and re-run the winning solution once
# with the requested resampling below
trial_kwargs = dict(kwargs)
if method == 'maxunmix':
trial_kwargs['n_boot'] = 0
fits = []
for table in starting_tables:
try:
fit = unmix_coercivity(x, M, method=method,
n_components=K if table is None else None,
initial_parameters=table,
curve_type=curve_type,
vary_skew=vary_skew, **trial_kwargs)
except (ValueError, RuntimeError):
continue
if fit['success'] and np.isfinite(fit['stats']['rss']):
fits.append(fit)
if not fits:
raise RuntimeError('no multistart fits converged')
# cluster converged fits into distinct solutions (best fit first)
fits.sort(key=lambda f: f['stats']['rss'])
clusters = [] # list of dicts: {'representative': fit, 'n_hits': int}
for fit in fits:
locations = fit['params']['location'].to_numpy()
proportions = fit['params']['proportion'].to_numpy()
for cluster in clusters:
ref = cluster['representative']['params']
same_location = np.all(np.abs(
locations - ref['location'].to_numpy()) <= location_tolerance)
same_proportion = np.all(np.abs(
proportions - ref['proportion'].to_numpy())
<= proportion_tolerance)
if same_location and same_proportion:
cluster['n_hits'] += 1
break
else:
clusters.append({'representative': fit, 'n_hits': 1})
aics = np.array([c['representative']['stats']['aic'] for c in clusters])
delta_aic = aics - aics.min()
with np.errstate(over='ignore'):
weights = np.exp(-0.5 * delta_aic)
weights = weights / weights.sum()
rows = []
for i, cluster in enumerate(clusters):
fit = cluster['representative']
row = {
'solution': i + 1,
'n_hits': cluster['n_hits'],
'rss': fit['stats']['rss'],
'r_squared': fit['stats']['r_squared'],
'aic': fit['stats']['aic'],
'delta_aic': delta_aic[i],
'akaike_weight': weights[i],
}
for comp_index, prow in fit['params'].iterrows():
row[f'B_mean_mT_c{comp_index}'] = prow['B_mean_mT']
row[f'sd_log_c{comp_index}'] = prow['sd_log']
row[f'proportion_c{comp_index}'] = prow['proportion']
rows.append(row)
solutions = pd.DataFrame(rows).set_index('solution')
if verbose:
print(f'{len(fits)}/{len(starting_tables)} fits converged; '
f'{len(clusters)} distinct solution(s)')
best = copy.deepcopy(clusters[0]['representative'])
if method == 'maxunmix' and kwargs.get('n_boot', 100) != 0:
# restore the full MAX UnMix resampling uncertainty for the winning
# solution only
best = unmix_coercivity(
x, M, method=method,
initial_parameters=best['params'][UNMIX_PARAM_COLUMNS],
curve_type=curve_type, vary_skew=vary_skew, **kwargs)
best['multistart'] = {
'solutions': solutions,
'results': [c['representative'] for c in clusters],
'n_starts': len(starting_tables),
'n_converged': len(fits),
'method': method,
}
return best
# Bayesian unmixing via nested sampling
# ------------------------------------------------------------------------------------------------------------------
def _check_dynesty():
try:
import dynesty # noqa: F401
except ImportError:
raise ImportError(
'dynesty is required for Bayesian unmixing. '
'Install it with: pip install dynesty')
def _ordered_uniform_transform(u, low, high):
"""
Map unit-cube draws to ordered uniform order statistics on [low, high].
Uses the standard recursive inverse-CDF construction so that the
returned values are jointly distributed as the order statistics of K
independent uniforms, which enforces component ordering (and thereby
removes label switching) without distorting the prior.
"""
K = len(u)
ordered = np.empty(K)
upper = 1.0
for k in range(K - 1, -1, -1):
upper = upper * u[k] ** (1.0 / (k + 1))
ordered[k] = upper
return low + (high - low) * ordered
[docs]
def unmix_coercivity_bayes(x, magnetization, n_components=2,
curve_type='backfield', space='curve',
vary_skew=False,
fit_offset=True, priors=None, nlive=250,
dlogz=0.1, sample='rslice', random_seed=None,
n_grid=200, n_posterior_curves=300,
verbose=False):
"""
Bayesian coercivity unmixing by nested sampling (requires dynesty).
The remanence data are modeled as a sum of skew-normal components, with
the noise standard deviation treated as a free parameter, and sampled
with static nested sampling (Skilling, 2006) as implemented in dynesty
(Speagle, 2020), which also returns the Bayesian evidence (logz) -- the
principled criterion for choosing the number of components (compare logz
between runs with different n_components). The fit can be performed in
either of two data spaces (see the `space` argument):
- space='curve' (default): the measured curve M(B) is fit directly with
cumulative skew-normal components plus a constant offset. An
independent Gaussian noise model is defensible here, and no numerical
differentiation is required. This is the more conservative choice.
- space='spectrum': the finite-difference coercivity spectrum
dM/dlog10(B) is fit with skew-normal densities (no offset). Fitting the
derivative directly reproduces the coercivity-distribution peak that a
curve fit can under-represent, and lets skewness be constrained by the
peak shape. The trade-off is that differencing correlates adjacent
points, so the i.i.d. Gaussian likelihood used here is an approximation
(the same one the least-squares and MAX UnMix spectrum fits make);
credible intervals in this space should be read with that caveat.
The component parameterization (contribution=area, location, dp, skew) is
identical in both spaces, so their results are directly comparable, and
comparing them is a useful robustness check.
Unlike bootstrap resampling of a single fit, the posterior represents
the full range of component decompositions consistent with the data
and priors: parameter trade-offs between overlapping components appear
as wide, correlated, and possibly multimodal posterior distributions
rather than being hidden by a single optimizer solution.
Component locations are sampled as ordered order-statistics, which
fixes component labels without distorting the prior. Default priors
are weakly informative (locations uniform across the measured field
range, dispersions log-uniform on [0.02, 1.0] decades, contributions
uniform up to ~3x the data range); mineralogical knowledge can be
injected through the priors argument.
Parameters
----------
x : array-like
log10 of field values (mT).
magnetization : array-like
Remanence curve values at x (e.g. 'magn_mass_shift').
n_components : int
Number of components (default 2).
curve_type : str
'backfield' or 'acquisition'.
space : str
'curve' (fit the measured curve, default) or 'spectrum' (fit the
finite-difference dM/dlog10(B) spectrum). See the summary above for
the trade-offs; with space='spectrum' the offset is not used.
vary_skew : bool
Sample component skew (uniform prior on [-10, 10]); default False
(symmetric log-Gaussian components). This deliberately deviates from
DEFAULT_UNMIX_VARY_SKEW: free skew multiplies the nested-sampling
cost and is usually better constrained through explicit `priors`
windows (e.g. from mineral_priors) than left fully free.
fit_offset : bool
Include a constant baseline offset (default True; ignored when
space='spectrum').
priors : dict, optional
Overrides for the default prior bounds. Recognized keys:
'mean', 'location', 'dp', 'contribution', 'skew' map to a list
of (low, high) tuples, one per component (in log10 units for
'mean'/'location'/'dp', magnetization units for 'contribution');
'offset' and 'noise' map to a single (low, high) tuple in
magnetization units. A 'mean' window constrains each component's
MEAN coercivity (log10 mT): the mean is sampled uniformly in the
window and the skew-normal location is derived from the sampled dp
and skew, so the window means what it says even for skewed
components (this is what mineral_priors produces). A 'location'
window instead constrains the raw location parameter directly.
Either replaces the weakly-informative ordered-uniform default, so
the windows should be non-overlapping or ordered to keep component
labels meaningful. 'mean' takes precedence over 'location' if both
are given.
nlive : int
Number of live points (default 250).
dlogz : float
Evidence convergence tolerance (default 0.1).
sample : str
dynesty sampling method (default 'rslice'). Slice sampling is
robust to the thin, curved likelihood ridges that overlapping
components produce; the dynesty default uniform-ellipsoid
sampler can stall on such geometries.
random_seed : None, int, or numpy.random.Generator
Seed for reproducibility.
n_grid : int
Grid size for posterior model bands.
n_posterior_curves : int
Number of posterior draws used for the model bands (default 300).
verbose : bool
Show dynesty progress.
Returns
-------
dict
Standardized result dictionary (parameters set to posterior
medians) with an added 'bayes' entry containing 'param_summary'
(per-component posterior mean/std/percentiles, same format as the
bootstrap summary), 'samples' (equally weighted posterior draws
for every parameter and derived quantity), 'logz', 'logzerr',
'noise' (posterior median noise standard deviation), and 'curves'
(posterior percentile bands of the model).
"""
_check_dynesty()
import dynesty
assert curve_type in ('backfield', 'acquisition'), \
"curve_type must be 'backfield' or 'acquisition'"
assert space in ('curve', 'spectrum'), \
"space must be 'curve' or 'spectrum'"
rng = _resolve_rng(random_seed)
priors = dict(priors or {})
x = np.asarray(x, dtype=float)
y = np.asarray(magnetization, dtype=float)
finite = np.isfinite(x) & np.isfinite(y)
x, y = x[finite], y[finite]
if space == 'spectrum':
# fit the finite-difference spectrum dM/dlog10(B); there is no
# baseline offset in derivative space
x, y = coercivity_spectrum_from_curve(x, y, curve_type)
fit_offset = False
y_scale = np.max(np.abs(y))
if y_scale == 0:
raise ValueError('magnetization data are all zero')
ys = y / y_scale
K = n_components
n_data = len(x)
def _forward(params, offset):
if space == 'spectrum':
return coercivity_spectrum_model(x, params)
return coercivity_curve_model(x, params, offset=offset,
curve_type=curve_type)
def _forward_components(grid, params):
if space == 'spectrum':
return coercivity_spectrum_components(grid, params)
return coercivity_curve_components(grid, params, curve_type=curve_type)
# prior bounds (normalized units for contribution/offset/noise)
# A component's coercivity window can be given either directly on the
# skew-normal 'location' parameter, or -- preferably, and as produced by
# mineral_priors -- on the component's MEAN coercivity via 'mean'. For a
# skewed component the location is not itself a physical coercivity, so a
# 'mean' window keeps the constraint on the reported mean coercivity
# (10**mean_log): the mean is sampled uniformly in the window and the
# location is derived from the sampled dp and skew.
mean_bounds = priors.get('mean')
if mean_bounds is not None:
mean_bounds = [tuple(b) for b in mean_bounds]
assert len(mean_bounds) == K
location_bounds = priors.get('location')
if location_bounds is not None:
location_bounds = [tuple(b) for b in location_bounds]
assert len(location_bounds) == K
location_range = (x.min() - 0.2, x.max() + 0.2)
dp_bounds = priors.get('dp', [(0.02, 1.0)] * K)
contribution_bounds = [(low / y_scale, high / y_scale) for low, high in
priors.get('contribution',
[(0.0, 3.0 * y_scale)] * K)]
skew_bounds = priors.get('skew', [(-10.0, 10.0)] * K)
offset_low, offset_high = np.array(
priors.get('offset', (-0.2 * y_scale, 0.2 * y_scale))) / y_scale
noise_low, noise_high = np.array(
priors.get('noise', (1e-4 * y_scale, 0.3 * y_scale))) / y_scale
ndim = 3 * K + (K if vary_skew else 0) + (1 if fit_offset else 0) + 1
# unit-cube layout: contributions[0:K], locations[K:2K], dps[2K:3K],
# skews[3K:4K] (if vary_skew), offset, noise. dps and skews are drawn
# before locations so that a 'mean' window can derive the location from
# the sampled dp and skew.
def prior_transform(u):
theta = np.empty(ndim)
for k in range(K): # contributions
low, high = contribution_bounds[k]
theta[k] = low + (high - low) * u[k]
for k in range(K): # dispersions, log-uniform
low, high = dp_bounds[k]
theta[2 * K + k] = np.exp(np.log(low)
+ (np.log(high) - np.log(low))
* u[2 * K + k])
if vary_skew:
for k in range(K):
low, high = skew_bounds[k]
theta[3 * K + k] = low + (high - low) * u[3 * K + k]
skew_vals = theta[3 * K:4 * K]
else:
skew_vals = np.zeros(K)
if mean_bounds is not None: # window on the mean coercivity
delta = skew_vals / np.sqrt(1.0 + skew_vals ** 2)
shift = theta[2 * K:3 * K] * delta * np.sqrt(2.0 / np.pi)
for k in range(K):
low, high = mean_bounds[k]
mean_log = low + (high - low) * u[K + k]
theta[K + k] = mean_log - shift[k] # location = mean - shift
elif location_bounds is not None: # window on the location parameter
for k in range(K):
low, high = location_bounds[k]
theta[K + k] = low + (high - low) * u[K + k]
else: # ordered locations (weakly informative default)
theta[K:2 * K] = _ordered_uniform_transform(
u[K:2 * K], location_range[0], location_range[1])
i = 4 * K if vary_skew else 3 * K
if fit_offset:
theta[i] = offset_low + (offset_high - offset_low) * u[i]
i += 1
theta[i] = np.exp(np.log(noise_low)
+ (np.log(noise_high) - np.log(noise_low)) * u[i])
return theta
def unpack(theta):
c = theta[:K]
loc = theta[K:2 * K]
dp = theta[2 * K:3 * K]
i = 3 * K
if vary_skew:
skew = theta[i:i + K]
i += K
else:
skew = np.zeros(K)
offset = theta[i] if fit_offset else 0.0
noise = theta[-1]
return c, loc, dp, skew, offset, noise
log_norm = -0.5 * n_data * np.log(2.0 * np.pi)
def loglike(theta):
c, loc, dp, skew, offset, noise = unpack(theta)
params = np.column_stack([c, loc, dp, skew])
model = _forward(params, offset)
residual = (model - ys) / noise
value = log_norm - n_data * np.log(noise) \
- 0.5 * float(residual @ residual)
return value if np.isfinite(value) else -1e300
sampler = dynesty.NestedSampler(loglike, prior_transform, ndim,
nlive=nlive, sample=sample, rstate=rng)
sampler.run_nested(dlogz=dlogz, print_progress=verbose)
ns_results = sampler.results
samples = ns_results.samples_equal(rstate=rng)
# per-draw derived quantities (analytic, vectorized)
contributions = samples[:, :K] * y_scale
locations = samples[:, K:2 * K]
dps = samples[:, 2 * K:3 * K]
skews = samples[:, 3 * K:4 * K] if vary_skew else np.zeros_like(locations)
offsets = (samples[:, -2] * y_scale if fit_offset
else np.zeros(len(samples)))
noises = samples[:, -1] * y_scale
deltas = skews / np.sqrt(1.0 + skews ** 2)
means_log = locations + dps * deltas * np.sqrt(2.0 / np.pi)
sd_logs = dps * np.sqrt(1.0 - 2.0 * deltas ** 2 / np.pi)
proportions = contributions / contributions.sum(axis=1, keepdims=True)
# per-draw medians and modes (vectorized root/mode finding) so the Bayes
# summary carries the same quantity set as the bootstrap summaries
medians_log = locations + dps * _skewnormal_median_z(skews)
modes_log = locations + dps * _skewnormal_mode_z(skews)
derived = {
'contribution': contributions,
'proportion': proportions,
'location': locations,
'dp': dps,
'skew': skews,
'sd_log': sd_logs,
'B_mean_mT': 10 ** means_log,
'B_median_mT': 10 ** medians_log,
'B_peak_mT': 10 ** modes_log,
}
param_summary = _summarize_component_samples(derived, K)
# posterior-median parameter table in the standard result format
median_params = np.column_stack([
np.median(contributions, axis=0), np.median(locations, axis=0),
np.median(dps, axis=0), np.median(skews, axis=0)])
offset_median = float(np.median(offsets))
rows = []
for comp in range(K):
c_med, loc_med, dp_med, skew_med = median_params[comp]
stats_comp = skewnormal_stats(loc_med, dp_med, skew_med)
rows.append({
'contribution': c_med,
'se_contribution': contributions[:, comp].std(),
'location': loc_med,
'se_location': locations[:, comp].std(),
'dp': dp_med,
'se_dp': dps[:, comp].std(),
'skew': skew_med,
'se_skew': skews[:, comp].std(),
'sd_log': stats_comp['std'],
'log10_B_mean': stats_comp['mean'],
'B_mean_mT': 10 ** stats_comp['mean'],
'B_median_mT': 10 ** stats_comp['median'],
'B_peak_mT': 10 ** stats_comp['mode'],
})
params = pd.DataFrame(rows, index=pd.RangeIndex(1, K + 1,
name='component'))
total = params['contribution'].sum()
params.insert(1, 'proportion', params['contribution'] / total)
y_fit = _forward(median_params, offset_median)
residuals = y - y_fit
rss = float(np.sum(residuals ** 2))
tss = float(np.sum((y - y.mean()) ** 2))
n_params = ndim
stats = {
'n': n_data,
'n_params': n_params,
'dof': n_data - n_params,
'rss': rss,
'r_squared': 1.0 - rss / tss if tss > 0 else np.nan,
'reduced_chi_square': (rss / (n_data - n_params)
if n_data > n_params else np.nan),
'aic': np.nan, # use logz for Bayesian model comparison
'bic': np.nan,
'logz': float(ns_results.logz[-1]),
'logzerr': float(ns_results.logzerr[-1]),
}
# posterior model bands from a subset of equally weighted draws
x_grid = np.linspace(x.min(), x.max(), n_grid)
n_draws = min(n_posterior_curves, len(samples))
draw_index = rng.choice(len(samples), size=n_draws, replace=False)
total_curves = np.empty((n_draws, n_grid))
comp_curves = np.empty((n_draws, K, n_grid))
for j, index in enumerate(draw_index):
draw_params = np.column_stack([
contributions[index], locations[index], dps[index],
skews[index]])
comps = _forward_components(x_grid, draw_params)
comp_curves[j] = comps
total_curves[j] = comps.sum(axis=0) + offsets[index]
curves = _percentile_curve_bands(x_grid, total_curves, comp_curves)
initial_df = pd.DataFrame(median_params, columns=UNMIX_PARAM_COLUMNS)
return {
'method': 'bayes',
'space': space,
'curve_type': curve_type,
'n_components': K,
'success': True,
'message': (f'nested sampling converged '
f'(logz = {stats["logz"]:.2f} '
f'+/- {stats["logzerr"]:.2f})'),
'params': params,
'offset': offset_median,
'se_offset': float(np.std(offsets)),
'x': x,
'y': y,
'y_fit': y_fit,
'residuals': residuals,
'weights': None,
'stats': stats,
'initial_parameters': initial_df,
'vary_skew': vary_skew,
'bayes': {
'param_summary': param_summary,
'samples': {**{name: values for name, values in derived.items()},
'offset': offsets, 'noise': noises},
'logz': stats['logz'],
'logzerr': stats['logzerr'],
'noise': float(np.median(noises)),
'ncall': int(ns_results.ncall.sum()),
'niter': int(ns_results.niter),
'nlive': nlive,
'curves': curves,
},
}
def _unmix_method_bayes(x, magnetization, n_components=None,
initial_parameters=None, curve_type='backfield',
vary_skew=False, priors=None, **kwargs):
"""
Registered 'bayes' method: nested-sampling posterior in measurement
space. If initial_parameters are supplied (e.g. from the interactive
widget) and no explicit priors are given, per-component location and
dispersion priors are centered on them.
"""
# a priors dict from mineral_priors() carries one entry per component
# (as a 'mean' or 'location' window), so n_components can be inferred
if n_components is None and priors is not None:
for key in ('mean', 'location'):
if priors.get(key) is not None:
n_components = len(priors[key])
break
if initial_parameters is not None:
if n_components is None:
n_components = len(initial_parameters)
if priors is None:
table = _unmix_parameters_to_array(initial_parameters)
order = np.argsort(table[:, 1])
table = table[order]
priors = {
'location': [(row[1] - max(3 * row[2], 0.3),
row[1] + max(3 * row[2], 0.3))
for row in table],
'dp': [(max(row[2] / 4, 0.02), min(row[2] * 4, 1.5))
for row in table],
}
if n_components is None:
raise ValueError('specify n_components, initial_parameters, or priors')
return unmix_coercivity_bayes(x, magnetization,
n_components=n_components,
curve_type=curve_type,
vary_skew=vary_skew, priors=priors,
**kwargs)
UNMIXING_METHODS['bayes'] = _unmix_method_bayes
# Coercivity component prior library
# ------------------------------------------------------------------------------------------------------------------
# Characteristic coercivity ranges for common remanence-carrying magnetic
# mineral components, for use as informative priors in Bayesian unmixing
# (unmix_coercivity_bayes) or as initial parameters for the least-squares
# methods. Covered minerals: magnetite (igneous, detrital, eolian,
# pedogenic/extracellular, and biogenic soft/hard), maghemite (pure/soft and
# oxidized-magnetite/hard), the ferrimagnetic iron sulphides greigite and
# pyrrhotite, and the antiferromagnetic hematite (pigmentary/detrital).
#
# GOETHITE is deliberately NOT included. It does not magnetically saturate even
# in ~57 T fields (Roberts 2025, p. 337; only ~2-10% of its Mr is acquired by
# 3 T), so an ordinary backfield or IRM coercivity spectrum sees only an
# ambiguous low-coercivity tail that overlaps hematite and cannot quantify
# goethite; a fitted "goethite" component would be a poorly-constrained minimum
# estimate that invites over-interpretation. A high-coercivity component that
# might be goethite is better flagged and confirmed by independent thermal or
# low-temperature methods (see goethite_removal).
#
# Each entry gives, as (low, high) bounds of a uniform prior:
# 'B_median_mT' : the characteristic (median ~ mean) coercivity window, mT.
# mineral_priors passes this to unmix_coercivity_bayes as a
# 'mean' window, so it constrains the component's MEAN
# coercivity (10**B_mean_mT) directly, whatever its skew.
# 'dp' : the dispersion (one standard deviation of the log10-field
# distribution), log10 units.
# 'skew' : the Azzalini skew-normal shape parameter (alpha), SIGNED
# by the physical asymmetry (see below). NB alpha is not the
# moment skewness; |alpha|~2-5 gives a visibly skewed
# component, alpha=0 is a symmetric log-Gaussian.
#
# SOURCES AND SHAPE CONVENTIONS:
# * Numeric coercivity (B_median) and width (dp) ranges: the magnetite-family
# and maghemite/loess values are from Egli (2003; 2004a Stud. Geophys.
# Geod. 48, 391-446) component analysis of AF-demagnetized ARM/IRM (MDF and
# DP), cross-checked against the grain-size systematics in Roberts (2025)
# "Mineral Magnetism" (magnetite pp. 105-141, maghemite pp. 284-321).
# Hematite, greigite, and pyrrhotite coercivity ranges are from Roberts
# (2025) (hematite pp. 182-228, 267-283; sulphides pp. 356-416).
# IMPORTANT: Roberts (2025) does NOT tabulate DP
# or skew; the dp windows are therefore taken from / consistent with Egli
# (2003, 2004a,b), Robertson & France (1994), and Kruiver et al. (2001),
# not from Roberts, and are deliberately broad.
# * SKEW SIGN (Roberts 2025, p. 117; Egli 2003, 2004b): coercivity
# distributions of stable-SD, MD, and interacting magnetite (and maghemite)
# are NEGATIVELY (left-) skewed on the log-field axis -- a heavier
# low-field tail -- so those entries use alpha in roughly [-5, 0].
# Pyrrhotite is POSITIVELY (right-) skewed (a hard, high-field tail; Roberts
# p. 374), so alpha in roughly [-1, 5]. Greigite (SD) is near-symmetric,
# left-skewed when SP/MD is present
# (Roberts pp. 405-406). Hematite is variable (pigmentary components are
# often fit skew-right, Maxbauer et al. 2016), so a broad two-sided window
# is used. These signs are qualitative in Roberts; no numeric skew exists
# to cite.
#
# IMPORTANT CAVEATS:
# * A coercivity prior constrains a WINDOW, it does not identify a mineral.
# The windows overlap: greigite (both saturate <0.3 T) and non-SD
# pyrrhotite overlap magnetite/maghemite (Roberts pp. 405-407, 374), and
# hard SD / metamorphic pyrrhotite overlaps pigmentary hematite.
# * The characteristic coercivity depends on HOW it is measured, and the
# ordering is systematic. For submicron magnetite Dunlop (1986, EPSL 78,
# 288-295, doi:10.1016/0012-821X(86)90068-3) measured Hc < MDF (the AF
# median destructive field, i.e. the
# Egli values) < Bcr (the backfield remanent coercive force) < the median
# IRM-acquisition field, with the backfield Bcr running ~1.3-1.5x and the
# acquisition median ~2x the AF-MDF (e.g. a 0.1-0.2 um PSD magnetite: MDF
# ~18 mT, Bcr ~27-28 mT, acquisition ~39-41 mT). rockmagpy unmixing fits
# the backfield or IRM coercivity distribution, whose median is the Bcr or
# acquisition field, so a window taken from an AF-demagnetization component
# analysis (the magnetite family, from Egli) sits somewhat low for these
# curves. Treat the windows as soft, approximate priors, not calibrations,
# and read the finer-is-harder-but-narrower trend of Dunlop (1986) as the
# reason biogenic/pedogenic (fine) magnetite has both higher coercivity and
# lower dp than coarse detrital magnetite.
# * Pyrrhotite does not fully saturate in ordinary laboratory fields (>2 T to
# saturate; Roberts p. 374), so a typical backfield/IRM experiment (<= 2-5
# T) captures only its lower-coercivity part and UNDERESTIMATES its
# contribution; pass field_max_mT to keep priors within the measured range
# and read any such contribution as a minimum. (Goethite is unsaturated even
# at 57 T, far more extreme, and is excluded from the library for that
# reason -- see the header.)
# * Hematite coercivity is grain-size controlled (Ozdemir & Dunlop 2014,
# Hc ~ d^-0.61): across the fitting-relevant range of ~100s of nm
# (pigmentary, fine specular) to ~100s of um (coarse specular) it runs from
# ~1 T down to ~100-200 mT, so finer hematite is harder. (The very soft
# Bcr of mm-scale single crystals is not a natural sedimentary population.)
# Al-substitution raises hematite coercivity up to ~7-13 mol% Al and then
# lowers it at high substitution, but this trend is strongly confounded by
# covarying grain size (Roberts pp. 271-272).
# * Component fitting near the SP/SD threshold can produce a spurious
# low-coercivity component (Heslop et al., 2004); an unexpectedly soft
# component should be checked against independent evidence.
# * Ranges are deliberately generous (roughly the spread of the cited
# populations, not a single sample). Narrow them with the `tighten`
# argument of mineral_priors only when justified by independent data.
COERCIVITY_COMPONENT_LIBRARY = {
'magnetite_pedogenic': {
'B_median_mT': (10.0, 25.0), 'dp': (0.25, 0.40),
'skew': (-5.0, 0.0),
'source': 'Egli (2004a) components PD/EX (MDF ~17-18 mT, DP ~0.3); '
'Roberts (2025) p. 112 (unstrained, fine, soft) -- note '
'Roberts p. 285/301 considers pedogenic ferrimagnet often '
'to be maghemite rather than magnetite',
'note': 'ultrafine SP-SSD magnetite; soft, narrow, left-skewed'},
'magnetite_detrital': {
'B_median_mT': (15.0, 45.0), 'dp': (0.30, 0.45),
'skew': (-5.0, 0.0),
'source': 'Egli (2004a) component D (AF MDF ~25-33 mT, DP ~0.35-0.40); '
'Dunlop (1986, doi:10.1016/0012-821X(86)90068-3) measured '
'submicron PSD magnetite (0.1-0.22 um) backfield Bcr ~27-28 '
'mT and median IRM-acquisition field ~39-41 mT, with broader '
'spectra for coarser grains; Roberts (2025) pp. 112, 121',
'note': 'coarse detrital magnetite in water-lain sediments; '
'broad/mixed, left-skewed. Backfield Bcr and IRM-acquisition '
'medians run above the AF MDF (see the library caveats)'},
'magnetite_igneous': {
'B_median_mT': (8.0, 45.0), 'dp': (0.20, 0.50),
'skew': (-5.0, 0.0),
'source': 'Roberts (2025) pp. 112-113 (grain-size systematics; SD '
'peak Bc ~15 mT; igneous grains stressed -> higher/more '
'variable coercivity than grown crystals)',
'note': 'primary magmatic (titano)magnetite; broad and grain-size '
'dependent, from low-coercivity coarse PSD-MD grains to finer '
'PSD/SD grains; oxidation to titanomaghemite raises coercivity '
'(see maghemite_oxidized)'},
'magnetite_eolian': {
'B_median_mT': (18.0, 45.0), 'dp': (0.20, 0.40),
'skew': (-5.0, 0.0),
'source': 'Egli (2004a) component ED (aeolian dust; MDF ~28 mT); '
'Roberts (2025) does not give an eolian-specific value',
'note': 'aeolian / loess detrital magnetite (often partly '
'maghemitized)'},
'magnetite_biogenic_soft': {
'B_median_mT': (25.0, 55.0), 'dp': (0.10, 0.25),
'skew': (-5.0, 0.0),
'source': 'Egli (2004a) component BS (MDF ~45 mT, DP ~0.17); '
'Roberts (2025) pp. 119-121',
'note': 'biogenic soft magnetite (equant magnetosomes); diagnostic '
'narrow DP (~0.1-0.2)'},
'magnetite_biogenic_hard': {
'B_median_mT': (50.0, 100.0), 'dp': (0.08, 0.22),
'skew': (-5.0, 0.0),
'source': 'Egli (2004a) component BH (MDF ~73 mT, DP ~0.11); '
'Roberts (2025) pp. 119-121',
'note': 'biogenic hard magnetite (elongated magnetosome chains); '
'diagnostic very narrow DP'},
'magnetite': {
'B_median_mT': (8.0, 100.0), 'dp': (0.15, 0.50),
'skew': (-5.0, 0.0),
'source': 'generic magnetite window spanning the specific entries '
'(coarse soft ~10 mT to fine/oxidized hard ~100 mT); '
'Egli (2004a); Dunlop (1986, '
'doi:10.1016/0012-821X(86)90068-3) measured submicron '
'magnetite backfield Bcr ~27-50 mT and IRM-acquisition '
'medians ~39-68 mT (0.22 um PSD to SD), finer grains harder '
'but with narrower spectra; Roberts (2025) pp. 105-141',
'note': 'broad default for stoichiometric-to-partly-oxidized '
'magnetite when the grain population is unknown; left-skewed. '
'Use the specific entries (detrital, biogenic, ...) when the '
'population is known, or maghemite_oxidized for a distinctly '
'harder (surface-oxidized) magnetite'},
'maghemite': {
'B_median_mT': (10.0, 40.0), 'dp': (0.15, 0.35),
'skew': (-5.0, 0.5),
'source': 'Roberts (2025) pp. 300-301 (pure gamma-Fe2O3: modal '
'coercivity ~14 mT equant to ~34 mT elongated; saturates '
'<100-200 mT)',
'note': 'pure maghemite is SOFT, softer than magnetite; skewed '
'distributions with a high-field tail. Distinct from the '
'harder oxidized-magnetite phase (maghemite_oxidized)'},
'maghemite_oxidized': {
'B_median_mT': (45.0, 90.0), 'dp': (0.15, 0.35),
'skew': (-5.0, 0.0),
'source': 'Roberts (2025) pp. 300-301 (surface-maghemitized magnetite '
'Bcr ~52 mT, harder than either endmember; harder in '
'coarser grains); Egli (2004a) loess component L',
'note': 'low-temperature (surface) oxidized magnetite (core-shell); '
'HARDER than either pure magnetite or pure maghemite'},
'greigite': {
'B_median_mT': (25.0, 95.0), 'dp': (0.15, 0.35),
'skew': (-3.0, 1.0),
'source': 'Roberts (2025) pp. 401, 405-406 (SD Bcr ~75 mT [60-95], '
'SD+MD ~45 mT, MD ~24.5 mT; saturates <0.3 T)',
'note': 'authigenic ferrimagnetic iron sulphide; SD SFD near-'
'symmetric and narrow, left-skewed when SP/MD present; '
'overlaps magnetite (both saturate <0.3 T)'},
'pyrrhotite': {
'B_median_mT': (25.0, 200.0), 'dp': (0.20, 0.50),
'skew': (-1.0, 5.0),
'source': 'Roberts (2025) pp. 369, 373-374, 383 (4C: Bc 16-70 mT by '
'grain size; FORC peaks ~30 mT MD to ~75-125 mT fine SD; '
'metamorphic/3C to >300-400 mT; needs >2 T to saturate)',
'note': 'monoclinic 4C (+3C) pyrrhotite; medium- to high-coercivity, '
'strongly grain-size/polytype dependent, right-skewed (hard '
'tail); backfield under-samples the hard fraction, so '
'contributions are minimum estimates'},
'hematite_pigmentary': {
'B_median_mT': (150.0, 800.0), 'dp': (0.35, 0.95),
'skew': (-2.0, 4.0),
'source': 'Ozdemir & Dunlop (2014, JGR 119, 2582-2594, '
'doi:10.1002/2013JB010739): fine (~100s nm) SD hematite, '
'Hc ~150-350 mT for 0.12-0.45 um and up to ~670 mT near '
'0.1 um, Hcr/Hc ~1.45-1.62 -> Bcr ~220 mT to ~1 T; '
'Roberts (2025) pp. 190, 197, 224; Maxbauer et al. (2016) '
'red-bed pigmentary hematite (often fit skew-right)',
'note': 'fine pigmentary/authigenic hematite (~100s of nm); intrinsic '
'coercivity is high (fine SD) but the natural population is '
'broad and often SP-affected, so the bulk median is variable '
'and often lower; commonly skew-right; non-saturating in '
'ordinary fields, so the median is a lower bound'},
'hematite_detrital': {
'B_median_mT': (400.0, 1500.0), 'dp': (0.18, 0.40),
'skew': (-2.0, 3.0),
'source': 'Ozdemir & Dunlop (2014, doi:10.1002/2013JB010739): '
'Hc ~ d^-0.61 over the fitting-relevant ~100s nm - 100s um '
'range, so fine specular is hard (several 100 mT to ~1 T) '
'and coarser specular softer (down to ~100-200 mT); '
'Roberts (2025) pp. 197, 224 (stable-SD, narrow, high '
'unblocking 660-680 C); Swanson-Hysell et al. (2019)',
'note': 'detrital / specular (specularite) hematite spanning ~100s '
'of nm to ~100s of um; well-crystallized and narrower (lower '
'dp) than pigmentary hematite, harder when finer. Coercivity '
'is grain-size controlled (finer = harder), so a coarser '
'specular population overlaps the pigmentary window'},
'hematite': {
'B_median_mT': (150.0, 1500.0), 'dp': (0.20, 0.90),
'skew': (-2.0, 4.0),
'source': 'Ozdemir & Dunlop (2014, doi:10.1002/2013JB010739): over '
'the fitting-relevant ~100s nm - 100s um range hematite Hc '
'varies continuously as ~d^-0.61 (fine SD hard, coarser '
'specular softer); Roberts (2025); Heslop (2015)',
'note': 'generic hematite window spanning the pigmentary and '
'specular/detrital natural populations (~100s of nm to ~100s '
'of um); coercivity is grain-size controlled. Use the specific '
'entries when the population is known'},
}
[docs]
def mineral_priors(mineral_names, tighten=1.0, widen=1.0, field_max_mT=None,
overrides=None):
"""
Build a Bayesian-unmixing prior dictionary from named mineral components.
Converts a list of component names from COERCIVITY_COMPONENT_LIBRARY
into the `priors` dictionary accepted by unmix_coercivity_bayes, with a
mean-coercivity, dispersion, and skew window per component. The mean
window constrains the component's mean coercivity directly (the
skew-normal location is derived from the sampled dp and skew), so the
window is meaningful whatever the fitted skew. Components are sorted by
their central coercivity so the returned order matches the (low-to-high
coercivity) component ordering of the fit.
The library windows are broad starting points; real samples often need
them adapted. Use `tighten` to narrow every window, `widen` to broaden
every window, and `overrides` to replace specific windows outright when
a mineral in your samples sits outside its library range (for example a
harder, finer-grained magnetite, or a hematite whose low-coercivity
shoulder starts below the library's detrital-hematite window). A typical
workflow is to fit unconstrained first, see where the components land,
and then set the windows accordingly.
Because these are informative priors on an ill-posed decomposition, they
should be treated as soft constraints; see the caveats in the
COERCIVITY_COMPONENT_LIBRARY documentation.
Parameters
----------
mineral_names : list of str
Component names, each a key of COERCIVITY_COMPONENT_LIBRARY (e.g.
['magnetite_detrital', 'hematite_pigmentary', 'hematite_detrital']).
tighten : float
Factor (>= 1) by which to narrow every window about its center; 1
keeps the library width.
widen : float
Factor (>= 1) by which to broaden every window about its center; 1
keeps the library width. tighten and widen compose (net width factor
= widen / tighten).
field_max_mT : float, optional
Maximum applied field of the measurement (mT). Coercivity upper
bounds are clipped to this value, so that components whose window
extends past the measured field (e.g. hard hematite or pyrrhotite in
a 1-2 T experiment) are not given prior support the data cannot
constrain.
overrides : dict, optional
Per-mineral window replacements, mapping a mineral name to a dict
with any of 'B_median_mT', 'dp', 'skew' as (low, high) tuples. The
replacement window is used in place of the library value (before
tighten/widen and field clipping are applied), letting you anchor to
the library for most minerals while tuning the ones your samples
require.
Returns
-------
dict
A priors dictionary with 'mean', 'dp', and 'skew' keys, each a list
of (low, high) tuples in the order of increasing coercivity, suitable
for `unmix_coercivity_bayes(..., priors=...)`. The 'mean' window
constrains each component's MEAN coercivity (log10 mT) -- so a library
window means what it says regardless of the component's skew. The
chosen component names, in the same order, are returned under the
'components' key.
"""
if isinstance(mineral_names, str):
mineral_names = [mineral_names]
if tighten < 1:
raise ValueError('tighten must be >= 1')
if widen < 1:
raise ValueError('widen must be >= 1')
overrides = overrides or {}
scale = widen / tighten # > 1 broadens, < 1 narrows
def _rescale(low, high):
center = 0.5 * (low + high)
half = 0.5 * (high - low) * scale
return center - half, center + half
entries = []
for name in mineral_names:
if name not in COERCIVITY_COMPONENT_LIBRARY:
raise KeyError(
f"unknown mineral component '{name}'; available: "
f'{sorted(COERCIVITY_COMPONENT_LIBRARY)}')
entry = {**COERCIVITY_COMPONENT_LIBRARY[name], **overrides.get(name, {})}
b_low, b_high = _rescale(*entry['B_median_mT'])
b_low = max(b_low, 1e-3) # keep positive for log10
if field_max_mT is not None:
# clip the upper bound to the measured field, but refuse to clip a
# window whose lower bound is already at or above it: that would
# collapse the window to zero width and silently pin the component
# at exactly log10(field_max_mT). Such a component sits entirely
# beyond the measured field and cannot be constrained here.
if b_low >= field_max_mT:
raise ValueError(
f"component '{name}': its coercivity window starts at "
f"{b_low:g} mT, at or above the maximum applied field "
f"({field_max_mT:g} mT), so this experiment cannot "
f"constrain {name}. Remove it from the mineral list, "
f"widen the field range, or supply an override window.")
b_high = min(b_high, field_max_mT)
entries.append((name, np.sqrt(b_low * b_high),
(np.log10(b_low), np.log10(b_high)),
_rescale(*entry['dp']), _rescale(*entry['skew'])))
entries.sort(key=lambda item: item[1])
return {
'components': [item[0] for item in entries],
'mean': [item[2] for item in entries],
'dp': [item[3] for item in entries],
'skew': [item[4] for item in entries],
}
def _format_mT_axis(ax):
"""Label a log10(B) axis with field values in mT."""
from matplotlib.ticker import FuncFormatter
def _fmt(value, _pos):
field = 10 ** value
return f'{field:g}' if field < 1000 else f'{field:.0f}'
ax.xaxis.set_major_formatter(FuncFormatter(_fmt))
# mineral family (for grouping/colouring the prior library) inferred from the
# component name, and a colour per family
_COERCIVITY_FAMILY_COLORS = {
'magnetite': '#1f77b4', 'maghemite': '#17becf', 'Fe-sulphide': '#2ca02c',
'hematite': '#d62728', 'other': '#7f7f7f'}
def _coercivity_family(name):
"""Mineral family of a COERCIVITY_COMPONENT_LIBRARY component name."""
if name.startswith('magnetite'):
return 'magnetite'
if name.startswith('maghemite'):
return 'maghemite'
if name in ('greigite', 'pyrrhotite'):
return 'Fe-sulphide'
if name.startswith('hematite'):
return 'hematite'
return 'other'
[docs]
def coercivity_prior_table(minerals=None):
"""
Summarize the coercivity-component prior library as a table.
Parameters
----------
minerals : list of str, optional
Component names, each a key of COERCIVITY_COMPONENT_LIBRARY. Defaults
to the whole library, ordered by central (geometric-mean) coercivity.
Returns
-------
pandas.DataFrame
One row per component with its family, mean-coercivity window (mT),
dispersion window (dp, log10 units), skew (Azzalini alpha) window, the
implied distribution asymmetry, and the leading literature source.
"""
names = list(minerals) if minerals is not None \
else list(COERCIVITY_COMPONENT_LIBRARY)
rows = []
for name in names:
if name not in COERCIVITY_COMPONENT_LIBRARY:
raise KeyError(f"unknown mineral component '{name}'")
entry = COERCIVITY_COMPONENT_LIBRARY[name]
b_lo, b_hi = entry['B_median_mT']
sk_lo, sk_hi = entry['skew']
sk_c = 0.5 * (sk_lo + sk_hi)
asymmetry = ('left (low-field tail)' if sk_c < -0.3 else
'right (high-field tail)' if sk_c > 0.3 else
'near-symmetric')
rows.append({
'component': name,
'family': _coercivity_family(name),
'mean coercivity (mT)': f'{b_lo:g}-{b_hi:g}',
'dp (log10)': f"{entry['dp'][0]:g}-{entry['dp'][1]:g}",
'skew (alpha)': f'{sk_lo:g}-{sk_hi:g}',
'asymmetry': asymmetry,
'central coercivity (mT)': float(np.sqrt(b_lo * b_hi)),
'source': entry['source'].split(';')[0],
})
table = pd.DataFrame(rows).sort_values('central coercivity (mT)')
return table.reset_index(drop=True)
[docs]
def plot_coercivity_prior_library(minerals=None, field_range=(1.0, 5e3),
n_grid=400, figsize=None, ax=None):
"""
Visualize the coercivity-component prior library.
Draws, for each named mineral component, the skew-normal coercivity
distribution implied by the centre of its library windows (mean
coercivity, dispersion, and skew) as a ridgeline over a shared log field
axis, with the mean-coercivity window drawn as a bar at the baseline and
a dot at its centre. Components are coloured by mineral family and ordered
by central coercivity, so the coercivity ranges of the different minerals,
their characteristic widths and skews, and -- importantly -- their
overlaps (the reason a coercivity prior constrains a window rather than
identifying a mineral) are all legible at a glance.
Parameters
----------
minerals : list of str, optional
Component names to show (default: the whole library).
field_range : tuple
(min, max) field in mT for the coercivity axis (default 1-5000).
n_grid : int
Number of points for the density curves.
figsize : tuple, optional
Figure size; a default is chosen from the number of components.
ax : matplotlib.axes.Axes, optional
Axis to draw on; a new figure is created if omitted.
Returns
-------
tuple
(fig, ax).
"""
from matplotlib.patches import Patch
names = list(minerals) if minerals is not None \
else list(COERCIVITY_COMPONENT_LIBRARY)
rows = []
for name in names:
entry = COERCIVITY_COMPONENT_LIBRARY[name]
b_lo, b_hi = entry['B_median_mT']
dp_c = 0.5 * (entry['dp'][0] + entry['dp'][1])
sk_c = 0.5 * (entry['skew'][0] + entry['skew'][1])
rows.append((name, b_lo, b_hi, float(np.sqrt(b_lo * b_hi)), dp_c,
sk_c, _coercivity_family(name)))
rows.sort(key=lambda r: r[3])
x_grid = np.linspace(np.log10(field_range[0]), np.log10(field_range[1]),
n_grid)
if figsize is None:
figsize = (8.5, 0.7 * len(rows) + 1.2)
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.figure
for i, (name, b_lo, b_hi, b_c, dp_c, sk_c, family) in enumerate(rows):
color = _COERCIVITY_FAMILY_COLORS.get(family, 'grey')
# representative shape: centre coercivity is the MEAN, so derive the
# skew-normal location from the central dp and skew
delta = sk_c / np.sqrt(1.0 + sk_c ** 2)
loc = np.log10(b_c) - dp_c * delta * np.sqrt(2.0 / np.pi)
density = skewnormal_pdf(x_grid, loc, dp_c, sk_c)
density = density / density.max() * 0.85
ax.fill_between(x_grid, i, i + density, color=color, alpha=0.5, lw=0)
ax.plot(x_grid, i + density, color=color, lw=1)
ax.plot([np.log10(b_lo), np.log10(b_hi)], [i, i], color=color,
lw=3.5, solid_capstyle='butt')
ax.plot(np.log10(b_c), i, 'o', color=color, ms=4, zorder=5)
ax.set_yticks(range(len(rows)))
ax.set_yticklabels([r[0] for r in rows], fontsize=8.5)
_format_mT_axis(ax)
decades = range(int(np.floor(np.log10(field_range[0]))),
int(np.ceil(np.log10(field_range[1]))) + 1)
ax.set_xticks([d for d in decades])
ax.set_xticks([np.log10(m * 10.0 ** d) for d in decades
for m in range(2, 10)], minor=True)
ax.set_xlabel('coercivity (mT)')
ax.set_xlim(np.log10(field_range[0]), np.log10(field_range[1]))
ax.set_ylim(-0.6, len(rows) + 0.2)
ax.grid(axis='x', alpha=0.25)
families_present = [f for f in _COERCIVITY_FAMILY_COLORS
if any(r[6] == f for r in rows)]
ax.legend(handles=[Patch(color=_COERCIVITY_FAMILY_COLORS[f], label=f)
for f in families_present],
fontsize=8, loc='lower right', framealpha=0.9,
title='mineral family')
fig.tight_layout()
return fig, ax
def _unmixing_component_colors(b_means_mT, color_by, class_boundaries,
class_colors):
"""Assign a color to each component, by fit order or coercivity class."""
n = len(b_means_mT)
if color_by == 'component':
return [f'C{i}' for i in range(n)]
if color_by not in ('class', 'coercivity'):
raise ValueError("color_by must be 'component', 'class', or "
"'coercivity'")
if class_boundaries is None:
raise ValueError("class_boundaries is required when color_by='class'")
boundaries = np.sort(np.atleast_1d(class_boundaries).astype(float))
n_classes = len(boundaries) + 1
if class_colors is None:
class_colors = (['royalblue', 'crimson'] if n_classes == 2
else [plt.get_cmap('coolwarm')(t)
for t in np.linspace(0, 1, n_classes)])
if len(class_colors) != n_classes:
raise ValueError(f'class_colors must have {n_classes} entries '
f'({len(boundaries)} boundaries + 1)')
# component in class k when its mean coercivity exceeds the k-th boundary;
# a component exactly on a boundary is assigned to the lower class
class_index = np.searchsorted(boundaries, np.asarray(b_means_mT),
side='left')
return [class_colors[k] for k in class_index]
def _result_is_spectrum(result):
"""
Whether result['x']/result['y'] hold the coercivity spectrum rather than
the measured curve, i.e. the fit was done in spectrum space. Plot helpers
use this so they do not differentiate an already-differentiated spectrum.
"""
method = result.get('method')
if method in ('spectrum', 'maxunmix'):
return True
if method == 'bayes':
return result.get('space') == 'spectrum'
return False
def _result_spectrum(result):
"""Return (x_mid, spectrum) for a result, without double-differentiating a
spectrum-space fit whose x/y are already the spectrum."""
x, y = result['x'], result['y']
if _result_is_spectrum(result):
return x, y
return coercivity_spectrum_from_curve(x, y, result['curve_type'])
[docs]
def plot_coercivity_unmixing(result, show_components=True,
show_bootstrap=True, show_initial=False,
n_grid=300, figsize=None, title=None,
color_by='component', class_boundaries=None,
class_colors=None):
"""
Plot an unmixing result in its fitted data space.
For spectrum-space fits a single panel shows the data, total model, and
components. For measurement-space (curve) fits an upper panel shows the
measured curve with the cumulative model and a lower panel shows the
finite-difference spectrum of the data with the implied component
density curves. Bootstrap 95% bands are drawn when present.
Parameters
----------
result : dict
Result from unmix_coercivity_spectrum, unmix_backfield_curve, or
unmixing_bootstrap.
show_components : bool
Draw the individual components (default True).
show_bootstrap : bool
Draw bootstrap confidence bands if available (default True).
show_initial : bool
Also draw the model implied by the initial parameters (dotted),
useful for judging how far the optimizer moved (default False).
n_grid : int
Number of points for smooth model curves.
figsize : tuple, optional
Figure size; defaults depend on the number of panels.
title : str, optional
Figure title.
color_by : str
How to color the components. 'component' (default) colors by fit
order (C0, C1, ...). 'class' (or the alias 'coercivity') colors
each component by the coercivity class its mean field falls into,
so the mineralogy is read directly and a second component of the
same mineral does not take a different color; requires
class_boundaries.
class_boundaries : float or sequence of float, optional
Coercivity cut points in mT that partition the components into
classes when color_by='class' (e.g. 200 for a magnetite/hematite
split, or [30, 300] for three classes).
class_colors : sequence, optional
One color per class (length = number of boundaries + 1). Defaults
to blue/red for two classes, or a diverging colormap otherwise.
Returns
-------
tuple
(fig, axes) with axes a list of the panel axes.
"""
method = result['method']
curve_type = result['curve_type']
params = result['params']
param_arr = params[UNMIX_PARAM_COLUMNS].to_numpy()
x, y = result['x'], result['y']
x_grid = np.linspace(x.min(), x.max(), n_grid)
component_colors = _unmixing_component_colors(
params['B_mean_mT'].to_numpy(), color_by, class_boundaries,
class_colors)
boot = None
band_label = '95% bootstrap band'
if show_bootstrap:
if 'bootstrap' in result:
boot = result['bootstrap']
elif 'bayes' in result:
boot = result['bayes']
band_label = '95% credible band'
plot_as_curve = (method in ('curve', 'bayes')
and result.get('space', 'curve') != 'spectrum')
n_panels = 2 if plot_as_curve else 1
if figsize is None:
figsize = (8, 4.5 * n_panels)
fig, axes = plt.subplots(nrows=n_panels, ncols=1, figsize=figsize,
sharex=True)
axes = list(np.atleast_1d(axes))
def _component_label(i):
row = params.loc[i]
return (f'component {i}: {row["B_mean_mT"]:.0f} mT, '
f'DP {row["sd_log"]:.2f}, {row["proportion"] * 100:.0f}%')
if plot_as_curve:
ax_curve, ax_spec = axes
ax_curve.scatter(x, y, c='grey', s=12, label='measurements')
ax_curve.plot(x_grid, coercivity_curve_model(
x_grid, param_arr, offset=result['offset'],
curve_type=curve_type), c='k', label='model')
if show_components:
comps = coercivity_curve_components(x_grid, param_arr,
curve_type=curve_type)
for i in range(len(params)):
ax_curve.plot(x_grid, comps[i] + result['offset'],
c=component_colors[i], alpha=0.8)
if boot is not None:
curves = boot['curves']
ax_curve.fill_between(curves['x_grid'], curves['total_p2_5'],
curves['total_p97_5'], color='k',
alpha=0.2, label=band_label)
ax_curve.set_ylabel('magnetization')
ax_curve.legend(fontsize=9)
# implied spectrum panel from finite differences of the data
x_mid, spec = coercivity_spectrum_from_curve(x, y, curve_type)
ax_spec.scatter(x_mid, spec, c='grey', s=12,
label='finite-difference spectrum')
ax_spec.plot(x_grid, coercivity_spectrum_model(x_grid, param_arr),
c='k', label='model')
if show_components:
comps = coercivity_spectrum_components(x_grid, param_arr)
for i, comp_index in enumerate(params.index):
ax_spec.plot(x_grid, comps[i], c=component_colors[i],
label=_component_label(comp_index))
if show_initial:
init_arr = _unmix_parameters_to_array(
result['initial_parameters'])
ax_spec.plot(x_grid, coercivity_spectrum_model(x_grid, init_arr),
c='k', ls=':', alpha=0.6, label='initial model')
ax_spec.set_ylabel('dM/dlog$_{10}$(B)')
ax_spec.set_xlabel('field (mT)')
ax_spec.legend(fontsize=9)
else:
ax_spec = axes[0]
ax_spec.scatter(x, y, c='grey', s=12, label='coercivity spectrum')
if boot is not None:
curves = boot['curves']
ax_spec.fill_between(curves['x_grid'], curves['total_p2_5'],
curves['total_p97_5'], color='k', alpha=0.2,
label=band_label)
if show_components:
for i in range(len(params)):
ax_spec.fill_between(curves['x_grid'],
curves['components_p2_5'][i],
curves['components_p97_5'][i],
color=component_colors[i], alpha=0.2)
ax_spec.plot(x_grid, coercivity_spectrum_model(x_grid, param_arr),
c='k', label='model')
if show_components:
comps = coercivity_spectrum_components(x_grid, param_arr)
for i, comp_index in enumerate(params.index):
ax_spec.plot(x_grid, comps[i], c=component_colors[i],
label=_component_label(comp_index))
if show_initial:
init_arr = _unmix_parameters_to_array(
result['initial_parameters'])
ax_spec.plot(x_grid, coercivity_spectrum_model(x_grid, init_arr),
c='k', ls=':', alpha=0.6, label='initial model')
ax_spec.set_ylabel('dM/dlog$_{10}$(B)')
ax_spec.set_xlabel('field (mT)')
ax_spec.legend(fontsize=9)
for ax in axes:
_format_mT_axis(ax)
if title is not None:
fig.suptitle(title)
fig.tight_layout()
return fig, axes
def _unmixing_samples(result):
"""
Return the per-draw parameter samples from a bootstrap or Bayesian result.
Both unmixing_bootstrap and unmix_coercivity_bayes attach a dictionary of
posterior/bootstrap draws (one array of shape (n_draws, n_components) per
tracked quantity). This helper returns that dictionary along with a label
describing the uncertainty source, or raises if the result carries no
draws.
Returns
-------
tuple
(samples, source_label) where samples maps quantity name -> array of
shape (n_draws, n_components) and source_label is 'bootstrap' or
'posterior'.
"""
if 'bayes' in result:
return result['bayes']['samples'], 'posterior'
if 'bootstrap' in result:
return result['bootstrap']['param_samples'], 'bootstrap'
raise ValueError('result has no bootstrap or Bayesian draws; run '
'unmixing_bootstrap or method="bayes" first')
[docs]
def plot_unmixing_posterior(result, quantity='B_mean_mT', bins=40,
figsize=None, colors=None):
"""
Plot marginal uncertainty distributions of a component quantity.
Draws one histogram per component of the requested derived quantity from
the bootstrap or Bayesian draws, with the median and 95% interval marked.
This visualizes how tightly each component parameter is constrained,
including asymmetric and multimodal uncertainties that a single
standard-error value cannot convey.
Parameters
----------
result : dict
Result from unmixing_bootstrap or unmix_coercivity_bayes.
quantity : str
Name of the quantity to plot (e.g. 'B_mean_mT', 'proportion',
'sd_log', 'location', 'dp', 'skew'). Must be present in the draws.
bins : int
Number of histogram bins.
figsize : tuple, optional
Figure size; defaults to (7, 2.2 * n_components).
colors : list, optional
Per-component colors; defaults to the matplotlib C0, C1, ... cycle.
Returns
-------
tuple
(fig, axes).
"""
samples, source = _unmixing_samples(result)
if quantity not in samples:
raise KeyError(f"'{quantity}' is not in the draws; available: "
f'{sorted(samples)}')
values = np.asarray(samples[quantity])
# per-component quantities are 2-D (n_draws, n_components); global
# quantities such as 'offset' and 'noise' are 1-D (n_draws,) -- show those
# as a single histogram rather than splitting by component
per_component = values.ndim > 1
if not per_component:
values = values[:, None]
K = values.shape[1]
log_x = quantity in ('B_mean_mT', 'B_median_mT', 'B_peak_mT')
if figsize is None:
figsize = (7, 2.2 * K)
if colors is None:
colors = [f'C{i}' for i in range(K)]
fig, axes = plt.subplots(K, 1, figsize=figsize, squeeze=False)
axes = axes.ravel()
for i in range(K):
column = values[:, i]
median = np.median(column)
low, high = np.percentile(column, [2.5, 97.5])
if log_x:
bin_edges = np.logspace(np.log10(column.min()),
np.log10(column.max()), bins)
axes[i].set_xscale('log')
else:
bin_edges = bins
axes[i].hist(column, bins=bin_edges, color=colors[i], alpha=0.6,
density=True)
axes[i].axvline(median, color='k', lw=1.2,
label=f'median {median:.3g}')
axes[i].axvline(low, color='k', ls='--', lw=0.9)
axes[i].axvline(high, color='k', ls='--', lw=0.9,
label=f'95%: [{low:.3g}, {high:.3g}]')
axes[i].set_ylabel(f'component {i + 1}' if per_component else 'density')
axes[i].legend(fontsize=8)
axes[-1].set_xlabel(f'{quantity} ({source} draws)')
fig.tight_layout()
return fig, axes
[docs]
def plot_unmixing_tradeoff(result, x='B_mean_mT', y='proportion',
component=None, figsize=(5, 5), colors=None):
"""
Scatter two component quantities across draws to reveal parameter trade-offs.
Overlapping coercivity components trade parameters against one another;
plotting one quantity against another across the bootstrap or posterior
draws exposes these correlations (and any multimodality) that marginal
intervals hide. By default every component is shown; pass a component
index to isolate one.
Parameters
----------
result : dict
Result from unmixing_bootstrap or unmix_coercivity_bayes.
x, y : str
Quantity names for the two axes (e.g. 'B_mean_mT', 'proportion',
'sd_log').
component : int, optional
1-based component index to plot alone; if None, all components are
overlaid.
figsize : tuple
Figure size.
colors : list, optional
Per-component colors.
Returns
-------
tuple
(fig, ax).
"""
samples, source = _unmixing_samples(result)
for name in (x, y):
if name not in samples:
raise KeyError(f"'{name}' is not in the draws; available: "
f'{sorted(samples)}')
xv = np.asarray(samples[x])
yv = np.asarray(samples[y])
# global quantities ('offset', 'noise') are 1-D; treat them as a single
# column so they can be paired with per-component quantities (broadcast
# across components) or with each other (a single scatter)
if xv.ndim == 1:
xv = xv[:, None]
if yv.ndim == 1:
yv = yv[:, None]
Kx, Ky = xv.shape[1], yv.shape[1]
K = max(Kx, Ky)
components = range(K) if component is None else [component - 1]
if colors is None:
colors = [f'C{i}' for i in range(K)]
fig, ax = plt.subplots(figsize=figsize)
for i in components:
# a global (single-column) quantity is reused for every component
xi = xv[:, min(i, Kx - 1)]
yi = yv[:, min(i, Ky - 1)]
ax.scatter(xi, yi, s=6, alpha=0.25, color=colors[i],
edgecolors='none', label=f'component {i + 1}')
if x in ('B_mean_mT', 'B_median_mT', 'B_peak_mT'):
ax.set_xscale('log')
if y in ('B_mean_mT', 'B_median_mT', 'B_peak_mT'):
ax.set_yscale('log')
ax.set_xlabel(x)
ax.set_ylabel(y)
ax.set_title(f'parameter trade-off ({source} draws)')
if component is None and K > 1:
ax.legend(fontsize=8)
fig.tight_layout()
return fig, ax
[docs]
def plot_unmixing_ensemble(result, space='spectrum', n_draws=200, n_grid=300,
show_components=True, figsize=(8, 5), title=None,
colors=None, random_seed=None):
"""
Overlay many draws of the model curves to visualize decomposition spread.
Rather than a single best fit with an error band, this draws the total
model and (optionally) the individual components for many bootstrap or
posterior samples, so the full range of decompositions consistent with
the data is visible directly -- including cases where components exchange
coercivity or amplitude between draws.
Parameters
----------
result : dict
Result from unmixing_bootstrap or unmix_coercivity_bayes.
space : str
'spectrum' (dM/dlog10 B) or 'curve' (measurement space).
n_draws : int
Number of draws to overlay (capped at the number available).
n_grid : int
Number of field points for the smooth curves.
show_components : bool
Overlay per-component curves in addition to the total.
figsize : tuple
Figure size.
title : str, optional
Figure title.
colors : list, optional
Per-component colors.
random_seed : None, int, or numpy.random.Generator
Seed for choosing which draws to plot.
Returns
-------
tuple
(fig, ax).
"""
assert space in ('spectrum', 'curve'), \
"space must be 'spectrum' or 'curve'"
samples, source = _unmixing_samples(result)
rng = _resolve_rng(random_seed)
curve_type = result['curve_type']
x, y = result['x'], result['y']
x_grid = np.linspace(x.min(), x.max(), n_grid)
contribution = np.asarray(samples['contribution'])
location = np.asarray(samples['location'])
dp = np.asarray(samples['dp'])
skew = np.asarray(samples['skew'])
n_available, K = contribution.shape
if colors is None:
colors = [f'C{i}' for i in range(K)]
offset_draws = np.asarray(result['bayes']['samples']['offset']) \
if ('bayes' in result and space == 'curve') else None
n_plot = min(n_draws, n_available)
idx = rng.choice(n_available, size=n_plot, replace=False)
fig, ax = plt.subplots(figsize=figsize)
# data
if space == 'spectrum':
x_mid, spectrum = _result_spectrum(result)
ax.scatter(x_mid, spectrum, s=10, c='grey', alpha=0.5, zorder=3,
label='data spectrum')
else:
ax.scatter(x, y, s=10, c='grey', alpha=0.5, zorder=3, label='data')
for draw, j in enumerate(idx):
params = np.column_stack([contribution[j], location[j], dp[j],
skew[j]])
if space == 'spectrum':
total = coercivity_spectrum_model(x_grid, params)
comps = (coercivity_spectrum_components(x_grid, params)
if show_components else None)
else:
offset = offset_draws[j] if offset_draws is not None \
else result['offset']
total = coercivity_curve_model(x_grid, params, offset=offset,
curve_type=curve_type)
comps = (coercivity_curve_components(x_grid, params,
curve_type=curve_type)
if show_components else None)
ax.plot(x_grid, total, color='k', alpha=0.04, lw=0.8,
label='model draws' if draw == 0 else None)
if comps is not None:
for i in range(K):
ax.plot(x_grid, comps[i], color=colors[i], alpha=0.04,
lw=0.8,
label=f'component {i + 1}' if draw == 0 else None)
ax.set_xlabel('field (mT)')
ax.set_ylabel('dM/dlog$_{10}$(B)' if space == 'spectrum'
else 'magnetization')
_format_mT_axis(ax)
ax.set_title(title if title is not None
else f'ensemble of {n_plot} {source} draws')
# legend with opaque proxies
handles, labels = ax.get_legend_handles_labels()
for handle in handles:
handle.set_alpha(1.0)
ax.legend(handles, labels, fontsize=8)
fig.tight_layout()
return fig, ax
[docs]
def plot_unmixing_multistart(result, max_solutions=6, space='spectrum',
n_grid=300, figsize=None, colors=None,
marker_scale='uniform'):
"""
Visualize the distinct solutions found by a multi-start analysis.
Produces a panel of small multiples, one per distinct solution (ordered
best-fit first), each showing that solution's decomposition against the
data, plus a final parameter-space map that places every solution's
components on a coercivity-versus-proportion plot. Together these make
the non-uniqueness of the decomposition concrete: how many genuinely
different solutions the data admit and how each partitions the spectrum.
Parameters
----------
result : dict
Result from unmixing_multistart (carries a 'multistart' entry).
max_solutions : int
Maximum number of distinct solutions to draw as small multiples
(the best-fitting solutions are shown).
space : str
'spectrum' (dM/dlog10 B) or 'curve' (measurement space) for the
decomposition panels.
n_grid : int
Number of field points for the smooth model curves.
figsize : tuple, optional
Figure size; a default is chosen from the panel count.
colors : list, optional
Per-solution colors; defaults to the tab10 cycle.
marker_scale : str
How the solution-map markers are sized: 'uniform' (default, all
equal, so no solution is visually privileged), 'n_hits' (size scaled
into a bounded range by the number of starts that reached each
solution), or 'weight' (size scaled by Akaike weight). On low-noise
data the Akaike weight collapses onto the lowest-RSS solution, so
'uniform' or 'n_hits' better reflect that the solutions are
alternatives; 'weight' is informative mainly when the noise is large
enough to spread support across solutions. The number of starts that
reached each solution is annotated on the map in every case.
Returns
-------
tuple
(fig, axes).
"""
if 'multistart' not in result:
raise ValueError('result has no multistart analysis; run '
'unmixing_multistart first')
assert space in ('spectrum', 'curve'), \
"space must be 'spectrum' or 'curve'"
assert marker_scale in ('uniform', 'n_hits', 'weight'), \
"marker_scale must be 'uniform', 'n_hits', or 'weight'"
multistart = result['multistart']
solutions = multistart['solutions']
solution_results = multistart['results']
curve_type = result['curve_type']
x, y = result['x'], result['y']
x_grid = np.linspace(x.min(), x.max(), n_grid)
# order solutions by Akaike weight (descending)
order = np.argsort(solutions['akaike_weight'].to_numpy())[::-1]
n_show = min(max_solutions, len(order))
order = order[:n_show]
if colors is None:
cmap = plt.get_cmap('tab10')
colors = [cmap(i % 10) for i in range(len(solution_results))]
n_panels = n_show + 1 # decompositions + parameter map
n_cols = min(3, n_panels)
n_rows = int(np.ceil(n_panels / n_cols))
if figsize is None:
figsize = (4.2 * n_cols, 3.2 * n_rows)
fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize, squeeze=False)
axes_flat = axes.ravel()
if space == 'spectrum':
x_data, y_data = _result_spectrum(result)
else:
x_data, y_data = x, y
for panel, sol_idx in enumerate(order):
ax = axes_flat[panel]
fit = solution_results[sol_idx]
params = fit['params'][UNMIX_PARAM_COLUMNS].to_numpy()
n_hits = int(solutions['n_hits'].iloc[sol_idx])
ax.scatter(x_data, y_data, s=8, c='lightgrey', zorder=1)
if space == 'spectrum':
total = coercivity_spectrum_model(x_grid, params)
comps = coercivity_spectrum_components(x_grid, params)
else:
total = coercivity_curve_model(x_grid, params,
offset=fit.get('offset', 0.0),
curve_type=curve_type)
comps = coercivity_curve_components(x_grid, params,
curve_type=curve_type)
for i in range(len(params)):
ax.plot(x_grid, comps[i], color=colors[sol_idx], lw=1.2,
alpha=0.9)
ax.plot(x_grid, total, color='k', lw=1.2)
ax.set_title(f'solution {sol_idx + 1}: {n_hits} starts',
fontsize=9, color=colors[sol_idx])
_format_mT_axis(ax)
ax.set_xlabel('field (mT)', fontsize=8)
ax.tick_params(labelsize=7)
# parameter-space map panel
ax_map = axes_flat[n_show]
shown_hits = solutions['n_hits'].to_numpy()[order]
hit_min, hit_max = shown_hits.min(), shown_hits.max()
component_cols = [c for c in solutions.columns
if c.startswith('B_mean_mT_c')]
for sol_idx in order:
row = solutions.iloc[sol_idx]
n_hits = int(row['n_hits'])
if marker_scale == 'weight':
size = 30 + 300 * row['akaike_weight']
elif marker_scale == 'n_hits':
# bounded 40-160 pt^2 so a large basin does not dominate the panel
frac = ((n_hits - hit_min) / (hit_max - hit_min)
if hit_max > hit_min else 0.5)
size = 40 + 120 * frac
else: # 'uniform'
size = 60
points = []
for col in component_cols:
suffix = col.split('_c')[-1]
proportion = row.get(f'proportion_c{suffix}', np.nan)
ax_map.scatter(row[col], proportion, s=size,
color=colors[sol_idx], alpha=0.7,
edgecolors='k', linewidths=0.5)
if np.isfinite(row[col]) and np.isfinite(proportion):
points.append((row[col], proportion))
# annotate the number of starts at the solution's highest-coercivity
# component, so the basin size is always readable
if points:
bx, by = max(points, key=lambda bp: bp[0])
ax_map.annotate(f'n={n_hits}', (bx, by),
textcoords='offset points', xytext=(5, 3),
fontsize=6.5, color=colors[sol_idx])
# x-data are already in mT (not log10 units), so a plain log scale is used
ax_map.set_xscale('log')
ax_map.set_xlabel('component mean coercivity (mT)', fontsize=8)
ax_map.set_ylabel('proportion', fontsize=8)
_map_titles = {
'uniform': 'solution map (n = starts reaching each)',
'n_hits': 'solution map (size ~ starts reaching each)',
'weight': 'solution map (size = Akaike weight)',
}
ax_map.set_title(_map_titles[marker_scale], fontsize=9)
ax_map.tick_params(labelsize=7)
for ax in axes_flat[n_panels:]:
ax.axis('off')
fig.tight_layout()
return fig, axes_flat[:n_panels]
[docs]
def coercivity_unmixing_interactive(x, magnetization, n_components=2,
method='spectrum',
curve_type='backfield', vary_skew=True,
figsize=(9, 5)):
"""
Interactive widget for choosing initial unmixing parameters visually.
Initial parameter choices strongly influence nonlinear unmixing fits.
This widget shows the coercivity spectrum with a live model built from
per-component sliders (peak field in mT on a log scale, proportion of
the total remanence, dispersion DP, and skew). Sliders are seeded from
automatic peak detection. Pressing "Fit" runs the chosen optimizer
(spectrum- or measurement-space) starting from the current slider
values and overlays the optimized model.
*Important*: run `%matplotlib widget` in the notebook first so the
figure updates live.
Parameters
----------
x : array-like
log10 of field values (mT), e.g. 'log_dc_field' from
process_backfield_data.
magnetization : array-like
Remanence curve values at x (e.g. 'magn_mass_shift').
n_components : int
Number of components (default 2).
method : str
'spectrum' or 'curve' -- the fitting approach used by the Fit
button.
curve_type : str
'backfield' or 'acquisition'.
vary_skew : bool
Include skew sliders and let the fit vary skew (default True).
figsize : tuple
Figure size.
Returns
-------
dict
A live handle with keys 'initial_parameters' (DataFrame updated as
sliders move; pass to the unmixing functions) and 'result' (filled
with the standardized result dictionary after Fit is pressed).
"""
_check_ipywidgets()
assert method in ('spectrum', 'curve'), \
"method must be 'spectrum' or 'curve'"
x = np.asarray(x, dtype=float)
M = np.asarray(magnetization, dtype=float)
x_mid, spectrum = coercivity_spectrum_from_curve(x, M, curve_type)
total_area = _trapz(spectrum, x_mid)
x_grid = np.linspace(x.min(), x.max(), 300)
auto = estimate_coercivity_components(x_mid, spectrum, n_components)
handle = {'initial_parameters': None, 'result': None}
fig, ax = plt.subplots(figsize=figsize)
fig.canvas.header_visible = False
sliders = []
for i in range(n_components):
peak_mT = 10 ** auto['location'][i]
proportion_0 = min(auto['contribution'][i] / total_area, 1.0)
column = [
widgets.FloatLogSlider(value=peak_mT, base=10,
min=x.min(), max=x.max(), step=0.01,
description=f'B{i + 1} (mT)',
continuous_update=False),
widgets.FloatSlider(value=round(proportion_0, 2), min=0.0,
max=1.0, step=0.01,
description=f'proportion{i + 1}',
continuous_update=False),
widgets.FloatSlider(value=round(auto['dp'][i], 2), min=0.02,
max=1.2, step=0.01,
description=f'DP{i + 1}',
continuous_update=False),
]
if vary_skew:
column.append(widgets.FloatSlider(value=0.0, min=-8.0, max=8.0,
step=0.1,
description=f'skew{i + 1}',
continuous_update=False))
sliders.append(column)
fit_button = widgets.Button(description='Fit from these values',
button_style='primary')
output = Output()
def slider_parameters():
rows = []
for column in sliders:
rows.append({
'contribution': column[1].value * total_area,
'location': np.log10(column[0].value),
'dp': column[2].value,
'skew': column[3].value if vary_skew else 0.0,
})
return pd.DataFrame(rows)
def redraw(*_args):
parameters = slider_parameters()
handle['initial_parameters'] = parameters
ax.clear()
ax.scatter(x_mid, spectrum, c='grey', s=10, alpha=0.6,
label='coercivity spectrum')
arr = parameters[UNMIX_PARAM_COLUMNS].to_numpy()
comps = coercivity_spectrum_components(x_grid, arr)
for i in range(n_components):
ax.plot(x_grid, comps[i], c=f'C{i}', ls='--', alpha=0.8,
label=f'component {i + 1}')
ax.plot(x_grid, comps.sum(axis=0), c='k', ls='--', alpha=0.8,
label='total (manual)')
if handle['result'] is not None:
fitted = handle['result']['params'][UNMIX_PARAM_COLUMNS].to_numpy()
fitted_comps = coercivity_spectrum_components(x_grid, fitted)
for i in range(n_components):
ax.plot(x_grid, fitted_comps[i], c=f'C{i}')
ax.plot(x_grid, fitted_comps.sum(axis=0), c='k',
label='total (fitted)')
ax.set_xlabel('field (mT)')
ax.set_ylabel('dM/dlog$_{10}$(B)')
_format_mT_axis(ax)
ax.legend(fontsize=8)
fig.canvas.draw_idle()
def run_fit(_button):
parameters = slider_parameters()
with output:
output.clear_output()
try:
if method == 'spectrum':
handle['result'] = unmix_coercivity_spectrum(
x_mid, spectrum, initial_parameters=parameters,
vary_skew=vary_skew)
else:
handle['result'] = unmix_backfield_curve(
x, M, initial_parameters=parameters,
curve_type=curve_type, vary_skew=vary_skew)
except (ValueError, RuntimeError) as error:
print(f'fit failed: {error}')
return
stats = handle['result']['stats']
print(f"fit success: {handle['result']['success']}, "
f"R^2 = {stats['r_squared']:.5f}, "
f"AIC = {stats['aic']:.1f}, BIC = {stats['bic']:.1f}")
display(handle['result']['params'].round(4))
redraw()
for column in sliders:
for slider in column:
slider.observe(redraw, names='value')
fit_button.on_click(run_fit)
display(HBox([VBox(column) for column in sliders]))
display(fit_button)
display(output)
redraw()
return handle
[docs]
def unmix_backfield_experiments(measurements, experiments=None,
n_components=2, method=DEFAULT_UNMIX_METHOD,
initial_parameters=None,
vary_skew=DEFAULT_UNMIX_VARY_SKEW,
n_boot=0, resample='cases',
proportion=1.0, noise_level=None,
smooth_mode='spline', smooth_frac=0.0,
drop_first=False, field='treat_dc_field',
magnetization='magn_mass', random_seed=None,
verbose=True, **method_kwargs):
"""
Batch coercivity unmixing of backfield experiments in a MagIC
measurements table.
Each experiment is processed with process_backfield_data and unmixed
with the requested method (any name registered in UNMIXING_METHODS,
dispatched through unmix_coercivity). When initial parameters are not
supplied they are estimated automatically per experiment; supplying a
common initial-parameter table (or a per-experiment dict, e.g. built
with coercivity_unmixing_interactive) enforces a consistent starting
model across specimens, which aids comparability of the resulting
components.
Parameters
----------
measurements : pandas.DataFrame
MagIC measurements table (must include 'experiment', 'specimen',
'method_codes', and the field/magnetization columns).
experiments : list, optional
Experiment names to process. Defaults to all experiments whose
method_codes include 'LP-BCR-BF'.
n_components : int
Number of components fit to each experiment (default 2).
method : str
Registered unmixing method name: 'spectrum', 'curve', 'maxunmix',
or a custom method added with register_unmixing_method.
initial_parameters : pandas.DataFrame or dict, optional
Either a single initial-parameter table applied to every
experiment, or a dict mapping experiment name -> table. Experiments
missing from the dict fall back to automatic estimation.
vary_skew : bool
Whether skew parameters vary during fitting.
n_boot : int
If > 0, ensure each result carries a bootstrap with this many
replicates (methods that bootstrap internally, like 'maxunmix',
are not re-bootstrapped).
resample, proportion, noise_level : see unmixing_bootstrap.
smooth_mode, smooth_frac, drop_first : see process_backfield_data.
The defaults (spline with smooth_frac=0) leave the data unsmoothed.
field, magnetization : str
Column names in the measurements table.
random_seed : None, int, or numpy.random.Generator
Seed for reproducible bootstraps.
verbose : bool
Print progress and failures.
**method_kwargs
Additional keyword arguments passed to the unmixing method.
Returns
-------
tuple
(components_df, results) where components_df is a tidy DataFrame
with one row per experiment and component (parameters, derived
coercivities, uncertainties, fit statistics, and Bcr) and results
is a dict mapping experiment name -> full result dictionary.
"""
if method not in UNMIXING_METHODS:
raise ValueError(f"unknown unmixing method '{method}'; available: "
f'{sorted(UNMIXING_METHODS)}')
if experiments is None:
is_backfield = measurements['method_codes'].astype(str).str.contains(
'LP-BCR-BF', na=False)
experiments = list(pd.unique(
measurements.loc[is_backfield, 'experiment']))
if len(experiments) == 0:
raise ValueError('no backfield experiments found')
rng = _resolve_rng(random_seed)
results = {}
rows = []
failures = []
for experiment_name in experiments:
experiment = measurements[
measurements['experiment'] == experiment_name].copy()
if len(experiment) == 0:
failures.append((experiment_name, 'experiment not found'))
continue
specimen = (experiment['specimen'].iloc[0]
if 'specimen' in experiment.columns else '')
try:
processed, Bcr = process_backfield_data(
experiment, field=field, magnetization=magnetization,
smooth_mode=smooth_mode, smooth_frac=smooth_frac,
drop_first=drop_first)
x = processed['log_dc_field'].to_numpy()
M = processed['magn_mass_shift'].to_numpy()
if smooth_frac > 0:
x_s = processed['smoothed_log_dc_field'].to_numpy()
M_s = processed['smoothed_magn_mass_shift'].to_numpy()
else:
x_s, M_s = x, M
if isinstance(initial_parameters, dict):
initial = initial_parameters.get(experiment_name)
else:
initial = initial_parameters
if initial is not None:
initial = initial.copy()
# The finite-difference least-squares methods ('spectrum',
# 'maxunmix') fit the optionally smoothed curve, because
# differentiating the raw curve amplifies its noise. The
# measurement-space ('curve') and Bayesian methods fit the
# unsmoothed data so their noise models -- the Bayesian methods
# infer an explicit noise level -- see the true measurement noise;
# feeding them the denoised curve would understate the noise and
# yield overconfident credible intervals. (A Bayesian fit with
# space='spectrum' differentiates the curve it is given
# internally, so it too must receive the unsmoothed data.)
x_fit, M_fit = ((x_s, M_s) if method in ('spectrum', 'maxunmix')
else (x, M))
if method == 'maxunmix':
method_kwargs.setdefault('n_boot', n_boot if n_boot > 0
else 100)
method_kwargs.setdefault('random_seed', rng)
result = unmix_coercivity(
x_fit, M_fit, method=method, n_components=n_components,
initial_parameters=initial, vary_skew=vary_skew,
**method_kwargs)
if n_boot > 0 and 'bootstrap' not in result and 'bayes' not in result:
result = unmixing_bootstrap(
result, n_boot=n_boot, resample=resample,
proportion=proportion, noise_level=noise_level,
random_seed=rng)
except (ValueError, RuntimeError, KeyError) as error:
failures.append((experiment_name, str(error)))
if verbose:
print(f' {experiment_name}: FAILED ({error})')
continue
results[experiment_name] = result
stats = result['stats']
for comp_index, prow in result['params'].iterrows():
row = {
'experiment': experiment_name,
'specimen': specimen,
'method': method,
'n_components': n_components,
'component': comp_index,
'success': result['success'],
'Bcr_mT': Bcr * 1e3 if np.isfinite(Bcr) else np.nan,
'rss': stats['rss'],
'r_squared': stats['r_squared'],
'aic': stats['aic'],
'bic': stats['bic'],
}
row.update(prow.to_dict())
uncertainty = result.get('bootstrap') or result.get('bayes')
if uncertainty is not None:
summary_row = uncertainty['param_summary'].loc[comp_index]
for name in ['proportion', 'B_mean_mT', 'sd_log']:
row[f'{name}_std'] = summary_row[f'{name}_std']
row[f'{name}_p2_5'] = summary_row[f'{name}_p2_5']
row[f'{name}_p97_5'] = summary_row[f'{name}_p97_5']
rows.append(row)
if verbose:
summary = ', '.join(
f"{prow['B_mean_mT']:.0f} mT ({prow['proportion'] * 100:.0f}%)"
for _i, prow in result['params'].iterrows())
print(f' {experiment_name} ({specimen}): {summary}, '
f"R^2 = {stats['r_squared']:.4f}")
components_df = pd.DataFrame(rows)
if verbose and failures:
print(f'{len(failures)} experiment(s) failed: '
f'{[name for name, _reason in failures]}')
return components_df, results
[docs]
def aggregate_by_class(components, boundaries_mT, class_names=None,
coercivity_column='B_mean_mT',
proportion_column='proportion',
contribution_column='contribution',
curve_factor=2.0, group_column='experiment',
passthrough=('specimen', 'Bcr_mT', 'r_squared')):
"""
Aggregate fitted unmixing components into coercivity classes.
Sums each component's remanence proportion (and, if available, its
contribution) into classes defined by one or more coercivity cut points,
per experiment. This is the robust way to quantify a mineral assemblage
from an unmixing fit: because it integrates the fitted distribution
within coercivity bands, the result is insensitive to how many
components the optimizer used or how it split a single mineral, so long
as the mineral populations are separated by the cut points. Typical use
is a magnetite/hematite split at a single boundary, but any number of
classes is supported.
Parameters
----------
components : pandas.DataFrame
Tidy component table, e.g. from unmix_backfield_experiments (one row
per experiment and component).
boundaries_mT : float or sequence of float
Coercivity cut point(s) in mT. A single value gives two classes; a
sequence of k values gives k+1 classes. A component is placed in the
class between the cut points that bracket its coercivity; a component
exactly on a boundary goes to the lower class.
class_names : sequence of str, optional
Names for the classes, in order of increasing coercivity (length =
number of boundaries + 1). Defaults to 'class_1', 'class_2', ...;
for a two-class split you would typically pass e.g.
['magnetite', 'hematite'].
coercivity_column : str
Column used to classify each component (default 'B_mean_mT').
proportion_column : str
Column summed to give each class's remanence fraction (default
'proportion').
contribution_column : str
Column summed (and divided by curve_factor) to give each class's
absolute remanence; skipped if the column is absent.
curve_factor : float
Divisor applied to summed contributions. Use 2 for a shift-corrected
backfield curve (which spans twice the saturation remanence) and 1
for an IRM acquisition curve.
group_column : str
Column identifying each specimen/experiment to aggregate within
(default 'experiment').
passthrough : sequence of str
Columns copied through unchanged (first value per group), e.g.
specimen name and fit statistics. Missing columns are ignored.
Returns
-------
pandas.DataFrame
One row per group with the passthrough columns and, for each class,
a '{name}_fraction' column and (when contributions are present) a
'{name}_remanence' column.
"""
if coercivity_column not in components.columns:
raise KeyError(f"components has no '{coercivity_column}' column")
boundaries = np.sort(np.atleast_1d(boundaries_mT).astype(float))
edges = np.concatenate([[-np.inf], boundaries, [np.inf]])
n_classes = len(edges) - 1
if class_names is None:
class_names = [f'class_{i + 1}' for i in range(n_classes)]
if len(class_names) != n_classes:
raise ValueError(f'class_names must have {n_classes} entries '
f'({len(boundaries)} boundaries + 1)')
has_contribution = contribution_column in components.columns
rows = []
for group_name, group in components.groupby(group_column):
row = {group_column: group_name}
for col in passthrough:
if col in group.columns:
row[col] = group[col].iloc[0]
b = group[coercivity_column].to_numpy()
for k, name in enumerate(class_names):
in_class = (b > edges[k]) & (b <= edges[k + 1])
row[f'{name}_fraction'] = \
group.loc[in_class, proportion_column].sum()
if has_contribution:
row[f'{name}_remanence'] = \
group.loc[in_class, contribution_column].sum() / curve_factor
rows.append(row)
return pd.DataFrame(rows)
[docs]
def parse_specimen_description(description):
"""
Parse a MagIC specimens 'description' cell into (text, dict).
The description convention used here stores free text and a
machine-readable JSON dictionary separated by ' | '. Legacy cells that
contain a Python dict repr (from older rockmagpy versions) are parsed
with ast.literal_eval.
Parameters
----------
description : str or NaN
Contents of the description cell.
Returns
-------
tuple
(text, data) where text is the free-text portion (str, possibly
empty) and data is the parsed dictionary (possibly empty).
"""
if description is None or (isinstance(description, float)
and np.isnan(description)):
return '', {}
description = str(description).strip()
if not description:
return '', {}
text, data = description, {}
if ' | ' in description:
candidate_text, candidate_json = description.split(' | ', 1)
try:
data = json.loads(candidate_json)
return candidate_text, data
except (json.JSONDecodeError, ValueError):
pass
for parser in (json.loads, ast.literal_eval):
try:
parsed = parser(description)
if isinstance(parsed, dict):
return '', parsed
except (ValueError, SyntaxError):
continue
return text, data
def _unmixing_component_record(row):
"""Build the JSON-serializable record for one unmixing component row."""
record = {
'component': int(row['component']),
'B_mean_mT': round(float(row['B_mean_mT']), 2),
'B_median_mT': round(float(row['B_median_mT']), 2),
'DP': round(float(row['sd_log']), 4),
'skew': round(float(row['skew']), 4),
'proportion': round(float(row['proportion']), 4),
'contribution': float(row['contribution']),
}
if 'B_mean_mT_p2_5' in row.index and pd.notna(row['B_mean_mT_p2_5']):
record['B_mean_mT_95CI'] = [round(float(row['B_mean_mT_p2_5']), 2),
round(float(row['B_mean_mT_p97_5']), 2)]
record['proportion_95CI'] = [round(float(row['proportion_p2_5']), 4),
round(float(row['proportion_p97_5']), 4)]
return record
# Marker written into (and matched from) the JSON 'description' of specimens
# rows that add_unmixing_to_specimens_table(mode='rows') creates, so a re-run
# removes exactly its own previous component rows -- and never an original
# specimen row that merely carries a mode='description' payload or a rem_cmf.
_UNMIXING_ROW_MARKER = 'coercivity_unmixing_component_row'
def _match_specimen_rows(specimens_df, experiment_name, specimen_name):
"""Boolean mask of specimens rows matching an experiment (or specimen).
An 'experiments' cell may hold a single name or a colon-delimited list,
so cells are split on ':' and each token compared exactly -- a substring
match would let experiment 'EXP1' also claim 'EXP10'.
"""
name = str(experiment_name)
if 'experiments' in specimens_df.columns:
mask = specimens_df['experiments'].apply(
lambda cell: name in str(cell).split(':'))
else:
mask = pd.Series(False, index=specimens_df.index)
if not mask.any() and 'specimen' in specimens_df.columns:
mask = specimens_df['specimen'] == specimen_name
return mask
[docs]
def add_unmixing_to_specimens_table(specimens_df, components_df, mode='rows'):
"""
Record coercivity unmixing results in a MagIC specimens table.
Two recording conventions are supported:
- mode='rows' (default, conformant with the MagIC data model): one new
specimens row is appended per experiment and component, populating
the controlled-vocabulary columns rem_cmf (component median field,
in tesla), rem_cd (component dispersion, log10 units), and
rem_n_comp (number of components in the model), along with specimen
identity columns copied from the matching existing row. The full
parameter set (proportion, skew, confidence intervals, ...) is
stored as JSON in each new row's 'description' cell. Rows from a
previous call for the same experiments are replaced.
- mode='description': the matching existing specimens rows are updated
in place, storing the complete unmixing model as JSON under the
'coercivity_unmixing' key in 'description' (any existing free text
is preserved with a 'text | json' convention readable by
parse_specimen_description).
Parameters
----------
specimens_df : pandas.DataFrame
MagIC specimens table.
components_df : pandas.DataFrame
Tidy components table from unmix_backfield_experiments.
mode : str
'rows' or 'description'.
Returns
-------
pandas.DataFrame
The updated specimens table. With mode='description' the input
table is also modified in place; with mode='rows' a new table is
returned (pandas cannot append rows in place).
"""
assert mode in ('rows', 'description'), \
"mode must be 'rows' or 'description'"
if 'description' not in specimens_df.columns:
specimens_df['description'] = np.nan
specimens_df['description'] = specimens_df['description'].astype(object)
if mode == 'description':
for experiment_name, group in components_df.groupby('experiment'):
first = group.iloc[0]
unmix_record = {
'method': f"rockmagpy_{first['method']}",
'software_version': pmagpy_version,
'n_components': int(first['n_components']),
'r_squared': round(float(first['r_squared']), 5),
'components': [_unmixing_component_record(row)
for _i, row in group.iterrows()],
}
mask = _match_specimen_rows(specimens_df, experiment_name,
first['specimen'])
for idx in specimens_df.index[mask]:
text, data = parse_specimen_description(
specimens_df.at[idx, 'description'])
data['coercivity_unmixing'] = unmix_record
payload = json.dumps(data)
specimens_df.at[idx, 'description'] = (
f'{text} | {payload}' if text else payload)
return specimens_df
# mode == 'rows': one MagIC specimens row per component.
# 'experiments' is deliberately NOT copied from the template: the template
# row may list several experiments (colon-delimited), but a component
# result derives from just this backfield experiment, so each new row's
# 'experiments' is set to the single experiment_name below. Copying the
# full colon-delimited string instead would also break the exact-match
# cleanup, causing rows to accumulate on re-runs.
identity_columns = ['specimen', 'sample', 'citations',
'result_quality', 'weight', 'volume']
new_rows = []
processed_experiments = []
for experiment_name, group in components_df.groupby('experiment'):
first = group.iloc[0]
mask = _match_specimen_rows(specimens_df, experiment_name,
first['specimen'])
template = (specimens_df.loc[mask].iloc[0]
if mask.any() else pd.Series(dtype=object))
processed_experiments.append(str(experiment_name))
for _i, row in group.iterrows():
new_row = {col: template[col] for col in identity_columns
if col in template.index and pd.notna(template[col])}
new_row.setdefault('specimen', first['specimen'])
new_row.setdefault('experiments', str(experiment_name))
new_row['method_codes'] = 'LP-BCR-BF'
new_row['software_packages'] = pmagpy_version
new_row['rem_cmf'] = float(row['B_median_mT']) * 1e-3 # tesla
new_row['rem_cd'] = float(row['sd_log'])
new_row['rem_n_comp'] = int(row['n_components'])
if pd.notna(row.get('Bcr_mT')):
new_row['rem_bcr'] = float(row['Bcr_mT']) * 1e-3 # tesla
record = {'coercivity_unmixing': {
'method': f"rockmagpy_{row['method']}",
'software_version': pmagpy_version,
'experiment': str(experiment_name),
'record_type': _UNMIXING_ROW_MARKER,
'n_components': int(row['n_components']),
'r_squared': round(float(row['r_squared']), 5),
'component': _unmixing_component_record(row),
}}
new_row['description'] = json.dumps(record)
new_rows.append(new_row)
# Drop only the component rows a PREVIOUS mode='rows' call created for these
# experiments. Rows are identified by the explicit marker this function
# writes, never by a generic 'coercivity_unmixing' string or a populated
# rem_cmf -- so an original specimen row carrying a mode='description'
# payload (or an independently populated rem_cmf) is never removed.
is_previous = specimens_df['description'].astype(str).str.contains(
_UNMIXING_ROW_MARKER, na=False, regex=False)
if 'experiments' in specimens_df.columns:
processed_set = set(processed_experiments)
# split on ':' so a row is matched whether its 'experiments' cell holds
# the single experiment_name (as newly written) or a colon-delimited
# list (e.g. a legacy row from before this fix), without the substring
# false positives a plain str.contains would introduce
in_processed = specimens_df['experiments'].apply(
lambda cell: bool(set(str(cell).split(':')) & processed_set))
is_previous = is_previous & in_processed
kept = specimens_df.loc[~is_previous]
return pd.concat([kept, pd.DataFrame(new_rows)], ignore_index=True)
# Day plot functions
# ------------------------------------------------------------------------------------------------------------------
[docs]
def plot_day_magic(specimen_data,
by ='specimen',
Mr = 'hyst_mr_mass',
Ms = 'hyst_ms_mass',
Bcr = 'rem_bcr',
Bc = 'hyst_bc',
**kwargs):
"""
Function to plot a Day plot from a MagIC specimens table.
Parameters
----------
specimen_data : pandas.DataFrame
DataFrame containing the specimens data.
by : str
Column name to group by (default is 'specimen').
Mr : str
Column name for the remanence (default is 'hyst_mr_mass').
Ms : str
Column name for the saturation magnetization (default is 'hyst_ms_mass').
Bcr : str
Column name for the coercivity (default is 'hyst_bcr').
Bc : str
Column name for the coercivity of remanence (default is 'hyst_bc').
**kwargs : keyword arguments
Additional arguments to pass to the plotting function.
Returns
-------
ax : matplotlib.axes.Axes
The axes object containing the plot.
"""
summary_sats = specimen_data.groupby(by).agg({Mr: 'mean', Ms: 'mean', Bcr: 'mean', Bc: 'mean'}).reset_index()
summary_sats = summary_sats.dropna()
fig, ax = plot_day(Mr = summary_sats[Mr],
Ms = summary_sats[Ms],
Bcr = summary_sats[Bcr],
Bc = summary_sats[Bc],
**kwargs)
return fig, ax
[docs]
def plot_day(Mr, Ms, Bcr, Bc,
Mr_Ms_lower=0.05, Mr_Ms_upper=0.5, Bc_Bcr_lower=1.5, Bc_Bcr_upper=4,
plot_day_lines = True,
plot_MD_slope=True,
plot_SP_SD_mixing=[10, 15, 25, 30],
plot_SD_MD_mixing=True,
color='black', marker='o',
label = 'sample', alpha=1,
lc='black', lw=0.5,
legend=True, figsize=(8,6),
show_plot=True, return_figure=True):
'''
function to plot given Ms, Mr, Bc, Bcr values either as single values or list/array of values
plots Mr/Ms vs Bc/Bcr.
Parameters
----------
Ms : float or array-like
saturation magnetization
Mr : float or array-like
remanent magnetization
Bc : float or array-like
coercivity
Bcr : float or array-like
coercivity of remanence
color : str, optional
color of the points. The default is 'black'.
marker : str, optional
marker style of the points. The default is 'o'.
label : str, optional
label for the points. The default is 'sample'.
alpha : float, optional
transparency of the points. The default is 1.
lc : str, optional
color of the lines. The default is 'black'.
lw : float, optional
line width of the lines. The default is 0.5.
legend : bool, optional
whether to show the legend. The default is True.
figsize : tuple, optional
size of the figure. The default is (6,6).
show_plot : bool, optional
whether to show the plot. The default is True.
return_figure : bool, optional
whether to return the figure and axes objects. The default is True, so that a different function (plot_day_MagIC) can use it.
Returns
-------
tuple or None
- If return_figure is True (default), returns (fig, ax).
- Otherwise, returns None.
'''
# force numpy arrays
Ms = np.asarray(Ms)
Mr = np.asarray(Mr)
Bc = np.asarray(Bc)
Bcr = np.asarray(Bcr)
Bcr_Bc = Bcr/Bc
Mr_Ms = Mr/Ms
fig, ax = plt.subplots(figsize = figsize)
# plotting SD, PSD, MD regions
if plot_day_lines:
ax.axhline(Mr_Ms_lower, color = lc, lw = lw)
ax.axhline(Mr_Ms_upper, color = lc, lw = lw)
ax.axvline(Bc_Bcr_lower, color = lc, lw = lw)
ax.axvline(Bc_Bcr_upper, color = lc, lw = lw)
ax.text(1.1, 0.55, 'SD', color = 'k', fontsize = 12)
ax.text(2.0, 0.06, 'PSD', color = 'k', fontsize = 12)
ax.text(5.0, 0.006, 'MD', color = 'k', fontsize = 12)
if plot_MD_slope:
MD_Bcr_Bc = np.linspace(4, 20, 100)
MD_Mr_Ms = 1/MD_Bcr_Bc * 45/480
ax.plot(MD_Bcr_Bc, MD_Mr_Ms, color = lc, lw = lw, label = 'MD slope')
if len(plot_SP_SD_mixing) > 0:
# get the SP saturation curve
Bcr_Bc_SP, Mr_Ms_SP = SP_saturation_curve()
ax.plot(Bcr_Bc_SP, Mr_Ms_SP, color = lc, lw = lw, ls='--', label = 'SP saturation curve')
for i, SP_size in enumerate(plot_SP_SD_mixing):
mixing_Bcr_Bc, mixing_Mr_Ms = SP_SD_mixture(SP_size)
# filter out anything above the SP saturation curve
Mr_Ms_SP_cutoff = np.interp(mixing_Bcr_Bc, Bcr_Bc_SP, Mr_Ms_SP)
mask = mixing_Mr_Ms < Mr_Ms_SP_cutoff
mixing_Bcr_Bc = mixing_Bcr_Bc[mask]
mixing_Mr_Ms = mixing_Mr_Ms[mask]
ax.plot(mixing_Bcr_Bc, mixing_Mr_Ms, color = 'C'+str(i), lw = lw, ls='--', label = f'SP size {SP_size} nm')
if plot_SD_MD_mixing:
# get the SD/MD mixing curve
mixing_Bcr_Bc, mixing_Mr_Ms = SD_MD_mixture()
# filter out anything above the SP saturation curve
Mr_Ms_SP_cutoff = np.interp(mixing_Bcr_Bc, Bcr_Bc_SP, Mr_Ms_SP)
mask = mixing_Mr_Ms < Mr_Ms_SP_cutoff
mixing_Bcr_Bc = mixing_Bcr_Bc[mask]
mixing_Mr_Ms = mixing_Mr_Ms[mask]
ax.plot(mixing_Bcr_Bc, mixing_Mr_Ms, color = 'k', lw = lw, ls='-.', label = 'SD/MD mixture')
# plot the data
ax.scatter(Bcr_Bc, Mr_Ms, color = color, marker = marker, label = label, alpha=alpha)
ax.set_xlim(1, 100)
ax.set_ylim(0.005, 1)
ax.set_xscale('log')
ax.set_yscale('log')
ax.set_xticks([1, 2, 5, 10, 20, 50, 100], [1, 2, 5, 10, 20, 50, 100])
ax.set_yticks([0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 1], [0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 1])
ax.set_xlabel('B$_{cr}$/B$_{c}$', fontsize=12)
ax.set_ylabel('M$_{r}$/M$_{s}$', fontsize=12)
ax.set_title('Day plot', fontsize=14)
if legend:
ax.legend(loc='lower right', fontsize=10)
if show_plot:
plt.show()
if return_figure:
return fig, ax
return None
[docs]
def plot_neel_magic(specimen_data,
by ='specimen',
Mr = 'hyst_mr_mass',
Ms = 'hyst_ms_mass',
Bcr = 'rem_bcr',
Bc = 'hyst_bc',
**kwargs):
"""
Function to plot a Day plot from a MagIC specimens table.
Parameters
----------
specimen_data : pandas.DataFrame
DataFrame containing the specimens data.
by : str
Column name to group by (default is 'specimen').
Mr : str
Column name for the remanence (default is 'hyst_mr_mass').
Ms : str
Column name for the saturation magnetization (default is 'hyst_ms_mass').
Bcr : str
Column name for the coercivity (default is 'hyst_bcr').
Bc : str
Column name for the coercivity of remanence (default is 'hyst_bc').
**kwargs : keyword arguments
Additional arguments to pass to the plotting function.
Returns
-------
ax : matplotlib.axes.Axes
The axes object containing the plot.
"""
summary_stats = specimen_data.groupby(by).agg({Mr: 'mean', Ms: 'mean', Bcr: 'mean', Bc: 'mean'}).reset_index()
summary_stats = summary_stats.dropna()
ax = plot_neel(Mr = summary_stats[Mr],
Ms = summary_stats[Ms],
Bc = summary_stats[Bc],
**kwargs)
return ax
[docs]
def plot_neel(Mr, Ms, Bc, color='black', marker = 'o', label = 'sample', alpha=1, lc = 'black', lw=0.5, legend=True, axis_scale='linear', figsize = (5, 5)):
"""
Generate a Néel plot (squareness-coercivity) of Mr/Ms versus Bc from hysteresis data.
This plot shows the ratio of remanent to saturation magnetization
(Mr/Ms) plotted against the coercivity (Bc). It is useful for
characterizing magnetic domain states in rock magnetic samples.
Parameters
----------
Mr : array-like
Saturation remanence values of the samples.
Ms : array-like
Saturation magnetization values of the samples.
Bc : array-like
Coercivity values of the samples.
color : str, optional
Color of the scatter points. Default is "black".
marker : str, optional
Marker style for scatter points. Default is "o".
label : str, optional
Label for the sample to be displayed in the legend. Default is "sample".
alpha : float, optional
Transparency of the scatter points. Default is 1 (opaque).
lc : str, optional
Color of the grid lines. Default is "black".
lw : float, optional
Line width of the grid lines. Default is 0.5.
legend : bool, optional
Whether to show the legend. Default is True.
axis_scale : str, optional
Scale for both axes: "linear" or "log". Default is "linear".
figsize : tuple of int, optional
Figure size in inches (width, height). Default is (5, 5).
Returns
-------
matplotlib.axes.Axes
The matplotlib axes object containing the plot.
"""
assert axis_scale in ['linear', 'log'], "axis_scale must be 'linear' or 'log'"
# force numpy arrays
Ms = np.asarray(Ms)
Mr = np.asarray(Mr)
Bc = np.asarray(Bc)
Mr_Ms = Mr/Ms
_, ax = plt.subplots(figsize = figsize)
ax.scatter(Bc, Mr_Ms, color = color, marker = marker, label = label, alpha=alpha, zorder = 100)
ax.set_xlabel('B$_c$ (T)', fontsize=12)
ax.set_ylabel('M$_r$/M$_s$', fontsize=12)
if axis_scale == 'linear':
ax.set_xscale('linear')
ax.set_yscale('linear')
else:
ax.set_xscale('log')
ax.set_yscale('log')
if legend:
ax.legend(loc='upper left', fontsize=12)
ax.grid(True, which='both', linestyle='--', linewidth=lw, color=lc)
return ax
[docs]
def Langevin(alpha):
'''
Langevin function
Parameters
----------
alpha : float
Langevin alpha value
Returns
-------
L : float
Langevin function value
'''
return 1 / np.tanh(alpha) - 1 / alpha
[docs]
def magnetite_Ms(T):
'''
Magnetite saturation magnetization calculation
Parameters
----------
T : float
temperature in Celsius
Returns
-------
Ms : float
saturation magnetization value
'''
return 737.384 * 51.876 * (580 - T)**0.4
[docs]
def chi_SP(SP_size, T):
'''
SP size distribution function
Parameters
----------
SP_size : float
size of the superparamagnetic particle in nm
T : float
temperature in Kelvin
Returns
-------
chi : float
susceptibility value
'''
mu0 = 4 * np.pi * 1e-7
k = 1.38064852e-23
Ms = magnetite_Ms(T - 273.15)
V = 4/3*np.pi*(SP_size/2)**3 / 1e27
return mu0 * V * Ms**2 / 3 / k / T
[docs]
def SP_SD_mixture(SP_size, SD_Mr_Ms = 0.5, SD_Bcr_Bc = 1.25, X_sd = 3, T = 300):
'''
function to calculate the SP/SD mixture curve according to Dunlop (2002)
Parameters
----------
SP_size : float
size of the superparamagnetic particle in nm
SD_Mr_Ms : float, optional
remanent to saturation magnetization ratio. The default is 0.5.
SD_Bcr_Bc : float, optional
remanent coercivity to coercivity ratio. The default is 1.25.
X_sd : float, optional
approximate Mrs/Bc slope. The default is 3 for magnetite
T : float, optional
temperature in Kelvin. The default is 300.
Returns
-------
Bcr_Bc : numpy.ndarray
coercivity ratio array
Mrs_Ms : numpy.ndarray
saturation magnetization ratio array
'''
f_sd = 1/np.logspace(0, 2, 100)
f_sp = 1 - f_sd
Mrs_Ms = f_sd * SD_Mr_Ms
X_sp = chi_SP(SP_size, T)
Bcr_Bc = 1 / (f_sd * X_sd / (f_sd * X_sd + f_sp * X_sp)) * SD_Bcr_Bc
return Bcr_Bc, Mrs_Ms
[docs]
def SP_saturation_curve(SD_Mr_Ms=0.5, SD_Bcr_Bc = 1.25):
'''
function to calculate the SP saturation curve according to Dunlop (2002)
Parameters
----------
SD_Mr_Ms : float, optional
saturation magnetization ratio. The default is 0.5.
SD_Bcr_Bc : float, optional
remanence coercivity to coercivity ratio. The default is 1.25.
Returns
-------
Bcr_Bc : numpy.ndarray
coercivity ratio array
Mrs_Ms : numpy.ndarray
saturation magnetization ratio array
'''
f_sp = np.linspace(0, 1/3, 100)
f_sd = 1 - f_sp
Mrs_Ms = f_sd * SD_Mr_Ms
Bcr_Bc = 1 / (1 - (f_sp/f_sd) / SD_Mr_Ms) * SD_Bcr_Bc
return Bcr_Bc, Mrs_Ms
[docs]
def SD_MD_mixture(Mr_Ms_SD = 0.5, Mr_Ms_MD = 0.019,
Bc_SD = 400, Bc_MD = 43,
Bcr_SD = 500, Bcr_MD = 230,
X_sd = 0.6, X_MD = 0.209,
Xr_SD = 0.48, Xr_MD = 0.039):
'''
function to calculate the SD/MD mixture curve according to Dunlop (2002)
Parameters
----------
Mr_Ms_SD : float
remanent to saturation magnetization ratio for SD. The default is 0.5.
Mr_Ms_MD : float
remanent to saturation magnetization ratio for MD. The default is 0.019.
Bc_SD : float
coercivity for SD. The default is 400.
Bc_MD : float
coercivity for MD. The default is 43.
Bcr_SD : float
coercivity of remanence for SD. The default is 500.
Bcr_MD : float
coercivity of remanence for MD. The default is 230.
X_sd : float
approximate Mrs/Bc slope for SD. The default is 0.6.
X_MD : float
approximate Mrs/Bc slope for MD. The default is 0.209.
Xr_SD : float
approximate Mrs/Bcr slope for SD. The default is 0.48.
Xr_MD : float
approximate Mrs/Bcr slope for MD. The default is 0.039.
Returns
-------
Bcr_Bc : numpy.ndarray
coercivity ratio array
Mrs_Ms : numpy.ndarray
saturation magnetization ratio array
* the default values are fro the IRM database
'''
f_sd = np.linspace(0, 1, 100)
f_md = 1 - f_sd
Mrs_Ms = f_sd * Mr_Ms_SD + f_md * Mr_Ms_MD
Bc = (f_sd * X_sd * Bc_SD + f_md * X_MD * Bc_MD) / (f_sd * X_sd + f_md * X_MD)
Bcr = (f_sd * Xr_SD * Bcr_SD + f_md * Xr_MD * Bcr_MD) / (f_sd * Xr_SD + f_md * Xr_MD)
Bcr_Bc = Bcr / Bc
return Bcr_Bc, Mrs_Ms