Skip to content

Commit 13d46c1

Browse files
authored
Merge pull request #208 from CU-ESIIL/codex/fix-fire-hull-viewer-rendering-issues
Fix fire vase hull scalar mapping and restore legible custom rendering
2 parents 465721f + d928071 commit 13d46c1

File tree

5 files changed

+214
-8
lines changed

5 files changed

+214
-8
lines changed

src/cubedynamics/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
documented for contributors but may change more frequently.
2525
"""
2626

27+
from __future__ import annotations
28+
2729
from .version import __version__
2830
from .piping import Pipe, pipe
2931
from . import verbs

src/cubedynamics/fire_time_hull.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,21 +1088,72 @@ def plot_climate_filled_hull(
10881088
var_label: str = "value",
10891089
save_prefix: Optional[str] = None,
10901090
color_limits: Optional[Tuple[float, float]] = None,
1091+
z_exaggeration: float = 2.2,
1092+
scalar_debug_mode: Optional[str] = None,
1093+
debug: bool = False,
10911094
) -> go.Figure:
10921095
verts = np.asarray(hull.verts_km)
10931096
tris = np.asarray(hull.tris)
10941097

1098+
# Scalar values are attached per-vertex by ring/time-layer order (not by
1099+
# absolute z value). This keeps the climate series aligned with mesh layout
1100+
# even when t_day uses non-consecutive values.
10951101
intensities = None
10961102
if isinstance(summary, HullClimateSummary) and summary.per_day_mean.size:
10971103
day_vals = np.asarray(summary.per_day_mean.sort_index().values, dtype=float)
1098-
M = int(hull.metrics.get("days", day_vals.size if day_vals.size else 0) or 0)
1099-
if M > 0 and day_vals.size:
1104+
n_vertices = int(verts.shape[0])
1105+
M = int(round(hull.metrics.get("days", 0) or 0))
1106+
if M <= 0 and day_vals.size:
1107+
M = int(day_vals.size)
1108+
if M > 0 and day_vals.size and n_vertices % M == 0:
1109+
verts_per_layer = n_vertices // M
11001110
if len(day_vals) < M:
11011111
day_vals = np.pad(day_vals, (0, M - len(day_vals)), mode="edge")
11021112
elif len(day_vals) > M:
11031113
day_vals = day_vals[:M]
1104-
layer_indices = np.clip((hull.t_days_vert - 1).astype(int), 0, len(day_vals) - 1)
1105-
intensities = day_vals[layer_indices]
1114+
intensities = np.repeat(day_vals, verts_per_layer)
1115+
if intensities.shape[0] != n_vertices:
1116+
raise ValueError(
1117+
"Hull climate scalar/vertex mismatch: "
1118+
f"{intensities.shape[0]} intensities for {n_vertices} vertices."
1119+
)
1120+
1121+
# Developer diagnostic mode: color by z/time to confirm vertical banding.
1122+
if scalar_debug_mode == "z":
1123+
intensities = verts[:, 2].astype(float)
1124+
1125+
if intensities is not None:
1126+
finite = intensities[np.isfinite(intensities)]
1127+
if color_limits is None and finite.size:
1128+
# Display-only robust normalization to preserve visible scalar bands.
1129+
cmin = float(np.nanpercentile(finite, 2))
1130+
cmax = float(np.nanpercentile(finite, 98))
1131+
if not np.isfinite(cmin) or not np.isfinite(cmax) or cmax <= cmin:
1132+
cmin = float(np.nanmin(finite))
1133+
cmax = float(np.nanmax(finite))
1134+
if cmax <= cmin:
1135+
cmax = cmin + 1e-9
1136+
color_limits = (cmin, cmax)
1137+
if debug:
1138+
pct = [1, 5, 25, 50, 75, 95, 99]
1139+
pct_vals = (
1140+
np.nanpercentile(finite, pct).tolist()
1141+
if finite.size
1142+
else [float("nan")] * len(pct)
1143+
)
1144+
print(
1145+
"fire_hull_scalar_debug:",
1146+
{
1147+
"verts_shape": tuple(verts.shape),
1148+
"tris_shape": tuple(tris.shape),
1149+
"scalar_shape": tuple(intensities.shape),
1150+
"nan_count": int(np.isnan(intensities).sum()),
1151+
"min": float(np.nanmin(finite)) if finite.size else float("nan"),
1152+
"max": float(np.nanmax(finite)) if finite.size else float("nan"),
1153+
"percentiles": dict(zip([str(p) for p in pct], pct_vals)),
1154+
"mode": scalar_debug_mode or "climate",
1155+
},
1156+
)
11061157

11071158
fig = go.Figure(
11081159
data=[
@@ -1115,6 +1166,8 @@ def plot_climate_filled_hull(
11151166
k=tris[:, 2],
11161167
intensity=intensities,
11171168
colorscale="Viridis",
1169+
intensitymode="vertex",
1170+
flatshading=False,
11181171
showscale=True,
11191172
cmin=None if color_limits is None else color_limits[0],
11201173
cmax=None if color_limits is None else color_limits[1],
@@ -1130,6 +1183,11 @@ def plot_climate_filled_hull(
11301183
xaxis_title="x (km)",
11311184
yaxis_title="y (km)",
11321185
zaxis_title="time (days)",
1186+
aspectmode="manual",
1187+
aspectratio=dict(x=1.0, y=1.0, z=float(max(0.1, z_exaggeration))),
1188+
xaxis=dict(showbackground=False, showgrid=False, zeroline=False),
1189+
yaxis=dict(showbackground=False, showgrid=False, zeroline=False),
1190+
zaxis=dict(showbackground=False, showgrid=False, zeroline=False),
11331191
),
11341192
template="plotly_white",
11351193
)

src/cubedynamics/verbs/fire.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def fire_plot(
3737
n_ring_samples: int = 200,
3838
n_theta: int = 296,
3939
color_limits: Optional[Tuple[float, float]] = None,
40+
z_exaggeration: float = 2.2,
41+
scalar_debug_mode: Optional[str] = None,
42+
debug_scalars: bool = False,
4043
show_hist: bool = False,
4144
verbose: bool = False,
4245
save_prefix: Optional[str] = None,
@@ -150,9 +153,34 @@ def _nan_guard(val):
150153
color_limits = (0.0, 1.0)
151154
else:
152155
color_limits = (
153-
float(np.nanpercentile(vals, 5)),
154-
float(np.nanpercentile(vals, 95)),
156+
float(np.nanpercentile(vals, 2)),
157+
float(np.nanpercentile(vals, 98)),
155158
)
159+
if not np.isfinite(color_limits[0]) or not np.isfinite(color_limits[1]) or color_limits[1] <= color_limits[0]:
160+
finite = vals[np.isfinite(vals)]
161+
if finite.size:
162+
vmin = float(np.nanmin(finite))
163+
vmax = float(np.nanmax(finite))
164+
if vmax <= vmin:
165+
vmax = vmin + 1e-9
166+
color_limits = (vmin, vmax)
167+
168+
if debug_scalars:
169+
vals = np.asarray(summary.per_day_mean.values, dtype=float)
170+
finite = vals[np.isfinite(vals)]
171+
pct = [1, 5, 25, 50, 75, 95, 99]
172+
pct_vals = np.nanpercentile(finite, pct).tolist() if finite.size else [float("nan")] * len(pct)
173+
log(
174+
True,
175+
"fire_plot scalar summary:",
176+
{
177+
"per_day_mean_len": int(vals.size),
178+
"nan_count": int(np.isnan(vals).sum()),
179+
"min": float(np.nanmin(finite)) if finite.size else float("nan"),
180+
"max": float(np.nanmax(finite)) if finite.size else float("nan"),
181+
"percentiles": dict(zip([str(p) for p in pct], pct_vals)),
182+
},
183+
)
156184

157185
if climate_variable == "tmmx":
158186
var_label = "Max temperature (°C)"
@@ -174,6 +202,9 @@ def _nan_guard(val):
174202
var_label=var_label,
175203
save_prefix=save_prefix,
176204
color_limits=color_limits,
205+
z_exaggeration=z_exaggeration,
206+
scalar_debug_mode=scalar_debug_mode,
207+
debug=debug_scalars,
177208
)
178209

179210
if show_hist:
@@ -292,4 +323,3 @@ def fire_derivative(
292323
"derivative_hull": deriv_hull,
293324
"fig": fig,
294325
}
295-
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import numpy as np
2+
import pandas as pd
3+
import geopandas as gpd
4+
import shapely.geometry as geom
5+
import pytest
6+
7+
from cubedynamics import fire_time_hull as fth
8+
from cubedynamics.fire_time_hull import (
9+
FireEventDaily,
10+
HullClimateSummary,
11+
TimeHull,
12+
plot_climate_filled_hull,
13+
)
14+
15+
16+
def _synthetic_hull(days: int = 3, verts_per_layer: int = 4) -> TimeHull:
17+
verts = []
18+
t_days = []
19+
for layer in range(days):
20+
z = 10.0 + layer # intentionally non-1-based to catch index/order bugs
21+
t_days.extend([z] * verts_per_layer)
22+
for j in range(verts_per_layer):
23+
angle = 2.0 * np.pi * j / verts_per_layer
24+
radius = 1.0 + 0.1 * layer
25+
verts.append([radius * np.cos(angle), radius * np.sin(angle), z])
26+
verts = np.asarray(verts, dtype=float)
27+
28+
tris = []
29+
for i in range(days - 1):
30+
for j in range(verts_per_layer):
31+
jn = (j + 1) % verts_per_layer
32+
v1 = i * verts_per_layer + j
33+
v2 = i * verts_per_layer + jn
34+
v3 = (i + 1) * verts_per_layer + jn
35+
v4 = (i + 1) * verts_per_layer + j
36+
tris.append([v1, v2, v3])
37+
tris.append([v1, v3, v4])
38+
39+
gdf = gpd.GeoDataFrame(
40+
{"id": [1], "date": [pd.Timestamp("2020-07-01")], "geometry": [geom.box(0, 0, 1, 1)]},
41+
crs="EPSG:4326",
42+
)
43+
event = FireEventDaily(1, gdf, pd.Timestamp("2020-07-01"), pd.Timestamp("2020-07-03"), 0.0, 0.0)
44+
return TimeHull(
45+
event=event,
46+
verts_km=verts,
47+
tris=np.asarray(tris, dtype=int),
48+
t_days_vert=np.asarray(t_days, dtype=float),
49+
t_norm_vert=np.linspace(0, 1, len(t_days)),
50+
metrics={"days": float(days), "scale_km": 1.0, "volume_km2_days": 1.0, "surface_km_day": 1.0},
51+
)
52+
53+
54+
@pytest.fixture
55+
def _plotly_stub(monkeypatch):
56+
class _Mesh3d:
57+
def __init__(self, **kwargs):
58+
self.__dict__.update(kwargs)
59+
60+
class _Figure:
61+
def __init__(self, data):
62+
self.data = data
63+
self.layout = type("Layout", (), {})()
64+
self.layout.scene = type("Scene", (), {})()
65+
self.layout.scene.aspectratio = type("Aspect", (), {})()
66+
67+
def update_layout(self, **kwargs):
68+
scene = kwargs.get("scene", {})
69+
aspect = scene.get("aspectratio", {})
70+
self.layout.scene.aspectratio.z = aspect.get("z")
71+
72+
def write_image(self, *_args, **_kwargs):
73+
return None
74+
75+
monkeypatch.setattr(fth.go, "Mesh3d", _Mesh3d, raising=False)
76+
monkeypatch.setattr(fth.go, "Figure", _Figure, raising=False)
77+
78+
79+
def test_plot_climate_filled_hull_attaches_scalars_by_layer_order(_plotly_stub):
80+
hull = _synthetic_hull(days=3, verts_per_layer=4)
81+
summary = HullClimateSummary(
82+
values_inside=np.array([1.0, 2.0, 3.0]),
83+
values_outside=np.array([0.0, 0.0]),
84+
per_day_mean=pd.Series([1.0, 5.0, 9.0], index=pd.date_range("2020-07-01", periods=3, freq="D")),
85+
)
86+
87+
fig = plot_climate_filled_hull(hull, summary, color_limits=None)
88+
got = np.asarray(fig.data[0].intensity, dtype=float)
89+
expected = np.repeat(np.array([1.0, 5.0, 9.0]), 4)
90+
np.testing.assert_allclose(got, expected)
91+
92+
93+
def test_plot_climate_filled_hull_debug_z_mode_and_z_exaggeration(_plotly_stub):
94+
hull = _synthetic_hull(days=3, verts_per_layer=4)
95+
summary = HullClimateSummary(
96+
values_inside=np.array([1.0]),
97+
values_outside=np.array([0.0]),
98+
per_day_mean=pd.Series([2.0, 2.1, 2.2], index=pd.date_range("2020-07-01", periods=3, freq="D")),
99+
)
100+
101+
fig = plot_climate_filled_hull(
102+
hull,
103+
summary,
104+
scalar_debug_mode="z",
105+
z_exaggeration=2.8,
106+
color_limits=None,
107+
)
108+
got = np.asarray(fig.data[0].intensity, dtype=float)
109+
np.testing.assert_allclose(got, hull.verts_km[:, 2])
110+
assert float(fig.layout.scene.aspectratio.z) == 2.8

tests/test_lexcube_viz.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
import pytest
77
import xarray as xr
88

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

1117
from cubedynamics.viz.lexcube_viz import show_cube_lexcube
1218

0 commit comments

Comments
 (0)