A Simple M vs. H Analysis¶
Perhaps the most useful aspect of MagnetoPy is the ease of use when creating new analyses of magnetism data. One such analysis is included in the base MagnetoPy package and is handled by the SimpleMvsHAnalysis
class. This class determines basic information about a hysteresis loop, i.e., saturation magnetization, coercive field, and remnant magnetization. In this example we'll build this class from scratch to explain how to use MagnetoPy to create new analyses.
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Any, Literal
import pandas as pd
import magnetopy as mp
DATA_PATH = Path("../../tests/data/")
Exploratory Data Analysis¶
MagnetoPy will likely be used in a notebook environment (like this example notebook) to develop analyses interactively. For example, let's consider a dataset containing several M vs. H experiments at different temperatures. Note also that one of the experiments only contains a reverse field sweep.
dset1 = mp.Magnetometry(DATA_PATH / "dataset1")
for mvsh in dset1.mvsh:
segments = []
for segment in ["forward", "reverse"]:
try:
_ = mvsh.select_segment(segment)
segments.append(segment)
except mp.MvsH.SegmentError:
pass
print(f"{mvsh}:\tavailable segments: {segments}")
MvsH at 2 K: available segments: ['forward', 'reverse'] MvsH at 4 K: available segments: ['forward', 'reverse'] MvsH at 6 K: available segments: ['forward', 'reverse'] MvsH at 8 K: available segments: ['forward', 'reverse'] MvsH at 10 K: available segments: ['forward', 'reverse'] MvsH at 12 K: available segments: ['forward', 'reverse'] MvsH at 300 K: available segments: ['reverse']
The first step in our analysis will be to select a particular MvsH
object based on a desired temperature.
mvsh = dset1.get_mvsh(2)
mvsh
MvsH at 2 K
To determine saturation magnetization, coercive field, and remnant magnetization, we'll need to inspect individual segments within the hysteresis loop. Better yet, we can average over all available segments to get a more robust estimate of these quantities. First we'll need to make a list of the available segments and the DataFrame
containing the data for each segment.
Note that we'll be analyzing a DataFrame
from the MvsH.simplified_data()
method. This ensures that no matter what the original data looks like or what scaling was applied, we'll be able to analyze it in a consistent manner.
segments: dict[str, pd.DataFrame] = {}
for segment in ["forward", "reverse"]:
try:
data = mvsh.simplified_data(segment)
segments[segment] = data
except mp.MvsH.SegmentError:
pass
segments["forward"]
time | temperature | field | moment | moment_err | chi | chi_err | chi_t | chi_t_err | |
---|---|---|---|---|---|---|---|---|---|
0 | 3803630121 | 1.999938 | -70000.35156 | -10.448284 | 0.013823 | 0.833620 | 0.001103 | 1.667187 | 0.002206 |
1 | 3803630124 | 2.000082 | -69999.64063 | -10.464880 | 0.013818 | 0.834952 | 0.001103 | 1.669973 | 0.002205 |
2 | 3803630129 | 1.999687 | -69746.61719 | -10.460689 | 0.012272 | 0.837646 | 0.000983 | 1.675029 | 0.001965 |
3 | 3803630134 | 2.000118 | -69498.96875 | -10.465098 | 0.015537 | 0.840985 | 0.001249 | 1.682069 | 0.002497 |
4 | 3803630139 | 2.000298 | -69246.27344 | -10.456070 | 0.014218 | 0.843326 | 0.001147 | 1.686902 | 0.002294 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
558 | 3803632902 | 1.999771 | 69002.42969 | 10.453863 | 0.040218 | 0.846127 | 0.003255 | 1.692060 | 0.006510 |
559 | 3803632907 | 2.000226 | 69253.85156 | 10.457121 | 0.040109 | 0.843318 | 0.003235 | 1.686827 | 0.006470 |
560 | 3803632912 | 1.999959 | 69500.30469 | 10.456319 | 0.041108 | 0.840263 | 0.003303 | 1.680491 | 0.006607 |
561 | 3803632917 | 1.999737 | 69751.79688 | 10.448923 | 0.043926 | 0.836641 | 0.003517 | 1.673063 | 0.007033 |
562 | 3803632925 | 2.000152 | 70000.31250 | 10.448547 | 0.043354 | 0.833641 | 0.003459 | 1.667409 | 0.006919 |
563 rows × 9 columns
The saturation magnetization for a given segment can be determined by averaging the maximum moment at positive fields and the absolute value of the minimum moment at negative fields. We can then average over all available segments.
m_s = 0
for segment in segments.values():
m_s += (segment["moment"].max() + abs(segment["moment"].min())) / 2
m_s /= len(segments)
m_s
10.467101768993821
The coercive field can be determined by finding the field at which the moment is zero. We can then average over all available segments.
h_c = 0
for segment in segments.values():
h_c += abs(segment["field"].iloc[segment["moment"].abs().idxmin()])
h_c /= len(segments)
h_c
4501.3806155
Finally, we can calculate the remnant magnetization by finding the moment at zero field. Again, we can average over all available segments.
m_r = 0
for segment in segments.values():
m_r += abs(segment["moment"].iloc[segment["field"].abs().idxmin()])
m_r / len(segments)
m_r
17.147614337928776
As described in the MvsH.simplified_data()
documentation, the unit of the field data is Oe. The unit of magnetic moment is dependent on what scaling was applied to the data. So our last step will be to check the original MvsH
object to see what scaling was applied to it.
if not mvsh.scaling:
scaling = "emu"
elif "mass" in mvsh.scaling:
scaling = "emu/g"
elif "molar" in mvsh.scaling:
scaling = "bohr magnetons/mol"
scaling
'bohr magnetons/mol'
That's it. Here's the summary of the analysis:
print(f"{mvsh} in {dset1.sample_id} has:")
print(f"\tMs = {m_s:.2f} {scaling}")
print(f"\tHc = {h_c:.2f} Oe")
print(f"\tMr = {m_r:.2f} {scaling}")
MvsH at 2 K in dataset1 has: Ms = 10.47 bohr magnetons/mol Hc = 4501.38 Oe Mr = 17.15 bohr magnetons/mol
Creating the SimpleMvsHAnalysis
Class¶
Having explored the data interactively, we can now create a class to perform the analysis. Using a class that implments MagnetoPy's Analysis
protocol makes it easy to integrate the analysis into MagnetoPy and take advantage of its features, notably the serialization of datasets and analyses.
The Notes section in the Analysis
protocol documentation provides an outline of how a class implementing the Analysis
protocol should be initialized. In summary, the __init__
method should have the following arguments:
dataset
: aMagnetometry
object. Passing the entireMagnetometry
object gives us access to all of the component experiments and sample information, as well as the methods used to process and access the data.parsing_args
: if we want to perform an analysis on, for example, a singleMvsH
experiment object within a dataset containing multipleMvsH
objects, we'll need to pass some information in theparsing_args
argument to tell the analysis class which experiment to use. In general, the values in theparsing_args
argument should be used to work with the various methods within theMagnetometry
and experiment classes. It is strongly recommended to use adataclass
to store theparsing_args
values.fitting_args
: we may also need to pass some values to the analysis class specific to the model we are implementing. These will likely be starting values or limits for the fitting parameters. As with theparsing_args
argument, it is strongly recommended to use adataclass
to store thefitting_args
values.
The only required attribute of the Analysis
protocol is the results
attribute, which should be a dataclass
containing the results of the analysis. The __init__
method should perform the analysis and store the results in results
.
We don't need any fitting_args
for this analysis, so we'll just need to create some classes for parsing_args
and results
.
@dataclass
class SimpleMvsHAnalysisParsingArgs:
"""Arguments needed to parse a `Magnetometry` object during the course of an
analysis performed by `SimpleMvsHAnalysis`.
Attributes
----------
temperature : float
The temperature in Kelvin of the measurement to be analyzed.
segments : Literal["auto", "loop", "forward", "reverse"], optional
The segments of the measurement to be analyzed. If `"auto"`, the forward and
reverse segments will be analyzed if they exist and will be ignored if they
don't. If `"loop"`, the forward and reverse segments will be analyzed if they
exist and an error will be raised if they don't. If `"forward"` or `"reverse"`,
only the forward or reverse segment will be analyzed, respectively.
"""
temperature: float
segments: Literal["auto", "loop", "forward", "reverse"] = "auto"
def as_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass
class SimpleMvsHAnalysisResults:
"""The results of an analysis performed by `SimpleMvsHAnalysis`.
Attributes
----------
m_s : float
The saturation magnetization of the sample in units of `moment_units`.
h_c : float
The coercive field of the sample in units of `field_units`.
m_r : float
The remanent magnetization of the sample in units of `moment_units`.
moment_units : str
The units of the saturation magnetization and remanent magnetization.
field_units : str
The units of the coercive field.
segments : list[{"forward", "reverse"}]
The segments of the measurement that were analyzed.
"""
m_s: float
h_c: float
m_r: float
moment_units: str
field_units: str
segments: Literal["forward", "reverse"]
def as_dict(self) -> dict[str, Any]:
return asdict(self)
Now we just need to move the analysis code we previously wrote into the __init__
method and add a few lines to store the results in the results
attribute. We'll also add some logic for handling requests for specific segments to be analyzed, as well as an as_dict()
method for serializing the results (this is a required method of the Analysis
protocol).
class SimpleMvsHAnalysis:
"""An analysis of an M vs. H experiment that determines basic information about the
hysteresis loop (i.e., saturation magnetization, coercive field, remnant field).
Parameters
----------
dataset : Magnetometry
The `Magnetometry` object which contains the `MvsH` object to be analyzed.
parsing_args : SimpleMvsHAnalysisParsingArgs
Arguments needed to parse the `Magnetometry` object to obtain the `MvsH` object
to be analyzed.
Attributes
----------
parsing_args : SimpleMvsHAnalysisParsingArgs
Arguments needed to parse the `Magnetometry` object to obtain the `MvsH` object
to be analyzed.
mvsh : MvsH
The analyzed `MvsH` object.
results : SimpleMvsHAnalysisResults
The results of the analysis.
"""
def __init__(
self,
dataset: mp.Magnetometry,
parsing_args: SimpleMvsHAnalysisParsingArgs,
) -> None:
self.parsing_args = parsing_args
self.mvsh = dataset.get_mvsh(self.parsing_args.temperature)
segments = self._get_segments()
m_s = self._determine_m_s(segments)
h_c = self._determine_h_c(segments)
m_r = self._determine_m_r(segments)
moment_units = self._determine_moment_units()
field_units = "Oe"
self.results = SimpleMvsHAnalysisResults(
m_s, h_c, m_r, moment_units, field_units, list(segments.keys())
)
def _get_segments(self) -> dict[str, pd.DataFrame]:
segments: dict[str : pd.DataFrame] = {}
if self.parsing_args.segments == "auto":
try:
segments["forward"] = self.mvsh.simplified_data("forward")
except mp.MvsH.SegmentError:
pass
try:
segments["reverse"] = self.mvsh.simplified_data("reverse")
except mp.MvsH.SegmentError:
pass
else:
if self.parsing_args.segments in ["loop", "forward"]:
segments["forward"] = self.mvsh.simplified_data("forward")
if self.parsing_args.segments in ["loop", "reverse"]:
segments["reverse"] = self.mvsh.simplified_data("reverse")
return segments
def _determine_m_s(self, segments: dict[str, pd.DataFrame]) -> float:
m_s = 0
for segment in segments.values():
m_s += (segment["moment"].max() + abs(segment["moment"].min())) / 2
return m_s / len(segments)
def _determine_h_c(self, segments: dict[str, pd.DataFrame]) -> float:
h_c = 0
for segment in segments.values():
h_c += abs(segment["field"].iloc[segment["moment"].abs().idxmin()])
return h_c / len(segments)
def _determine_m_r(self, segments: dict[str, pd.DataFrame]) -> float:
m_r = 0
for segment in segments.values():
m_r += abs(segment["moment"].iloc[segment["field"].abs().idxmin()])
return m_r / len(segments)
def _determine_moment_units(self) -> str:
scaling = self.mvsh.scaling
if not scaling:
return "emu"
elif "mass" in scaling:
return "emu/g"
elif "molar" in scaling:
return "bohr magnetons/mol"
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the analysis.
Returns
-------
dict[str, Any]
Keys are `"mvsh"`, `"parsing_args"`, and `"results"`.
"""
return {
"mvsh": self.mvsh,
"parsing_args": self.parsing_args,
"results": self.results,
}
The Purpose of MagnetoPy¶
Now we can easily analyze M vs. H experiments in any dataset. Note that the processing done for each dataset is different -- these differences include: VSM vs DC measurements, settling vs scanning magnetic field, different scaling based on sample information, one dataset applies a field correction, etc. Despite all of these differences, MagnetoPy makes it easy to perform the same analysis on all of the datasets.
dset1 = mp.Magnetometry(DATA_PATH / "dataset1")
dset2 = mp.Magnetometry(DATA_PATH / "dataset2")
dset3 = mp.Magnetometry(
DATA_PATH / "dataset3",
true_field_correction="sequence_1"
)
dset4 = mp.Magnetometry(DATA_PATH / "dataset4")
for dset in [dset1, dset2, dset3, dset4]:
analyses = []
for mvsh in dset.mvsh:
analysis = SimpleMvsHAnalysis(
dset, SimpleMvsHAnalysisParsingArgs(mvsh.temperature)
)
analyses.append(analysis)
dset.add_analysis(analyses)
If we were publishing this work we would likely take advantage of the MvsH.create_report()
method. For now, we'll just print the results.
print("| Dataset | Temperature (K) | H_c (Oe) | M_s | M_r | M units |")
print("| ------- | --------------- | -------- | --- | --- | ------- |")
for dset in [dset1, dset2, dset3, dset4]:
for analysis in dset.analyses:
print(
f"| {dset.sample_id} | {analysis.parsing_args.temperature} | "
f"{analysis.results.h_c:.2f} | {analysis.results.m_s:.2f} | "
f"{analysis.results.m_r:.2f} | {analysis.results.moment_units} |")
| Dataset | Temperature (K) | H_c (Oe) | M_s | M_r | M units | | ------- | --------------- | -------- | --- | --- | ------- | | dataset1 | 2 | 4501.38 | 10.47 | 8.57 | bohr magnetons/mol | | dataset1 | 4 | 3502.37 | 9.45 | 4.49 | bohr magnetons/mol | | dataset1 | 6 | 1502.06 | 9.32 | 1.24 | bohr magnetons/mol | | dataset1 | 8 | 355.58 | 9.23 | 0.16 | bohr magnetons/mol | | dataset1 | 10 | 1.34 | 9.01 | 0.02 | bohr magnetons/mol | | dataset1 | 12 | 7.78 | 8.87 | 0.00 | bohr magnetons/mol | | dataset1 | 300 | 4.78 | 0.99 | 0.00 | bohr magnetons/mol | | dataset2 | 293.0 | 0.11 | 0.77 | 0.05 | emu/g | | dataset3 | 300 | 4.92 | 51.64 | 0.49 | emu/g | | dataset4 | 2 | 0.12 | 8.50 | 0.06 | bohr magnetons/mol |
Here is the same data formatted in markdown:
Dataset | Temperature (K) | H_c (Oe) | M_s | M_r | M units |
---|---|---|---|---|---|
dataset1 | 2 | 4501.38 | 10.47 | 8.57 | bohr magnetons/mol |
dataset1 | 4 | 3502.37 | 9.45 | 4.49 | bohr magnetons/mol |
dataset1 | 6 | 1502.06 | 9.32 | 1.24 | bohr magnetons/mol |
dataset1 | 8 | 355.58 | 9.23 | 0.16 | bohr magnetons/mol |
dataset1 | 10 | 1.34 | 9.01 | 0.02 | bohr magnetons/mol |
dataset1 | 12 | 7.78 | 8.87 | 0.00 | bohr magnetons/mol |
dataset1 | 300 | 4.78 | 0.99 | 0.00 | bohr magnetons/mol |
dataset2 | 293.0 | 0.11 | 0.77 | 0.05 | emu/g |
dataset3 | 300 | 4.92 | 51.64 | 0.49 | emu/g |
dataset4 | 2 | 0.12 | 8.50 | 0.06 | bohr magnetons/mol |
Other Elements in Analyses¶
The Analysis
protocol class just defines minimum requirements, and additional functionality may be desired in an analysis class. For example, for analyses which benefit from some sort of visualization, it may be useful to implement a plot()
method. This could exist in the class itself or as a standalone method within the analysis module.