Skip to content

mixed_dome

MixedDomeGenerator

Bases: BaseGenerator

Randomly samples and form-finds a radial dome whose rings alternate between tension and compression.

Like DomeGenerator, n_trails radial CEM trails converge on a center or central opening, but ring deviation edges may flip force sign at sign_flip_indices, mixing convex and concave (tension/compression) sections. The resulting geometry is verified to be radially symmetric.

Source code in src/torch_structure/generators/mixed_dome.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
class MixedDomeGenerator(BaseGenerator):
    """Randomly samples and form-finds a radial dome whose rings alternate between tension and compression.

    Like [DomeGenerator][torch_structure.generators.dome.DomeGenerator], ``n_trails``
    radial CEM trails converge on a center or central opening, but ring
    deviation edges may flip force sign at ``sign_flip_indices``, mixing
    convex and concave (tension/compression) sections. The resulting
    geometry is verified to be radially symmetric.
    """

    def __init__(self, **overrides):
        super().__init__(**overrides)
        self.max_attempts = 100
        self.node_attrs = {
            "coords": torch.empty((0, 3), dtype=torch.float),
            "load": torch.empty((0, 3), dtype=torch.float),
            "support_condition": torch.empty((0, 3), dtype=torch.long),
            "is_origin_node": torch.empty((0, 1), dtype=torch.bool),
            "sequence": torch.empty((0, 1), dtype=torch.long),
        }
        self.edge_attrs = {
            "force": torch.empty((0, 1), dtype=torch.float),
            "length": torch.empty((0, 1), dtype=torch.float),
            "is_trail_edge": torch.empty((0, 1), dtype=torch.bool),
            "force_sign": torch.empty((0, 1), dtype=torch.float),
        }
        self.default_attrs = {
            "force": torch.tensor([torch.nan]),
            "length": torch.tensor([torch.nan]),
            "coords": torch.full((3,), torch.nan),
            "load": torch.zeros(3, dtype=torch.float),
            "support_condition": torch.zeros(3, dtype=torch.bool),
            "is_origin_node": torch.tensor(0, dtype=torch.bool),
        }

    def validate_input(
        self,
        n_trails,
        n_rings,
        trail_length,
        center_deviation_force,
        opening,
        sign_flip_indices,
        center_trail_sign,
    ):
        """Validate that ``n_trails`` is even and ``center_trail_sign`` is 'tension' or 'compression'."""
        if n_trails % 2 != 0:
            raise ValueError("Number of trails must be even.")
        if center_trail_sign not in ["tension", "compression"]:
            raise ValueError(
                "center_trail_sign must be either 'tension' or 'compression'."
            )

    def sample_input(
        self,
        n_trails=None,
        n_rings=None,
        trail_length=0.1,
        center_deviation_force=None,
        opening=None,
        sign_flip_indices=None,
        center_trail_sign=None,
    ):
        """Sample any unspecified generation parameters, deriving dependent defaults where relevant.

        Each parameter defaults to ``None``, meaning "sample randomly"; an
        explicit value passed via ``overrides`` (see
        [BaseGenerator][torch_structure.generators.base_generator.BaseGenerator])
        is left unchanged. Returns a dict of all resolved parameters, to be
        passed to [generate][torch_structure.generators.mixed_dome.MixedDomeGenerator.generate].
        """
        if n_trails is None:
            n_trails = 2 * np.random.randint(5, 15)
        if n_rings is None:
            n_rings = np.random.randint(4, 8)
        if sign_flip_indices is None:
            sign_flip_indices = np.random.choice(
                np.arange(2, n_rings - 1),
                size=np.random.randint(0, min(3, n_rings - 2)),
                replace=False,
            )
        if center_trail_sign is None:
            center_trail_sign = np.random.choice(["tension", "compression"])
        if center_deviation_force is None:
            center_deviation_force = np.random.uniform(1.0, 4.0)
            if center_trail_sign == "compression":
                center_deviation_force *= -1
        if opening is None:
            opening = np.random.choice([True, False], p=[0.5, 0.5])

        return {
            "n_trails": n_trails,
            "n_rings": n_rings,
            "trail_length": trail_length,
            "center_deviation_force": center_deviation_force,
            "opening": opening,
            "sign_flip_indices": sign_flip_indices,
            "center_trail_sign": center_trail_sign,
        }

    def generate(
        self,
        n_trails,
        n_rings,
        trail_length,
        center_deviation_force,
        opening,
        sign_flip_indices,
        center_trail_sign,
    ):
        """Build the trails and sign-flipping ring deviations, form-find with MP-CEM, and normalize the geometry.

        If ``opening`` is ``False``, the trail origin nodes are merged into
        a single centroid node after form-finding ([fix_graph][torch_structure.generators.mixed_dome.MixedDomeGenerator.fix_graph]).

        Returns:
            StructData: the form-found dome, scaled to unit horizontal
            radius and translated to non-negative z.

        Raises:
            InvalidSampleError: if the resulting geometry is not radially
                symmetric.
        """
        # Initialize data object
        data = StructData(
            node_attrs=self.node_attrs,
            edge_attrs=self.edge_attrs,
            default_attrs=self.default_attrs,
        )

        # Create topology diagram
        centroid = torch.tensor([0.0, 0.0, 0.0])
        angles = torch.linspace(0, 2 * math.pi, n_trails + 1)[:-1]

        force_signs = torch.ones(n_rings, dtype=torch.float)
        if center_trail_sign == "compression":
            force_signs *= -1
        for i in sign_flip_indices:
            force_signs[i:] *= -1

        # Generate trails
        for i in range(n_trails):
            angle = angles[i]
            if opening:
                origin_diameter = trail_length
                x = torch.cos(angle) * 0.5 * origin_diameter
                y = torch.sin(angle) * 0.5 * origin_diameter
                origin_coords = centroid + torch.tensor([x, y, 0.0])
                origin_load = torch.tensor([0.0, 0.0, -1.0])
            else:
                origin_coords = centroid
                x_load = torch.cos(angle) * center_deviation_force
                y_load = torch.sin(angle) * center_deviation_force
                origin_load = torch.tensor([x_load, y_load, -1.0 / n_trails])
            self.generate_trail(
                data=data,
                origin_coords=origin_coords,
                origin_load=origin_load,
                id=i,
                n_rings=n_rings,
                trail_length=trail_length,
                force_signs=force_signs,
            )

        # Add ring deviations
        for i in range(1, n_rings):
            force_sign = torch.randint(2, (1,)) * 2 - 1
            force_magnitude = torch.rand(1) * 3 + 3
            force = force_sign * force_magnitude
            if i in sign_flip_indices:
                force = force_signs[i] * np.random.uniform(20.0, 40.0)
            for j in range(n_trails):
                data.add_edge(
                    f"trail_{j}_node_{i}",
                    f"trail_{(j + 1) % n_trails}_node_{i}",
                    is_trail_edge=torch.tensor(False),
                    force=force,
                )

        if opening:
            ring_force = (
                center_deviation_force * origin_diameter * np.random.uniform(25, 75)
            )
            for i in range(n_trails):
                data.add_edge(
                    f"trail_{i}_node_0",
                    f"trail_{(i + 1) % n_trails}_node_0",
                    is_trail_edge=torch.tensor(False),
                    force=ring_force,
                )

        # Formfinding
        data = data.mpcem()
        if not opening:
            self.fix_graph(data, n_trails)

        # Scale to unit length
        radius = torch.norm(data.coords[:, 0:2], dim=1).max()
        data.coords /= radius

        # Translate to positive coordinates
        data.coords[:, 2] -= data.coords[data.is_support.view(-1), 2].min()

        # Set support
        data.is_support = data.is_support

        # Check for radial symmetry in coords
        radial_distance = torch.norm(data.coords[:, 0:2], dim=1)
        for i in range(n_rings):
            ring_radial_distance = radial_distance[i : -1 : n_rings + int(opening)]
            ring_height = data.coords[i : -1 : n_rings + int(opening), 2]
            symmetric = True
            if not torch.allclose(
                ring_radial_distance, ring_radial_distance[0], atol=1e-4
            ):
                symmetric = False
            if not torch.allclose(ring_height, ring_height[0], atol=1e-4):
                symmetric = False
            if not symmetric:
                raise InvalidSampleError("Form-found structure is not radially symmetric.")

        return data

    def generate_trail(
        self, data, origin_coords, origin_load, id, n_rings, trail_length, force_signs
    ):
        """Add a single radial CEM trail, applying ``force_signs[i]`` to each ring's edge, in place."""
        data.add_node(
            f"trail_{id}_node_0",
            coords=origin_coords,
            is_origin_node=torch.tensor(True),
            sequence=torch.tensor(0),
            load=origin_load,
        )
        for i in range(1, n_rings + 1):
            support_condition = (
                torch.tensor([True, True, True])
                if i == n_rings
                else torch.tensor([False, False, False])
            )
            data.add_node(
                f"trail_{id}_node_{i}",
                is_origin_node=torch.tensor(False),
                sequence=torch.tensor(i),
                load=torch.tensor([0.0, 0.0, -1.0]),
                support_condition=support_condition,
            )
            data.add_edge(
                f"trail_{id}_node_{i - 1}",
                f"trail_{id}_node_{i}",
                is_trail_edge=torch.tensor(True),
                length=trail_length,
                force_sign=torch.tensor(force_signs[i - 1]),
            )

    def fix_graph(self, data, n_trails):
        """Merge all trails' origin nodes into a single centroid node, in place."""
        data.add_node(
            "centroid",
            coords=torch.tensor([0.0, 0.0, 0.0]),
            load=torch.tensor([0.0, 0.0, -1.0]),
        )
        merge_nodes = [f"trail_{i}_node_0" for i in range(n_trails)]
        data.merge_nodes("centroid", merge_nodes)

fix_graph(data, n_trails)

Merge all trails' origin nodes into a single centroid node, in place.

Source code in src/torch_structure/generators/mixed_dome.py
264
265
266
267
268
269
270
271
272
def fix_graph(self, data, n_trails):
    """Merge all trails' origin nodes into a single centroid node, in place."""
    data.add_node(
        "centroid",
        coords=torch.tensor([0.0, 0.0, 0.0]),
        load=torch.tensor([0.0, 0.0, -1.0]),
    )
    merge_nodes = [f"trail_{i}_node_0" for i in range(n_trails)]
    data.merge_nodes("centroid", merge_nodes)

generate(n_trails, n_rings, trail_length, center_deviation_force, opening, sign_flip_indices, center_trail_sign)

Build the trails and sign-flipping ring deviations, form-find with MP-CEM, and normalize the geometry.

If opening is False, the trail origin nodes are merged into a single centroid node after form-finding (fix_graph).

Returns:

Name Type Description
StructData

the form-found dome, scaled to unit horizontal

radius and translated to non-negative z.

Raises:

Type Description
InvalidSampleError

if the resulting geometry is not radially symmetric.

Source code in src/torch_structure/generators/mixed_dome.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def generate(
    self,
    n_trails,
    n_rings,
    trail_length,
    center_deviation_force,
    opening,
    sign_flip_indices,
    center_trail_sign,
):
    """Build the trails and sign-flipping ring deviations, form-find with MP-CEM, and normalize the geometry.

    If ``opening`` is ``False``, the trail origin nodes are merged into
    a single centroid node after form-finding ([fix_graph][torch_structure.generators.mixed_dome.MixedDomeGenerator.fix_graph]).

    Returns:
        StructData: the form-found dome, scaled to unit horizontal
        radius and translated to non-negative z.

    Raises:
        InvalidSampleError: if the resulting geometry is not radially
            symmetric.
    """
    # Initialize data object
    data = StructData(
        node_attrs=self.node_attrs,
        edge_attrs=self.edge_attrs,
        default_attrs=self.default_attrs,
    )

    # Create topology diagram
    centroid = torch.tensor([0.0, 0.0, 0.0])
    angles = torch.linspace(0, 2 * math.pi, n_trails + 1)[:-1]

    force_signs = torch.ones(n_rings, dtype=torch.float)
    if center_trail_sign == "compression":
        force_signs *= -1
    for i in sign_flip_indices:
        force_signs[i:] *= -1

    # Generate trails
    for i in range(n_trails):
        angle = angles[i]
        if opening:
            origin_diameter = trail_length
            x = torch.cos(angle) * 0.5 * origin_diameter
            y = torch.sin(angle) * 0.5 * origin_diameter
            origin_coords = centroid + torch.tensor([x, y, 0.0])
            origin_load = torch.tensor([0.0, 0.0, -1.0])
        else:
            origin_coords = centroid
            x_load = torch.cos(angle) * center_deviation_force
            y_load = torch.sin(angle) * center_deviation_force
            origin_load = torch.tensor([x_load, y_load, -1.0 / n_trails])
        self.generate_trail(
            data=data,
            origin_coords=origin_coords,
            origin_load=origin_load,
            id=i,
            n_rings=n_rings,
            trail_length=trail_length,
            force_signs=force_signs,
        )

    # Add ring deviations
    for i in range(1, n_rings):
        force_sign = torch.randint(2, (1,)) * 2 - 1
        force_magnitude = torch.rand(1) * 3 + 3
        force = force_sign * force_magnitude
        if i in sign_flip_indices:
            force = force_signs[i] * np.random.uniform(20.0, 40.0)
        for j in range(n_trails):
            data.add_edge(
                f"trail_{j}_node_{i}",
                f"trail_{(j + 1) % n_trails}_node_{i}",
                is_trail_edge=torch.tensor(False),
                force=force,
            )

    if opening:
        ring_force = (
            center_deviation_force * origin_diameter * np.random.uniform(25, 75)
        )
        for i in range(n_trails):
            data.add_edge(
                f"trail_{i}_node_0",
                f"trail_{(i + 1) % n_trails}_node_0",
                is_trail_edge=torch.tensor(False),
                force=ring_force,
            )

    # Formfinding
    data = data.mpcem()
    if not opening:
        self.fix_graph(data, n_trails)

    # Scale to unit length
    radius = torch.norm(data.coords[:, 0:2], dim=1).max()
    data.coords /= radius

    # Translate to positive coordinates
    data.coords[:, 2] -= data.coords[data.is_support.view(-1), 2].min()

    # Set support
    data.is_support = data.is_support

    # Check for radial symmetry in coords
    radial_distance = torch.norm(data.coords[:, 0:2], dim=1)
    for i in range(n_rings):
        ring_radial_distance = radial_distance[i : -1 : n_rings + int(opening)]
        ring_height = data.coords[i : -1 : n_rings + int(opening), 2]
        symmetric = True
        if not torch.allclose(
            ring_radial_distance, ring_radial_distance[0], atol=1e-4
        ):
            symmetric = False
        if not torch.allclose(ring_height, ring_height[0], atol=1e-4):
            symmetric = False
        if not symmetric:
            raise InvalidSampleError("Form-found structure is not radially symmetric.")

    return data

generate_trail(data, origin_coords, origin_load, id, n_rings, trail_length, force_signs)

Add a single radial CEM trail, applying force_signs[i] to each ring's edge, in place.

Source code in src/torch_structure/generators/mixed_dome.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def generate_trail(
    self, data, origin_coords, origin_load, id, n_rings, trail_length, force_signs
):
    """Add a single radial CEM trail, applying ``force_signs[i]`` to each ring's edge, in place."""
    data.add_node(
        f"trail_{id}_node_0",
        coords=origin_coords,
        is_origin_node=torch.tensor(True),
        sequence=torch.tensor(0),
        load=origin_load,
    )
    for i in range(1, n_rings + 1):
        support_condition = (
            torch.tensor([True, True, True])
            if i == n_rings
            else torch.tensor([False, False, False])
        )
        data.add_node(
            f"trail_{id}_node_{i}",
            is_origin_node=torch.tensor(False),
            sequence=torch.tensor(i),
            load=torch.tensor([0.0, 0.0, -1.0]),
            support_condition=support_condition,
        )
        data.add_edge(
            f"trail_{id}_node_{i - 1}",
            f"trail_{id}_node_{i}",
            is_trail_edge=torch.tensor(True),
            length=trail_length,
            force_sign=torch.tensor(force_signs[i - 1]),
        )

sample_input(n_trails=None, n_rings=None, trail_length=0.1, center_deviation_force=None, opening=None, sign_flip_indices=None, center_trail_sign=None)

Sample any unspecified generation parameters, deriving dependent defaults where relevant.

Each parameter defaults to None, meaning "sample randomly"; an explicit value passed via overrides (see BaseGenerator) is left unchanged. Returns a dict of all resolved parameters, to be passed to generate.

Source code in src/torch_structure/generators/mixed_dome.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def sample_input(
    self,
    n_trails=None,
    n_rings=None,
    trail_length=0.1,
    center_deviation_force=None,
    opening=None,
    sign_flip_indices=None,
    center_trail_sign=None,
):
    """Sample any unspecified generation parameters, deriving dependent defaults where relevant.

    Each parameter defaults to ``None``, meaning "sample randomly"; an
    explicit value passed via ``overrides`` (see
    [BaseGenerator][torch_structure.generators.base_generator.BaseGenerator])
    is left unchanged. Returns a dict of all resolved parameters, to be
    passed to [generate][torch_structure.generators.mixed_dome.MixedDomeGenerator.generate].
    """
    if n_trails is None:
        n_trails = 2 * np.random.randint(5, 15)
    if n_rings is None:
        n_rings = np.random.randint(4, 8)
    if sign_flip_indices is None:
        sign_flip_indices = np.random.choice(
            np.arange(2, n_rings - 1),
            size=np.random.randint(0, min(3, n_rings - 2)),
            replace=False,
        )
    if center_trail_sign is None:
        center_trail_sign = np.random.choice(["tension", "compression"])
    if center_deviation_force is None:
        center_deviation_force = np.random.uniform(1.0, 4.0)
        if center_trail_sign == "compression":
            center_deviation_force *= -1
    if opening is None:
        opening = np.random.choice([True, False], p=[0.5, 0.5])

    return {
        "n_trails": n_trails,
        "n_rings": n_rings,
        "trail_length": trail_length,
        "center_deviation_force": center_deviation_force,
        "opening": opening,
        "sign_flip_indices": sign_flip_indices,
        "center_trail_sign": center_trail_sign,
    }

validate_input(n_trails, n_rings, trail_length, center_deviation_force, opening, sign_flip_indices, center_trail_sign)

Validate that n_trails is even and center_trail_sign is 'tension' or 'compression'.

Source code in src/torch_structure/generators/mixed_dome.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def validate_input(
    self,
    n_trails,
    n_rings,
    trail_length,
    center_deviation_force,
    opening,
    sign_flip_indices,
    center_trail_sign,
):
    """Validate that ``n_trails`` is even and ``center_trail_sign`` is 'tension' or 'compression'."""
    if n_trails % 2 != 0:
        raise ValueError("Number of trails must be even.")
    if center_trail_sign not in ["tension", "compression"]:
        raise ValueError(
            "center_trail_sign must be either 'tension' or 'compression'."
        )