46 n2_Z_csv="n2_Z_lookup.csv",
48 branch_temperatures_K=None,
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.
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.
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."
69 branch_temperatures_K = branch_temperatures_K
or {}
71 def infer_pressurant_R():
72 fluids = getattr(config,
"fluids",
None)
or {}
74 if isinstance(fluids, dict):
75 press_cfg = fluids.get(
"pressurant")
78 press_cfg = fluids.get(
"pressurant")
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)
87 molar_mass = getattr(press_cfg,
"molar_mass",
None)
88 if molar_mass
not in (
None, 0):
89 return 8.31446261815324 / float(molar_mass)
93 R = float(pressurant_R)
if pressurant_R
is not None else (infer_pressurant_R()
or R_default)
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"),
110 "extra_mass_paths": [
111 (
"propellant",
"initial_mass_kg"),
112 (
"propellant",
"initial_oxidizer_mass_kg"),
113 (
"press_tank",
"initial_propellant_mass_kg"),
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"),
130 "extra_mass_paths": [
131 (
"propellant",
"initial_mass_kg"),
132 (
"propellant",
"initial_fuel_mass_kg"),
133 (
"press_tank",
"initial_propellant_mass_kg"),
138 def get_nested_attr(root, *attrs):
143 cur = getattr(cur, attr,
None)
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)
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
157 psi_to_Pa = 6894.757293168
160 time = np.asarray(df[
"time"].values, dtype=float)
165 raise ValueError(
"Need multiple time samples.")
166 dt_full = np.concatenate(([dt[0]], dt))
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})")
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:
185 val = getattr(tank_section, field,
None)
188 for path
in spec.get(
"extra_volume_paths" if allow_geometry
else "extra_mass_paths", []):
189 val = get_nested_attr(config, *path)
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)
199 def resolve_tank_volume(spec):
200 volume = resolve_tank_value(spec, spec[
"volume_field_candidates"], allow_geometry=
True)
202 volume = get_nested_attr(config,
"propellant",
"tank_volume_m3")
204 raise KeyError(f
"Need {spec['label']} tank volume in config (e.g., config.{spec['tank_attr']}.* or propellant.tank_volume_m3).")
207 def resolve_initial_mass(spec):
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)
215 for path
in spec.get(
"extra_mass_paths", []):
216 val = get_nested_attr(config, *path)
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).")
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")
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.")
232 rho = float(config.fluids[spec[
"fluid_key"]].density)
233 V_tank = resolve_tank_volume(spec)
234 m_initial = resolve_initial_mass(spec)
236 Vg0 = V_tank - (m_initial / rho)
238 raise ValueError(f
"[{label}] Computed initial ullage <= 0. Check tank volume, density, or initial mass inputs.")
240 dV = mdot * dt_full / rho
241 Vg = Vg0 + np.cumsum(dV)
243 P_tank_Pa = P_tank_psi * psi_to_Pa
244 T_branch = float(branch_temperatures_K.get(branch_key, Tp_K))
246 Zp_branch =
Z_lookup(T_branch, P_tank_Pa, interp)
248 Zp_branch = np.ones_like(P_tank_Pa)
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)
257 "mdot_column": mdot_col,
258 "pressure_column": P_col,
260 "P_tank_Pa": P_tank_Pa,
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,
274 for branch_key, spec
in branch_meta.items():
275 branch_results[branch_key] = compute_branch_requirements(branch_key, spec)
277 if not branch_results:
278 raise ValueError(
"No branch data computed; ensure dataframe includes oxidizer and fuel inputs.")
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)
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"])
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"]
298 M_total = combined_M[-1]
301 def estimate_Z_H(P_H):
306 z =
Z_lookup(T0_K, P_H, interp, default_Z=1.0)
310 def compute_PH_from_mH(mH, V_H, P0):
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.
319 Z_guess = estimate_Z_H(P0)
322 prefactor = (mH * Z_guess * R * T0_K) / (V_H * (P0 ** ((n - 1.0) / n)))
324 prefactor =
max(prefactor, 1e-20)
329 Z_new = estimate_Z_H(P_H)
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
336 if abs(P_H_new - P_H) /
max(1.0, P_H_new) < 1e-6:
343 def copv_pressure_trace(m0, V_H, P0):
345 mH = np.maximum(mH, 1e-12)
346 PH = np.array([compute_PH_from_mH(mHi, V_H, P0)
for mHi
in mH])
350 def solve_for_P0_given_VH(V_H):
351 P0_low = np.max(P_tank_required_Pa)
352 P0_high =
max(P0_low * 3.0, 5.0e7)
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
362 while not ok(P0_high)
and itg < 30:
366 lo, hi = P0_low, P0_high
367 for _
in range(max_iter):
368 mid = 0.5 * (lo + hi)
373 if (hi - lo) /
max(1.0, hi) < tol:
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
382 def solve_for_VH_given_P0(P0):
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
393 while not ok(V_high)
and itg < 30:
397 lo, hi = V_low, V_high
398 for _
in range(max_iter):
399 mid = 0.5 * (lo + hi)
404 if (hi - lo) /
max(1.0, hi) < tol:
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
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),
425 "branch_gas_temperatures_K": {k: float(v[
"gas_temperature_K"])
for k, v
in branch_results.items()},
429 if copv_volume_m3
is not None and copv_m0_kg
is not None:
431 V_H, m0 = float(copv_volume_m3), float(copv_m0_kg)
432 P0 = m0 * R * T0_K / V_H
434 P0_new = m0 * estimate_Z_H(P0) * R * T0_K / V_H
435 if abs(P0_new - P0) <= 1e-9 * P0_new:
439 PH = copv_pressure_trace(m0, V_H, P0)
441 "mode":
"checked_m0_given_VH",
442 "copv_volume_m3": V_H,
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()
451 elif copv_volume_m3
is not None:
453 P0_star, m0_star, PH_star = solve_for_P0_given_VH(copv_volume_m3)
455 name: float(np.min(PH_star - data[
"P_tank_Pa"]))
456 for name, data
in branch_results.items()
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,
469 V_star, m0_star, PH_star = solve_for_VH_given_P0(copv_P0_Pa)
471 name: float(np.min(PH_star - data[
"P_tank_Pa"]))
472 for name, data
in branch_results.items()
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,