Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions tidy3d/components/data/data_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
"face_index": {"long_name": "face index"},
"vertex_index": {"long_name": "vertex index"},
"axis": {"long_name": "axis"},
"sample": {"long_name": "surface sample index"},
"side": {"long_name": "surface side"},
}


Expand Down Expand Up @@ -1032,6 +1034,46 @@ class TriangleMeshDataArray(DataArray):
_data_attrs = {"long_name": "surface mesh triangles"}


class SurfaceScalarDataArray(DataArray):
"""Scalar field samples recorded on geometry surfaces.

Dims: (sample, f, side)

- ``sample``: index of a unique surface sample point.
- ``f``: frequency in Hz.
- ``side``: surface side label, typically ``['inside', 'outside']``.
"""

__slots__ = ()
_dims = ("sample", "f", "side")
_data_attrs = {"long_name": "surface scalar samples"}


class SurfaceVectorDataArray(DataArray):
"""Vector-valued quantity per surface sample (e.g., coordinates or normals).

Dims: (sample, axis)

- ``sample``: index of surface sample.
- ``axis``: x=0, y=1, z=2.
"""

__slots__ = ()
_dims = ("sample", "axis")
_data_attrs = {"long_name": "surface vector components"}


class SampleWeightDataArray(DataArray):
"""Quadrature weight or differential area associated with a surface sample.

Dims: (sample,)
"""

__slots__ = ()
_dims = ("sample",)
_data_attrs = {"long_name": "surface sample weight"}


class HeatDataArray(DataArray):
"""Heat data array.

Expand Down
68 changes: 68 additions & 0 deletions tidy3d/components/data/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
GroupIndexDataArray,
ModeDispersionDataArray,
ModeIndexDataArray,
SampleWeightDataArray,
ScalarFieldDataArray,
ScalarFieldTimeDataArray,
ScalarModeFieldCylindricalDataArray,
ScalarModeFieldDataArray,
SurfaceScalarDataArray,
SurfaceVectorDataArray,
TimeDataArray,
TriangleMeshDataArray,
)
Expand Down Expand Up @@ -675,6 +678,71 @@ class TriangleMeshDataset(Dataset):
)


class SurfaceSamplesDataset(Dataset):
"""Geometry surface samples and associated geometric metadata.

- ``points``: Cartesian coordinates of each surface sample.
- ``normals``: Outward-pointing unit normals at each sample.
- ``perp1`` and ``perp2``: Optional orthonormal tangents spanning the local tangent plane.
If not provided, we can construct a consistent basis from ``normals``.
- ``weights``: Quadrature weights (e.g., differential area per sample).
"""

points: SurfaceVectorDataArray = pd.Field(
Copy link
Contributor

@groberts-flex groberts-flex Aug 29, 2025

Choose a reason for hiding this comment

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

ultimately, to connect to the gradient, we will need to associate each of these points with the geometric parameter we are getting the gradient for. For PolySlab, for example, we will need a good way to sort these points as belonging to different edges for example. I wonder if possibly, we want the surface sample points broken down by edge. This would work well, I think, for Box and PolySlab, but it may not be as straightforward if we had something like a level set geometry in the future. See below as well - I think we may need each geometric edge to correspond to a list of interface edges where each interface edge is a continuous run of inside and outside materials being the same combination.

..., title="Sample points", description="Sample coordinates (x,y,z) per surface sample."
)
normals: SurfaceVectorDataArray = pd.Field(
..., title="Normals", description="Outward surface normals at each sample."
)
weights: SampleWeightDataArray = pd.Field(
..., title="Weights", description="Quadrature weight or area per surface sample."
)
perp1: SurfaceVectorDataArray = pd.Field(
None,
title="Tangent 1",
description="Optional first tangent vector per sample; orthonormal to the normal.",
)
perp2: SurfaceVectorDataArray = pd.Field(
None,
title="Tangent 2",
description="Optional second tangent vector; completes an orthonormal basis.",
)

Copy link
Contributor

Choose a reason for hiding this comment

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

we may want distance from points to the geometry edge - this would be useful for 2D PEC singularity correction in the gradient


class AdjointDielectricSurfaceComponents(Dataset):
Copy link
Contributor

@groberts-flex groberts-flex Aug 29, 2025

Choose a reason for hiding this comment

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

If we want to support a more flexible integration scheme, instead of having surface components for a single type of material like this, we would want to have surface components specific to each type of interface (dielectric <-> dielectric, PEC <-> dielectric, lossy metal <-> dielectric, PEC <-> PEC (this one so we would know to ignore the integration here)). This would allow a dielectric geometry that was inside a PEC background to be integrated in the adjoint instead of assuming there was dielectric outside of it for example. With overlapping structures, we could potentially have multiple of these types of interfaces for a single geometry and each geometric edge could have different interfaces on it. So I would think that we could have something like

AdjointDielectricDielectricSurfaceComponents, AdjointDielectricPECSurfaceComponents, etc.

and then the samples would be for a single edge where an edge is a run of continuous interface. This edge maps to a geometric edge (i.e. - one of the sides of a Box or between two vertices in a PolySlab). Then, each geometric edge has a list of these interface edges each with field components corresponding to that type of interface. When we integrate each geometric edge, we can integrate/sum each interface edge in the list and they will all operate under their specific integration rule.

"""Projected components recorded for dielectric adjoint surface VJP."""

samples: SurfaceSamplesDataset = pd.Field(
..., title="Surface samples", description="Geometry surface samples and metadata."
)
Et1: SurfaceScalarDataArray = pd.Field(
None, title="Et1", description="First tangential component of E at samples."
)
Et2: SurfaceScalarDataArray = pd.Field(
None, title="Et2", description="Second tangential component of E at samples."
)
Dn: SurfaceScalarDataArray = pd.Field(
None, title="Dn", description="Normal component of D at samples."
)


class AdjointPECSurfaceComponents(Dataset):
Copy link
Contributor

Choose a reason for hiding this comment

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

LossyMetal would look similar to this although we would include the Et1 and Et2 as well.

"""Projected components recorded for PEC adjoint surface VJP."""

samples: SurfaceSamplesDataset = pd.Field(
..., title="Surface samples", description="Geometry surface samples and metadata."
)
En: SurfaceScalarDataArray = pd.Field(
None, title="En", description="Normal component of E at samples (outside PEC)."
)
Ht1: SurfaceScalarDataArray = pd.Field(
None, title="Ht1", description="First tangential component of H at samples (outside PEC)."
)
Ht2: SurfaceScalarDataArray = pd.Field(
None, title="Ht2", description="Second tangential component of H at samples (outside PEC)."
)


class TimeDataset(Dataset):
"""Dataset for storing a function of time."""

Expand Down
39 changes: 39 additions & 0 deletions tidy3d/components/data/monitor_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
)
from .dataset import (
AbstractFieldDataset,
AdjointDielectricSurfaceComponents,
AdjointPECSurfaceComponents,
AuxFieldTimeDataset,
Dataset,
ElectromagneticFieldDataset,
Expand Down Expand Up @@ -3907,6 +3909,41 @@ def fields_circular_polarization(self) -> xr.Dataset:
return xr.Dataset(dict(zip(keys, data_arrays)))


class AdjointDielectricSurfaceData(AdjointDielectricSurfaceComponents, MonitorData):
"""
Data recorded by a dielectric adjoint surface monitor.

Fields
- samples: SurfaceSamplesDataset
- Et1: SurfaceScalarDataArray
- Et2: SurfaceScalarDataArray
- Dn: SurfaceScalarDataArray
"""

monitor: MonitorType = pd.Field(
...,
title="Monitor",
description="Dielectric adjoint surface monitor associated with the data.",
)


class AdjointPECSurfaceData(AdjointPECSurfaceComponents, MonitorData):
"""
Data recorded by a PEC adjoint surface monitor.

Fields
- samples: SurfaceSamplesDataset
- En: SurfaceScalarDataArray
- Ht1: SurfaceScalarDataArray
- Ht2: SurfaceScalarDataArray
"""

monitor: MonitorType = pd.Field(
..., title="Monitor", description="PEC adjoint surface monitor associated with the data."
)


# Register all monitor data types after class definitions
MonitorDataTypes = (
FieldData,
FieldTimeData,
Expand All @@ -3921,6 +3958,8 @@ def fields_circular_polarization(self) -> xr.Dataset:
FieldProjectionAngleData,
DiffractionData,
DirectivityData,
AdjointDielectricSurfaceData,
AdjointPECSurfaceData,
)

MonitorDataType = Union[MonitorDataTypes]
54 changes: 54 additions & 0 deletions tidy3d/components/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,57 @@ def _storage_size_solver(self, num_cells: int, tmesh: ArrayFloat1D) -> int:
return BYTES_COMPLEX * num_cells * len(self.freqs) * 6


class AdjointSurfaceMonitor(FreqMonitor):
Copy link
Contributor

Choose a reason for hiding this comment

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

See above - but I think a general AdjointSurfaceMonitor may make sense and the data returned will have all the information on what type of interface it is (dielectric <-> dielectric, dielectric <-> PEC, etc.)

"""Base class for adjoint shape-optimization surface monitors.

Records boundary field samples on geometry surfaces within the monitor bounding box.
Concrete subclasses define exactly which components are recorded.

Parameters
----------
sides : tuple['inside'|'outside', ...]
Which sides of the surface to record. Concrete subclasses may constrain this
(e.g., PEC outside-only).
"""

sides: tuple[Literal["inside", "outside"], ...] = pydantic.Field(
("inside", "outside"),
title="Sides to record",
description="Which surface sides to record. Concrete monitors may constrain this.",
)

colocate: Literal[True] = pydantic.Field(
True,
title="Colocate Fields",
description="Surface sampling is colocated to boundary locations by design.",
)

def storage_size(self, num_cells: int, tmesh: ArrayFloat1D) -> int:
"""Conservative storage estimate: 3 complex components × freqs × sides × samples.

Notes
-----
'num_cells' is treated as the number of surface samples (post-discretization).
Concrete subclasses may adjust this if needed.
"""
nfreq = len(self.freqs)
nsides = len(self.sides)
ncomp = 3
return BYTES_COMPLEX * num_cells * nfreq * ncomp * nsides


class AdjointDielectricSurfaceMonitor(AdjointSurfaceMonitor):
"""Convenience class for dielectric adjoint surface recording."""

sides: tuple[Literal["inside", "outside"], ...] = ("inside", "outside")


class AdjointPECSurfaceMonitor(AdjointSurfaceMonitor):
"""Convenience class for PEC adjoint surface recording."""

sides: tuple[Literal["outside"], ...] = ("outside",)


# types of monitors that are accepted by simulation
MonitorType = Union[
FieldMonitor,
Expand All @@ -1558,4 +1609,7 @@ def _storage_size_solver(self, num_cells: int, tmesh: ArrayFloat1D) -> int:
FieldProjectionKSpaceMonitor,
DiffractionMonitor,
DirectivityMonitor,
AdjointSurfaceMonitor,
AdjointDielectricSurfaceMonitor,
AdjointPECSurfaceMonitor,
]