diff --git a/src/cubedynamics/fire_time_hull.py b/src/cubedynamics/fire_time_hull.py index 407b1eb..9430b2e 100644 --- a/src/cubedynamics/fire_time_hull.py +++ b/src/cubedynamics/fire_time_hull.py @@ -1092,14 +1092,50 @@ def plot_climate_filled_hull( scalar_debug_mode: Optional[str] = None, debug: bool = False, ) -> go.Figure: - def _build_vertex_slice_index() -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: - t_days_vert = np.asarray(hull.t_days_vert, dtype=float) - if t_days_vert.shape[0] != verts.shape[0]: + def _sanitize_mesh_inputs( + verts_in: np.ndarray, + tris_in: np.ndarray, + t_days_in: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + verts_arr = np.asarray(verts_in, dtype=float) + tris_arr = np.asarray(tris_in, dtype=int) + t_days_arr = np.asarray(t_days_in, dtype=float) + + if verts_arr.ndim != 2 or verts_arr.shape[1] != 3: + raise ValueError("TimeHull verts_km must have shape (N, 3).") + if tris_arr.ndim != 2 or tris_arr.shape[1] != 3: + raise ValueError("TimeHull tris must have shape (M, 3).") + if t_days_arr.shape[0] != verts_arr.shape[0]: raise ValueError( "Per-vertex time coordinate mismatch: " - f"{t_days_vert.shape[0]} t_days values for {verts.shape[0]} vertices." + f"{t_days_arr.shape[0]} t_days values for {verts_arr.shape[0]} vertices." ) + valid_vertex_mask = np.isfinite(verts_arr).all(axis=1) & np.isfinite(t_days_arr) + if not np.all(valid_vertex_mask): + old_to_new = np.full(verts_arr.shape[0], -1, dtype=int) + old_to_new[valid_vertex_mask] = np.arange(int(valid_vertex_mask.sum())) + verts_arr = verts_arr[valid_vertex_mask] + t_days_arr = t_days_arr[valid_vertex_mask] + remapped = old_to_new[tris_arr] + tri_valid = (remapped >= 0).all(axis=1) + tris_arr = remapped[tri_valid] + + if verts_arr.shape[0] == 0: + raise ValueError("TimeHull contains no finite vertices to render.") + if tris_arr.shape[0] == 0: + raise ValueError("TimeHull contains no valid triangles to render.") + + tri_bounds_ok = (tris_arr >= 0).all() and (tris_arr < verts_arr.shape[0]).all() + if not tri_bounds_ok: + tri_valid = ((tris_arr >= 0) & (tris_arr < verts_arr.shape[0])).all(axis=1) + tris_arr = tris_arr[tri_valid] + if tris_arr.shape[0] == 0: + raise ValueError("TimeHull contains no in-bounds triangles to render.") + + return verts_arr, tris_arr, t_days_arr + + def _build_vertex_slice_index() -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: layer_days, vertex_slice_index = np.unique(t_days_vert, return_inverse=True) n_layers_local = int(layer_days.size) if n_layers_local <= 0: @@ -1166,8 +1202,7 @@ def _scalar_stats(values: np.ndarray) -> dict[str, Any]: "approx_unique_count": int(np.unique(finite).size), } - verts = np.asarray(hull.verts_km) - tris = np.asarray(hull.tris) + verts, tris, t_days_vert = _sanitize_mesh_inputs(hull.verts_km, hull.tris, hull.t_days_vert) n_vertices = int(verts.shape[0]) layer_days, vertex_slice_index, alignment = _build_vertex_slice_index() n_layers = int(layer_days.size) diff --git a/tests/test_fire_hull_viewer_scalars.py b/tests/test_fire_hull_viewer_scalars.py index 8b74dfa..630d6e6 100644 --- a/tests/test_fire_hull_viewer_scalars.py +++ b/tests/test_fire_hull_viewer_scalars.py @@ -194,3 +194,39 @@ def test_plot_climate_filled_hull_debug_output_reports_stats_and_alignment(_plot assert "face_alignment" in out assert re.search(r"'min_slice_span':\s*1", out) assert re.search(r"'max_slice_span':\s*1", out) + + +def test_plot_climate_filled_hull_drops_nonfinite_vertices_and_remaps_tris(_plotly_stub): + hull = _synthetic_hull(days=3, verts_per_layer=4) + verts = hull.verts_km.copy() + verts[0, 0] = np.nan + hull = TimeHull( + event=hull.event, + verts_km=verts, + tris=hull.tris, + t_days_vert=hull.t_days_vert, + t_norm_vert=hull.t_norm_vert, + metrics=hull.metrics, + ) + summary = HullClimateSummary( + values_inside=np.array([1.0]), + values_outside=np.array([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) + + x = np.asarray(fig.data[0].x, dtype=float) + y = np.asarray(fig.data[0].y, dtype=float) + z = np.asarray(fig.data[0].z, dtype=float) + i = np.asarray(fig.data[0].i, dtype=int) + j = np.asarray(fig.data[0].j, dtype=int) + k = np.asarray(fig.data[0].k, dtype=int) + intensity = np.asarray(fig.data[0].intensity, dtype=float) + + assert x.shape[0] == verts.shape[0] - 1 + assert np.isfinite(x).all() and np.isfinite(y).all() and np.isfinite(z).all() + assert i.size > 0 and j.size > 0 and k.size > 0 + assert (i >= 0).all() and (j >= 0).all() and (k >= 0).all() + assert (i < x.shape[0]).all() and (j < x.shape[0]).all() and (k < x.shape[0]).all() + assert intensity.shape[0] == x.shape[0]