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
« 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.
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}
303"""Forward mapping between operating condition short and long names."""
306@dataclass(frozen=True)
307class OperatingCondition:
308 """Operating conditions for a Hall thruster.
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 """
315 background_pressure_torr: float
316 discharge_voltage_v: float
317 anode_mass_flow_rate_kg_s: float
320# ====================================================================================================================
321# Amisc interop utilities
322# ====================================================================================================================
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 ]
343 # Check that all keys are present
344 for key in keys:
345 value = outputs.get(key)
347 if value is None:
348 return False
350 return True
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)
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)
363 coords = outputs["u_ion_coords"][i]
364 u_ion = outputs["u_ion"][i]
366 ion_velocity = IonVelocityData(
367 axial_distance_m=coords,
368 velocity_m_s=Measurement(u_ion, np.full_like(u_ion, NaN)),
369 )
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)
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)
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 )
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.
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
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
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 }
422 return output_dict
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]
436 return Measurement(thrust, NaN)
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).
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)
459 return data
462def _rel_uncertainty_key(qty: str) -> str:
463 body, _ = qty.split('(', 1) if '(' in qty else (qty, '')
464 return body.rstrip().casefold() + ' relative uncertainty'
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
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
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
491 if end is None:
492 end = val.size
494 if scalar:
495 return Measurement(mean[start], std[start])
496 else:
497 return Measurement(mean[start:end], std[start:end])
500def _load_single_file(file: PathLike) -> dict[OperatingCondition, ThrusterData]:
501 """Load data from a single file into a dict map of `OperatingCondition` -> `ThrusterData`."""
503 # Read table from file
504 table = _table_from_file(file, delimiter=",", comments="#")
505 keys = list(table.keys())
506 data: dict[OperatingCondition, ThrusterData] = {}
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
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)`.")
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 )
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])
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
552 # either at end of operating condition or end of table
553 # fill up thruster data object for this row
554 data[opcond] = ThrusterData()
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
567 data[opcond].thrust_N = _read_measurement(
568 table, key, val, opcond_start, scale=conversion_scale, scalar=True
569 )
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)
574 elif key == "cathode coupling voltage (v)":
575 data[opcond].cathode_coupling_voltage_V = _read_measurement(table, key, val, opcond_start, scalar=True)
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)
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 )
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
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)
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 )
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
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
623 opcond = next_opcond
624 opcond_start = row
626 return data
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]
640 return data
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
656 if header == "":
657 return {}
659 column_names = header.split(delimiter)
660 data = np.genfromtxt(file, delimiter=delimiter, comments=comments, skip_header=header_start + 1)
662 table: dict[str, Array] = {column.casefold().strip(): data[:, i] for (i, column) in enumerate(column_names)}
663 return table