Skip to content

Commit ac0ee58

Browse files
authored
Merge pull request #209 from CU-ESIIL/codex/fix-custom-fire-hull-viewer
Fix fire hull climate scalar mapping to respect hull time layers
2 parents 13d46c1 + 0e1f3d0 commit ac0ee58

File tree

2 files changed

+79
-20
lines changed

2 files changed

+79
-20
lines changed

src/cubedynamics/fire_time_hull.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,32 +1095,51 @@ def plot_climate_filled_hull(
10951095
verts = np.asarray(hull.verts_km)
10961096
tris = np.asarray(hull.tris)
10971097

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.
1098+
n_vertices = int(verts.shape[0])
1099+
layer_days = np.unique(np.asarray(hull.t_days_vert, dtype=float))
1100+
n_layers = int(layer_days.size)
1101+
if n_layers <= 0:
1102+
raise ValueError("TimeHull has no time layers to color.")
1103+
if n_vertices % n_layers != 0:
1104+
raise ValueError(
1105+
"Cannot align hull vertices to time layers: "
1106+
f"{n_vertices} vertices not divisible by {n_layers} layers."
1107+
)
1108+
verts_per_layer = n_vertices // n_layers
1109+
1110+
# Scalar values are attached per-vertex, expanded from per-layer climate
1111+
# means using the same ring/time layer order used to build verts_km.
11011112
intensities = None
11021113
if isinstance(summary, HullClimateSummary) and summary.per_day_mean.size:
1103-
day_vals = np.asarray(summary.per_day_mean.sort_index().values, dtype=float)
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
1110-
if len(day_vals) < M:
1111-
day_vals = np.pad(day_vals, (0, M - len(day_vals)), mode="edge")
1112-
elif len(day_vals) > M:
1113-
day_vals = day_vals[:M]
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-
)
1114+
per_day = summary.per_day_mean.copy()
1115+
per_day.index = normalize_dates(per_day.index)
1116+
per_day = per_day.sort_index()
1117+
1118+
# Map each hull time layer (event_day-like z) to a calendar date so
1119+
# climate values are selected by the true layer date, not by truncating
1120+
# an arbitrarily sorted climate series.
1121+
layer_date_index = normalize_dates(
1122+
hull.event.t0 + pd.to_timedelta(layer_days - np.nanmin(layer_days), unit="D")
1123+
)
1124+
layer_vals = per_day.reindex(layer_date_index)
1125+
if layer_vals.isna().any():
1126+
# Prefer forward-fill because perimeters are cumulative in time; if
1127+
# the earliest layer is missing, backfill once to avoid all-NaN.
1128+
layer_vals = layer_vals.ffill().bfill()
1129+
day_vals = np.asarray(layer_vals.values, dtype=float)
1130+
intensities = np.repeat(day_vals, verts_per_layer)
1131+
if intensities.shape[0] != n_vertices:
1132+
raise ValueError(
1133+
"Hull climate scalar/vertex mismatch: "
1134+
f"{intensities.shape[0]} intensities for {n_vertices} vertices."
1135+
)
11201136

11211137
# Developer diagnostic mode: color by z/time to confirm vertical banding.
11221138
if scalar_debug_mode == "z":
11231139
intensities = verts[:, 2].astype(float)
1140+
elif scalar_debug_mode == "slice":
1141+
# Diagnostic mode to verify explicit slice->vertex alignment.
1142+
intensities = np.repeat(np.arange(n_layers, dtype=float), verts_per_layer)
11241143

11251144
if intensities is not None:
11261145
finite = intensities[np.isfinite(intensities)]
@@ -1147,11 +1166,15 @@ def plot_climate_filled_hull(
11471166
"verts_shape": tuple(verts.shape),
11481167
"tris_shape": tuple(tris.shape),
11491168
"scalar_shape": tuple(intensities.shape),
1169+
"scalar_dtype": str(intensities.dtype),
11501170
"nan_count": int(np.isnan(intensities).sum()),
1171+
"unique_count": int(np.unique(intensities[np.isfinite(intensities)]).size),
11511172
"min": float(np.nanmin(finite)) if finite.size else float("nan"),
11521173
"max": float(np.nanmax(finite)) if finite.size else float("nan"),
11531174
"percentiles": dict(zip([str(p) for p in pct], pct_vals)),
11541175
"mode": scalar_debug_mode or "climate",
1176+
"n_layers": n_layers,
1177+
"verts_per_layer": verts_per_layer,
11551178
},
11561179
)
11571180

tests/test_fire_hull_viewer_scalars.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ def test_plot_climate_filled_hull_attaches_scalars_by_layer_order(_plotly_stub):
9090
np.testing.assert_allclose(got, expected)
9191

9292

93+
def test_plot_climate_filled_hull_aligns_layers_by_event_dates_not_series_prefix(_plotly_stub):
94+
hull = _synthetic_hull(days=3, verts_per_layer=4)
95+
# Include buffer days before/after event window to emulate cube-first fire_plot.
96+
summary = HullClimateSummary(
97+
values_inside=np.array([1.0, 2.0, 3.0]),
98+
values_outside=np.array([0.0, 0.0]),
99+
per_day_mean=pd.Series(
100+
[100.0, 1.0, 5.0, 9.0, 200.0],
101+
index=pd.date_range("2020-06-30", periods=5, freq="D"),
102+
),
103+
)
104+
105+
fig = plot_climate_filled_hull(hull, summary, color_limits=None)
106+
got = np.asarray(fig.data[0].intensity, dtype=float)
107+
expected = np.repeat(np.array([1.0, 5.0, 9.0]), 4)
108+
np.testing.assert_allclose(got, expected)
109+
110+
93111
def test_plot_climate_filled_hull_debug_z_mode_and_z_exaggeration(_plotly_stub):
94112
hull = _synthetic_hull(days=3, verts_per_layer=4)
95113
summary = HullClimateSummary(
@@ -108,3 +126,21 @@ def test_plot_climate_filled_hull_debug_z_mode_and_z_exaggeration(_plotly_stub):
108126
got = np.asarray(fig.data[0].intensity, dtype=float)
109127
np.testing.assert_allclose(got, hull.verts_km[:, 2])
110128
assert float(fig.layout.scene.aspectratio.z) == 2.8
129+
130+
131+
def test_plot_climate_filled_hull_debug_slice_mode(_plotly_stub):
132+
hull = _synthetic_hull(days=3, verts_per_layer=4)
133+
summary = HullClimateSummary(
134+
values_inside=np.array([1.0]),
135+
values_outside=np.array([0.0]),
136+
per_day_mean=pd.Series([2.0, 2.1, 2.2], index=pd.date_range("2020-07-01", periods=3, freq="D")),
137+
)
138+
139+
fig = plot_climate_filled_hull(
140+
hull,
141+
summary,
142+
scalar_debug_mode="slice",
143+
color_limits=None,
144+
)
145+
got = np.asarray(fig.data[0].intensity, dtype=float)
146+
np.testing.assert_allclose(got, np.repeat(np.array([0.0, 1.0, 2.0]), 4))

0 commit comments

Comments
 (0)