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

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 "b_hat": "magnetic_field_scale", 

303} 

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

305 

306 

307@dataclass(frozen=True) 

308class OperatingCondition: 

309 """Operating conditions for a Hall thruster. 

310 

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 

316 

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 

321 

322 

323# ==================================================================================================================== 

324# Amisc interop utilities 

325# ==================================================================================================================== 

326 

327 

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 ] 

345 

346 # Check that all keys are present 

347 for key in keys: 

348 value = outputs.get(key) 

349 

350 if value is None: 

351 return False 

352 

353 return True 

354 

355 

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) 

360 

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) 

365 

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

367 u_ion = outputs["u_ion"][i] 

368 

369 ion_velocity = IonVelocityData( 

370 axial_distance_m=coords, 

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

372 ) 

373 

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) 

382 

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) 

387 

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 ) 

400 

401 

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. 

408 

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 

415 

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 

419 

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 } 

424 

425 return output_dict 

426 

427 

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] 

438 

439 return Measurement(thrust, NaN) 

440 

441 

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

448 

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) 

461 

462 return data 

463 

464 

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

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

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

468 

469 

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 

473 

474 

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 

488 

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 

493 

494 std = np.abs(std) #hot fix for negative data that is computed relatively 

495 

496 if end is None: 

497 end = val.size 

498 

499 if scalar: 

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

501 else: 

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

503 

504 

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

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

507 

508 # Read table from file 

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

510 keys = list(table.keys()) 

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

512 

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 

530 

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

535 

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 ) 

543 

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 

554 

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

562 

563 

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 

574 

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

576 # fill up thruster data object for this row 

577 data[opcond] = ThrusterData() 

578 

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 

589 

590 data[opcond].thrust_N = _read_measurement( 

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

592 ) 

593 

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) 

596 

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

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

599 

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) 

602 

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 ) 

608 

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 

615 

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) 

625 

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 ) 

635 

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 

641 

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 

645 

646 opcond = next_opcond 

647 opcond_start = row 

648 

649 return data 

650 

651 

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] 

662 

663 return data 

664 

665 

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 

678 

679 if header == "": 

680 return {} 

681 

682 column_names = header.split(delimiter) 

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

684 

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

686 return table