from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, overload
if TYPE_CHECKING:
from quchip.chip import Chip
from quchip.devices.base import BaseDevice
[docs]
@dataclass(frozen=True)
class ObservableReport:
"""Per-target record from a ``fit_a_dress`` run.
Attributes
----------
kind
Canonical desired-chip kind (``"freq"``, ``"anharmonicity"``,
``"cross_kerr"``, ``"exchange_rate"``, or
``"coupling_strength"``).
label
Target locator: a device label for single-device observables, a
``(label_a, label_b)`` tuple for pair observables, or a coupling label
for coupling-keyed observables.
target
Value the optimizer tried to match (GHz).
initial
Observable value at the seed chip, before optimization (GHz).
final
Observable value at the fitted chip, after optimization (GHz).
evaluator
``"full"`` for evaluation on the whole chip or ``"local"`` for
evaluation on a one-hop subsystem (see ``evaluator`` in
:func:`fit_a_dress`).
source
``"component default"`` or ``"explicit"``.
"""
kind: str
label: Any
target: float
initial: float
final: float
evaluator: str
source: str = "explicit"
@property
def residual(self) -> float:
"""Final signed error, ``final - target`` (GHz)."""
return self.final - self.target
@property
def relative_residual(self) -> float:
"""Final residual on the fitter's normalized objective scale."""
return self.residual / max(abs(self.target), 1e-9)
[docs]
@dataclass(frozen=True)
class FitParameterReport:
"""Record one fitted bare parameter.
Attributes
----------
name : str
Path in ``chip.parameters``.
initial, final : float
Seed and fitted values in the parameter's native units.
lower_bound, upper_bound : float
Bounds used by the optimizer, in the same units.
seed_source : str
Origin of the starting value, as recorded by the fitter.
sign_choice : str or None
Coupling-sign decision, or ``None`` when no decision was necessary.
"""
name: str
initial: float
final: float
lower_bound: float
upper_bound: float
seed_source: str
sign_choice: str | None = None
@property
def delta(self) -> float:
"""Signed optimizer displacement, ``final - initial``, in the parameter's native units."""
return self.final - self.initial
_UNMET_RELATIVE = 1e-2
_UNMET_FLOOR_GHZ = 1e-6
def _target_unmet(report: "ObservableReport") -> bool:
"""True when a target's final error exceeds 1% of its magnitude (floor 1 kHz)."""
return abs(report.residual) > max(_UNMET_RELATIVE * abs(report.target), _UNMET_FLOOR_GHZ)
[docs]
@dataclass(frozen=True)
class FitADressResult:
"""Result of a :func:`fit_a_dress` optimization run.
Attributes
----------
chip
Fitted chip: a clone of the desired specification with updated device
and coupling parameters. The fit never mutates the input chip. Because
the result exposes ``.chip``, it satisfies
:class:`~quchip.chip.transformations.ChipTransform` structurally,
without inheritance.
loss
Final objective (sum of squared, scale-normalized residuals).
history
One-dimensional ``numpy`` array with the normalized objective at each
distinct parameter vector passed to the residual function. The first
entry is the seed and the last entry is :attr:`loss`. With a numerical
Jacobian, the intermediate entries include finite-difference probes and
accepted solver iterates. For a monotone best-so-far convergence curve,
use ``numpy.minimum.accumulate(history)``.
history_params
One ``{parameter_name: value}`` dict per :attr:`history` entry, named
like :attr:`final_params`. The first entry equals
:attr:`initial_params` and the last entry equals :attr:`final_params`.
history_residuals
Array of shape ``(len(history), len(final_targets))``. Each row holds
the observed minus the target value of each target (GHz) at that
:attr:`history` entry, in :attr:`final_targets` order. The sum of the
squared residuals, each divided by ``max(abs(target), 1e-9)``, is the
matching :attr:`history` entry.
initial_targets
One :class:`ObservableReport` per target, evaluated on the
optimizer's initial candidate.
final_targets
One :class:`ObservableReport` per target, evaluated on the
fitted chip.
initial_params
``{parameter_name: seed_value}``, the optimizer's starting point.
final_params
``{parameter_name: fitted_value}``, the optimizer output. Parameter
names follow ``"<device>.freq"``, ``"<device>.anharmonicity"``, and
``"<coupling>.<coupling_strength_name>"``. The coupling names are
``"<coupling>.g"`` for :class:`~quchip.chip.couplings.Capacitive`,
``"<coupling>.g_0"`` for
:class:`~quchip.chip.couplings.TunableCapacitive`, ``"<coupling>.chi"``
for :class:`~quchip.chip.couplings.CrossKerr`.
parameter_reports
One :class:`FitParameterReport` per varied bare parameter, with its
bounds, the source of its starting point, and any coupling-sign choice.
solver_info
``scipy`` solver metadata (``method``, ``status``, ``message``, ``nfev``,
``jacobian``). It also holds the identifiability receipt that each
:func:`~quchip.inverse_design.fit.fit_a_dress` call records. The receipt
contains ``n_free_parameters`` (length of ``final_params``) and
``n_target_residuals`` (length of ``final_targets``). Its
``underdetermined_by_count`` is ``True`` when the former exceeds the
latter, a necessary but not sufficient identifiability condition. The
receipt also contains the final scaled-Jacobian rank, the condition
number, the singular values, and any weak parameter directions.
Rank uses normalized residuals in the solver's scaled parameter
coordinates. ``history_axis`` names the sampling axis used by
:attr:`history`, and ``n_recorded_evaluations`` gives its length.
``jacobian`` is ``"jax"`` when a JAX-native backend supplies the exact
residual Jacobian and ``"finite-difference"`` otherwise.
"""
chip: Chip
loss: float
history: Any
initial_targets: tuple[ObservableReport, ...]
final_targets: tuple[ObservableReport, ...]
initial_params: dict[str, float]
final_params: dict[str, float]
solver_info: dict[str, Any]
parameter_reports: tuple[FitParameterReport, ...] = ()
history_params: tuple[dict[str, float], ...] = ()
history_residuals: Any = None
@property
def converged(self) -> bool | None:
"""Convergence flag of the optimizer, or None when the status is not available."""
status = self.solver_info.get("status")
return None if status is None else int(status) > 0
@property
def message(self) -> str | None:
"""The optimizer's termination reason, when recorded."""
return self.solver_info.get("message")
[docs]
def summary(self) -> str:
"""Return a compact target, parameter, and identifiability receipt."""
status = {True: "converged", False: "stopped", None: "status unavailable"}[self.converged]
rank = self.solver_info.get("jacobian_rank", "?")
n_parameters = self.solver_info.get("n_free_parameters", len(self.final_params))
condition = self.solver_info.get("jacobian_condition_number")
if condition is None:
condition_text = "unknown"
elif condition == float("inf"):
condition_text = "inf"
else:
condition_text = f"{float(condition):.3g}"
unmet = [report for report in self.final_targets if _target_unmet(report)]
lines = [
f"fit_a_dress: {status} | loss {self.loss:.3g} | "
f"targets: {len(self.final_targets)} | parameters: {n_parameters}",
f"identifiability: rank {rank}/{n_parameters} | condition {condition_text}",
]
if unmet:
lines.append(
f"{len(unmet)} of {len(self.final_targets)} targets remain unmet (error above 1% of "
"the target): the targets, the model, or the vary selection are inconsistent."
)
if self.message is not None:
lines.append(self.message)
if self.final_targets:
lines.append("targets (GHz):")
for target_report in self.final_targets:
locator = (
" <-> ".join(str(part) for part in target_report.label)
if isinstance(target_report.label, tuple)
else str(target_report.label)
)
marker = " <- unmet" if _target_unmet(target_report) else ""
lines.append(
f" {locator}.{target_report.kind} [{target_report.source}]: "
f"{target_report.target:.6g} -> {target_report.final:.6g} "
f"(error {target_report.residual:+.2g}){marker}"
)
if self.parameter_reports:
lines.append("bare parameters (GHz):")
for parameter_report in self.parameter_reports:
choice = f"; {parameter_report.sign_choice}" if parameter_report.sign_choice else ""
lines.append(
f" {parameter_report.name}: {parameter_report.initial:.6g} -> "
f"{parameter_report.final:.6g} [{parameter_report.seed_source}{choice}]"
)
return "\n".join(lines)
@overload
def rebind(self, seed: BaseDevice | str, /) -> BaseDevice: ...
@overload
def rebind(self, seed: BaseDevice | str, /, *more: BaseDevice | str) -> tuple[BaseDevice, ...]: ...
[docs]
def rebind(self, *seeds: BaseDevice | str) -> Any:
"""Find the fitted clones that match one or more seed devices.
For example, call ``fit.rebind(qb, tc, cr)``.
Parameters
----------
*seeds : BaseDevice or str
One or more devices (or their labels) from the *seed* chip passed
to :func:`~quchip.inverse_design.fit.fit_a_dress`. You must give at
least one.
Returns
-------
BaseDevice or tuple[BaseDevice, ...]
The matching device(s) on :attr:`chip` (the fitted clone), in input
order. A single positional ``seed`` returns that device directly,
and two or more seeds return a tuple.
Raises
------
ValueError
No seeds were given.
"""
if not seeds:
raise ValueError("rebind requires at least one seed device or label")
fitted = tuple(self.chip[s] for s in seeds)
return fitted[0] if len(fitted) == 1 else fitted