guv-calcs 0.0.2__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 J. Vivian Belenky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.1
2
+ Name: guv-calcs
3
+ Version: 0.0.2
4
+ Summary: A library for carrying out fluence and irradiance calculations for germicidal UV (GUV) applications.
5
+ Home-page: https://github.com/jvbelenky/guv-calcs
6
+ Author: J. Vivian Belenky
7
+ Author-email: j.vivian.belenky@outlook.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: numpy
15
+ Requires-Dist: scipy
16
+ Requires-Dist: matplotlib
17
+ Requires-Dist: plotly
18
+ Requires-Dist: photompy
19
+
20
+ GUV Calcs
21
+ ======================
22
+
23
+ A library for carrying out fluence and irradiance calculations for germicidal UV (GUV) applications.
24
+
25
+ ## Installation
26
+
27
+ Install with pip:
28
+
29
+ pip install guv-calcs
30
+
31
+ Alternatively, clone the repo and install locally:
32
+
33
+ git clone https://github.com/jvbelenky/guv-calcs.git
34
+ cd guv-calcs
35
+ python setup.py sdist
36
+ pip install .
37
+
38
+
39
+ ## Example Usage
40
+
41
+ *Coming soon...*
42
+
43
+ ## Roadmap
44
+
45
+ *Coming soon...*
46
+
47
+ ## License
48
+
49
+ Distributed under the MIT License. See `LICENSE.txt` for more information.
50
+
51
+ ## Contact
52
+
53
+ Vivian Belenky - j.vivian.belenky@outlook.com - [@vivian_belenky](https://twitter.com/vivian_belenky)
54
+
55
+ Project Link: [https://github.com/jvbelenky/guv-calcs/](https://github.com/jvbelenky/guv-calcs/)
@@ -0,0 +1,36 @@
1
+ GUV Calcs
2
+ ======================
3
+
4
+ A library for carrying out fluence and irradiance calculations for germicidal UV (GUV) applications.
5
+
6
+ ## Installation
7
+
8
+ Install with pip:
9
+
10
+ pip install guv-calcs
11
+
12
+ Alternatively, clone the repo and install locally:
13
+
14
+ git clone https://github.com/jvbelenky/guv-calcs.git
15
+ cd guv-calcs
16
+ python setup.py sdist
17
+ pip install .
18
+
19
+
20
+ ## Example Usage
21
+
22
+ *Coming soon...*
23
+
24
+ ## Roadmap
25
+
26
+ *Coming soon...*
27
+
28
+ ## License
29
+
30
+ Distributed under the MIT License. See `LICENSE.txt` for more information.
31
+
32
+ ## Contact
33
+
34
+ Vivian Belenky - j.vivian.belenky@outlook.com - [@vivian_belenky](https://twitter.com/vivian_belenky)
35
+
36
+ Project Link: [https://github.com/jvbelenky/guv-calcs/](https://github.com/jvbelenky/guv-calcs/)
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ with open("README.md", "r") as fh:
4
+ long_description = fh.read()
5
+ setup(
6
+ name="guv-calcs",
7
+ url="https://github.com/jvbelenky/guv-calcs",
8
+ version="0.0.2",
9
+ author="J. Vivian Belenky",
10
+ author_email="j.vivian.belenky@outlook.com",
11
+ description="A library for carrying out fluence and irradiance calculations for germicidal UV (GUV) applications.",
12
+ long_description=long_description,
13
+ long_description_content_type="text/markdown",
14
+ classifiers=[
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "License :: OSI Approved :: MIT License",
18
+ ],
19
+ packages=find_packages('src'),
20
+ package_dir={'': 'src'},
21
+ zip_safe=True,
22
+ python_requires=">=3.8",
23
+ install_requires=[
24
+ "numpy",
25
+ "scipy",
26
+ "matplotlib",
27
+ "plotly",
28
+ "photompy"
29
+ ],
30
+ )
@@ -0,0 +1,14 @@
1
+ from .room import Room
2
+ from .lamp import Lamp
3
+ from .calc_zone import CalcVol, CalcPlane
4
+ from .trigonometry import to_polar, to_cartesian, attitude
5
+
6
+ __all__ = [
7
+ "Room",
8
+ "Lamp",
9
+ "CalcVol",
10
+ "CalcPlane",
11
+ "to_polar",
12
+ "to_cartesian",
13
+ "attitude",
14
+ ]
@@ -0,0 +1,370 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ from photompy import get_intensity_vectorized
4
+ from .trigonometry import attitude, to_polar
5
+
6
+
7
+ class CalcZone(object):
8
+ """
9
+ Base class representing a calculation zone.
10
+
11
+ This class provides a template for setting up zones within which various
12
+ calculations related to lighting conditions are performed. Subclasses should
13
+ provide specific implementations of the coordinate setting method.
14
+
15
+ NOTE: I changed this from an abstract base class to an object superclass
16
+ to make it more convenient to work with the website, but this class doesn't really
17
+ work on its own
18
+
19
+ Parameters:
20
+ --------
21
+ zone_id: str
22
+ identification tag for internal tracking
23
+ name: str, default=None
24
+ externally visible name for zone
25
+ dimensions: array of floats, default=None
26
+ array of len 2 if CalcPlane, of len 3 if CalcVol
27
+ offset: bool, default=True
28
+ fov80: bool, default=False
29
+ apply 80 degree field of view filtering - used for calculating eye limits
30
+ vert: bool, default=False
31
+ calculate vertical irradiance only
32
+ horiz: bool, default=False
33
+ calculate horizontal irradiance only
34
+ dose: bool, default=False
35
+ whether to calculate a dose over N hours or just fluence
36
+ hours: float, default = 8.0
37
+ number of hours to calculate dose over. Only relevant if dose is True.
38
+ enabled: bool, default = True
39
+ whether or not the calc zone is enabled for calculations
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ zone_id,
45
+ name=None,
46
+ offset=None,
47
+ fov80=None,
48
+ vert=None,
49
+ horiz=None,
50
+ dose=None,
51
+ hours=None,
52
+ enabled=None,
53
+ ):
54
+ self.zone_id = zone_id
55
+ self.name = zone_id if name is None else name
56
+ self.enabled = True if enabled is None else enabled
57
+ self.offset = True if offset is None else offset
58
+ self.fov80 = False if fov80 is None else fov80
59
+ self.vert = False if vert is None else vert
60
+ self.horiz = False if horiz is None else horiz
61
+ self.dose = False if dose is None else dose
62
+ if self.dose:
63
+ self.units = "mJ/cm2"
64
+ else:
65
+ self.units = "uW/cm2"
66
+ self.hours = 8.0 if hours is None else hours # only used if dose is true
67
+ # these will all be calculated after spacing is set, which is set in the subclass
68
+ self.x1 = None
69
+ self.x2 = None
70
+ self.y1 = None
71
+ self.y2 = None
72
+ self.z1 = None
73
+ self.z2 = None
74
+ self.height = None
75
+ self.spacing = None
76
+ self.x_spacing = None
77
+ self.y_spacing = None
78
+ self.z_spacing = None
79
+ self.num_points = None
80
+ self.xp = None
81
+ self.yp = None
82
+ self.zp = None
83
+ self.coords = None
84
+ self.values = None
85
+
86
+ def set_dimensions(self, dimensions):
87
+ raise NotImplementedError
88
+
89
+ def set_spacing(self, spacing):
90
+ raise NotImplementedError
91
+
92
+ def set_offset(self, offset):
93
+ if type(offset) is not bool:
94
+ raise TypeError("Offset must be either True or False")
95
+ self.offset = offset
96
+ self._update
97
+
98
+ def set_value_type(self, dose):
99
+ """
100
+ if true values will be in dose over time
101
+ if false
102
+ """
103
+ if type(dose) is not bool:
104
+ raise TypeError("Dose must be either True or False")
105
+
106
+ # convert values if they need converting
107
+ if dose and not self.dose:
108
+ self.values = self.values * 3.6 * self.hours
109
+ elif self.dose and not dose:
110
+ self.values = self.values / (3.6 * self.hours)
111
+
112
+ self.dose = dose
113
+ if self.dose:
114
+ self.units = "mJ/cm2"
115
+ else:
116
+ self.units = "uW/cm2"
117
+
118
+ def set_dose_time(self, hours):
119
+ if type(hours) not in [float, int]:
120
+ raise TypeError("Hours must be numeric")
121
+ self.hours = hours
122
+
123
+ def calculate_values(self, lamps: list):
124
+ """
125
+ Calculate and return irradiance values at all coordinate points within the zone.
126
+ """
127
+ total_values = np.zeros(self.coords.shape[0])
128
+ for lamp_id, lamp in lamps.items():
129
+ if lamp.filedata is not None and lamp.enabled:
130
+ # determine lamp placement + calculate relative coordinates
131
+ rel_coords = self.coords - lamp.position
132
+ # store the theta and phi data based on this orientation
133
+ Theta0, Phi0, R0 = to_polar(*rel_coords.T)
134
+ # apply all transformations that have been applied to this lamp, but in reverse
135
+ rel_coords = np.array(
136
+ attitude(rel_coords.T, roll=0, pitch=0, yaw=-lamp.heading)
137
+ ).T
138
+ rel_coords = np.array(
139
+ attitude(rel_coords.T, roll=0, pitch=-lamp.bank, yaw=0)
140
+ ).T
141
+ rel_coords = np.array(
142
+ attitude(rel_coords.T, roll=0, pitch=0, yaw=-lamp.angle)
143
+ ).T
144
+ Theta, Phi, R = to_polar(*rel_coords.T)
145
+ values = get_intensity_vectorized(Theta, Phi, lamp.interpdict) / R ** 2
146
+
147
+ if self.fov80:
148
+ values[Theta0 < 50] = 0
149
+ if self.vert:
150
+ values *= np.sin(np.radians(Theta0))
151
+ if self.horiz:
152
+ values *= np.cos(np.radians(Theta0))
153
+ if lamp.intensity_units == "mW/Sr":
154
+ total_values += values / 10 # convert from mW/Sr to uW/cm2
155
+ else:
156
+ raise KeyError("Units not recognized")
157
+
158
+ # save the max value to the lamp object
159
+ lamp.max_irradiances[self.zone_id] = values.max()
160
+
161
+ # I have no earthly idea why it is necessary to do it this way
162
+ if len(self.num_points) == 3:
163
+ total_values = total_values.reshape(
164
+ (self.num_points[1], self.num_points[0], self.num_points[2])
165
+ )
166
+ elif len(self.num_points) == 2:
167
+ total_values = total_values.reshape(
168
+ (self.num_points[1], self.num_points[0])
169
+ )
170
+ total_values = np.ma.masked_invalid(
171
+ total_values
172
+ ) # mask any nans near light source
173
+ self.values = total_values
174
+ # convert to dose
175
+ if self.dose:
176
+ self.values = self.values * 3.6 * self.hours
177
+ return self.values
178
+
179
+
180
+ class CalcVol(CalcZone):
181
+ """
182
+ Represents a volumetric calculation zone.
183
+ A subclass of CalcZone designed for three-dimensional volumetric calculations.
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ zone_id,
189
+ name=None,
190
+ x1=None,
191
+ x2=None,
192
+ y1=None,
193
+ y2=None,
194
+ z1=None,
195
+ z2=None,
196
+ x_spacing=None,
197
+ y_spacing=None,
198
+ z_spacing=None,
199
+ offset=None,
200
+ fov80=None,
201
+ vert=None,
202
+ horiz=None,
203
+ dose=None,
204
+ hours=None,
205
+ enabled=None,
206
+ ):
207
+
208
+ super().__init__(
209
+ zone_id, name, offset, fov80, vert, horiz, dose, hours, enabled
210
+ )
211
+ self.x1 = 0 if x1 is None else x1
212
+ self.x2 = 6 if x2 is None else x2
213
+ self.y1 = 0 if y1 is None else y1
214
+ self.y2 = 4 if y2 is None else y2
215
+ self.z1 = 0 if z1 is None else z1
216
+ self.z2 = 2.7 if z2 is None else z2
217
+ self.x_spacing = 0.1 if x_spacing is None else x_spacing
218
+ self.y_spacing = 0.1 if y_spacing is None else y_spacing
219
+ self.z_spacing = 0.1 if z_spacing is None else z_spacing
220
+ self._update()
221
+
222
+ def set_dimensions(self, x1=None, x2=None, y1=None, y2=None, z1=None, z2=None):
223
+ self.x1 = self.x1 if x1 is None else x1
224
+ self.x2 = self.x2 if x2 is None else x2
225
+ self.y1 = self.y1 if y1 is None else y1
226
+ self.y2 = self.y2 if y2 is None else y2
227
+ self.z1 = self.z1 if z1 is None else z1
228
+ self.z2 = self.z2 if z2 is None else z2
229
+ self._update()
230
+
231
+ def set_spacing(self, x_spacing=None, y_spacing=None, z_spacing=None):
232
+ self.x_spacing = self.x_spacing if x_spacing is None else x_spacing
233
+ self.y_spacing = self.y_spacing if y_spacing is None else y_spacing
234
+ self.z_spacing = self.z_spacing if z_spacing is None else z_spacing
235
+ self._update()
236
+
237
+ def _update(self):
238
+ """
239
+ Update the number of points based on the spacing, and then the points
240
+ """
241
+ numx = int((self.x2 - self.x1) / self.x_spacing)
242
+ numy = int((self.y2 - self.y1) / self.y_spacing)
243
+ numz = int((self.z2 - self.z1) / self.z_spacing)
244
+ self.num_points = np.array([numx, numy, numz])
245
+ if self.offset:
246
+ xpoints = np.linspace(
247
+ self.x1 + (self.x_spacing / 2), self.x2 - (self.x_spacing / 2), numx
248
+ )
249
+ ypoints = np.linspace(
250
+ self.y1 + (self.y_spacing / 2), self.y2 - (self.y_spacing / 2), numy
251
+ )
252
+ zpoints = np.linspace(
253
+ self.z1 + (self.z_spacing / 2), self.z2 - (self.z_spacing / 2), numz
254
+ )
255
+ else:
256
+ xpoints = np.linspace(self.x1, self.x2, numx)
257
+ ypoints = np.linspace(self.y1, self.y2, numy)
258
+ zpoints = np.linspace(self.z1, self.z2, numz)
259
+ self.points = [xpoints, ypoints, zpoints]
260
+ self.xp, self.yp, self.zp = self.points
261
+ X, Y, Z = [grid.reshape(-1) for grid in np.meshgrid(*self.points)]
262
+ self.coords = np.array((X, Y, Z)).T
263
+
264
+
265
+ class CalcPlane(CalcZone):
266
+ """
267
+ Represents a planar calculation zone.
268
+ A subclass of CalcZone designed for two-dimensional planar calculations at a specific height.
269
+ """
270
+
271
+ def __init__(
272
+ self,
273
+ zone_id,
274
+ name=None,
275
+ x1=None,
276
+ x2=None,
277
+ y1=None,
278
+ y2=None,
279
+ height=None,
280
+ x_spacing=None,
281
+ y_spacing=None,
282
+ offset=None,
283
+ fov80=None,
284
+ vert=None,
285
+ horiz=None,
286
+ dose=None,
287
+ hours=None,
288
+ enabled=None,
289
+ ):
290
+
291
+ super().__init__(
292
+ zone_id, name, offset, fov80, vert, horiz, dose, hours, enabled
293
+ )
294
+
295
+ self.height = 1.9 if height is None else height
296
+ self.x1 = 0 if x1 is None else x1
297
+ self.x2 = 6 if x2 is None else x2
298
+ self.y1 = 0 if y1 is None else y1
299
+ self.y2 = 4 if y2 is None else y2
300
+ self.x_spacing = 0.1 if x_spacing is None else x_spacing
301
+ self.y_spacing = 0.1 if y_spacing is None else y_spacing
302
+ self._update()
303
+
304
+ def set_height(self, height):
305
+ """set height of calculation plane. currently we only support vertical planes"""
306
+ if type(height) not in [float, int]:
307
+ raise TypeError("Height must be numeric")
308
+ self.height = height
309
+ self._update()
310
+
311
+ def set_dimensions(self, x1=None, x2=None, y1=None, y2=None):
312
+ """set the dimensions and update the coordinate points"""
313
+ self.x1 = self.x1 if x1 is None else x1
314
+ self.x2 = self.x2 if x2 is None else x2
315
+ self.y1 = self.y1 if y1 is None else y1
316
+ self.y2 = self.y2 if y2 is None else y2
317
+ self._update()
318
+
319
+ def set_spacing(self, x_spacing=None, y_spacing=None):
320
+ """set the fineness of the grid spacing and update the coordinate points"""
321
+ self.x_spacing = self.x_spacing if x_spacing is None else x_spacing
322
+ self.y_spacing = self.y_spacing if y_spacing is None else y_spacing
323
+ self._update()
324
+
325
+ def _update(self):
326
+ """
327
+ Update the number of points based on the spacing, and then the points
328
+ """
329
+ numx = int((self.x2 - self.x1) / self.x_spacing)
330
+ numy = int((self.y2 - self.y1) / self.y_spacing)
331
+ self.num_points = np.array([numx, numy])
332
+ if self.offset:
333
+ xpoints = np.linspace(
334
+ self.x1 + (self.x_spacing / 2), self.x2 - (self.x_spacing / 2), numx
335
+ )
336
+ ypoints = np.linspace(
337
+ self.y1 + (self.y_spacing / 2), self.y2 - (self.y_spacing / 2), numy
338
+ )
339
+ else:
340
+ xpoints = np.linspace(self.x1, self.x2, numx)
341
+ ypoints = np.linspace(self.y1, self.y2, numy)
342
+ self.points = [xpoints, ypoints]
343
+ self.xp, self.yp = self.points
344
+ X, Y = [grid.reshape(-1) for grid in np.meshgrid(*self.points)]
345
+ xy_coords = np.array([np.array((x0, y0)) for x0, y0 in zip(X, Y)])
346
+ zs = np.ones(xy_coords.shape[0]) * self.height
347
+ self.coords = np.stack([xy_coords.T[0], xy_coords.T[1], zs]).T
348
+
349
+ def plot_plane(self, fig=None, vmin=None, vmax=None, title=None):
350
+ """Plot the image of the radiation pattern"""
351
+ if fig is None:
352
+ fig, ax = plt.subplots()
353
+ title = "" if title is None else title
354
+ if self.values is not None:
355
+ vmin = self.values.min() if vmin is None else vmin
356
+ vmax = self.values.max() if vmax is None else vmax
357
+ extent = [self.y1, self.y2, self.x1, self.x2]
358
+ # ratio = (self.y2 - self.y1) / (self.x2 - self.x1)
359
+ # if ratio < 1:
360
+ # orientation, location = "horizontal", "top"
361
+ # else:
362
+ # orientation, location = "vertical", "right"
363
+ img = ax.imshow(self.values.T, extent=extent, vmin=vmin, vmax=vmax)
364
+ cbar = fig.colorbar(
365
+ img,
366
+ pad=0.03, # orientation=orientation, location=location
367
+ )
368
+ ax.set_title(title)
369
+ cbar.set_label(self.units, loc="center")
370
+ return fig