Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cubedynamics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
documented for contributors but may change more frequently.
"""

from __future__ import annotations

from .version import __version__
from .piping import Pipe, pipe
from . import verbs
Expand Down
66 changes: 62 additions & 4 deletions src/cubedynamics/fire_time_hull.py
Original file line number Diff line number Diff line change
Expand Up @@ -1088,21 +1088,72 @@ def plot_climate_filled_hull(
var_label: str = "value",
save_prefix: Optional[str] = None,
color_limits: Optional[Tuple[float, float]] = None,
z_exaggeration: float = 2.2,
scalar_debug_mode: Optional[str] = None,
debug: bool = False,
) -> go.Figure:
verts = np.asarray(hull.verts_km)
tris = np.asarray(hull.tris)

# Scalar values are attached per-vertex by ring/time-layer order (not by
# absolute z value). This keeps the climate series aligned with mesh layout
# even when t_day uses non-consecutive values.
intensities = None
if isinstance(summary, HullClimateSummary) and summary.per_day_mean.size:
day_vals = np.asarray(summary.per_day_mean.sort_index().values, dtype=float)
M = int(hull.metrics.get("days", day_vals.size if day_vals.size else 0) or 0)
if M > 0 and day_vals.size:
n_vertices = int(verts.shape[0])
M = int(round(hull.metrics.get("days", 0) or 0))
if M <= 0 and day_vals.size:
M = int(day_vals.size)
if M > 0 and day_vals.size and n_vertices % M == 0:
verts_per_layer = n_vertices // M
if len(day_vals) < M:
day_vals = np.pad(day_vals, (0, M - len(day_vals)), mode="edge")
elif len(day_vals) > M:
day_vals = day_vals[:M]
layer_indices = np.clip((hull.t_days_vert - 1).astype(int), 0, len(day_vals) - 1)
intensities = day_vals[layer_indices]
intensities = np.repeat(day_vals, verts_per_layer)
if intensities.shape[0] != n_vertices:
raise ValueError(
"Hull climate scalar/vertex mismatch: "
f"{intensities.shape[0]} intensities for {n_vertices} vertices."
)

# Developer diagnostic mode: color by z/time to confirm vertical banding.
if scalar_debug_mode == "z":
intensities = verts[:, 2].astype(float)

if intensities is not None:
finite = intensities[np.isfinite(intensities)]
if color_limits is None and finite.size:
# Display-only robust normalization to preserve visible scalar bands.
cmin = float(np.nanpercentile(finite, 2))
cmax = float(np.nanpercentile(finite, 98))
if not np.isfinite(cmin) or not np.isfinite(cmax) or cmax <= cmin:
cmin = float(np.nanmin(finite))
cmax = float(np.nanmax(finite))
if cmax <= cmin:
cmax = cmin + 1e-9
color_limits = (cmin, cmax)
if debug:
pct = [1, 5, 25, 50, 75, 95, 99]
pct_vals = (
np.nanpercentile(finite, pct).tolist()
if finite.size
else [float("nan")] * len(pct)
)
print(
"fire_hull_scalar_debug:",
{
"verts_shape": tuple(verts.shape),
"tris_shape": tuple(tris.shape),
"scalar_shape": tuple(intensities.shape),
"nan_count": int(np.isnan(intensities).sum()),
"min": float(np.nanmin(finite)) if finite.size else float("nan"),
"max": float(np.nanmax(finite)) if finite.size else float("nan"),
"percentiles": dict(zip([str(p) for p in pct], pct_vals)),
"mode": scalar_debug_mode or "climate",
},
)

fig = go.Figure(
data=[
Expand All @@ -1115,6 +1166,8 @@ def plot_climate_filled_hull(
k=tris[:, 2],
intensity=intensities,
colorscale="Viridis",
intensitymode="vertex",
flatshading=False,
showscale=True,
cmin=None if color_limits is None else color_limits[0],
cmax=None if color_limits is None else color_limits[1],
Expand All @@ -1130,6 +1183,11 @@ def plot_climate_filled_hull(
xaxis_title="x (km)",
yaxis_title="y (km)",
zaxis_title="time (days)",
aspectmode="manual",
aspectratio=dict(x=1.0, y=1.0, z=float(max(0.1, z_exaggeration))),
xaxis=dict(showbackground=False, showgrid=False, zeroline=False),
yaxis=dict(showbackground=False, showgrid=False, zeroline=False),
zaxis=dict(showbackground=False, showgrid=False, zeroline=False),
),
template="plotly_white",
)
Expand Down
36 changes: 33 additions & 3 deletions src/cubedynamics/verbs/fire.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ def fire_plot(
n_ring_samples: int = 200,
n_theta: int = 296,
color_limits: Optional[Tuple[float, float]] = None,
z_exaggeration: float = 2.2,
scalar_debug_mode: Optional[str] = None,
debug_scalars: bool = False,
show_hist: bool = False,
verbose: bool = False,
save_prefix: Optional[str] = None,
Expand Down Expand Up @@ -150,9 +153,34 @@ def _nan_guard(val):
color_limits = (0.0, 1.0)
else:
color_limits = (
float(np.nanpercentile(vals, 5)),
float(np.nanpercentile(vals, 95)),
float(np.nanpercentile(vals, 2)),
float(np.nanpercentile(vals, 98)),
)
if not np.isfinite(color_limits[0]) or not np.isfinite(color_limits[1]) or color_limits[1] <= color_limits[0]:
finite = vals[np.isfinite(vals)]
if finite.size:
vmin = float(np.nanmin(finite))
vmax = float(np.nanmax(finite))
if vmax <= vmin:
vmax = vmin + 1e-9
color_limits = (vmin, vmax)

if debug_scalars:
vals = np.asarray(summary.per_day_mean.values, dtype=float)
finite = vals[np.isfinite(vals)]
pct = [1, 5, 25, 50, 75, 95, 99]
pct_vals = np.nanpercentile(finite, pct).tolist() if finite.size else [float("nan")] * len(pct)
log(
True,
"fire_plot scalar summary:",
{
"per_day_mean_len": int(vals.size),
"nan_count": int(np.isnan(vals).sum()),
"min": float(np.nanmin(finite)) if finite.size else float("nan"),
"max": float(np.nanmax(finite)) if finite.size else float("nan"),
"percentiles": dict(zip([str(p) for p in pct], pct_vals)),
},
)

if climate_variable == "tmmx":
var_label = "Max temperature (°C)"
Expand All @@ -174,6 +202,9 @@ def _nan_guard(val):
var_label=var_label,
save_prefix=save_prefix,
color_limits=color_limits,
z_exaggeration=z_exaggeration,
scalar_debug_mode=scalar_debug_mode,
debug=debug_scalars,
)

if show_hist:
Expand Down Expand Up @@ -292,4 +323,3 @@ def fire_derivative(
"derivative_hull": deriv_hull,
"fig": fig,
}

110 changes: 110 additions & 0 deletions tests/test_fire_hull_viewer_scalars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import numpy as np
import pandas as pd
import geopandas as gpd
import shapely.geometry as geom
import pytest

from cubedynamics import fire_time_hull as fth
from cubedynamics.fire_time_hull import (
FireEventDaily,
HullClimateSummary,
TimeHull,
plot_climate_filled_hull,
)


def _synthetic_hull(days: int = 3, verts_per_layer: int = 4) -> TimeHull:
verts = []
t_days = []
for layer in range(days):
z = 10.0 + layer # intentionally non-1-based to catch index/order bugs
t_days.extend([z] * verts_per_layer)
for j in range(verts_per_layer):
angle = 2.0 * np.pi * j / verts_per_layer
radius = 1.0 + 0.1 * layer
verts.append([radius * np.cos(angle), radius * np.sin(angle), z])
verts = np.asarray(verts, dtype=float)

tris = []
for i in range(days - 1):
for j in range(verts_per_layer):
jn = (j + 1) % verts_per_layer
v1 = i * verts_per_layer + j
v2 = i * verts_per_layer + jn
v3 = (i + 1) * verts_per_layer + jn
v4 = (i + 1) * verts_per_layer + j
tris.append([v1, v2, v3])
tris.append([v1, v3, v4])

gdf = gpd.GeoDataFrame(
{"id": [1], "date": [pd.Timestamp("2020-07-01")], "geometry": [geom.box(0, 0, 1, 1)]},
crs="EPSG:4326",
)
event = FireEventDaily(1, gdf, pd.Timestamp("2020-07-01"), pd.Timestamp("2020-07-03"), 0.0, 0.0)
return TimeHull(
event=event,
verts_km=verts,
tris=np.asarray(tris, dtype=int),
t_days_vert=np.asarray(t_days, dtype=float),
t_norm_vert=np.linspace(0, 1, len(t_days)),
metrics={"days": float(days), "scale_km": 1.0, "volume_km2_days": 1.0, "surface_km_day": 1.0},
)


@pytest.fixture
def _plotly_stub(monkeypatch):
class _Mesh3d:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)

class _Figure:
def __init__(self, data):
self.data = data
self.layout = type("Layout", (), {})()
self.layout.scene = type("Scene", (), {})()
self.layout.scene.aspectratio = type("Aspect", (), {})()

def update_layout(self, **kwargs):
scene = kwargs.get("scene", {})
aspect = scene.get("aspectratio", {})
self.layout.scene.aspectratio.z = aspect.get("z")

def write_image(self, *_args, **_kwargs):
return None

monkeypatch.setattr(fth.go, "Mesh3d", _Mesh3d, raising=False)
monkeypatch.setattr(fth.go, "Figure", _Figure, raising=False)


def test_plot_climate_filled_hull_attaches_scalars_by_layer_order(_plotly_stub):
hull = _synthetic_hull(days=3, verts_per_layer=4)
summary = HullClimateSummary(
values_inside=np.array([1.0, 2.0, 3.0]),
values_outside=np.array([0.0, 0.0]),
per_day_mean=pd.Series([1.0, 5.0, 9.0], index=pd.date_range("2020-07-01", periods=3, freq="D")),
)

fig = plot_climate_filled_hull(hull, summary, color_limits=None)
got = np.asarray(fig.data[0].intensity, dtype=float)
expected = np.repeat(np.array([1.0, 5.0, 9.0]), 4)
np.testing.assert_allclose(got, expected)


def test_plot_climate_filled_hull_debug_z_mode_and_z_exaggeration(_plotly_stub):
hull = _synthetic_hull(days=3, verts_per_layer=4)
summary = HullClimateSummary(
values_inside=np.array([1.0]),
values_outside=np.array([0.0]),
per_day_mean=pd.Series([2.0, 2.1, 2.2], index=pd.date_range("2020-07-01", periods=3, freq="D")),
)

fig = plot_climate_filled_hull(
hull,
summary,
scalar_debug_mode="z",
z_exaggeration=2.8,
color_limits=None,
)
got = np.asarray(fig.data[0].intensity, dtype=float)
np.testing.assert_allclose(got, hull.verts_km[:, 2])
assert float(fig.layout.scene.aspectratio.z) == 2.8
8 changes: 7 additions & 1 deletion tests/test_lexcube_viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
import pytest
import xarray as xr

pytest.importorskip("lexcube", reason="lexcube is required for these visualization tests")
try:
import lexcube # noqa: F401
except Exception as exc: # pragma: no cover - environment-dependent optional dependency
pytest.skip(
f"lexcube is required for these visualization tests (import failed: {exc})",
allow_module_level=True,
)

from cubedynamics.viz.lexcube_viz import show_cube_lexcube

Expand Down
Loading