# -*- coding: utf8 -*-
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Plot catalog timeline using matplotlib.
:copyright:
2022-2026 Claudio Satriano <satriano@ipgp.fr>
:license:
GNU General Public License v3.0 or later
(https://www.gnu.org/licenses/gpl-3.0-standalone.html)
"""
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.ticker import FuncFormatter
from .plot_timeline_utils import (
get_event_times_values_and_events, bin_events_by_time,
get_bin_size_label, ONE_DAY_SECONDS,
get_cumulative_event_times_and_counts,
)
from .plot_utils import (
get_label_for_attribute, get_matplotlib_colormap, format_epoch_seconds,
)
from ..database.dbfunctions import get_catalog_stats
from ..utils import err_exit
def _plot_cumulative_overlay(ax, events, bin_size_label):
"""Overlay raw-event cumulative count on a secondary y-axis."""
ax2 = ax.twinx()
times, cumulative = get_cumulative_event_times_and_counts(events)
if not times:
err_exit('No events to plot.')
ax2.step(
times,
cumulative,
where='post',
color='firebrick',
linewidth=1.6,
zorder=4,
label='Cumulative Count',
)
ax2.set_ylabel('Cumulative Event Count', color='firebrick')
ax2.tick_params(axis='y', labelcolor='firebrick')
ax2.set_ylim(bottom=0)
title = (
'Event Count and Cumulative Count vs. Time '
f'(bin size: {bin_size_label})'
)
ax.set_title(title)
def _plot_attribute(events, args, ax):
"""
Scatter plot of an event attribute vs. time.
:param events: EventList of Event dicts
:param args: parsed command-line arguments
:param ax: matplotlib Axes
"""
attribute = args.attribute
color_attr = getattr(args, 'colorby', None) or attribute
data = get_event_times_values_and_events(events, attribute, color_attr)
if not data:
err_exit(
f'No events with valid values for attribute "{attribute}" '
f'and color attribute "{color_attr}".'
)
times = [item[0] for item in data]
values = np.array([item[1] for item in data])
source_events = [item[2] for item in data]
if color_attr == attribute:
color_values = values
else:
color_values = np.array([
float(event[color_attr]) for event in source_events
])
_, cmap = get_matplotlib_colormap(getattr(args, 'colormap', None))
sc = ax.scatter(
times, values,
s=20, alpha=0.7,
c=color_values, cmap=cmap,
zorder=3,
)
cbar = plt.colorbar(sc, ax=ax, label=get_label_for_attribute(color_attr))
if color_attr == 'time':
cbar.formatter = FuncFormatter(
lambda value, _pos: format_epoch_seconds(value, multiline=True)
)
cbar.update_ticks()
if attribute == 'time':
ax.yaxis.set_major_formatter(FuncFormatter(
lambda value, _pos: format_epoch_seconds(value, multiline=True)
))
ax.set_ylabel(get_label_for_attribute(attribute))
ax.set_title(
f'{get_label_for_attribute(attribute)} vs. Time '
f'(color: {get_label_for_attribute(color_attr)})'
)
def _plot_count(events, args, ax):
"""
Bar chart of event count per time bin, optionally with cumulative overlay.
Modes:
- Count only: histogram of binned events
- Cumulative only: raw cumulative step plot
- Both: dual-axis with histogram (left) and cumulative (right)
:param events: EventList of Event dicts
:param args: parsed command-line arguments
:param ax: matplotlib Axes
"""
count_plot = getattr(args, 'count', False)
cumulative_plot = getattr(args, 'cumulative', False)
cumulative_only = cumulative_plot and not count_plot
if cumulative_only:
# Cumulative-only mode: raw event times, no binning
times, cumulative = get_cumulative_event_times_and_counts(events)
if not times:
err_exit('No events to plot.')
ax.step(
times,
cumulative,
where='post',
color='firebrick',
linewidth=1.6,
zorder=4,
)
ax.set_ylabel('Cumulative Event Count')
ax.set_ylim(bottom=0)
ax.set_title('Cumulative Event Count vs. Time')
return
# Count mode (with or without cumulative overlay)
bins_spec = getattr(args, 'bins', None)
bins = bin_events_by_time(events, bins_spec)
if not bins:
err_exit('No events to plot.')
bin_size_label = get_bin_size_label(bins, bins_spec)
bin_starts = [b[0] for b in bins]
bin_ends = [b[1] for b in bins]
counts = [b[2] for b in bins]
# Width and center in units of days (matplotlib float date)
widths_days = [
(e - s).total_seconds() / ONE_DAY_SECONDS * 0.9
for s, e in zip(bin_starts, bin_ends)
]
centers = [s + (e - s) / 2 for s, e in zip(bin_starts, bin_ends)]
ax.bar(
mdates.date2num(centers),
counts,
width=widths_days,
color='steelblue',
edgecolor='white',
linewidth=0.4,
zorder=3,
)
ax.xaxis_date()
ax.set_ylabel('Event Count (per bin)')
# Dual-axis mode: overlay cumulative on secondary y-axis
if not cumulative_only and cumulative_plot:
_plot_cumulative_overlay(ax, events, bin_size_label)
else:
ax.set_title(f'Event Count vs. Time (bin size: {bin_size_label})')
[docs]
def plot_catalog_timeline_matplotlib(events, config):
"""
Plot the catalog timeline using matplotlib.
:param events: EventList of Event dicts
:param config: config object
"""
args = config['args']
count_plot = getattr(args, 'count', False)
cumulative_plot = getattr(args, 'cumulative', False)
fig, ax = plt.subplots(figsize=(12, 5))
ax.grid(axis='y', linestyle='--', alpha=0.5, zorder=0)
if count_plot or cumulative_plot:
_plot_count(events, args, ax)
else:
_plot_attribute(events, args, ax)
# X-axis formatting
ax.xaxis.set_major_formatter(
mdates.ConciseDateFormatter(mdates.AutoDateLocator())
)
fig.autofmt_xdate()
# Catalog stats as a figure text
stats = get_catalog_stats(config)
fig.text(
0.01, 0.01, stats,
fontsize=7, color='grey',
va='bottom', ha='left',
transform=fig.transFigure,
)
ax.set_xlabel('Time')
plt.tight_layout()
if out_file := getattr(args, 'out_file', None):
fig.savefig(out_file, dpi=150)
print(f'Timeline saved to {out_file}')
else:
plt.show()