2Core functionality for CSV to .eng conversion.
5from __future__
import annotations
9from dataclasses
import dataclass, field
10from pathlib
import Path
11from typing
import List, Tuple, Optional, Dict, Any, Union
14LBF_TO_N = 4.4482216152605
18TIME_COLUMNS = {
"time",
"t",
"seconds",
"time_s",
"time_ms"}
19THRUST_COLUMNS = {
"thrust",
"f",
"force",
"thrust_n",
"thrust_lbf"}
23 "engine_name":
"name",
26 "diameter_mm":
"diameter_mm",
27 "diameter":
"diameter_mm",
28 "diam":
"diameter_mm",
29 "length_mm":
"length_mm",
30 "length":
"length_mm",
32 "prop_mass_kg":
"prop_mass_kg",
33 "propellant_mass":
"prop_mass_kg",
34 "prop_mass":
"prop_mass_kg",
35 "total_mass_kg":
"total_mass_kg",
36 "total_mass":
"total_mass_kg",
37 "mass":
"total_mass_kg",
38 "manufacturer":
"manufacturer",
39 "mfr":
"manufacturer",
40 "mfg":
"manufacturer",
48 """Metadata for a rocket engine, used in .eng file header."""
51 diameter_mm: float = 0.0
52 length_mm: float = 0.0
54 prop_mass_kg: float = 0.0
55 total_mass_kg: float = 0.0
56 manufacturer: str =
"Unknown"
60 Generate the .eng header line.
62 RASP Format (7 fields): <designation> <diameter_mm> <length_mm> <delays> <prop_mass_kg> <total_mass_kg> <manufacturer>
66 name = self.
name.replace(
" ",
"_")
if self.
name else "Unknown"
69 f
"{name} {self.diameter_mm:.0f} {self.length_mm:.0f} "
70 f
"{self.delays} {self.prop_mass_kg:.4f} {self.total_mass_kg:.4f} {mfr}"
74CurvePoints = List[Tuple[float, float]]
79) -> Tuple[int, int, Dict[str, int]]:
81 Detect time and thrust column indices from headers.
84 (time_idx, thrust_idx, metadata_col_indices)
87 ValueError: If time or thrust columns cannot be identified.
89 headers_lower = [h.lower().strip()
for h
in headers]
93 metadata_indices: Dict[str, int] = {}
95 for i, h
in enumerate(headers_lower):
98 for pattern
in TIME_COLUMNS:
99 if pattern
in h
or h.startswith(pattern):
105 for pattern
in THRUST_COLUMNS:
106 if pattern
in h
or h.startswith(pattern):
111 for meta_pattern, meta_name
in METADATA_COLUMNS.items():
112 if meta_pattern
in h
or h == meta_pattern:
113 if meta_name
not in metadata_indices:
114 metadata_indices[meta_name] = i
117 if time_idx < 0
and thrust_idx < 0:
118 if len(headers) >= 2:
123 "Cannot detect time/thrust columns. "
124 "Ensure CSV has headers like 'time,thrust' or at least 2 columns."
128 raise ValueError(
"Cannot detect time column in CSV headers.")
130 raise ValueError(
"Cannot detect thrust column in CSV headers.")
132 return time_idx, thrust_idx, metadata_indices
137 Detect units from column header suffix.
140 (time_unit, thrust_unit) - None if not detected
142 h = header.lower().strip()
146 if "_ms" in h
or h.endswith(
"ms"):
148 elif "_s" in h
or h.endswith(
"s"):
151 if "_lbf" in h
or h.endswith(
"lbf"):
153 elif "_n" in h
or h.endswith(
"n"):
156 return time_unit, thrust_unit
160 """Check if a line is a comment."""
161 stripped = line.strip()
162 return stripped.startswith(
"#")
or stripped.startswith(
"//")
166 """Parse a string to float, returning None on failure."""
168 return float(value.strip())
169 except (ValueError, TypeError):
174 path: Union[str, Path],
175 time_units: Optional[str] =
None,
176 thrust_units: Optional[str] =
None,
177) -> Tuple[EngineMetadata, CurvePoints]:
179 Read a CSV file containing thrust curve data.
182 path: Path to the CSV file.
183 time_units: Force time units ("s" or "ms"). Auto-detected if None.
184 thrust_units: Force thrust units ("N" or "lbf"). Auto-detected if None.
187 Tuple of (EngineMetadata, list of (time_s, thrust_N) tuples)
190 FileNotFoundError: If file doesn't exist.
191 ValueError: If CSV format is invalid.
194 if not path.exists():
195 raise FileNotFoundError(f
"CSV file not found: {path}")
198 points: CurvePoints = []
201 with open(path,
"r", newline=
"", encoding=
"utf-8")
as f:
205 raise ValueError(f
"CSV file is empty or contains only comments: {path}")
208 reader = csv.reader(lines)
212 raise ValueError(f
"No data rows in CSV: {path}")
219 for cell
in first_row[:2]:
229 headers = [
"time",
"thrust"]
236 if time_units
is None:
238 time_units = detected_time
or "s"
239 if thrust_units
is None:
241 thrust_units = detected_thrust
or "N"
244 metadata_extracted =
False
246 for row_num, row
in enumerate(data_rows, start=2
if has_header
else 1):
247 if len(row) <=
max(time_idx, thrust_idx):
251 row = [cell.strip()
for cell
in row]
257 if time_val
is None or thrust_val
is None:
261 if time_units ==
"ms":
263 if thrust_units ==
"lbf":
264 thrust_val *= LBF_TO_N
268 raise ValueError(f
"Negative time value at row {row_num}: {time_val}")
274 points.append((time_val, thrust_val))
277 if not metadata_extracted:
278 for meta_name, col_idx
in meta_indices.items():
279 if col_idx < len(row)
and row[col_idx]:
281 if meta_name
in (
"diameter_mm",
"length_mm",
"prop_mass_kg",
"total_mass_kg"):
283 if numeric_val
is not None:
284 setattr(metadata, meta_name, numeric_val)
286 setattr(metadata, meta_name, val)
287 metadata_extracted =
True
290 raise ValueError(f
"No valid data points found in CSV: {path}")
292 return metadata, points
297 time_decimals: int = 3,
298 thrust_decimals: int = 1,
301 Normalize thrust curve data.
304 1. Sort by time (ascending)
305 2. Remove duplicate time entries (keep last value for each time)
306 3. Ensure curve starts at t=0 (insert if missing)
307 4. Ensure curve ends with thrust=0 at burnout (append if needed)
308 5. Round values to specified decimal places
311 points: List of (time_s, thrust_N) tuples.
312 time_decimals: Decimal places for time (default 3).
313 thrust_decimals: Decimal places for thrust (default 1).
316 Normalized list of (time_s, thrust_N) tuples.
322 sorted_points = sorted(points, key=
lambda p: p[0])
325 time_to_thrust: Dict[float, float] = {}
326 for t, f
in sorted_points:
327 rounded_t = round(t, time_decimals)
328 time_to_thrust[rounded_t] = round(f, thrust_decimals)
331 deduped = sorted(time_to_thrust.items(), key=
lambda p: p[0])
334 if deduped[0][0] > 0:
336 deduped.insert(0, (0.0, 0.0))
339 if deduped[-1][1] != 0.0:
340 last_time = deduped[-1][0]
343 burnout_time = round(last_time + 0.001, time_decimals)
344 deduped.append((burnout_time, 0.0))
350 path: Union[str, Path],
351 metadata: EngineMetadata,
353 time_decimals: int = 3,
354 thrust_decimals: int = 1,
355 include_comment: bool =
True,
358 Write thrust curve data to a RASP .eng file.
361 path: Output file path.
362 metadata: Engine metadata for header line.
363 points: List of (time_s, thrust_N) tuples.
364 time_decimals: Decimal places for time output (default 3).
365 thrust_decimals: Decimal places for thrust output (default 1).
366 include_comment: Include generator comment at top (default True).
371 path.parent.mkdir(parents=
True, exist_ok=
True)
373 with open(path,
"w", encoding=
"utf-8")
as f:
376 f.write(
"; Generated by csv_to_eng\n")
379 f.write(metadata.header_line() +
"\n")
382 time_fmt = f
"{{:.{time_decimals}f}}"
383 thrust_fmt = f
"{{:.{thrust_decimals}f}}"
385 for t, thrust
in points:
386 f.write(f
"{time_fmt.format(t)} {thrust_fmt.format(thrust)}\n")
390 csv_path: Union[str, Path],
391 eng_path: Union[str, Path],
393 name: Optional[str] =
None,
394 diameter_mm: Optional[float] =
None,
395 length_mm: Optional[float] =
None,
396 delays: Optional[str] =
None,
397 prop_mass_kg: Optional[float] =
None,
398 total_mass_kg: Optional[float] =
None,
399 manufacturer: Optional[str] =
None,
401 time_units: Optional[str] =
None,
402 thrust_units: Optional[str] =
None,
404 time_decimals: int = 3,
405 thrust_decimals: int = 1,
408 Convert a CSV thrust curve file to RASP .eng format.
410 This is the high-level convenience function that combines read, normalize,
411 and write operations.
414 csv_path: Input CSV file path.
415 eng_path: Output .eng file path.
416 name: Engine name override.
417 diameter_mm: Engine diameter in mm override.
418 length_mm: Engine length in mm override.
419 delays: Delay charges string override.
420 prop_mass_kg: Propellant mass in kg override.
421 total_mass_kg: Total loaded mass in kg override.
422 manufacturer: Manufacturer name override.
423 time_units: Force time units ("s" or "ms").
424 thrust_units: Force thrust units ("N" or "lbf").
425 time_decimals: Decimal places for time (default 3).
426 thrust_decimals: Decimal places for thrust (default 1).
431 time_units=time_units,
432 thrust_units=thrust_units,
438 if diameter_mm
is not None:
439 metadata.diameter_mm = diameter_mm
440 if length_mm
is not None:
441 metadata.length_mm = length_mm
442 if delays
is not None:
443 metadata.delays = delays
444 if prop_mass_kg
is not None:
445 metadata.prop_mass_kg = prop_mass_kg
446 if total_mass_kg
is not None:
447 metadata.total_mass_kg = total_mass_kg
448 if manufacturer
is not None:
449 metadata.manufacturer = manufacturer
455 write_eng(eng_path, metadata, points, time_decimals, thrust_decimals)
Tuple[int, int, Dict[str, int]] _detect_column_indices(List[str] headers)
Tuple[EngineMetadata, CurvePoints] read_csv_curve(Union[str, Path] path, Optional[str] time_units=None, Optional[str] thrust_units=None)
Optional[float] _parse_numeric(str value)
None convert(Union[str, Path] csv_path, Union[str, Path] eng_path, Optional[str] name=None, Optional[float] diameter_mm=None, Optional[float] length_mm=None, Optional[str] delays=None, Optional[float] prop_mass_kg=None, Optional[float] total_mass_kg=None, Optional[str] manufacturer=None, Optional[str] time_units=None, Optional[str] thrust_units=None, int time_decimals=3, int thrust_decimals=1)
None write_eng(Union[str, Path] path, EngineMetadata metadata, CurvePoints points, int time_decimals=3, int thrust_decimals=1, bool include_comment=True)
Tuple[Optional[str], Optional[str]] _detect_units_from_header(str header)
CurvePoints normalize_curve(CurvePoints points, int time_decimals=3, int thrust_decimals=1)
bool _is_comment_line(str line)