Source code for pyfli.io.data_saving

"""
Save fitted maps, metadata, figures, and session artifacts to disk.

This module belongs to :mod:`pyfli.io` and is part of PyFLI detector importers, file
readers, saving helpers, and processed-data loaders. Public API includes classes
:class:`DataSaver`; functions :func:`filter_vars`.
"""

# pyfli/io/data_saving.py
import json
import os
from datetime import datetime
from typing import Any

import matplotlib.pyplot as plt
import numpy as np

from pyfli import logging


[docs] def filter_vars(local_vars: np.ndarray, keys: np.ndarray) -> Any: """ Run the filter vars routine. Parameters ---------- local_vars : np.ndarray Local variable dictionary filtered before saving. keys : np.ndarray Dataset keys to include in the saved output. Returns ------- Any Object produced by filter vars. """ return {k: local_vars[k] for k in keys if k in local_vars}
[docs] class DataSaver: """ Persist PyFLI fitting outputs and analysis artifacts. The class creates a session directory and writes arrays, metadata, figures, and tabular summaries in a consistent layout. Parameters ---------- path : str Filesystem path loaded or saved by the routine. folder_name : str Output folder where session files are written. new_session : bool If ``True``, create a new timestamped session folder. """ def __init__( self, path: str, folder_name: str | None = "_pyfli_Analysis", new_session: bool = False, ) -> None: """ Parameters ---------- path : str Either: - a base file/dir path to derive a NEW session folder from (used together with `folder_name` to build the folder name), or - the exact path of an EXISTING session folder to reconnect to (used together with `folder_name=None`). folder_name : str | None - str : suffix appended to `path`'s base name to build/create the save directory, e.g. path="foo", folder_name="_Analysis" -> save_dir=".../foo_Analysis" (created if missing). - None : `path` IS the save directory already -- connect to it directly with no renaming/reconstruction. `path` must already exist in this case. new_session : bool If True, writes a session-start banner to the log. Leave False when reconnecting to load existing data, so you don't append a spurious "session started" entry to an existing log. """ path = os.path.normpath(path) if folder_name is None: # --- Reconnect to an existing session folder, as-is ----------- if not os.path.isdir(path): raise FileNotFoundError( f"folder_name=None expects an existing directory to " f"connect to, but no such directory exists: {path}" ) self.save_dir = path base_name = os.path.basename(self.save_dir) else: # --- Build a session folder from a base name ------------------- self.save_dir, base_name = self._resolve_save_dir(path, folder_name) os.makedirs(self.save_dir, exist_ok=True) self.log_file = os.path.join(self.save_dir, base_name + "_pyfli_log.txt") # only if starting a new session if new_session: self.log("=" * 40) self.log(f"Session Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") self.log("=" * 40)
[docs] @classmethod def load(cls, save_dir: str) -> "DataSaver": """ Convenience constructor for reconnecting to an existing session folder for data loading. Equivalent to: DataSaver(path=save_dir, folder_name=None, new_session=False) """ return cls(path=save_dir, folder_name=None, new_session=False)
@staticmethod def _resolve_save_dir(path: str, folder_name: str) -> tuple[str, str]: """ Pure path-arithmetic: given a base `path` and a `folder_name` suffix, returns (save_dir, base_name) -- the exact same save_dir that __init__ would create. No filesystem side effects (no makedirs), so this is safe to call just to find out where an auto-derived session folder WOULD be / already IS, without creating anything. This is the single source of truth for the "base name + suffix" folder-naming scheme, so save and load can never disagree about where an auto-derived folder lives. """ path = os.path.normpath(path) if os.path.isdir(path): base_dir = os.path.dirname(path) base_name = os.path.basename(path) else: base_dir = os.path.dirname(path) base_name = os.path.splitext(os.path.basename(path))[0] save_dir = os.path.join(base_dir, base_name + folder_name) return save_dir, base_name
[docs] @classmethod def resolve_path(cls, path: str, folder_name: str) -> str: """ Public helper: returns the save_dir that DataSaver(path, folder_name) would resolve to, without creating it. Use this to derive the load path for an auto-saved session before deciding whether to load from or write into it. """ save_dir, _ = cls._resolve_save_dir(path, folder_name) return save_dir
[docs] def log(self, message: Any) -> None: """ Run the log routine. Parameters ---------- message : Any Message text displayed to the user. Returns ------- None No object is returned; the function perform log. """ formatted_msg = f"{message}" logging.info(formatted_msg) with open(self.log_file, "a") as f: f.write(formatted_msg + "\n")
[docs] def log_to_file(self, message: Any) -> None: """ Append to the log file only, without echoing through logging/console. Use for bulk/verbose content (e.g. a printed comparison table) that belongs in the session log but would be noisy to also emit via the logger on every line. For normal status messages, use ``log()``. Parameters ---------- message : Any Message text appended to the log file. Returns ------- None No object is returned; the message is written to ``self.log_file``. """ with open(self.log_file, "a") as f: f.write(f"{message}\n")
[docs] def save_plot( self, name: str, fig: Any | None = None, dpi: int = 300, close: bool = True ) -> None: """ Save plot. Parameters ---------- name : str Dataset, experiment, figure, or output name. fig : Any | None Matplotlib figure object to update or save. dpi : int Resolution used when saving a figure. close : bool Whether to close the figure after saving. Returns ------- None No object is returned; the function save plot. """ path = os.path.join(self.save_dir, f"{name}.png") target = fig if fig is not None else plt try: target.savefig(path, bbox_inches="tight", dpi=dpi) self.log(f"IMAGE SAVED >> {name}.png") except Exception as e: self.log(f"ERROR saving {name}: {e!s}") if close: plt.close(fig) if fig else plt.close()
[docs] def save_json(self, name: str, data_dict: np.ndarray) -> None: """Saves settings/dictionaries as JSON.""" path = os.path.join(self.save_dir, f"{name}.json") with open(path, "w") as f: json.dump(data_dict, f, indent=4) self.log(f"JSON saved: >> {name}.json")
[docs] def load_json(self, name: str) -> Any: """Loads a dictionary previously saved with save_json.""" path = os.path.join(self.save_dir, f"{name}.json") with open(path) as f: data = json.load(f) self.log(f"JSON loaded: << {name}.json") return data
[docs] def save_npy(self, name: str, array: np.ndarray) -> None: """ Save npy. Parameters ---------- name : str Dataset, experiment, figure, or output name. array : np.ndarray Array processed by the routine. Returns ------- None No object is returned; the function save npy. """ path = os.path.join(self.save_dir, f"{name}.npy") np.save(path, array) if isinstance(array, dict): self.log(f"Array saved: >> {name}.npy | Type: Dictionary") elif isinstance(array, np.ndarray): self.log(f"Array saved: >> {name}.npy | Shape: {array.shape}") else: self.log(f"Array saved: >> {name}.npy")
[docs] def load_npy(self, name: str, allow_pickle: bool = True) -> Any: """Loads an array/dict previously saved with save_npy.""" path = os.path.join(self.save_dir, f"{name}.npy") array = np.load(path, allow_pickle=allow_pickle) # np.save on a dict wraps it in a 0-d object array; unwrap it back. if array.shape == () and array.dtype == object: array = array.item() if isinstance(array, dict): self.log(f"Array loaded: << {name}.npy | Type: Dictionary") elif isinstance(array, np.ndarray): self.log(f"Array loaded: << {name}.npy | Shape: {array.shape}") else: self.log(f"Array loaded: << {name}.npy") return array
[docs] def save_params(self, **kwargs: Any) -> None: """ Save params. Parameters ---------- **kwargs : Any Additional keyword options forwarded to the underlying implementation. Returns ------- None No object is returned; the function save params. """ for key, value in kwargs.items(): self.log(f"Parameter: >> {key}: {value}")
[docs] def save_config( self, config_dict: np.ndarray, name: str = "fitting_config" ) -> None: """ Save config. Parameters ---------- config_dict : np.ndarray Configuration dictionary written to disk. name : str Dataset, experiment, figure, or output name. Returns ------- None No object is returned; the function save config. """ self.log(f"--- Configuration: {name} ---") serializable_config = {} for k, v in config_dict.items(): self.log(f"SETTING >> {k}: {v}") if isinstance(v, (list, dict, str, int, float, bool, type(None))): serializable_config[k] = v else: serializable_config[k] = str(v) self.save_json(name, serializable_config)
[docs] def load_config(self, name: str = "fitting_config") -> Any: """Loads a config dict previously saved with save_config.""" config_dict = self.load_json(name) self.log(f"--- Configuration Loaded: {name} ---") for k, v in config_dict.items(): self.log(f"SETTING << {k}: {v}") return config_dict