Skip to content

Conversation

@marcorudolphflex
Copy link
Contributor

@marcorudolphflex marcorudolphflex commented Nov 5, 2025

  • Implement TriangleMesh _compute_derivatives, wiring it into the existing permittivity-gradient machinery so mesh surfaces participate in adjoint workflows.
  • Sample triangle faces adaptively within simulation bounds, evaluate boundary gradients via DerivativeInfo.evaluate_gradient_at_points, and accumulate sensitivities back onto per-vertex surface-mesh data arrays.
  • Cache barycentric sampling patterns, reuse interpolators when available, and add early-outs for degenerate meshes and out-of-domain geometries to avoid unnecessary work.
  • Expand autograd integration tests to include a TriangleMesh structure and ensure forward workflows still trace gradients for the full structure set.
  • Add a dedicated test_autograd_triangle_mesh.py suite that checks analytic gradients, directional derivatives, permutation invariance, constant-field force balance, out-of-bounds zeroing, and non-watertight handling.
  • Document TriangleMesh autograd support in the geometry API docs, including performance guidance for large meshes.
  • Enhance TriangleMesh sampling to consider both face area and longest edge length, preventing undersampling of skinny, high-aspect triangles.
  • Extend the watertight mesh fixtures with a slender tetrahedron case to exercise the new edge-aware subdivision logic inside the gradient tests.
  • Add a regression that asserts the subdivision count always meets the minimum implied by the longest edge, guarding against future regressions in sampling density.

Greptile Overview

Greptile Summary

This PR implements autograd support for TriangleMesh geometries, enabling gradient-based optimization of arbitrary mesh surfaces in inverse design workflows.

Key changes:

  • Implemented _compute_derivatives in TriangleMesh that samples triangle surfaces adaptively, evaluates boundary gradients via the existing permittivity-gradient infrastructure, and accumulates sensitivities onto per-vertex data arrays using barycentric interpolation
  • Enhanced triangle subdivision logic to consider both face area and longest edge length, preventing undersampling of skinny high-aspect triangles
  • Added specialized handling for collapsed simulation axes (2D) by computing plane-triangle intersections and sampling along intersection segments
  • Extended DataArray.__init__ to convert numpy object arrays containing autograd boxes, enabling traced geometry parameters to flow through mesh construction
  • Added comprehensive test coverage including analytic gradient validation, directional derivatives, permutation invariance, constant-field force balance, and comparison against PolySlab gradients for equivalent rectangular geometries
  • Implemented to_stl export method for TriangleMesh with binary/ASCII format support

Issues found:

  • Three floating-point equality comparisons should use tolerance-based checks (np.isclose or <=) instead of ==
  • CHANGELOG entry missing backticks around code identifier

Confidence Score: 4/5

  • This PR is safe to merge with minor syntax fixes needed for floating-point comparisons
  • The implementation is mathematically sound with comprehensive test coverage validating gradients against analytic solutions and finite differences. The adaptive sampling strategy properly handles edge cases like collapsed axes, out-of-bounds geometries, and degenerate triangles. However, three floating-point equality checks need fixing to avoid potential numerical instability, and the CHANGELOG needs a formatting update.
  • Pay attention to mesh.py:975 and test_autograd_triangle_mesh.py:149,190 for floating-point comparison fixes

Important Files Changed

File Analysis

Filename Score Overview
tidy3d/components/geometry/mesh.py 4/5 Core TriangleMesh autograd implementation with comprehensive surface sampling, gradient accumulation, and special handling for collapsed axes. One floating-point comparison issue.
tidy3d/components/data/data_array.py 5/5 Added autograd box conversion for numpy object arrays to properly handle traced geometry parameters during differentiation.
tests/test_components/autograd/test_autograd_triangle_mesh.py 4/5 Comprehensive test suite covering analytic gradients, directional derivatives, permutation invariance, and edge cases. Two floating-point comparison issues found.
tests/test_components/autograd/numerical/test_autograd_polyslab_trianglemesh_numerical.py 5/5 Validates TriangleMesh gradients against PolySlab and finite-difference for rectangular geometries across 2D/3D configurations.

Sequence Diagram

sequenceDiagram
    participant User
    participant Autograd
    participant TriangleMesh
    participant DerivativeInfo
    participant Sampling
    participant Interpolator
    participant GradAccum

    User->>Autograd: Define objective with TriangleMesh parameters
    Autograd->>TriangleMesh: Forward pass (construct geometry)
    TriangleMesh->>TriangleMesh: _triangles_to_trimesh (strip autograd boxes)
    Note over TriangleMesh: Convert ArrayBox to static arrays<br/>for trimesh compatibility
    
    Autograd->>TriangleMesh: Backward pass (_compute_derivatives)
    TriangleMesh->>TriangleMesh: Validate mesh_dataset exists
    TriangleMesh->>DerivativeInfo: Check simulation_bounds
    
    alt Mesh outside simulation
        TriangleMesh-->>Autograd: Return zero gradients
    else Mesh intersects simulation
        TriangleMesh->>Sampling: _collect_surface_samples(triangles, spacing, bounds)
        
        loop For each triangle face
            Sampling->>Sampling: Compute area and normal
            alt Collapsed axis (2D simulation)
                Sampling->>Sampling: _triangle_plane_segments (intersect with plane)
                Sampling->>Sampling: Sample along intersection segments
            else Full 3D
                Sampling->>Sampling: _subdivision_count (adaptive based on area + edges)
                Sampling->>Sampling: _get_barycentric_samples (cached patterns)
            end
            Sampling->>Sampling: Filter samples inside simulation bounds
        end
        
        Sampling-->>TriangleMesh: Return {points, normals, perps, weights, faces, barycentric}
        
        TriangleMesh->>DerivativeInfo: create_interpolators if needed
        DerivativeInfo-->>Interpolator: Build field interpolators
        
        TriangleMesh->>DerivativeInfo: evaluate_gradient_at_points(samples, interpolators)
        DerivativeInfo->>Interpolator: Interpolate fields at sample points
        Interpolator-->>DerivativeInfo: Return gradient values g
        DerivativeInfo-->>TriangleMesh: Boundary gradient g
        
        TriangleMesh->>GradAccum: Accumulate per-vertex contributions
        loop For each vertex (0, 1, 2)
            GradAccum->>GradAccum: scaled = (weights * g * normals) * barycentric[:, vertex]
            GradAccum->>GradAccum: np.add.at(triangle_grads[:, vertex], faces, scaled)
        end
        
        GradAccum-->>TriangleMesh: triangle_grads array
        TriangleMesh-->>Autograd: vjps[("mesh_dataset", "surface_mesh")]
    end
    
    Autograd-->>User: Final gradients w.r.t. mesh vertices
Loading

Context used:

  • Rule from dashboard - Use a tolerance-based comparison (e.g., np.isclose) for floating-point numbers instead of direct equ... (source)
  • Rule from dashboard - In changelogs, enclose code identifiers (class, function names) in backticks and use specific names ... (source)

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

@marcorudolphflex marcorudolphflex force-pushed the FXC-3693-triangle-mesh-support-for-adjoint branch 2 times, most recently from 862e0c2 to 427fe09 Compare November 6, 2025 16:53
@github-actions
Copy link
Contributor

github-actions bot commented Nov 6, 2025

Diff Coverage

Diff: origin/develop...HEAD, staged and unstaged changes

  • tidy3d/components/data/data_array.py (90.0%): Missing lines 90
  • tidy3d/components/geometry/mesh.py (89.8%): Missing lines 349,739,744,747,773-775,779,847,851,891,902,957,975,996,1043,1075,1096,1106-1107,1113-1114,1117-1118,1122,1125,1130,1135,1153,1202,1223,1257,1263
  • tidy3d/components/geometry/primitives.py (39.3%): Missing lines 248,253,263-264,266-267,269-270,273-274,276-279,281,287,290-292,294-300,302-304,306-309,313

Summary

  • Total: 389 lines
  • Missing: 68 lines
  • Coverage: 82%

tidy3d/components/data/data_array.py

  86             data = TidyArrayBox.from_arraybox(data)
  87         # do the same for xr.Variable or xr.DataArray type
  88         elif isinstance(data, (xr.Variable, xr.DataArray)):
  89             if isbox(data.data) and not is_tidy_box(data.data):
! 90                 data.data = TidyArrayBox.from_arraybox(data.data)
  91         super().__init__(data, *args, **kwargs)
  92 
  93     @staticmethod
  94     def _maybe_convert_object_boxes(data):

tidy3d/components/geometry/mesh.py

  345         # geometry parameters. ``trimesh`` expects plain ``float`` values, so strip any
  346         # tracing information before constructing the mesh.
  347         triangles = np.array(triangles)
  348         if triangles.dtype == np.object_:
! 349             triangles = anp.array(triangles.tolist())
  350         triangles = np.asarray(get_static(triangles), dtype=np.float64)
  351         return trimesh.Trimesh(**trimesh.triangles.to_kwargs(triangles))
  352 
  353     @classmethod

  735         """Compute adjoint derivatives for a ``TriangleMesh`` geometry."""
  736         vjps: AutogradFieldMap = {}
  737 
  738         if not self.mesh_dataset:
! 739             raise DataError("Can't compute derivatives without mesh data.")
  740 
  741         valid_paths = {("mesh_dataset", "surface_mesh")}
  742         for path in derivative_info.paths:
  743             if path not in valid_paths:
! 744                 raise ValueError(f"No derivative defined w.r.t. 'TriangleMesh' field '{path}'.")
  745 
  746         if ("mesh_dataset", "surface_mesh") not in derivative_info.paths:
! 747             return vjps
  748 
  749         triangles = np.asarray(self.triangles, dtype=config.adjoint.gradient_dtype_float)
  750 
  751         # early exit if geometry is completely outside simulation bounds

  769             sim_max=sim_max,
  770         )
  771 
  772         if samples["points"].shape[0] == 0:
! 773             zeros = np.zeros_like(triangles)
! 774             vjps[("mesh_dataset", "surface_mesh")] = zeros
! 775             return vjps
  776 
  777         interpolators = derivative_info.interpolators
  778         if interpolators is None:
! 779             interpolators = derivative_info.create_interpolators(
  780                 dtype=config.adjoint.gradient_dtype_float
  781             )
  782 
  783         g = derivative_info.evaluate_gradient_at_points(

  843         warning_msg = "Some triangles from the mesh lie outside the simulation bounds - this may lead to inaccurate gradients."
  844         for face_index, tri in enumerate(triangles_arr):
  845             area, normal = self._triangle_area_and_normal(tri)
  846             if area <= AREA_SIZE_THRESHOLD:
! 847                 continue
  848 
  849             perps = self._triangle_tangent_basis(tri, normal)
  850             if perps is None:
! 851                 continue
  852             perp1, perp2 = perps
  853 
  854             if collapsed_axis is not None and plane_value is not None:
  855                 samples, outside_bounds = self._collect_surface_samples_2d(

  887                 log.warning(warning_msg)
  888                 warned = True
  889 
  890             if samples is None:
! 891                 continue
  892 
  893             points_list.append(samples["points"])
  894             normals_list.append(samples["normals"])
  895             perps1_list.append(samples["perps1"])

  898             faces_list.append(samples["faces"])
  899             bary_list.append(samples["barycentric"])
  900 
  901         if not points_list:
! 902             return {
  903                 "points": np.zeros((0, 3), dtype=dtype),
  904                 "normals": np.zeros((0, 3), dtype=dtype),
  905                 "perps1": np.zeros((0, 3), dtype=dtype),
  906                 "perps2": np.zeros((0, 3), dtype=dtype),

  953         for start, end in segments:
  954             vec = end - start
  955             length = float(np.linalg.norm(vec))
  956             if length <= tol:
! 957                 continue
  958 
  959             subdivisions = max(1, int(np.ceil(length / spacing)))
  960             t_vals = (np.arange(subdivisions, dtype=dtype) + 0.5) / subdivisions
  961             sample_points = start[None, :] + t_vals[:, None] * vec[None, :]

  971                 )
  972 
  973             outside_bounds = outside_bounds or (not np.all(inside_mask))
  974             if not np.any(inside_mask):
! 975                 continue
  976 
  977             sample_points = sample_points[inside_mask]
  978             bary_inside = bary[inside_mask]
  979             n_inside = sample_points.shape[0]

   992             faces.append(faces_tile)
   993             barycentric.append(bary_inside)
   994 
   995         if not points:
!  996             return None, outside_bounds
   997 
   998         samples = {
   999             "points": np.concatenate(points, axis=0),
  1000             "normals": np.concatenate(normals, axis=0),

  1039             sample_points[:, valid_axes] >= (sim_min - tol)[valid_axes], axis=1
  1040         ) & np.all(sample_points[:, valid_axes] <= (sim_max + tol)[valid_axes], axis=1)
  1041         outside_bounds = not np.all(inside_mask)
  1042         if not np.any(inside_mask):
! 1043             return None, outside_bounds
  1044 
  1045         sample_points = sample_points[inside_mask]
  1046         bary_inside = barycentric[inside_mask]
  1047         n_samples_inside = sample_points.shape[0]

  1071         edge02 = triangle[2] - triangle[0]
  1072         cross = np.cross(edge01, edge02)
  1073         norm = np.linalg.norm(cross)
  1074         if norm <= 0.0:
! 1075             return 0.0, np.zeros(3, dtype=triangle.dtype)
  1076         normal = (cross / norm).astype(triangle.dtype, copy=False)
  1077         area = 0.5 * norm
  1078         return area, normal

  1092 
  1093         def add_point(pt: np.ndarray) -> None:
  1094             for existing in points:
  1095                 if np.linalg.norm(existing - pt) <= tol:
! 1096                     return
  1097             points.append(pt.copy())
  1098 
  1099         for i, j in edges:
  1100             di = distances[i]

  1102             vi = vertices[i]
  1103             vj = vertices[j]
  1104 
  1105             if abs(di) <= tol and abs(dj) <= tol:
! 1106                 segments.append((vi.copy(), vj.copy()))
! 1107                 continue
  1108 
  1109             if di * dj > 0.0:
  1110                 continue

  1109             if di * dj > 0.0:
  1110                 continue
  1111 
  1112             if abs(di) <= tol:
! 1113                 add_point(vi)
! 1114                 continue
  1115 
  1116             if abs(dj) <= tol:
! 1117                 add_point(vj)
! 1118                 continue
  1119 
  1120             denom = di - dj
  1121             if abs(denom) <= tol:
! 1122                 continue
  1123             t = di / denom
  1124             if t < 0.0 or t > 1.0:
! 1125                 continue
  1126             point = vi + t * (vj - vi)
  1127             add_point(point)
  1128 
  1129         if segments:
! 1130             return segments
  1131 
  1132         if len(points) >= 2:
  1133             return [(points[0], points[1])]
  1134 
! 1135         return []
  1136 
  1137     @staticmethod
  1138     def _barycentric_coordinates(triangle: NDArray, points: np.ndarray, tol: float) -> np.ndarray:
  1139         """Compute barycentric coordinates of ``points`` with respect to ``triangle``."""

  1149         d01 = float(np.dot(v0v1, v0v2))
  1150         d11 = float(np.dot(v0v2, v0v2))
  1151         denom = d00 * d11 - d01 * d01
  1152         if abs(denom) <= tol:
! 1153             return np.tile(
  1154                 np.array([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], dtype=triangle.dtype), (pts.shape[0], 1)
  1155             )
  1156 
  1157         v0p = pts - v0

  1198     def _build_barycentric_samples(subdivisions: int) -> np.ndarray:
  1199         """Construct barycentric sampling points for a given subdivision level."""
  1200 
  1201         if subdivisions <= 1:
! 1202             return np.array([[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]])
  1203 
  1204         bary = []
  1205         for i in range(subdivisions):
  1206             for j in range(subdivisions - i):

  1219 
  1220         def midpoint(i: int, j: int) -> int:
  1221             key = (i, j) if i < j else (j, i)
  1222             if key in midpoint_cache:
! 1223                 return midpoint_cache[key]
  1224             vm = 0.5 * (verts_list[i] + verts_list[j])
  1225             verts_list.append(vm)
  1226             idx = len(verts_list) - 1
  1227             midpoint_cache[key] = idx

  1253                 edge = (candidate / length).astype(triangle.dtype, copy=False)
  1254                 break
  1255 
  1256         if edge is None:
! 1257             return None
  1258 
  1259         perp1 = edge
  1260         perp2 = np.cross(normal, perp1)
  1261         perp2_norm = np.linalg.norm(perp2)

  1259         perp1 = edge
  1260         perp2 = np.cross(normal, perp1)
  1261         perp2_norm = np.linalg.norm(perp2)
  1262         if perp2_norm <= tol:
! 1263             return None
  1264         perp2 = (perp2 / perp2_norm).astype(triangle.dtype, copy=False)
  1265         return perp1, perp2

tidy3d/components/geometry/primitives.py

  244         subdivisions: Optional[int] = None,
  245     ) -> np.ndarray:
  246         """Return unit sphere triangles discretized via an icosphere."""
  247 
! 248         unit_tris = UNIT_SPHERE._unit_sphere_triangles(
  249             target_edge_length=target_edge_length,
  250             subdivisions=subdivisions,
  251             copy_result=True,
  252         )
! 253         return unit_tris
  254 
  255     def _unit_sphere_triangles(
  256         self,
  257         *,

  259         subdivisions: Optional[int] = None,
  260         copy_result: bool = True,
  261     ) -> np.ndarray:
  262         """Return cached unit-sphere triangles with optional copying."""
! 263         if target_edge_length is not None and subdivisions is not None:
! 264             raise ValueError("Specify either target_edge_length OR subdivisions, not both.")
  265 
! 266         if subdivisions is None:
! 267             subdivisions = self._subdivisions_for_edge(target_edge_length)
  268 
! 269         triangles, _ = self._icosphere_data(subdivisions)
! 270         return np.array(triangles, copy=copy_result)
  271 
  272     def _subdivisions_for_edge(self, target_edge_length: Optional[float]) -> int:
! 273         if target_edge_length is None or target_edge_length <= 0.0:
! 274             return 0
  275 
! 276         for subdiv in range(_MAX_ICOSPHERE_SUBDIVISIONS + 1):
! 277             _, max_edge = self._icosphere_data(subdiv)
! 278             if max_edge <= target_edge_length:
! 279                 return subdiv
  280 
! 281         log.warning(
  282             f"Requested sphere mesh edge length {target_edge_length:.3e} μm requires more than "
  283             f"{_MAX_ICOSPHERE_SUBDIVISIONS} subdivisions. "
  284             "Clipping to the finest available mesh.",
  285             log_once=True,

  283             f"{_MAX_ICOSPHERE_SUBDIVISIONS} subdivisions. "
  284             "Clipping to the finest available mesh.",
  285             log_once=True,
  286         )
! 287         return _MAX_ICOSPHERE_SUBDIVISIONS
  288 
  289     def _icosphere_data(self, subdivisions: int) -> tuple[np.ndarray, float]:
! 290         cache = self._icosphere_cache
! 291         if subdivisions in cache:
! 292             return cache[subdivisions]
  293 
! 294         vertices = np.asarray(_ICOSAHEDRON_VERTS, dtype=float)
! 295         faces = np.asarray(_ICOSAHEDRON_FACES, dtype=int)
! 296         if subdivisions > 0:
! 297             vertices = vertices.copy()
! 298             faces = faces.copy()
! 299             for _ in range(subdivisions):
! 300                 vertices, faces = TriangleMesh.subdivide_faces(vertices, faces)
  301 
! 302         norms = np.linalg.norm(vertices, axis=1, keepdims=True)
! 303         norms = np.where(norms == 0.0, 1.0, norms)
! 304         vertices = vertices / norms
  305 
! 306         triangles = vertices[faces]
! 307         max_edge = self._edge_length(triangles)
! 308         cache[subdivisions] = (triangles, max_edge)
! 309         return triangles, max_edge
  310 
  311     @staticmethod
  312     def _edge_length(triangles: np.ndarray) -> float:
! 313         return float(np.linalg.norm(triangles[0, 0] - triangles[0, 1]))
  314 
  315 
  316 UNIT_SPHERE = Sphere(center=(0.0, 0.0, 0.0), radius=1.0)

@marcorudolphflex marcorudolphflex marked this pull request as draft November 11, 2025 08:58
@marcorudolphflex marcorudolphflex force-pushed the FXC-3693-triangle-mesh-support-for-adjoint branch 2 times, most recently from 0f34a50 to 3a8d240 Compare November 13, 2025 17:25
@marcorudolphflex marcorudolphflex force-pushed the FXC-3693-triangle-mesh-support-for-adjoint branch 3 times, most recently from 26bbd4e to e124a1e Compare November 24, 2025 13:55
@marcorudolphflex marcorudolphflex force-pushed the FXC-3693-triangle-mesh-support-for-adjoint branch from e124a1e to ac9b7f5 Compare November 27, 2025 08:08
@marcorudolphflex marcorudolphflex marked this pull request as ready for review November 27, 2025 12:33
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

Copy link
Collaborator

@yaugenst-flex yaugenst-flex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @marcorudolphflex this is awesome!

Comment on lines +93 to +101
@staticmethod
def _maybe_convert_object_boxes(data):
"""Convert object arrays of autograd boxes into ArrayBox instances."""

if isinstance(data, np.ndarray) and data.dtype == np.object_ and data.size:
# only convert if at least one element is an autograd tracer
if any(isbox(item) for item in data.flat):
return anp.array(data.tolist())
return data
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't really get it either. In the context of hashing in run(), this happens with the old code:

tidy3d/components/base.py:922: in add_data_to_file
    value.to_hdf5(fname=f_handle, group_path=subpath)
tidy3d/components/data/data_array.py:237: in to_hdf5
    self.to_hdf5_handle(f_handle=fname, group_path=group_path)
tidy3d/components/data/data_array.py:243: in to_hdf5_handle
    sub_group[DATA_ARRAY_VALUE_NAME] = get_static(self.data)
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../../.cache/pypoetry/virtualenvs/tidy3d-fpwg7McD-py3.12/lib/python3.12/site-packages/h5py/_hl/group.py:493: in __setitem__
    ds = self.create_dataset(None, data=obj)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../../.cache/pypoetry/virtualenvs/tidy3d-fpwg7McD-py3.12/lib/python3.12/site-packages/h5py/_hl/group.py:193: in create_dataset
    dsid = dataset.make_new_dset(group, shape, dtype, data, name, **kwds)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../../.cache/pypoetry/virtualenvs/tidy3d-fpwg7McD-py3.12/lib/python3.12/site-packages/h5py/_hl/dataset.py:90: in make_new_dset
    tid = h5t.py_create(dtype, logical=1)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
h5py/h5t.pyx:1674: in h5py.h5t.py_create
    ???
h5py/h5t.pyx:1698: in h5py.h5t.py_create
    ???
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

>   ???
E   TypeError: Object dtype dtype('O') has no native HDF5 equivalent

h5py/h5t.pyx:1765: TypeError

# geometry parameters. ``trimesh`` expects plain ``float`` values, so strip any
# tracing information before constructing the mesh.
triangles = np.array(triangles)
if triangles.dtype == np.object_:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we checking against np.object_ type here instead of for ArrayBox? Isn't this too broad?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm wondering when this happens. Generally we shouldn't even be dealing with object arrays in the first place...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the strange pydantic bug I mentioned one time. It only happens on validation of the simulation, not if only validating the mesh itself. At that point, this function get's a np.ndarray and validation would fail with a test on ArrayBox.

test_autograd_box_polyslab_numerical.py:232: in run_parameter_simulations
    sim = base_sim.updated_copy(structures=[structure])
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../../../../tidy3d/components/base.py:378: in updated_copy
    return self._updated_copy(**kwargs, deep=deep, validate=validate)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../../../../tidy3d/components/base.py:423: in _updated_copy
    return self.copy(update=kwargs, deep=deep, validate=validate)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../../../../tidy3d/components/base.py:354: in copy
    return self.validate(new_copy.dict())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
virtualenvs/tidy3d-fpwg7McD-py3.12/lib/python3.12/site-packages/pydantic/v1/main.py:717: in validate
    return cls(**value)
           ^^^^^^^^^^^^
../../../../tidy3d/components/base.py:187: in __init__
    super().__init__(**kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

__pydantic_self__ = Simulation()
data = {'attrs': {}, 'boundary_spec': {'attrs': {}, 'type': 'BoundarySpec', 'x': {'attrs': {}, 'minus': {'attrs': {}, 'name':...rs': {}, 'name': None, 'type': 'Periodic'}, 'type': 'Boundary'}, ...}, 'center': (0.0, 0.0, 0.0), 'courant': 0.99, ...}
values = {'attrs': {}, 'boundary_spec': BoundarySpec(attrs={}, x=Boundary(attrs={}, plus=Periodic(attrs={}, name=None, type='Pe...lpha_min=0.0, alpha_max=0.0)), type='Boundary'), type='BoundarySpec'), 'center': (0.0, 0.0, 0.0), 'courant': 0.99, ...}
fields_set = {'attrs', 'boundary_spec', 'center', 'courant', 'grid_spec', 'internal_absorbers', ...}
validation_error = ValidationError(model='Simulation', errors=[{'loc': ('structures',), 'msg': 'setting an array element with a sequence.', 'type': 'value_error'}])

    def __init__(__pydantic_self__, **data: Any) -> None:
        """
        Create a new model by parsing and validating input data from keyword arguments.
    
        Raises ValidationError if the input data cannot be parsed to form a valid model.
        """
        # Uses something other than `self` the first arg to allow "self" as a settable attribute
        values, fields_set, validation_error = validate_model(__pydantic_self__.__class__, data)
        if validation_error:
>           raise validation_error
E           pydantic.v1.error_wrappers.ValidationError: 1 validation error for Simulation
E           structures
E             setting an array element with a sequence. (type=value_error)

virtualenvs/tidy3d-fpwg7McD-py3.12/lib/python3.12/site-packages/pydantic/v1/main.py:347: ValidationError

def _get_barycentric_samples(self, subdivisions: int, dtype: np.dtype) -> np.ndarray:
"""Return barycentric sample coordinates for a subdivision level."""

cache = self._barycentric_samples
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to use a cached property here instead of a new private model field?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't work with subdivisions and dtype argument, right? Alternatively, do we want to build a cache variant with arguments?

vjps[("mesh_dataset", "surface_mesh")] = triangle_grads
return vjps

def _collect_surface_samples(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is preeetty complex, can we split it up? At least it should be possible to split out the 2d and 3d sampling?

@marcorudolphflex marcorudolphflex force-pushed the FXC-3693-triangle-mesh-support-for-adjoint branch from ac9b7f5 to 253795f Compare November 27, 2025 15:24
@marcorudolphflex marcorudolphflex force-pushed the FXC-3693-triangle-mesh-support-for-adjoint branch from 253795f to a052631 Compare November 27, 2025 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants