# This file is part of pelagos_py.
#
# Copyright 2025-2026 National Oceanography Centre and The Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Class definition for finding vertical and horizontal profiles in depth data."""
from pelagos_py.steps.base_step import BaseStep, register_step
from pelagos_py.utils.qc_handling import QCHandlingMixin
import pandas as pd
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from pelagos_py.utils import fig_spec
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
UNKNOWN = 0
ASCENT = 1
DESCENT = 2
SURFACING = 3
PARKING = 4
INFLECTION = 5
PROPELLED = 6
TRANSITION = 7
PHASE_COLOURS = {
UNKNOWN: "#9ca3af",
ASCENT: "#22c55e",
DESCENT: "#3b82f6",
SURFACING: "#f97316",
PARKING: "#a855f7",
INFLECTION: "#06b6d4",
PROPELLED: "#ef4444",
TRANSITION: "#eab308",
}
PHASE_NAMES = {
UNKNOWN: "0 Unknown",
ASCENT: "1 Ascent",
DESCENT: "2 Descent",
SURFACING: "3 Surfacing",
PARKING: "4 Parking",
INFLECTION: "5 Inflection",
PROPELLED: "6 Propelled",
TRANSITION: "7 Transition",
}
DERIVED_COLUMNS = ["SCI_PHASE", "PROFILE_NUMBER", "PROFILE_DIRECTION", "CYCLE", "GRADIENT"]
# ---------------------------------------------------------------------------
# Core Processing Logic
# ---------------------------------------------------------------------------
def _compute_chunk_id(time_seconds, gap_threshold_seconds):
# Real data gaps split the record into disconnected chunks - velocity is
# never computed, and no run ever allowed, across one.
return np.concatenate((
[0], np.cumsum(np.diff(time_seconds) > gap_threshold_seconds)
)).astype(np.int32)
def _gradient_per_chunk(values, time_seconds, chunk_id):
# np.gradient over the whole record would bridge real data gaps (e.g. surface
# comms windows, or an upcast-only glider whose data just stops mid-ascent),
# diluting the slope right at the edge of a gap. Compute it chunk-by-chunk.
result = np.zeros(len(values))
for cid in np.unique(chunk_id):
idx = np.flatnonzero(chunk_id == cid)
if idx.size >= 2:
result[idx] = np.gradient(values[idx], time_seconds[idx])
return result
def _smoothed_velocity(depth, time, time_seconds, chunk_id, window):
# Smooth depth, differentiate per-chunk, then smooth the resulting velocity
# itself (median, to despike) - both rolling passes are time-windowed so
# they stay meaningful under irregular sampling.
depth_series = pd.Series(depth, index=pd.DatetimeIndex(time))
smoothed_depth = depth_series.rolling(window, center=True, min_periods=1).mean().to_numpy()
velocity = _gradient_per_chunk(smoothed_depth, time_seconds, chunk_id)
return (
pd.Series(velocity, index=depth_series.index)
.rolling(window, center=True, min_periods=1)
.median()
.to_numpy()
)
def _runs_by_chunk(mask, chunk_id):
# Yields (start, end) for each maximal run of True in `mask`, additionally
# split wherever chunk_id changes inside it, so a run never bridges a gap.
n = len(mask)
if n == 0 or not mask.any():
return
boundary = np.empty(n, dtype=bool)
boundary[0] = True
boundary[1:] = (mask[1:] != mask[:-1]) | (chunk_id[1:] != chunk_id[:-1])
edges = np.flatnonzero(boundary)
edges = np.append(edges, n)
for s, e in zip(edges[:-1], edges[1:]):
if mask[s]:
yield int(s), int(e)
def _classify_ascent_descent(smoothed_velocity, time_seconds, chunk_id, velocity_threshold, min_duration_seconds):
# Threshold velocity into raw ascent/descent, then run-length merge, dropping
# runs too short to trust (sensor noise) or that straddle a chunk boundary.
n = len(smoothed_velocity)
raw_phase = np.zeros(n, dtype=np.int8)
raw_phase[smoothed_velocity > velocity_threshold] = DESCENT
raw_phase[smoothed_velocity < -velocity_threshold] = ASCENT
change = np.empty(n, dtype=bool)
change[0] = True
change[1:] = (raw_phase[1:] != raw_phase[:-1]) | (chunk_id[1:] != chunk_id[:-1])
run_starts = np.flatnonzero(change)
run_ends = np.append(run_starts[1:], n)
run_values = raw_phase[run_starts]
run_durations = time_seconds[run_ends - 1] - time_seconds[run_starts]
keep = (run_values != UNKNOWN) & (run_durations >= min_duration_seconds)
return np.repeat(np.where(keep, run_values, UNKNOWN), run_ends - run_starts).astype(np.int8)
def _classify_propelled_surfacing(phase, depth, time_seconds, chunk_id,
surfacing_depth_threshold, min_duration_seconds,
min_transect_duration_seconds, transect_phase):
# Applied only to what ascent/descent left unknown. A flat, undulating stretch
# away from the surface, gated by a much longer minimum duration than surfacing
# so a turnaround isn't mistaken for one (a turn also sits near-zero velocity
# briefly, but only for seconds, not minutes), is either propelled (ALR-class
# platforms, which actually have thrusters) or parking (everything else, which
# can only be drifting) - see `transect_phase`.
for rs, re in _runs_by_chunk(phase == UNKNOWN, chunk_id):
duration = time_seconds[re - 1] - time_seconds[rs]
if np.median(depth[rs:re]) <= surfacing_depth_threshold:
if duration >= min_duration_seconds:
phase[rs:re] = SURFACING
elif duration >= min_transect_duration_seconds:
phase[rs:re] = transect_phase
def _classify_inflection(phase, depth, chunk_id, surfacing_depth_threshold):
# The single apex of a turn between a descent and an ascent (or vice versa,
# for a mid-water W-cast). Only the one deepest/shallowest sample is marked,
# unless that apex itself is shallow, in which case it's surfacing instead.
# The run's leading edge may be missing entirely (record starts mid-turn,
# e.g. no descent ever sampled) as long as the trailing edge confirms the
# turn; a missing trailing edge is genuinely ambiguous and always skipped.
n = len(phase)
for s, e in _runs_by_chunk(phase == UNKNOWN, chunk_id):
if e == n or chunk_id[e] != chunk_id[e - 1]:
continue
start_gap = s == 0 or chunk_id[s - 1] != chunk_id[s]
before = None if start_gap else phase[s - 1]
after = phase[e]
lo = s if start_gap else s - 1
if after == ASCENT and before in (DESCENT, None):
idx = lo + np.argmax(depth[lo:e + 1])
elif after == DESCENT and before in (ASCENT, None):
idx = lo + np.argmin(depth[lo:e + 1])
else:
continue
phase[idx] = SURFACING if depth[idx] <= surfacing_depth_threshold else INFLECTION
def _classify_transition(phase, depth, chunk_id, surfacing_depth_threshold):
# Whatever's still unknown immediately either side of a turn - the shoulder
# between an inflection/surfacing point and the ascent/descent it leads into
# or out of. Shallow, it's surfacing instead, same backstop as above. Same
# leading/trailing asymmetry as the inflection pass: the shoulder heading
# into a turn can have nothing before it at all (the turn point itself
# confirms it), but the shoulder coming out always needs a real ascent/descent
# after it, or it's left unknown.
n = len(phase)
for s, e in _runs_by_chunk(phase == UNKNOWN, chunk_id):
if e == n or chunk_id[e] != chunk_id[e - 1]:
continue
start_gap = s == 0 or chunk_id[s - 1] != chunk_id[s]
before = None if start_gap else phase[s - 1]
after = phase[e]
turn_to_core = before in (SURFACING, INFLECTION) and after in (ASCENT, DESCENT)
core_to_turn = after in (SURFACING, INFLECTION) and before in (ASCENT, DESCENT, None)
if not (turn_to_core or core_to_turn):
continue
phase[s:e] = SURFACING if np.median(depth[s:e]) <= surfacing_depth_threshold else TRANSITION
def _assign_profile_and_cycle(phase, chunk_id):
# Each ascent/descent run is its own profile - no pairing required, so an
# upcast with no downcast is still a valid, numbered profile. A profile also
# claims its adjacent transition shoulders, and - only on its leading edge -
# the single bottom inflection point that marks where it started. It never
# reaches past surfacing/propelled/a top inflection: those always belong to
# whatever comes after them, so a bottom turn is never claimed by both the
# descent before it and the ascent after.
#
# Cycle: a new one starts as soon as a descent begins (from the same
# extended point PROFILE_NUMBER gives it), running up to but not including
# the start of the next descent - so it carries through the bottom
# inflection, the ascent, its trailing transition, and surfacing, all as one
# cycle. An ascent with nothing directly adjacent before its own extended
# start (upcast-only) starts a fresh cycle the same way a descent would -
# which is also what makes a mostly-propelled platform start a new cycle
# each time it actually goes underwater.
n = len(phase)
core_mask = (phase == ASCENT) | (phase == DESCENT)
padded = np.concatenate(([False], core_mask, [False]))
starts = np.flatnonzero(padded[1:] & ~padded[:-1])
ends = np.flatnonzero(~padded[1:] & padded[:-1]) # exclusive
profile_num = np.full(n, np.nan)
cycle = np.ones(n, dtype=np.int32)
prev_hi = None
current_cycle = 1
for k, (s, e) in enumerate(zip(starts, ends), start=1):
lo = s
while lo > 0 and phase[lo - 1] == TRANSITION and chunk_id[lo - 1] == chunk_id[lo]:
lo -= 1
if phase[s] == ASCENT and lo > 0 and phase[lo - 1] == INFLECTION and chunk_id[lo - 1] == chunk_id[lo]:
lo -= 1
hi = e
while hi < n and phase[hi] == TRANSITION and chunk_id[hi] == chunk_id[hi - 1]:
hi += 1
profile_num[lo:hi] = k
is_new_cycle = prev_hi is None or phase[s] == DESCENT or lo != prev_hi
if is_new_cycle and prev_hi is not None:
current_cycle += 1
cycle[lo:] = current_cycle
prev_hi = hi
return profile_num, cycle
def find_profiles(
df_raw,
depth_col="PRES",
smoothing_window_seconds=30,
velocity_threshold=0.033,
min_duration_seconds=60,
gap_threshold_minutes=5,
surfacing_depth_threshold=2.0,
min_transect_duration_seconds=300,
transect_phase=PARKING,
):
df = df_raw.dropna(subset=["TIME", depth_col]).sort_values("TIME")
if df.empty:
df_raw["SCI_PHASE"] = UNKNOWN
df_raw["PROFILE_NUMBER"] = np.nan
df_raw["PROFILE_DIRECTION"] = np.nan
df_raw["CYCLE"] = 1
df_raw["GRADIENT"] = np.nan
return df_raw
# astype("int64") on a datetime64 array assumes its native unit; pandas'
# default resolution varies by version/source (ns historically, us as of
# pandas 3), so normalize to ns first or the deltas below are silently
# off by a unit-dependent factor (e.g. 1000x under datetime64[us]).
time_seconds = df["TIME"].to_numpy().astype("datetime64[ns]").astype("int64") / 1e9
depth = df[depth_col].to_numpy(dtype=float)
chunk_id = _compute_chunk_id(time_seconds, gap_threshold_minutes * 60)
smoothed_velocity = _smoothed_velocity(
depth, df["TIME"].to_numpy(), time_seconds, chunk_id, f"{smoothing_window_seconds}s"
)
phase = _classify_ascent_descent(
smoothed_velocity, time_seconds, chunk_id, velocity_threshold, min_duration_seconds
)
_classify_propelled_surfacing(
phase, depth, time_seconds, chunk_id,
surfacing_depth_threshold, min_duration_seconds, min_transect_duration_seconds,
transect_phase,
)
_classify_inflection(phase, depth, chunk_id, surfacing_depth_threshold)
_classify_transition(phase, depth, chunk_id, surfacing_depth_threshold)
direction = np.full(len(phase), np.nan)
direction[phase == ASCENT] = -1
direction[phase == DESCENT] = 1
direction[(phase == SURFACING) | (phase == PROPELLED) | (phase == PARKING)] = 0
profile_num, cycle = _assign_profile_and_cycle(phase, chunk_id)
result = pd.DataFrame(
{
"SCI_PHASE": phase,
"PROFILE_NUMBER": profile_num,
"PROFILE_DIRECTION": direction,
"CYCLE": cycle,
"GRADIENT": smoothed_velocity,
},
index=df.index,
)
out = df_raw.copy()
out[DERIVED_COLUMNS] = result.reindex(out.index)
out["SCI_PHASE"] = out["SCI_PHASE"].fillna(UNKNOWN).astype(int)
out["CYCLE"] = out["CYCLE"].ffill().fillna(1).astype(int)
return out
@register_step
[docs]
class FindProfilesStep(BaseStep, QCHandlingMixin):
"""
Identifies and classifies vertical and horizontal profiles from depth-time data.
The step smooths the depth (or pressure) record, derives vertical velocity from
it, and uses that to label every measurement with a scientific phase. From those
phases it then derives a continuous ``PROFILE_NUMBER``, a ``PROFILE_DIRECTION``,
a ``CYCLE`` count, and ``PROFILE_GRADIENT`` (the smoothed vertical velocity used
for classification, in depth units/s).
**All parameters are optional** — every parameter has a sensible default, so the
step runs unchanged on a typical OG1 dataset with no configuration at all (see the
first example below).
Phase definitions
-----------------
Phases follow the OceanGliders OG1 ``phase`` vocabulary [1]_ and are written to
``SCI_PHASE``. This step's scope:
- **0 – unknown**: the default until reclassified below, and where it stays if
nothing below applies.
- **1 – ascent** / **2 – descent**: vertical velocity beyond ``velocity_threshold``,
sustained for at least ``min_duration_seconds``. This pass runs first and always
wins — nothing later ever overrides an ascent/descent classification.
- **3 – surfacing**: whatever's left unknown near the surface (run median depth
at or below ``surfacing_depth_threshold``) once ascent/descent is settled —
covers both a flat/undulating stretch at the surface and the shoulder or apex
of a turn that happens to be shallow.
- **4 – parking** / **6 – propelled**: a flat, undulating stretch away from the
surface, gated by the longer ``min_transect_duration_seconds`` so a turnaround
(also briefly near-zero velocity) isn't mistaken for one. Which of the two it's
labelled is controlled by ``transect_phase`` — parking is a passive drift,
propelled means the platform is actually under thruster power (ALR-class).
- **5 – inflection**: the single deepest/shallowest sample of a non-surface turn
between a descent and an ascent (or vice versa, for a mid-water turn).
- **7 – transition**: whatever's still unknown on the shoulder of a turn, between
an inflection/surfacing point and the ascent/descent either side of it.
A real data gap longer than ``gap_threshold_minutes`` splits the record into
disconnected chunks: velocity is never computed, and no run ever allowed, across
one — so e.g. an upcast whose data simply stops just before surfacing, with no
matching downcast sampled, is still its own valid profile rather than being
bridged into a false transition.
Parameters
----------
depth_column : str, optional
Depth or pressure column used for the analysis. Default ``"PRES"``.
smoothing_window_seconds : int, optional
Rolling-mean window (seconds) applied to depth before differentiating, to
keep sensor noise from flipping the sign of the velocity. Default ``30``.
velocity_threshold : float, optional
Vertical velocity (depth units/s) a smoothed sample must exceed to count as
moving; below this, in either direction, it's unknown. Default ``0.033``.
min_duration_seconds : int, optional
A run of consistent ascent/descent (or of surfacing) shorter than this is
too brief to trust and is folded back to unknown. Default ``60``.
gap_threshold_minutes : int, optional
A time gap longer than this splits the record into disconnected chunks.
Default ``5``.
surfacing_depth_threshold : float, optional
Depth below which a propelled stretch, or the apex/shoulder of a turn, is
classified surfacing instead — judged on run median depth. Default ``2.0``.
min_transect_duration_seconds : int, optional
Minimum duration for an unknown run to be classified parking/propelled.
Deliberately much longer than ``min_duration_seconds``, since a turnaround
also sits near-zero velocity for a few seconds but never for minutes.
Default ``300``.
transect_phase : {"auto", "parking", "propelled"}, optional
Which phase a long, flat, non-surface "unknown" stretch is classified as.
``"parking"`` — passive drift, ``"propelled"`` — under thruster power
(ALR-class platforms). ``"auto"`` looks for ``"ALR"`` in the source
filename and picks ``"propelled"`` if found, else falls back to
``"parking"``. Default ``"auto"``.
Examples
--------
The defaults are designed to work out of the box, so the simplest valid
configuration sets no parameters at all:
.. code-block:: yaml
steps:
- name: Find Profiles
To tune the classification, any subset of parameters may be supplied. The block
below lists every parameter set to its default value:
.. code-block:: yaml
steps:
- name: Find Profiles
parameters:
depth_column: "PRES"
smoothing_window_seconds: 30
velocity_threshold: 0.033
min_duration_seconds: 60
gap_threshold_minutes: 5
surfacing_depth_threshold: 2.0
min_transect_duration_seconds: 300
transect_phase: "auto"
References
----------
.. [1] OceanGliders Community, OG1 format user manual — ``phase`` vocabulary
collection.
https://github.com/OceanGlidersCommunity/OG-format-user-manual/blob/main/vocabularyCollection/phase.md
"""
step_name = "Find Profiles"
required_variables = ["TIME"]
provided_variables = ["PROFILE_NUMBER", "PROFILE_DIRECTION", "PROFILE_GRADIENT", "CYCLE", "SCI_PHASE"]
variable_parameters = ["depth_column"]
uses_data_subset = True
parameter_schema = {
"depth_column": {
"type": str,
"default": "PRES",
"description": "Depth or pressure column name. Defaults to PRES."
},
"smoothing_window_seconds": {
"type": int,
"default": 30,
"description": "Rolling-mean window (seconds) applied to depth before differentiating."
},
"velocity_threshold": {
"type": float,
"default": 0.033,
"description": "Vertical velocity (depth units/s) to trigger ascent/descent classification."
},
"min_duration_seconds": {
"type": int,
"default": 60,
"description": "Minimum seconds for an ascent/descent/surfacing run to be trusted."
},
"gap_threshold_minutes": {
"type": int,
"default": 5,
"description": "Time gap (minutes) that splits the record into disconnected chunks."
},
"surfacing_depth_threshold": {
"type": float,
"default": 2.0,
"description": "Depth below which a propelled/turn run is classified surfacing instead."
},
"min_transect_duration_seconds": {
"type": int,
"default": 300,
"description": "Minimum duration for an unknown run to be classified parking/propelled."
},
"transect_phase": {
"type": str,
"default": "auto",
"options": ["auto", "parking", "propelled"],
"description": "Phase for a long, flat, non-surface unknown stretch. 'auto' detects "
"'ALR' in the source filename and picks 'propelled', else 'parking'."
},
}
def run(self):
self.log("Attempting to designate profile numbers, cycles, directions, and phases")
self.check_data()
self.filter_qc()
# Parameters are resolved from parameter_schema in BaseStep.__init__,
# so every declared attribute is guaranteed to be set.
depth_col = self.depth_column
if depth_col not in self.data.variables:
raise ValueError(f"Specified depth column '{depth_col}' not found in the dataset.")
cols_to_extract = ["TIME", depth_col]
df_raw = self.data[cols_to_extract].to_dataframe().reset_index()
# Flagged samples must not shape the depth smoothing/velocity, but every
# sample is still labelled: results are merged back on TIME, untouched here.
# Interpolated (flag 8) depth is excluded too, on top of the default
# calculation_mask (3/4/9): a linearly-interpolated ramp across a real gap
# (e.g. surface comms) would otherwise be read as genuine depth movement.
calc_mask = self.calculation_mask(["TIME", depth_col])
depth_qc_var = f"{depth_col}_QC"
if depth_qc_var in self.data:
calc_mask &= self.data[depth_qc_var].values != 8
df_raw.loc[~calc_mask, depth_col] = np.nan
if self.transect_phase == "auto":
source_file = self.context.get("global_parameters", {}).get("source_file") or ""
transect_phase = PROPELLED if "ALR" in str(source_file).upper() else PARKING
else:
transect_phase = PROPELLED if self.transect_phase == "propelled" else PARKING
df_final = find_profiles(
df_raw, depth_col,
smoothing_window_seconds=self.smoothing_window_seconds,
velocity_threshold=self.velocity_threshold,
min_duration_seconds=self.min_duration_seconds,
gap_threshold_minutes=self.gap_threshold_minutes,
surfacing_depth_threshold=self.surfacing_depth_threshold,
min_transect_duration_seconds=self.min_transect_duration_seconds,
transect_phase=transect_phase,
)
if self.diagnostics:
self.generate_diagnostics(df_final, depth_col)
self.data["PROFILE_NUMBER"] = (("N_MEASUREMENTS",), df_final["PROFILE_NUMBER"].to_numpy())
self.data.PROFILE_NUMBER.attrs = {
"long_name": "Derived profile number. NaN indicates no profile.",
"units": "None",
"standard_name": "Profile Number",
"valid_min": 1,
"valid_max": np.inf,
}
self.data["PROFILE_DIRECTION"] = (("N_MEASUREMENTS",), df_final["PROFILE_DIRECTION"].to_numpy())
self.data.PROFILE_DIRECTION.attrs = {
"long_name": "Profile direction: -1 ascent, 1 descent, 0 transect (surfacing/propelled), NaN otherwise.",
"units": "None",
"standard_name": "Profile Direction",
"valid_min": -1,
"valid_max": 1,
}
self.data["PROFILE_GRADIENT"] = (("N_MEASUREMENTS",), df_final["GRADIENT"].to_numpy())
self.data.PROFILE_GRADIENT.attrs = {
"long_name": "Smoothed vertical velocity used for phase classification",
"units": "m/s",
}
self.data["CYCLE"] = (("N_MEASUREMENTS",), df_final["CYCLE"].to_numpy())
self.data.CYCLE.attrs = {
"long_name": "Continuous cycle number derived from surfacing points",
"units": "None",
"standard_name": "Cycle Number",
"valid_min": 1,
"valid_max": np.inf,
}
self.data["SCI_PHASE"] = (("N_MEASUREMENTS",), df_final["SCI_PHASE"].to_numpy())
self.data.SCI_PHASE.attrs = {
"long_name": "Scientific Phase Classification",
"units": "None",
"valid_min": 0,
"valid_max": 7,
"flag_values": "0, 1, 2, 3, 4, 5, 6, 7",
"flag_meanings": "unknown ascent descent surfacing parking inflection propelled transition"
}
self.generate_qc({
"PROFILE_NUMBER_QC": ["TIME_QC", f"{depth_col}_QC"],
"PROFILE_DIRECTION_QC": ["TIME_QC", f"{depth_col}_QC"],
"PROFILE_GRADIENT_QC": ["TIME_QC", f"{depth_col}_QC"],
"CYCLE_QC": ["TIME_QC", f"{depth_col}_QC"],
"SCI_PHASE_QC": ["TIME_QC", f"{depth_col}_QC"]
})
self.context["data"].update(self.data)
return self.context
def generate_diagnostics(self, mapped_df, depth_col):
# Two panels: depth coloured by scientific phase, and profile/cycle numbering.
matplotlib.use("tkagg")
fig, axes = fig_spec.new_fig(nrows=2, sharex=True, height_ratios=(3, 1))
ax1, ax2 = axes[0][0], axes[1][0]
# Panel 1: high-resolution phase mapping, one series per scientific phase.
has_data = mapped_df[depth_col].notna()
for p_val in range(8):
mask = (mapped_df["SCI_PHASE"] == p_val) & has_data
n_points = int(mask.sum())
t_data = mapped_df["TIME"][mask] if n_points else []
depth_data = mapped_df[depth_col][mask] if n_points else []
lbl = f"{PHASE_NAMES.get(p_val, f'Phase {p_val}')} (n={n_points})"
fig_spec.points(ax1, t_data, depth_data,
color=PHASE_COLOURS.get(p_val, "black"), label=lbl)
ax1.invert_yaxis()
fig_spec.style_axes(ax1, title="High Resolution Phase Mapping", ylabel="Pressure/Depth")
fig_spec.legend(ax1)
# Panel 2: derived profile & cycle numbering.
fig_spec.points(ax2, mapped_df["TIME"], mapped_df["PROFILE_NUMBER"],
color=fig_spec.CATEGORY[1], label="Profile Number")
fig_spec.points(ax2, mapped_df["TIME"], mapped_df["CYCLE"],
color=fig_spec.CATEGORY[2], label="Cycle Number")
fig_spec.date_axis(ax2, which="x")
fig_spec.style_axes(ax2, xlabel="Time", ylabel="ID / Cycle")
fig_spec.legend(ax2)
fig_spec.finish(fig, suptitle="Find Profiles Diagnostics")
plt.show(block=True)