Coverage for src/hallmd/data/__init__.py: 84%

238 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-07-10 23:12 +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. 

5 

6## Thrusters 

7 

8### SPT-100 

9Currently the only thruster with available data. Data for the SPT-100 comes from four sources: 

10 

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. 

15 

16Citations: 

17``` title="SPT-100.bib" 

18--8<-- "hallmd/data/spt100/spt100.bib:citations" 

19``` 

20 

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)`. 

25 

26### Operating conditions 

27 

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. 

33 

34#### Background pressure 

35We expect a column named 'background pressure (torr)' (not case sensitive). 

36We assume the pressure is in units of Torr. 

37 

38#### Anode mass flow rate 

39We look for the following column names in order: 

40 

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'. 

44 

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. 

48 

49#### Discharge voltage 

50We look for the following column names in order: 

51 

521. 'discharge voltage (v)', 

532. 'anode voltage (v)' 

54 

55In both cases, the unit of the voltage is expected to be Volts. 

56 

57### Data 

58 

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' 

65 

66Relative uncertainties are fractions (so 0.2 == 20%) and absolute uncertainties are in the same units as the main quantity. 

67 

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. 

73 

74#### Thrust 

75We look for a column called 'thrust (mn)' or 'thrust (n)'. 

76We then convert the thrust to Newtons internally. 

77 

78#### Discharge current 

79We look for one of the following column names in order: 

80 

811. 'discharge current (a)' 

822. 'anode current (a)' 

83 

84#### Cathode coupling voltage 

85We look for a column called 'cathode coupling voltage (v)'. 

86 

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)' 

92 

93#### Ion current density 

94We look for three columns 

95 

961. The radial distance from the thruster exit plane. 

97Allowed keys: 'radial position from thruster exit (m)' 

98 

992. The angle relative to thruster centerline 

100Allowed keys: 'angular position from thruster centerline (deg)' 

101 

1023. The current density. 

103Allowed keys: 'ion current density (ma/cm^2)' or 'ion current density (a/m^2)' 

104 

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. 

108 

109#### Ion velocity 

110We look for two columns: 

111 

1121. Axial position from anode 

113Allowed keys: 'axial position from anode (m)' 

114 

1152. Ion velocity 

116Allowed keys: 'ion velocity (m/s)' 

117 

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. 

121 

122""" # noqa: E501 

123 

124from abc import ABC, abstractmethod 

125from dataclasses import dataclass, fields 

126from pathlib import Path 

127from typing import Any, Generic, Optional, Sequence, TypeAlias, TypeVar 

128 

129import numpy as np 

130import numpy.typing as npt 

131 

132__all__ = [ 

133 'ThrusterDataset', 

134 'ThrusterData', 

135 'OperatingCondition', 

136 'get_thruster', 

137 'opcond_keys', 

138 'load', 

139 'pem_to_thrusterdata', 

140] 

141 

142Array: TypeAlias = npt.NDArray[np.floating[Any]] 

143PathLike: TypeAlias = str | Path 

144T = TypeVar("T", np.float64, Array) 

145 

146 

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 """ 

156 

157 mean: T 

158 std: T 

159 

160 def __str__(self): 

161 return f"(μ = {self.mean}, σ = {self.std})" 

162 

163 

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 """ 

171 

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 

177 

178 @staticmethod 

179 @abstractmethod 

180 def all_data() -> list[Path]: 

181 """Return a list of paths to all datasets for this thruster.""" 

182 pass 

183 

184 

185@dataclass 

186class CurrentDensitySweep: 

187 """Contains data for a single current density sweep. 

188 

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 """ 

193 

194 radius_m: np.float64 

195 angles_rad: Array 

196 current_density_A_m2: Measurement[Array] 

197 

198 

199@dataclass 

200class IonVelocityData: 

201 """Contains measurements of axial ion velocity along with coordinates. 

202 

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 """ 

206 

207 axial_distance_m: Array 

208 velocity_m_s: Measurement[Array] 

209 

210 

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. 

216 

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 """ 

227 

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 

241 

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" 

251 

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 

266 

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) 

274 

275 

276# ==================================================================================================================== 

277# Operating conditions and associated types/functions 

278# ==================================================================================================================== 

279 

280 

281def get_thruster(name: str) -> ThrusterDataset: 

282 """Return a thruster dataset object based on the given thruster name. 

283 

284 :param name: the name of the thruster to get data for 

285 """ 

286 if name.casefold() in {'h9'}: 

287 from .h9 import H9 

288 

289 return H9 

290 elif name.casefold() in {'spt-100', 'spt100'}: 

291 from .spt100 import SPT100 

292 

293 return SPT100 

294 else: 

295 raise ValueError(f"Invalid thruster name {name}.") 

296 

297 

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} 

303"""Forward mapping between operating condition short and long names.""" 

304 

305 

306@dataclass(frozen=True) 

307class OperatingCondition: 

308 """Operating conditions for a Hall thruster. 

309 

310 :ivar background_pressure_torr: the background pressure in Torr. 

311 :ivar discharge_voltage_v: the discharge voltage in Volts. 

312 :ivar anode_mass_flow_rate_kg_s: the anode mass flow rate in kg/s. 

313 """ 

314 

315 background_pressure_torr: float 

316 discharge_voltage_v: float 

317 anode_mass_flow_rate_kg_s: float 

318 

319 

320# ==================================================================================================================== 

321# Amisc interop utilities 

322# ==================================================================================================================== 

323 

324 

325def _amisc_output_is_valid(outputs: dict, num_conditions: int) -> bool: 

326 """ 

327 Check all keys we use from amisc output and make sure that they're present. 

328 """ 

329 keys = [ 

330 "V_cc", 

331 "I_d", 

332 "I_B0", 

333 "u_ion_coords", 

334 "u_ion", 

335 "j_ion_coords", 

336 "j_ion", 

337 "eta_m", 

338 "eta_c", 

339 "eta_v", 

340 "eta_a", 

341 ] 

342 

343 # Check that all keys are present 

344 for key in keys: 

345 value = outputs.get(key) 

346 

347 if value is None: 

348 return False 

349 

350 return True 

351 

352 

353def _single_opcond_to_thrusterdata( 

354 i: int, outputs: dict, sweep_radii: Any, use_corrected_thrust: bool = True 

355) -> ThrusterData: 

356 NaN = np.float64(np.nan) 

357 

358 cathode_coupling_voltage_V = Measurement(outputs["V_cc"][i], NaN) 

359 thrust_N = _pem_thrust(i, outputs, use_corrected_thrust) 

360 discharge_current_A = Measurement(outputs["I_d"][i], NaN) 

361 ion_current_A = Measurement(outputs["I_B0"][i], NaN) 

362 

363 coords = outputs["u_ion_coords"][i] 

364 u_ion = outputs["u_ion"][i] 

365 

366 ion_velocity = IonVelocityData( 

367 axial_distance_m=coords, 

368 velocity_m_s=Measurement(u_ion, np.full_like(u_ion, NaN)), 

369 ) 

370 

371 ion_current_sweeps = [] 

372 for j, radius in enumerate(sweep_radii): 

373 angles_rad = outputs["j_ion_coords"][i] 

374 j_ion = np.atleast_3d(outputs["j_ion"])[i, :, j] 

375 sweep = CurrentDensitySweep( 

376 radius_m=radius, angles_rad=angles_rad, current_density_A_m2=Measurement(j_ion, np.full_like(j_ion, NaN)) 

377 ) 

378 ion_current_sweeps.append(sweep) 

379 

380 efficiency_mass = Measurement(outputs["eta_m"][i], NaN) 

381 efficiency_current = Measurement(outputs["eta_c"][i], NaN) 

382 efficiency_voltage = Measurement(outputs["eta_v"][i], NaN) 

383 efficiency_anode = Measurement(outputs["eta_a"][i], NaN) 

384 

385 return ThrusterData( 

386 cathode_coupling_voltage_V=cathode_coupling_voltage_V, 

387 thrust_N=thrust_N, 

388 discharge_current_A=discharge_current_A, 

389 ion_current_A=ion_current_A, 

390 ion_velocity=ion_velocity, 

391 ion_current_sweeps=ion_current_sweeps, 

392 efficiency_mass=efficiency_mass, 

393 efficiency_current=efficiency_current, 

394 efficiency_voltage=efficiency_voltage, 

395 efficiency_anode=efficiency_anode, 

396 ) 

397 

398 

399def pem_to_thrusterdata( 

400 operating_conditions: list[OperatingCondition], outputs: dict, sweep_radii: Array, use_corrected_thrust: bool = True 

401) -> Optional[dict[OperatingCondition, ThrusterData]]: 

402 """Given a list of operating conditions and an `outputs` dict from amisc, 

403 construct a `dict` mapping the operating conditions to `ThrusterData` objects. 

404 Note: we assume that the amisc outputs are ordered based on the input operating conditions. 

405 

406 :param operating_conditions: A list of `OperatingConditions` at which the pem was run. 

407 :param outputs: the amisc output dict from the run 

408 :param sweep_radii: an array of radii at which ion current density data was taken 

409 :param use_corrected_thrust: Whether to use the base thrust from HallThruster.jl or the thrust corrected by the 

410 divergence angle computed in the plume model. 

411 """ # noqa: E501 

412 

413 # Check to make sure all keys that we expect to be there are there 

414 if not _amisc_output_is_valid(outputs, len(operating_conditions)): 

415 return None 

416 

417 output_dict = { 

418 opcond: _single_opcond_to_thrusterdata(i, outputs, sweep_radii, use_corrected_thrust) 

419 for (i, opcond) in enumerate(operating_conditions) 

420 } 

421 

422 return output_dict 

423 

424 

425def _pem_thrust(i: int, outputs: dict, use_corrected_thrust: bool) -> Measurement: 

426 """Helper to return the correct thrust measurement.""" 

427 NaN = np.float64(np.nan) 

428 if use_corrected_thrust: 

429 thrust = outputs['T_c'][i] 

430 if not np.isscalar(thrust): 

431 # Take the last (furthest) thrust to be the "true" thrust 

432 thrust = thrust[-1] 

433 else: 

434 thrust = outputs['T'][i] 

435 

436 return Measurement(thrust, NaN) 

437 

438 

439# ==================================================================================================================== 

440# File IO and parsing 

441# ==================================================================================================================== 

442def load(files: Sequence[PathLike] | PathLike) -> dict[OperatingCondition, ThrusterData]: 

443 """Load all data from the given files into a dict map of `OperatingCondition` -> `ThrusterData`. 

444 Each thruster operating condition corresponds to one set of thruster measurements or quantities of interest (QoIs). 

445 

446 :param files: A list of file paths or a single file path to load data from (only .csv supported). 

447 :return: A dict map of `OperatingCondition` -> `ThrusterData` objects. 

448 """ 

449 data: dict[OperatingCondition, ThrusterData] = {} 

450 if isinstance(files, list): 

451 # Recursively load resources in this list (possibly list of lists) 

452 for file in files: 

453 new_data = load(file) 

454 data = _update_data(data, new_data) 

455 else: 

456 new_data = _load_single_file(files) 

457 data = _update_data(data, new_data) 

458 

459 return data 

460 

461 

462def _rel_uncertainty_key(qty: str) -> str: 

463 body, _ = qty.split('(', 1) if '(' in qty else (qty, '') 

464 return body.rstrip().casefold() + ' relative uncertainty' 

465 

466 

467def _abs_uncertainty_key(qty: str) -> str: 

468 body, unit = qty.split('(', 1) if '(' in qty else (qty, '') 

469 return body.rstrip().casefold() + ' absolute uncertainty (' + unit 

470 

471 

472def _read_measurement( 

473 table: dict[str, Array], 

474 key: str, 

475 val: Array, 

476 start: int = 0, 

477 end: int | None = None, 

478 scale: float = 1.0, 

479 scalar: bool = False, 

480) -> Measurement: 

481 """Read a value and its uncertainty from a csv table and return as a `Measurement` object.""" 

482 default_rel_err = 0.02 

483 mean = val * scale 

484 std = default_rel_err * mean / 2 

485 

486 if (abs_err := table.get(_abs_uncertainty_key(key))) is not None: 

487 std = abs_err * scale / 2 

488 elif (rel_err := table.get(_rel_uncertainty_key(key))) is not None: 

489 std = rel_err * mean / 2 

490 

491 if end is None: 

492 end = val.size 

493 

494 if scalar: 

495 return Measurement(mean[start], std[start]) 

496 else: 

497 return Measurement(mean[start:end], std[start:end]) 

498 

499 

500def _load_single_file(file: PathLike) -> dict[OperatingCondition, ThrusterData]: 

501 """Load data from a single file into a dict map of `OperatingCondition` -> `ThrusterData`.""" 

502 

503 # Read table from file 

504 table = _table_from_file(file, delimiter=",", comments="#") 

505 keys = list(table.keys()) 

506 data: dict[OperatingCondition, ThrusterData] = {} 

507 

508 # Get anode mass flow rate (or compute it from total flow rate and cathode flow fraction or anode flow ratio) 

509 mdot_a = table.get("anode flow rate (mg/s)") 

510 if mdot_a is None: 

511 mdot_t = table.get("total flow rate (mg/s)") 

512 if mdot_t is not None and (cathode_flow_fraction := table.get("cathode flow fraction")) is not None: 

513 anode_flow_fraction = 1 - cathode_flow_fraction 

514 mdot_a = mdot_t * anode_flow_fraction 

515 elif mdot_t is not None and (anode_flow_ratio := table.get("anode-cathode flow ratio")) is not None: 

516 anode_flow_fraction = anode_flow_ratio / (anode_flow_ratio + 1) 

517 mdot_a = mdot_t * anode_flow_fraction 

518 else: 

519 raise KeyError( 

520 f"{file}: No mass flow rate provided." 

521 + " Expected a key called `anode flow rate (mg/s)` or a key called [`total flow rate (mg/s)`" 

522 + "and one of (`anode-cathode flow ratio`, `cathode flow fraction`)]" 

523 ) 

524 mdot_a *= 1e-6 # convert flow rate from mg/s to kg/s 

525 

526 # Get background pressure 

527 P_B = table.get("background pressure (torr)") 

528 if P_B is None: 

529 raise KeyError(f"{file}: No background pressure provided. Expected a key called `background pressure (torr)`.") 

530 

531 # Get discharge voltage 

532 V_a = table.get("discharge voltage (v)", table.get("anode voltage (v)", None)) 

533 if V_a is None: 

534 raise KeyError( 

535 f"{file}: No discharge voltage provided." 

536 + " Expected a key called `discharge voltage (v)` or `anode voltage (v)`." 

537 ) 

538 

539 num_rows = len(table[keys[0]]) 

540 row = 0 

541 opcond_start = 0 

542 opcond = OperatingCondition(P_B[0], V_a[0], mdot_a[0]) 

543 

544 while True: 

545 next_opcond = opcond 

546 if row < num_rows: 

547 next_opcond = OperatingCondition(P_B[row], V_a[row], mdot_a[row]) 

548 if next_opcond == opcond: 

549 row += 1 

550 continue 

551 

552 # either at end of operating condition or end of table 

553 # fill up thruster data object for this row 

554 data[opcond] = ThrusterData() 

555 

556 # Fill up data 

557 # We assume all errors (expressed as value +/- error) correspond to two standard deviations 

558 # We also assume a default relative error of 2% if none is provided 

559 for key, val in table.items(): 

560 if key in {"thrust (mn)", "thrust (n)"}: 

561 # Load thrust, converting units if necessary 

562 if key.endswith("(mn)"): 

563 conversion_scale = 1e-3 

564 else: 

565 conversion_scale = 1.0 

566 

567 data[opcond].thrust_N = _read_measurement( 

568 table, key, val, opcond_start, scale=conversion_scale, scalar=True 

569 ) 

570 

571 elif key in {"anode current (a)", "discharge current (a)"}: 

572 data[opcond].discharge_current_A = _read_measurement(table, key, val, opcond_start, scalar=True) 

573 

574 elif key == "cathode coupling voltage (v)": 

575 data[opcond].cathode_coupling_voltage_V = _read_measurement(table, key, val, opcond_start, scalar=True) 

576 

577 elif key in {"ion current (a)", "beam current (a)", "ion beam current (a)"}: 

578 data[opcond].ion_current_A = _read_measurement(table, key, val, opcond_start, scalar=True) 

579 

580 elif key == "ion velocity (m/s)": 

581 data[opcond].ion_velocity = IonVelocityData( 

582 axial_distance_m=table["axial position from anode (m)"][opcond_start:row], 

583 velocity_m_s=_read_measurement(table, key, val, start=opcond_start, end=row, scalar=False), 

584 ) 

585 

586 elif key in {"ion current density (ma/cm^2)", "ion current density (a/m^2)"}: 

587 # Load ion current density data and convert to A/m^2 

588 if key.endswith("(ma/cm^2)"): 

589 conversion_scale = 10.0 

590 else: 

591 conversion_scale = 1.0 

592 

593 jion = _read_measurement( 

594 table, key, val, start=opcond_start, end=row, scale=conversion_scale, scalar=False 

595 ) 

596 radii_m = table["radial position from thruster exit (m)"][opcond_start:row] 

597 jion_coords: Array = table["angular position from thruster centerline (deg)"][opcond_start:row] 

598 unique_radii = np.unique(radii_m) 

599 sweeps: list[CurrentDensitySweep] = [] 

600 # Keep only angles between 0 and 90 degrees 

601 keep_inds = np.logical_and(jion_coords < 90, jion_coords >= 0) 

602 

603 for r in unique_radii: 

604 inds = np.logical_and(radii_m == r, keep_inds) 

605 sweeps.append( 

606 CurrentDensitySweep( 

607 radius_m=r, 

608 angles_rad=jion_coords[inds] * np.pi / 180, 

609 current_density_A_m2=Measurement(mean=jion.mean[inds], std=jion.std[inds]), 

610 ) 

611 ) 

612 

613 current_sweeps = data[opcond].ion_current_sweeps 

614 if current_sweeps is None: 

615 data[opcond].ion_current_sweeps = sweeps 

616 else: 

617 data[opcond].ion_current_sweeps = current_sweeps + sweeps 

618 

619 # Advance to next operating condition or break out of loop if we're at the end of the table 

620 if row == num_rows: 

621 break 

622 

623 opcond = next_opcond 

624 opcond_start = row 

625 

626 return data 

627 

628 

629def _update_data( 

630 old_data: dict[OperatingCondition, ThrusterData], new_data: dict[OperatingCondition, ThrusterData] 

631) -> dict[OperatingCondition, ThrusterData]: 

632 """Helper to update operating conditions in `old_data` with those in `new_data`.""" 

633 data = old_data.copy() 

634 for opcond in new_data.keys(): 

635 if opcond in old_data.keys(): 

636 data[opcond] = ThrusterData.update(old_data[opcond], new_data[opcond]) 

637 else: 

638 data[opcond] = new_data[opcond] 

639 

640 return data 

641 

642 

643def _table_from_file(file: PathLike, delimiter=",", comments="#") -> dict[str, Array]: 

644 """Return a `dict` of `numpy` arrays from a CSV file. The keys of the dict are the column names in the CSV.""" 

645 # Read header of file to get column names 

646 # We skip comments (lines starting with the string in the `comments` arg) 

647 header_start = 0 

648 header = "" 

649 with open(file, "r", encoding="utf-8-sig") as f: 

650 for i, line in enumerate(f): 

651 if not line.startswith(comments): 

652 header = line.rstrip() 

653 header_start = i 

654 break 

655 

656 if header == "": 

657 return {} 

658 

659 column_names = header.split(delimiter) 

660 data = np.genfromtxt(file, delimiter=delimiter, comments=comments, skip_header=header_start + 1) 

661 

662 table: dict[str, Array] = {column.casefold().strip(): data[:, i] for (i, column) in enumerate(column_names)} 

663 return table