"""Render time series from CSV or NetCDF as simple line charts."""
from __future__ import annotations
from io import BytesIO
from typing import Any, Optional
from .base import Renderer
from .styles import FIGURE_DPI, apply_matplotlib_style
[docs]
class TimeSeriesManager(Renderer):
"""Render a time series chart from CSV or NetCDF inputs.
Parameters
----------
title : str, optional
Figure title.
xlabel, ylabel : str, optional
Axis labels.
style : str, default="line"
One of {"line", "marker", "line_marker"}.
"""
def __init__(
self,
*,
title: Optional[str] = None,
xlabel: Optional[str] = None,
ylabel: Optional[str] = None,
style: str = "line",
) -> None:
self.title = title
self.xlabel = xlabel
self.ylabel = ylabel
self.style = style
self._fig = None
# Renderer API
def _resolve_series(
self,
*,
input_path: str,
x: Optional[str] = None,
y: Optional[str] = None,
var: Optional[str] = None,
):
if input_path.lower().endswith(".csv"):
import pandas as pd
df = pd.read_csv(input_path)
if not x or not y:
raise ValueError("CSV inputs require --x and --y column names")
xs = df[x]
ys = df[y]
return xs, ys
elif input_path.lower().endswith((".nc", ".nc4")):
import xarray as xr
if not var:
raise ValueError("NetCDF inputs require --var for the data variable")
ds = xr.open_dataset(input_path)
try:
da = ds[var]
# Use 'time' coord when available, else the first coordinate
if "time" in da.coords:
xs = da["time"].values
else:
first_dim = list(da.coords)[0] if da.coords else da.dims[0]
xs = (
da[first_dim].values
if first_dim in da.coords
else range(da.shape[0])
)
ys = da.values
finally:
ds.close()
return xs, ys
else:
raise ValueError("Unsupported input file; use .csv or .nc for timeseries")
[docs]
def render(self, data: Any = None, **kwargs: Any): # data unused for now
width = int(kwargs.get("width", 1024))
height = int(kwargs.get("height", 512))
dpi = int(kwargs.get("dpi", FIGURE_DPI))
input_path = kwargs.get("input_path")
x = kwargs.get("x")
y = kwargs.get("y")
var = kwargs.get("var")
style = kwargs.get("style", self.style)
if not input_path:
raise ValueError("input_path is required")
xs, ys = self._resolve_series(input_path=input_path, x=x, y=y, var=var)
apply_matplotlib_style()
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(width / dpi, height / dpi), dpi=dpi)
if style == "marker":
ax.plot(xs, ys, linestyle="", marker="o", markersize=3)
elif style == "line_marker":
ax.plot(xs, ys, linestyle="-", marker="o", markersize=3)
else:
ax.plot(xs, ys, linestyle="-")
if self.title:
ax.set_title(self.title)
if self.xlabel:
ax.set_xlabel(self.xlabel)
if self.ylabel:
ax.set_ylabel(self.ylabel)
ax.grid(True, linestyle=":", alpha=0.5)
fig.tight_layout()
self._fig = fig
return fig
[docs]
def save(self, output_path: Optional[str] = None, *, as_buffer: bool = False):
if self._fig is None:
return None
if as_buffer:
bio = BytesIO()
self._fig.savefig(bio, format="png", bbox_inches="tight", pad_inches=0)
bio.seek(0)
return bio
if output_path is None:
output_path = "timeseries.png"
self._fig.savefig(output_path, bbox_inches="tight", pad_inches=0)
return output_path