Coverage for src/hallmd/data/__init__.py: 83%
251 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-10-10 19:54 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-10-10 19:54 +0000
1"""The `hallmd.data` package contains a folder for each unique thruster. The experimental data for each thruster
2is further divided by folders for each individual paper or reference. The raw experimental data is contained within
3these folders in any supported format (currently only .csv). Any additional documentation
4for the datasets is encouraged (e.g. citations, descriptions, summaries, etc.) and can be included in the data folders.
6## Thrusters
8### SPT-100
9Currently the only thruster with available data. Data for the SPT-100 comes from four sources:
111. [Diamant et al. 2014](https://arc.aiaa.org/doi/10.2514/6.2014-3710) - provides thrust and ion current density data as a function of chamber background pressure.
122. [Macdonald et al. 2019](https://arc.aiaa.org/doi/10.2514/1.B37133) - provides ion velocity profiles for varying chamber pressures.
133. [Sankovic et al. 1993](https://www.semanticscholar.org/paper/Performance-evaluation-of-the-Russian-SPT-100-at-Sankovic-Hamley/81b7d985669b21aa1a8419277c52e7a879bf3b46) - provides thrust at varying operating conditions.
144. [Jorns and Byrne. 2021](https://pepl.engin.umich.edu/pdf/2021_PSST_Jorns.pdf) - provides cathode coupling voltages at same conditions as Diamant et al. 2014.
16Citations:
17``` title="SPT-100.bib"
18--8<-- "hallmd/data/spt100/spt100.bib:citations"
19```
21## Data conventions
22The data used in the PEM is expected to be in a standard format.
23This format may evolve over time to account for more data, but at present, when we read a CSV file, here is what we look for in the columns.
24Note that we treat columns case-insensitively, so `Anode current (A)` is treated the same as `anode current (a)`.
26### Operating conditions
28Data is imported into a dictionary that maps operating conditions to data.
29An Operating Condition (made concrete in the `OperatingCondition` class) consists of a unique set of an
30anode mass flow rate, a background pressure, and a discharge / anode voltage.
31These quantities are **mandatory** for each data file, but can be provided in a few ways.
32In cases where multiple options are allowed, the first matching column is chosen.
34#### Background pressure
35We expect a column named 'background pressure (torr)' (not case sensitive).
36We assume the pressure is in units of Torr.
38#### Anode mass flow rate
39We look for the following column names in order:
411. A column named 'anode flow rate (mg/s)',
422. A column named 'total flow rate (mg/s)' and a column named 'anode-cathode flow ratio',
433. A column named 'total flow rate (mg/s)' and a column named 'cathode flow fraction'.
45In all cases, the unit of the flow rate is expected to be mg/s.
46For option 2, the cathode flow fraction is expected as a fraction between zero and one.
47For option 3, the anode-cathode flow ratio is unitless and is expected to be greater than one.
49#### Discharge voltage
50We look for the following column names in order:
521. 'discharge voltage (v)',
532. 'anode voltage (v)'
55In both cases, the unit of the voltage is expected to be Volts.
57### Data
59The following data-fields are all **optional**.
60The `ThrusterData` struct will be populated only with what is provided.
61For each of these quantities, an uncertainty can be provided, either relative or absolute.
62The formats for uncertainties for a quantity of the form '{quantity} ({unit})' are
631. '{quantity} absolute uncertainty ({unit})'
642. '{quantity} relative uncertainty'
66Relative uncertainties are fractions (so 0.2 == 20%) and absolute uncertainties are in the same units as the main quantity.
68As an example, thrust of 100 mN and a relative uncertainty of 0.05 represents 100 +/- 5 mN.
69We assume the errors are normally distributed about the nominal value with the uncertainty representing two standard deviations.
70In this case, the distribution of the experimentally-measured thrust would be T ~ N(100, 2.5).
71If both relative and absolute uncertainties are provided, we use the absolute uncertainty.
72If an uncertainty is not provided, a relative uncertainty of 2% is assumed.
74#### Thrust
75We look for a column called 'thrust (mn)' or 'thrust (n)'.
76We then convert the thrust to Newtons internally.
78#### Discharge current
79We look for one of the following column names in order:
811. 'discharge current (a)'
822. 'anode current (a)'
84#### Cathode coupling voltage
85We look for a column called 'cathode coupling voltage (v)'.
87### Ion/beam current
88We look for one of the following column names in order:
891. 'ion current (a)'
902. 'beam current (a)'
913. 'ion beam current (a)'
93#### Ion current density
94We look for three columns
961. The radial distance from the thruster exit plane.
97Allowed keys: 'radial position from thruster exit (m)'
992. The angle relative to thruster centerline
100Allowed keys: 'angular position from thruster centerline (deg)'
1023. The current density.
103Allowed keys: 'ion current density (ma/cm^2)' or 'ion current density (a/m^2)'
105We do not look for uncertainties for the radius and angle.
106The current density is assumed to have units of mA / cm^2 or A / m^2, depending on the key.
107If one or two of these quantities is provided, we throw an error.
109#### Ion velocity
110We look for two columns:
1121. Axial position from anode
113Allowed keys: 'axial position from anode (m)'
1152. Ion velocity
116Allowed keys: 'ion velocity (m/s)'
118We do not look for uncertainties for the axial position.
119The ion velocity is assumed to have units of m/s.
120If only one of these quantities is provided, we throw an error.
122""" # noqa: E501
124from abc import ABC, abstractmethod
125from dataclasses import dataclass, fields
126from pathlib import Path
127from typing import Any, Generic, Optional, Sequence, TypeAlias, TypeVar
129import numpy as np
130import numpy.typing as npt
132__all__ = [
133 'ThrusterDataset',
134 'ThrusterData',
135 'OperatingCondition',
136 'get_thruster',
137 'opcond_keys',
138 'load',
139 'pem_to_thrusterdata',
140]
142Array: TypeAlias = npt.NDArray[np.floating[Any]]
143PathLike: TypeAlias = str | Path
144T = TypeVar("T", np.float64, Array)
147# ====================================================================================================================
148# Measurement
149# ====================================================================================================================
150@dataclass(frozen=True)
151class Measurement(Generic[T]):
152 """A measurement object that includes a mean and standard deviation. The mean is the best estimate of the
153 quantity being measured, and the standard deviation is the uncertainty in the measurement. Can be used to specify
154 a scalar measurement quantity or a field quantity (e.g. a profile) in the form of a `numpy` array.
155 """
157 mean: T
158 std: T
160 def __str__(self):
161 return f"(μ = {self.mean}, σ = {self.std})"
164# ====================================================================================================================
165# ThrusterData and associated types
166# ====================================================================================================================
167class ThrusterDataset(ABC):
168 """Abstract base class for thruster datasets. A thruster dataset provides paths to experimental data files
169 for a specific thruster.
170 """
172 @staticmethod
173 @abstractmethod
174 def datasets_from_names(dataset_names: list[str]) -> list[Path]:
175 """Return a list of paths to the datasets with the given names."""
176 pass
178 @staticmethod
179 @abstractmethod
180 def all_data() -> list[Path]:
181 """Return a list of paths to all datasets for this thruster."""
182 pass
185@dataclass
186class CurrentDensitySweep:
187 """Contains data for a single current density sweep.
189 :ivar radius_m: the radial distance (in m) from the thruster exit plane at which the sweep was obtained.
190 :ivar angles_rad: the angular measurement locations (in rad) from thruster centerline.
191 :ivar current_density_A_m2: the measured current density in (A/m^2) at all measurement locations.
192 """
194 radius_m: np.float64
195 angles_rad: Array
196 current_density_A_m2: Measurement[Array]
199@dataclass
200class IonVelocityData:
201 """Contains measurements of axial ion velocity along with coordinates.
203 :ivar axial_distance_m: the axial channel distance (in m) from the anode where the measurements were taken.
204 :ivar velocity_m_s: the ion velocity measurements obtained from LIF (in m/s).
205 """
207 axial_distance_m: Array
208 velocity_m_s: Measurement[Array]
211@dataclass
212class ThrusterData:
213 """Class for Hall thruster data. Contains fields for all relevant performance metrics and quantities of interest.
214 All metrics are time-averaged unless otherwise noted. A `ThrusterData` instance can contain as many or as few
215 fields as needed, depending on the available data.
217 :ivar cathode_coupling_voltage_V: the cathode coupling voltage (V)
218 :ivar thrust_N: the thrust (N)
219 :ivar discharge_current_A: the discharge current (A)
220 :ivar efficiency_current: the current efficiency
221 :ivar efficiency_mass: the mass efficiency
222 :ivar efficiency_voltage: the voltage efficiency
223 :ivar efficiency_anode: the anode efficiency
224 :ivar ion_velocity: the axial ion velocity data
225 :ivar ion_current_sweeps: a list of current density sweeps
226 """
228 # Cathode
229 cathode_coupling_voltage_V: Optional[Measurement[np.float64]] = None
230 # Thruster
231 thrust_N: Optional[Measurement[np.float64]] = None
232 discharge_current_A: Optional[Measurement[np.float64]] = None
233 ion_current_A: Optional[Measurement[np.float64]] = None
234 efficiency_current: Optional[Measurement[np.float64]] = None
235 efficiency_mass: Optional[Measurement[np.float64]] = None
236 efficiency_voltage: Optional[Measurement[np.float64]] = None
237 efficiency_anode: Optional[Measurement[np.float64]] = None
238 ion_velocity: Optional[IonVelocityData] = None
239 # Plume
240 ion_current_sweeps: Optional[list[CurrentDensitySweep]] = None
242 def __str__(self) -> str:
243 fields_str = ",\n".join(
244 [
245 f"\t{field.name} = {val}"
246 for field in fields(ThrusterData)
247 if (val := getattr(self, field.name)) is not None
248 ]
249 )
250 return f"ThrusterData(\n{fields_str}\n)\n"
252 @staticmethod
253 def _merge_field(field: str, data1: 'ThrusterData', data2: 'ThrusterData'):
254 """Merge the given `field` between the two `ThrusterData` instances. If the field is present in both instances,
255 the value from `data2` is used. If the field is present in only one instance, that value is used. If the field
256 is not present in either instance, `None` is returned.
257 """
258 val1 = getattr(data1, field)
259 val2 = getattr(data2, field)
260 if val2 is None and val1 is None:
261 return None
262 elif val2 is None:
263 return val1
264 else:
265 return val2
267 @staticmethod
268 def update(data1: 'ThrusterData', data2: 'ThrusterData') -> 'ThrusterData':
269 """Return a new `ThrusterData` instance with merged fields from `data1` and `data2`."""
270 merged = {}
271 for field in fields(ThrusterData):
272 merged[field.name] = ThrusterData._merge_field(field.name, data1, data2)
273 return ThrusterData(**merged)
276# ====================================================================================================================
277# Operating conditions and associated types/functions
278# ====================================================================================================================
281def get_thruster(name: str) -> ThrusterDataset:
282 """Return a thruster dataset object based on the given thruster name.
284 :param name: the name of the thruster to get data for
285 """
286 if name.casefold() in {'h9'}:
287 from .h9 import H9
289 return H9
290 elif name.casefold() in {'spt-100', 'spt100'}:
291 from .spt100 import SPT100
293 return SPT100
294 else:
295 raise ValueError(f"Invalid thruster name {name}.")
298opcond_keys: dict[str, str] = {
299 "p_b": "background_pressure_torr",
300 "v_a": "discharge_voltage_v",
301 "mdot_a": "anode_mass_flow_rate_kg_s",
302 "b_hat": "magnetic_field_scale",
303}
304"""Forward mapping between operating condition short and long names."""
307@dataclass(frozen=True)
308class OperatingCondition:
309 """Operating conditions for a Hall thruster.
311 :ivar background_pressure_torr: the background pressure in Torr.
312 :ivar discharge_voltage_v: the discharge voltage in Volts.
313 :ivar anode_mass_flow_rate_kg_s: the anode mass flow rate in kg/s.
314 :ivar magnetic_field_scale: a factor by which to scale magnetic field from file, optional and assumed unity if not provided.
315 """ # noqa: E501
317 background_pressure_torr: float
318 discharge_voltage_v: float
319 anode_mass_flow_rate_kg_s: float
320 magnetic_field_scale: float = 1.0 #assume field is nominal if unspecified
323# ====================================================================================================================
324# Amisc interop utilities
325# ====================================================================================================================
328def _amisc_output_is_valid(outputs: dict, num_conditions: int) -> bool:
329 """
330 Check all keys we use from amisc output and make sure that they're present.
331 """
332 keys = [
333 "V_cc",
334 "I_d",
335 "I_B0",
336 "u_ion_coords",
337 "u_ion",
338 "j_ion_coords",
339 "j_ion",
340 "eta_m",
341 "eta_c",
342 "eta_v",
343 "eta_a",
344 ]
346 # Check that all keys are present
347 for key in keys:
348 value = outputs.get(key)
350 if value is None:
351 return False
353 return True
356def _single_opcond_to_thrusterdata(
357 i: int, outputs: dict, sweep_radii: Any, use_corrected_thrust: bool = True
358) -> ThrusterData:
359 NaN = np.float64(np.nan)
361 cathode_coupling_voltage_V = Measurement(outputs["V_cc"][i], NaN)
362 thrust_N = _pem_thrust(i, outputs, use_corrected_thrust)
363 discharge_current_A = Measurement(outputs["I_d"][i], NaN)
364 ion_current_A = Measurement(outputs["I_B0"][i], NaN)
366 coords = outputs["u_ion_coords"][i]
367 u_ion = outputs["u_ion"][i]
369 ion_velocity = IonVelocityData(
370 axial_distance_m=coords,
371 velocity_m_s=Measurement(u_ion, np.full_like(u_ion, NaN)),
372 )
374 ion_current_sweeps = []
375 for j, radius in enumerate(sweep_radii):
376 angles_rad = outputs["j_ion_coords"][i]
377 j_ion = np.atleast_3d(outputs["j_ion"])[i, :, j]
378 sweep = CurrentDensitySweep(
379 radius_m=radius, angles_rad=angles_rad, current_density_A_m2=Measurement(j_ion, np.full_like(j_ion, NaN))
380 )
381 ion_current_sweeps.append(sweep)
383 efficiency_mass = Measurement(outputs["eta_m"][i], NaN)
384 efficiency_current = Measurement(outputs["eta_c"][i], NaN)
385 efficiency_voltage = Measurement(outputs["eta_v"][i], NaN)
386 efficiency_anode = Measurement(outputs["eta_a"][i], NaN)
388 return ThrusterData(
389 cathode_coupling_voltage_V=cathode_coupling_voltage_V,
390 thrust_N=thrust_N,
391 discharge_current_A=discharge_current_A,
392 ion_current_A=ion_current_A,
393 ion_velocity=ion_velocity,
394 ion_current_sweeps=ion_current_sweeps,
395 efficiency_mass=efficiency_mass,
396 efficiency_current=efficiency_current,
397 efficiency_voltage=efficiency_voltage,
398 efficiency_anode=efficiency_anode,
399 )
402def pem_to_thrusterdata(
403 operating_conditions: list[OperatingCondition], outputs: dict, sweep_radii: Array, use_corrected_thrust: bool = True
404) -> Optional[dict[OperatingCondition, ThrusterData]]:
405 """Given a list of operating conditions and an `outputs` dict from amisc,
406 construct a `dict` mapping the operating conditions to `ThrusterData` objects.
407 Note: we assume that the amisc outputs are ordered based on the input operating conditions.
409 :param operating_conditions: A list of `OperatingConditions` at which the pem was run.
410 :param outputs: the amisc output dict from the run
411 :param sweep_radii: an array of radii at which ion current density data was taken
412 :param use_corrected_thrust: Whether to use the base thrust from HallThruster.jl or the thrust corrected by the
413 divergence angle computed in the plume model.
414 """ # noqa: E501
416 # Check to make sure all keys that we expect to be there are there
417 if not _amisc_output_is_valid(outputs, len(operating_conditions)):
418 return None
420 output_dict = {
421 opcond: _single_opcond_to_thrusterdata(i, outputs, sweep_radii, use_corrected_thrust)
422 for (i, opcond) in enumerate(operating_conditions)
423 }
425 return output_dict
428def _pem_thrust(i: int, outputs: dict, use_corrected_thrust: bool) -> Measurement:
429 """Helper to return the correct thrust measurement."""
430 NaN = np.float64(np.nan)
431 if use_corrected_thrust:
432 thrust = outputs['T_c'][i]
433 if not np.isscalar(thrust):
434 # Take the last (furthest) thrust to be the "true" thrust
435 thrust = thrust[-1]
436 else:
437 thrust = outputs['T'][i]
439 return Measurement(thrust, NaN)
442# ====================================================================================================================
443# File IO and parsing
444# ====================================================================================================================
445def load(files: Sequence[PathLike] | PathLike) -> dict[OperatingCondition, ThrusterData]:
446 """Load all data from the given files into a dict map of `OperatingCondition` -> `ThrusterData`.
447 Each thruster operating condition corresponds to one set of thruster measurements or quantities of interest (QoIs).
449 :param files: A list of file paths or a single file path to load data from (only .csv supported).
450 :return: A dict map of `OperatingCondition` -> `ThrusterData` objects.
451 """
452 data: dict[OperatingCondition, ThrusterData] = {}
453 if isinstance(files, list):
454 # Recursively load resources in this list (possibly list of lists)
455 for file in files:
456 new_data = load(file)
457 data = _update_data(data, new_data)
458 else:
459 new_data = _load_single_file(files)
460 data = _update_data(data, new_data)
462 return data
465def _rel_uncertainty_key(qty: str) -> str:
466 body, _ = qty.split('(', 1) if '(' in qty else (qty, '')
467 return body.rstrip().casefold() + ' relative uncertainty'
470def _abs_uncertainty_key(qty: str) -> str:
471 body, unit = qty.split('(', 1) if '(' in qty else (qty, '')
472 return body.rstrip().casefold() + ' absolute uncertainty (' + unit
475def _read_measurement(
476 table: dict[str, Array],
477 key: str,
478 val: Array,
479 start: int = 0,
480 end: int | None = None,
481 scale: float = 1.0,
482 scalar: bool = False,
483) -> Measurement:
484 """Read a value and its uncertainty from a csv table and return as a `Measurement` object."""
485 default_rel_err = 0.02
486 mean = val * scale
487 std = default_rel_err * mean / 2
489 if (abs_err := table.get(_abs_uncertainty_key(key))) is not None:
490 std = abs_err * scale / 2
491 elif (rel_err := table.get(_rel_uncertainty_key(key))) is not None:
492 std = rel_err * mean / 2
494 std = np.abs(std) #hot fix for negative data that is computed relatively
496 if end is None:
497 end = val.size
499 if scalar:
500 return Measurement(mean[start], std[start])
501 else:
502 return Measurement(mean[start:end], std[start:end])
505def _load_single_file(file: PathLike) -> dict[OperatingCondition, ThrusterData]:
506 """Load data from a single file into a dict map of `OperatingCondition` -> `ThrusterData`."""
508 # Read table from file
509 table = _table_from_file(file, delimiter=",", comments="#")
510 keys = list(table.keys())
511 data: dict[OperatingCondition, ThrusterData] = {}
513 # Get anode mass flow rate (or compute it from total flow rate and cathode flow fraction or anode flow ratio)
514 mdot_a = table.get("anode flow rate (mg/s)")
515 if mdot_a is None:
516 mdot_t = table.get("total flow rate (mg/s)")
517 if mdot_t is not None and (cathode_flow_fraction := table.get("cathode flow fraction")) is not None:
518 anode_flow_fraction = 1/(1+cathode_flow_fraction) #1 - cathode_flow_fraction #erroneous definition maybe built in for SPT-100/h9 results; references I've encountered the cathode flow fraction is a percentage of the anode, not total, flow # noqa: E501
519 mdot_a = mdot_t * anode_flow_fraction
520 elif mdot_t is not None and (anode_flow_ratio := table.get("anode-cathode flow ratio")) is not None:
521 anode_flow_fraction = anode_flow_ratio / (anode_flow_ratio + 1)
522 mdot_a = mdot_t * anode_flow_fraction
523 else:
524 raise KeyError(
525 f"{file}: No mass flow rate provided."
526 + " Expected a key called `anode flow rate (mg/s)` or a key called [`total flow rate (mg/s)`"
527 + "and one of (`anode-cathode flow ratio`, `cathode flow fraction`)]"
528 )
529 mdot_a *= 1e-6 # convert flow rate from mg/s to kg/s
531 # Get background pressure
532 P_B = table.get("background pressure (torr)")
533 if P_B is None:
534 raise KeyError(f"{file}: No background pressure provided. Expected a key called `background pressure (torr)`.")
536 # Get discharge voltage
537 V_a = table.get("discharge voltage (v)", table.get("anode voltage (v)", None))
538 if V_a is None:
539 raise KeyError(
540 f"{file}: No discharge voltage provided."
541 + " Expected a key called `discharge voltage (v)` or `anode voltage (v)`."
542 )
544 # Get magnetic field scale
545 B_hat = table.get("magnetic field scale") #try to read directly
546 if B_hat is None: #if there is none directly provided
547 I_mag = table.get('magnet current (a)') #try to read coil current
548 I_star = table.get('nominal magnet current (a)') #try to read nominal coil current
549 if I_mag is not None and I_star is not None: #if both are included
550 B_hat = I_mag / I_star #linearly scale B field by actual vs nominal coil current (ignores potential nonlinearity/saturation) # noqa: E501
551 else: #if not provided in any way
552 pass #do nothing (B_hat will remain None, which will be interpreted later)
553 #this effectively makes the magnetic field scale an optional parameter and is trying to promote backwards compatibility # noqa: E501
555 num_rows = len(table[keys[0]])
556 row = 0
557 opcond_start = 0
558 if B_hat is not None: #if magnetic field scaling is specified
559 opcond = OperatingCondition(P_B[0], V_a[0], mdot_a[0], B_hat[0])
560 else: #if unspecified, leave as defau
561 opcond = OperatingCondition(P_B[0], V_a[0], mdot_a[0])
564 while True:
565 next_opcond = opcond
566 if row < num_rows:
567 if B_hat is not None: #if magnetic field scaling is specified
568 next_opcond = OperatingCondition(P_B[row], V_a[row], mdot_a[row], B_hat[row])
569 else: #if unspecified, leave as defau
570 next_opcond = OperatingCondition(P_B[row], V_a[row], mdot_a[row])
571 if next_opcond == opcond:
572 row += 1
573 continue
575 # either at end of operating condition or end of table
576 # fill up thruster data object for this row
577 data[opcond] = ThrusterData()
579 # Fill up data
580 # We assume all errors (expressed as value +/- error) correspond to two standard deviations
581 # We also assume a default relative error of 2% if none is provided
582 for key, val in table.items():
583 if key in {"thrust (mn)", "thrust (n)"}:
584 # Load thrust, converting units if necessary
585 if key.endswith("(mn)"):
586 conversion_scale = 1e-3
587 else:
588 conversion_scale = 1.0
590 data[opcond].thrust_N = _read_measurement(
591 table, key, val, opcond_start, scale=conversion_scale, scalar=True
592 )
594 elif key in {"anode current (a)", "discharge current (a)"}:
595 data[opcond].discharge_current_A = _read_measurement(table, key, val, opcond_start, scalar=True)
597 elif key == "cathode coupling voltage (v)":
598 data[opcond].cathode_coupling_voltage_V = _read_measurement(table, key, val, opcond_start, scalar=True)
600 elif key in {"ion current (a)", "beam current (a)", "ion beam current (a)"}:
601 data[opcond].ion_current_A = _read_measurement(table, key, val, opcond_start, scalar=True)
603 elif key == "ion velocity (m/s)":
604 data[opcond].ion_velocity = IonVelocityData(
605 axial_distance_m=table["axial position from anode (m)"][opcond_start:row],
606 velocity_m_s=_read_measurement(table, key, val, start=opcond_start, end=row, scalar=False),
607 )
609 elif key in {"ion current density (ma/cm^2)", "ion current density (a/m^2)"}:
610 # Load ion current density data and convert to A/m^2
611 if key.endswith("(ma/cm^2)"):
612 conversion_scale = 10.0
613 else:
614 conversion_scale = 1.0
616 jion = _read_measurement(
617 table, key, val, start=opcond_start, end=row, scale=conversion_scale, scalar=False
618 )
619 radii_m = table["radial position from thruster exit (m)"][opcond_start:row]
620 jion_coords: Array = table["angular position from thruster centerline (deg)"][opcond_start:row]
621 unique_radii = np.unique(radii_m)
622 sweeps: list[CurrentDensitySweep] = []
623 # Keep only angles between 0 and 90 degrees
624 keep_inds = np.logical_and(jion_coords < 90, jion_coords >= 0)
626 for r in unique_radii:
627 inds = np.logical_and(radii_m == r, keep_inds)
628 sweeps.append(
629 CurrentDensitySweep(
630 radius_m=r,
631 angles_rad=jion_coords[inds] * np.pi / 180,
632 current_density_A_m2=Measurement(mean=jion.mean[inds], std=jion.std[inds]),
633 )
634 )
636 current_sweeps = data[opcond].ion_current_sweeps
637 if current_sweeps is None:
638 data[opcond].ion_current_sweeps = sweeps
639 else:
640 data[opcond].ion_current_sweeps = current_sweeps + sweeps
642 # Advance to next operating condition or break out of loop if we're at the end of the table
643 if row == num_rows:
644 break
646 opcond = next_opcond
647 opcond_start = row
649 return data
652def _update_data(
653 old_data: dict[OperatingCondition, ThrusterData], new_data: dict[OperatingCondition, ThrusterData]
654) -> dict[OperatingCondition, ThrusterData]:
655 """Helper to update operating conditions in `old_data` with those in `new_data`."""
656 data = old_data.copy()
657 for opcond in new_data.keys():
658 if opcond in old_data.keys():
659 data[opcond] = ThrusterData.update(old_data[opcond], new_data[opcond])
660 else:
661 data[opcond] = new_data[opcond]
663 return data
666def _table_from_file(file: PathLike, delimiter=",", comments="#") -> dict[str, Array]:
667 """Return a `dict` of `numpy` arrays from a CSV file. The keys of the dict are the column names in the CSV."""
668 # Read header of file to get column names
669 # We skip comments (lines starting with the string in the `comments` arg)
670 header_start = 0
671 header = ""
672 with open(file, "r", encoding="utf-8-sig") as f:
673 for i, line in enumerate(f):
674 if not line.startswith(comments):
675 header = line.rstrip()
676 header_start = i
677 break
679 if header == "":
680 return {}
682 column_names = header.split(delimiter)
683 data = np.genfromtxt(file, delimiter=delimiter, comments=comments, skip_header=header_start + 1)
685 table: dict[str, Array] = {column.casefold().strip(): data[:, i] for (i, column) in enumerate(column_names)}
686 return table