Coverage for python / aubellhop / environment.py: 98%
366 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-24 14:11 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-24 14:11 +0000
1"""Environment configuration for aubellhop.
3This module provides dataclass-based environment configuration with automatic validation,
4replacing manual option checking with field validators.
5"""
7from __future__ import annotations
9from collections.abc import MutableMapping
10from dataclasses import dataclass, fields
11from typing import Any, Iterator, Self, Callable
13from pprint import pformat
14import warnings
15from itertools import product
17import numpy as np
18from numpy.typing import NDArray
19import pandas as pd
21from .constants import BHStrings, FlagMaps, EnvDefaults, MiscDefaults
24@dataclass
25class Environment(MutableMapping[str, Any]):
26 """Dataclass for underwater acoustic environment configuration.
28 This class provides automatic validation of environment parameters,
29 eliminating the need for manual checking of option validity.
31 These entries are either intended to be set or edited by the user, or with `_` prefix are
32 internal state read from a .env file or inferred by other data. Some others are ignored.
34 Parameters
35 ----------
36 **kv : dict
37 Keyword arguments for environment configuration.
39 Returns
40 -------
41 env : dict
42 A new underwater environment dictionary.
44 Raises
45 ------
46 ValueError
47 If any parameter value is invalid according to BELLHOP constraints.
49 Example
50 -------
52 To see all the parameters available and their default values:
54 >>> import aubellhop as bh
55 >>> env = bh.Environment()
56 >>> print(env)
58 The environment parameters may be changed by passing keyword arguments
59 or modified later using dictionary notation:
61 >>> import aubellhop as bh
62 >>> env = bh.Environment(bottom_depth=40, soundspeed=1540)
63 >>> print(env)
64 >>> env.bottom_depth = 25
65 >>> env.bottom_soundspeed = 1800
66 >>> print(env)
68 The default environment has a constant sound speed.
69 A depth dependent sound speed profile be provided as a Nx2 array of (depth, sound speed):
71 >>> import aubellhop as bh
72 >>> env = bh.Environment(bottom_depth=20,
73 >>> soundspeed=[[0,1540], [5,1535], [10,1535], [20,1530]])
75 A range-and-depth dependent sound speed profile can be provided as a Pandas frame:
77 >>> import aubellhop as bh
78 >>> import pandas as pd
79 >>> ssp2 = pd.DataFrame({
80 >>> 0: [1540, 1530, 1532, 1533], # profile at 0 m range
81 >>> 100: [1540, 1535, 1530, 1533], # profile at 100 m range
82 >>> 200: [1530, 1520, 1522, 1525] }, # profile at 200 m range
83 >>> index=[0, 10, 20, 30]) # depths of the profile entries in m
84 >>> env = bh.Environment(bottom_depth=20, soundspeed=ssp2)
86 The default environment has a constant water depth. A range dependent bathymetry
87 can be provided as a Nx2 array of (range, water depth):
89 >>> import aubellhop as bh
90 >>> env = bh.Environment(bottom_depth=[[0,20], [300,10], [500,18], [1000,15]])
91 """
93 # Basic environment properties
94 name: str = 'bellhop/python default'
95 _from_file: str | None = None
96 dimension: str = EnvDefaults.dimension
97 _dimension: int = EnvDefaults._dimension
98 frequency: float = EnvDefaults.frequency
99 _num_media: int = 1 # must always = 1 in bellhop
101 # Sound speed parameters
102 soundspeed: float | Any = MiscDefaults.sound_speed # m/s
103 soundspeed_interp: str = EnvDefaults.soundspeed_interp
105 # Depth parameters
106 _mesh_npts: int = 0 # ignored by bellhop
107 _depth_sigma: float = 0.0 # ignored by bellhop
108 _depth_max: float | None = None # m
109 _range_max: float | None = None # m -- not used in the environment file
111 # Flags to read/write from separate files
112 _bathymetry: str = BHStrings.flat # set to "from-file" if multiple bottom depths
113 _altimetry: str = BHStrings.flat # set to "from-file" if multiple surface heights
114 _sbp_file: str = BHStrings.default # set to "from-file" if source_directionality defined
116 # Bottom parameters
117 bottom_depth: float | Any = 25.0 # m
118 bottom_interp: str = EnvDefaults.bottom_interp
119 _bottom_depth: float | None = None # m
120 bottom_soundspeed: float = MiscDefaults.sound_speed # m/s
121 _bottom_soundspeed_shear: float = 0.0 # m/s (ignored)
122 bottom_density: float = MiscDefaults.density # kg/m^3
123 bottom_attenuation: float | None = None # dB/wavelength
124 _bottom_attenuation_shear: float | None = None # dB/wavelength (ignored)
125 bottom_roughness: float = 0.0 # m (rms)
126 bottom_beta: float | None = None
127 bottom_transition_freq: float | None = None # Hz
128 bottom_boundary_condition: str = BHStrings.acousto_elastic
129 bottom_reflection_coefficient: Any | None = None
130 bottom_grain_size: float | None = None
132 # Surface parameters
133 surface_depth: Any | None = None # surface profile
134 surface_interp: str = EnvDefaults.surface_interp # curvilinear/linear
135 surface_boundary_condition: str = BHStrings.vacuum
136 surface_reflection_coefficient: Any | None = None
137 surface_soundspeed: float = MiscDefaults.sound_speed # m/s
138 _surface_soundspeed_shear: float = 0.0 # m/s (ignored)
139 surface_density: float = MiscDefaults.density # kg/m^3
140 surface_attenuation: float | None = None # dB/wavelength
141 _surface_attenuation_shear: float | None = None # dB/wavelength (ignored)
142 _surface_min: float | None = None
143 surface_min: float | None = None
145 # Source parameters
146 source_type: str = BHStrings.default
147 source_range: float | Any = 0.0
148 source_cross_range: float | Any = 0.0
149 source_depth: float | Any = 5.0 # m - Any allows for np.ndarray
150 source_ndepth: int | None = None
151 source_nrange: int | None = None
152 source_ncrossrange: int | None = None
153 source_directionality: Any | None = None # [(deg, dB)...]
154 _source_num: int = 0
156 # Receiver parameters
157 receiver_depth: float | Any = 10.0 # m - Any allows for np.ndarray
158 receiver_range: float | Any = 1000.0 # m - Any allows for np.ndarray
159 receiver_bearing: float | Any = 0.0 # deg - Any allows for np.ndarray
160 receiver_ndepth: int | None = None
161 receiver_nrange: int | None = None
162 receiver_nbearing: int | None = None
163 _receiver_num: int = 0
165 # Beam settings
166 beam_type: str = BHStrings.default
167 beam_angle_min: float | None = None # deg
168 beam_angle_max: float | None = None # deg
169 beam_bearing_min: float | None = None # deg
170 beam_bearing_max: float | None = None # deg
171 beam_num: int = 0 # (0 = auto)
172 beam_bearing_num: int = 0
173 single_beam_index: int | None = None
174 _single_beam: str = BHStrings.default # value inferred from `single_beam_index`
176 # Cerveny Gaussian Beams
177 beam_width_type: str | None = None
178 beam_reflection_curvature_change: str | None = None
179 beam_reflection_shift: str | None = None
180 beam_epsilon_multipler: float | None = None
181 beam_range_loop: float | None = None # km in env file
182 beam_images_num: int | None = None
183 beam_window: int | None = None
184 beam_component: str | None = None
186 # Simulation extent
187 simulation_depth: float | None = None
188 simulation_range: float | None = None
189 simulation_cross_range: float | None = None
190 simulation_depth_scale: float | None = None
191 simulation_range_scale: float | None = None
192 simulation_cross_range_scale: float | None = None
193 simulation_cross_range_min: float | None = None
195 # Solution parameters
196 step_size: float | None = 0.0 # (0 = auto)
197 grid_type: str = BHStrings.default
198 task: str | None = None
199 interference_mode: str | None = None # subset of `task` for providing TL interface
201 # Attenuation parameters
202 volume_attenuation: str = EnvDefaults.volume_attenuation
203 attenuation_units: str = EnvDefaults.attenuation_units
204 biological_layer_parameters: Any | None = None
206 # Francois-Garrison volume attenuation parameters (has setter `.set_fg_attenuation(...)`)
207 _fg_salinity: float | None = None
208 _fg_temperature: float | None = None
209 _fg_pH: float | None = None
210 _fg_depth: float | None = None
212 comment_pad: int = EnvDefaults.comment_pad
214 ############# CLASS METHODS ################
216 @classmethod
217 def from_file(cls, fname: str) -> "Environment":
218 """Create an Environment from an .env file."""
219 from aubellhop.readers import EnvironmentReader
220 env = EnvironmentReader(cls(), fname).read()
221 env._from_file = fname
222 return env
225 @classmethod
226 def from_dict(cls, data: dict[str, Any]) -> "Environment":
227 """Create Environment from dictionary.
229 Unlike `Environment(**data)`, unknown fields are ignored (with a warning message)."""
230 valid_fields = {f.name for f in fields(cls)}
231 invalid = set(data.keys()) - valid_fields
232 if invalid:
233 warnings.warn(f"{cls.__name__}.from_dict: ignoring unknown fields: {invalid}")
234 filtered_data = {k: v for k, v in data.items() if k in valid_fields}
235 return cls(**filtered_data)
237 ############# WRITING ################
239 def to_file(self, fname: str, task: str | None = None) -> str:
240 """Write a complete .env file for specifying a Bellhop simulation.
242 This is the user-facing file writer. It infers the file basename and
243 resolves the task from the environment unless overridden.
245 Parameters
246 ----------
247 fname : str
248 Filename or filename base for the .env file. If no extension is
249 provided, `.env` is appended.
250 task : str, optional
251 Task string which defines the computation to run (e.g. "rays",
252 "eigenrays", "arrivals", "coherent", "incoherent", "semicoherent").
253 If not provided, this is inferred from `env.task` or
254 `env.interference_mode`.
256 Returns
257 -------
258 str
259 The filename base (no extension) of the written file.
260 """
261 from pathlib import Path
262 from aubellhop.constants import FileExt, FlagMaps, EnvDefaults
263 from aubellhop.writers import EnvironmentWriter
265 path = Path(fname)
266 if path.suffix == "":
267 path = path.with_suffix(FileExt.env)
269 # Resolve task
270 task_val = task or self.get("task") or self.get("interference_mode") or EnvDefaults.interference_mode
271 if task_val is None:
272 raise ValueError("Task must be specified via argument or env.task/interference_mode")
274 if isinstance(task_val, str):
275 task_val = BHStrings(task_val) # avoid strict type issue
277 taskcode = FlagMaps.task_rev[task_val]
278 fname_base = str(path.with_suffix(""))
280 path.parent.mkdir(parents=True, exist_ok=True)
281 with open(path, "w") as fh:
282 EnvironmentWriter(self, fh, fname_base, taskcode).write()
284 return fname_base
286 ############# SMALL METHODS ################
288 def reset(self) -> Self:
289 """Delete values for all user-facing parameters."""
290 for k in self.keys():
291 if not k.startswith("_"):
292 self[k] = None
293 return self
295 def defaults(self) -> Self:
296 """Applies default values if not already set."""
297 for f in fields(EnvDefaults):
298 if getattr(self, f.name) is None:
299 setattr(self, f.name, getattr(EnvDefaults(), f.name))
300 return self
302 def to_dict(self) -> dict[str,Any]:
303 """Return a dictionary representation of the environment."""
304 from dataclasses import asdict
305 return asdict(self)
307 def copy(self) -> "Environment":
308 """Return a shallow copy of the environment."""
309 # Copy all fields
310 data = {f.name: getattr(self, f.name) for f in fields(self)}
311 # Return a new instance
312 new_env = type(self)(**data)
313 return new_env
315 def unwrap(self, *keys: str) -> list[Environment]:
316 """Return a list of Environment copies expanded over the given keys.
318 If multiple keys are provided, all combinations are produced.
319 Each unwrapped Environment gets a unique `.name` derived from the
320 parent name and the expanded field values.
321 """
323 # Ensure keys are valid
324 for k in keys:
325 if k not in self:
326 raise KeyError(f"Environment has no field '{k}'")
328 # Prepare value lists (convert scalars → singletons)
329 values: list[Any] = []
330 for k in keys:
331 v = self[k]
332 if isinstance(v, (list, tuple, np.ndarray)):
333 values.append(v)
334 else:
335 values.append([v])
337 combos = product(*values)
338 envs = []
340 base_name = str(self.get("name", "env"))
342 for combo in combos:
343 env_i = self.copy()
344 name_parts = [base_name]
345 for k, v in zip(keys, combo):
346 env_i[k] = v
347 # Replace disallowed chars and truncate floats nicely
348 if isinstance(v, float):
349 v_str = f"{v:g}"
350 else:
351 v_str = str(v)
352 name_parts.append(f"{k}{v_str}")
353 env_i["name"] = "-".join(name_parts)
354 envs.append(env_i)
356 return envs
358 ############## SETTERS ###############
360 def set_fg_attenuation(self,
361 salinity: float,
362 temperature: float,
363 pH: float,
364 depth: float
365 ) -> Self:
366 """Interface to set Francois-Garrison volume attenuation parameters."""
367 self.volume_attenuation = BHStrings.francois_garrison
368 self._fg_salinity = salinity
369 self._fg_temperature = temperature
370 self._fg_pH = pH
371 self._fg_depth = depth
372 return self
374 ############## CHECKING ###############
376 def check(self) -> Self:
377 """Finalise environment parameters and perform assertion checks."""
378 self._finalise()
379 try:
380 self._check_env_header()
381 self._check_env_surface()
382 self._check_env_depth()
383 self._check_env_ssp()
384 self._check_env_source()
385 self._check_env_beam()
386 return self
387 except AssertionError as e:
388 raise ValueError(f"Env check error: {str(e)}") from None
390 def _finalise(self) -> Self:
391 """Reviews the data within an environment and updates settings for consistency.
393 This function is run as the first step of `.check()`.
394 """
396 if self.dimension == BHStrings.two_d:
397 self._dimension = 2
398 elif self.dimension == BHStrings.two_half_d or self.dimension == BHStrings.three_d:
399 self._dimension = 3
401 if np.size(self['bottom_depth']) > 1:
402 self["_bathymetry"] = BHStrings.from_file
403 if self["surface_depth"] is not None and np.size(self["surface_depth"]) > 1:
404 self["_altimetry"] = BHStrings.from_file
405 if self["bottom_reflection_coefficient"] is not None:
406 self["bottom_boundary_condition"] = BHStrings.from_file
407 if self["surface_reflection_coefficient"] is not None:
408 self["surface_boundary_condition"] = BHStrings.from_file
410 self.surface_depth = self.surface_depth if self.surface_depth is not None else EnvDefaults.surface_depth
411 def _extremum(
412 expl: float | None,
413 vec: float | NDArray[np.float64],
414 fn: Callable[[NDArray[np.float64]], float]
415 ) -> float:
416 if expl is not None:
417 return float(expl)
418 if np.size(vec) == 1:
419 return float(vec)
420 if isinstance(vec, np.ndarray):
421 arr = np.asarray(vec)
422 if arr.ndim == 1:
423 return float(fn(arr))
424 if arr.ndim == 2:
425 return float(fn(arr[:, 1]))
426 raise TypeError(f"Unexpected type for _extremum argument: {type(vec)}")
428 self._depth_max = _extremum(self._depth_max, self['bottom_depth'], np.max)
429 self._surface_min = _extremum(self.surface_min, self['surface_depth'], np.min)
431 if not isinstance(self['soundspeed'], pd.DataFrame):
432 if np.size(self['soundspeed']) == 1:
433 speed = np.array([float(self["soundspeed"]), float(self["soundspeed"])])
434 depth = np.array([self._surface_min, self._depth_max])
435 self["soundspeed"] = pd.DataFrame(speed, columns=pd.Index(["speed"]), index=depth)
436 self["soundspeed"].index.name = "depth"
437 elif self['soundspeed'].shape[0] == 1 and self['soundspeed'].shape[1] == 2:
438 # only one depth/soundspeed pair specified -- does this happen??
439 speed = [float(self["soundspeed"][0,1]), float(self["soundspeed"][0,1])]
440 d1 = float(min([self._surface_min, self["soundspeed"][0,0]]))
441 d2 = float(max([self["soundspeed"][0,0], self._depth_max]))
442 self["soundspeed"] = pd.DataFrame(speed, columns=pd.Index(["speed"]), index=pd.Index([d1, d2]))
443 self["soundspeed"].index.name = "depth"
444 elif self['soundspeed'].ndim == 2 and self['soundspeed'].shape[1] == 2:
445 depth = self['soundspeed'][:,0]
446 speed = self['soundspeed'][:,1]
447 self["soundspeed"] = pd.DataFrame(speed, columns=pd.Index(["speed"]), index=depth)
448 self["soundspeed"].index.name = "depth"
449 else:
450 raise TypeError("For an NDArray, soundspeed must be defined as a Nx2 array of [depth,soundspeed]. Use a DataFrame with 'depth' index for a 2D soundspeed profile.")
452 if "depth" in self["soundspeed"].columns:
453 self["soundspeed"] = self["soundspeed"].set_index("depth")
455 if len(self['soundspeed'].columns) > 1:
456 self['soundspeed_interp'] == BHStrings.quadrilateral
458 self.bottom_attenuation = self._float_or_default('bottom_attenuation', EnvDefaults.bottom_attenuation)
460 self.source_ndepth = self.source_ndepth or np.size(self.source_depth)
461 self.source_nrange = self.source_nrange or np.size(self.source_range)
462 self.source_ncrossrange = self.source_ncrossrange or np.size(self.source_cross_range)
463 self._source_num = self.source_ndepth * self.source_nrange * self.source_ncrossrange
465 self.receiver_ndepth = self.receiver_ndepth or np.size(self.receiver_depth)
466 self.receiver_nrange = self.receiver_nrange or np.size(self.receiver_range)
467 self.receiver_nbearing = self.receiver_nbearing or np.size(self.receiver_bearing)
468 self._receiver_num = self.receiver_ndepth * self.receiver_nrange * self.receiver_nbearing
470 # Beam angle ranges default to half-space if source is left-most, otherwise full-space:
471 if self['beam_angle_min'] is None:
472 if np.min(self['receiver_range']) < 0:
473 self['beam_angle_min'] = - MiscDefaults.beam_angle_fullspace
474 else:
475 self['beam_angle_min'] = - MiscDefaults.beam_angle_halfspace
476 if self['beam_angle_max'] is None:
477 if np.min(self['receiver_range']) < 0:
478 self['beam_angle_max'] = MiscDefaults.beam_angle_fullspace
479 else:
480 self['beam_angle_max'] = MiscDefaults.beam_angle_halfspace
482 # Identical logic for bearing angles
483 if np.min(self['receiver_range']) < 0:
484 angle_min = -MiscDefaults.beam_bearing_fullspace
485 angle_max = +MiscDefaults.beam_bearing_fullspace
486 else:
487 angle_min = -MiscDefaults.beam_bearing_halfspace
488 angle_max = +MiscDefaults.beam_bearing_halfspace
490 self.beam_bearing_min = self._float_or_default('beam_bearing_min', angle_min)
491 self.beam_bearing_max = self._float_or_default('beam_bearing_max', angle_max)
493 self.simulation_depth_scale = self._float_or_default('simulation_depth_scale', EnvDefaults.simulation_depth_scale)
494 self.simulation_range_scale = self._float_or_default('simulation_range_scale', EnvDefaults.simulation_range_scale)
495 self.simulation_cross_range_scale = self._float_or_default('simulation_cross_range_scale', EnvDefaults.simulation_cross_range_scale)
496 self.simulation_cross_range_min = self._float_or_default('simulation_cross_range_min', EnvDefaults.simulation_cross_range_min)
498 self._range_max = np.abs(self['receiver_range']).max()
499 bearing_absmax = np.abs([self['beam_bearing_max'], self['beam_bearing_min']]).max()
500 cross_range_max = self._range_max * np.sin(np.deg2rad(bearing_absmax))
502 self.simulation_depth = self._float_or_default('simulation_depth', self.simulation_depth_scale * self._depth_max)
503 self.simulation_range = self._float_or_default('simulation_range', self.simulation_range_scale * self._range_max)
504 self.simulation_cross_range = self._float_or_default('simulation_cross_range',
505 np.max([self.simulation_cross_range_min, self.simulation_cross_range_scale * cross_range_max]))
507 return self
509 def _float_or_default(self, key: str, default: float) -> float:
510 """Return the current value if not None, otherwise return and set a default."""
511 val = getattr(self, key, None)
512 if val is None:
513 setattr(self, key, default)
514 val = default
515 return val
517 def _check_env_header(self) -> None:
518 assert self["_num_media"] == 1, f"BELLHOP only supports 1 medium, found {self['_num_media']}"
520 def _check_env_surface(self) -> None:
521 assert self['surface_depth'] is not None, 'surface must be defined or initialised'
522 if np.size(self['surface_depth']) > 1:
523 assert self['surface_depth'].ndim == 2, 'surface must be a scalar or an Nx2 array'
524 assert self['surface_depth'].shape[1] == 2, 'surface must be a scalar or an Nx2 array'
525 assert self['surface_depth'][0,0] <= 0, 'First range in surface array must be 0 m'
526 assert self['surface_depth'][-1,0] >= self._range_max, 'Last range in surface array must be beyond maximum range: '+str(self._range_max)+' m'
527 assert np.all(np.diff(self['surface_depth'][:,0]) > 0), 'surface array must be strictly monotonic in range'
528 if self["surface_reflection_coefficient"] is not None:
529 assert self["surface_boundary_condition"] == BHStrings.from_file, "TRC values need to be read from file"
531 def _check_env_depth(self) -> None:
532 assert self['bottom_depth'] is not None, 'depth must be defined or initialised'
533 if np.size(self['bottom_depth']) > 1:
534 assert self['bottom_depth'].ndim == 2, 'depth must be a scalar or an Nx2 array [ranges, depths]'
535 assert self['bottom_depth'].shape[1] == 2, 'depth must be a scalar or an Nx2 array [ranges, depths]'
536 assert self['bottom_depth'][-1,0] >= self._range_max, 'Last range in depth array must be beyond maximum range: '+str(self._range_max)+' m'
537 assert np.all(np.diff(self['bottom_depth'][:,0]) > 0), 'Depth array must be strictly monotonic in range'
538 assert self["_bathymetry"] == BHStrings.from_file, 'len(depth)>1 requires BTY file'
539 if self["bottom_reflection_coefficient"] is not None:
540 assert self["bottom_boundary_condition"] == BHStrings.from_file, "BRC values need to be read from file"
541 assert np.max(self['source_depth']) <= self['_depth_max'], f'source_depth {self.source_depth} cannot exceed water depth: {str(self._depth_max)}'
542 #assert np.max(self['receiver_depth']) <= self['_depth_max'], f'receiver_depth {self.receiver_depth} cannot exceed water depth: {str(self._depth_max)}'
544 def _check_env_ssp(self) -> None:
545 assert isinstance(self['soundspeed'], pd.DataFrame), 'Soundspeed should always be a DataFrame by this point'
546 assert self['soundspeed'].size > 1, "Soundspeed DataFrame should have been constructed internally to be two elements"
547 if self['soundspeed_interp'] == BHStrings.spline:
548 assert self['soundspeed'].shape[0] > 3, 'soundspeed profile must have at least 4 points for spline interpolation'
549 else:
550 assert self['soundspeed'].shape[0] > 1, 'soundspeed profile must have at least 2 points'
551 assert self['soundspeed'].index[0] <= self._surface_min, 'First depth in soundspeed array must be ≤ to minimum surface depth'
552 assert np.all(np.diff(self['soundspeed'].index) > 0), 'Soundspeed array must be strictly monotonic in depth'
553 if self['_depth_max'] != self['soundspeed'].index[-1]:
554 indlarger = np.argwhere(self['soundspeed'].index > self['_depth_max'])[0][0]
555 prev_ind = self['soundspeed'].index[:indlarger].tolist()
556 insert_ss_val = [
557 np.interp(self['_depth_max'],
558 self['soundspeed'].index,
559 self['soundspeed'].iloc[:, i])
560 for i in range(self['soundspeed'].shape[1])
561 ]
562 new_row = pd.DataFrame([insert_ss_val], columns=self['soundspeed'].columns)
563 new_row.index = [self._depth_max]
564 self['soundspeed'] = pd.concat([
565 self['soundspeed'].iloc[:indlarger], # rows before insertion
566 new_row, # new row
567 ])
568 self['soundspeed'].index = prev_ind + [self['_depth_max']]
569 warnings.warn("aubellhop has used linear interpolation to ensure the sound speed profile ends at the max depth. Ensure this is what you want.", UserWarning)
570 # TODO: check soundspeed range limits
572 def _check_env_source(self) -> None:
573 if self._dimension == 2:
574 assert self.source_range == 0.0, "Bellhop2D does not support non-zero source range."
575 assert self.source_cross_range == 0.0, "Bellhop2D does not support non-zero source cross range."
576 if self['source_directionality'] is not None:
577 assert np.size(self['source_directionality']) > 1, 'source_directionality must be an Nx2 array'
578 assert self['source_directionality'].ndim == 2, 'source_directionality must be an Nx2 array'
579 assert self['source_directionality'].shape[1] == 2, 'source_directionality must be an Nx2 array'
580 assert np.all(self['source_directionality'][:,0] >= -180) and np.all(self['source_directionality'][:,0] <= 180), 'source_directionality angles must be in (-180, 180]'
582 def _check_env_beam(self) -> None:
583 assert (self._dimension == 2) or (self._dimension == 3 and self.source_type in (BHStrings.point, BHStrings.default)), "Can only have point source in 3D (line or point in 2D)"
584 assert self['beam_angle_min'] >= -180 and self['beam_angle_min'] <= 180, 'beam_angle_min must be in range [-180, 180]'
585 assert self['beam_angle_max'] >= -180 and self['beam_angle_max'] <= 180, 'beam_angle_max must be in range [-180, 180]'
586 if self['_single_beam'] == BHStrings.single_beam:
587 assert self['single_beam_index'] is not None, 'Single beam was requested with option I but no index was provided in NBeam line'
590 ############# STANDARD INTERFACES ###############
592 def __getitem__(self, key: str) -> Any:
593 if not hasattr(self, key):
594 raise KeyError(key)
595 return getattr(self, key)
597 def __setitem__(self, key: str, value: Any) -> None:
598 self.__setattr__(key, value)
600 def __setattr__(self, key: str, value: Any) -> None:
601 if not hasattr(self, key):
602 raise KeyError(f"Unknown environment configuration parameter: {key!r}")
603 allowed = getattr(FlagMaps, key, None)
604 if allowed is not None and value is not None and value not in set(allowed.values()):
605 raise ValueError(f"Invalid value for {key!r}: {value}. Allowed: {set(allowed.values())}")
606 if not (
607 value is None
608 or isinstance(value, pd.DataFrame)
609 or np.isscalar(value)
610 ):
611 if not isinstance(value[0], str):
612 value = np.asarray(value, dtype=np.float64)
613 object.__setattr__(self, key, value)
615 def __delitem__(self, key: str) -> None:
616 raise KeyError("Environment parameters cannot be deleted")
618 def __iter__(self) -> Iterator[str]:
619 return (f.name for f in fields(self))
621 def __len__(self) -> int:
622 return len(fields(self))
624 def __repr__(self) -> str:
625 return pformat(self.to_dict())