STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
core.py
Go to the documentation of this file.
1"""
2Core functionality for CSV to .eng conversion.
3"""
4
5from __future__ import annotations
6
7import csv
8import re
9from dataclasses import dataclass, field
10from pathlib import Path
11from typing import List, Tuple, Optional, Dict, Any, Union
12
13# Conversion constants
14LBF_TO_N = 4.4482216152605
15MS_TO_S = 0.001
16
17# Column name patterns (case-insensitive matching)
18TIME_COLUMNS = {"time", "t", "seconds", "time_s", "time_ms"}
19THRUST_COLUMNS = {"thrust", "f", "force", "thrust_n", "thrust_lbf"}
20
21# Metadata column mappings
22METADATA_COLUMNS = {
23 "engine_name": "name",
24 "name": "name",
25 "motor_name": "name",
26 "diameter_mm": "diameter_mm",
27 "diameter": "diameter_mm",
28 "diam": "diameter_mm",
29 "length_mm": "length_mm",
30 "length": "length_mm",
31 "len": "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",
41 "delays": "delays",
42 "delay": "delays",
43}
44
45
46@dataclass
48 """Metadata for a rocket engine, used in .eng file header."""
49
50 name: str = "Unknown"
51 diameter_mm: float = 0.0
52 length_mm: float = 0.0
53 delays: str = "0" # Kept for compatibility but not used in header
54 prop_mass_kg: float = 0.0
55 total_mass_kg: float = 0.0
56 manufacturer: str = "Unknown"
57
58 def header_line(self) -> str:
59 """
60 Generate the .eng header line.
61
62 RASP Format (7 fields): <designation> <diameter_mm> <length_mm> <delays> <prop_mass_kg> <total_mass_kg> <manufacturer>
63 """
64 # Replace spaces in manufacturer with underscores
65 mfr = self.manufacturer.replace(" ", "_") if self.manufacturer else "Unknown"
66 name = self.name.replace(" ", "_") if self.name else "Unknown"
67
68 return (
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}"
71 )
72
73
74CurvePoints = List[Tuple[float, float]]
75
76
78 headers: List[str],
79) -> Tuple[int, int, Dict[str, int]]:
80 """
81 Detect time and thrust column indices from headers.
82
83 Returns:
84 (time_idx, thrust_idx, metadata_col_indices)
85
86 Raises:
87 ValueError: If time or thrust columns cannot be identified.
88 """
89 headers_lower = [h.lower().strip() for h in headers]
90
91 time_idx = -1
92 thrust_idx = -1
93 metadata_indices: Dict[str, int] = {}
94
95 for i, h in enumerate(headers_lower):
96 # Check for time column
97 if time_idx < 0:
98 for pattern in TIME_COLUMNS:
99 if pattern in h or h.startswith(pattern):
100 time_idx = i
101 break
102
103 # Check for thrust column
104 if thrust_idx < 0:
105 for pattern in THRUST_COLUMNS:
106 if pattern in h or h.startswith(pattern):
107 thrust_idx = i
108 break
109
110 # Check for metadata columns
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
115
116 # Fallback: if no headers detected, assume first two columns are time, thrust
117 if time_idx < 0 and thrust_idx < 0:
118 if len(headers) >= 2:
119 time_idx = 0
120 thrust_idx = 1
121 else:
122 raise ValueError(
123 "Cannot detect time/thrust columns. "
124 "Ensure CSV has headers like 'time,thrust' or at least 2 columns."
125 )
126
127 if time_idx < 0:
128 raise ValueError("Cannot detect time column in CSV headers.")
129 if thrust_idx < 0:
130 raise ValueError("Cannot detect thrust column in CSV headers.")
131
132 return time_idx, thrust_idx, metadata_indices
133
134
135def _detect_units_from_header(header: str) -> Tuple[Optional[str], Optional[str]]:
136 """
137 Detect units from column header suffix.
138
139 Returns:
140 (time_unit, thrust_unit) - None if not detected
141 """
142 h = header.lower().strip()
143 time_unit = None
144 thrust_unit = None
145
146 if "_ms" in h or h.endswith("ms"):
147 time_unit = "ms"
148 elif "_s" in h or h.endswith("s"):
149 time_unit = "s"
150
151 if "_lbf" in h or h.endswith("lbf"):
152 thrust_unit = "lbf"
153 elif "_n" in h or h.endswith("n"):
154 thrust_unit = "N"
155
156 return time_unit, thrust_unit
157
158
159def _is_comment_line(line: str) -> bool:
160 """Check if a line is a comment."""
161 stripped = line.strip()
162 return stripped.startswith("#") or stripped.startswith("//")
163
164
165def _parse_numeric(value: str) -> Optional[float]:
166 """Parse a string to float, returning None on failure."""
167 try:
168 return float(value.strip())
169 except (ValueError, TypeError):
170 return None
171
172
174 path: Union[str, Path],
175 time_units: Optional[str] = None,
176 thrust_units: Optional[str] = None,
177) -> Tuple[EngineMetadata, CurvePoints]:
178 """
179 Read a CSV file containing thrust curve data.
180
181 Args:
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.
185
186 Returns:
187 Tuple of (EngineMetadata, list of (time_s, thrust_N) tuples)
188
189 Raises:
190 FileNotFoundError: If file doesn't exist.
191 ValueError: If CSV format is invalid.
192 """
193 path = Path(path)
194 if not path.exists():
195 raise FileNotFoundError(f"CSV file not found: {path}")
196
197 metadata = EngineMetadata()
198 points: CurvePoints = []
199
200 # Read and filter out comment lines
201 with open(path, "r", newline="", encoding="utf-8") as f:
202 lines = [line for line in f if not _is_comment_line(line)]
203
204 if not lines:
205 raise ValueError(f"CSV file is empty or contains only comments: {path}")
206
207 # Parse CSV content
208 reader = csv.reader(lines)
209 rows = list(reader)
210
211 if not rows:
212 raise ValueError(f"No data rows in CSV: {path}")
213
214 # Detect if first row is a header
215 first_row = rows[0]
216 has_header = False
217
218 # Check if first row looks like a header (contains non-numeric values in expected positions)
219 for cell in first_row[:2]:
220 if _parse_numeric(cell) is None:
221 has_header = True
222 break
223
224 if has_header:
225 headers = first_row
226 data_rows = rows[1:]
227 else:
228 # No header - assume columns are time, thrust
229 headers = ["time", "thrust"]
230 data_rows = rows
231
232 # Detect column indices
233 time_idx, thrust_idx, meta_indices = _detect_column_indices(headers)
234
235 # Auto-detect units from headers if not specified
236 if time_units is None:
237 detected_time, _ = _detect_units_from_header(headers[time_idx])
238 time_units = detected_time or "s"
239 if thrust_units is None:
240 _, detected_thrust = _detect_units_from_header(headers[thrust_idx])
241 thrust_units = detected_thrust or "N"
242
243 # Parse data rows
244 metadata_extracted = False
245
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):
248 continue # Skip incomplete rows
249
250 # Strip whitespace from all cells
251 row = [cell.strip() for cell in row]
252
253 # Parse time and thrust
254 time_val = _parse_numeric(row[time_idx])
255 thrust_val = _parse_numeric(row[thrust_idx])
256
257 if time_val is None or thrust_val is None:
258 continue # Skip rows with invalid numeric data
259
260 # Convert units
261 if time_units == "ms":
262 time_val *= MS_TO_S
263 if thrust_units == "lbf":
264 thrust_val *= LBF_TO_N
265
266 # Validate values
267 if time_val < 0:
268 raise ValueError(f"Negative time value at row {row_num}: {time_val}")
269
270 # Clamp negative thrust to 0 (with warning possible)
271 if thrust_val < 0:
272 thrust_val = 0.0
273
274 points.append((time_val, thrust_val))
275
276 # Extract metadata from first data row
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]:
280 val = row[col_idx]
281 if meta_name in ("diameter_mm", "length_mm", "prop_mass_kg", "total_mass_kg"):
282 numeric_val = _parse_numeric(val)
283 if numeric_val is not None:
284 setattr(metadata, meta_name, numeric_val)
285 else:
286 setattr(metadata, meta_name, val)
287 metadata_extracted = True
288
289 if not points:
290 raise ValueError(f"No valid data points found in CSV: {path}")
291
292 return metadata, points
293
294
296 points: CurvePoints,
297 time_decimals: int = 3,
298 thrust_decimals: int = 1,
299) -> CurvePoints:
300 """
301 Normalize thrust curve data.
302
303 Operations:
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
309
310 Args:
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).
314
315 Returns:
316 Normalized list of (time_s, thrust_N) tuples.
317 """
318 if not points:
319 return [(0.0, 0.0)]
320
321 # Sort by time
322 sorted_points = sorted(points, key=lambda p: p[0])
323
324 # De-duplicate times (keep last occurrence)
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)
329
330 # Convert back to sorted list
331 deduped = sorted(time_to_thrust.items(), key=lambda p: p[0])
332
333 # Ensure start at t=0
334 if deduped[0][0] > 0:
335 # Insert t=0 with thrust=0 (motor hasn't ignited yet)
336 deduped.insert(0, (0.0, 0.0))
337
338 # Ensure end with thrust=0
339 if deduped[-1][1] != 0.0:
340 last_time = deduped[-1][0]
341 # Append a point slightly after with thrust=0
342 # Use a small delta to indicate burnout
343 burnout_time = round(last_time + 0.001, time_decimals)
344 deduped.append((burnout_time, 0.0))
345
346 return deduped
347
348
350 path: Union[str, Path],
351 metadata: EngineMetadata,
352 points: CurvePoints,
353 time_decimals: int = 3,
354 thrust_decimals: int = 1,
355 include_comment: bool = True,
356) -> None:
357 """
358 Write thrust curve data to a RASP .eng file.
359
360 Args:
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).
367 """
368 path = Path(path)
369
370 # Ensure parent directory exists
371 path.parent.mkdir(parents=True, exist_ok=True)
372
373 with open(path, "w", encoding="utf-8") as f:
374 # Optional comment line
375 if include_comment:
376 f.write("; Generated by csv_to_eng\n")
377
378 # Header line
379 f.write(metadata.header_line() + "\n")
380
381 # Data lines
382 time_fmt = f"{{:.{time_decimals}f}}"
383 thrust_fmt = f"{{:.{thrust_decimals}f}}"
384
385 for t, thrust in points:
386 f.write(f"{time_fmt.format(t)} {thrust_fmt.format(thrust)}\n")
387
388
390 csv_path: Union[str, Path],
391 eng_path: Union[str, Path],
392 # Metadata overrides
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,
400 # Unit specifications
401 time_units: Optional[str] = None,
402 thrust_units: Optional[str] = None,
403 # Formatting options
404 time_decimals: int = 3,
405 thrust_decimals: int = 1,
406) -> None:
407 """
408 Convert a CSV thrust curve file to RASP .eng format.
409
410 This is the high-level convenience function that combines read, normalize,
411 and write operations.
412
413 Args:
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).
427 """
428 # Read CSV
429 metadata, points = read_csv_curve(
430 csv_path,
431 time_units=time_units,
432 thrust_units=thrust_units,
433 )
434
435 # Apply overrides
436 if name is not None:
437 metadata.name = name
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
450
451 # Normalize curve
452 points = normalize_curve(points, time_decimals, thrust_decimals)
453
454 # Write output
455 write_eng(eng_path, metadata, points, time_decimals, thrust_decimals)
#define max(a, b)
Definition Arduino.h:43
Tuple[int, int, Dict[str, int]] _detect_column_indices(List[str] headers)
Definition core.py:79
Tuple[EngineMetadata, CurvePoints] read_csv_curve(Union[str, Path] path, Optional[str] time_units=None, Optional[str] thrust_units=None)
Definition core.py:177
Optional[float] _parse_numeric(str value)
Definition core.py:165
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)
Definition core.py:406
None write_eng(Union[str, Path] path, EngineMetadata metadata, CurvePoints points, int time_decimals=3, int thrust_decimals=1, bool include_comment=True)
Definition core.py:356
Tuple[Optional[str], Optional[str]] _detect_units_from_header(str header)
Definition core.py:135
CurvePoints normalize_curve(CurvePoints points, int time_decimals=3, int thrust_decimals=1)
Definition core.py:299
bool _is_comment_line(str line)
Definition core.py:159