diff --git a/tests/test_fiber_animation.py b/tests/test_fiber_animation.py
new file mode 100644
index 0000000..b99891c
--- /dev/null
+++ b/tests/test_fiber_animation.py
@@ -0,0 +1,210 @@
+"""Unit tests for visualization/fiber_animation.py."""
+
+from __future__ import annotations
+
+import inspect
+import math
+from typing import Any
+
+import plotly.graph_objects as go
+import pytest
+
+from visualization.fiber_animation import create_fiber_ray_figure
+
+
+@pytest.fixture
+def tir_ray_data() -> dict[str, Any]:
+ """Provide a valid Total Internal Reflection (TIR) ray_data payload."""
+ return {
+ "core_radius": 25.0,
+ "cladding_radius": 62.5,
+ "fiber_length": 200.0,
+ "incident_ray": {
+ "x": [-20.0, 0.0],
+ "y": [-10.0, 0.0],
+ },
+ "propagating_ray": {
+ "x": [0.0, 50.0, 150.0, 200.0],
+ "y": [0.0, 25.0, -25.0, 0.0],
+ },
+ "is_tir": True,
+ "n_core": 1.48,
+ "n_cladding": 1.46,
+ "incident_angle_deg": 8.0,
+ "acceptance_angle_deg": 14.0,
+ "critical_angle_deg": 80.6,
+ }
+
+
+@pytest.fixture
+def non_tir_ray_data(tir_ray_data: dict[str, Any]) -> dict[str, Any]:
+ """Provide a valid non-TIR (refraction / cladding loss) ray_data payload."""
+ data = dict(tir_ray_data)
+ data["is_tir"] = False
+ data["incident_angle_deg"] = 22.0
+ data["refracted_rays"] = [
+ {"x": [50.0, 90.0], "y": [25.0, 62.5]},
+ {"x": [150.0, 190.0], "y": [-25.0, -62.5]},
+ ]
+ return data
+
+
+def test_returns_plotly_figure(tir_ray_data: dict[str, Any]) -> None:
+ """1. Verify create_fiber_ray_figure returns a valid Plotly Figure object."""
+ fig = create_fiber_ray_figure(tir_ray_data)
+ assert isinstance(fig, go.Figure)
+
+
+def test_core_and_cladding_shapes_present(tir_ray_data: dict[str, Any]) -> None:
+ """2. Verify core and cladding rectangular geometry shapes are rendered."""
+ fig = create_fiber_ray_figure(tir_ray_data)
+ assert len(fig.layout.shapes) == 2
+ shape_names = {shape.name for shape in fig.layout.shapes}
+ assert "Core Region" in shape_names
+ assert "Cladding Boundary" in shape_names
+
+
+def test_tir_labeling_and_traces(tir_ray_data: dict[str, Any]) -> None:
+ """3. Verify TIR regime trace labels, reflection points, and title text."""
+ fig = create_fiber_ray_figure(tir_ray_data)
+ trace_names = [trace.name for trace in fig.data if trace.name]
+ assert "Incident Launch Ray" in trace_names
+ assert "Guided Ray (TIR)" in trace_names
+ assert "Interface Reflection Points" in trace_names
+ assert "Regime: Total Internal Reflection (TIR)" in fig.layout.title.text
+
+
+def test_non_tir_labeling_and_refracted_rays(
+ non_tir_ray_data: dict[str, Any],
+) -> None:
+ """4. Verify non-TIR regime labels and refracted cladding loss traces."""
+ fig = create_fiber_ray_figure(non_tir_ray_data)
+ trace_names = [trace.name for trace in fig.data if trace.name]
+ assert "Refracted Cladding Loss" in trace_names
+ assert "Attenuated Ray (Refracted)" in trace_names
+ assert "Regime: Refraction / Cladding Loss" in fig.layout.title.text
+
+
+def test_optional_metadata_in_subtitle(tir_ray_data: dict[str, Any]) -> None:
+ """5. Verify optional optical metadata appears formatted in the figure title."""
+ fig = create_fiber_ray_figure(tir_ray_data)
+ title_text = fig.layout.title.text
+ assert "n_core=1.480" in title_text
+ assert "n_clad=1.460" in title_text
+ assert "θ_in=8.0°" in title_text
+ assert "θ_a=14.0°" in title_text
+ assert "θ_c=80.6°" in title_text
+
+
+def test_accepts_tuple_coordinate_sequences(
+ tir_ray_data: dict[str, Any],
+) -> None:
+ """6. Verify ray paths and reflection_points work with (x, y) tuple sequences."""
+ tir_ray_data["incident_ray"] = [(-20.0, -10.0), (0.0, 0.0)]
+ tir_ray_data["propagating_ray"] = [(0.0, 0.0), (50.0, 25.0), (100.0, 0.0)]
+ tir_ray_data["reflection_points"] = [(50.0, 25.0)]
+ fig = create_fiber_ray_figure(tir_ray_data)
+ assert isinstance(fig, go.Figure)
+
+
+def test_rejects_non_mapping_input() -> None:
+ """7. Verify non-mapping ray_data raises TypeError."""
+ with pytest.raises(TypeError, match="ray_data must be a dictionary-like Mapping"):
+ create_fiber_ray_figure(["not", "a", "dict"]) # type: ignore[arg-type]
+
+
+def test_rejects_missing_required_keys(tir_ray_data: dict[str, Any]) -> None:
+ """8. Verify missing any required contract key raises ValueError."""
+ for missing_key in (
+ "core_radius",
+ "cladding_radius",
+ "fiber_length",
+ "incident_ray",
+ "propagating_ray",
+ "is_tir",
+ ):
+ payload = dict(tir_ray_data)
+ del payload[missing_key]
+ with pytest.raises(ValueError, match="Missing required keys in ray_data"):
+ create_fiber_ray_figure(payload)
+
+
+def test_rejects_non_positive_dimensions(tir_ray_data: dict[str, Any]) -> None:
+ """9. Verify zero or negative fiber geometry dimensions raise ValueError."""
+ for key, bad_value in (
+ ("core_radius", 0.0),
+ ("core_radius", -5.0),
+ ("cladding_radius", -10.0),
+ ("fiber_length", 0.0),
+ ):
+ payload = dict(tir_ray_data, **{key: bad_value})
+ with pytest.raises(ValueError, match="must be a finite positive number"):
+ create_fiber_ray_figure(payload)
+
+
+def test_rejects_cladding_not_greater_than_core(
+ tir_ray_data: dict[str, Any],
+) -> None:
+ """10. Verify cladding_radius <= core_radius raises ValueError."""
+ tir_ray_data["cladding_radius"] = tir_ray_data["core_radius"]
+ with pytest.raises(
+ ValueError,
+ match="cladding_radius must be strictly greater than core_radius",
+ ):
+ create_fiber_ray_figure(tir_ray_data)
+
+
+def test_rejects_nan_and_infinity_inputs(tir_ray_data: dict[str, Any]) -> None:
+ """11. Verify NaN and infinity values in dimensions, rays, or metadata raise ValueError."""
+ for bad_num in (math.nan, math.inf, -math.inf):
+ bad_dim = dict(tir_ray_data, core_radius=bad_num)
+ with pytest.raises(ValueError, match="finite"):
+ create_fiber_ray_figure(bad_dim)
+
+ bad_ray = dict(
+ tir_ray_data,
+ incident_ray={"x": [0.0, bad_num], "y": [0.0, 1.0]},
+ )
+ with pytest.raises(ValueError, match="finite"):
+ create_fiber_ray_figure(bad_ray)
+
+ bad_meta = dict(tir_ray_data, n_core=bad_num)
+ with pytest.raises(ValueError, match="finite"):
+ create_fiber_ray_figure(bad_meta)
+
+
+def test_rejects_invalid_or_inconsistent_ray_coordinates(
+ tir_ray_data: dict[str, Any],
+) -> None:
+ """12. Verify mismatched x/y lengths or fewer than 2 points raise ValueError."""
+ mismatched = dict(
+ tir_ray_data,
+ propagating_ray={"x": [0.0, 50.0, 100.0], "y": [0.0, 25.0]},
+ )
+ with pytest.raises(ValueError, match="equal length"):
+ create_fiber_ray_figure(mismatched)
+
+ too_short = dict(
+ tir_ray_data,
+ incident_ray={"x": [0.0], "y": [0.0]},
+ )
+ with pytest.raises(ValueError, match="at least 2 coordinate points"):
+ create_fiber_ray_figure(too_short)
+
+
+def test_invalid_optional_metadata_and_architecture_isolation(
+ tir_ray_data: dict[str, Any],
+) -> None:
+ """13. Verify invalid optional metadata is rejected and module is isolated from Streamlit."""
+ bad_indices = dict(tir_ray_data, n_core=1.45, n_cladding=1.48)
+ with pytest.raises(
+ ValueError,
+ match="Core refractive index must be strictly greater than cladding refractive index",
+ ):
+ create_fiber_ray_figure(bad_indices)
+
+ import visualization.fiber_animation as mod
+
+ source = inspect.getsource(mod)
+ assert "import streamlit" not in source
+ assert "from streamlit" not in source
diff --git a/visualization/fiber_animation.py b/visualization/fiber_animation.py
new file mode 100644
index 0000000..d5fb572
--- /dev/null
+++ b/visualization/fiber_animation.py
@@ -0,0 +1,441 @@
+"""Optical fiber ray propagation visualization module for FiberLab.
+
+This module strictly separates visualization from physics computations.
+All ray trajectory coordinates, angles, and reflection classifications must be
+pre-computed by the physics/simulation layer and passed via `ray_data`.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+import plotly.graph_objects as go
+
+REQUIRED_KEYS: frozenset[str] = frozenset(
+ {
+ "core_radius",
+ "cladding_radius",
+ "fiber_length",
+ "incident_ray",
+ "propagating_ray",
+ "is_tir",
+ }
+)
+
+
+def _is_valid_number(value: Any) -> bool:
+ """Return True if value is a finite int or float (excluding bool)."""
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return False
+ return math.isfinite(float(value))
+
+
+def _validate_ray_points(ray: Any, ray_key: str) -> tuple[list[float], list[float]]:
+ """Validate and normalize a ray coordinate structure into (x_list, y_list)."""
+ if isinstance(ray, Mapping):
+ if "x" not in ray or "y" not in ray:
+ raise ValueError(f"'{ray_key}' mapping must contain both 'x' and 'y' keys.")
+ raw_x = ray["x"]
+ raw_y = ray["y"]
+ if (
+ not isinstance(raw_x, Sequence)
+ or isinstance(raw_x, (str, bytes))
+ or not isinstance(raw_y, Sequence)
+ or isinstance(raw_y, (str, bytes))
+ ):
+ raise TypeError(f"'{ray_key}' x and y fields must be sequences of numbers.")
+ if len(raw_x) != len(raw_y):
+ raise ValueError(f"'{ray_key}' x and y sequences must have equal length.")
+ if len(raw_x) < 2:
+ raise ValueError(f"'{ray_key}' must contain at least 2 coordinate points.")
+ for coord in (*raw_x, *raw_y):
+ if not _is_valid_number(coord):
+ raise ValueError(
+ f"'{ray_key}' coordinates must be finite real numbers."
+ )
+ return [float(v) for v in raw_x], [float(v) for v in raw_y]
+
+ if isinstance(ray, Sequence) and not isinstance(ray, (str, bytes)):
+ if len(ray) < 2:
+ raise ValueError(f"'{ray_key}' must contain at least 2 coordinate points.")
+ xs: list[float] = []
+ ys: list[float] = []
+ for pt in ray:
+ if (
+ not isinstance(pt, Sequence)
+ or isinstance(pt, (str, bytes))
+ or len(pt) != 2
+ or not _is_valid_number(pt[0])
+ or not _is_valid_number(pt[1])
+ ):
+ raise ValueError(f"'{ray_key}' points must be finite (x, y) pairs.")
+ xs.append(float(pt[0]))
+ ys.append(float(pt[1]))
+ return xs, ys
+
+ raise TypeError(
+ f"'{ray_key}' must be a dict with 'x' and 'y' sequences or a sequence of (x, y) pairs."
+ )
+
+
+def _validate_ray_data(ray_data: Mapping[str, Any]) -> None:
+ """Validate the incoming ray_data payload against the module contract."""
+ if not isinstance(ray_data, Mapping):
+ raise TypeError("ray_data must be a dictionary-like Mapping.")
+
+ missing = REQUIRED_KEYS - set(ray_data.keys())
+ if missing:
+ raise ValueError(f"Missing required keys in ray_data: {sorted(missing)}")
+
+ for key in ("core_radius", "cladding_radius", "fiber_length"):
+ val = ray_data[key]
+ if not _is_valid_number(val) or float(val) <= 0:
+ raise ValueError(f"'{key}' must be a finite positive number.")
+
+ if float(ray_data["cladding_radius"]) <= float(ray_data["core_radius"]):
+ raise ValueError("cladding_radius must be strictly greater than core_radius.")
+
+ if not isinstance(ray_data["is_tir"], bool):
+ raise TypeError("'is_tir' must be a boolean value.")
+
+ _validate_ray_points(ray_data["incident_ray"], "incident_ray")
+ _validate_ray_points(ray_data["propagating_ray"], "propagating_ray")
+
+ # Optional refracted ray validation
+ if "refracted_ray" in ray_data and ray_data["refracted_ray"] is not None:
+ _validate_ray_points(ray_data["refracted_ray"], "refracted_ray")
+
+ if "refracted_rays" in ray_data and ray_data["refracted_rays"] is not None:
+ refracted_rays = ray_data["refracted_rays"]
+ if not isinstance(refracted_rays, Sequence) or isinstance(
+ refracted_rays, (str, bytes)
+ ):
+ raise ValueError("'refracted_rays' must be a sequence of ray paths.")
+ for idx, r_ray in enumerate(refracted_rays):
+ _validate_ray_points(r_ray, f"refracted_rays[{idx}]")
+
+ if "reflection_points" in ray_data and ray_data["reflection_points"] is not None:
+ pts = ray_data["reflection_points"]
+ if isinstance(pts, Mapping):
+ if "x" not in pts or "y" not in pts or len(pts["x"]) != len(pts["y"]):
+ raise ValueError(
+ "'reflection_points' mapping must have equal-length 'x' and 'y' sequences."
+ )
+ for coord in (*pts["x"], *pts["y"]):
+ if not _is_valid_number(coord):
+ raise ValueError(
+ "'reflection_points' coordinates must be finite numbers."
+ )
+ elif isinstance(pts, Sequence) and not isinstance(pts, (str, bytes)):
+ for pt in pts:
+ if (
+ not isinstance(pt, Sequence)
+ or len(pt) != 2
+ or not _is_valid_number(pt[0])
+ or not _is_valid_number(pt[1])
+ ):
+ raise ValueError(
+ "'reflection_points' must contain finite (x, y) pairs."
+ )
+ else:
+ raise ValueError(
+ "'reflection_points' must be a coordinate mapping or sequence of (x, y) pairs."
+ )
+
+ # Optional scalar metadata validation
+ for opt_key in (
+ "n_core",
+ "n_cladding",
+ "core_refractive_index",
+ "cladding_refractive_index",
+ "incident_angle_deg",
+ "acceptance_angle_deg",
+ "critical_angle_deg",
+ ):
+ if (
+ opt_key in ray_data
+ and ray_data[opt_key] is not None
+ and not _is_valid_number(ray_data[opt_key])
+ ):
+ raise ValueError(
+ f"Optional metadata '{opt_key}' must be a finite real number."
+ )
+
+ n_core = ray_data.get("n_core", ray_data.get("core_refractive_index"))
+ n_clad = ray_data.get("n_cladding", ray_data.get("cladding_refractive_index"))
+ if n_core is not None and float(n_core) <= 0:
+ raise ValueError("Core refractive index must be positive.")
+ if n_clad is not None and float(n_clad) <= 0:
+ raise ValueError("Cladding refractive index must be positive.")
+ if n_core is not None and n_clad is not None and float(n_core) <= float(n_clad):
+ raise ValueError(
+ "Core refractive index must be strictly greater than cladding refractive index."
+ )
+
+
+def _add_fiber_structure(
+ fig: go.Figure,
+ core_r: float,
+ clad_r: float,
+ length: float,
+ inc_x_min: float,
+) -> None:
+ """Add core, cladding, and interfaces to the Plotly figure."""
+ x_min = min(-0.1 * length, inc_x_min * 1.05 if inc_x_min < 0 else -0.1 * length)
+ x_max = length
+
+ fig.add_shape(
+ type="rect",
+ x0=0,
+ y0=-clad_r,
+ x1=x_max,
+ y1=clad_r,
+ fillcolor="rgba(220, 235, 252, 0.35)",
+ line={"color": "rgba(100, 149, 237, 0.8)", "width": 1.5, "dash": "dot"},
+ layer="below",
+ name="Cladding Boundary",
+ )
+
+ fig.add_shape(
+ type="rect",
+ x0=0,
+ y0=-core_r,
+ x1=x_max,
+ y1=core_r,
+ fillcolor="rgba(173, 216, 230, 0.45)",
+ line={"color": "rgba(70, 130, 180, 0.9)", "width": 1.5},
+ layer="below",
+ name="Core Region",
+ )
+
+ fig.add_trace(
+ go.Scatter(
+ x=[0, x_max],
+ y=[core_r, core_r],
+ mode="lines",
+ line={"color": "steelblue", "width": 1, "dash": "dash"},
+ name="Upper Core Interface",
+ showlegend=True,
+ )
+ )
+ fig.add_trace(
+ go.Scatter(
+ x=[0, x_max],
+ y=[-core_r, -core_r],
+ mode="lines",
+ line={"color": "steelblue", "width": 1, "dash": "dash"},
+ name="Lower Core Interface",
+ showlegend=True,
+ )
+ )
+
+ fig.add_trace(
+ go.Scatter(
+ x=[x_min, x_max],
+ y=[0, 0],
+ mode="lines",
+ line={"color": "gray", "width": 1, "dash": "dashdot"},
+ name="Fiber Optical Axis",
+ showlegend=True,
+ )
+ )
+
+
+def _extract_reflection_points(
+ ray_data: Mapping[str, Any],
+ prop_x: list[float],
+ prop_y: list[float],
+ core_r: float,
+) -> tuple[list[float], list[float]]:
+ """Extract reflection points from explicit metadata or propagating ray boundary hits."""
+ pts = ray_data.get("reflection_points")
+ if pts is not None:
+ if isinstance(pts, Mapping):
+ return [float(v) for v in pts["x"]], [float(v) for v in pts["y"]]
+ return [float(p[0]) for p in pts], [float(p[1]) for p in pts]
+
+ bounce_x: list[float] = []
+ bounce_y: list[float] = []
+ for x, y in zip(prop_x, prop_y):
+ if abs(abs(y) - core_r) < 1e-4:
+ bounce_x.append(x)
+ bounce_y.append(y)
+ return bounce_x, bounce_y
+
+
+def _add_ray_paths(
+ fig: go.Figure,
+ inc_x: list[float],
+ inc_y: list[float],
+ prop_x: list[float],
+ prop_y: list[float],
+ bounce_x: list[float],
+ bounce_y: list[float],
+ is_tir: bool,
+) -> None:
+ """Draw incident, internal reflection/refraction, and bounce markers."""
+ fig.add_trace(
+ go.Scatter(
+ x=inc_x,
+ y=inc_y,
+ mode="lines+markers",
+ line={"color": "darkorange", "width": 2.5},
+ marker={"size": 4},
+ name="Incident Launch Ray",
+ hovertemplate="Incident: (z=%{x:.2f} μm, r=%{y:.2f} μm)",
+ )
+ )
+
+ prop_color = "crimson" if is_tir else "goldenrod"
+ prop_label = "Guided Ray (TIR)" if is_tir else "Attenuated Ray (Refracted)"
+ fig.add_trace(
+ go.Scatter(
+ x=prop_x,
+ y=prop_y,
+ mode="lines+markers",
+ line={"color": prop_color, "width": 2.5},
+ marker={"size": 5, "symbol": "circle"},
+ name=prop_label,
+ hovertemplate="Core Ray: (z=%{x:.2f} μm, r=%{y:.2f} μm)",
+ )
+ )
+
+ if bounce_x:
+ fig.add_trace(
+ go.Scatter(
+ x=bounce_x,
+ y=bounce_y,
+ mode="markers",
+ marker={
+ "size": 8,
+ "color": "gold",
+ "symbol": "diamond",
+ "line": {"color": "black", "width": 1},
+ },
+ name="Interface Reflection Points",
+ hovertemplate="Reflection Point: (z=%{x:.2f} μm, r=%{y:.2f} μm)",
+ )
+ )
+
+
+def _add_refracted_rays(
+ fig: go.Figure,
+ refracted_rays: Sequence[Any],
+) -> None:
+ """Draw cladding-refracted rays when total internal reflection is not met."""
+ for idx, r_ray in enumerate(refracted_rays):
+ rx, ry = _validate_ray_points(r_ray, f"refracted_rays[{idx}]")
+ fig.add_trace(
+ go.Scatter(
+ x=rx,
+ y=ry,
+ mode="lines",
+ line={"color": "mediumpurple", "width": 1.8, "dash": "dash"},
+ name="Refracted Cladding Loss" if idx == 0 else None,
+ showlegend=(idx == 0),
+ hovertemplate="Refracted Loss: (z=%{x:.2f} μm, r=%{y:.2f} μm)",
+ )
+ )
+
+
+def create_fiber_ray_figure(ray_data: Mapping[str, Any]) -> go.Figure:
+ """Build a complete 2D Plotly Figure illustrating optical fiber ray propagation."""
+ _validate_ray_data(ray_data)
+
+ core_r = float(ray_data["core_radius"])
+ clad_r = float(ray_data["cladding_radius"])
+ length = float(ray_data["fiber_length"])
+ inc_x, inc_y = _validate_ray_points(ray_data["incident_ray"], "incident_ray")
+ prop_x, prop_y = _validate_ray_points(
+ ray_data["propagating_ray"], "propagating_ray"
+ )
+ is_tir = ray_data["is_tir"]
+
+ refracted_list: list[Any] = []
+ if ray_data.get("refracted_ray") is not None:
+ refracted_list.append(ray_data["refracted_ray"])
+ if ray_data.get("refracted_rays") is not None:
+ refracted_list.extend(ray_data["refracted_rays"])
+
+ fig = go.Figure()
+ inc_x_min = min(inc_x)
+
+ _add_fiber_structure(
+ fig=fig,
+ core_r=core_r,
+ clad_r=clad_r,
+ length=length,
+ inc_x_min=inc_x_min,
+ )
+
+ bounce_x, bounce_y = _extract_reflection_points(
+ ray_data=ray_data,
+ prop_x=prop_x,
+ prop_y=prop_y,
+ core_r=core_r,
+ )
+
+ _add_ray_paths(
+ fig=fig,
+ inc_x=inc_x,
+ inc_y=inc_y,
+ prop_x=prop_x,
+ prop_y=prop_y,
+ bounce_x=bounce_x,
+ bounce_y=bounce_y,
+ is_tir=is_tir,
+ )
+
+ if refracted_list:
+ _add_refracted_rays(fig, refracted_list)
+
+ subtitle_parts: list[str] = [
+ "Regime: Total Internal Reflection (TIR)"
+ if is_tir
+ else "Regime: Refraction / Cladding Loss"
+ ]
+ n_core = ray_data.get("n_core", ray_data.get("core_refractive_index"))
+ n_clad = ray_data.get("n_cladding", ray_data.get("cladding_refractive_index"))
+ if n_core is not None and n_clad is not None:
+ subtitle_parts.append(f"n_core={float(n_core):.3f}, n_clad={float(n_clad):.3f}")
+ if ray_data.get("incident_angle_deg") is not None:
+ subtitle_parts.append(f"θ_in={float(ray_data['incident_angle_deg']):.1f}°")
+ if ray_data.get("acceptance_angle_deg") is not None:
+ subtitle_parts.append(f"θ_a={float(ray_data['acceptance_angle_deg']):.1f}°")
+ if ray_data.get("critical_angle_deg") is not None:
+ subtitle_parts.append(f"θ_c={float(ray_data['critical_angle_deg']):.1f}°")
+
+ subtitle_text = " | ".join(subtitle_parts)
+
+ fig.update_layout(
+ title={
+ "text": f"Optical Fiber Ray Propagation
{subtitle_text}",
+ "x": 0.5,
+ "xanchor": "center",
+ },
+ xaxis={
+ "title": "Axial Distance z (μm)",
+ "range": [min(-0.1 * length, inc_x_min * 1.1), length * 1.05],
+ "zeroline": True,
+ "showgrid": True,
+ },
+ yaxis={
+ "title": "Radial Position r (μm)",
+ "range": [-clad_r * 1.25, clad_r * 1.25],
+ "zeroline": False,
+ "showgrid": True,
+ },
+ legend={
+ "orientation": "h",
+ "yanchor": "bottom",
+ "y": 1.02,
+ "xanchor": "center",
+ "x": 0.5,
+ },
+ template="plotly_white",
+ margin={"l": 60, "r": 40, "t": 90, "b": 60},
+ )
+
+ return fig