Plesty Documentation

Analyze

Turn the run's rows into a result with an analyzer — a Malus fit of the excitation power against the plate angle — called as the experiment's last step and saved with its report.

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.