Source code for pydox.calibration.spec

from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Dict, Any, Self, Optional, List
import json

import hashlib
from collections import OrderedDict
from dataclasses import asdict

import numpy as np
import matplotlib as mpl
import argopy as ar
import pydox as do
from pydox._config.config import check_config, Config
from pydox.commodities import (
    ParameterSet,
    ConfigsDict,
    CoefsDict,
    Coefficients,
    CoefficientsInAir,
    PydoxFigure,
    VALID_FIGURE_CATEGORIES,
)
from pydox.errors import UnFitted, UnSelected, UnsupportedSetting
from pydox.utils.casting import is_ctelist, to_list
from pydox.io.argo.facade import corr_B_files
from pydox.reporting.logs import getLogger
from pydox.reporting.html import CalibrationHTMLReport

log = getLogger("pydox.calibration.spec", context_level=20)


[docs] class Workflow(ABC): """ Base class for one or more calibrations: - Support more than one configuration set - Support more than one method - Support ordered vs sequential vs parallel gain computation Notes ----- There is some ambiguity here wrt the use of the word "configuration": - It may refer to the user-level configuration object that holds all possible library settings and method parameters - But it may also refer to the lower-level unique set of parameters describing a unique calibration computation. We shall review and fix this. Warnings -------- A 'Workflow' instance (deep)copies the global configuration object internally and will work only with this copy afterward. Therefore, any changes to the global configuration made after the creation of an instance, won't have any impact on that 'Workflow' instance. """
[docs] def __init__(self, *args, **kwargs): """ Parameters ---------- name: Optional[str] = None Other Parameters ---------------- config: Config A specific configuration object to use instead of the current one from :attr:`pydox.params` """ config: Config = kwargs.get("config", do.params) self.name: str = kwargs.get("name", "") # Implement some validation on this config argument: config = check_config( config ) # Will raise an error if not a valid configuration # but, this may not be coherent with the from_config class method expectation, see below. # Init private placeholders: self._cfg: Config = deepcopy( config ) # Used by self.get_params(), self.set_params(), self.reset_params() self._fitted: bool = False # Filled by self.fit(), return by self.fitted self._fitted_float: dict = { "WMO": None, "CYCLE_NUMBER": {}, } # Used to register float WMO/CYCLE_NUMBER used for fit self._best_fit: int = ( None # Filled by self.set_best_fit(), return by self.best_fit ) # Init private placeholders depending on configuration order and number: self._input_data = ( OrderedDict() ) # Filled by self.load_input_data() and self.fit(), return by self.input_data self._coefs: CoefsDict = ( OrderedDict() ) # Filled by self.fit(), return by self.coefs self._fit_data = OrderedDict() # Filled by self.fit(), return by self.fit_data
def _repr_params_shared(self) -> list[str]: """Return a description of parameters shared by all methods (These are stored into a specific group in the configuration, eg 'calibration_parameters') Returns ------- list[str] To be used by :class:`Method.__repr__` """ summary = [] # Piecewise subgroup: summary += [f" piecewise (not used): {json.dumps(self._sparam('piecewise'))}"] # Initial condition subgroup: summary += [f" initial_guess: {json.dumps(self._sparam('initial_guess'))}"] # Cycle numbers subgroup: if is_ctelist([c.cycles for c in self.configs.values()]): summary += [f" cycles: {json.dumps(self._sparam('cycles'))}"] else: summary += [f" cycles: <Values depend on configurations, see below>"] # Fit drift: if is_ctelist([c.fit_drift for c in self.configs.values()]): summary += [f" fit_drift: {json.dumps(self._sparam('fit_drift'))}"] else: summary += [f" fit_drift: <Values depend on configurations, see below>"] return summary def _repr_configs(self) -> list[str]: """Return a description of all unique computation configurations Returns ------- list[str] To be used by :class:`Method.__repr__` """ summary = [] for ii, cfg in self.configs.items(): d: dict = asdict( cfg ) # cfg is a commodity ParameterSet produced by self._flatten_configs method = d["method"] d.pop("method") if not self.get_params("pydox.verbose.configs"): d.pop("src") if self.best_fit is not None and self.best_fit == ii: summary += [f"🏆{ii}: method='{method}' {d}"] else: summary += [f" {ii}: method='{method}' {d}"] return summary def _repr_fitted(self) -> list[str]: """Return a description of the fit Returns ------- list[str] To be used by :class:`Method.__repr__` """ summary = [] if self.fitted: # summary += [ # f"fitted: {self.fitted} (WMO={self._fitted_float.get('WMO', '?')}" # ] cycs_per_config = [c for c in self._fitted_float["CYCLE_NUMBER"].values()] if is_ctelist(cycs_per_config): summary += [ f"fitted: {self.fitted} (WMO={self._fitted_float.get('WMO', '?')}, CYCLES {cycs_per_config[0]})" ] else: summary += [ f"fitted: {self.fitted} (WMO={self._fitted_float.get('WMO', '?')}, CYCLES range depend on configurations, see below)" ] else: summary += [f"fitted: {self.fitted}"] return summary def _repr_coefs(self) -> list[str]: """Return a description of coefficients when fitted Returns ------- list[str] To be used by :class:`Method.__repr__` """ summary = [] for ic in range(self.n_configs): if self.best_fit is not None and self.best_fit == ic: summary += [f"🏆{ic}: {str(self.coefs[ic])}"] else: summary += [f" {ic}: {str(self.coefs[ic])}"] return summary def __repr__(self): """Repr data shared by any class inheriting from this based class""" summary = [f"<pydox.Workflow> {self.name}"] [summary.append(line) for line in self._repr_fitted()] summary += [""] # Blank line summary += ["default parameters shared by all methods:"] [summary.append(line) for line in self._repr_params_shared()] summary += [""] # Blank line if self.n_configs == 0: summary += ["no configurations"] else: summary += [f"configurations [{self.n_configs}]:"] [summary.append(line) for line in self._repr_configs()] if self.fitted: summary += [""] # Blank line summary += [f"coefficients [{len(self.coefs)}]:"] [summary.append(line) for line in self._repr_coefs()] return "\n".join(summary) def _uid(self, icfg: int = None) -> str: """UID for this Workflow or a specific configuration""" m = hashlib.sha256() m.update(bytes(str(self.name), "utf-8")) m.update(bytes(str(self._cfg), "utf-8")) configs = ( self.configs.keys() if icfg is None else ar.utils.checkers.to_list(icfg) ) for c in configs: m.update(bytes(str(self.configs[c]), "utf-8")) return m.hexdigest()
[docs] def uid(self, icfg: Optional[int] = None) -> str: """Unique identifier string for this instance Parameters ---------- icfg: Optional[int], default=None Possibly return a unique identifier for a specific configuration number. Returns ------- str Notes ----- The UID is based on the instance name and configuration attributes. """ return self._uid(icfg)
@property def fitted(self) -> bool: """Was the instance fitted at least once ?""" return self._fitted # Set by self.fit() def get_params(self, *args, **kwargs): """Get configuration parameter(s) for this instance only This is a shortcut of :meth:`pydox.get_params` with this instance configuration object. """ return do.get_params(*args, **kwargs, config=self._cfg) def set_params(self, *args, **kwargs) -> Self: """Set configuration parameter(s) for this instance only This is a shortcut of :meth:`pydox.set_params` with this instance configuration object. """ do.set_params(*args, **kwargs, config=self._cfg) return self def reset_params(self, *args, **kwargs) -> Self: """Reset configuration parameter(s) to instantiation initial value(s) This is a shortcut of :meth:`pydox.reset_params` with this instance configuration object. Warnings -------- ‼️ This method does not reset parameters to *default* or *factory* values, but to values set at instantiation time. """ do.reset_params(*args, **kwargs, config=self._cfg) return self def _sparam( self, param: Optional[str] = None, fallback: Optional[Any] = None ) -> Any: """Return parameters shared by all methods Get one shared parameter value, and possibly return a fallback if value is None. (These are stored into a specific group in the configuration, eg 'calibration_parameters') This is a private method to shorten syntax when retrieving parameters """ if param is not None: value: Any | Dict[str, Any] = self.get_params( f"calibration_parameters.{param}" ) else: value: Dict[str, Any] = self.get_params("calibration_parameters") return value if value is not None else fallback def flatten_configs(self) -> ConfigsDict: """Return a dictionary with all possible configurations Dictionary keys are integers, values are :class:`pydox.commodities.Params` dataclasses with all required parameters for the coefficients computation. Notes ----- This is a method that "translates" information from the user-level API configuration into :class:`pydox.commodities.Params` dataclasses to be consumed by low-level computational functions. """ return self._flatten_configs() @property def configs(self) -> ConfigsDict: """A property to directly access the dictionary of configurations""" return self.flatten_configs() @property def n_configs(self) -> int: """Return the number of all possible configurations In theory there is always at least 1 configuration. But the number of configs depends on: `self.configs < self.flatten_configs() < self._flatten_configs()` So, if `self._flatten_configs()` is not or partially implemented, `n_configs` can be 0. """ return len(self.flatten_configs()) @property def input_data(self) -> Dict[int, Any]: """Input data for fit""" return self._input_data # Populated by `self.load_input_data()` @abstractmethod def load_input_data(self, data: Any) -> Dict[int, Any]: """Load input data for the flatten list of configurations""" # Must populate the internal placeholder `self._input_data` raise NotImplementedError @property def coefs(self) -> CoefsDict: """Dictionary of fit coefficients for each configuration""" # if self.fitted: return self._coefs # Populated by `self.fit()` else: raise UnFitted("No coefficients computed") @property def fit_data(self) -> Dict[int, Any]: """Dictionary of fit auxiliary data for each configuration""" if self.fitted: return self._fit_data # Populated by `self.fit()` else: raise UnFitted("No coefficients data computed") @property def configs_figures(self) -> OrderedDict[int, List[PydoxFigure]]: """Dictionary of figures associated with each configurations UID See Also -------- :class:`Workflow.figures` """ results = OrderedDict() for icfg in range(self.n_configs): results[icfg]: List[PydoxFigure] = do.figures.uidstartswith(self.uid(icfg)) return results @property def figures(self) -> List[PydoxFigure]: """List of figures associated with this workflow UID See Also -------- :class:`Workflow.configs_figures` """ return do.figures.uidstartswith(self.uid()) @abstractmethod def _flatten_configs(self) -> ConfigsDict: """Scan all parameters and create an ordered dictionary with all configurations to compute Dictionary keys are integers, values are commodity dataclasses with all required parameters for the coefs computation. Notes ----- This is a private method that "translates" information from the user-level API configuration into internal dataclasses (see commodities) to be consumed by low-level computational functions. """ raise NotImplementedError @abstractmethod def fit(self, data: Any) -> Self: """Load input data and compute coefficients""" # Must rely on `self.load_input_data` to load data for fit. # Must populate `self.fitted`, `self._fit_data`, `self._coefs`, `self._fitted_float` raise NotImplementedError def plot( self, icfg: Optional[int] = None, categories: str | list[str] = "fit_results", configs_layout: str | list[str] = "hue", ) -> List[mpl.figure.Figure]: """Show figures from this instance Parameters ---------- icfg: int, optional, default = None Select plots for a specific configuration number. If set to None (default), show only plots shared by all configurations (eg: fit results). categories: str | list[str], default = ``fit_results`` Select one or a list of plot categories to show (eg: ``debug``, ``input_data``, ``fit_results``). Valid values are in :class:`pydox.commodities.VALID_FIGURE_CATEGORIES`. configs_layout: str | list[str], default = ``hue`` Select one or more possible layout for plots with more than one configuration (eg: ``hue``, ``subplot``, ``figure``). Returns ------- List[:class:`matplotlib.figure.Figure`] """ ###### Validate arguments cfg_list: list[int] = [] if icfg is not None: cfg_list: list[int] = to_list(icfg) for i in cfg_list: if i not in np.arange(self.n_configs): raise ValueError( f"Invalid configuration number {i}. Valid values are {np.arange(self.n_configs)}" ) categories: list[str] = to_list( "fit_results" if categories is None else categories ) if "all" in categories: # Select what to display among commodities.VALID_FIGURE_CATEGORIES values: categories: list[str] = VALID_FIGURE_CATEGORIES # categories: list[str] = ["input_data", "fit_results"] for cat in categories: if cat not in VALID_FIGURE_CATEGORIES: raise ValueError( f"Invalid plot category '{cat}'. Valid values are: {VALID_FIGURE_CATEGORIES}" ) configs_layout: list[str] = to_list(configs_layout) ###### Get the list of figures matching arguments pfig_list: List[PydoxFigure] = [] if len(cfg_list) == 0: for category in categories: for fig in self.figures: if fig.category == category: if "configs_layout" in fig.name: for layout in configs_layout: if f"[configs_layout='{layout}'" in fig.name: pfig_list.append(fig) else: pfig_list.append(fig) emsg = f"No figures correspond to your criteria ! May be you need to provide a specific configuration number in {np.arange(self.n_configs)} or set a lower value to the 'plots.level' setting to generate more figures (currently set to {self.get_params('plots.level')})" else: for icfg in cfg_list: for category in categories: for fig in self.configs_figures[icfg]: if fig.category == category: pfig_list.append(fig) emsg = f"No figures correspond to your criteria ! May be you should NOT specify a specific configuration number or set a lower value to the 'plots.level' setting to generate more figures (currently set to {self.get_params('plots.level')})." if len(pfig_list) == 0: raise ValueError(emsg) ###### Show figures for fig in pfig_list: fig.reload().show() return [f.fig for f in pfig_list] @property def best_fit(self) -> int: """ID of the configuration selected as the best fit This is an integer in the range: 0 < `n_configs` """ return self._best_fit def set_best_fit(self, icfg: int) -> Self: """Set the configuration ID for the best fit Parameters ---------- icfg: int Configuration ID, must be an integer in the range: 0 < `n_configs` """ if self.fitted: if icfg in range(self.n_configs): if self.best_fit is not None and self.best_fit != icfg: log.warning( f"This instance 'best_fit' is already set to {self.best_fit}, you're about to overwrite it with {icfg}." ) self._best_fit = icfg else: raise ValueError( f"Configuration id {icfg} is not valid, must be one in: {np.arange(self.n_configs)}" ) else: raise UnFitted( "Cannot select the best configuration before fitting on one Argo float data !" ) return self def create_corrBfile( self, a_float: ar.ArgoFloat, icfg: Optional[int] = None ) -> None: # todo Add docstring if not self.fitted: raise UnFitted("Cannot create BD files without a fit, use 'fit()'") else: if icfg is None and self.best_fit is None: raise UnSelected( "Cannot create BD files without a best fit, use 'set_best_fit()'" ) elif self._fitted_float["WMO"] != a_float.WMO: raise ValueError( f"BD files creation/update must be done with the same float as the fit ! {a_float.WMO} vs {self._fitted_float['WMO']}" ) else: icfg = self.best_fit if icfg is None else icfg if icfg not in range(self.n_configs): raise ValueError( f"Invalid configuration id={icfg}, must be in {np.arange(self.n_configs)}" ) if icfg != self.best_fit: log.warning( f"Create BD files with a configuration id={icfg} that is not the selected best fit {self.best_fit}" ) coef: Coefficients | CoefficientsInAir = deepcopy(self.coefs[icfg]) return corr_B_files(a_float, coef) def to_report( self, a_float: ar.ArgoFloat, file_name: Optional[str] = None, **kwargs ): """Create a calibration report Parameters ---------- a_float: :class:`argopy.ArgoFloat` The Argo float to use for the report, must be the same used to fit this instance. file_name: str The file name of the report to write. Returns ------- :class:`pathlib.Path` Notes ----- - The best fit ID must be selected with :meth:`set_best_fit` - Only HTML format are supported at this point - The report is based on the template defined in settings See Also -------- :class:`pydox.reporting.CalibrationHTMLReport` Path to published report, using settings along the convention: `<output.root>/<WMO>/<reports.save.path>/<reports.save.prefix>{report_name}.<reports.save.format>` """ if do.get_params("reports.save.format", config=self._cfg) != "html": raise UnsupportedSetting( f"Only 'html' report format is supported at this time, '{do.get_params('reports.save.format', config=self._cfg)}'." ) if not self.fitted: raise UnFitted("Cannot create a report without a fit, use 'fit()'") elif self.best_fit is None: raise UnSelected( "Cannot create a report without a best fit, use 'set_best_fit()'" ) elif self._fitted_float["WMO"] != a_float.WMO: raise ValueError( f"Reporting must be done with the same float as the fit ! {a_float.WMO} vs {self._fitted_float['WMO']}" ) self._reporter = CalibrationHTMLReport(self, a_float) return self._reporter.publish(file_name=file_name, **kwargs)