spatialsample 0.1.0__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.
@@ -0,0 +1,562 @@
1
+ """Classical and field designs: simple random, systematic, stratified, Poisson-disk,
2
+ cluster, two-stage and transect sampling."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import math
7
+ from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Union
8
+
9
+ import numpy as np
10
+
11
+ from ._frame import as_domain
12
+ from ._types import Domain, FloatArray, SeedLike, as_rng
13
+ from .bas import bas
14
+ from .grts import grts
15
+ from .lpm import lpm
16
+ from .probabilities import allocate, inclusion_probabilities, systematic_pps
17
+ from .raster import RasterMask
18
+ from .sample import Sample
19
+
20
+ __all__ = [
21
+ "srs",
22
+ "systematic",
23
+ "stratified",
24
+ "stratified_pixels",
25
+ "poisson_disk",
26
+ "cluster",
27
+ "two_stage",
28
+ "points_along_lines",
29
+ "random_transects",
30
+ ]
31
+
32
+
33
+ def srs(domain: Any, n: int, seed: SeedLike = None) -> Sample:
34
+ """Simple random sample: ``n`` independent uniform locations in the domain."""
35
+ dom = as_domain(domain)
36
+ if n < 1:
37
+ raise ValueError("n must be >= 1")
38
+ xy = dom.random_points(n, as_rng(seed))
39
+ density = n / dom.area
40
+ return Sample(
41
+ xy=xy,
42
+ pi=np.full(n, density),
43
+ weight=np.full(n, 1.0 / density),
44
+ design="srs",
45
+ meta={"n": n, "area": dom.area},
46
+ )
47
+
48
+
49
+ def systematic(
50
+ domain: Any,
51
+ n: Optional[int] = None,
52
+ spacing: Optional[float] = None,
53
+ shape: str = "square",
54
+ rotation: Union[float, str, None] = 0.0,
55
+ seed: SeedLike = None,
56
+ ) -> Sample:
57
+ """Systematic grid with a uniformly random start.
58
+
59
+ Parameters
60
+ ----------
61
+ n:
62
+ Target sample size; the spacing is chosen so that the expected number
63
+ of points in the domain is ``n``. The realised size varies slightly.
64
+ spacing:
65
+ Distance between neighbouring grid points (overrides ``n``).
66
+ shape:
67
+ ``"square"`` or ``"hex"`` (triangular lattice, hexagonal cells).
68
+ rotation:
69
+ Grid rotation in degrees, or ``"random"`` for a uniformly random angle.
70
+ """
71
+ dom = as_domain(domain)
72
+ rng = as_rng(seed)
73
+ shape = shape.lower()
74
+ if shape not in ("square", "hex"):
75
+ raise ValueError("shape must be 'square' or 'hex'")
76
+ if spacing is None:
77
+ if n is None or n < 1:
78
+ raise ValueError("give n >= 1 or spacing")
79
+ cell = dom.area / n
80
+ spacing = math.sqrt(cell) if shape == "square" else math.sqrt(2.0 * cell / math.sqrt(3.0))
81
+ if spacing <= 0:
82
+ raise ValueError("spacing must be positive")
83
+ s = float(spacing)
84
+ row_h = s if shape == "square" else s * math.sqrt(3.0) / 2.0
85
+ cell_area = s * row_h
86
+ if rotation == "random":
87
+ theta = rng.uniform(0.0, 180.0 if shape == "square" else 60.0)
88
+ else:
89
+ theta = float(rotation or 0.0)
90
+ x0, y0, x1, y1 = dom.bounds
91
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
92
+ r = math.hypot(x1 - x0, y1 - y0) / 2 + 2 * s
93
+ off = np.array([rng.uniform(0, s), rng.uniform(0, row_h)])
94
+ ni = int(math.ceil(r / s)) + 2
95
+ nj = int(math.ceil(r / row_h)) + 2
96
+ ii, jj = np.meshgrid(np.arange(-ni, ni + 1), np.arange(-nj, nj + 1))
97
+ gx = ii * s + off[0]
98
+ if shape == "hex":
99
+ gx = gx + (jj % 2) * (s / 2.0)
100
+ gy = jj * row_h + off[1]
101
+ t = math.radians(theta)
102
+ ct, st = math.cos(t), math.sin(t)
103
+ px = cx + gx.ravel() * ct - gy.ravel() * st
104
+ py = cy + gx.ravel() * st + gy.ravel() * ct
105
+ pts = np.column_stack([px, py])
106
+ pts = pts[dom.contains(pts)]
107
+ # serpentine (boustrophedon) visiting order in the grid frame
108
+ lx = (pts[:, 0] - cx) * ct + (pts[:, 1] - cy) * st
109
+ ly = -(pts[:, 0] - cx) * st + (pts[:, 1] - cy) * ct
110
+ row = np.round((ly - off[1]) / row_h).astype(np.int64)
111
+ key = np.where(row % 2 == 0, lx, -lx)
112
+ order_idx = np.lexsort((key, row))
113
+ pts = pts[order_idx]
114
+ m = len(pts)
115
+ density = 1.0 / cell_area
116
+ return Sample(
117
+ xy=pts,
118
+ pi=np.full(m, density),
119
+ weight=np.full(m, cell_area),
120
+ design=f"systematic-{shape}",
121
+ meta={
122
+ "n": n,
123
+ "spacing": s,
124
+ "rotation": theta,
125
+ "area": dom.area,
126
+ "expected_n": dom.area / cell_area,
127
+ },
128
+ )
129
+
130
+
131
+ _CONTINUOUS: Dict[str, Callable[..., Sample]] = {"srs": srs, "bas": bas, "grts": grts, "lpm": lpm}
132
+
133
+
134
+ def stratified(
135
+ strata: Any,
136
+ n: Union[int, Mapping[Hashable, int]],
137
+ allocation: str = "proportional",
138
+ sd: Optional[Mapping[Hashable, float]] = None,
139
+ min_per_stratum: int = 1,
140
+ method: str = "srs",
141
+ seed: SeedLike = None,
142
+ ) -> Sample:
143
+ """Stratified sample over polygons or raster classes.
144
+
145
+ Parameters
146
+ ----------
147
+ strata:
148
+ Mapping ``label -> domain`` (Regions, RasterMasks or GeoJSON), or a
149
+ :class:`~spatialsample.RasterMask` whose distinct class values become
150
+ strata.
151
+ n:
152
+ Total sample size, or an explicit mapping ``label -> n_h``.
153
+ allocation:
154
+ ``"proportional"`` (to stratum area), ``"equal"`` or ``"neyman"``
155
+ (proportional to area times ``sd[label]``).
156
+ min_per_stratum:
157
+ Minimum sites per stratum when allocating a total.
158
+ method:
159
+ Within-stratum design: ``"srs"``, ``"bas"``, ``"grts"`` or ``"lpm"``.
160
+ """
161
+ rng = as_rng(seed)
162
+ if isinstance(strata, RasterMask):
163
+ doms: Dict[Hashable, Domain] = {c: strata.select([c]) for c in strata.class_counts()}
164
+ elif isinstance(strata, Mapping) and strata.get("type") is None:
165
+ doms = {k: as_domain(v) for k, v in strata.items()}
166
+ else:
167
+ raise TypeError("strata must be a mapping label -> domain or a RasterMask")
168
+ if method not in _CONTINUOUS:
169
+ raise ValueError(f"method must be one of {sorted(_CONTINUOUS)}")
170
+ areas = {k: d.area for k, d in doms.items()}
171
+ if isinstance(n, Mapping):
172
+ alloc = {k: int(n.get(k, 0)) for k in doms}
173
+ else:
174
+ alloc = allocate(int(n), areas, allocation, sd=sd, min_per_stratum=min_per_stratum)
175
+ parts: List[Sample] = []
176
+ labels: List[Any] = []
177
+ for k, dom in doms.items():
178
+ nh = alloc[k]
179
+ if nh <= 0:
180
+ continue
181
+ sub = _CONTINUOUS[method](dom, nh, seed=rng)
182
+ parts.append(sub)
183
+ labels.extend([k] * len(sub))
184
+ if not parts:
185
+ raise ValueError("allocation produced an empty sample")
186
+ xy = np.vstack([p.xy for p in parts])
187
+ pi = np.concatenate([p.pi for p in parts])
188
+ return Sample(
189
+ xy=xy,
190
+ pi=pi,
191
+ weight=1.0 / pi,
192
+ design=f"stratified-{method}",
193
+ stratum=np.array(labels, dtype=object),
194
+ meta={
195
+ "n": int(sum(alloc.values())),
196
+ "allocation": alloc,
197
+ "stratum_area": areas,
198
+ "area": float(sum(areas.values())),
199
+ },
200
+ )
201
+
202
+
203
+ def stratified_pixels(
204
+ mask: RasterMask,
205
+ n: Union[int, Mapping[Hashable, int]],
206
+ allocation: str = "proportional",
207
+ sd: Optional[Mapping[Hashable, float]] = None,
208
+ min_per_stratum: int = 1,
209
+ seed: SeedLike = None,
210
+ ) -> Sample:
211
+ """Stratified random sample of *pixels* (without replacement) by map class.
212
+
213
+ This is the design recommended by Olofsson et al. (2014) for accuracy
214
+ assessment and area estimation of land-cover and change maps: strata are
215
+ the map classes, sample units are pixels, and each pixel in stratum ``h``
216
+ has inclusion probability ``n_h / N_h``.
217
+ """
218
+ rng = as_rng(seed)
219
+ counts = mask.class_counts()
220
+ if isinstance(n, Mapping):
221
+ alloc = {k: int(n.get(k, 0)) for k in counts}
222
+ else:
223
+ alloc = allocate(
224
+ int(n), counts, allocation, sd=sd, min_per_stratum=min_per_stratum, caps=counts
225
+ )
226
+ centres, rows, cols = mask.cell_centers()
227
+ values = mask.data[rows, cols]
228
+ take: List[np.ndarray] = []
229
+ pis: List[np.ndarray] = []
230
+ labs: List[Any] = []
231
+ for k, nh in alloc.items():
232
+ if nh <= 0:
233
+ continue
234
+ idx = np.flatnonzero(values == k)
235
+ if nh > len(idx):
236
+ raise ValueError(f"stratum {k!r} has only {len(idx)} pixels")
237
+ sel = rng.choice(idx, size=nh, replace=False)
238
+ take.append(sel)
239
+ pis.append(np.full(nh, nh / len(idx)))
240
+ labs.extend([k] * nh)
241
+ sel_all = np.concatenate(take)
242
+ pi = np.concatenate(pis)
243
+ return Sample(
244
+ xy=centres[sel_all],
245
+ pi=pi,
246
+ weight=1.0 / pi,
247
+ design="stratified-pixels",
248
+ kind="finite",
249
+ stratum=np.array(labs, dtype=object),
250
+ index=sel_all,
251
+ attrs={"row": rows[sel_all], "col": cols[sel_all]},
252
+ meta={
253
+ "n": int(pi.size),
254
+ "allocation": alloc,
255
+ "stratum_size": counts,
256
+ "population_size": int(sum(counts.values())),
257
+ "cell_area": mask.cell_area,
258
+ },
259
+ )
260
+
261
+
262
+ def poisson_disk(
263
+ domain: Any,
264
+ n: int,
265
+ min_dist: Optional[float] = None,
266
+ max_tries: int = 30000,
267
+ seed: SeedLike = None,
268
+ ) -> Sample:
269
+ """Minimum-distance (simple sequential inhibition) sample.
270
+
271
+ Uniform candidate points are accepted in turn if they lie at least
272
+ ``min_dist`` from every point already accepted. The default distance is
273
+ ``0.6 * sqrt(area / n)``, comfortably below the random-packing limit
274
+ (about ``0.83 * sqrt(area / n)``). Inclusion densities are reported as
275
+ ``n / area``, which is exact only away from the boundary; use GRTS, BAS or
276
+ LPM when design-based inference with exact probabilities matters.
277
+ """
278
+ dom = as_domain(domain)
279
+ rng = as_rng(seed)
280
+ if n < 1:
281
+ raise ValueError("n must be >= 1")
282
+ d = float(min_dist) if min_dist is not None else 0.6 * math.sqrt(dom.area / n)
283
+ d2 = d * d
284
+ acc = np.empty((n, 2))
285
+ k = 0
286
+ tried = 0
287
+ while k < n and tried < max_tries:
288
+ cand = dom.random_points(min(512, max_tries - tried), rng)
289
+ for c in cand:
290
+ tried += 1
291
+ if k == 0 or np.min(((acc[:k] - c) ** 2).sum(axis=1)) >= d2:
292
+ acc[k] = c
293
+ k += 1
294
+ if k == n:
295
+ break
296
+ if k < n:
297
+ raise ValueError(f"only {k} of {n} points could be placed {d:g} apart; lower min_dist or n")
298
+ density = n / dom.area
299
+ return Sample(
300
+ xy=acc,
301
+ pi=np.full(n, density),
302
+ weight=np.full(n, 1.0 / density),
303
+ design="poisson-disk",
304
+ meta={"n": n, "min_dist": d, "area": dom.area, "approximate_pi": True},
305
+ )
306
+
307
+
308
+ _LAYOUTS: Dict[str, FloatArray] = {
309
+ # US Forest Inventory and Analysis plot: centre plus three subplots at 120 degrees.
310
+ "fia": np.array(
311
+ [[0.0, 0.0]]
312
+ + [[math.sin(math.radians(a)), math.cos(math.radians(a))] for a in (0.0, 120.0, 240.0)]
313
+ ),
314
+ "square": np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) - 0.5,
315
+ "line": np.array([[0.0, k] for k in range(5)]) - np.array([0.0, 2.0]),
316
+ "cross": np.array([[0.0, 0.0], [1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0]]),
317
+ }
318
+
319
+
320
+ def cluster(
321
+ domain: Any,
322
+ n_clusters: int,
323
+ spacing: float,
324
+ layout: Union[str, Sequence[Sequence[float]]] = "fia",
325
+ rotation: Union[float, str] = 0.0,
326
+ centers: str = "srs",
327
+ seed: SeedLike = None,
328
+ ) -> Sample:
329
+ """Cluster sample: a fixed pattern of sub-plots around each sampled centre.
330
+
331
+ Centres are drawn with ``centers`` (``"srs"``, ``"bas"`` or ``"grts"``) and
332
+ every centre carries the sub-plot ``layout`` scaled by ``spacing``:
333
+ ``"fia"`` (centre + 3 at 120 degrees), ``"square"`` (2 x 2), ``"line"``
334
+ (5 in a row), ``"cross"`` (5) or an explicit list of unit offsets.
335
+ Sub-plots that fall outside the domain are dropped. Each sub-plot location
336
+ has inclusion density ``n_clusters / area`` (exact away from the edge);
337
+ analyse cluster means, not individual sub-plots, for variance estimation.
338
+ """
339
+ dom = as_domain(domain)
340
+ rng = as_rng(seed)
341
+ offs = _LAYOUTS[layout] if isinstance(layout, str) else np.asarray(layout, dtype=float)
342
+ centre_design = _CONTINUOUS.get(centers)
343
+ if centre_design is None or centers == "lpm":
344
+ raise ValueError("centers must be 'srs', 'bas' or 'grts'")
345
+ base = centre_design(dom, n_clusters, seed=rng)
346
+ cs = base.xy
347
+ m = len(offs)
348
+ if rotation == "random":
349
+ ang = rng.uniform(0, 2 * math.pi, len(cs))
350
+ else:
351
+ ang = np.full(len(cs), math.radians(float(rotation)))
352
+ ca, sa = np.cos(ang)[:, None], np.sin(ang)[:, None]
353
+ ox = offs[None, :, 0] * spacing
354
+ oy = offs[None, :, 1] * spacing
355
+ px = cs[:, 0:1] + ox * ca - oy * sa
356
+ py = cs[:, 1:2] + ox * sa + oy * ca
357
+ xy = np.column_stack([px.ravel(), py.ravel()])
358
+ cid = np.repeat(np.arange(len(cs)), m)
359
+ sub = np.tile(np.arange(m), len(cs))
360
+ keep = dom.contains(xy)
361
+ density = n_clusters / dom.area
362
+ k = int(keep.sum())
363
+ return Sample(
364
+ xy=xy[keep],
365
+ pi=np.full(k, density),
366
+ weight=np.full(k, 1.0 / density),
367
+ design=f"cluster-{centers}",
368
+ attrs={"cluster": cid[keep], "subplot": sub[keep]},
369
+ meta={"n_clusters": n_clusters, "subplots": m, "spacing": spacing, "area": dom.area},
370
+ )
371
+
372
+
373
+ def two_stage(
374
+ psus: Mapping[Hashable, Any],
375
+ n_psu: int,
376
+ m_per_psu: int,
377
+ pps: bool = True,
378
+ seed: SeedLike = None,
379
+ ) -> Sample:
380
+ """Two-stage sample: primary units (polygons), then random sites inside them.
381
+
382
+ First stage: ``n_psu`` primary sampling units (PSUs, e.g. villages, census
383
+ tracts, forest compartments) are drawn without replacement, with
384
+ probability proportional to area (``pps=True``, randomised systematic
385
+ PPS) or with equal probability. Second stage: ``m_per_psu`` uniform sites
386
+ in each selected PSU. With PPS and a fixed ``m_per_psu`` the design is
387
+ self-weighting (every site represents the same area).
388
+ """
389
+ rng = as_rng(seed)
390
+ doms = {k: as_domain(v) for k, v in psus.items()}
391
+ keys = list(doms)
392
+ if not 1 <= n_psu <= len(keys):
393
+ raise ValueError("n_psu must be between 1 and the number of PSUs")
394
+ areas = np.array([doms[k].area for k in keys])
395
+ sizes = areas if pps else np.ones(len(keys))
396
+ pi1 = inclusion_probabilities(sizes, n_psu)
397
+ perm = rng.permutation(len(keys))
398
+ chosen = perm[systematic_pps(pi1[perm], rng)]
399
+ xy_parts: List[FloatArray] = []
400
+ dens: List[np.ndarray] = []
401
+ lab: List[Any] = []
402
+ for c in chosen:
403
+ k = keys[c]
404
+ pts = doms[k].random_points(m_per_psu, rng)
405
+ xy_parts.append(pts)
406
+ dens.append(np.full(m_per_psu, pi1[c] * m_per_psu / areas[c]))
407
+ lab.extend([k] * m_per_psu)
408
+ pi = np.concatenate(dens)
409
+ return Sample(
410
+ xy=np.vstack(xy_parts),
411
+ pi=pi,
412
+ weight=1.0 / pi,
413
+ design="two-stage",
414
+ attrs={
415
+ "psu": np.array(lab, dtype=object),
416
+ "psu_pi": np.repeat(pi1[chosen], m_per_psu),
417
+ },
418
+ meta={"n_psu": n_psu, "m_per_psu": m_per_psu, "pps": pps, "area": float(areas.sum())},
419
+ )
420
+
421
+
422
+ def _as_lines(lines: Any) -> List[FloatArray]:
423
+ if isinstance(lines, Mapping) or hasattr(lines, "__geo_interface__"):
424
+ obj = lines if isinstance(lines, Mapping) else lines.__geo_interface__
425
+ out: List[FloatArray] = []
426
+ _collect_lines(obj, out)
427
+ if not out:
428
+ raise ValueError("no LineString geometry found")
429
+ return out
430
+ arr = [np.asarray(ln, dtype=float)[:, :2] for ln in lines]
431
+ for a in arr:
432
+ if a.ndim != 2 or len(a) < 2:
433
+ raise ValueError("each line needs at least two vertices")
434
+ return arr
435
+
436
+
437
+ def _collect_lines(obj: Mapping[str, Any], out: List[FloatArray]) -> None:
438
+ t = obj.get("type")
439
+ if t == "FeatureCollection":
440
+ for f in obj.get("features", []):
441
+ _collect_lines(f, out)
442
+ elif t == "Feature" and obj.get("geometry"):
443
+ _collect_lines(obj["geometry"], out)
444
+ elif t == "GeometryCollection":
445
+ for g in obj.get("geometries", []):
446
+ _collect_lines(g, out)
447
+ elif t == "LineString":
448
+ out.append(np.asarray(obj["coordinates"], dtype=float)[:, :2])
449
+ elif t == "MultiLineString":
450
+ out.extend(np.asarray(c, dtype=float)[:, :2] for c in obj["coordinates"])
451
+
452
+
453
+ def points_along_lines(
454
+ lines: Any,
455
+ n: Optional[int] = None,
456
+ spacing: Optional[float] = None,
457
+ seed: SeedLike = None,
458
+ ) -> Sample:
459
+ """Systematic points along lines (roads, rivers, hedgerows, transects).
460
+
461
+ The lines are chained end to end; points are placed every ``spacing``
462
+ length units from a uniformly random start in ``[0, spacing)``, so each
463
+ location along the network has the same inclusion density ``1/spacing``
464
+ per unit length. Give ``n`` to derive ``spacing = total_length / n``.
465
+ """
466
+ rng = as_rng(seed)
467
+ segs = _as_lines(lines)
468
+ lens = [np.hypot(*np.diff(s, axis=0).T) for s in segs]
469
+ totals = np.array([float(L.sum()) for L in lens])
470
+ total = float(totals.sum())
471
+ if total <= 0:
472
+ raise ValueError("lines have zero length")
473
+ if spacing is None:
474
+ if n is None or n < 1:
475
+ raise ValueError("give n >= 1 or spacing")
476
+ spacing = total / n
477
+ s = float(spacing)
478
+ pos = rng.uniform(0, s) + s * np.arange(int(math.floor(total / s)) + 1)
479
+ pos = pos[pos < total]
480
+ starts = np.concatenate([[0.0], np.cumsum(totals)[:-1]])
481
+ line_id = np.searchsorted(np.cumsum(totals), pos, side="right")
482
+ line_id = np.minimum(line_id, len(segs) - 1)
483
+ xy = np.empty((len(pos), 2))
484
+ chain = pos - starts[line_id]
485
+ for k in range(len(segs)):
486
+ sel = line_id == k
487
+ if not sel.any():
488
+ continue
489
+ cum = np.concatenate([[0.0], np.cumsum(lens[k])])
490
+ xy[sel, 0] = np.interp(chain[sel], cum, segs[k][:, 0])
491
+ xy[sel, 1] = np.interp(chain[sel], cum, segs[k][:, 1])
492
+ m = len(pos)
493
+ return Sample(
494
+ xy=xy,
495
+ pi=np.full(m, 1.0 / s),
496
+ weight=np.full(m, s),
497
+ design="line-systematic",
498
+ attrs={"line": line_id, "chainage": chain},
499
+ meta={"spacing": s, "total_length": total},
500
+ lines=segs,
501
+ )
502
+
503
+
504
+ def random_transects(
505
+ domain: Any,
506
+ n: int,
507
+ length: float,
508
+ bearing: Optional[float] = None,
509
+ spacing: Optional[float] = None,
510
+ seed: SeedLike = None,
511
+ ) -> Sample:
512
+ """Random straight transects centred on uniform random points.
513
+
514
+ Each transect has the given ``length`` and a uniformly random bearing (or
515
+ a fixed ``bearing`` in degrees clockwise from north). With ``spacing`` the
516
+ sample contains observation points every ``spacing`` along each transect
517
+ (points outside the domain are dropped); otherwise it contains the centres.
518
+ The transect lines are kept in ``Sample.lines``.
519
+ """
520
+ dom = as_domain(domain)
521
+ rng = as_rng(seed)
522
+ if n < 1 or length <= 0:
523
+ raise ValueError("n must be >= 1 and length > 0")
524
+ centres = dom.random_points(n, rng)
525
+ if bearing is None:
526
+ az = rng.uniform(0.0, 180.0, n)
527
+ else:
528
+ az = np.full(n, float(bearing))
529
+ t = np.radians(az)
530
+ dx, dy = np.sin(t) * length / 2, np.cos(t) * length / 2
531
+ lines = [
532
+ np.array([[c[0] - a, c[1] - b], [c[0] + a, c[1] + b]]) for c, a, b in zip(centres, dx, dy)
533
+ ]
534
+ if spacing is None:
535
+ xy = centres
536
+ tid = np.arange(n)
537
+ chain = np.full(n, length / 2)
538
+ else:
539
+ offs = np.arange(0.0, length + 1e-9, float(spacing))
540
+ frac = offs / length
541
+ xy_l, tid_l, ch_l = [], [], []
542
+ for k, ln in enumerate(lines):
543
+ p = ln[0] + frac[:, None] * (ln[1] - ln[0])
544
+ xy_l.append(p)
545
+ tid_l.append(np.full(len(p), k))
546
+ ch_l.append(offs)
547
+ xy = np.vstack(xy_l)
548
+ tid = np.concatenate(tid_l)
549
+ chain = np.concatenate(ch_l)
550
+ keep = dom.contains(xy)
551
+ xy, tid, chain = xy[keep], tid[keep], chain[keep]
552
+ density = n / dom.area
553
+ m = len(xy)
554
+ return Sample(
555
+ xy=xy,
556
+ pi=np.full(m, density),
557
+ weight=np.full(m, 1.0 / density),
558
+ design="random-transects",
559
+ attrs={"transect": tid, "chainage": chain, "bearing": az[tid]},
560
+ meta={"n": n, "length": length, "area": dom.area},
561
+ lines=lines,
562
+ )