oowl 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oowl/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """OOWL: onshore and offshore wind layout generation.
2
+
3
+ Three composable layers combined by a single entry point:
4
+
5
+ * :mod:`oowl.boundaries` -- region generators (``Circle``, ``Rectangle``,
6
+ ``RadialPolygon``, ...), composable with modifiers (shipping channels,
7
+ exclusion zones).
8
+ * :mod:`oowl.layouts` -- turbine placement algorithms (``GridLayout``),
9
+ sized strictly by turbine count and fitted to a boundary by a pluggable
10
+ :class:`~oowl.layouts.fitters.Fitter`.
11
+ * :mod:`oowl.strings` -- optional ordered guide geometry consumed by
12
+ ``StringLayout``.
13
+
14
+ Combine them with :func:`generate_farm`, and persist results with windIO via
15
+ :meth:`Farm.save <oowl.layouts.base.Farm.save>` / :func:`load`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from . import boundaries, layouts, plotting, site, strings
21
+ from .api import generate_farm
22
+ from .layouts import Farm
23
+ from .plotting import plot_boundary, plot_farm
24
+ from .site import SiteConditions, Weibull, WindRose, generate_site_conditions
25
+ from .windio import load, save
26
+
27
+ __version__ = "0.0.1"
28
+
29
+ __all__ = [
30
+ "boundaries",
31
+ "layouts",
32
+ "strings",
33
+ "plotting",
34
+ "site",
35
+ "generate_farm",
36
+ "generate_site_conditions",
37
+ "Farm",
38
+ "SiteConditions",
39
+ "Weibull",
40
+ "WindRose",
41
+ "plot_boundary",
42
+ "plot_farm",
43
+ "load",
44
+ "save",
45
+ ]
oowl/_random.py ADDED
@@ -0,0 +1,71 @@
1
+ """Shared random-number utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ __all__ = [
8
+ "as_rng",
9
+ "farthest_point_sample",
10
+ "resolve_seed",
11
+ "spawn_seeds",
12
+ ]
13
+
14
+
15
+ _MAX_SEED = np.iinfo(np.uint64).max
16
+
17
+
18
+ def resolve_seed(seed: int | np.random.Generator | None) -> int:
19
+ """Resolve ``seed`` to a replayable non-negative integer.
20
+
21
+ ``None`` receives fresh operating-system entropy. Passing a generator
22
+ consumes one integer from it, isolating the generated object from later
23
+ changes to that generator's state.
24
+ """
25
+ if isinstance(seed, np.random.Generator):
26
+ return int(seed.integers(0, _MAX_SEED, dtype=np.uint64))
27
+ if seed is None:
28
+ return int(np.random.SeedSequence().entropy)
29
+ if not isinstance(seed, (int, np.integer)) or int(seed) < 0:
30
+ raise ValueError("seed must be a non-negative integer, Generator, or None")
31
+ return int(seed)
32
+
33
+
34
+ def spawn_seeds(seed: int, n_children: int) -> tuple[int, ...]:
35
+ """Derive independent, deterministic child seeds from ``seed``."""
36
+ if n_children < 0:
37
+ raise ValueError("n_children must be >= 0")
38
+ children = np.random.SeedSequence(int(seed)).spawn(n_children)
39
+ return tuple(
40
+ int(child.generate_state(1, dtype=np.uint64)[0])
41
+ for child in children
42
+ )
43
+
44
+
45
+ def as_rng(seed: int | np.random.Generator | None) -> np.random.Generator:
46
+ """Return a NumPy ``Generator`` from a seed, generator, or ``None``."""
47
+ if isinstance(seed, np.random.Generator):
48
+ return seed
49
+ return np.random.default_rng(seed)
50
+
51
+
52
+ def farthest_point_sample(pts: np.ndarray, n: int, rng) -> np.ndarray:
53
+ """Select *n* points from *pts* using farthest-point sampling.
54
+
55
+ Iteratively picks the point farthest from all previously selected points,
56
+ producing a spatially uniform subsample that preserves coverage.
57
+ """
58
+ m = len(pts)
59
+ if m <= n:
60
+ return pts
61
+
62
+ selected = [int(rng.integers(m))]
63
+ dist_sq = np.sum((pts - pts[selected[0]]) ** 2, axis=1)
64
+
65
+ for _ in range(n - 1):
66
+ idx = int(np.argmax(dist_sq))
67
+ selected.append(idx)
68
+ new_dist_sq = np.sum((pts - pts[idx]) ** 2, axis=1)
69
+ dist_sq = np.minimum(dist_sq, new_dist_sq)
70
+
71
+ return pts[selected]
oowl/api.py ADDED
@@ -0,0 +1,243 @@
1
+ """Single public entry point for ordinary, multipart, and clustered farms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from copy import copy
7
+
8
+ import numpy as np
9
+
10
+ from ._random import as_rng, resolve_seed, spawn_seeds
11
+ from .boundaries.base import Boundary
12
+ from .boundaries.composites import BoundaryCluster
13
+ from .layouts.base import Farm, LayoutAlgorithm, _allocate_counts, _combine_guides
14
+ from .layouts.composites import MixedLayout
15
+
16
+ __all__ = ["generate_farm"]
17
+
18
+
19
+ def generate_farm(
20
+ boundary: Boundary | LayoutAlgorithm,
21
+ layout: LayoutAlgorithm | MixedLayout | Sequence[LayoutAlgorithm] | None = None,
22
+ seed=None,
23
+ ) -> Farm:
24
+ """Generate one exact-count layout, optionally fitted to a boundary.
25
+
26
+ A multipart ordinary boundary remains one farm and uses one algorithm.
27
+ ``BoundaryCluster`` coordinates multiple logical farms and accepts one
28
+ fixed algorithm, a ``MixedLayout``, or an explicit algorithm per farm.
29
+ """
30
+ if layout is None:
31
+ if not isinstance(boundary, LayoutAlgorithm):
32
+ raise TypeError("standalone generation requires a LayoutAlgorithm")
33
+ layout = boundary
34
+ boundary = None
35
+
36
+ if isinstance(boundary, BoundaryCluster):
37
+ if isinstance(layout, MixedLayout):
38
+ effective_seed = layout.seed if seed is None else seed
39
+ return _generate_distributed_cluster(
40
+ boundary,
41
+ layout.n_turbines,
42
+ layout.layouts,
43
+ layout.probabilities,
44
+ effective_seed,
45
+ selection_method="mixed",
46
+ )
47
+ if isinstance(layout, LayoutAlgorithm):
48
+ if layout.n_turbines is None:
49
+ raise ValueError("n_turbines is required for cluster generation")
50
+ effective_seed = layout.seed if seed is None else seed
51
+ return _generate_distributed_cluster(
52
+ boundary,
53
+ layout.n_turbines,
54
+ (layout,),
55
+ (1.0,),
56
+ effective_seed,
57
+ selection_method="fixed",
58
+ )
59
+ return _generate_explicit_cluster(boundary, layout, seed)
60
+
61
+ if boundary is None:
62
+ if not isinstance(layout, LayoutAlgorithm):
63
+ raise TypeError("standalone generation requires a LayoutAlgorithm")
64
+ algorithm = copy(layout)
65
+ if seed is not None:
66
+ algorithm.seed = seed
67
+ return algorithm.generate()
68
+
69
+ if isinstance(layout, MixedLayout):
70
+ return _generate_mixed_single(boundary, layout, seed)
71
+ if not isinstance(layout, LayoutAlgorithm):
72
+ raise TypeError("an ordinary Boundary requires one LayoutAlgorithm")
73
+ algorithm = copy(layout)
74
+ if seed is not None:
75
+ algorithm.seed = seed
76
+ return algorithm.generate(boundary)
77
+
78
+
79
+ def _with_selection(result, root_seed, index, algorithm, child_seed):
80
+ provenance = dict(result.provenance)
81
+ provenance["seed"] = int(root_seed)
82
+ provenance["selection"] = {
83
+ "method": "mixed",
84
+ "index": int(index),
85
+ "algorithm": type(algorithm).__name__,
86
+ "seed": int(child_seed),
87
+ }
88
+ return Farm(
89
+ result.positions, result.boundary, provenance, guide=result.guide
90
+ )
91
+
92
+
93
+ def _generate_mixed_single(boundary, mixed, seed):
94
+ root_seed = resolve_seed(mixed.seed if seed is None else seed)
95
+ selection_seed, child_seed = spawn_seeds(root_seed, 2)
96
+ rng = as_rng(selection_seed)
97
+ index = int(rng.choice(len(mixed.layouts), p=mixed.probabilities))
98
+ algorithm = copy(mixed.layouts[index])
99
+ algorithm.n_turbines = mixed.n_turbines
100
+ algorithm.seed = child_seed
101
+ result = algorithm.generate(boundary)
102
+ return _with_selection(result, root_seed, index, algorithm, child_seed)
103
+
104
+
105
+ def _generate_distributed_cluster(
106
+ boundary,
107
+ n_turbines,
108
+ layouts,
109
+ probabilities,
110
+ seed,
111
+ selection_method,
112
+ ):
113
+ root_seed = resolve_seed(seed)
114
+ selection_seed, farm_root = spawn_seeds(root_seed, 2)
115
+ counts = _allocate_counts(
116
+ n_turbines, [farm.area for farm in boundary.boundaries]
117
+ )
118
+ selection_rng = as_rng(selection_seed)
119
+ selected = selection_rng.choice(
120
+ len(layouts), size=len(boundary.boundaries), p=probabilities
121
+ )
122
+ farm_seeds = spawn_seeds(farm_root, len(boundary.boundaries))
123
+ assignments = []
124
+ for count, layout_index, farm_seed in zip(counts, selected, farm_seeds):
125
+ layout_index = int(layout_index)
126
+ template = layouts[layout_index]
127
+ assignments.append((
128
+ template,
129
+ int(count),
130
+ farm_seed,
131
+ {
132
+ "method": selection_method,
133
+ "index": layout_index,
134
+ "algorithm": type(template).__name__,
135
+ },
136
+ ))
137
+ return _aggregate_cluster(
138
+ boundary, assignments, root_seed, n_turbines
139
+ )
140
+
141
+
142
+ def _generate_explicit_cluster(boundary, layouts, seed):
143
+ if isinstance(layouts, LayoutAlgorithm) or not isinstance(layouts, Sequence):
144
+ raise TypeError(
145
+ "BoundaryCluster requires a sequence of LayoutAlgorithm objects"
146
+ )
147
+ layouts = tuple(layouts)
148
+ if len(layouts) != len(boundary.boundaries):
149
+ raise ValueError(
150
+ f"BoundaryCluster has {len(boundary.boundaries)} farms but "
151
+ f"received {len(layouts)} layouts"
152
+ )
153
+ for index, algorithm in enumerate(layouts):
154
+ if not isinstance(algorithm, LayoutAlgorithm):
155
+ raise TypeError(
156
+ f"cluster layout {index} must be a LayoutAlgorithm, "
157
+ f"not {type(algorithm).__name__}"
158
+ )
159
+ if algorithm.n_turbines is None:
160
+ raise ValueError(f"cluster layout {index} must define n_turbines")
161
+
162
+ root_seed = resolve_seed(seed) if seed is not None else None
163
+ override_seeds = (
164
+ spawn_seeds(root_seed, len(layouts)) if root_seed is not None else None
165
+ )
166
+ assignments = []
167
+ for index, template in enumerate(layouts):
168
+ override = override_seeds[index] if override_seeds is not None else None
169
+ assignments.append((
170
+ template,
171
+ template.n_turbines,
172
+ override,
173
+ {
174
+ "method": "explicit",
175
+ "index": index,
176
+ "algorithm": type(template).__name__,
177
+ },
178
+ ))
179
+ return _aggregate_cluster(
180
+ boundary,
181
+ assignments,
182
+ root_seed,
183
+ sum(algorithm.n_turbines for algorithm in layouts),
184
+ )
185
+
186
+
187
+ def _aggregate_cluster(boundary, assignments, root_seed, requested_n):
188
+ positions = []
189
+ guides = []
190
+ farms = []
191
+ start = 0
192
+
193
+ for index, (farm_boundary, assignment) in enumerate(
194
+ zip(boundary.boundaries, assignments)
195
+ ):
196
+ template, count, override_seed, selection = assignment
197
+ count = int(count)
198
+ algorithm = copy(template)
199
+ algorithm.n_turbines = count
200
+ if override_seed is not None:
201
+ algorithm.seed = override_seed
202
+
203
+ child_provenance = None
204
+ child_seed = int(override_seed) if override_seed is not None else None
205
+ if count:
206
+ try:
207
+ result = algorithm.generate(farm_boundary)
208
+ except (TypeError, ValueError) as exc:
209
+ raise type(exc)(
210
+ f"Could not generate cluster farm {index}: {exc}"
211
+ ) from exc
212
+ positions.append(result.positions)
213
+ guides.append(result.guide)
214
+ child_provenance = result.provenance
215
+ child_seed = child_provenance.get("seed")
216
+
217
+ stop = start + count
218
+ farms.append({
219
+ "farm_index": index,
220
+ "area": float(farm_boundary.area),
221
+ "n_turbines": count,
222
+ "start": start,
223
+ "stop": stop,
224
+ "seed": child_seed,
225
+ "selection": selection,
226
+ "layout": child_provenance,
227
+ })
228
+ start = stop
229
+
230
+ combined = np.concatenate(positions) if positions else np.empty((0, 2))
231
+ return Farm(
232
+ combined,
233
+ boundary,
234
+ {
235
+ "kind": "cluster",
236
+ "seed": int(root_seed) if root_seed is not None else None,
237
+ "requested_n": int(requested_n),
238
+ "achieved_n": int(len(combined)),
239
+ "farms": farms,
240
+ "modifiers": [],
241
+ },
242
+ guide=_combine_guides(guides),
243
+ )
@@ -0,0 +1,36 @@
1
+ """Boundary geometries for wind farms.
2
+
3
+ Concrete boundaries subclass :class:`Boundary` and return a Shapely
4
+ ``Polygon`` / ``MultiPolygon``. Extras (shipping channels, exclusion zones) are
5
+ :class:`BoundaryModifier` objects applied via :class:`CompositeBoundary`, so
6
+ they compose with any boundary. :class:`BoundaryCluster` explicitly groups
7
+ boundaries belonging to separately configured farms.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .base import Boundary
13
+ from .composites import BoundaryCluster, CompositeBoundary
14
+ from .hull import ConvexHull
15
+ from .modifiers import BoundaryModifier, ExclusionZones, ShippingChannels
16
+ from .primitives import Annulus, Circle, Ellipse, Rectangle, RegularPolygon, Square
17
+ from .radial import RadialPolygon, RandomRadialPolygon, SmoothBlob
18
+
19
+ __all__ = [
20
+ "Boundary",
21
+ "BoundaryCluster",
22
+ "CompositeBoundary",
23
+ "Rectangle",
24
+ "Square",
25
+ "RegularPolygon",
26
+ "Circle",
27
+ "Ellipse",
28
+ "Annulus",
29
+ "RadialPolygon",
30
+ "RandomRadialPolygon",
31
+ "SmoothBlob",
32
+ "ConvexHull",
33
+ "BoundaryModifier",
34
+ "ShippingChannels",
35
+ "ExclusionZones",
36
+ ]
@@ -0,0 +1,117 @@
1
+ """Base classes for boundary geometries.
2
+
3
+ A :class:`Boundary` owns a Shapely ``Polygon`` / ``MultiPolygon`` and exposes the
4
+ small interface that layout fitters need (``area``, ``bounds``, ``centroid``,
5
+ ``contains``). Concrete shapes live in :mod:`primitives`, :mod:`radial` and
6
+ :mod:`hull`. Extras such as shipping channels and exclusion zones are applied as
7
+ :class:`~oowl.boundaries.modifiers.BoundaryModifier` objects through a
8
+ :class:`~oowl.boundaries.composites.CompositeBoundary`, so they mix
9
+ into *any* boundary.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from functools import cached_property
16
+ from typing import TYPE_CHECKING
17
+
18
+ import numpy as np
19
+ import shapely
20
+ from shapely.geometry import MultiPolygon, Polygon
21
+ from shapely.geometry.base import BaseGeometry
22
+
23
+ if TYPE_CHECKING: # pragma: no cover
24
+ from .modifiers import BoundaryModifier
25
+
26
+ __all__ = ["Boundary"]
27
+
28
+ Point2D = tuple[float, float]
29
+
30
+
31
+ class Boundary(ABC):
32
+ """Abstract wind farm boundary backed by a Shapely geometry."""
33
+
34
+ @abstractmethod
35
+ def _build_geometry(self) -> BaseGeometry:
36
+ """Construct and return the Shapely geometry for this boundary."""
37
+
38
+ @cached_property
39
+ def geometry(self) -> Polygon | MultiPolygon:
40
+ """The Shapely ``Polygon`` / ``MultiPolygon`` for this boundary."""
41
+ geometry = self._build_geometry()
42
+ if not isinstance(geometry, (Polygon, MultiPolygon)):
43
+ raise TypeError("boundary geometry must be a Polygon or MultiPolygon")
44
+ if geometry.is_empty or geometry.area <= 0:
45
+ raise ValueError("boundary geometry must have positive area")
46
+ if not geometry.is_valid:
47
+ raise ValueError("boundary geometry must be valid")
48
+ return geometry
49
+
50
+ # -- properties layouts/fitters rely on -------------------------------
51
+ @property
52
+ def area(self) -> float:
53
+ return self.geometry.area
54
+
55
+ @cached_property
56
+ def bounds(self) -> tuple[float, float, float, float]:
57
+ return self.geometry.bounds
58
+
59
+ @cached_property
60
+ def centroid(self) -> Point2D:
61
+ c = self.geometry.centroid
62
+ return (c.x, c.y)
63
+
64
+ def contains(self, points) -> np.ndarray:
65
+ """Vectorised point-in-boundary test, inclusive of the boundary edge.
66
+
67
+ Points lying exactly on the boundary count as contained, so turbines
68
+ that touch an edge are not spuriously dropped. For point queries this is
69
+ the ``covers`` predicate.
70
+
71
+ Parameters
72
+ ----------
73
+ points:
74
+ Array-like of shape ``(N, 2)`` (or ``(2,)``).
75
+
76
+ Returns
77
+ -------
78
+ numpy.ndarray
79
+ Boolean array of length ``N``.
80
+ """
81
+ pts = np.asarray(points, dtype=float).reshape(-1, 2)
82
+ return shapely.intersects_xy(self.geometry, pts[:, 0], pts[:, 1])
83
+
84
+ # -- composition (modifiers) ------------------------------------------
85
+ def with_modifiers(self, *modifiers: "BoundaryModifier"):
86
+ """Return a new boundary with ``modifiers`` applied in order."""
87
+ from .composites import CompositeBoundary
88
+
89
+ return CompositeBoundary(self, modifiers)
90
+
91
+ def with_channels(self, **kwargs):
92
+ """Cut shipping channels through this boundary (see ``ShippingChannels``)."""
93
+ from .modifiers import ShippingChannels
94
+
95
+ return self.with_modifiers(ShippingChannels(**kwargs))
96
+
97
+ def with_exclusions(self, *holes):
98
+ """Subtract exclusion zones (geometries or boundaries) from this boundary."""
99
+ from .modifiers import ExclusionZones
100
+
101
+ return self.with_modifiers(ExclusionZones(holes))
102
+
103
+ # -- plotting ----------------------------------------------------------
104
+ def plot(self, ax=None, **kwargs):
105
+ from ..plotting import plot_boundary
106
+
107
+ return plot_boundary(self.geometry, ax=ax, **kwargs)
108
+
109
+
110
+ class _GeometryBoundary(Boundary):
111
+ """Internal boundary view over an existing polygon geometry."""
112
+
113
+ def __init__(self, geometry: Polygon | MultiPolygon):
114
+ self._geometry = geometry
115
+
116
+ def _build_geometry(self) -> BaseGeometry:
117
+ return self._geometry
@@ -0,0 +1,102 @@
1
+ """Compositions of logical farm boundaries and modifier chains."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from shapely.ops import unary_union
8
+
9
+ from .base import Boundary, _GeometryBoundary
10
+ from .modifiers import BoundaryModifier
11
+
12
+ __all__ = ["CompositeBoundary", "BoundaryCluster"]
13
+
14
+
15
+ class CompositeBoundary(Boundary):
16
+ """A base boundary with an ordered chain of geometry modifiers."""
17
+
18
+ def __init__(self, base: Boundary, modifiers: Iterable[BoundaryModifier]):
19
+ if not isinstance(base, Boundary):
20
+ raise TypeError("base must be a Boundary")
21
+ modifiers = tuple(modifiers)
22
+ for modifier in modifiers:
23
+ if not isinstance(modifier, BoundaryModifier):
24
+ raise TypeError("boundary modifiers must be BoundaryModifier objects")
25
+ if isinstance(base, CompositeBoundary):
26
+ self.base = base.base
27
+ self.modifiers = base.modifiers + modifiers
28
+ else:
29
+ self.base = base
30
+ self.modifiers = modifiers
31
+
32
+ def _build_geometry(self):
33
+ geometry = self.base.geometry
34
+ for modifier in self.modifiers:
35
+ geometry = modifier.apply(geometry)
36
+ return geometry
37
+
38
+ def with_modifiers(self, *modifiers):
39
+ return CompositeBoundary(self.base, self.modifiers + tuple(modifiers))
40
+
41
+
42
+ class BoundaryCluster(Boundary):
43
+ """An ordered collection of non-overlapping logical wind farms."""
44
+
45
+ def __init__(self, boundaries: Iterable[Boundary]):
46
+ try:
47
+ members = tuple(boundaries)
48
+ except TypeError as exc:
49
+ raise TypeError(
50
+ "boundaries must be an iterable of Boundary objects"
51
+ ) from exc
52
+ if not members:
53
+ raise ValueError("BoundaryCluster requires at least one boundary")
54
+ for index, boundary in enumerate(members):
55
+ if isinstance(boundary, BoundaryCluster):
56
+ raise TypeError("nested BoundaryCluster objects are not supported")
57
+ if not isinstance(boundary, Boundary):
58
+ raise TypeError(
59
+ f"cluster member {index} must be a Boundary, "
60
+ f"not {type(boundary).__name__}"
61
+ )
62
+ for left in range(len(members)):
63
+ for right in range(left + 1, len(members)):
64
+ overlap = members[left].geometry.intersection(
65
+ members[right].geometry
66
+ )
67
+ if overlap.area > 0:
68
+ raise ValueError(
69
+ f"cluster boundaries {left} and {right} overlap in area"
70
+ )
71
+ self.boundaries = members
72
+
73
+ @classmethod
74
+ def from_components(cls, boundary: Boundary):
75
+ if not isinstance(boundary, Boundary):
76
+ raise TypeError("boundary must be a Boundary")
77
+ geometry = boundary.geometry
78
+ polygons = (
79
+ geometry.geoms
80
+ if geometry.geom_type == "MultiPolygon"
81
+ else [geometry]
82
+ )
83
+ return cls(_GeometryBoundary(polygon) for polygon in polygons)
84
+
85
+ def _build_geometry(self):
86
+ return unary_union([boundary.geometry for boundary in self.boundaries])
87
+
88
+ def with_modifiers(self, *modifiers):
89
+ for modifier in modifiers:
90
+ if not isinstance(modifier, BoundaryModifier):
91
+ raise TypeError("boundary modifiers must be BoundaryModifier objects")
92
+ geometries = [boundary.geometry for boundary in self.boundaries]
93
+ for modifier in modifiers:
94
+ geometries = modifier.apply_to_cluster(geometries)
95
+ members = [
96
+ _GeometryBoundary(geometry)
97
+ for geometry in geometries
98
+ if not geometry.is_empty and geometry.area > 0
99
+ ]
100
+ if not members:
101
+ raise ValueError("cluster modifiers removed every farm boundary")
102
+ return BoundaryCluster(members)
@@ -0,0 +1,38 @@
1
+ """Point-cloud based boundary shapes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from shapely.geometry import MultiPoint, Polygon
7
+ from shapely.geometry.base import BaseGeometry
8
+
9
+ from .._random import as_rng, resolve_seed
10
+ from .base import Boundary, Point2D
11
+
12
+ __all__ = ["ConvexHull"]
13
+
14
+
15
+ class ConvexHull(Boundary):
16
+ """Convex boundary as the convex hull of random scattered points."""
17
+
18
+ def __init__(
19
+ self,
20
+ n_points: int = 20,
21
+ spread: float = 1.0,
22
+ center: Point2D = (0.0, 0.0),
23
+ seed: int | np.random.Generator | None = None,
24
+ ):
25
+ if n_points < 3:
26
+ raise ValueError("n_points must be >= 3")
27
+ self.n_points = n_points
28
+ self.spread = spread
29
+ self.center = center
30
+ self.seed = resolve_seed(seed)
31
+
32
+ def _build_geometry(self) -> BaseGeometry:
33
+ rng = as_rng(self.seed)
34
+ pts = rng.normal(loc=self.center, scale=self.spread, size=(self.n_points, 2))
35
+ hull = MultiPoint(pts).convex_hull
36
+ if not isinstance(hull, Polygon):
37
+ raise ValueError("convex hull did not produce a polygon; add more points")
38
+ return hull