Repository navigation
Expand file tree
/
Copy pathcalc-plumbing.js
More file actions
4725 lines (4406 loc) · 305 KB
/
Copy pathcalc-plumbing.js
File metadata and controls
4725 lines (4406 loc) · 305 KB
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
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Group B: Plumbing and Gas calculators (utilities 12 through 20).
import {
hazenWilliamsFrictionLoss,
feetOfHeadToPsi,
darcyWeisbachFrictionLoss,
} from "./pure-math.js";
// spec-v86 cap-relief split: the onsite-wastewater / septic bench (septic-tank,
// septic-drainfield, septic-dose-tank, septic-pumpout-interval,
// septic-lpp-orifice) relocated to calc-septic.js. The limitation-banner import
// moved with it (only the drainfield renderer used it), leaving no orphan here.
// v18 §7 contract guard: reject a non-finite numeric input. A renderer
// coerces an empty number field to 0 (Number("") === 0), so a NaN or
// Infinity reaching a solver is genuinely unusable (a pasted 1e999, a
// degenerate computed slot); per the spec-v18 §2 output contract the
// solver returns {error} rather than leaking a non-finite output field.
// Generic over the input object, so it needs no per-tile slot list, and
// it inspects only own numeric values (strings/arrays/null pass through).
// Non-exported, so it adds no v14 derivation-corpus row.
const _finiteGuard = (o) => {
if (o && typeof o === "object" && !Array.isArray(o)) {
for (const v of Object.values(o)) {
if (typeof v === "number" && !Number.isFinite(v)) {
return { error: "All numeric inputs must be finite numbers." };
}
}
}
return null;
};
// --- Utility 12: Pipe Sizing (Hunter's Curve) ---
// Fixture units per fixture: UPC-style private-use WSFU (above IPC Appendix E
// Table E103.3(2), so conservative) and IPC Table 709.1 DFU (the flush-valve
// water closet at the public >1.6 gpf value, 6; conservative). These per-fixture
// values are not in Hunter's NBS BMS65, which supplies only the method.
export const FIXTURE_UNITS = {
lavatory: { wsfu_total: 1, dfu: 1 },
water_closet_flush_tank: { wsfu_total: 2.5, dfu: 3 },
water_closet_flush_valve: { wsfu_total: 6, dfu: 6 },
shower: { wsfu_total: 2, dfu: 2 },
bathtub: { wsfu_total: 4, dfu: 2 },
kitchen_sink: { wsfu_total: 1.5, dfu: 2 },
laundry_tray: { wsfu_total: 1.5, dfu: 2 },
dishwasher: { wsfu_total: 1.5, dfu: 2 },
hose_bibb: { wsfu_total: 2.5, dfu: 0 },
};
// Hunter's Curve estimate (gpm from total WSFU) - flush-tank predominant.
// Rows of IPC Appendix E Table E103.3(3), flush-tank column. Until 2026-09-18
// these were eyeballed points up to 18% low (10 WSFU read 12 gpm; the table
// prints 14.6).
export const WSFU_FLUSH_TANK = [[1, 3.0], [5, 9.4], [10, 14.6], [20, 19.6], [30, 23.3], [40, 26.3], [50, 29.1], [60, 32.0], [80, 38.0], [100, 43.5], [120, 48.0], [140, 52.5], [160, 57.0], [200, 65.0], [250, 75.0], [300, 85.0]];
export const WSFU_FLUSH_VALVE = [[5, 15.0], [10, 27.0], [20, 35.0], [30, 42.0], [40, 46.0], [50, 50.0], [60, 54.0], [80, 61.2], [100, 67.5], [120, 73.0], [140, 77.0], [160, 81.0], [200, 90.0], [250, 101.0], [300, 108.0]];
// The pipe-sizing tile reads the same printed rows as wsfu-demand. Until
// 2026-10-07 it carried its own nine-row flush-tank copy (missing the 30, 50,
// 60, 100... rows) and used it even with a flush-valve water closet in the
// list: two flush valves and two lavatories (14 WSFU) read 16.6 gpm and a 1 in
// supply, where the flush-valve column gives 30.2 gpm.
export const HUNTERS_CURVE = WSFU_FLUSH_TANK.map(([wsfu, gpm]) => ({ wsfu, gpm }));
const HUNTERS_CURVE_FLUSH_VALVE = WSFU_FLUSH_VALVE.map(([wsfu, gpm]) => ({ wsfu, gpm }));
function huntersFlowFromWSFU(wsfu, curve = HUNTERS_CURVE) {
if (wsfu <= 0) return 0;
if (wsfu <= curve[0].wsfu) return curve[0].gpm * (wsfu / curve[0].wsfu);
for (let i = 1; i < curve.length; i++) {
if (wsfu <= curve[i].wsfu) {
const a = curve[i - 1];
const b = curve[i];
const t = (wsfu - a.wsfu) / (b.wsfu - a.wsfu);
return a.gpm + t * (b.gpm - a.gpm);
}
}
// Above table: extend linearly using last slope.
const a = curve[curve.length - 2];
const b = curve[curve.length - 1];
const slope = (b.gpm - a.gpm) / (b.wsfu - a.wsfu);
return b.gpm + slope * (wsfu - b.wsfu);
}
// Recommended minimum size by demand gpm (typical plumbing engineering).
// dims: in { gpm: L^3 T^-1 } out: { size_in: L }
export function recommendedSupplySize(gpm) {
if (gpm <= 4) return "1/2";
if (gpm <= 12) return "3/4";
if (gpm <= 25) return "1";
if (gpm <= 50) return "1-1/4";
if (gpm <= 80) return "1-1/2";
return "2 or larger";
}
// Drainage minimum size by total DFU: the smallest building drain in IPC 2021
// Table 710.1(1) (SANITARY_BUILDING_DRAIN_MAX_DFU, below) that carries the load
// at the slope, and never under 3 in when a water closet drains into it (its
// 3 in outlet, IPC Table 709.1; no reduction in the direction of flow, 704.2).
// Until 2026-10-07 this was an unsourced ladder (2 in to 6 DFU, 3 in to 16, 4
// in to 84) that the sanitary-dfu tile's table contradicted, and it sized a
// water closet and a lavatory (4 DFU) to a 2 in drain.
// dims: in { dfu: dimensionless, slope_in_per_ft: dimensionless, has_water_closet: dimensionless } out: { size_in: L }
export function recommendedDrainageSize(dfu, slope_in_per_ft = 0.25, has_water_closet = false) {
const table = SANITARY_BUILDING_DRAIN_MAX_DFU[String(Number(slope_in_per_ft))];
if (!table) return null;
if (!(dfu > 0)) return "n/a (no drainage fixtures)";
const sizes = Object.keys(table).map(Number).sort((a, b) => a - b);
const fits = sizes.find((s) => table[s] >= dfu && (!has_water_closet || s >= 3));
if (fits === undefined) return "larger than " + sizes[sizes.length - 1] + " (engineered)";
return { 1.25: "1-1/4", 1.5: "1-1/2", 2.5: "2-1/2" }[fits] || String(fits);
}
// v8 §C.2: residential fixture-list presets. Lets the renderer offer
// "3-bed/2-bath" / "4-bed/3-bath" / "5-bed/3.5-bath" buttons that prefill
// the fixture list; the user tweaks individual rows.
export const PIPE_SIZING_RESIDENTIAL_PRESETS = {
"3bed_2bath": {
label: "3-bed / 2-bath",
fixtures: [
{ fixture: "water_closet_flush_tank", count: 2 },
{ fixture: "lavatory", count: 3 },
{ fixture: "shower", count: 1 },
{ fixture: "bathtub", count: 1 },
{ fixture: "kitchen_sink", count: 1 },
{ fixture: "dishwasher", count: 1 },
{ fixture: "laundry_tray", count: 1 },
{ fixture: "hose_bibb", count: 2 },
],
},
"4bed_3bath": {
label: "4-bed / 3-bath",
fixtures: [
{ fixture: "water_closet_flush_tank", count: 3 },
{ fixture: "lavatory", count: 5 },
{ fixture: "shower", count: 2 },
{ fixture: "bathtub", count: 1 },
{ fixture: "kitchen_sink", count: 1 },
{ fixture: "dishwasher", count: 1 },
{ fixture: "laundry_tray", count: 1 },
{ fixture: "hose_bibb", count: 2 },
],
},
"5bed_35bath": {
label: "5-bed / 3.5-bath",
fixtures: [
{ fixture: "water_closet_flush_tank", count: 4 },
{ fixture: "lavatory", count: 7 },
{ fixture: "shower", count: 3 },
{ fixture: "bathtub", count: 2 },
{ fixture: "kitchen_sink", count: 1 },
{ fixture: "dishwasher", count: 1 },
{ fixture: "laundry_tray", count: 1 },
{ fixture: "hose_bibb", count: 3 },
],
},
};
// dims: in { presetId: dimensionless } out: { preset: dimensionless }
export function pipeSizingFromPreset(presetId) {
const p = PIPE_SIZING_RESIDENTIAL_PRESETS[presetId];
if (!p) return { error: "Unknown residential preset." };
return computePipeSizing({ fixtures: p.fixtures, slope_in_per_ft: 0.25 });
}
// dims: in { fixtures: dimensionless, slope_in_per_ft: dimensionless } out: { wsfu: dimensionless, dfu: dimensionless, supply_size_in: L, drain_size_in: L }
export function computePipeSizing({ fixtures, slope_in_per_ft = 0.25 }) {
let wsfu = 0;
let dfu = 0;
for (const f of fixtures || []) {
const v = FIXTURE_UNITS[f.fixture];
if (!v) return { error: "Unknown fixture: " + f.fixture };
const c = f.count || 1;
wsfu += (v.wsfu_total || 0) * c;
dfu += (v.dfu || 0) * c;
}
const flushValve = (fixtures || []).some((f) => f.fixture === "water_closet_flush_valve");
const gpm = huntersFlowFromWSFU(wsfu, flushValve ? HUNTERS_CURVE_FLUSH_VALVE : HUNTERS_CURVE);
const hasWc = (fixtures || []).some((f) => /^water_closet/.test(f.fixture) && (f.count || 1) > 0);
const drain = recommendedDrainageSize(dfu, slope_in_per_ft, hasWc);
if (drain === null) return { error: "Drain slope must be 1/8 (0.125), 1/4 (0.25), or 1/2 (0.5) in per ft." };
return {
total_wsfu: wsfu,
total_dfu: dfu,
estimated_demand_gpm: gpm,
demand_curve: flushValve ? "flush valve" : "flush tank",
recommended_supply_size: recommendedSupplySize(gpm),
recommended_drainage_size: drain,
};
}
export const pipeSizingExample = {
inputs: { fixtures: [
{ fixture: "lavatory", count: 2 },
{ fixture: "water_closet_flush_tank", count: 2 },
{ fixture: "shower", count: 1 },
{ fixture: "kitchen_sink", count: 1 },
]},
expected: { total_wsfu: 2*1 + 2*2.5 + 2 + 1.5 },
};
// --- Utility 13: Friction Loss ---
// Schedule 40 internal diameters in inches.
export const SCH40_ID_IN = {
"0.5": 0.622, "0.75": 0.824, "1": 1.049, "1.25": 1.380, "1.5": 1.610,
"2": 2.067, "2.5": 2.469, "3": 3.068, "4": 4.026,
};
// Actual inside diameters by material for the nominal-size picker: PVC and steel
// are Schedule 40; copper is Type L (ASTM B88); CPVC is CTS SDR 11 (ASTM D2846);
// PEX is CTS SDR 9 (ASTM F876). The same bores pipe-velocity uses. Until
// 2026-10-07 every material took the steel Schedule 40 bore, so 1 in PEX at
// 10 gpm read 3.71 ft/s and 2.4 psi per 100 ft where its 0.863 in bore gives
// 5.48 ft/s and 6.2 psi, and the >5 ft/s noise flag never fired.
export const FRICTION_ID_BY_MATERIAL = {
PVC: SCH40_ID_IN, steel_new: SCH40_ID_IN, steel_old: SCH40_ID_IN,
copper: { "0.5": 0.545, "0.75": 0.785, "1": 1.025, "1.25": 1.265, "1.5": 1.505, "2": 1.985 },
CPVC: { "0.5": 0.469, "0.75": 0.695, "1": 0.901, "1.25": 1.232, "1.5": 1.469, "2": 1.913 },
pex: { "0.5": 0.475, "0.75": 0.671, "1": 0.863, "1.25": 1.053, "1.5": 1.243, "2": 1.629 },
};
export const HAZEN_C = {
PVC: 150, CPVC: 150, copper: 140, steel_new: 120, steel_old: 100, pex: 150,
};
// v8 §C.2: classify velocity against plumbing-engineering-practice
// thresholds. > 5 ft/s flags noise risk (water hammer / hiss); > 10 ft/s
// flags erosion risk (especially for copper).
function _v8frictionVelocityFlag(v_ft_s) {
if (!Number.isFinite(v_ft_s) || v_ft_s <= 0) return null;
if (v_ft_s > 10) return "erosion risk (>10 ft/s; copper especially)";
if (v_ft_s > 5) return "noise risk (>5 ft/s)";
return "within typical (≤5 ft/s)";
}
// dims: in { method: dimensionless, material: dimensionless, nominal_size: L, length_ft: L, flow_gpm: L^3 T^-1, internal_diameter_in: L } out: { head_loss_ft: L, pressure_loss_psi: M L^-1 T^-2 }
export function computeFrictionLoss({ method, material, nominal_size, length_ft, flow_gpm, internal_diameter_in }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
const d = internal_diameter_in || (FRICTION_ID_BY_MATERIAL[material] || SCH40_ID_IN)[String(nominal_size)];
if (!d) return { error: "No bundled bore for " + nominal_size + " in " + material + "; provide the internal diameter directly." };
// Compute velocity once (independent of method): V (ft/s) = (Q gpm × 0.4085) / d² (in²).
const velocity_ft_s = (Number(flow_gpm) || 0) * 0.4085 / (d * d);
const velocity_flag = _v8frictionVelocityFlag(velocity_ft_s);
if (method === "hazen-williams") {
const C = HAZEN_C[material];
if (!C) return { error: "Unknown material for Hazen-Williams." };
const headLoss_ft = hazenWilliamsFrictionLoss({ flow_gpm, internal_diameter_in: d, length_ft, C });
return { headLoss_ft, pressureLoss_psi: feetOfHeadToPsi(headLoss_ft), velocity_ft_s, velocity_flag, internal_diameter_in: d };
}
if (method === "darcy-weisbach") {
// For water at 60 F by default.
const rho = 999.0;
const mu = 1.124e-3;
const eps = 1.5e-6; // smooth pipe default
const d_m = d * 0.0254;
const A_m2 = Math.PI * (d_m / 2) ** 2;
const Q_m3_s = (flow_gpm * 0.003785411784) / 60;
const v_m_s = Q_m3_s / A_m2;
const L_m = length_ft * 0.3048;
const h_m = darcyWeisbachFrictionLoss({
internal_diameter_m: d_m, length_m: L_m, velocity_m_s: v_m_s,
density_kg_m3: rho, viscosity_Pa_s: mu, roughness_m: eps,
});
const headLoss_ft = h_m / 0.3048;
// v_m_s / 0.3048 produces a slightly different velocity from the Hazen-Williams
// analytic shortcut at the second decimal; both are correct, so we keep the
// Darcy-Weisbach value for consistency with that branch's other outputs.
const v_dw = v_m_s / 0.3048;
return { headLoss_ft, pressureLoss_psi: feetOfHeadToPsi(headLoss_ft), velocity_ft_s: v_dw, velocity_flag: _v8frictionVelocityFlag(v_dw), internal_diameter_in: d };
}
return { error: "Unknown method." };
}
export const frictionLossExample = {
inputs: { method: "hazen-williams", material: "PVC", nominal_size: "1", length_ft: 100, flow_gpm: 10 },
expectedRange: { pressureLoss_psi: { min: 2.0, max: 2.8 } },
};
// --- Utility 14: Pipe Volume ---
// dims: in { internal_diameter_in: L, length_ft: L, nominal_size: L } out: { volume_gal: L^3 }
export function computePipeVolume({ internal_diameter_in, length_ft, nominal_size }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
// Until 2026-09-26 a negative entry here returned a negative quantity with no error.
if (["internal_diameter_in", "length_ft"].some((k) => Number(arguments[0]?.[k]) < 0)) return { error: "Pipe diameter and length cannot be negative." };
const d = internal_diameter_in || SCH40_ID_IN[String(nominal_size)];
if (!d) return { error: "Unknown nominal size; provide internal diameter directly." };
// V (in^3) = pi/4 * d^2 * L_in. 1 gal = 231 in^3.
const L_in = length_ft * 12;
const v_in3 = (Math.PI / 4) * d * d * L_in;
const gallons = v_in3 / 231;
const cubic_feet = v_in3 / 1728;
return { gallons, gallons_per_ft: length_ft > 0 ? gallons / length_ft : null, cubic_feet };
}
export const pipeVolumeExample = {
inputs: { nominal_size: "1", length_ft: 100 },
expectedRange: { gallons: { min: 4.0, max: 4.8 } },
};
// --- Utility 15: Pump Sizing ---
// dims: in { flow_gpm: L^3 T^-1, total_dynamic_head_ft: L, efficiency: dimensionless, fluid_specific_gravity: dimensionless } out: { brake_hp: M L^2 T^-3, motor_hp: M L^2 T^-3 }
export function computePumpSize({ flow_gpm, total_dynamic_head_ft, efficiency = 0.65, fluid_specific_gravity = 1 }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
if (!(Number(flow_gpm) > 0) || !(Number(total_dynamic_head_ft) > 0)) return { error: "Flow (gpm) and total dynamic head (ft) must be positive." };
if (Number(efficiency) > 1 || Number(efficiency) < 0) return { error: "Pump efficiency is a fraction between 0 and 1 (0.65, not 65)." };
if ([fluid_specific_gravity].some((x) => Number(x) < 0)) return { error: "Fluid specific gravity cannot be negative." };
// Hydraulic horsepower: HP_h = (Q * H * SG) / 3960. Pump shaft hp = HP_h / efficiency.
const hp_h = (flow_gpm * total_dynamic_head_ft * fluid_specific_gravity) / 3960;
const hp_shaft = efficiency > 0 ? hp_h / efficiency : null;
return { hydraulic_hp: hp_h, shaft_hp: hp_shaft };
}
export const pumpSizingExample = {
inputs: { flow_gpm: 100, total_dynamic_head_ft: 80, efficiency: 0.65 },
expectedRange: { shaft_hp: { min: 2.5, max: 4.0 } },
};
// --- Utility 16: Static Pressure Loss in Piping ---
// dims: in { elevation_change_ft: L, friction_loss_psi: M L^-1 T^-2, fluid_density_lb_ft3: M L^-3 } out: { total_pressure_loss_psi: M L^-1 T^-2 }
export function computeStaticPressureLossPiping({ elevation_change_ft, friction_loss_psi = 0, fluid_density_lb_ft3 = 62.4 }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
friction_loss_psi = Number(friction_loss_psi);
// DR-09: a non-positive density is non-physical (yields zero/negative head).
if (!(fluid_density_lb_ft3 > 0)) return { error: "Fluid density must be positive." };
const elev_psi = (elevation_change_ft * fluid_density_lb_ft3) / 144;
return { elevation_loss_psi: elev_psi, friction_loss_psi, total_psi: elev_psi + friction_loss_psi };
}
export const staticPressurePipingExample = {
inputs: { elevation_change_ft: 30, friction_loss_psi: 5 },
expected: { total_psi_approx: 30 / 2.31 + 5 },
};
// --- Utility 17: Gas Pipe Sizing -> relocated to calc-gas.js (spec-v42 split) ---
// --- Utility 18: Slope ---
// dims: in { rise: L, run: L, units: dimensionless } out: { slope_in_per_ft: dimensionless, slope_percent: dimensionless, slope_degrees: dimensionless }
export function computeSlope({ rise, run, units = "in_per_ft" }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
if (run === 0) return { error: "Run cannot be zero." };
let in_per_ft;
if (units === "in_per_ft") in_per_ft = rise; // rise is already in inches per foot
else if (units === "rise_run") in_per_ft = (rise / run) * 12; // rise and run in same units
else return { error: "Unknown units." };
const fraction = in_per_ft / 12;
const percent = fraction * 100;
const degrees = Math.atan(fraction) * (180 / Math.PI);
return { in_per_ft, percent, degrees, fraction };
}
export const slopeExample = {
inputs: { rise: 1, run: 4, units: "rise_run" },
expected: { in_per_ft: 3 },
};
// --- Utility 19: Pressure Conversion ---
// dims: in { value: M L^-1 T^-2, from: dimensionless, to: dimensionless } out: { value: M L^-1 T^-2 }
export function pressureConvert({ value, from, to }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
// Convert all to Pa first. Both water columns are at 60 F: ft_h2o = 12 x in_h2o.
const toPa = {
psi: 6894.757293168361,
kPa: 1000,
bar: 100000,
in_h2o: 248.84,
ft_h2o: 2986.08,
Pa: 1,
atm: 101325,
};
const aPa = toPa[from];
const bPa = toPa[to];
if (!aPa || !bPa) return { error: "Unknown unit." };
const Pa = value * aPa;
return { value: Pa / bPa };
}
export const pressureConversionExample = {
inputs: { value: 1, from: "atm", to: "psi" },
expectedRange: { value: { min: 14.69, max: 14.7 } },
};
// --- Utility 20: Backflow Reference (data only) ---
export const BACKFLOW_REFERENCE = [
{ scenario: "Hose bibb to grade or yard", typical_preventer: "Atmospheric vacuum breaker (AVB) or hose-bibb vacuum breaker." },
{ scenario: "Lawn irrigation, no chemicals", typical_preventer: "Pressure vacuum breaker (PVB)." },
{ scenario: "Lawn irrigation with chemicals or boilers", typical_preventer: "Reduced-pressure principle assembly (RPZ)." },
{ scenario: "Fire sprinkler systems", typical_preventer: "Double-check valve assembly (DCVA) or RPZ depending on hazard." },
{ scenario: "Commercial dishwasher", typical_preventer: "Air gap or RPZ depending on chemical use." },
{ scenario: "Boiler with chemical treatment", typical_preventer: "RPZ." },
];
// dims: in { args: dimensionless } out: { reference: dimensionless }
export function computeBackflow() { return { reference: BACKFLOW_REFERENCE }; }
export const backflowExample = { inputs: {} };
// --- Renderers ---
//
// Lighter-weight renderer set than the electrical group: each calculator
// constructs inputs and an output region wired to compute().
import {
DEBOUNCE_MS, debounce, makeNumber, makeText, makeSelect, makeCheckbox,
makeOutputLine, attachExampleButton, fmt,
stepsFor,
} from "./ui-fields.js";
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderPipeSizing(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: WSFU from UPC-style private-use fixture-unit values (above IPC Appendix E Table E103.3(2), so the sizing is conservative; IPC Table 604.3 gives flow rates, not fixture units) and DFU per IPC Table 709.1 (the flush-valve water closet at its public, over-1.6 gpf value of 6, conservative for a dwelling); Hunter's Curve (1940; NBS BMS65) public-domain methodology converts water-supply fixture units to gpm. AHJ governs. Free at codes.iccsafe.org.";
const fixtures = Object.keys(FIXTURE_UNITS);
const rows = [];
for (const f of fixtures) {
const wrap = document.createElement("div"); wrap.className = "field";
const lab = document.createElement("label"); lab.textContent = f.replace(/_/g, " "); lab.htmlFor = "ps-" + f;
const inp = document.createElement("input"); inp.type = "number"; inp.id = "ps-" + f; inp.inputMode = "decimal"; inp.min = "0"; inp.value = "";
wrap.appendChild(lab); wrap.appendChild(inp); inputRegion.appendChild(wrap);
rows.push({ key: f, input: inp });
}
const slope = makeSelect("Drain slope", "ps-slope", [
{ value: "0.125", label: "1/8 in per ft" },
{ value: "0.25", label: "1/4 in per ft", selected: true },
{ value: "0.5", label: "1/2 in per ft" },
]);
inputRegion.appendChild(slope.wrap);
attachExampleButton(inputRegion, () => {
const ex = { lavatory: 2, water_closet_flush_tank: 2, shower: 1, kitchen_sink: 1 };
for (const r of rows) r.input.value = ex[r.key] || "";
slope.select.value = "0.25";
update();
});
const oWSFU = makeOutputLine(outputRegion, "Total WSFU", "ps-out-wsfu");
const oGPM = makeOutputLine(outputRegion, "Estimated demand", "ps-out-gpm");
const oSup = makeOutputLine(outputRegion, "Recommended supply size", "ps-out-sup");
const oDFU = makeOutputLine(outputRegion, "Total DFU", "ps-out-dfu");
const oDr = makeOutputLine(outputRegion, "Recommended drainage size", "ps-out-dr");
const update = debounce(() => {
const fx = rows.map((r) => ({ fixture: r.key, count: Number(r.input.value) || 0 })).filter((f) => f.count > 0);
const r = computePipeSizing({ fixtures: fx, slope_in_per_ft: Number(slope.select.value) }); stepsFor(outputRegion, inputRegion, ["fixtures", "slope_in_per_ft"], r);
if (r.error) { oWSFU.textContent = r.error; return; }
oWSFU.textContent = fmt(r.total_wsfu, 1);
oGPM.textContent = fmt(r.estimated_demand_gpm, 1) + " gpm";
oSup.textContent = r.recommended_supply_size + "\"";
oDFU.textContent = fmt(r.total_dfu, 1);
oDr.textContent = /^[\d/-]+$/.test(r.recommended_drainage_size) ? r.recommended_drainage_size + "\"" : r.recommended_drainage_size;
}, DEBOUNCE_MS);
for (const r of rows) r.input.addEventListener("input", update);
slope.select.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderFrictionLoss(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: Hazen-Williams (1905, public domain). IPC 2021 referenced for application. Darcy-Weisbach with Colebrook-White for general fluid use. Free at codes.iccsafe.org.";
const method = makeSelect("Method", "fl-method", [
{ value: "hazen-williams", label: "Hazen-Williams (water)" },
{ value: "darcy-weisbach", label: "Darcy-Weisbach (water)" },
]);
const material = makeSelect("Material", "fl-mat", Object.keys(HAZEN_C).map((m) => ({ value: m, label: m })));
const size = makeSelect("Nominal size (Sch 40 PVC/steel, Type L copper, CTS CPVC/PEX)", "fl-size", Object.keys(SCH40_ID_IN).map((s) => ({ value: s, label: s + "\""})));
const length = makeNumber("Length (ft)", "fl-len", { step: "any", min: "0" });
const flow = makeNumber("Flow (gpm)", "fl-flow", { step: "any", min: "0" });
for (const f of [method, material, size, length, flow]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => {
method.select.value = "hazen-williams"; material.select.value = "PVC"; size.select.value = "1";
length.input.value = "100"; flow.input.value = "10"; update();
});
const oH = makeOutputLine(outputRegion, "Head loss", "fl-out-h");
const oP = makeOutputLine(outputRegion, "Pressure loss", "fl-out-p");
// v8 §C.2: surface velocity + 5/10 ft/s threshold flag.
const oVel = makeOutputLine(outputRegion, "Velocity", "fl-out-vel");
const oVelFlag = makeOutputLine(outputRegion, "Velocity status", "fl-out-vel-flag");
const update = debounce(() => {
const r = computeFrictionLoss({
method: method.select.value,
material: material.select.value,
nominal_size: size.select.value,
length_ft: Number(length.input.value) || 0,
flow_gpm: Number(flow.input.value) || 0,
}); stepsFor(outputRegion, inputRegion, ["method", "material", "nominal_size", "length_ft", "flow_gpm"], r);
if (r.error) { oH.textContent = r.error; oP.textContent = "-"; oVel.textContent = "-"; oVelFlag.textContent = "-"; return; }
oH.textContent = fmt(r.headLoss_ft, 2) + " ft of head";
oP.textContent = fmt(r.pressureLoss_psi, 2) + " psi";
oVel.textContent = r.velocity_ft_s === undefined ? "-" : fmt(r.velocity_ft_s, 2) + " ft/s";
oVelFlag.textContent = r.velocity_flag || "-";
}, DEBOUNCE_MS);
for (const el of [method.select, material.select, size.select, length.input, flow.input]) el.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderPipeVolume(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: V = (pi/4) * d^2 * L. 1 US gallon = 231 in^3.";
const size = makeSelect("Nominal Sch 40 size", "pv-size", Object.keys(SCH40_ID_IN).map((s) => ({ value: s, label: s + "\""})));
const length = makeNumber("Length (ft)", "pv-len", { step: "any", min: "0" });
for (const f of [size, length]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => { size.select.value = "1"; length.input.value = "100"; update(); });
const oG = makeOutputLine(outputRegion, "Volume", "pv-out-g");
const oPF = makeOutputLine(outputRegion, "Per foot", "pv-out-pf");
const update = debounce(() => {
const r = computePipeVolume({ nominal_size: size.select.value, length_ft: Number(length.input.value) || 0 }); stepsFor(outputRegion, inputRegion, ["nominal_size", "length_ft"], r);
if (r.error) { oG.textContent = r.error; return; }
oG.textContent = fmt(r.gallons, 2) + " gal (" + fmt(r.cubic_feet, 3) + " ft^3)";
oPF.textContent = fmt(r.gallons_per_ft, 4) + " gal/ft";
}, DEBOUNCE_MS);
for (const el of [size.select, length.input]) el.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderPumpSizing(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: Hydraulic horsepower HP_h = (Q * H * SG) / 3960; shaft hp = HP_h / efficiency.";
const flow = makeNumber("Flow (gpm)", "pm-q", { step: "any", min: "0" });
const tdh = makeNumber("Total dynamic head (ft)", "pm-h", { step: "any", min: "0" });
const eff = makeNumber("Pump efficiency (0-1)", "pm-e", { step: "any", min: "0", max: "1", value: "0.65" });
eff.input.value = "0.65";
for (const f of [flow, tdh, eff]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => { flow.input.value = "100"; tdh.input.value = "80"; eff.input.value = "0.65"; update(); });
const oH = makeOutputLine(outputRegion, "Hydraulic hp", "pm-out-h");
const oS = makeOutputLine(outputRegion, "Shaft hp", "pm-out-s");
const update = debounce(() => {
const r = computePumpSize({ flow_gpm: Number(flow.input.value) || 0, total_dynamic_head_ft: Number(tdh.input.value) || 0, efficiency: Number(eff.input.value) || 0.65 }); stepsFor(outputRegion, inputRegion, ["flow_gpm", "total_dynamic_head_ft", "efficiency"], r);
oH.textContent = fmt(r.hydraulic_hp, 2);
oS.textContent = r.shaft_hp === null ? "-" : fmt(r.shaft_hp, 2);
}, DEBOUNCE_MS);
for (const el of [flow.input, tdh.input, eff.input]) el.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderStaticPressurePiping(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: Pressure from elevation = rho * g * h. For water at 60 F, 1 ft of head ~ 0.433 psi.";
const elev = makeNumber("Elevation change (ft)", "sp-e", { step: "any" });
const fric = makeNumber("Friction loss (psi)", "sp-f", { step: "any", min: "0", value: "0" });
fric.input.value = "0";
for (const f of [elev, fric]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => { elev.input.value = "30"; fric.input.value = "5"; update(); });
const oE = makeOutputLine(outputRegion, "Elevation loss", "sp-out-e");
const oT = makeOutputLine(outputRegion, "Total", "sp-out-t");
const update = debounce(() => {
const r = computeStaticPressureLossPiping({ elevation_change_ft: Number(elev.input.value) || 0, friction_loss_psi: Number(fric.input.value) || 0 }); stepsFor(outputRegion, inputRegion, ["elevation_change_ft", "friction_loss_psi"], r);
oE.textContent = fmt(r.elevation_loss_psi, 2) + " psi";
oT.textContent = fmt(r.total_psi, 2) + " psi";
}, DEBOUNCE_MS);
for (const el of [elev.input, fric.input]) el.addEventListener("input", update);
}
// renderGasPipeSizing -> relocated to calc-gas.js (spec-v42 split)
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderSlope(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: Geometric slope. 1/4 inch per foot equals approximately 2.08 percent or 1.19 degrees.";
const rise = makeNumber("Rise", "sl-r", { step: "any" });
const run = makeNumber("Run (same units as rise)", "sl-rn", { step: "any", min: "0" });
for (const f of [rise, run]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => { rise.input.value = "1"; run.input.value = "4"; update(); });
const oI = makeOutputLine(outputRegion, "Inches per foot", "sl-out-i");
const oP = makeOutputLine(outputRegion, "Percent", "sl-out-p");
const oD = makeOutputLine(outputRegion, "Degrees", "sl-out-d");
const update = debounce(() => {
const r = computeSlope({ rise: Number(rise.input.value) || 0, run: Number(run.input.value) || 0, units: "rise_run" }); stepsFor(outputRegion, inputRegion, ["rise", "run", "units"], r);
if (r.error) { oI.textContent = r.error; oP.textContent = "-"; oD.textContent = "-"; return; }
oI.textContent = fmt(r.in_per_ft, 3); oP.textContent = fmt(r.percent, 2) + " %"; oD.textContent = fmt(r.degrees, 2);
}, DEBOUNCE_MS);
for (const el of [rise.input, run.input]) el.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderPressureConversion(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: NIST SP 811 unit factors. 1 psi = 6894.757 Pa; 1 atm = 101325 Pa; 1 in w.c. = 248.84 Pa.";
const value = makeNumber("Value", "pc-v", { step: "any" });
const units = ["psi", "kPa", "bar", "in_h2o", "ft_h2o", "Pa", "atm"];
const from = makeSelect("From", "pc-from", units.map((u) => ({ value: u, label: u })));
const to = makeSelect("To", "pc-to", units.map((u) => ({ value: u, label: u })));
for (const f of [value, from, to]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => { value.input.value = "1"; from.select.value = "atm"; to.select.value = "psi"; update(); });
const oV = makeOutputLine(outputRegion, "Result", "pc-out");
const update = debounce(() => {
const r = pressureConvert({ value: Number(value.input.value) || 0, from: from.select.value, to: to.select.value });
oV.textContent = r.error ? r.error : (fmt(r.value, 4) + " " + to.select.value);
}, DEBOUNCE_MS);
for (const el of [value.input, from.select, to.select]) el.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderBackflow(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: Original plain-English summaries by the project author. Common backflow scenarios and typical preventer types.";
const list = document.createElement("dl");
for (const item of BACKFLOW_REFERENCE) {
const dt = document.createElement("dt"); dt.textContent = item.scenario; list.appendChild(dt);
const dd = document.createElement("dd"); dd.textContent = item.typical_preventer; list.appendChild(dd);
}
outputRegion.appendChild(list);
const note = document.createElement("p"); note.textContent = "Always verify against the AHJ and the manufacturer of the assembly. Reduced-pressure principle assemblies require periodic testing by a certified tester.";
outputRegion.appendChild(note);
}
// =====================================================================
// v2 utilities (72-78): spec-v2.md section 2 Group B extensions.
// =====================================================================
// --- Utility 72: Water Hammer Arrestor Sizing (PDI WH-201 method) ---
//
// Method only; the published PDI WH-201 text is not reproduced. The
// designation map is the public PDI sizing convention by fixture-unit
// totals.
export const PDI_WH_ARRESTOR_SIZES = [
{ designation: "AA-A", max_wsfu: 11 },
{ designation: "AA-B", max_wsfu: 32 },
{ designation: "AA-C", max_wsfu: 60 },
{ designation: "AA-D", max_wsfu: 113 },
{ designation: "AA-E", max_wsfu: 154 },
{ designation: "AA-F", max_wsfu: 330 },
];
// dims: in { wsfu: dimensionless, length_ft: L, internal_diameter_in: L, system_pressure_psi: M L^-1 T^-2 } out: { aa_size: dimensionless, pre_charge_psi: M L^-1 T^-2 }
export function computeWaterHammerArrestor({ wsfu, length_ft = 0, internal_diameter_in = 0, system_pressure_psi = 0 }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
internal_diameter_in = Number(internal_diameter_in);
const w = Number(wsfu) || 0;
if (w <= 0) return { error: "Provide a positive WSFU total." };
const row = PDI_WH_ARRESTOR_SIZES.find((r) => w <= r.max_wsfu);
if (!row) return { error: "WSFU exceeds bundled PDI size table." };
// Long branches typically need an arrestor at every fixture group; flag
// the case for the user.
const long_branch = (Number(length_ft) || 0) > 20;
// v8 §C.2: arrestor air-charge pre-charge pressure that the tech needs
// to set with the system depressurized. Manufacturer typical: pre-charge
// = static system pressure (so the bladder sits at the wall at static
// conditions). When system_pressure not supplied, default to a
// residential 60 psi typical.
const sys_psi = system_pressure_psi > 0 ? system_pressure_psi : 60;
const precharge_psi = sys_psi;
return {
designation: row.designation,
wsfu_total: w,
long_branch_flag: long_branch,
pipe_diameter_in: internal_diameter_in,
precharge_psi,
placement_note: "Install at the end of the branch line, downstream of the last fixture. Pre-charge with the system depressurized.",
};
}
export const waterHammerArrestorExample = {
inputs: { wsfu: 30, length_ft: 25, internal_diameter_in: 1 },
expected: { designation: "AA-B" },
};
// --- Utility 73: Hot Water Recirculation Pump Head ---
// dims: in { pipe_length_ft: L, fittings_count: dimensionless, target_flow_gpm: L^3 T^-1, internal_diameter_in: L, material: dimensionless, equivalent_length_per_fitting_ft: L } out: { head_ft: L, flow_gpm: L^3 T^-1, pump_hp: M L^2 T^-3 }
export function computeRecircPumpHead({
pipe_length_ft, fittings_count = 0, target_flow_gpm,
internal_diameter_in, material = "copper",
equivalent_length_per_fitting_ft = 2,
}) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
if (Number(arguments[0]?.fittings_count) < 0 || (arguments[0]?.fittings_count !== undefined && !Number.isFinite(Number(arguments[0].fittings_count)))) return { error: "Fittings count must be a number, zero or more." };
const d = Number(internal_diameter_in) || 0;
const L = Number(pipe_length_ft) || 0;
const Q = Number(target_flow_gpm) || 0;
const C = HAZEN_C[material] || 140;
if (d <= 0 || L <= 0 || Q <= 0) return { error: "Provide positive length, diameter, and flow." };
const equiv_ft = Number(fittings_count) * Number(equivalent_length_per_fitting_ft);
const total_length_ft = L + equiv_ft;
const headLoss_ft = hazenWilliamsFrictionLoss({
flow_gpm: Q, internal_diameter_in: d, length_ft: total_length_ft, C,
});
return {
head_ft: headLoss_ft,
pressure_psi: feetOfHeadToPsi(headLoss_ft),
equivalent_length_ft: equiv_ft,
total_length_ft,
target_flow_gpm: Q,
};
}
export const recircPumpHeadExample = {
inputs: { pipe_length_ft: 100, fittings_count: 8, target_flow_gpm: 4, internal_diameter_in: 0.75, material: "copper" },
expectedRange: { head_ft: { min: 0.2, max: 10 } },
};
// --- Utility 74: Septic Tank Sizing -> relocated to calc-septic.js (spec-v86 split) ---
// --- Utility 75: Trap Arm Length ---
//
// IPC 2021 Table 909.1 maximum distance of fixture trap from vent: 1-1/4 in 5 ft, 1-1/2 in 6 ft,
// 2 in 8 ft at 1/4 in/ft; 3 in 12 ft and 4 in 16 ft at 1/8 in/ft. IPC 909.2 limits the total fall to one
// pipe diameter. Until 2026-09-25 the small sizes were 3.5 and 5 ft (neither IPC nor UPC) and the
// citation called the table "public engineering practice".
export const TRAP_ARM_MAX_FT = {
"1.25": 5,
"1.5": 6,
"2": 8,
"3": 12,
"4": 16,
};
// dims: in { pipe_diameter_in: L, slope_in_per_ft: dimensionless } out: { max_trap_arm_in: L }
export function computeTrapArm({ pipe_diameter_in, slope_in_per_ft = 0.25 }) {
const max_ft = TRAP_ARM_MAX_FT[String(pipe_diameter_in)];
if (max_ft === undefined) return { error: "Pipe diameter not in bundled table." };
// The trap weir must not be below the inlet to the vent (the vent must
// be above the weir). Limit length so total fall does not exceed one
// pipe diameter (engineering practice).
const max_fall_in = Number(pipe_diameter_in);
const slope = Number(slope_in_per_ft) || 0.25;
const fall_limited_ft = slope > 0 ? max_fall_in / slope : max_ft;
return {
max_length_ft: Math.min(max_ft, fall_limited_ft),
table_max_ft: max_ft,
fall_limited_ft,
pipe_diameter_in: Number(pipe_diameter_in),
};
}
export const trapArmExample = {
inputs: { pipe_diameter_in: 2, slope_in_per_ft: 0.25 },
expected: { table_max_ft: 8 },
};
// --- Utility 76: Pipe Thermal Expansion (delta-L = alpha * L * delta-T) ---
//
// alpha values in 1/F from data/plumbing/material-expansion.json.
export const PIPE_EXPANSION_ALPHA_PER_F = {
copper: 9.4e-6,
PEX: 1.1e-4,
PVC: 3.0e-5,
CPVC: 3.4e-5, // FlowGuard published 3.4e-5 in/in/F (3.7e-5 until 2026-09-19; the other two CPVC tiles already used 3.4e-5)
steel: 6.5e-6,
};
// dims: in { material: dimensionless, length_ft: L, delta_T_F: T } out: { expansion_in: L }
export function computePipeExpansion({ material, length_ft, delta_T_F }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
const alpha = PIPE_EXPANSION_ALPHA_PER_F[material];
if (alpha === undefined) return { error: "Unknown pipe material." };
const L = Number(length_ft) || 0;
const dT = Number(delta_T_F);
if (!Number.isFinite(dT)) return { error: "Provide a numeric temperature change." };
// Convert L to inches for output: dL (in) = alpha (1/F) * L (ft) * 12 * dT (F).
const dL_in = alpha * L * 12 * dT;
return { delta_L_in: dL_in, alpha_per_F: alpha, length_ft: L, delta_T_F: dT };
}
export const pipeExpansionExample = {
inputs: { material: "copper", length_ft: 100, delta_T_F: 100 },
expectedRange: { delta_L_in: { min: 1.0716, max: 1.1844 } },
};
// --- Utility 77: Tankless Water Heater GPM ---
//
// GPM = kBTU * 1000 / (8.33 * 60 * delta-T)
// 1 lb water needs 1 BTU per F; 1 gal water = 8.33 lb; 60 min/hr.
export const TANKLESS_INLET_F_BY_ZONE = {
"1A_Miami_FL": 75,
"2A_Houston_TX": 70,
"3A_Atlanta_GA": 60,
"4A_Baltimore_MD": 55,
"5A_Chicago_IL": 50,
"6A_Minneapolis_MN": 45,
"7_Duluth_MN": 40,
};
// dims: in { kbtu_input: M L^2 T^-3, climate_zone: dimensionless, target_outlet_F: T, solve_for: dimensionless, target_gpm: L^3 T^-1, inlet_override_F: T } out: { gpm: L^3 T^-1, kbtu_input: M L^2 T^-3, delta_T_F: T }
export function computeTanklessGPM({ kbtu_input, climate_zone, target_outlet_F = 110, solve_for = "gpm", target_gpm = 0, inlet_override_F = 0, thermal_efficiency = 0.82 }) {
const _g = _finiteGuard(arguments[0]); if (_g) return _g;
// The burner's INPUT rating times its thermal efficiency is the heat the water
// gets. The citation always said so (default 0.82, the DOE gas-tankless
// minimum), but until 2026-09-18 the code took input as delivered heat, which
// overstated flow 22% at 0.82 and 5% for a 0.95 condensing unit.
const eff = Number(thermal_efficiency);
if (!(eff > 0 && eff <= 1)) return { error: "Thermal efficiency must be over 0 and at most 1 (0.82 typical non-condensing, 0.95 condensing)." };
let inlet = TANKLESS_INLET_F_BY_ZONE[climate_zone];
if (inlet === undefined) return { error: "Unknown climate zone." };
// v23 EN.4: optional inlet override (a summer/winter worst-case preset the
// renderer fills) and a solve-for selector across {GPM, kBTU, delta-T}.
const ov = Number(inlet_override_F);
if (Number.isFinite(ov) && ov > 0) inlet = ov;
const out = Number(target_outlet_F) || 0;
const dT = out - inlet;
if (solve_for === "kbtu") {
const g = Number(target_gpm) || 0;
if (!(g > 0 && Number.isFinite(g))) return { error: "Provide a positive target GPM." };
if (!(dT > 0)) return { error: "Outlet must exceed inlet." };
return { solve_for, kbtu_input: (g * 8.33 * 60 * dT) / (1000 * eff), gpm: g, delta_T_F: dT, inlet_F: inlet, target_outlet_F: out };
}
if (solve_for === "dt") {
const g = Number(target_gpm) || 0;
const kbtu = Number(kbtu_input) || 0;
if (!(g > 0 && Number.isFinite(g))) return { error: "Provide a positive target GPM." };
if (!(kbtu > 0 && Number.isFinite(kbtu))) return { error: "Provide positive kBTU." };
const dt_req = (kbtu * 1000 * eff) / (8.33 * 60 * g);
return { solve_for, delta_T_F: dt_req, gpm: g, kbtu_input: kbtu, inlet_F: inlet, target_outlet_F: inlet + dt_req };
}
const kbtu = Number(kbtu_input) || 0;
if (kbtu <= 0 || dT <= 0) return { error: "Provide positive kBTU and outlet > inlet." };
const gpm = (kbtu * 1000 * eff) / (8.33 * 60 * dT);
return { gpm, delta_T_F: dT, inlet_F: inlet, target_outlet_F: out, solve_for: "gpm" };
}
export const tanklessGPMExample = {
inputs: { kbtu_input: 199, climate_zone: "5A_Chicago_IL", target_outlet_F: 110 },
expectedRange: { gpm: { min: 5, max: 8 } },
};
// --- Utility 78: Gas Leak Rate -> relocated to calc-gas.js (spec-v42 split) ---
// --- v2 view renderers ---
// v8 §C.2 / accessibility.md preset-chip pattern: typical static water
// pressure values residential / suburban / commercial techs see most often.
export const WHA_SYSTEM_PRESSURE_PRESETS = [
{ id: "low", label: "Low 40 psi", psi: 40, description: "Low residential / well system" },
{ id: "typical", label: "Typical 60 psi", psi: 60, description: "Typical residential static pressure" },
{ id: "high", label: "High 80 psi", psi: 80, description: "High residential / commercial near PRV limit" },
];
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderWaterHammerArrestor(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: PDI WH-201 sizing method (the method, not the published text). Designation by fixture-unit totals.";
const wsfu = makeNumber("Total fixture units (WSFU)", "wha-w", { step: "any", min: "0" });
const length = makeNumber("Branch length (ft)", "wha-l", { step: "any", min: "0", value: "0" });
length.input.value = "0";
const dia = makeNumber("Pipe internal diameter (in)", "wha-d", { step: "any", min: "0" });
// v8 §C.2: optional system pressure so the renderer can show the
// air-charge pre-charge pressure the tech needs to set.
const sp = makeNumber("System pressure (psi, optional)", "wha-sp", { step: "any", min: "0" });
// v8 §C.2 + accessibility.md preset-chip pattern: typical residential /
// commercial system-pressure presets so the tech sets pre-charge with
// one tap.
const chipRow = document.createElement("div");
chipRow.className = "preset-chip-row";
chipRow.setAttribute("role", "group");
chipRow.setAttribute("aria-label", "System pressure presets");
for (const p of WHA_SYSTEM_PRESSURE_PRESETS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "preset-chip";
btn.dataset.presetId = p.id;
btn.textContent = p.label;
btn.title = p.description;
btn.addEventListener("click", () => { sp.input.value = String(p.psi); update(); });
chipRow.appendChild(btn);
}
for (const f of [wsfu, length, dia, sp]) inputRegion.appendChild(f.wrap);
inputRegion.appendChild(chipRow);
attachExampleButton(inputRegion, () => { wsfu.input.value = "30"; length.input.value = "25"; dia.input.value = "1"; update(); });
const oD = makeOutputLine(outputRegion, "Designation", "wha-out-d");
const oF = makeOutputLine(outputRegion, "Long-branch note", "wha-out-f");
const oPC = makeOutputLine(outputRegion, "Pre-charge (set air side, system depressurized)", "wha-out-pc");
const oPN = makeOutputLine(outputRegion, "Placement", "wha-out-pn");
const update = debounce(() => {
const r = computeWaterHammerArrestor({
wsfu: Number(wsfu.input.value) || 0,
length_ft: Number(length.input.value) || 0,
internal_diameter_in: Number(dia.input.value) || 0,
system_pressure_psi: Number(sp.input.value) || 0,
}); stepsFor(outputRegion, inputRegion, ["wsfu", "length_ft", "internal_diameter_in", "system_pressure_psi"], r);
if (r.error) { oD.textContent = r.error; oF.textContent = "-"; oPC.textContent = "-"; oPN.textContent = "-"; return; }
oD.textContent = r.designation;
oF.textContent = r.long_branch_flag ? "Branch length over 20 ft; consider an arrestor at each fixture group." : "Standard branch length.";
oPC.textContent = fmt(r.precharge_psi, 1) + " psi" + (Number(sp.input.value) > 0 ? "" : " (default 60 psi residential)");
oPN.textContent = r.placement_note;
}, DEBOUNCE_MS);
for (const el of [wsfu.input, length.input, dia.input, sp.input]) el.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderRecircPumpHead(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: Hazen-Williams head loss over the supply loop plus an equivalent-length allowance for fittings. Public engineering practice.";
const len = makeNumber("Pipe length one-way (ft)", "rp-l", { step: "any", min: "0" });
const fits = makeNumber("Fittings count", "rp-f", { step: "1", min: "0", value: "0" });
fits.input.value = "0";
const eq = makeNumber("Equiv length per fitting (ft)", "rp-eq", { step: "any", min: "0", value: "2" });
eq.input.value = "2";
const flow = makeNumber("Target flow (gpm)", "rp-q", { step: "any", min: "0" });
const dia = makeNumber("Internal diameter (in)", "rp-d", { step: "any", min: "0" });
const mat = makeSelect("Material", "rp-m", Object.keys(HAZEN_C).map((m) => ({ value: m, label: m })));
for (const f of [len, fits, eq, flow, dia, mat]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => {
len.input.value = "100"; fits.input.value = "8"; eq.input.value = "2";
flow.input.value = "4"; dia.input.value = "0.75"; mat.select.value = "copper"; update();
});
const oH = makeOutputLine(outputRegion, "Pump head", "rp-out-h");
const oP = makeOutputLine(outputRegion, "Pressure", "rp-out-p");
const oE = makeOutputLine(outputRegion, "Equivalent length", "rp-out-e");
const update = debounce(() => {
const r = computeRecircPumpHead({
pipe_length_ft: Number(len.input.value) || 0,
fittings_count: Number(fits.input.value) || 0,
equivalent_length_per_fitting_ft: Number(eq.input.value) || 2,
target_flow_gpm: Number(flow.input.value) || 0,
internal_diameter_in: Number(dia.input.value) || 0,
material: mat.select.value,
}); stepsFor(outputRegion, inputRegion, ["pipe_length_ft", "fittings_count", "equivalent_length_per_fitting_ft", "target_flow_gpm", "internal_diameter_in", "material"], r);
if (r.error) { oH.textContent = r.error; oP.textContent = "-"; oE.textContent = "-"; return; }
oH.textContent = fmt(r.head_ft, 3) + " ft";
oP.textContent = fmt(r.pressure_psi, 3) + " psi";
oE.textContent = fmt(r.equivalent_length_ft, 1) + " ft";
}, DEBOUNCE_MS);
for (const el of [len.input, fits.input, eq.input, flow.input, dia.input, mat.select]) el.addEventListener("input", update);
}
// renderSepticTank -> relocated to calc-septic.js (spec-v86 split)
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderTrapArm(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: IPC 2021 Table 909.1 (1-1/4 in 5 ft, 1-1/2 in 6 ft, 2 in 8 ft at 1/4 in/ft; 3 in 12 ft, 4 in 16 ft at 1/8 in/ft) and IPC 909.2 (total fall not more than one pipe diameter, vent connection not below the trap weir).";
const dia = makeSelect("Pipe diameter (in)", "ta-d", Object.keys(TRAP_ARM_MAX_FT).map((s) => ({ value: s, label: s + "\""})));
const slope = makeNumber("Slope (in/ft)", "ta-s", { step: "any", min: "0" });
for (const f of [dia, slope]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => { dia.select.value = "2"; slope.input.value = "0.25"; update(); });
const oM = makeOutputLine(outputRegion, "Maximum length", "ta-out-m");
const oT = makeOutputLine(outputRegion, "Table max", "ta-out-t");
const update = debounce(() => {
const r = computeTrapArm({
pipe_diameter_in: dia.select.value,
slope_in_per_ft: Number(slope.input.value) || 0.25,
}); stepsFor(outputRegion, inputRegion, ["pipe_diameter_in", "slope_in_per_ft"], r);
if (r.error) { oM.textContent = r.error; oT.textContent = "-"; return; }
oM.textContent = fmt(r.max_length_ft, 2) + " ft";
oT.textContent = fmt(r.table_max_ft, 2) + " ft";
}, DEBOUNCE_MS);
for (const el of [dia.select, slope.input]) el.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderPipeExpansion(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: Linear thermal expansion dL = alpha * L * dT. Coefficients (per F) from NIST and manufacturer technical bulletins.";
const mat = makeSelect("Material", "pe-m", Object.keys(PIPE_EXPANSION_ALPHA_PER_F).map((m) => ({ value: m, label: m })));
const len = makeNumber("Length (ft)", "pe-l", { step: "any", min: "0" });
const dT = makeNumber("Temperature change (°F)", "pe-t", { step: "any" });
for (const f of [mat, len, dT]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => { mat.select.value = "copper"; len.input.value = "100"; dT.input.value = "100"; update(); });
const oL = makeOutputLine(outputRegion, "Expansion", "pe-out-l");
const oA = makeOutputLine(outputRegion, "Coefficient", "pe-out-a");
const update = debounce(() => {
const r = computePipeExpansion({
material: mat.select.value,
length_ft: Number(len.input.value) || 0,
delta_T_F: Number(dT.input.value),
}); stepsFor(outputRegion, inputRegion, ["material", "length_ft", "delta_T_F"], r);
if (r.error) { oL.textContent = r.error; oA.textContent = "-"; return; }
oL.textContent = fmt(r.delta_L_in, 3) + " in";
oA.textContent = r.alpha_per_F.toExponential(2) + " 1/F";
}, DEBOUNCE_MS);
for (const el of [mat.select, len.input, dT.input]) el.addEventListener("input", update);
}
// dims: in { dom: dimensionless } out: { dom_side_effect: dimensionless }
export function renderTanklessGPM(inputRegion, outputRegion, citationEl) {
citationEl.textContent = "Citation: GPM = kBTU x 1000 x thermal efficiency / (8.33 x 60 x dT) (default 0.82). 1 lb water requires 1 BTU per F; 1 gal water = 8.33 lb. Inlet temperatures by climate zone (NOAA design data).";
// v23 EN.4: solve-for selector + worst-case inlet override (season preset).
const solve = makeSelect("Solve for", "tl-s", [
{ value: "gpm", label: "GPM from kBTU + dT" },
{ value: "kbtu", label: "kBTU from GPM + dT" },
{ value: "dt", label: "dT from kBTU + GPM" },
]);
const kbtu = makeNumber("Burner input (kBTU/hr)", "tl-k", { step: "any", min: "0" });
const tgpm = makeNumber("Target flow (gpm, for inverse)", "tl-tg", { step: "any", min: "0" });
const zone = makeSelect("Climate zone", "tl-z", Object.keys(TANKLESS_INLET_F_BY_ZONE).map((z) => ({ value: z, label: z.replace(/_/g, " ") })));
const ovr = makeNumber("Inlet override (F, winter worst-case; blank = zone)", "tl-ov", { step: "any", min: "0" });
const out = makeNumber("Target outlet (°F)", "tl-o", { step: "any", min: "0", value: "110" });
const eff = makeNumber("Thermal efficiency (0.82 non-condensing, 0.95 condensing)", "tl-eff", { step: "any", min: "0", max: "1", value: "0.82" });
eff.input.value = "0.82";
out.input.value = "110";
for (const f of [solve, kbtu, tgpm, zone, ovr, out, eff]) inputRegion.appendChild(f.wrap);
attachExampleButton(inputRegion, () => { solve.select.value = "gpm"; kbtu.input.value = "199"; tgpm.input.value = ""; zone.select.value = "5A_Chicago_IL"; ovr.input.value = ""; out.input.value = "110"; eff.input.value = "0.82"; update(); });
const oG = makeOutputLine(outputRegion, "Solved", "tl-out-g");
const oI = makeOutputLine(outputRegion, "Inlet temperature", "tl-out-i");
const oD = makeOutputLine(outputRegion, "delta T", "tl-out-d");
const update = debounce(() => {
const r = computeTanklessGPM({
kbtu_input: Number(kbtu.input.value) || 0,
climate_zone: zone.select.value,
target_outlet_F: Number(out.input.value) || 0,
solve_for: solve.select.value,
target_gpm: Number(tgpm.input.value) || 0,
inlet_override_F: Number(ovr.input.value) || 0,
thermal_efficiency: Number(eff.input.value) || 0.82,
}); stepsFor(outputRegion, inputRegion, ["kbtu_input", "climate_zone", "target_outlet_F", "solve_for", "target_gpm", "inlet_override_F", "thermal_efficiency"], r);
if (r.error) { oG.textContent = r.error; oI.textContent = "-"; oD.textContent = "-"; return; }
oG.textContent = r.solve_for === "kbtu" ? (fmt(r.kbtu_input, 1) + " kBTU/hr") : r.solve_for === "dt" ? (fmt(r.delta_T_F, 1) + " F rise") : (fmt(r.gpm, 2) + " gpm");
oI.textContent = fmt(r.inlet_F, 0) + " F";
oD.textContent = fmt(r.delta_T_F, 0) + " F";
}, DEBOUNCE_MS);
for (const el of [solve.select, kbtu.input, tgpm.input, zone.select, ovr.input, out.input, eff.input]) el.addEventListener("input", update);
}
// renderGasLeakRate -> relocated to calc-gas.js (spec-v42 split)