The rows say what the power meter read at each angle. A result is something else — here, the curve those readings follow. A half-wave plate turns the polarisation by twice its own angle, so the power through a polariser behind it repeats every 90° of plate: two periods over the scan, a Malus curve. Fitting it gives the mean power, how deep the oscillation is, and the plate angle of maximum power. That is an analyzer's job, and the experiment calls it as the last step of its plan (see Analysis and Reports).
The analyzer
An analyzer declares what it takes and gives, and computes in one method.
analysis.py:
import numpy as np
from plesty.lib.analyzer import Analyzer
from plesty.lib.data import PlestyArray
class MalusFit(Analyzer):
"""Fit the power behind a turning half-wave plate: P = a + b cos 4θ + c sin 4θ."""
input_schema = {
"angle": {
"dtype": "array_float",
"unit": "deg",
"description": "Plate angle of each row.",
},
"power": {"dtype": "array_float", "unit": "W", "description": "Power of each row."},
}
output_schema = {
"mean": {"dtype": "float", "unit": "W", "description": "Mean power over a period."},
"modulation": {
"dtype": "float",
"unit": "",
"description": "Amplitude of the oscillation relative to the mean.",
},
"axis": {"dtype": "float", "unit": "deg", "description": "Plate angle of maximum power."},
}
def analyze(self, angle: PlestyArray, power: PlestyArray) -> dict:
"""Fit the curve by least squares, and read the mean, contrast and axis off it."""
theta = np.radians(np.asarray(angle, dtype=float))
design = np.column_stack([np.ones_like(theta), np.cos(4 * theta), np.sin(4 * theta)])
(a, b, c), *_ = np.linalg.lstsq(design, np.asarray(power, dtype=float), rcond=None)
return {
"mean": float(a),
"modulation": float(np.hypot(b, c) / a),
"axis": float(np.degrees(np.arctan2(c, b)) / 4 % 90),
}
Calling it checks the inputs and outputs against the schemas and stamps its
provenance — class, version, settings — on the results. An analyzer used by
more than one experiment is a module of its own (plesty init analyzer), like
plesty-fss, which the
production plesty-pol-pl calls on the spectra.
The analysis step
The plan already ends with Step(id="analysis", op="analyze") (see
Plan & Steps). Its method reads the rows back, calls the
analyzer, and saves what it returned and its report into the run's
analysis/ folder:
import numpy as np
from plesty.lib.data import PlestyArray, save_result
from plesty.lib.experiment import Run
from plesty.lib.report import REPORT_SUFFIX
from .analysis import MalusFit
def analyze(self) -> dict[str, float]:
"""Fit the rows' power against the angle, and save the fit and its report."""
rows = [r for r in Run(self.run_dir).records() if "power_w" in r]
analyzer = MalusFit()
outputs = analyzer(
angle=PlestyArray(np.array([r["hwp_deg"] for r in rows])),
power=PlestyArray(np.array([r["power_w"] for r in rows])),
)
stem = self.run_dir / "analysis" / "malus_fit"
stem.parent.mkdir(exist_ok=True)
provenance = {"run": self.run_id, "analyzer": analyzer.provenance()}
values = [outputs["mean"], outputs["modulation"], outputs["axis"]]
save_result(PlestyArray(np.array(values)), stem, provenance)
report = analyzer.report(outputs)
report.title = "Excitation power against plate angle"
report.provenance = provenance
report.save(f"{stem}{REPORT_SUFFIX}") # malus_fit.report.json
return {
"mean_power_w": outputs["mean"],
"modulation": outputs["modulation"],
"axis_deg": outputs["axis"],
}
Results go through save_result, never np.save or open(...) — gate e2
checks — so every run's results have the same readable form:
analysis/
├── malus_fit.npy the three numbers
├── malus_fit.json what they are, and the provenance: run, analyzer, version
└── malus_fit.report.json the analyzer's report
The step's return value is its record — the value of the last line of
records.jsonl, beside the step's id and when it was saved:
{"mean_power_w": 1.1692184034420108e-05, "modulation": 1.0140140751673992, "axis_deg": 60.09712127448079}
A modulation near 1 means the power falls almost to zero twice over the scan — the full contrast of a polariser behind the plate.
The report
Without any code, an analyzer's report is a table of its results; it renders without the analyzer installed:
Excitation power against plate angle
| Quantity | Value |
|---|---|
| Mean (W) | 1.169 × 10⁻⁵ |
| Modulation | 1.014 |
| Axis (deg) | 60.10 |
A report() method of the analyzer's own can add a sentence, a table laid out
for a paper, a figure. When the run is started from PLESTY Bench, every
analyzer report is a section of the run's report.html.
Next: Watch It.