radphen-iadpython 0.7.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.
iadpython/layer.py ADDED
@@ -0,0 +1,241 @@
1
+ """One layer of a multilayer sample.
2
+
3
+ A :class:`Layer` bundles the optical properties of one slab in a stack. The
4
+ two independent axes of a simulation are kept structurally distinct:
5
+
6
+ * **layer axis** -- the position of the ``Layer`` in the ``layers`` list passed
7
+ to :class:`iadpython.Sample`;
8
+ * **wavelength axis** -- arrays *inside* each ``Layer`` attribute (``a``,
9
+ ``b``, ``g``, ``n`` may each be a scalar or a 1-D array over wavelengths;
10
+ a ``TABULATED`` phase function carries one ``pf_data`` column per
11
+ wavelength).
12
+
13
+ A scalar attribute is automatically held constant (broadcast) across all
14
+ wavelengths of the sweep, so layers with constant properties can be freely
15
+ mixed with wavelength-dependent ones.
16
+
17
+ Example::
18
+
19
+ >>> import iadpython as iad
20
+ >>> l0 = iad.Layer(a=0.95, b=0.84, g=0.20, n=1.35, d=0.10)
21
+ >>> l1 = iad.Layer(a=0.81, b=0.31, g=0.85, n=2.00, d=0.05)
22
+ >>> s = iad.Sample(layers=[l0, l1], quad_pts=16)
23
+ >>> ur1, ut1, uru, utu = s.rt()
24
+ """
25
+
26
+ import numpy as np
27
+
28
+ __all__ = ("Layer",)
29
+
30
+ _PF_TYPES = ("HG", "TABULATED", "MOMENTS")
31
+
32
+
33
+ def _as_scalar_or_1d(value, name):
34
+ """Return ``value`` unchanged if scalar, else as a 1-D float array.
35
+
36
+ Lists/tuples are converted to numpy arrays so downstream code can rely on
37
+ numpy semantics. Anything with more than one dimension is rejected --
38
+ the wavelength axis is the only in-attribute axis a ``Layer`` carries.
39
+ """
40
+ if value is None or np.isscalar(value):
41
+ return value
42
+ arr = np.asarray(value)
43
+ if arr.ndim == 0:
44
+ return arr.item()
45
+ if arr.ndim > 1:
46
+ raise ValueError(
47
+ "Layer.%s must be a scalar or 1-D wavelength array, got shape %s"
48
+ % (name, arr.shape)
49
+ )
50
+ return arr
51
+
52
+
53
+ def _fmt(value):
54
+ """Compact scalar-or-range formatting for repr (no iadpython imports)."""
55
+ if value is None:
56
+ return "None"
57
+ if np.isscalar(value) or np.ndim(value) == 0:
58
+ return "%.3f" % np.real(value)
59
+ arr = np.asarray(value)
60
+ return "%.3f..%.3f[%d]" % (np.real(arr.min()), np.real(arr.max()), arr.size)
61
+
62
+
63
+ class Layer:
64
+ """Optical properties of one slab in a multilayer sample.
65
+
66
+ Attributes:
67
+ a: single-scattering albedo (scalar or wavelength array)
68
+ b: optical thickness (scalar or wavelength array)
69
+ g: scattering anisotropy, used when ``pf_type == 'HG'``
70
+ (scalar or wavelength array)
71
+ d: physical thickness [mm] (scalar; informational -- the radiative
72
+ transfer solve consumes ``b``, not ``d``)
73
+ n: refractive index (scalar or wavelength array)
74
+ pf_type: ``'HG'`` (Henyey-Greenstein, from ``g``), ``'TABULATED'``
75
+ (from ``pf_data``), or ``'MOMENTS'`` (from ``pf_data``)
76
+ pf_data: for ``'TABULATED'``, a pandas ``DataFrame`` indexed by
77
+ mu = cos(theta) with one column per wavelength (a single column
78
+ is broadcast across the sweep). For ``'MOMENTS'``, a plain
79
+ ndarray of Legendre moments a_l -- 1-D ``(n_mom,)`` (broadcast
80
+ across the sweep) or 2-D ``(n_mom, n_wavelength)`` -- supplied
81
+ directly instead of a tabulated phase function, bypassing the
82
+ spline+quadrature step ``'TABULATED'`` requires. Required when
83
+ ``pf_type`` is ``'TABULATED'`` or ``'MOMENTS'``.
84
+
85
+ Cross-layer consistency (matching wavelength counts, physical ranges) is
86
+ checked at solve time by ``validate_layers`` so partially built layers can
87
+ be edited freely before use.
88
+
89
+ Note on ``pf_type='MOMENTS'`` in a mismatched-refractive-index stack:
90
+ unlike ``'TABULATED'`` (a continuous curve that can be re-integrated to
91
+ any order on demand), a MOMENTS array is a fixed, finite set of
92
+ precomputed numbers. The mismatched-index solver's internal master grid
93
+ may need a *larger* effective quadrature order for some sub-layers than
94
+ the Sample's own ``quad_pts`` -- so a MOMENTS array sized only for the
95
+ nominal ``quad_pts`` can raise "needs at least N moments" partway
96
+ through a solve. Supply generously more moments than ``2*quad_pts+1``
97
+ when index-mismatched layers are involved.
98
+ """
99
+
100
+ def __init__(self, a=0, b=1, g=0, d=1, n=1, pf_type="HG", pf_data=None):
101
+ """Create one layer; see class docstring for attribute meanings."""
102
+ self.a = _as_scalar_or_1d(a, "a")
103
+ self.b = _as_scalar_or_1d(b, "b")
104
+ self.g = _as_scalar_or_1d(g, "g")
105
+ self.n = _as_scalar_or_1d(n, "n")
106
+ self.d = d
107
+
108
+ self.pf_type = str(pf_type).upper()
109
+ if self.pf_type not in _PF_TYPES:
110
+ raise ValueError(
111
+ "Layer pf_type must be one of %s, got %r" % (_PF_TYPES, pf_type)
112
+ )
113
+ if self.pf_type == "TABULATED":
114
+ # duck-typed DataFrame check to keep this module pandas-free
115
+ if pf_data is None or not hasattr(pf_data, "iloc"):
116
+ raise TypeError(
117
+ "pf_type='TABULATED' requires pf_data to be a pandas "
118
+ "DataFrame (index = cos(theta), one column per wavelength)"
119
+ )
120
+ elif self.pf_type == "MOMENTS":
121
+ # Legendre moments a_l, bypassing TABULATED's spline+quadrature.
122
+ # Explicitly reject DataFrame-like input -- np.asarray(df) would
123
+ # otherwise silently coerce it instead of failing loudly.
124
+ if pf_data is None or hasattr(pf_data, "iloc"):
125
+ raise TypeError(
126
+ "pf_type='MOMENTS' requires pf_data to be a plain array "
127
+ "of Legendre moments (1-D), or a 2-D array (n_mom, "
128
+ "n_wavelength) for a wavelength sweep -- not a DataFrame"
129
+ )
130
+ pf_data = np.asarray(pf_data, dtype=float)
131
+ if pf_data.ndim not in (1, 2):
132
+ raise ValueError(
133
+ "MOMENTS pf_data must be 1-D (n_mom,) or 2-D (n_mom, "
134
+ "n_wavelength), got shape %s" % (pf_data.shape,)
135
+ )
136
+ self.pf_data = pf_data
137
+
138
+ def n_wavelengths(self):
139
+ """Number of wavelengths this layer's own data spans (1 if scalar)."""
140
+ counts = [1]
141
+ for value in (self.a, self.b, self.g, self.n):
142
+ if not (value is None or np.isscalar(value) or np.ndim(value) == 0):
143
+ counts.append(len(value))
144
+ if self.pf_type == "TABULATED" and self.pf_data is not None:
145
+ counts.append(self.pf_data.shape[1])
146
+ if self.pf_type == "MOMENTS" and self.pf_data is not None and self.pf_data.ndim == 2:
147
+ counts.append(self.pf_data.shape[1])
148
+ return max(counts)
149
+
150
+ def __repr__(self):
151
+ """Compact one-line description."""
152
+ s = "Layer(a=%s, b=%s, g=%s, n=%s, d=%s, pf=%s" % (
153
+ _fmt(self.a),
154
+ _fmt(self.b),
155
+ _fmt(self.g),
156
+ _fmt(self.n),
157
+ _fmt(self.d),
158
+ self.pf_type,
159
+ )
160
+ if self.pf_type == "TABULATED" and self.pf_data is not None:
161
+ s += "[%d col]" % self.pf_data.shape[1]
162
+ if self.pf_type == "MOMENTS" and self.pf_data is not None:
163
+ if self.pf_data.ndim == 2:
164
+ s += "[%d mom x %d col]" % self.pf_data.shape
165
+ else:
166
+ s += "[%d mom]" % self.pf_data.shape[0]
167
+ return s + ")"
168
+
169
+
170
+ def validate_layers(layers):
171
+ """Check a list of layers for cross-layer consistency.
172
+
173
+ Determines the wavelength count of the sweep as the maximum over every
174
+ layer's array lengths and ``pf_data`` column counts, then verifies that
175
+ each per-layer axis is compatible with it (length 1 broadcasts).
176
+
177
+ Args:
178
+ layers: sequence of :class:`Layer` objects (at least one)
179
+
180
+ Returns:
181
+ n_wavelengths: the number of wavelengths of the sweep (>= 1)
182
+
183
+ Raises:
184
+ RuntimeError: on empty input, non-Layer entries, conflicting
185
+ wavelength counts, or out-of-range ``a``/``b`` values.
186
+ """
187
+ if layers is None or len(layers) == 0:
188
+ raise RuntimeError("Sample(layers=...) needs at least one Layer")
189
+
190
+ for k, layer in enumerate(layers):
191
+ if not isinstance(layer, Layer):
192
+ raise RuntimeError(
193
+ "layers[%d] is %s, expected iadpython.Layer" % (k, type(layer).__name__)
194
+ )
195
+
196
+ n_wave = max(layer.n_wavelengths() for layer in layers)
197
+
198
+ for k, layer in enumerate(layers):
199
+ for name in ("a", "b", "g", "n"):
200
+ value = getattr(layer, name)
201
+ if value is None or np.isscalar(value) or np.ndim(value) == 0:
202
+ continue
203
+ if len(value) not in (1, n_wave):
204
+ raise RuntimeError(
205
+ "layers[%d].%s has %d wavelengths but the stack sweep "
206
+ "has %d (lengths must be 1 or match)"
207
+ % (k, name, len(value), n_wave)
208
+ )
209
+
210
+ if not (np.isscalar(layer.d) or np.ndim(layer.d) == 0):
211
+ raise RuntimeError(
212
+ "layers[%d].d must be a scalar physical thickness "
213
+ "(wavelength-dependent d is not meaningful)" % k
214
+ )
215
+
216
+ if layer.pf_type == "TABULATED":
217
+ ncols = layer.pf_data.shape[1]
218
+ if ncols not in (1, n_wave):
219
+ raise RuntimeError(
220
+ "layers[%d].pf_data has %d columns but the stack sweep "
221
+ "has %d wavelengths (columns must be 1 or match)"
222
+ % (k, ncols, n_wave)
223
+ )
224
+
225
+ if layer.pf_type == "MOMENTS" and layer.pf_data.ndim == 2:
226
+ ncols = layer.pf_data.shape[1]
227
+ if ncols not in (1, n_wave):
228
+ raise RuntimeError(
229
+ "layers[%d].pf_data has %d columns but the stack sweep "
230
+ "has %d wavelengths (columns must be 1 or match)"
231
+ % (k, ncols, n_wave)
232
+ )
233
+
234
+ a_arr = np.atleast_1d(layer.a).astype(float)
235
+ b_arr = np.atleast_1d(layer.b).astype(float)
236
+ if np.any(a_arr < 0) or np.any(a_arr > 1):
237
+ raise RuntimeError("layers[%d].a must lie in [0, 1]" % k)
238
+ if np.any(b_arr < 0):
239
+ raise RuntimeError("layers[%d].b must be non-negative" % k)
240
+
241
+ return n_wave
iadpython/nist.py ADDED
@@ -0,0 +1,114 @@
1
+ """Module for importing reflection spectra from NIST database.
2
+
3
+ Example::
4
+
5
+ >>> import numpy as np
6
+ >>> import matplotlib.pyplot as plt
7
+ >>> import iadpython as iad
8
+
9
+ >>> # Retrieve and plot subject 5
10
+
11
+ >>> subject_number = 5
12
+ >>> lambda0, R = iad.subject_average_reflectance(subject_number)
13
+ >>> plt.plot(lambda0, R)
14
+ >>> plt.xlabel("Wavelength (nm)")
15
+ >>> plt.ylabel("Total Reflectance")
16
+ >>> plt.title("Subject #%d" % subject_number)
17
+ >>> plt.show()
18
+
19
+ >>> # Retrieve and plot all subjects
20
+
21
+ >>> lambda0, R = iad.all_average_reflectances()
22
+ >>> for i in range(100):
23
+ >>> plt.plot(lambda0, R[:, i])
24
+ >>> plt.xlabel("Wavelength (nm)")
25
+ >>> plt.ylabel("Total Reflectance")
26
+ >>> plt.title("All Subjects")
27
+ >>> plt.show()
28
+
29
+ Reference:
30
+ <https://nvlpubs.nist.gov/nistpubs/jres/122/jres.122.026.pdf>
31
+ nist_db = 'https://doi.org/10.18434/M38597'
32
+ """
33
+
34
+ import os
35
+ import numpy as np
36
+
37
+ __all__ = (
38
+ "subject_reflectances",
39
+ "subject_average_reflectance",
40
+ "all_average_reflectances",
41
+ )
42
+
43
+
44
+ def get_subject_data(cols):
45
+ """
46
+ Load and return data from a CSV file based on specified columns.
47
+
48
+ This function reads data from a CSV file located in the 'data' directory relative
49
+ to the script's location and allows you to specify the columns to extract.
50
+
51
+ Args:
52
+ cols: A tuple of column indices to extract from the CSV file.
53
+
54
+ Returns:
55
+ An array containing the requested data.
56
+
57
+ Example:
58
+ To extract columns 1 and 2 from the CSV file, you can call the function like this:
59
+ >>> cols_to_extract = (1, 2)
60
+ >>> data = get_subject_data(cols_to_extract)
61
+ """
62
+ script_dir = os.path.dirname(__file__) # Path to directory of this file
63
+ data_file_path = os.path.join(script_dir, "data", "M38597.csv")
64
+ data = np.loadtxt(data_file_path, skiprows=8, usecols=cols, delimiter=",", encoding="latin1")
65
+ return data
66
+
67
+
68
+ def subject_reflectances(subject_number):
69
+ """Extract all reflection data for one subject."""
70
+ if subject_number <= 0 or subject_number > 100:
71
+ raise ValueError("subject_number must be 1 to 100")
72
+
73
+ col = (subject_number - 1) * 4 + 1
74
+
75
+ cols = (0, col, col + 1, col + 2, col + 3)
76
+
77
+ data = get_subject_data(cols)
78
+
79
+ lambda0 = data[:, 0]
80
+ r_1 = data[:, 1]
81
+ r_2 = data[:, 2]
82
+ r_3 = data[:, 3]
83
+ r_ave = data[:, 4]
84
+
85
+ return lambda0, r_1, r_2, r_3, r_ave
86
+
87
+
88
+ def subject_average_reflectance(subject_number):
89
+ """Extract average reflection for one subject."""
90
+ if subject_number <= 0 or subject_number > 100:
91
+ raise ValueError("subject_number must be 1 to 100")
92
+
93
+ col = (subject_number - 1) * 4 + 4
94
+
95
+ cols = (0, col)
96
+
97
+ data = get_subject_data(cols)
98
+
99
+ lambda0 = data[:, 0]
100
+ r_ave = data[:, 1]
101
+
102
+ return lambda0, r_ave
103
+
104
+
105
+ def all_average_reflectances():
106
+ """Extract average reflectance for all subjects."""
107
+ cols = [4 * i for i in range(101)]
108
+
109
+ data = get_subject_data(cols)
110
+
111
+ lambda0 = data[:, 0]
112
+ r_ave = data[:, 1:]
113
+
114
+ return lambda0, r_ave
iadpython/port.py ADDED
@@ -0,0 +1,242 @@
1
+ # pylint: disable=protected-access)
2
+ """
3
+ Implementation of container class for an integrating sphere port.
4
+
5
+ This module defines the Port class, which is integral to the analysis of light properties
6
+ within an integrating sphere. The Port class specifically models a port on the sphere's
7
+ surface, calculating and storing its geometrical properties and its interaction with light.
8
+
9
+ Classes:
10
+ Port: Represents a port in an integrating sphere. It holds information about
11
+ the port's dimensions, position, and optical properties. The class provides
12
+ methods to calculate the port's geometrical characteristics, such as the
13
+ area of the spherical cap it forms, its sagitta, and the chord distance from
14
+ the port's center to its edge. Additionally, it includes functionality to
15
+ assess whether a given point on the sphere's surface falls within the port's
16
+ boundaries.
17
+
18
+ Functions:
19
+ - cap_area_exact: Calculates the area of the spherical cap formed by the port.
20
+ - relative_cap_area: Calculates the approximate relative area of the spherical cap.
21
+ - calculate_sagitta: Determines the sagitta (or height) of the spherical cap.
22
+ - max_center_chord: Computes the maximum distance from the port's center to its edge.
23
+ - cap_area_exact: Calculates the exact relative area of the spherical cap.
24
+ - hit: Checks if a point on the sphere falls within the port's boundaries.
25
+
26
+ Example:
27
+ >>> import iadpython as iad
28
+ >>> sphere = iad.Sphere(200, 20) # Example of creating a sphere object
29
+ >>> port = Port(sphere, 20, 0.5, 0, 0, 0)
30
+ >>> print(port)
31
+
32
+ See Also:
33
+ Sphere: Another class/module within the iadpython package that models the entire integrating sphere.
34
+ """
35
+
36
+ import random
37
+ import numpy as np
38
+ import iadpython as iad
39
+
40
+
41
+ def uniform_disk():
42
+ """
43
+ Generate a point uniformly distributed on a unit disk.
44
+
45
+ This function generates and returns a point (x, y) that is uniformly distributed
46
+ over the area of a unit disk centered at the origin (0, 0). The unit disk is
47
+ defined as the set of all points (x, y) such that x^2 + y^2 <= 1. The method
48
+ uses rejection sampling, where random points are generated within the square
49
+ that bounds the unit disk, and only points that fall within the disk are accepted.
50
+
51
+ Returns:
52
+ tuple of (float, float, float): A point (x, y) where x and y are the
53
+ coordinates of the point uniformly distributed within the unit disk,
54
+ and s is the square of the distance from the origin to this point.
55
+ """
56
+ s = 2
57
+ while s > 1:
58
+ x = 2 * random.random() - 1
59
+ y = 2 * random.random() - 1
60
+ s = x * x + y * y
61
+ return x, y, s
62
+
63
+
64
+ class Port:
65
+ """
66
+ Container class for a port on an integrating sphere.
67
+
68
+ A container class for a port in an integrating sphere, which is a structure
69
+ used to analyze light properties. The class calculates and stores various
70
+ geometrical properties of the port, such as its diameter, position, and the
71
+ relative area of the spherical cap it forms on the sphere's surface.
72
+
73
+ Attributes:
74
+ sphere (object): Reference to the sphere the port belongs to.
75
+ d (float): Diameter of the port in millimeters (mm).
76
+ uru (float): Diffuse reflectance of diffuse light on the port.
77
+ x (float): X-coordinate of the port's center on the sphere's surface.
78
+ y (float): Y-coordinate of the port's center on the sphere's surface.
79
+ z (float): Z-coordinate of the port's center on the sphere's surface.
80
+ a (float): Relative area of the port's spherical cap to the sphere's surface.
81
+ chord2 (float): Square of the distance from the port center to the port edge.
82
+ sagitta (float): The sagitta (height) of the spherical cap formed by the port.
83
+
84
+ Examples:
85
+ Importing the module and creating a sphere with a port:
86
+
87
+ ```python
88
+ import iadpython as iad
89
+ s = iad.Sphere(200, 20)
90
+ print(s.sample)
91
+ """
92
+
93
+ def __init__(self, sphere, d, uru=0, x=0, y=0, z=0):
94
+ """
95
+ Initializes a Port object with specified dimensions and position within a sphere.
96
+
97
+ The coordinates (x,y,z) indicate the center of the port and must be located
98
+ on the sphere surface. They default to the center of the sphere (0,0,0). The
99
+ cap location is only used by the `MCSphere` class which uses the `hit()`
100
+ method.
101
+
102
+ Args:
103
+ sphere: The sphere object to which the port belongs.
104
+ d: Diameter of the port in millimeters.
105
+ uru: Diffuse reflectance of diffuse light on the port.
106
+ x: X-coordinate of the port's center on the sphere's surface.
107
+ y: Y-coordinate of the port's center on the sphere's surface.
108
+ z: Z-coordinate of the port's center on the sphere's surface.
109
+ """
110
+ self.sphere = sphere
111
+ self._d = d
112
+ self.uru = uru
113
+
114
+ self.x = x
115
+ self.y = y
116
+ self.z = z
117
+
118
+ self.a = self.relative_cap_area()
119
+ self.sagitta = self.calculate_sagitta()
120
+ self.chord2 = self.max_center_chord() ** 2
121
+
122
+ def __repr__(self):
123
+ """Short string for port."""
124
+ return "d=%5.1fmm, uru=%s\n" % (
125
+ self.d,
126
+ iad.stringify("%5.1f%%", self.uru * 100),
127
+ )
128
+
129
+ def __str__(self):
130
+ """Return basic details as a string for printing."""
131
+ s = ""
132
+ s += " diameter = %7.2f mm\n" % self.d
133
+ s += " radius = %7.2f mm\n" % (self.d / 2)
134
+ s += " chord = %7.2f mm\n" % np.sqrt(self.chord2)
135
+ s += " sagitta = %7.2f mm\n" % self.sagitta
136
+ s += " center = (%6.1f, %6.1f, %6.1f) mm\n" % (self.x, self.y, self.z)
137
+ s += " relative area = %7.2f%%\n" % (self.a * 100)
138
+ s += " uru = %s\n" % iad.stringify("%5.1f%%", self.uru * 100)
139
+ return s
140
+
141
+ @property
142
+ def d(self):
143
+ """float: Gets the diameter of the port."""
144
+ return self._d
145
+
146
+ @d.setter
147
+ def d(self, value):
148
+ """
149
+ Sets the diameter of the port and recalculates geometrical properties.
150
+
151
+ Args:
152
+ value (float): The new diameter of the port.
153
+
154
+ Raises:
155
+ AssertionError: If the new diameter exceeds the diameter of the sphere.
156
+ """
157
+ assert self.sphere.d >= value, "Sphere must be bigger than the port."
158
+ self._d = value
159
+ self.a = self.relative_cap_area()
160
+ self.sagitta = self.calculate_sagitta()
161
+ self.chord2 = self.max_center_chord()
162
+ self.sphere._a_wall = 1 - self.sphere.sample.a - self.sphere.third.a - self.sphere.detector.a
163
+
164
+ def cap_area(self):
165
+ """Approximate area of spherical cap."""
166
+ return np.pi * self.d**2 / 4
167
+
168
+ def cap_area_exact(self):
169
+ """Exact area of spherical cap."""
170
+ R = self.sphere.d / 2
171
+ r = self.d / 2
172
+ h = R - np.sqrt(R**2 - r**2)
173
+ return 2 * np.pi * R * h
174
+
175
+ def relative_cap_area(self):
176
+ """Calculate approx relative area of spherical cap."""
177
+ R = self.sphere.d / 2
178
+ r = self.d / 2
179
+ return r**2 / (4 * R**2)
180
+
181
+ def calculate_sagitta(self):
182
+ """Calculate sagitta of spherical cap."""
183
+ R = self.sphere.d / 2
184
+ r = self.d / 2
185
+ return R - np.sqrt(R**2 - r**2)
186
+
187
+ def max_center_chord(self):
188
+ """Distance of chord from port center (on sphere) to port edge."""
189
+ r = self.d / 2
190
+ s = self.sagitta
191
+ return np.sqrt(r**2 + s**2)
192
+
193
+ def relative_cap_area_exact(self):
194
+ """Calculate relative area of spherical cap."""
195
+ h = (self.sphere.d - np.sqrt(self.sphere.d**2 - self.d**2)) / 2
196
+ return h / self.sphere.d
197
+
198
+ def hit(self):
199
+ """Determine if point on the sphere within the port."""
200
+ r2 = (self.x - self.sphere.x) ** 2
201
+ r2 += (self.y - self.sphere.y) ** 2
202
+ r2 += (self.z - self.sphere.z) ** 2
203
+ # print('cap center (%7.2f, %7.2f, %7.2f)' % (self.x,self.y,self.z))
204
+ # print('pt on sph (%7.2f, %7.2f, %7.2f)' % (self.sphere.x,self.sphere.y,self.sphere.z))
205
+ # print("cap distance %7.2f %7.2f"% (r2, self.chord2))
206
+ return r2 < self.chord2
207
+
208
+ def set_center(self, x, y, z):
209
+ """Centers the cap at x,y,z."""
210
+ assert x**2 + y**2 + z**2 - (self.sphere.d / 2) ** 2 < 1e-6, "center not on sphere."
211
+ self.x = x
212
+ self.y = y
213
+ self.z = z
214
+
215
+ def uniform(self):
216
+ """
217
+ Generate a point uniformly distributed on a spherical cap.
218
+
219
+ This function generates points uniformly distributed over a spherical cap,
220
+ defined by a specified sagitta (height of the cap from its base to the top)
221
+ and sphere radius. The spherical cap can be positioned either at the top or
222
+ bottom of the sphere. The method utilizes principles from uniform distribution
223
+ on a disk and transforms these points to the spherical cap geometry.
224
+
225
+ WARNING: The cap assumed to be at the top of a sphere. Nothing is done to rotate
226
+ the points so they align with the center of the cap!
227
+
228
+ The algorithm to generate a random point on a spherical cap is adapted from
229
+ http://marc-b-reynolds.github.io/distribution/2016/11/28/Uniform.html
230
+
231
+ Args:
232
+ sagitta: The height of the spherical cap from its base to the top.
233
+ sphere_radius: The radius of the sphere with the cap.
234
+
235
+ Returns:
236
+ A numpy array of a random point on the spherical cap surface.
237
+ """
238
+ h = self.sagitta / (self.sphere.d / 2)
239
+ ux, uy, s = uniform_disk()
240
+ root = np.sqrt(h * (2 - h * s))
241
+ point = np.array([ux * root, uy * root, 1 - h * s]) * (self.sphere.d / 2)
242
+ return point