STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
copv_solve_both.py
Go to the documentation of this file.
1import numpy as np
2import pandas as pd
3from scipy.interpolate import RegularGridInterpolator
4
5# ----------------------------
6# Z(P,T) utilities (unchanged)
7# ----------------------------
8def load_Z_lookup_table(csv_path):
9 df_z = pd.read_csv(csv_path)
10 T_vals = np.unique(np.sort(df_z["T_K"].values))
11 P_vals = np.unique(np.sort(df_z["P_Pa"].values))
12 pivot = df_z.pivot(index="T_K", columns="P_Pa", values="Z")
13 pivot = pivot.reindex(index=T_vals, columns=P_vals)
14 Z_grid = pivot.values
15 interp = RegularGridInterpolator((T_vals, P_vals), Z_grid,
16 bounds_error=False, fill_value=None)
17 return interp, T_vals, P_vals
18
19def Z_lookup(T_K, P_Pa, interp, default_Z=1.0):
20 T_arr = np.atleast_1d(T_K).astype(float)
21 P_arr = np.atleast_1d(P_Pa).astype(float)
22 if T_arr.size == 1 and P_arr.size > 1:
23 T_arr = np.full_like(P_arr, T_arr[0])
24 elif P_arr.size == 1 and T_arr.size > 1:
25 P_arr = np.full_like(T_arr, P_arr[0])
26 elif T_arr.size != P_arr.size:
27 raise ValueError("Temperature and pressure arrays must have matching sizes.")
28 pts = np.column_stack([T_arr, P_arr])
29 Z_vals = interp(pts)
30 Z_vals = np.where(np.isnan(Z_vals), default_Z, Z_vals)
31 if np.isscalar(T_K) and np.isscalar(P_Pa):
32 return float(Z_vals[0])
33 return Z_vals
34
35# ----------------------------
36# Core solver (updated)
37# ----------------------------
39 df,
40 config,
41 *,
42 n=1.2,
43 T0_K=300.0,
44 Tp_K=300.0,
45 use_real_gas=True,
46 n2_Z_csv="n2_Z_lookup.csv",
47 pressurant_R=None,
48 branch_temperatures_K=None,
49 copv_volume_m3=None,
50 copv_P0_Pa=None,
51 copv_m0_kg=None,
52 tol=1e-4,
53 max_iter=60
54):
55 """
56 Updated solver that uses known COPV volume (config.press_tank.press_volume)
57 and initial ullage computed from initial propellant mass, prop tank volume, and density.
58
59 Provide either copv_volume_m3 OR copv_P0_Pa (exactly one). With copv_volume_m3, also giving
60 copv_m0_kg (the charge actually loaded) CHECKS that charge -- its P0 at T0_K, its pressure
61 trace and its margin -- instead of sizing the P0 that just meets the requirement.
62 """
63
64 assert (copv_volume_m3 is None) ^ (copv_P0_Pa is None), \
65 "Provide exactly one: copv_volume_m3 OR copv_P0_Pa."
66 assert copv_m0_kg is None or copv_volume_m3 is not None, \
67 "copv_m0_kg checks a charge in a known volume: give copv_volume_m3 with it."
68
69 branch_temperatures_K = branch_temperatures_K or {}
70
71 def infer_pressurant_R():
72 fluids = getattr(config, "fluids", None) or {}
73 press_cfg = None
74 if isinstance(fluids, dict):
75 press_cfg = fluids.get("pressurant")
76 else:
77 try:
78 press_cfg = fluids.get("pressurant")
79 except Exception:
80 press_cfg = None
81 candidate_attrs = ("R", "R_specific")
82 if press_cfg is not None:
83 for attr in candidate_attrs:
84 val = getattr(press_cfg, attr, None)
85 if val is not None:
86 return float(val)
87 molar_mass = getattr(press_cfg, "molar_mass", None)
88 if molar_mass not in (None, 0):
89 return 8.31446261815324 / float(molar_mass)
90 return None
91
92 R_default = 296.803 # J/(kg·K) for gaseous N2
93 R = float(pressurant_R) if pressurant_R is not None else (infer_pressurant_R() or R_default)
94
95 branch_meta = {
96 "oxidizer": {
97 "label": "oxidizer",
98 "fluid_key": "oxidizer",
99 "tank_attr": "lox_tank",
100 "height_attr": "lox_h",
101 "radius_attr": "lox_radius",
102 "mdot_columns": ["mdot_O (kg/s)", "mdot_O_kg_s"],
103 "pressure_columns": ["P_tank_O (psi)", "P_tank_O_psi"],
104 "volume_field_candidates": ["tank_volume_m3", "volume_m3", "tank_volume", "volume"],
105 "mass_field_candidates": ["mass", "initial_mass_kg"],
106 "extra_volume_paths": [
107 ("propellant", "tank_volume_m3"),
108 ("propellant", "oxidizer_tank_volume_m3"),
109 ],
110 "extra_mass_paths": [
111 ("propellant", "initial_mass_kg"),
112 ("propellant", "initial_oxidizer_mass_kg"),
113 ("press_tank", "initial_propellant_mass_kg"),
114 ],
115 },
116 "fuel": {
117 "label": "fuel",
118 "fluid_key": "fuel",
119 "tank_attr": "fuel_tank",
120 "height_attr": "rp1_h",
121 "radius_attr": "rp1_radius",
122 "mdot_columns": ["mdot_F (kg/s)", "mdot_F_kg_s"],
123 "pressure_columns": ["P_tank_F (psi)", "P_tank_F_psi"],
124 "volume_field_candidates": ["tank_volume_m3", "volume_m3", "tank_volume", "volume"],
125 "mass_field_candidates": ["mass", "initial_mass_kg"],
126 "extra_volume_paths": [
127 ("propellant", "tank_volume_m3"),
128 ("propellant", "fuel_tank_volume_m3"),
129 ],
130 "extra_mass_paths": [
131 ("propellant", "initial_mass_kg"),
132 ("propellant", "initial_fuel_mass_kg"),
133 ("press_tank", "initial_propellant_mass_kg"),
134 ],
135 },
136 }
137
138 def get_nested_attr(root, *attrs):
139 cur = root
140 for attr in attrs:
141 if cur is None:
142 return None
143 cur = getattr(cur, attr, None)
144 return cur
145
146 # Known COPV physical volume from config
147 if copv_volume_m3 is None:
148 if hasattr(config, "press_tank") and hasattr(config.press_tank, "press_volume"):
149 copv_volume_m3 = float(config.press_tank.press_volume)
150 elif hasattr(config, "press_tank") and hasattr(config.press_tank, "volume_m3"):
151 copv_volume_m3 = float(config.press_tank.volume_m3)
152 else:
153 raise KeyError("COPV volume not provided and not found in config.press_tank (press_volume).")
154 copv_volume_m3 = config.press_tank.press_volume
155
156 # Conversion
157 psi_to_Pa = 6894.757293168
158
159 # --- Read time-series from df ---
160 time = np.asarray(df["time"].values, dtype=float)
161
162 # Time step handling
163 dt = np.diff(time)
164 if dt.size == 0:
165 raise ValueError("Need multiple time samples.")
166 dt_full = np.concatenate(([dt[0]], dt))
167
168 # Z lookups
169 if use_real_gas:
170 interp, _, _ = load_Z_lookup_table(n2_Z_csv)
171 else:
172 interp = None
173
174 def pick_column(column_options, label):
175 for candidate in column_options:
176 if candidate in df.columns:
177 return candidate, np.asarray(df[candidate].values, dtype=float)
178 raise KeyError(f"Dataframe missing {label} column (tried: {column_options})")
179
180 def resolve_tank_value(spec, field_candidates, *, allow_geometry=False):
181 tank_section = getattr(config, spec["tank_attr"], None)
182 for field in field_candidates:
183 if tank_section is None:
184 break
185 val = getattr(tank_section, field, None)
186 if val is not None:
187 return float(val)
188 for path in spec.get("extra_volume_paths" if allow_geometry else "extra_mass_paths", []):
189 val = get_nested_attr(config, *path)
190 if val is not None:
191 return float(val)
192 if allow_geometry and tank_section is not None:
193 h = getattr(tank_section, spec["height_attr"], None)
194 r = getattr(tank_section, spec["radius_attr"], None)
195 if h is not None and r is not None:
196 return float(np.pi * (r ** 2) * h)
197 return None
198
199 def resolve_tank_volume(spec):
200 volume = resolve_tank_value(spec, spec["volume_field_candidates"], allow_geometry=True)
201 if volume is None:
202 volume = get_nested_attr(config, "propellant", "tank_volume_m3")
203 if volume is None:
204 raise KeyError(f"Need {spec['label']} tank volume in config (e.g., config.{spec['tank_attr']}.* or propellant.tank_volume_m3).")
205 return float(volume)
206
207 def resolve_initial_mass(spec):
208 # start with direct tank fields
209 tank_section = getattr(config, spec["tank_attr"], None)
210 if tank_section is not None:
211 for field in spec["mass_field_candidates"]:
212 val = getattr(tank_section, field, None)
213 if val is not None:
214 return float(val)
215 for path in spec.get("extra_mass_paths", []):
216 val = get_nested_attr(config, *path)
217 if val is not None:
218 return float(val)
219 fallback = get_nested_attr(config, "press_tank", "initial_propellant_mass_kg")
220 if fallback is not None:
221 return float(fallback)
222 raise KeyError(f"Need initial {spec['label']} mass in config (e.g., config.{spec['tank_attr']}.mass).")
223
224 def compute_branch_requirements(branch_key, spec):
225 label = spec["label"]
226 mdot_col, mdot = pick_column(spec["mdot_columns"], f"{label} mass flow")
227 P_col, P_tank_psi = pick_column(spec["pressure_columns"], f"{label} tank pressure")
228
229 if mdot.shape[0] != time.shape[0] or P_tank_psi.shape[0] != time.shape[0]:
230 raise ValueError(f"{label.capitalize()} timeseries length mismatch with 'time' column.")
231
232 rho = float(config.fluids[spec["fluid_key"]].density)
233 V_tank = resolve_tank_volume(spec)
234 m_initial = resolve_initial_mass(spec)
235
236 Vg0 = V_tank - (m_initial / rho)
237 if Vg0 <= 0:
238 raise ValueError(f"[{label}] Computed initial ullage <= 0. Check tank volume, density, or initial mass inputs.")
239
240 dV = mdot * dt_full / rho
241 Vg = Vg0 + np.cumsum(dV)
242
243 P_tank_Pa = P_tank_psi * psi_to_Pa
244 T_branch = float(branch_temperatures_K.get(branch_key, Tp_K))
245 if use_real_gas:
246 Zp_branch = Z_lookup(T_branch, P_tank_Pa, interp)
247 else:
248 Zp_branch = np.ones_like(P_tank_Pa)
249
250 m_g_req_branch = (P_tank_Pa * Vg) / (Zp_branch * R * T_branch)
251 m_prev = np.concatenate(([m_g_req_branch[0]], m_g_req_branch[:-1]))
252 dm_branch = np.maximum(m_g_req_branch - m_prev, 0.0)
253 M_branch = np.cumsum(dm_branch)
254
255 return {
256 "label": label,
257 "mdot_column": mdot_col,
258 "pressure_column": P_col,
259 "mdot_kg_s": mdot,
260 "P_tank_Pa": P_tank_Pa,
261 "Vg0_m3": Vg0,
262 "Vg_m3": Vg,
263 "gas_temperature_K": T_branch,
264 "rho_propellant": rho,
265 "tank_volume_m3": V_tank,
266 "initial_mass_kg": m_initial,
267 "m_g_req_kg": m_g_req_branch,
268 "dm_delivered_kg": dm_branch,
269 "M_delivered_kg": M_branch,
270 "Zp_trace": Zp_branch,
271 }
272
273 branch_results = {}
274 for branch_key, spec in branch_meta.items():
275 branch_results[branch_key] = compute_branch_requirements(branch_key, spec)
276
277 if not branch_results:
278 raise ValueError("No branch data computed; ensure dataframe includes oxidizer and fuel inputs.")
279
280 # Combined requirements across all tanks: pressures take the maximum, mass draws sum
281 pressure_traces = [data["P_tank_Pa"] for data in branch_results.values()]
282 P_tank_required_Pa = pressure_traces[0].copy()
283 for arr in pressure_traces[1:]:
284 if arr.shape != P_tank_required_Pa.shape:
285 raise ValueError("All branch traces must share the same time dimension.")
286 P_tank_required_Pa = np.maximum(P_tank_required_Pa, arr)
287
288 template_branch = next(iter(branch_results.values()))
289 combined_m_g_req = np.zeros_like(template_branch["m_g_req_kg"])
290 combined_dm = np.zeros_like(template_branch["dm_delivered_kg"])
291 combined_M = np.zeros_like(template_branch["M_delivered_kg"])
292
293 for data in branch_results.values():
294 combined_m_g_req += data["m_g_req_kg"]
295 combined_dm += data["dm_delivered_kg"]
296 combined_M += data["M_delivered_kg"]
297
298 M_total = combined_M[-1]
299
300 # --- Helper: compute Z_H at given P_H (with fallback) ---
301 def estimate_Z_H(P_H):
302 if not use_real_gas:
303 return 1.0
304 # The interpolator EXTRAPOLATES outside the table (fill_value=None), so the table must cover
305 # COPV pressures: n2_Z_lookup.csv runs to 60 MPa (CoolProp; it stopped at 8.3 MPa before).
306 z = Z_lookup(T0_K, P_H, interp, default_Z=1.0)
307 return z
308
309 # --- HPS pressure from m_H, V_H, and P0 using fixed-point on Z_H ---
310 def compute_PH_from_mH(mH, V_H, P0):
311 """
312 Solve for P_H given mH, V_H, P0, T0_K and polytropic exponent n.
313 This solves: mH = P_H * V_H / (Z_H(P_H) * R * T_H)
314 with T_H = T0 * (P_H/P0)^{(n-1)/n}
315 and Z_H a function of P_H (estimated via estimate_Z_H).
316 We iterate a few times on P_H because Z_H depends on P_H.
317 """
318 # initial guess: assume Z_H = Z_at_P0
319 Z_guess = estimate_Z_H(P0)
320 # compute initial P_H estimate from explicit algebra assuming Z_guess
321 # P_H = ( (mH * Z_guess * R * T0) / (V_H * P0^{(n-1)/n}) )^n
322 prefactor = (mH * Z_guess * R * T0_K) / (V_H * (P0 ** ((n - 1.0) / n)))
323 # protect against negative/zero
324 prefactor = max(prefactor, 1e-20)
325 P_H = prefactor ** n
326
327 # iterate to update Z_H(P_H) then recompute P_H
328 for _ in range(8):
329 Z_new = estimate_Z_H(P_H)
330 if Z_new <= 0:
331 Z_new = Z_guess
332 prefactor = (mH * Z_new * R * T0_K) / (V_H * (P0 ** ((n - 1.0) / n)))
333 prefactor = max(prefactor, 1e-20)
334 P_H_new = prefactor ** n
335 # convergence check relative
336 if abs(P_H_new - P_H) / max(1.0, P_H_new) < 1e-6:
337 P_H = P_H_new
338 break
339 P_H = P_H_new
340 return P_H
341
342 # copv_pressure_trace now uses compute_PH_from_mH
343 def copv_pressure_trace(m0, V_H, P0):
344 mH = m0 - combined_M
345 mH = np.maximum(mH, 1e-12)
346 PH = np.array([compute_PH_from_mH(mHi, V_H, P0) for mHi in mH])
347 return PH
348
349 # Feasibility solvers (updated to compute Z0 appropriately inside)
350 def solve_for_P0_given_VH(V_H):
351 P0_low = np.max(P_tank_required_Pa) # must be at least as large as peak requirement
352 P0_high = max(P0_low * 3.0, 5.0e7)
353
354 def ok(P0):
355 # compute m0 from P0 and Z at P0
356 Z0_trial = estimate_Z_H(P0)
357 m0 = P0 * V_H / (Z0_trial * R * T0_K)
358 PH = copv_pressure_trace(m0, V_H, P0)
359 return np.min(PH - P_tank_required_Pa) >= 0.0
360
361 itg = 0
362 while not ok(P0_high) and itg < 30:
363 P0_high *= 1.6
364 itg += 1
365
366 lo, hi = P0_low, P0_high
367 for _ in range(max_iter):
368 mid = 0.5 * (lo + hi)
369 if ok(mid):
370 hi = mid
371 else:
372 lo = mid
373 if (hi - lo) / max(1.0, hi) < tol:
374 break
375
376 P0_star = hi
377 Z0_star = estimate_Z_H(P0_star)
378 m0_star = P0_star * V_H / (Z0_star * R * T0_K)
379 PH_star = copv_pressure_trace(m0_star, V_H, P0_star)
380 return P0_star, m0_star, PH_star
381
382 def solve_for_VH_given_P0(P0):
383 V_low = 1e-5
384 V_high = 0.5
385
386 def ok(VH):
387 Z0_trial = estimate_Z_H(P0)
388 m0 = P0 * VH / (Z0_trial * R * T0_K)
389 PH = copv_pressure_trace(m0, VH, P0)
390 return np.min(PH - P_tank_required_Pa) >= 0.0
391
392 itg = 0
393 while not ok(V_high) and itg < 30:
394 V_high *= 1.6
395 itg += 1
396
397 lo, hi = V_low, V_high
398 for _ in range(max_iter):
399 mid = 0.5 * (lo + hi)
400 if ok(mid):
401 hi = mid
402 else:
403 lo = mid
404 if (hi - lo) / max(1.0, hi) < tol:
405 break
406
407 V_star = hi
408 Z0_star = estimate_Z_H(P0)
409 m0_star = P0 * V_star / (Z0_star * R * T0_K)
410 PH_star = copv_pressure_trace(m0_star, V_star, P0)
411 return V_star, m0_star, PH_star
412
413 # --- Results bundle ---
414 results = {
415 "time_s": time,
416 "branches": branch_results,
417 "P_tank_required_Pa": P_tank_required_Pa,
418 "combined_m_g_req_kg": combined_m_g_req,
419 "combined_dm_delivered_kg": combined_dm,
420 "combined_M_delivered_kg": combined_M,
421 "total_delivered_mass_kg": float(M_total),
422 "R_pressurant": R,
423 "T0_K": T0_K,
424 "Tp_K": Tp_K,
425 "branch_gas_temperatures_K": {k: float(v["gas_temperature_K"]) for k, v in branch_results.items()},
426 "n": n,
427 }
428
429 if copv_volume_m3 is not None and copv_m0_kg is not None:
430 # a charge m0 in a known volume: its P0 is the state m0 = P0 V / (Z(P0) R T0)
431 V_H, m0 = float(copv_volume_m3), float(copv_m0_kg)
432 P0 = m0 * R * T0_K / V_H
433 for _ in range(50):
434 P0_new = m0 * estimate_Z_H(P0) * R * T0_K / V_H
435 if abs(P0_new - P0) <= 1e-9 * P0_new:
436 P0 = P0_new
437 break
438 P0 = P0_new
439 PH = copv_pressure_trace(m0, V_H, P0)
440 results.update({
441 "mode": "checked_m0_given_VH",
442 "copv_volume_m3": V_H,
443 "P0_Pa": float(P0),
444 "m0_kg": m0,
445 "PH_trace_Pa": PH,
446 "min_margin_Pa": float(np.min(PH - P_tank_required_Pa)),
447 "branch_min_margins_Pa": {
448 name: float(np.min(PH - data["P_tank_Pa"])) for name, data in branch_results.items()
449 },
450 })
451 elif copv_volume_m3 is not None:
452 # user gave a COPV physical volume; solve for P0
453 P0_star, m0_star, PH_star = solve_for_P0_given_VH(copv_volume_m3)
454 branch_margins = {
455 name: float(np.min(PH_star - data["P_tank_Pa"]))
456 for name, data in branch_results.items()
457 }
458 results.update({
459 "mode": "sized_P0_given_VH",
460 "copv_volume_m3": float(copv_volume_m3),
461 "P0_Pa": float(P0_star),
462 "m0_kg": float(m0_star),
463 "PH_trace_Pa": PH_star,
464 "min_margin_Pa": float(np.min(PH_star - P_tank_required_Pa)),
465 "branch_min_margins_Pa": branch_margins,
466 })
467 else:
468 # user provided P0 and we size VH
469 V_star, m0_star, PH_star = solve_for_VH_given_P0(copv_P0_Pa)
470 branch_margins = {
471 name: float(np.min(PH_star - data["P_tank_Pa"]))
472 for name, data in branch_results.items()
473 }
474 results.update({
475 "mode": "sized_VH_given_P0",
476 "P0_Pa": float(copv_P0_Pa),
477 "copv_volume_m3": float(V_star),
478 "m0_kg": float(m0_star),
479 "PH_trace_Pa": PH_star,
480 "min_margin_Pa": float(np.min(PH_star - P_tank_required_Pa)),
481 "branch_min_margins_Pa": branch_margins,
482 })
483
484 return results
#define max(a, b)
Definition Arduino.h:43
static bool ok
Definition flight_e2e.c:435
size_or_check_copv_for_polytropic_N2(df, config, *, n=1.2, T0_K=300.0, Tp_K=300.0, use_real_gas=True, n2_Z_csv="n2_Z_lookup.csv", pressurant_R=None, branch_temperatures_K=None, copv_volume_m3=None, copv_P0_Pa=None, copv_m0_kg=None, tol=1e-4, max_iter=60)
Z_lookup(T_K, P_Pa, interp, default_Z=1.0)
load_Z_lookup_table(csv_path)