Source code for site_example

"""Example / template site module for pyOMA-Monitoring.

Copy this file, rename it ``site_<yoursite>.py``, and fill in every
``raise NotImplementedError`` stub with your site-specific logic.  Import the
finished module once (e.g. at the top of ``daily.py``) to register and activate
your site in the monitoring engine::

    import site_mysite  # registers on import

All engine functions in ``monitoring.py`` will then use your callbacks
automatically.
"""

import logging
import os
from typing import Dict, List, Optional, Tuple

import numpy as np
import pandas as pd

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Site configuration — replace every placeholder string / dict with real values
# ---------------------------------------------------------------------------

#: Absolute path to the root directory that holds the result databases
#: (``file_info_*.nc``, ``stats_*.nc``, ``modal_*.nc``).
EXAMPLE_DB_ROOT_PATH: str = "/path/to/results/db/"

#: Absolute path to the root directory used for pre-processed signal slices.
EXAMPLE_SLICE_ROOT_PATH: str = "/path/to/slices/"

#: Absolute path to the directory that contains the OMA configuration files
#: (``nodes``, ``lines``, ``master_slaves``, ``ssi_config``, …).
EXAMPLE_MODAL_CONF_DIR: str = "/path/to/modal_conf/"

#: Absolute path to the root directory that holds the raw measurement files.
EXAMPLE_FILE_ROOT_PATH: str = "/path/to/raw_data/"

#: Map *quantity* → *origin tag*.  The origin tag is the key used in
#: :attr:`subpaths`, :attr:`all_channels`, and :attr:`dtstarts`.
#: Add one entry per measured quantity.
#:
#: Example::
#:
#:     EXAMPLE_ORIGINS = {
#:         "accel": "accel",
#:         "wind":  "wind",
#:     }
EXAMPLE_ORIGINS: Dict[str, str] = {
    "accel": "accel",
    # add more quantities here
}

#: Map *origin tag* → relative sub-path under :data:`EXAMPLE_FILE_ROOT_PATH`.
EXAMPLE_SUBPATHS: Dict[str, str] = {
    "accel": "accel/",
    # add more origins here
}

#: Map *quantity* → list of channel names that *must* be present in every slice.
EXAMPLE_ALL_CHANNELS: Dict[str, List[str]] = {
    "accel": ["Accel_01", "Accel_02"],
    # add more quantities here
}

#: Map *quantity* → list of channel names that *may* be present (not required).
EXAMPLE_OPTIONAL_CHANNELS: Dict[str, List[str]] = {
    "accel": [],
    # add more quantities here
}

#: Map *origin tag* → earliest available datetime (ISO string or
#: ``datetime.datetime``).  Slices before this timestamp are skipped.
EXAMPLE_DTSTARTS: Dict[str, object] = {
    "accel": "2020-01-01",
    # add more origins here
}

#: Map *channel name* → ``(min, max)`` plausibility range.  Samples outside
#: this interval cause the affected time slice to be flagged as erroneous.
EXAMPLE_RANGES: Dict[str, Tuple[float, float]] = {
    "Accel_01": (-20.0, 20.0),
    # add more channels here
}

# ---------------------------------------------------------------------------
# Error rules
# ---------------------------------------------------------------------------

#: Map *quantity* → dict of kurtosis thresholds used by ``describe_stats``.
#: Slices whose kurtosis falls outside ``[kurtosis_min, kurtosis_max]`` are
#: flagged as erroneous.  Omit a quantity to apply no kurtosis check.
EXAMPLE_ERROR_RULES: Dict[str, dict] = {
    "accel": {"kurtosis_max": 5, "kurtosis_min": -2},
}

# ---------------------------------------------------------------------------
# Modal frequency bands
# ---------------------------------------------------------------------------

#: List of ``(f_lo, f_hi)`` Hz bands passed to ``split_modepairs`` when
#: assigning poles to named modes.  Add one tuple per expected mode.
EXAMPLE_MODAL_BANDS: List[Tuple[float, float]] = [
    (0.5, 1.0),
    # add more bands here
]

# ---------------------------------------------------------------------------
# Pre-processing channel selection
# ---------------------------------------------------------------------------

#: Map *quantity* → list of channel names kept after the band-pass /
#: decimation pre-processing step.  Only relevant for quantities that go
#: through OMA (typically ``"accel"`` and strain-type quantities).
EXAMPLE_PREPROC_CHANNELS: Dict[str, List[str]] = {
    "accel": ["Accel_01", "Accel_02"],
    # add more quantities here
}

# ---------------------------------------------------------------------------
# Transform callbacks
# ---------------------------------------------------------------------------

def _example_accel_transform(
    start_time,
    headers: List[str],
    units: List[str],
    end_time,
    sample_rate: float,
    measurement: np.ndarray,
    quantity: Optional[str] = None,
    **kwargs,
):
    """Apply site-specific post-processing to a raw acceleration slice.

    The function receives a *Slice* — a 6-tuple produced by
    ``monitoring.get_slice`` — and must return a (possibly modified) *Slice*
    of the same shape, or ``None`` to discard the slice entirely.

    Parameters
    ----------
    start_time:
        Timezone-aware ``datetime`` of the first sample.
    headers:
        List of channel names, length ``N``.
    units:
        List of unit strings, length ``N``.
    end_time:
        Timezone-aware ``datetime`` of the last sample.
    sample_rate:
        Samples per second.
    measurement:
        2-D NumPy array of shape ``(T, N)``.
    quantity:
        The quantity key (e.g. ``"accel"``), injected by the engine.
    **kwargs:
        Additional keyword arguments passed through from the engine (e.g.
        ``start_time_local``, ``duration``, ``file_info_temp``).

    Returns
    -------
    tuple | None
        Modified ``(start_time, headers, units, end_time, sample_rate,
        measurement)`` 6-tuple, or ``None`` to drop the slice.
    """
    raise NotImplementedError("Implement site-specific acceleration transform here")


# ---------------------------------------------------------------------------
# setup_prep callbacks
# ---------------------------------------------------------------------------

def _example_setup_accel(headers: List[str]):
    """Return the OMA channel-role mapping for acceleration data.

    Called once per modal-analysis slice, immediately before constructing a
    ``PreProcessSignals`` object.

    Parameters
    ----------
    headers:
        Ordered list of channel names present in the slice.

    Returns
    -------
    ref_channels : list[int]
        Indices into *headers* of the reference (roving-reference) channels.
    accel_channels : list[int]
        Indices of all acceleration channels.
    disp_channels : list[int]
        Indices of all displacement channels (empty if none).
    chan_dofs_dict : dict[str, list]
        Mapping *channel name* → ``[node_id, azimuth_deg, inclination_deg]``
        used to assemble the ``channel_dofs`` file consumed by pyOMA.
    """
    raise NotImplementedError("Implement site-specific OMA channel mapping here")


# ---------------------------------------------------------------------------
# Synchronisation-era policy
# ---------------------------------------------------------------------------

def _example_sync_policy(start_time, file_time, duration):
    """Return the synchronised start time for this recording.

    The monitoring engine calls this for every file it reads.  The default
    implementation simply returns *start_time* unchanged.  Override this
    function when the DAQ clock differs from wall time in a known, era-dependent
    way (e.g. the file's filesystem timestamp is more reliable than the embedded
    timestamp during a specific period).

    Parameters
    ----------
    start_time:
        Timezone-aware start time embedded in the file header.
    file_time:
        Timezone-aware filesystem modification time of the file.
    duration:
        ``datetime.timedelta`` length of the recording.

    Returns
    -------
    datetime.datetime
        The corrected, timezone-aware start time to use as the time coordinate
        in all databases.
    """
    return start_time  # default: trust the embedded timestamp


# ---------------------------------------------------------------------------
# File-list discovery
# ---------------------------------------------------------------------------

def _example_get_file_list(
    origin: str,
    reduced: bool = False,
    file_info=None,
) -> List[str]:
    """Return absolute paths of all data files for *origin*.

    The engine calls this callback with ``reduced=True`` during incremental
    updates (only new files) and with ``reduced=False`` for a full scan.

    Parameters
    ----------
    origin:
        Origin tag (a value from :data:`EXAMPLE_ORIGINS`).
    reduced:
        When ``True`` and *file_info* is provided, return only files not yet
        present in *file_info*.
    file_info:
        ``xarray.Dataset`` of already-processed files (the current
        ``file_info_<origin>.nc`` database).  May be ``None``.

    Returns
    -------
    list[str]
        Absolute file paths to process.
    """
    raise NotImplementedError("Implement site-specific file discovery here")


# ---------------------------------------------------------------------------
# Circular-mean helper (optional)
# ---------------------------------------------------------------------------

def _example_channel_mean_fn(
    header: str,
    measurement: np.ndarray,
    headers: List[str],
) -> Optional[float]:
    """Return a custom mean for *header*, or ``None`` to use the arithmetic mean.

    Used by ``describe_stats`` when computing summary statistics.  Implement
    this for directional channels (e.g. wind direction) where the arithmetic
    mean is undefined.

    Parameters
    ----------
    header:
        Name of the channel whose mean is being computed.
    measurement:
        Full 2-D measurement array of shape ``(T, N)``.
    headers:
        List of all channel names, length ``N``.

    Returns
    -------
    float | None
        The custom mean value, or ``None`` to fall back to the arithmetic mean.
    """
    return None  # default: use arithmetic mean for all channels


# ---------------------------------------------------------------------------
# Site registration
# ---------------------------------------------------------------------------

[docs] def register_example_site() -> None: """Register and activate the example site in the monitoring engine. Called automatically when this module is imported. Safe to call multiple times — it simply overwrites the registry entry for ``"example"``. Raises ------ NotImplementedError Any stub callback that has not been implemented yet will raise this error at runtime when the engine first invokes it. """ import monitoring as _m # late import avoids circular dependency at load time example_site = _m.Site( name="example", # --- processing callbacks --- transforms={ "accel": _example_accel_transform, # add more quantities here; omit a quantity for no-op processing }, setup_prep={ "accel": _example_setup_accel, # add more quantities here }, error_rules=EXAMPLE_ERROR_RULES, sync_policy=_example_sync_policy, modal_bands=EXAMPLE_MODAL_BANDS, file_list_fn=_example_get_file_list, channel_mean_fn=_example_channel_mean_fn, preproc_channels=EXAMPLE_PREPROC_CHANNELS, # --- site configuration --- db_root_path=EXAMPLE_DB_ROOT_PATH, slice_root_path=EXAMPLE_SLICE_ROOT_PATH, modal_conf_dir=EXAMPLE_MODAL_CONF_DIR, file_root_path=EXAMPLE_FILE_ROOT_PATH, origins=EXAMPLE_ORIGINS, subpaths=EXAMPLE_SUBPATHS, all_channels=EXAMPLE_ALL_CHANNELS, optional_channels=EXAMPLE_OPTIONAL_CHANNELS, dtstarts=EXAMPLE_DTSTARTS, ranges=EXAMPLE_RANGES, ) _m.register_site(example_site) _m.set_active_site(example_site)
register_example_site()