ghostieee11's picture
Upload folder using huggingface_hub
15b595e verified
Raw
History Blame Contribute Delete
41.1 kB
"""Visualization module for Solar Intelligence.
Generates interactive charts using the HoloViz ecosystem:
- hvPlot for high-level plotting API
- HoloViews for composable elements, overlays, and streams
- Datashader for large dataset rendering with dynamic rasterization
- Param for parameterized visualization classes
All chart generators return HoloViews/hvPlot objects that can be embedded
in Panel dashboards or displayed in Jupyter notebooks.
"""
from __future__ import annotations
import logging
from typing import Any
import holoviews as hv
import hvplot.pandas # noqa: F401 — registers .hvplot accessor
import numpy as np
import pandas as pd
import param
from holoviews import streams
from solar_intelligence.config import CB_PALETTE
try:
import cartopy.feature as cfeature
HAS_CARTOPY = True
except ImportError:
HAS_CARTOPY = False
hv.extension("bokeh")
logger = logging.getLogger(__name__)
class SolarVisualizer(param.Parameterized):
"""Generate interactive solar energy visualizations.
Uses hvPlot, HoloViews, and Datashader to create rich, interactive
charts for solar irradiance analysis, energy estimation, and
orientation comparison.
Parameters
----------
width : int
Default chart width in pixels.
height : int
Default chart height in pixels.
cmap : str
Default colormap for heatmaps.
"""
width = param.Integer(default=700, bounds=(300, 2000))
height = param.Integer(default=400, bounds=(200, 1200))
cmap = param.String(default="cividis")
# -------------------------------------------------------------------
# Irradiance Charts
# -------------------------------------------------------------------
def monthly_irradiance_bar(self, monthly_df: pd.DataFrame) -> hv.Bars:
"""Monthly irradiance bar chart (GHI/DNI/DHI stacked).
Parameters
----------
monthly_df : pd.DataFrame
Output from SolarAnalyzer.monthly_irradiance().
Returns
-------
hv.Layout
Grouped bar chart of monthly irradiance components.
"""
plot_df = monthly_df.reset_index()
cols = [c for c in ["GHI", "DNI", "DHI"] if c in plot_df.columns]
melted = plot_df.melt(
id_vars=["month", "month_name"],
value_vars=cols,
var_name="component",
value_name="irradiance",
)
return melted.hvplot.bar(
x="month_name", y="irradiance", by="component",
title="Monthly Solar Irradiance",
xlabel="Month", ylabel="Irradiance (kWh/m²/day)",
width=self.width, height=self.height,
rot=45, legend="top_right",
color=CB_PALETTE[:3],
)
def daily_irradiance_timeseries(self, daily_df: pd.DataFrame) -> Any:
"""Daily GHI time series with rolling average overlay.
Parameters
----------
daily_df : pd.DataFrame
Output from SolarAnalyzer.rolling_average().
"""
cols_to_plot = [c for c in daily_df.columns if c != "time"]
return daily_df.hvplot.line(
x="time", y=cols_to_plot,
title="Daily Solar Irradiance",
xlabel="Date", ylabel="GHI (kWh/m²/day)",
width=self.width, height=self.height,
legend="top_right",
line_width=[1, 2],
alpha=[0.4, 1.0],
)
def seasonal_heatmap(self, dataset) -> hv.HeatMap:
"""Month × component heatmap of irradiance.
Parameters
----------
dataset : xr.Dataset
Solar dataset with time dimension.
"""
ghi = dataset["ALLSKY_SFC_SW_DWN"]
monthly = ghi.groupby("time.month").mean()
records = []
for month in range(1, 13):
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
records.append({
"month": month_names[month - 1],
"metric": "GHI",
"value": float(monthly.sel(month=month)),
})
if "ALLSKY_SFC_SW_DNI" in dataset:
dni = dataset["ALLSKY_SFC_SW_DNI"].groupby("time.month").mean()
for month in range(1, 13):
records.append({
"month": month_names[month - 1],
"metric": "DNI",
"value": float(dni.sel(month=month)),
})
if "ALLSKY_SFC_SW_DIFF" in dataset:
dhi = dataset["ALLSKY_SFC_SW_DIFF"].groupby("time.month").mean()
for month in range(1, 13):
records.append({
"month": month_names[month - 1],
"metric": "DHI",
"value": float(dhi.sel(month=month)),
})
df = pd.DataFrame(records)
return df.hvplot.heatmap(
x="month", y="metric", C="value",
title="Seasonal Irradiance Heatmap",
cmap=self.cmap, colorbar=True,
width=self.width, height=300,
)
def clearsky_vs_actual(self, dataset) -> Any:
"""Overlay of actual vs clear-sky GHI.
Parameters
----------
dataset : xr.Dataset
Dataset with ALLSKY_SFC_SW_DWN and CLRSKY_SFC_SW_DWN.
"""
ghi = dataset["ALLSKY_SFC_SW_DWN"]
clearsky = dataset["CLRSKY_SFC_SW_DWN"]
# Resample to monthly for cleaner visualization
monthly_ghi = ghi.resample(time="ME").mean()
monthly_cs = clearsky.resample(time="ME").mean()
df = pd.DataFrame({
"time": monthly_ghi.time.values,
"Actual GHI": monthly_ghi.values,
"Clear Sky GHI": monthly_cs.values,
})
return df.hvplot.area(
x="time", y=["Clear Sky GHI", "Actual GHI"],
title="Actual vs Clear Sky Irradiance",
xlabel="Date", ylabel="GHI (kWh/m²/day)",
width=self.width, height=self.height,
alpha=0.6, legend="top_right",
stacked=False,
)
def irradiance_distribution(self, dataset) -> Any:
"""Histogram of daily GHI distribution.
Parameters
----------
dataset : xr.Dataset
"""
ghi = dataset["ALLSKY_SFC_SW_DWN"].values
df = pd.DataFrame({"GHI": ghi})
return df.hvplot.hist(
y="GHI", bins=40,
title="Daily GHI Distribution",
xlabel="GHI (kWh/m²/day)", ylabel="Frequency",
width=self.width, height=self.height,
color=CB_PALETTE[1], alpha=0.8,
)
# -------------------------------------------------------------------
# Orientation Charts
# -------------------------------------------------------------------
def orientation_comparison_bar(self, sim_df: pd.DataFrame, tilt: int = 30) -> Any:
"""Bar chart comparing annual energy by direction for a given tilt.
Parameters
----------
sim_df : pd.DataFrame
Output from OrientationSimulator.simulate_all_orientations().
tilt : int
Tilt angle to compare.
"""
available_tilts = sim_df["tilt_deg"].unique()
if len(available_tilts) > 0 and tilt not in available_tilts:
tilt = int(available_tilts[np.argmin(np.abs(available_tilts - tilt))])
filtered = sim_df[sim_df["tilt_deg"] == tilt].drop_duplicates(
subset=["direction"]
)
return filtered.hvplot.bar(
x="direction", y="annual_energy_kwh",
title=f"Annual Energy by Direction (Tilt: {tilt}°)",
xlabel="Panel Direction", ylabel="Annual Energy (kWh)",
width=self.width, height=self.height,
color="annual_energy_kwh", cmap=self.cmap,
rot=45,
)
def tilt_energy_curve(self, sensitivity_df: pd.DataFrame) -> Any:
"""Line chart of energy vs tilt angle.
Parameters
----------
sensitivity_df : pd.DataFrame
Output from OrientationSimulator.tilt_sensitivity_analysis().
"""
return sensitivity_df.hvplot.line(
x="tilt_deg", y="annual_energy_kwh",
title="Energy vs Panel Tilt Angle",
xlabel="Tilt Angle (°)", ylabel="Annual Energy (kWh)",
width=self.width, height=self.height,
line_width=3, color=CB_PALETTE[4],
markers=True,
)
def orientation_heatmap(self, sim_df: pd.DataFrame) -> hv.HeatMap:
"""Heatmap of direction × tilt × annual energy.
Parameters
----------
sim_df : pd.DataFrame
Full simulation results.
"""
annual = sim_df.drop_duplicates(subset=["direction", "tilt_deg"])
return annual.hvplot.heatmap(
x="direction", y="tilt_deg", C="annual_energy_kwh",
title="Energy by Orientation & Tilt",
xlabel="Direction", ylabel="Tilt (°)",
cmap=self.cmap, colorbar=True,
width=self.width, height=self.height,
rot=45,
)
def daily_profile_overlay(self, profile_df: pd.DataFrame) -> Any:
"""Overlay of hourly energy profiles for different directions.
Parameters
----------
profile_df : pd.DataFrame
Output from OrientationSimulator.daily_profile_by_orientation().
"""
return profile_df.hvplot.line(
x="hour", y="energy_kwh", by="direction",
title="Hourly Energy Profile by Direction",
xlabel="Hour (UTC)", ylabel="Energy (kWh)",
width=self.width, height=self.height,
legend="top_right", line_width=2,
)
def seasonal_orientation_comparison(self, seasonal_df: pd.DataFrame) -> Any:
"""Grouped bar chart of seasonal energy by direction.
Parameters
----------
seasonal_df : pd.DataFrame
Output from OrientationSimulator.seasonal_comparison().
"""
return seasonal_df.hvplot.bar(
x="season", y="seasonal_energy_kwh", by="direction",
title="Seasonal Energy by Direction",
xlabel="Season", ylabel="Energy (kWh)",
width=self.width, height=self.height,
rot=0, legend="top_right",
)
# -------------------------------------------------------------------
# Energy Charts
# -------------------------------------------------------------------
def energy_projection_area(self, monthly_energy_df: pd.DataFrame) -> Any:
"""Area chart of monthly energy projection.
Parameters
----------
monthly_energy_df : pd.DataFrame
Output from EnergyEstimator.estimate_monthly_energy().
"""
return monthly_energy_df.hvplot.area(
x="month_name", y="avg_monthly_energy",
title="Monthly Energy Generation Projection",
xlabel="Month", ylabel="Energy (kWh)",
width=self.width, height=self.height,
color=CB_PALETTE[2], alpha=0.7,
rot=45,
)
def annual_energy_summary_table(self, summary: dict) -> hv.Table:
"""HoloViews table of system performance summary.
Parameters
----------
summary : dict
Output from EnergyEstimator.system_summary().
"""
rows = []
for section, metrics in summary.items():
if isinstance(metrics, dict):
for key, value in metrics.items():
label = key.replace("_", " ").title()
rows.append({"Metric": label, "Value": str(value)})
df = pd.DataFrame(rows)
return hv.Table(df, kdims=["Metric"], vdims=["Value"]).opts(
width=self.width, height=300,
)
# -------------------------------------------------------------------
# Map Visualizations
# -------------------------------------------------------------------
@staticmethod
def _add_coastlines_hook(plot, element):
"""Bokeh plot hook to add coastline MultiLine glyphs directly."""
if not HAS_CARTOPY:
return
from bokeh.models import ColumnDataSource, MultiLine
def _extract(feature):
xs, ys = [], []
for geom in feature.geometries():
geoms = geom.geoms if hasattr(geom, "geoms") else [geom]
for g in geoms:
coords = np.array(g.coords)
if len(coords) > 1:
xs.append(coords[:, 0].tolist())
ys.append(coords[:, 1].tolist())
return xs, ys
fig = plot.state
# Coastlines
cxs, cys = _extract(cfeature.COASTLINE)
coast_src = ColumnDataSource(data={"xs": cxs, "ys": cys})
coast_glyph = MultiLine(xs="xs", ys="ys", line_color="white",
line_width=1.0, line_alpha=0.8)
fig.add_glyph(coast_src, coast_glyph)
# Borders
bxs, bys = _extract(cfeature.BORDERS)
border_src = ColumnDataSource(data={"xs": bxs, "ys": bys})
border_glyph = MultiLine(xs="xs", ys="ys", line_color="gray",
line_width=0.4, line_alpha=0.5,
line_dash="dotted")
fig.add_glyph(border_src, border_glyph)
def _coastline_overlay(self):
"""No-op; coastlines are added via Bokeh hook instead."""
return hv.Overlay([])
def global_solar_map(
self,
lat_grid: np.ndarray,
lon_grid: np.ndarray,
ghi_grid: np.ndarray,
) -> hv.Image:
"""Global solar radiation map using HoloViews Image.
For large grids, Datashader is used for server-side rendering.
Parameters
----------
lat_grid : array
Latitude values.
lon_grid : array
Longitude values.
ghi_grid : 2D array
GHI values (lat × lon).
"""
bounds = (
float(lon_grid.min()), float(lat_grid.min()),
float(lon_grid.max()), float(lat_grid.max()),
)
hooks = [self._add_coastlines_hook] if HAS_CARTOPY else []
img = hv.Image(
ghi_grid,
bounds=bounds,
kdims=["Longitude", "Latitude"],
vdims=["GHI (kWh/m²/day)"],
).opts(
cmap="inferno",
colorbar=True,
width=self.width + 200,
height=self.height + 100,
title="Global Solar Irradiance Map",
tools=["hover"],
hooks=hooks,
)
return img
def location_marker(self, lat: float, lon: float, label: str = "") -> hv.Points:
"""Create a location marker overlay for maps.
Parameters
----------
lat, lon : float
Location coordinates.
label : str
Label text.
"""
df = pd.DataFrame({
"Longitude": [lon],
"Latitude": [lat],
"Label": [label or f"({lat:.2f}, {lon:.2f})"],
})
return hv.Points(df, kdims=["Longitude", "Latitude"]).opts(
size=15, color="red", marker="triangle",
tools=["hover"],
)
# -------------------------------------------------------------------
# Financial Charts
# -------------------------------------------------------------------
def payback_timeline(self, savings_df: pd.DataFrame, currency_symbol: str = "$") -> Any:
"""Line chart of cumulative savings vs time.
Parameters
----------
savings_df : pd.DataFrame
Output from FinancialAnalyzer.lifetime_savings().
currency_symbol : str
Currency symbol for axis label.
"""
return savings_df.hvplot.line(
x="year", y="cumulative_net_savings",
title="Solar Investment Payback Timeline",
xlabel="Year", ylabel=f"Cumulative Net Savings ({currency_symbol})",
width=self.width, height=self.height,
line_width=3, color=CB_PALETTE[2],
).opts(
# Add horizontal line at y=0
) * hv.HLine(0).opts(color="gray", line_dash="dashed", line_width=1)
def carbon_savings_bar(self, savings_df: pd.DataFrame) -> Any:
"""Bar chart of annual carbon offset.
Parameters
----------
savings_df : pd.DataFrame
DataFrame with 'year' and 'carbon_offset_kg' columns.
"""
return savings_df.hvplot.bar(
x="year", y="carbon_offset_kg",
title="Annual Carbon Offset",
xlabel="Year", ylabel="CO₂ Avoided (kg)",
width=self.width, height=self.height,
color=CB_PALETTE[2], alpha=0.8,
)
# -------------------------------------------------------------------
# Composite Dashboard Panels
# -------------------------------------------------------------------
def create_overview_layout(
self,
monthly_irradiance: pd.DataFrame,
rolling_data: pd.DataFrame,
dataset,
) -> hv.Layout:
"""Create the overview tab layout combining multiple charts.
Returns
-------
hv.Layout
Grid layout of overview charts.
"""
bar = self.monthly_irradiance_bar(monthly_irradiance)
ts = self.daily_irradiance_timeseries(rolling_data)
dist = self.irradiance_distribution(dataset)
heatmap = self.seasonal_heatmap(dataset)
return (bar + ts + dist + heatmap).cols(2).opts(
title="Solar Irradiance Overview",
)
def create_orientation_layout(
self,
sim_df: pd.DataFrame,
sensitivity_df: pd.DataFrame,
profile_df: pd.DataFrame,
seasonal_df: pd.DataFrame,
) -> hv.Layout:
"""Create the orientation analysis tab layout.
Returns
-------
hv.Layout
Grid layout of orientation charts.
"""
bar = self.orientation_comparison_bar(sim_df)
curve = self.tilt_energy_curve(sensitivity_df)
profile = self.daily_profile_overlay(profile_df)
heatmap = self.orientation_heatmap(sim_df)
return (bar + curve + profile + heatmap).cols(2).opts(
title="Panel Orientation Analysis",
)
# -------------------------------------------------------------------
# Datashader-Powered Large Dataset Rendering
# -------------------------------------------------------------------
def datashader_global_map(
self,
ds: Any,
ghi_var: str = "GHI",
) -> Any:
"""Render a global solar map using Datashader for million-point grids.
Uses Datashader's Canvas to rasterize an xarray Dataset into
a resolution-appropriate image. Handles grids from 100K to 10M+ points.
Parameters
----------
ds : xr.Dataset
Gridded dataset with lat/lon dimensions and a GHI variable.
ghi_var : str
Name of the GHI variable in the dataset.
Returns
-------
hv.Image
Rasterized image suitable for embedding in Panel dashboards.
"""
data = ds[ghi_var]
lats = data.coords[data.dims[0]].values
lons = data.coords[data.dims[1]].values
values = data.values
bounds = (float(lons.min()), float(lats.min()),
float(lons.max()), float(lats.max()))
img = hv.Image(
values, bounds=bounds,
kdims=["Longitude", "Latitude"],
vdims=["GHI (kWh/m\u00b2/day)"],
).opts(
cmap="inferno", colorbar=True,
width=self.width + 200, height=self.height + 100,
title="Global Solar Irradiance (Datashader)",
tools=["hover", "wheel_zoom", "pan", "reset"],
)
if HAS_CARTOPY:
return img * self._coastline_overlay()
return img
def datashader_point_density(
self,
df: pd.DataFrame,
x: str = "lon",
y: str = "lat",
agg_col: str = "ghi",
plot_width: int = 800,
plot_height: int = 400,
) -> Any:
"""Render a point density map using Datashader Canvas.
Aggregates millions of scattered points into a raster image
using server-side rendering for performance.
Parameters
----------
df : pd.DataFrame
DataFrame with x, y, and aggregation columns.
x, y : str
Column names for coordinates.
agg_col : str
Column to aggregate (mean).
plot_width, plot_height : int
Output image dimensions.
Returns
-------
hv.Image
Datashader-rasterized point density image.
"""
import datashader as ds_lib
canvas = ds_lib.Canvas(
plot_width=plot_width, plot_height=plot_height,
x_range=(float(df[x].min()), float(df[x].max())),
y_range=(float(df[y].min()), float(df[y].max())),
)
agg = canvas.points(df, x, y, agg=ds_lib.mean(agg_col))
bounds = (
float(df[x].min()), float(df[y].min()),
float(df[x].max()), float(df[y].max()),
)
img = hv.Image(
agg.values, bounds=bounds,
kdims=["Longitude", "Latitude"],
vdims=["Mean GHI"],
)
return img.opts(
cmap="inferno", colorbar=True,
width=plot_width, height=plot_height,
title="Solar Irradiance Point Density (Datashader)",
tools=["hover"],
)
# -------------------------------------------------------------------
# Multi-Location Comparison Charts
# -------------------------------------------------------------------
def multi_location_bar(self, comparison_df: pd.DataFrame) -> Any:
"""Bar chart comparing annual GHI across locations.
Parameters
----------
comparison_df : pd.DataFrame
Output from MultiLocationComparator.compare_ghi().
"""
return comparison_df.hvplot.bar(
x="location", y="annual_kwh_m2",
title="Annual Solar Energy by Location",
xlabel="Location", ylabel="Annual Energy (kWh/m\u00b2/year)",
width=self.width, height=self.height,
color="annual_kwh_m2", cmap=self.cmap,
rot=45,
)
def multi_location_monthly(self, monthly_df: pd.DataFrame) -> Any:
"""Line chart of monthly GHI for multiple locations.
Parameters
----------
monthly_df : pd.DataFrame
Output from MultiLocationComparator.compare_monthly().
"""
return monthly_df.hvplot.line(
x="month", y="GHI", by="location",
title="Monthly GHI Comparison",
xlabel="Month", ylabel="GHI (kWh/m\u00b2/day)",
width=self.width, height=self.height,
legend="top_right", line_width=2,
)
def multi_location_radar_table(self, ranking_df: pd.DataFrame) -> hv.Table:
"""Ranking table of locations by solar potential.
Parameters
----------
ranking_df : pd.DataFrame
Output from MultiLocationComparator.ranking().
"""
display_df = ranking_df[["rank", "location", "GHI", "annual_kwh_m2"]].copy()
display_df.columns = ["Rank", "Location", "Avg Daily GHI", "Annual kWh/m\u00b2"]
display_df["Avg Daily GHI"] = display_df["Avg Daily GHI"].round(2)
display_df["Annual kWh/m\u00b2"] = display_df["Annual kWh/m\u00b2"].round(0)
return hv.Table(
display_df,
kdims=["Rank", "Location"],
vdims=["Avg Daily GHI", "Annual kWh/m\u00b2"],
).opts(width=self.width, height=250)
# -------------------------------------------------------------------
# Orientation Polar Plot
# -------------------------------------------------------------------
def orientation_polar_plot(
self,
sim_df: pd.DataFrame,
tilt: int = 30,
) -> hv.Overlay:
"""Polar-style plot of energy by compass direction.
Renders direction vs energy as a radial bar chart using HoloViews
Points on a circular layout (0-360 degrees azimuth).
Parameters
----------
sim_df : pd.DataFrame
Output from OrientationSimulator.simulate_all_orientations().
tilt : int
Tilt angle to visualize.
Returns
-------
hv.Overlay
Polar-style scatter plot of direction vs energy.
"""
azimuth_map = {
"North": 0, "North-East": 45, "East": 90, "South-East": 135,
"South": 180, "South-West": 225, "West": 270, "North-West": 315,
}
# Use nearest available tilt if exact match not found
available_tilts = sim_df["tilt_deg"].unique()
if len(available_tilts) == 0:
return hv.Points([]).opts(title="No simulation data")
if tilt not in available_tilts:
tilt = int(available_tilts[np.argmin(np.abs(available_tilts - tilt))])
filtered = sim_df[sim_df["tilt_deg"] == tilt].drop_duplicates(
subset=["direction"]
).copy()
filtered["azimuth"] = filtered["direction"].map(azimuth_map)
filtered = filtered.dropna(subset=["azimuth"])
# Convert to radians for polar-like x/y projection
theta = np.radians(filtered["azimuth"].values)
energy = filtered["annual_energy_kwh"].values
# Normalize energy to 0-1 range for radius
e_min, e_max = energy.min(), energy.max()
if e_max > e_min:
radius = 0.2 + 0.8 * (energy - e_min) / (e_max - e_min)
else:
radius = np.ones_like(energy) * 0.5
x = radius * np.sin(theta)
y = radius * np.cos(theta)
df = pd.DataFrame({
"x": x, "y": y,
"direction": filtered["direction"].values,
"energy_kwh": energy,
"azimuth": filtered["azimuth"].values,
})
points = hv.Points(
df, kdims=["x", "y"],
vdims=["direction", "energy_kwh"],
).opts(
size=15, color="energy_kwh", cmap=self.cmap,
colorbar=True, tools=["hover"],
width=self.height + 50, height=self.height + 50,
title=f"Energy by Direction (Tilt: {tilt}°)",
xaxis=None, yaxis=None,
)
# Add direction labels
label_r = 1.1
labels_data = []
for direction, azimuth_deg in azimuth_map.items():
if direction in filtered["direction"].values:
th = np.radians(azimuth_deg)
labels_data.append({
"x": label_r * np.sin(th),
"y": label_r * np.cos(th),
"text": direction,
})
if labels_data:
labels_df = pd.DataFrame(labels_data)
labels = hv.Labels(labels_df, kdims=["x", "y"], vdims=["text"]).opts(
text_font_size="9pt", text_color="gray",
)
return points * labels
return points
# -------------------------------------------------------------------
# HoloViews Streams — Interactive Map Selection
# -------------------------------------------------------------------
def interactive_map_with_tap(
self,
lat_grid: np.ndarray,
lon_grid: np.ndarray,
ghi_grid: np.ndarray,
) -> tuple[hv.DynamicMap, streams.Tap]:
"""Create an interactive solar map with Tap stream for location selection.
Click on the map to select a location. The Tap stream provides
the clicked (x, y) coordinates for downstream reactive updates.
Parameters
----------
lat_grid, lon_grid : array
1D latitude/longitude arrays.
ghi_grid : 2D array
GHI values (lat x lon).
Returns
-------
tuple[hv.DynamicMap, hv.streams.Tap]
DynamicMap with tap marker overlay, and the Tap stream instance.
"""
bounds = (
float(lon_grid.min()), float(lat_grid.min()),
float(lon_grid.max()), float(lat_grid.max()),
)
base_map = hv.Image(
ghi_grid, bounds=bounds,
kdims=["Longitude", "Latitude"],
vdims=["GHI (kWh/m\u00b2/day)"],
).opts(
cmap="inferno", colorbar=True,
width=self.width + 200, height=self.height + 100,
title="Click to Select Location",
tools=["tap", "hover"],
)
tap_stream = streams.Tap(source=base_map, x=0, y=0)
def tap_marker(x, y):
if x == 0 and y == 0:
return hv.Points([]).opts(size=0)
marker_df = pd.DataFrame({
"Longitude": [x], "Latitude": [y],
"Label": [f"({y:.2f}, {x:.2f})"],
})
return hv.Points(
marker_df, kdims=["Longitude", "Latitude"],
).opts(
size=18, color="red", marker="triangle",
tools=["hover"],
)
marker_dmap = hv.DynamicMap(tap_marker, streams=[tap_stream])
if HAS_CARTOPY:
result = base_map * self._coastline_overlay() * marker_dmap
else:
result = base_map * marker_dmap
return result, tap_stream
def interactive_timeseries_with_range(
self,
daily_df: pd.DataFrame,
) -> tuple[Any, streams.RangeX]:
"""Create an interactive timeseries with RangeX stream for zoom selection.
Zooming/panning on the plot updates the RangeX stream with the
current x-axis bounds, enabling downstream reactive filtering.
Parameters
----------
daily_df : pd.DataFrame
DataFrame with 'time' and GHI columns.
Returns
-------
tuple[hv.DynamicMap, hv.streams.RangeX]
The interactive plot and the RangeX stream.
"""
cols_to_plot = [c for c in daily_df.columns if c != "time"]
base_plot = daily_df.hvplot.line(
x="time", y=cols_to_plot,
title="Daily GHI (zoom to select range)",
xlabel="Date", ylabel="GHI (kWh/m\u00b2/day)",
width=self.width, height=self.height,
legend="top_right",
)
range_stream = streams.RangeX(source=base_plot)
return base_plot, range_stream
def interactive_orientation_selector(
self,
sim_df: pd.DataFrame,
tilt: int = 30,
) -> tuple[Any, streams.Selection1D]:
"""Orientation bar chart with Selection1D stream.
Click on bars to select orientations for detailed comparison.
The Selection1D stream provides indices of selected elements.
Parameters
----------
sim_df : pd.DataFrame
Simulation results.
tilt : int
Tilt angle to display.
Returns
-------
tuple[hv.Bars, hv.streams.Selection1D]
Interactive bar chart and selection stream.
"""
available_tilts = sim_df["tilt_deg"].unique()
if len(available_tilts) > 0 and tilt not in available_tilts:
tilt = int(available_tilts[np.argmin(np.abs(available_tilts - tilt))])
filtered = sim_df[sim_df["tilt_deg"] == tilt].drop_duplicates(
subset=["direction"]
).reset_index(drop=True)
bars = hv.Bars(
filtered, kdims=["direction"], vdims=["annual_energy_kwh"],
).opts(
title=f"Select Orientations to Compare (Tilt: {tilt}°)",
xlabel="Direction", ylabel="Annual Energy (kWh)",
width=self.width, height=self.height,
color="annual_energy_kwh", cmap=self.cmap,
tools=["tap", "hover"],
)
selection_stream = streams.Selection1D(source=bars)
return bars, selection_stream
# -------------------------------------------------------------------
# Dynamic Datashader Rasterization
# -------------------------------------------------------------------
def dynamic_rasterized_map(
self,
ds: Any,
ghi_var: str = "GHI",
) -> hv.DynamicMap:
"""Create a dynamically rasterized map using Datashader + HoloViews.
Uses holoviews.operation.datashader.rasterize() for zoom-dependent
re-rendering. As the user zooms in, the data is re-rasterized at
the appropriate resolution for the current viewport.
Parameters
----------
ds : xr.Dataset
Gridded dataset with lat/lon dimensions.
ghi_var : str
Variable name for GHI.
Returns
-------
hv.DynamicMap
Dynamically rasterized map that re-renders on zoom/pan.
"""
from holoviews.operation.datashader import rasterize
data = ds[ghi_var]
lats = data.coords[data.dims[0]].values
lons = data.coords[data.dims[1]].values
values = data.values
bounds = (float(lons.min()), float(lats.min()),
float(lons.max()), float(lats.max()))
img = hv.Image(
values, bounds=bounds,
kdims=["Longitude", "Latitude"],
vdims=["GHI"],
)
rasterized = rasterize(img).opts(
cmap="inferno", colorbar=True,
width=self.width + 200, height=self.height + 100,
title="Global Solar Map (Dynamic Rasterization)",
tools=["hover", "wheel_zoom", "pan", "reset", "box_zoom"],
)
return rasterized
# ------------------------------------------------------------------
# Dual-Source Comparison Charts
# ------------------------------------------------------------------
def dual_source_timeseries(
self,
aligned_df: pd.DataFrame,
variable: str = "GHI",
) -> hv.Overlay:
"""Overlay timeseries from two data sources for visual comparison.
Parameters
----------
aligned_df : pd.DataFrame
DataFrame with time index and one column per source.
variable : str
Label for the variable being compared.
Returns
-------
hv.Overlay
"""
curves = []
colors = CB_PALETTE[:4]
for i, col in enumerate(aligned_df.columns):
curve = hv.Curve(
(aligned_df.index, aligned_df[col]),
"Time", f"{variable} (kWh/m²/day)",
label=col,
).opts(
color=colors[i % len(colors)],
line_width=1.5,
alpha=0.7,
)
curves.append(curve)
overlay = hv.Overlay(curves).opts(
width=self.width, height=self.height,
title=f"{variable} — Multi-Source Comparison",
legend_position="top_right",
tools=["hover", "wheel_zoom", "pan"],
)
return overlay
def dual_source_monthly_bar(
self,
monthly_df: pd.DataFrame,
) -> hv.Bars:
"""Grouped bar chart comparing monthly GHI from multiple sources.
Parameters
----------
monthly_df : pd.DataFrame
Columns: month_name, plus one column per source.
Returns
-------
hv.Bars
"""
source_cols = [c for c in monthly_df.columns if c not in ("month", "month_name")]
records = []
for _, row in monthly_df.iterrows():
for src in source_cols:
records.append({
"Month": row["month_name"],
"Source": src,
"GHI": row[src],
})
df = pd.DataFrame(records)
bars = hv.Bars(df, kdims=["Month", "Source"], vdims="GHI").opts(
width=self.width, height=self.height,
title="Monthly GHI — Source Comparison",
ylabel="GHI (kWh/m²/day)",
xrotation=45,
multi_level=False,
color="Source",
cmap="Category10",
legend_position="top_right",
tools=["hover"],
)
return bars
def dual_source_scatter(
self,
aligned_df: pd.DataFrame,
) -> hv.Overlay:
"""Scatter plot of Source A vs Source B for correlation visualization.
Parameters
----------
aligned_df : pd.DataFrame
DataFrame with exactly 2 source columns.
Returns
-------
hv.Overlay
Scatter points + 1:1 reference line.
"""
cols = list(aligned_df.columns)
if len(cols) < 2:
return hv.Div("<p>Need 2 sources for scatter comparison</p>")
common = aligned_df[cols[:2]].dropna()
src_a, src_b = cols[0], cols[1]
scatter = hv.Points(
common, kdims=[src_a, src_b],
).opts(
size=3, alpha=0.4, color=CB_PALETTE[0],
width=self.height, height=self.height,
title=f"GHI Correlation: {src_a} vs {src_b}",
xlabel=f"{src_a} (kWh/m²/day)",
ylabel=f"{src_b} (kWh/m²/day)",
tools=["hover"],
)
# 1:1 reference line
vmin = float(common.min().min())
vmax = float(common.max().max())
ref_line = hv.Curve(
[(vmin, vmin), (vmax, vmax)],
).opts(color="red", line_dash="dashed", line_width=2)
return scatter * ref_line
def dual_source_difference_heatmap(
self,
aligned_df: pd.DataFrame,
) -> hv.HeatMap:
"""Month x Year heatmap of difference between two sources.
Parameters
----------
aligned_df : pd.DataFrame
DataFrame with 2 source columns and time index.
Returns
-------
hv.HeatMap
"""
cols = list(aligned_df.columns)
if len(cols) < 2:
return hv.Div("<p>Need 2 sources for difference heatmap</p>")
common = aligned_df[cols[:2]].dropna()
diff = common[cols[0]] - common[cols[1]]
df = pd.DataFrame({
"month": diff.index.month,
"year": diff.index.year,
"difference": diff.values,
})
monthly = df.groupby(["year", "month"])["difference"].mean().reset_index()
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
monthly["month_name"] = monthly["month"].apply(lambda m: month_names[m - 1])
heatmap = hv.HeatMap(
monthly, kdims=["month_name", "year"], vdims="difference",
).opts(
width=self.width, height=self.height,
title=f"GHI Difference ({cols[0]} - {cols[1]})",
cmap="RdBu_r",
colorbar=True,
tools=["hover"],
xrotation=45,
)
return heatmap
def source_location_map(
self,
lat: float,
lon: float,
ghi_values: dict[str, float],
global_ds: xr.Dataset | None = None,
) -> hv.Overlay:
"""Map showing location marker with per-source GHI annotations.
Parameters
----------
lat, lon : float
Location coordinates.
ghi_values : dict[str, float]
Source name -> average daily GHI.
global_ds : xr.Dataset, optional
Global solar grid for background.
Returns
-------
hv.Overlay
"""
# Background map
if global_ds is not None:
ghi_data = global_ds["GHI"].values
lats = global_ds.coords["lat"].values
lons = global_ds.coords["lon"].values
base = hv.Image(
(lons, lats, ghi_data),
kdims=["Longitude", "Latitude"],
vdims=["GHI"],
).opts(
cmap=self.cmap, colorbar=True, alpha=0.6,
width=self.width + 200, height=self.height + 100,
)
else:
base = hv.Tiles("https://tile.openstreetmap.org/{Z}/{X}/{Y}.png").opts(
width=self.width + 200, height=self.height + 100,
)
# Location marker
label_parts = [f"{src}: {ghi:.2f}" for src, ghi in ghi_values.items()]
label = " | ".join(label_parts)
marker = hv.Points(
pd.DataFrame({"Longitude": [lon], "Latitude": [lat], "Label": [label]}),
kdims=["Longitude", "Latitude"],
vdims=["Label"],
).opts(
size=15, color="red", marker="star",
tools=["hover"],
)
title_text = f"Solar Resource at ({lat:.2f}, {lon:.2f})"
return (base * marker).opts(title=title_text)