File size: 42,477 Bytes
ed65aea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
"""
Cryogenic Pump Cycle ODE — 10-Variable System with Fast Engine Architecture

Merges VBA Pack 5 physics (10 state variables, downstream volumes, multi-cycle)
into cycle2mdot_fast_v2's performance layer (AbstractState, specify_phase,
1D DCV table, flash caching, two-pass Newton EOS).

State vector (10 variables, full downstream):
    y = [mc, uc, vp, xp, vip, xip, msb, usb, mchss, uchss]
When exit_param is None, 6-variable reduced system (no downstream).

Dual-backend EOS (Phase 2):
  - H2: Numba-JIT Helmholtz (break_coolprop/h2_props) — ~0.7μs per state eval
  - N2 / other fluids: CoolProp AbstractState with specify_phase — ~15-30μs

Key optimizations vs original VBA5 port:
  1. Numba Helmholtz EOS for H2 — 15-20x faster than CoolProp per call
  2. CoolProp AbstractState + specify_phase for N2 and other fluids
  3. 1D DCV table — zero EOS calls for ~70% of DCV flow evaluations
  4. Flash caching — ICV+BB shared upstream PT flash (CoolProp path)
  5. Two-pass Newton EOS — faster convergence than DmassUmass
  6. Corrected energy balance — fixes VBA line 322 bug

Author: ODE v2 — Phase 2 (dual-backend)
"""

import sys
import os
import math
import time
import numpy as np
import CoolProp
from scipy.integrate import solve_ivp
from typing import Optional, Tuple, List, Dict, Any

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cycle2mdot_cached import (
    refprop, coolprop_fluid_name, kv_from_Cd_and_RO_dia, flow_RF_kgpm,
    mixture_pump_prop, free_conv_2cyl_Wpm, composite_thermal_conductivity,
    _get_cached_fluid_props, _build_ps_table_1d, print_results,
    minmax, PI, STEFAN_BOLTZMANN,
)
from cycle2mdot_fast import _build_ps_table_1d_cached, tc_ss304

# Numba Helmholtz EOS for H2 (Phase 2 fast path — graceful fallback)
_HAS_NUMBA_H2 = False
try:
    from break_coolprop import h2_props as h2_numba
    _HAS_NUMBA_H2 = True
except ImportError:
    h2_numba = None

# ============================================================================
# Constants (cached from CoolProp to avoid attribute lookups in hot path)
# ============================================================================
_H2_TC = 33.145         # K — H2 critical temperature
_H2_PC_PA = 1.2964e6    # Pa — H2 critical pressure

_PHASE_SUPERCRITICAL_GAS = CoolProp.iphase_supercritical_gas
_PHASE_SUPERCRITICAL_LIQUID = CoolProp.iphase_supercritical_liquid
_PHASE_LIQUID = CoolProp.iphase_liquid
_PT_INPUTS = CoolProp.PT_INPUTS
_PSmass_INPUTS = CoolProp.PSmass_INPUTS
_DmassT_INPUTS = CoolProp.DmassT_INPUTS

_sqrt = math.sqrt
_sin = math.sin
_cos = math.cos
_fabs = math.fabs
_TWO_PI = 2.0 * PI


# ============================================================================
# Phase specification guards (from cycle2mdot_fast_v2.py)
# ============================================================================
def _specify_phase_safe(AS, P_Pa, T_K, fluid_Tc=_H2_TC, fluid_Pc=_H2_PC_PA):
    """Specify CoolProp phase for supercritical fluid, skip near critical."""
    if T_K > fluid_Tc and P_Pa > fluid_Pc:
        AS.specify_phase(_PHASE_SUPERCRITICAL_GAS)
    else:
        AS.unspecify_phase()


def _specify_phase_td(AS, T_K, den, fluid_Tc=_H2_TC):
    """Phase specification for T,D flash. At T > Tc, any density is supercritical."""
    if T_K > fluid_Tc:
        AS.specify_phase(_PHASE_SUPERCRITICAL_GAS)
    else:
        AS.unspecify_phase()


def Kv_from_Cv(Cv):
    return 0.865 * Cv


# ============================================================================
# RHS factory: builds the closure-based ODE right-hand side
# ============================================================================
def _make_rhs_v2(p, AS, dcv1d_P, dcv1d_h, dcv1d_d, corrected=True):
    """Build a high-performance RHS function from parameter dict p.

    Returns (rhs, rhs_calls) where rhs(t, y) -> dy/dt.

    Uses v2's AbstractState API, specify_phase, 1D DCV table, flash caching,
    and two-pass Newton EOS — NOT PropsSI or DmassUmass.
    """
    # ── Unpack all parameters from dict ──
    pump_cpm = p['pump_cpm']; Ptank_barg = p['Ptank_barg']; Tin_K = p['Tin_K']
    flash_eff = p['flash_eff']; has_ds = p['has_downstream']
    ICVport_mm = p['ICVport_mm']; ICVmass = p['ICVmass']; ICVtravel = p['ICVtravel']
    ICVdpArea = p['ICVdpArea']; ICVFs_N = p['ICVFs_N']; ICVSC_Npm = p['ICVSC_Npm']
    ICVleakKv = p['ICVleakKv']
    DCVport_mm = p['DCVport_mm']; DCVmass = p['DCVmass']; DCVtravel = p['DCVtravel']
    DCVdpArea = p['DCVdpArea']; DCVFs_N = p['DCVFs_N']; DCVSC_Npm = p['DCVSC_Npm']
    DCVleakKv = p['DCVleakKv']
    bore = p['bore']; stroke = p['stroke']; HousingOD = p['HousingOD']
    ChamberLen = p['ChamberLen']; em_housing = p['em_housing']; em_shield = p['em_shield']
    keff = p['keff']; Vacuum_micron = p['Vacuum_micron']; dvf = p['dvf']
    Tamb_K = p['Tamb_K']; htc_amb = p['htc_amb']; Ffric = p['Ffric']
    Kv_BB = p['Kv_BB']; Pbbexit_barg = p['Pbbexit_barg']
    fric2chamber = p['fric2chamber']; K_bulk = p['K_bulk']
    h_in = p['h_in']; h_out = p['h_out']; mc0 = p['mc0']
    Tout_K = p['Tout_K']; fluid = p['fluid']
    FillType = p.get('FillType', 1); Vsnubber = p.get('Vsnubber', 0.001)
    VCHSS = p.get('VCHSS', 0.001)
    kv_AOV140 = p.get('kv_AOV140', 0.0); kv_vent = p.get('kv_vent', 0.0)
    Pexit_fixed = p.get('Pexit_barg', 0.0)
    _cf = p['_cf']; _h1_exit_J = p['_h1_exit_J']
    _rho0_p0_factor = p['_rho0_p0_factor']
    _fluid_Tc = p.get('_fluid_Tc', _H2_TC)
    _fluid_Pc = p.get('_fluid_Pc', _H2_PC_PA)

    # ── Derived geometry ──
    Vdisp = PI / 4.0 * bore**2 * stroke
    tcycle = 60.0 / pump_cpm
    tstroke = tcycle / 2.0
    vm_max = PI * stroke * pump_cpm / 60.0
    _two_pi_inv_tcycle = _TWO_PI / tcycle

    # ── Radiation geometry ──
    bore_mm = bore * 1000.0; HousingOD_mm = HousingOD * 1000.0
    ChamberLen_mm = ChamberLen * 1000.0
    bot = 1.0 / em_housing + (1.0 - em_housing) / em_housing * (bore_mm / HousingOD_mm)
    ds = (bore_mm + HousingOD_mm) / 2.0
    bot += 2.0 * (1.0 - em_shield) / em_shield * (bore_mm / ds)
    pa = Vacuum_micron / 1000.0 * (101325.0 / 760.0)

    _rad_factor = PI * bore_mm * ChamberLen_mm * STEFAN_BOLTZMANN / (2.0 * bot) / 1e6
    _Tamb4 = Tamb_K**4
    _conv_htc_factor = 2.0 * PI / 4.0 * HousingOD_mm**2 * htc_amb / 1e6
    _ChamberLen_m = ChamberLen / 1.0  # already in meters

    # ── Thermal mass (VBA Pack 3) ──
    tmass = 0.0
    if flash_eff > 0.0:
        _theta_max = Tout_K - Tin_K
        _theta_avg = 0.215 * _theta_max
        _tc_w = tc_ss304(Tin_K + _theta_avg)
        _kappa = _tc_w / 7800.0 / 500.0
        _delta = 2.6 * _sqrt(_kappa * tcycle)
        _a_ch = 2.0 * (PI / 4.0 * bore**2) + PI * bore * stroke
        tmass = flash_eff * 7800.0 * 500.0 * _a_ch * _delta * _theta_avg / tstroke / 1000.0

    # Penalty springs — adaptive stiffness to keep penetration < 1% of travel for any fluid
    # Peak water hammer force: WHdp_peak ~ den_in * vm_max * sqrt(K_bulk*1e6/den_in) * ICVdpArea
    vm_max = PI * stroke * pump_cpm / 60.0
    den_est = p.get('den_in', 70.0)  # inlet density estimate
    vwave_est = _sqrt(K_bulk * 1e6 / max(den_est, 1.0))
    WHdp_peak = den_est * vm_max * vwave_est * ICVdpArea
    # k must keep penetration under 1% of valve travel
    # 100x safety factor ensures near-zero penetration + strong critical damping
    k_min = 5e5  # baseline (fine for H2)
    k_from_WHdp = 100.0 * WHdp_peak / (0.01 * min(ICVtravel, DCVtravel))
    k_wall = max(k_min, k_from_WHdp)
    c_DCV = 2.0 * _sqrt(k_wall * DCVmass)
    c_ICV = 2.0 * _sqrt(k_wall * ICVmass)

    # ── Convection cache (mutable) ──
    Tc_init = p['Tc_init']
    _cc = {
        'Qcb': free_conv_2cyl_Wpm(bore, Tc_init, HousingOD, Tamb_K, pa, "air"),
        'Tl': Tc_init,
    }

    # ── EOS state trackers (mutable, for two-pass Newton warm-start) ──
    _eos_chamber = {'Tc': Tc_init, 'pc': Pexit_fixed, 'hc': h_out, 'cv': 10.0}
    _eos_snubber = {'Tc': Tc_init, 'pc': Pexit_fixed, 'hc': h_out, 'cv': 10.0}
    _eos_chss = {'Tc': Tc_init, 'pc': Pexit_fixed, 'hc': h_out, 'cv': 10.0}

    rhs_calls = [0]

    # ── Backend selection: Numba H2 vs CoolProp ──
    use_numba = p.get('_use_numba', False)
    _Pg = p.get('_Pg', None)  # Numba PS LUT arrays
    _Sg = p.get('_Sg', None)
    _d_tbl = p.get('_d_tbl', None)
    _h_tbl = p.get('_h_tbl', None)

    # ── EOS flash: dual-backend (uc, ρ) → (Tc, pc, hc, cv) ──
    def _eos_flash(uc_val, den_val, state):
        Tc_prev = state['Tc']; pc_prev = state['pc']
        hc_prev = state['hc']; cv_prev = state['cv']

        if use_numba:
            # ── NUMBA PATH: direct Helmholtz evaluation ──
            den_safe = max(den_val, 0.1)
            cv_kJkgK = h2_numba.cv_td(max(Tc_prev, 14.0), den_safe)
            if cv_kJkgK <= 0.0:
                cv_kJkgK = cv_prev

            # Consistent: evaluate h,P at (Tc_prev, den_current)
            pc_at_prev, hc_at_prev = h2_numba.state_td(max(Tc_prev, 14.0), den_safe)
            uc_prev_est = hc_at_prev - (pc_at_prev + 1.01325) * 1e5 / den_safe / 1000.0
            dT_est = (uc_val - uc_prev_est) / max(cv_kJkgK, 0.1)
            # Clamp dT to prevent runaway (solver probes can produce extreme states)
            dT_est = max(-200.0, min(200.0, dT_est))
            T_est = max(Tc_prev + dT_est, 14.0)
            T_est = min(T_est, 500.0)

            pc_new, hc_new = h2_numba.state_td(T_est, den_safe)

            uc_check = hc_new - (pc_new + 1.01325) * 1e5 / den_safe / 1000.0
            dT_ref = (uc_val - uc_check) / max(cv_kJkgK, 0.1)
            dT_ref = max(-200.0, min(200.0, dT_ref))
            T_ref = max(T_est + dT_ref, 14.0)
            T_ref = min(T_ref, 500.0)

            pc_new, hc_new = h2_numba.state_td(T_ref, den_safe)
            Tc_new = T_ref
        else:
            # ── COOLPROP PATH: AbstractState + specify_phase ──
            den_safe = max(den_val, 0.1)
            try:
                _specify_phase_td(AS, Tc_prev, den_safe, _fluid_Tc)
                AS.update(_DmassT_INPUTS, den_safe, Tc_prev)
                cv_kJkgK = AS.cvmass() / 1000.0
                # Get h and P at (Tc_prev, den_current) for consistent uc_prev_est
                hc_at_prev = AS.hmass() / 1000.0
                pc_at_prev = AS.p() / 1e5 - 1.01325
                if cv_kJkgK <= 0.0:
                    cv_kJkgK = cv_prev
            except Exception:
                cv_kJkgK = cv_prev
                hc_at_prev = hc_prev
                pc_at_prev = pc_prev
            finally:
                AS.unspecify_phase()

            uc_prev_est = hc_at_prev - (pc_at_prev + 1.01325) * 1e5 / den_safe / 1000.0
            dT_est = (uc_val - uc_prev_est) / max(cv_kJkgK, 0.1)
            T_est = max(Tc_prev + dT_est, 14.0)

            try:
                _specify_phase_td(AS, T_est, den_val, _fluid_Tc)
                AS.update(_DmassT_INPUTS, den_val, T_est)
                pc_new = AS.p() / 1e5 - 1.01325
                hc_new = AS.hmass() / 1000.0

                uc_check = hc_new - (pc_new + 1.01325) * 1e5 / max(den_val, 0.1) / 1000.0
                dT_ref = (uc_val - uc_check) / max(cv_kJkgK, 0.1)
                T_ref = max(T_est + dT_ref, 14.0)

                _specify_phase_td(AS, T_ref, den_val, _fluid_Tc)
                AS.update(_DmassT_INPUTS, den_val, T_ref)
                pc_new = AS.p() / 1e5 - 1.01325
                hc_new = AS.hmass() / 1000.0
                Tc_new = T_ref
            except Exception:
                pc_new = pc_prev; hc_new = hc_prev; Tc_new = Tc_prev
            finally:
                AS.unspecify_phase()

        state['Tc'] = Tc_new; state['pc'] = pc_new
        state['hc'] = hc_new; state['cv'] = cv_kJkgK
        return Tc_new, pc_new, hc_new, cv_kJkgK

    # ── Flow functions: dual-backend ──
    def _flow_core(p1_barg, p2_barg, h1_J, Kv, d2hat, h2hat_kJ):
        if _fabs(p1_barg - p2_barg) < 0.0001:
            return 0.0
        sg = -1.0 if p2_barg > p1_barg else 1.0
        dh = h1_J - h2hat_kJ * 1000.0
        if dh < 0.0:
            dh = 0.0
        return sg * Kv * d2hat * _rho0_p0_factor * _sqrt(dh) / 60.0

    def _cached_flow_1d(p1_barg, p2_barg, h1_J, Kv):
        if _fabs(p1_barg - p2_barg) < 0.0001:
            return 0.0
        Phigha = max(p1_barg, p2_barg) + 1.01325
        Plowa = min(p1_barg, p2_barg) + 1.01325
        p2hat_MPa = max(Plowa / 10.0, Phigha * _cf / 10.0)
        d2hat = float(np.interp(p2hat_MPa, dcv1d_P, dcv1d_d))
        h2hat_kJ = float(np.interp(p2hat_MPa, dcv1d_P, dcv1d_h))
        return _flow_core(p1_barg, p2_barg, h1_J, Kv, d2hat, h2hat_kJ)

    def _flow_calc(p1_barg, p2_barg, T_upstream_K, GasKv):
        """Flow calculation — dispatches to Numba or CoolProp."""
        if _fabs(p1_barg - p2_barg) < 0.0001:
            return 0.0
        if use_numba:
            # Numba path: single call does PT + PS + flow formula
            return h2_numba.flow_calc(p1_barg, p2_barg, T_upstream_K, GasKv,
                                       _cf, _Pg, _Sg, _d_tbl, _h_tbl) / 60.0
        else:
            # CoolProp path: AbstractState PT + PS flash
            return _flow_AS(p1_barg, p2_barg, T_upstream_K - 273.15, GasKv) / 60.0

    def _flow_AS(p1_barg, p2_barg, Tupstream_C, GasKv):
        """Flow via CoolProp AbstractState with specify_phase (N2 / fallback)."""
        if _fabs(p1_barg - p2_barg) < 0.0001:
            return 0.0
        sg = 1.0
        Phigh, Plow = p1_barg, p2_barg
        if p2_barg > p1_barg:
            Phigh, Plow = p2_barg, p1_barg
            sg = -1.0
        Phigha = Phigh + 1.01325; Plowa = Plow + 1.01325
        T_K = Tupstream_C + 273.15; P_Pa = Phigha * 1e5
        pc_bara = Phigha * _cf; p2hat_bara = max(Plowa, pc_bara)
        P2_Pa = p2hat_bara * 1e5

        _specify_phase_safe(AS, P_Pa, T_K, _fluid_Tc, _fluid_Pc)
        try:
            AS.update(_PT_INPUTS, P_Pa, T_K)
        except Exception:
            try:
                AS.update(_PT_INPUTS, P_Pa, T_K - 0.05)
            except Exception:
                AS.unspecify_phase()
                return 0.0
        h1_J = AS.hmass(); s1 = AS.smass()

        _specify_phase_safe(AS, P2_Pa, T_K, _fluid_Tc, _fluid_Pc)
        try:
            AS.update(_PSmass_INPUTS, P2_Pa, s1)
            d2hat = AS.rhomass(); h2hat_J = AS.hmass()
        except Exception:
            d2hat = 1.0; h2hat_J = h1_J + (p2hat_bara - Phigha) * 1e5 / max(d2hat, 1.0)
        finally:
            AS.unspecify_phase()

        dh = max(h1_J - h2hat_J, 0.0)
        return sg * GasKv * d2hat * _rho0_p0_factor * _sqrt(dh) / 60.0

    def _flow_AS_downstream_only(p1_barg, p2_barg, h1_J_cached, s1_cached, GasKv, T_upstream_K=300.0):
        """Flow using pre-computed upstream PT flash (CoolProp flash caching).

        Phase specification strategy for PS flash:
        - P > Pc: use iphase_supercritical_gas (works for CoolProp PS flash
          regardless of temperature — tells solver to skip saturation search)
        - P <= Pc and T < Tc: use iphase_liquid to avoid two-phase region
        - P <= Pc and T >= Tc: unspecify (gas phase, no saturation issue)
        """
        if _fabs(p1_barg - p2_barg) < 0.0001:
            return 0.0
        sg = 1.0
        Phigh, Plow = p1_barg, p2_barg
        if p2_barg > p1_barg:
            Phigh, Plow = p2_barg, p1_barg
            sg = -1.0
        Phigha = Phigh + 1.01325; Plowa = Plow + 1.01325
        pc_bara = Phigha * _cf; p2hat_bara = max(Plowa, pc_bara)
        P2_Pa = p2hat_bara * 1e5

        if P2_Pa > _fluid_Pc:
            # Supercritical pressure: iphase_supercritical_gas works for PS flash
            # even at T < Tc (tells CoolProp to skip saturation boundary search)
            AS.specify_phase(_PHASE_SUPERCRITICAL_GAS)
        elif T_upstream_K < _fluid_Tc:
            # Subcritical P, subcritical T: liquid phase
            try:
                AS.specify_phase(_PHASE_LIQUID)
            except Exception:
                AS.unspecify_phase()
        else:
            AS.unspecify_phase()
        try:
            AS.update(_PSmass_INPUTS, P2_Pa, s1_cached)
            d2hat = AS.rhomass(); h2hat_J = AS.hmass()
        except Exception:
            AS.unspecify_phase()
            return 0.0
        finally:
            AS.unspecify_phase()

        dh = max(h1_J_cached - h2hat_J, 0.0)
        return sg * GasKv * d2hat * _rho0_p0_factor * _sqrt(dh) / 60.0

    # ── The RHS function ──
    def rhs(t, y):
        rhs_calls[0] += 1
        mc = max(y[0], mc0 * 1e-6)
        uc = y[1]
        vp = y[2]; xp = y[3]; vip = y[4]; xip = y[5]
        if has_ds:
            msb = max(y[6], 1e-12); usb = y[7]
            mchss = max(y[8], 1e-12); uchss = y[9]

        # ── Kinematics (multi-cycle) ──
        t_mod = t % tcycle
        retract = (t_mod <= tstroke)
        phase_a = _two_pi_inv_tcycle * t
        yp_pos = 0.5 * (1.0 - _cos(phase_a))
        v_pist = vm_max * _sin(phase_a)
        vc = Vdisp * (dvf + yp_pos)
        den = mc / vc
        dvc_dt = Vdisp * PI / tcycle * _sin(phase_a)

        # ── Chamber EOS (two-pass Newton, NOT DmassUmass) ──
        Tc_K, pc, hc, cv_val = _eos_flash(uc, den, _eos_chamber)

        # ── Downstream EOS ──
        if has_ds:
            dsb = msb / Vsnubber
            Tsb, psb, hsb, _ = _eos_flash(usb, dsb, _eos_snubber)
            Pexit = psb
            pchss = 0.0
            if FillType == 1:
                dchss = mchss / VCHSS
                _, pchss, _, _ = _eos_flash(uchss, dchss, _eos_chss)
        else:
            Pexit = Pexit_fixed
            psb = Pexit; Tsb = Tc_K; hsb = hc

        # ── DCV flow (1D table for forward, AbstractState for reverse) ──
        xp_c = max(0.0, min(DCVtravel, xp))
        x_fr = xp_c / DCVtravel
        kv_d = DCVleakKv + kv_from_Cd_and_RO_dia(DCVport_mm * (1.0 - x_fr))

        if Pexit > pc:
            # Forward flow: DCV upstream is exit/snubber. Use 1D table.
            mdot_DCV = _cached_flow_1d(Pexit, pc, _h1_exit_J, kv_d) / 60.0
        else:
            # Reverse flow: chamber is upstream
            mdot_DCV = _flow_calc(Pexit, pc, Tc_K, kv_d)

        h_DCV = (hsb if has_ds and mdot_DCV > 0 else (h_out if mdot_DCV > 0 else hc))

        # ── ICV + blowby flow with flash caching ──
        xip_c = max(0.0, min(ICVtravel, xip))
        xi_fr = xip_c / ICVtravel
        kv_i = ICVleakKv + kv_from_Cd_and_RO_dia(ICVport_mm * xi_fr)

        icv_up_is_chamber = (pc > Ptank_barg)
        bb_up_is_chamber = (pc > Pbbexit_barg)
        Tu_icv_K = Tc_K if icv_up_is_chamber else Tin_K

        if use_numba:
            # ── NUMBA PATH: _flow_calc handles /60 (kg/min → kg/s) ──
            mdot_ICV = _flow_calc(Ptank_barg, pc, Tu_icv_K, kv_i)
            mdot_BB = _flow_calc(Pbbexit_barg, pc, Tc_K, Kv_BB)
        elif icv_up_is_chamber and bb_up_is_chamber:
            # ── COOLPROP PATH: shared upstream flash caching ──
            Phigha = pc + 1.01325
            P_Pa_up = Phigha * 1e5
            _specify_phase_safe(AS, P_Pa_up, Tc_K, _fluid_Tc, _fluid_Pc)
            try:
                AS.update(_PT_INPUTS, P_Pa_up, Tc_K)
                _cached_h1 = AS.hmass()
                _cached_s1 = AS.smass()
                mdot_ICV = _flow_AS_downstream_only(Ptank_barg, pc, _cached_h1, _cached_s1, kv_i, Tc_K) / 60.0
                mdot_BB = _flow_AS_downstream_only(Pbbexit_barg, pc, _cached_h1, _cached_s1, Kv_BB, Tc_K) / 60.0
            except Exception:
                AS.unspecify_phase()
                mdot_ICV = _flow_AS(Ptank_barg, pc, Tu_icv_K - 273.15, kv_i) / 60.0
                mdot_BB = _flow_AS(Pbbexit_barg, pc, Tc_K - 273.15, Kv_BB) / 60.0
        else:
            # ── COOLPROP PATH: separate flow calcs ──
            mdot_ICV = _flow_AS(Ptank_barg, pc, Tu_icv_K - 273.15, kv_i) / 60.0
            mdot_BB = _flow_AS(Pbbexit_barg, pc, Tc_K - 273.15, Kv_BB) / 60.0

        h_ICV = h_in if mdot_ICV > 0 else hc
        h_bb = hc  # blowby always carries chamber enthalpy out

        # ── Heat terms ──
        Qrad = _rad_factor * (_Tamb4 - Tc_K**4)
        if _fabs(Tc_K - _cc['Tl']) > 2.0:
            _cc['Qcb'] = free_conv_2cyl_Wpm(bore, Tc_K, HousingOD, Tamb_K, pa, "air")
            _cc['Tl'] = Tc_K
        Qconv = -_cc['Qcb'] * _ChamberLen_m + _conv_htc_factor * (Tamb_K - Tc_K)
        Qig = (Qrad + Qconv) / 1000.0  # kW
        Qf = Ffric * _fabs(v_pist) * fric2chamber / 1000.0  # kW
        work = -pc * dvc_dt * 100.0  # kW
        Qtmass = (1.0 if retract else -1.0) * tmass  # kW

        # ── ODE #1-2: chamber mass and energy ──
        dmc = mdot_ICV + mdot_DCV + mdot_BB
        e_in = (mdot_ICV * h_ICV + mdot_DCV * h_DCV + mdot_BB * h_bb
                + Qig + Qf + work + Qtmass)
        duc = (e_in - uc * dmc) / mc if corrected else e_in

        # ── ODE #3-4: DCV dynamics + penalty walls ──
        Fs_d = DCVFs_N - DCVSC_Npm * xp_c
        Fdp_d = (Pexit - pc) * 1e5 * DCVdpArea
        Fw_d = 0.0
        if xp < 0.0:
            Fw_d = -k_wall * xp - c_DCV * vp
        elif xp > DCVtravel:
            Fw_d = -k_wall * (xp - DCVtravel) - c_DCV * vp
        dvp = (Fs_d + Fdp_d + Fw_d) / DCVmass

        # ── ODE #5-6: ICV dynamics + penalty walls ──
        Fs_i = ICVFs_N - ICVSC_Npm * (ICVtravel - xip_c)
        Fdp_i = (Ptank_barg - pc) * 1e5 * ICVdpArea
        vwave = _sqrt(K_bulk * 1e6 / max(den, 1e-6))
        WHdp = (1.0 if retract else -1.0) * den * v_pist * vwave * ICVdpArea
        Fw_i = 0.0
        if xip < 0.0:
            Fw_i = -k_wall * xip - c_ICV * vip
        elif xip > ICVtravel:
            Fw_i = -k_wall * (xip - ICVtravel) - c_ICV * vip
        dvip = (Fdp_i - Fs_i + WHdp + Fw_i) / ICVmass

        if not has_ds:
            return [dmc, duc, dvp, vp, dvip, vip]

        # ── ODE #7-8: Snubber mass + energy ──
        if FillType == 1:
            mdot_sb = _flow_calc(psb, pchss, Tsb, kv_AOV140)
        else:
            mdot_sb = _flow_calc(psb, 0.0, Tsb, kv_vent)

        dmsb = -mdot_DCV - mdot_sb
        # Corrected specific energy form: du = (sum(h*mdot) - u*dm) / m
        h_into_sb = hc if mdot_DCV < 0 else h_DCV  # DCV discharge enters snubber
        e_in_sb = -mdot_DCV * h_into_sb - mdot_sb * hsb
        dusb = (e_in_sb - usb * dmsb) / msb if corrected else e_in_sb

        # ── ODE #9-10: CHSS ──
        dmchss = mdot_sb if FillType == 1 else 0.0
        duchss_raw = mdot_sb * hsb if FillType == 1 else 0.0
        duchss = (duchss_raw - uchss * dmchss) / mchss if (corrected and FillType == 1) else duchss_raw

        return [dmc, duc, dvp, vp, dvip, vip, dmsb, dusb, dmchss, duchss]

    return rhs, rhs_calls


# ============================================================================
# ODE Driver
# ============================================================================
def ODE_driver_v2(Pexit_barg, speed_f, Ptank_barg, Psat_barg,
                  ICVparam, DCVparam, pump_geom, proc_param,
                  fluid="h2", prtMode=0, flash_eff=0.02,
                  exit_param=None, n_cycles=1,
                  use_corrected=True, method='RK45', rtol=1e-6, atol=1e-8):
    """10-variable ODE pump cycle with v2's performance architecture.

    Returns (out, history) matching ICV_open() format.
    """
    t_start = time.time()
    has_ds = exit_param is not None

    # ── Create AbstractState (reused for ALL CoolProp calls) ──
    fluid_cp = coolprop_fluid_name(fluid)
    AS = CoolProp.AbstractState('HEOS', fluid_cp)
    _fluid_Tc = AS.T_critical()   # K (H2: 33.145, N2: 126.21)
    _fluid_Pc = AS.p_critical()   # Pa (H2: 1.2964e6, N2: 3.396e6)

    # ── Unpack input arrays ──
    ICVport_mm, ICVmass_g, ICVtravel_mm = ICVparam[0], ICVparam[1], ICVparam[2]
    ICVdpArea_mm2, ICVFs_N, ICVSC_Npmm = ICVparam[3], ICVparam[4], ICVparam[5]
    ICVleakKv, ICVcomp_eff = ICVparam[6], ICVparam[7]

    DCVport_mm, DCVmass_g, DCVtravel_mm = DCVparam[0], DCVparam[1], DCVparam[2]
    DCVdpArea_mm2, DCVFs_N, DCVSC_Npmm = DCVparam[3], DCVparam[4], DCVparam[5]
    DCVleakKv, DCVcomp_eff = DCVparam[6], DCVparam[7]

    bore_mm, stroke_mm = pump_geom[0], pump_geom[1]
    HousingOD_mm, ChamberLen_mm = pump_geom[2], pump_geom[3]
    em_housing, em_shield = pump_geom[4], pump_geom[5]
    kvoid, khousing, Vfvoid = pump_geom[6], pump_geom[7], pump_geom[8]
    Vacuum_micron, design_cpm, dvf = pump_geom[9], pump_geom[10], pump_geom[11]

    Tamb_K, htc_amb = proc_param[0], proc_param[1]
    NetDriveCouplerForce_kgf, F_multiplier = proc_param[2], proc_param[3]
    Kv_BB, Pbbexit_barg = proc_param[4], proc_param[5]
    fric2chamber, Exp_eff = proc_param[6], proc_param[7]

    # ── Geometry ──
    stroke = stroke_mm / 1000.0; bore = bore_mm / 1000.0
    Vdisp = PI / 4.0 * bore**2 * stroke; V_dead = dvf * Vdisp
    pump_cpm = design_cpm * speed_f
    tcycle = 60.0 / pump_cpm; tstroke = tcycle / 2.0

    # ── Thermodynamics (CoolProp one-time, pre-loop) ──
    PsMPa = Psat_barg / 10.0 + 0.101325
    PtMPa = Ptank_barg / 10.0 + 0.101325
    PeMPa = Pexit_barg / 10.0 + 0.101325

    Tin_K = refprop("t", fluid, "pq", "si", PsMPa, 0.0)
    den_in = refprop("d", fluid, "pt", "si", PtMPa, Tin_K)
    h_in = refprop("h", fluid, "pt", "si", PtMPa, Tin_K)
    den_out = mixture_pump_prop("d", Ptank_barg, Pexit_barg, 0.0, DCVcomp_eff, fluid, Tin_K)
    h_out = mixture_pump_prop("h", Ptank_barg, Pexit_barg, 0.0, DCVcomp_eff, fluid, Tin_K)
    Tout_K = mixture_pump_prop("t", Ptank_barg, Pexit_barg, 0.0, DCVcomp_eff, fluid, Tin_K)

    _props = _get_cached_fluid_props(fluid)
    _cf = _props["cf"]
    _s_exit = refprop("s", fluid, "pt", "si", PeMPa, Tout_K)
    _h1_exit_J = refprop("h", fluid, "pt", "si", PeMPa, Tout_K) * 1000.0
    _rho0_p0_factor = _sqrt(1000.0 / 1e5 / 1.0)

    # ── Build 1D DCV table (zero CoolProp in hot path) ──
    dcv1d_P, dcv1d_h, dcv1d_d = _build_ps_table_1d_cached(
        s_fixed=_s_exit, P_range_MPa=(0.101325, PeMPa + 1.0),
        fluid=fluid, n=2000)

    # ── Valve physical params (SI) ──
    DCVmass = DCVmass_g / 1000.0; DCVSC_Npm = DCVSC_Npmm * 1000.0
    DCVtravel = DCVtravel_mm / 1000.0; DCVdpArea = DCVdpArea_mm2 / 1e6
    ICVmass = ICVmass_g / 1000.0; ICVSC_Npm = ICVSC_Npmm * 1000.0
    ICVtravel = ICVtravel_mm / 1000.0; ICVdpArea = ICVdpArea_mm2 / 1e6
    K_bulk = refprop("bs", fluid, "pt", "si", PtMPa, Tin_K)

    keff = composite_thermal_conductivity(1, kvoid, Vfvoid, khousing)
    Ffric = NetDriveCouplerForce_kgf * F_multiplier * 9.80665

    # ── Chamber initial conditions ──
    mc0 = V_dead * den_out
    pc = Pexit_barg; Tc_K = Tout_K
    uc = h_out - (pc + 1.01325) * 1e5 / den_out / 1000.0

    # ── Downstream volumes ──
    FillType = 1; Vsnubber = 0.001; VCHSS = 0.001
    kv_AOV140 = 0.0; kv_vent = 0.0
    msb = 0.0; usb = 0.0; mchss = 0.0; uchss = 0.0
    if has_ds:
        FillType = int(exit_param[0])
        Vsnubber = max(exit_param[1] / 1000.0, 0.001)
        VCHSS = max(exit_param[2] / 1000.0, 0.001)
        AOV140f = exit_param[3]; ROdia_mm = exit_param[4]
        Kv_RO = kv_from_Cd_and_RO_dia(ROdia_mm)
        kv_AOV140 = Kv_from_Cv(0.25) * AOV140f
        kv_vent = 1.0 / (1.0 / max(kv_AOV140, 1e-12) + 1.0 / max(Kv_RO, 1e-12))
        msb = Vsnubber * den_out; usb = uc
        mchss = VCHSS * den_out; uchss = uc

    # ── Numba backend init (H2 only) ──
    _use_numba = False
    _Pg = _Sg = _d_tbl = _h_tbl = None
    if _HAS_NUMBA_H2 and fluid.lower() in ('h2', 'hydrogen'):
        ps_lut_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'h2_ps_lut.npz')
        if os.path.exists(ps_lut_path):
            _Pg, _Sg, _d_tbl, _h_tbl = h2_numba.init(ps_lut_path)
            _use_numba = True
            if prtMode:
                print("  Phase 2: Numba Helmholtz EOS active (H2)")
    if not _use_numba and prtMode:
        print(f"  Phase 1: CoolProp AbstractState ({fluid})")

    # ── Pack params for RHS factory ──
    params = dict(
        pump_cpm=pump_cpm, Ptank_barg=Ptank_barg, Tin_K=Tin_K,
        flash_eff=flash_eff, has_downstream=has_ds,
        FillType=FillType, Vsnubber=Vsnubber, VCHSS=VCHSS,
        kv_AOV140=kv_AOV140, kv_vent=kv_vent, K_bulk=K_bulk,
        ICVport_mm=ICVport_mm, ICVmass=ICVmass, ICVtravel=ICVtravel,
        ICVdpArea=ICVdpArea, ICVFs_N=ICVFs_N, ICVSC_Npm=ICVSC_Npm, ICVleakKv=ICVleakKv,
        DCVport_mm=DCVport_mm, DCVmass=DCVmass, DCVtravel=DCVtravel,
        DCVdpArea=DCVdpArea, DCVFs_N=DCVFs_N, DCVSC_Npm=DCVSC_Npm, DCVleakKv=DCVleakKv,
        bore=bore, stroke=stroke, HousingOD=HousingOD_mm / 1000.0,
        ChamberLen=ChamberLen_mm / 1000.0, em_housing=em_housing, em_shield=em_shield,
        keff=keff, Vacuum_micron=Vacuum_micron, dvf=dvf,
        Tamb_K=Tamb_K, htc_amb=htc_amb, Ffric=Ffric,
        Kv_BB=Kv_BB, Pbbexit_barg=Pbbexit_barg,
        fric2chamber=fric2chamber, Exp_eff=Exp_eff,
        h_in=h_in, den_in=den_in, h_out=h_out,
        Tout_K=Tout_K, Tc_init=Tc_K, mc0=mc0,
        Pexit_barg=Pexit_barg, fluid=fluid,
        _cf=_cf, _h1_exit_J=_h1_exit_J, _rho0_p0_factor=_rho0_p0_factor,
        _use_numba=_use_numba, _Pg=_Pg, _Sg=_Sg, _d_tbl=_d_tbl, _h_tbl=_h_tbl,
        _fluid_Tc=_fluid_Tc, _fluid_Pc=_fluid_Pc,
    )

    rhs, rhs_calls = _make_rhs_v2(params, AS, dcv1d_P, dcv1d_h, dcv1d_d,
                                   corrected=use_corrected)

    # ── Initial state ──
    y0 = ([mc0, uc, 0.0, 0.0, 0.0, 0.0, msb, usb, mchss, uchss] if has_ds
           else [mc0, uc, 0.0, 0.0, 0.0, 0.0])
    y0 = np.array(y0)

    t_pre = time.time()

    sol = solve_ivp(rhs, [0.0, n_cycles * tcycle], y0, method=method,
                    rtol=rtol, atol=atol, dense_output=True,
                    max_step=tcycle / 200, first_step=tcycle / 10000)

    t_solve = time.time() - t_pre

    if sol.status != 0:
        print(f"WARNING: solve_ivp status={sol.status}: {sol.message}")

    # ============================== Post-processing ==============================
    t_arr = sol.t; n_pts = len(t_arr)
    mc_arr, uc_arr = sol.y[0], sol.y[1]
    xp_arr, xip_arr = sol.y[3], sol.y[5]

    angle_arr = t_arr * 360.0 / tcycle
    yp_arr = 0.5 * (1.0 - np.cos(_TWO_PI * t_arr / tcycle))
    vc_arr = Vdisp * (dvf + yp_arr); den_arr = mc_arr / vc_arr

    # Reconstruct (pc, Tc, hc) via two-pass Newton at each point
    pc_arr = np.zeros(n_pts); Tc_arr = np.zeros(n_pts); hc_arr = np.zeros(n_pts)
    dcv_of = np.zeros(n_pts); icv_of = np.zeros(n_pts)

    if _use_numba:
        # NUMBA POST-PROCESSING: direct Helmholtz state evaluation
        _eos_Tc_prev = Tout_K
        for i in range(n_pts):
            den_i = max(den_arr[i], 0.1)
            cv_i = h2_numba.cv_td(max(_eos_Tc_prev, 14.0), den_i)
            if cv_i <= 0.0: cv_i = 10.0
            _eos_pc_prev = pc_arr[max(i-1, 0)] if i > 0 else Pexit_barg
            _eos_hc_prev = hc_arr[max(i-1, 0)] if i > 0 else h_out
            uc_prev_est = _eos_hc_prev - (_eos_pc_prev + 1.01325) * 1e5 / den_i / 1000.0
            dT = min(200.0, max(-200.0, (uc_arr[i] - uc_prev_est) / max(cv_i, 0.1)))
            T_est = min(500.0, max(14.0, _eos_Tc_prev + dT))
            pc_1, hc_1 = h2_numba.state_td(T_est, den_i)
            uc_chk = hc_1 - (pc_1 + 1.01325) * 1e5 / den_i / 1000.0
            dT2 = min(200.0, max(-200.0, (uc_arr[i] - uc_chk) / max(cv_i, 0.1)))
            T_ref = min(500.0, max(14.0, T_est + dT2))
            pc_arr[i], hc_arr[i] = h2_numba.state_td(T_ref, den_i)
            Tc_arr[i] = T_ref
            _eos_Tc_prev = T_ref
            dcv_of[i] = 1.0 - max(0.0, min(1.0, xp_arr[i] / DCVtravel))
            icv_of[i] = max(0.0, min(1.0, xip_arr[i] / ICVtravel))
    else:
        _eos_post = {'Tc': Tout_K, 'pc': Pexit_barg, 'hc': h_out, 'cv': 10.0}
        for i in range(n_pts):
            Tc_i, pc_i, hc_i, _ = _eos_flash_post(AS, uc_arr[i], den_arr[i], _eos_post, _fluid_Tc)
            pc_arr[i] = pc_i; Tc_arr[i] = Tc_i; hc_arr[i] = hc_i
            dcv_of[i] = 1.0 - max(0.0, min(1.0, xp_arr[i] / DCVtravel))
            icv_of[i] = max(0.0, min(1.0, xip_arr[i] / ICVtravel))

    # Flow rates for output
    dcv_leak = np.zeros(n_pts); icv_leak = np.zeros(n_pts)
    if _use_numba:
        # NUMBA POST-PROCESSING: use flow_calc instead of flow_RF_kgpm
        for i in range(n_pts):
            kv_d = DCVleakKv + kv_from_Cd_and_RO_dia(DCVport_mm * dcv_of[i])
            p_ex = Pexit_barg
            if p_ex > pc_arr[i]:
                dcv_leak[i] = _cached_flow_1d_post(p_ex, pc_arr[i], _h1_exit_J,
                                                     kv_d, _cf, _rho0_p0_factor,
                                                     dcv1d_P, dcv1d_h, dcv1d_d) * 60.0
            else:
                dcv_leak[i] = h2_numba.flow_calc(p_ex, pc_arr[i], max(Tc_arr[i], 14.0),
                                                   kv_d, _cf, _Pg, _Sg, _d_tbl, _h_tbl)
            kv_i = ICVleakKv + kv_from_Cd_and_RO_dia(ICVport_mm * icv_of[i])
            Tu_i = Tc_arr[i] if pc_arr[i] > Ptank_barg else Tin_K
            icv_leak[i] = h2_numba.flow_calc(Ptank_barg, pc_arr[i], max(Tu_i, 14.0),
                                               kv_i, _cf, _Pg, _Sg, _d_tbl, _h_tbl)
    else:
        for i in range(n_pts):
            kv_d = DCVleakKv + kv_from_Cd_and_RO_dia(DCVport_mm * dcv_of[i])
            p_ex = Pexit_barg
            if has_ds and sol.y.shape[0] > 6:
                try:
                    _eos_sb = {'Tc': Tout_K, 'pc': Pexit_barg, 'hc': h_out, 'cv': 10.0}
                    _, p_ex, _, _ = _eos_flash_post(AS, sol.y[7, i], sol.y[6, i] / Vsnubber, _eos_sb, _fluid_Tc)
                except Exception:
                    pass
            if p_ex > pc_arr[i]:
                dcv_leak[i] = _cached_flow_1d_post(p_ex, pc_arr[i], _h1_exit_J,
                                                     kv_d, _cf, _rho0_p0_factor,
                                                     dcv1d_P, dcv1d_h, dcv1d_d) * 60.0
            else:
                dcv_leak[i] = flow_RF_kgpm(p_ex, pc_arr[i], Tc_arr[i] - 273.15, kv_d, fluid)
            kv_i = ICVleakKv + kv_from_Cd_and_RO_dia(ICVport_mm * icv_of[i])
            Tu_i = (Tc_arr[i] if pc_arr[i] > Ptank_barg else Tin_K) - 273.15
            icv_leak[i] = flow_RF_kgpm(Ptank_barg, pc_arr[i], Tu_i, kv_i, fluid)

    # Mass discharged
    dt_arr = np.diff(t_arr, prepend=0.0)
    dt_arr[0] = dt_arr[1] if len(dt_arr) > 1 else 1e-6
    m_out = -np.sum(np.minimum(dcv_leak / 60.0, 0.0) * dt_arr)
    m_in = np.sum(np.maximum(icv_leak / 60.0, 0.0) * dt_arr)
    mdot_out = m_out / (n_cycles * tcycle) * 60.0
    mass_eff = m_in / (Vdisp * den_in); m_out_eff = m_out / (Vdisp * den_in)

    # pV work
    dvc = np.diff(vc_arr); pc_mid = 0.5 * (pc_arr[:-1] + pc_arr[1:])
    work_inc = -pc_mid * dvc * 100.0
    ret_mask = (t_arr[:-1] % tcycle) <= tstroke
    kWh_ret = np.sum(work_inc[ret_mask]); kWh_ext = np.sum(work_inc[~ret_mask])
    kWh_ret_out = kWh_ret / 3600.0 / m_out if m_out > 0 else 0.0
    kWh_ext_out = kWh_ext / 3600.0 / m_out if m_out > 0 else 0.0

    ICVmax_frac = float(np.max(icv_of)); pc_peak = float(np.max(pc_arr))
    t_wall = time.time()

    # Output array
    out = np.zeros((15, 2), dtype=object)
    labels = ["s, cpu time", ", mass inflow efficiency", "s, DCV closure time",
              ", stroke fraction ICV starts to open", ", max ICV open fraction",
              "N, max closure force on DCV", "N, max open force on ICV",
              "N, max closure force on ICV", "m/s, max ICV opening velocity",
              "kg, total discharged mass per cycle", ", mass efficiency of cycle",
              ", stroke fraction DCV starts to open", "s, ICV closure time after extend start",
              "kWh/kg, retract pV work", "kWh/kg, extend pV work"]
    vals = [t_wall - t_start, mass_eff, 0.0, 0.0, ICVmax_frac, 0.0, 0.0, 0.0, 0.0,
            m_out, m_out_eff, 0.0, 0.0, kWh_ret_out, kWh_ext_out]
    for i in range(15):
        out[i, 0] = vals[i]; out[i, 1] = labels[i]

    history = {
        'angle_deg': angle_arr, 'pc': pc_arr, 'den': den_arr, 'yp': yp_arr,
        'mc_g': mc_arr * 1000.0, 'Tc_K': Tc_arr, 'hc': hc_arr,
        'DCV_open_frac': dcv_of, 'DCV_leak_kgpm': dcv_leak,
        'ICV_open_frac': icv_of, 'ICV_leak_kgpm': icv_leak,
        'mdot_kgpm': mdot_out, 'tcycle_s': tcycle, 'tstroke_s': tstroke,
        'steps': n_pts, 'kWh_retract': kWh_ret_out, 'kWh_extend': kWh_ext_out,
        'rhs_calls': rhs_calls[0], 'nfev': sol.nfev,
        'njev': getattr(sol, 'njev', 0), 'nlu': getattr(sol, 'nlu', 0),
        'pc_peak': pc_peak, 'n_cycles': n_cycles,
        'method': method, 'use_corrected': use_corrected,
        'solve_time': t_solve, 'pre_time': t_pre - t_start,
    }

    # Downstream histories
    if has_ds:
        _eos_sb_post = {'Tc': Tout_K, 'pc': Pexit_barg, 'hc': h_out, 'cv': 10.0}
        msb_a = sol.y[6]; psb_a = np.zeros(n_pts); Tsb_a = np.zeros(n_pts)
        for i in range(n_pts):
            try:
                Tsb_a[i], psb_a[i], _, _ = _eos_flash_post(AS, sol.y[7, i], msb_a[i] / Vsnubber, _eos_sb_post, _fluid_Tc)
            except Exception:
                psb_a[i] = psb_a[max(i-1, 0)]; Tsb_a[i] = Tsb_a[max(i-1, 0)]
        history['psnub'] = psb_a; history['Tsnub'] = Tsb_a
        if FillType == 1:
            _eos_ch_post = {'Tc': Tout_K, 'pc': Pexit_barg, 'hc': h_out, 'cv': 10.0}
            mc_a = sol.y[8]; pc_a = np.zeros(n_pts)
            for i in range(n_pts):
                try:
                    _, pc_a[i], _, _ = _eos_flash_post(AS, sol.y[9, i], mc_a[i] / VCHSS, _eos_ch_post, _fluid_Tc)
                except Exception:
                    pc_a[i] = pc_a[max(i-1, 0)]
            history['pchss'] = pc_a; history['mchss'] = mc_a

    return out, history


# ============================================================================
# Post-processing EOS flash (standalone, not inside RHS closure)
# ============================================================================
def _eos_flash_post(AS, uc_val, den_val, state, fluid_Tc=_H2_TC):
    """Two-pass Newton EOS flash for post-processing."""
    Tc_prev = state['Tc']; pc_prev = state['pc']
    hc_prev = state['hc']; cv_prev = state['cv']
    den_safe = max(den_val, 0.1)

    try:
        _specify_phase_td(AS, Tc_prev, den_safe, fluid_Tc)
        AS.update(_DmassT_INPUTS, den_safe, Tc_prev)
        cv_kJkgK = AS.cvmass() / 1000.0
        if cv_kJkgK <= 0.0:
            cv_kJkgK = cv_prev
    except Exception:
        cv_kJkgK = cv_prev
    finally:
        AS.unspecify_phase()

    uc_prev_est = hc_prev - (pc_prev + 1.01325) * 1e5 / den_safe / 1000.0
    dT_est = (uc_val - uc_prev_est) / max(cv_kJkgK, 0.1)
    T_est = max(Tc_prev + dT_est, 14.0)

    try:
        _specify_phase_td(AS, T_est, den_safe, fluid_Tc)
        AS.update(_DmassT_INPUTS, den_safe, T_est)
        pc_new = AS.p() / 1e5 - 1.01325
        hc_new = AS.hmass() / 1000.0

        uc_check = hc_new - (pc_new + 1.01325) * 1e5 / den_safe / 1000.0
        dT_ref = (uc_val - uc_check) / max(cv_kJkgK, 0.1)
        T_ref = max(T_est + dT_ref, 14.0)

        _specify_phase_td(AS, T_ref, den_safe, fluid_Tc)
        AS.update(_DmassT_INPUTS, den_safe, T_ref)
        pc_new = AS.p() / 1e5 - 1.01325
        hc_new = AS.hmass() / 1000.0
        Tc_new = T_ref
    except Exception:
        pc_new = pc_prev; hc_new = hc_prev; Tc_new = Tc_prev
    finally:
        AS.unspecify_phase()

    state['Tc'] = Tc_new; state['pc'] = pc_new
    state['hc'] = hc_new; state['cv'] = cv_kJkgK
    return Tc_new, pc_new, hc_new, cv_kJkgK


def _cached_flow_1d_post(p1_barg, p2_barg, h1_J, Kv, cf, rho0_p0_factor,
                          P_grid, h_tbl, d_tbl):
    """1D table flow for post-processing (standalone)."""
    if _fabs(p1_barg - p2_barg) < 0.0001:
        return 0.0
    sg = -1.0 if p2_barg > p1_barg else 1.0
    Phigha = max(p1_barg, p2_barg) + 1.01325
    Plowa = min(p1_barg, p2_barg) + 1.01325
    p2hat_MPa = max(Plowa / 10.0, Phigha * cf / 10.0)
    d2hat = float(np.interp(p2hat_MPa, P_grid, d_tbl))
    h2hat_kJ = float(np.interp(p2hat_MPa, P_grid, h_tbl))
    dh = h1_J - h2hat_kJ * 1000.0
    if dh < 0.0:
        dh = 0.0
    return sg * Kv * d2hat * rho0_p0_factor * _sqrt(dh) / 60.0


# ============================================================================
# Validation
# ============================================================================
if __name__ == "__main__":
    from cycle2mdot_fast import ICV_open as ICV_open_fast

    print("=" * 80)
    print("cycle2mdot_ode_v2.py — Phase 2 Dual-Backend — Validation")
    print("=" * 80)

    ICVp = [20.5, 48, 5, 779.3, 33.4, 3.75, 0.0000736, 1.0, 100]
    DCVp = [8.3, 25, 3.79, 78.54, 7.8, 1.053, 0.0000026, 0.8, 200]
    pump = [40.3, 60, 120, 300, 0.8, 0.8, 0.026, 15, 37, 760000, 500, 0.01]
    proc = [300, 10, 4, 30, 0.006, 0, 0.5, 0.2]

    for fl in ['h2', 'n2']:
        backend = "Numba Helmholtz" if (fl == 'h2' and _HAS_NUMBA_H2) else "CoolProp AS"
        print(f"\n--- Fluid: {fl.upper()} ({backend}) ---")
        print(f"{'Pexit':>6} {'rtol':>7} | {'Fast':>10} {'ODEv2':>10} "
              f"{'Err%':>7} | {'tFast':>7} {'tODE':>7} {'tSolve':>7} | "
              f"{'Node':>7} {'RHS':>8}")
        print("-" * 100)

        tols = [(1e-6, 1e-8), (1e-5, 1e-7)] if fl == 'h2' else [(1e-6, 1e-8)]
        for P in [350, 900]:
            t0 = time.time()
            out_f, hist_f = ICV_open_fast(P, 0.8, 7, 2, ICVp, DCVp, pump, proc, fl)
            t_fast = time.time() - t0

            for rtol, atol in tols:
                t0 = time.time()
                out_c, hist_c = ODE_driver_v2(P, 0.8, 7, 2, ICVp, DCVp, pump, proc, fl,
                                               flash_eff=0.0, use_corrected=True,
                                               rtol=rtol, atol=atol)
                t_ode = time.time() - t0

                mf = hist_f['mdot_kgpm']; mc = hist_c['mdot_kgpm']
                err = abs(mc - mf) / mf * 100 if mf != 0 else 0.0

                print(f"{P:>6} {rtol:>7.0e} | {mf:>10.4f} {mc:>10.4f} "
                      f"{err:>6.2f}% | {t_fast:>6.1f}s {t_ode:>6.1f}s "
                      f"{hist_c['solve_time']:>6.1f}s | "
                      f"{hist_c['steps']:>7} {hist_c['rhs_calls']:>8}")

    print("\nDone.")