iapws-if97 2.2.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +25 -2
  2. package/README.md +69 -246
  3. package/dist/backward/candidate-resolution.d.ts +8 -0
  4. package/dist/backward/candidate-resolution.js +40 -0
  5. package/dist/backward/fixed-temperature-candidates.d.ts +4 -0
  6. package/dist/backward/fixed-temperature-candidates.js +188 -0
  7. package/dist/backward/fixed-temperature-solver.d.ts +3 -3
  8. package/dist/backward/fixed-temperature-solver.js +38 -17
  9. package/dist/backward/fixed-temperature-stationary.d.ts +4 -0
  10. package/dist/backward/fixed-temperature-stationary.js +146 -0
  11. package/dist/backward/hs-region.d.ts +50 -0
  12. package/dist/backward/hs-region.js +695 -0
  13. package/dist/backward/hs-state.d.ts +15 -0
  14. package/dist/backward/hs-state.js +316 -0
  15. package/dist/backward/hs.js +28 -242
  16. package/dist/backward/ph.d.ts +2 -0
  17. package/dist/backward/ph.js +72 -21
  18. package/dist/backward/pressure-property-region.d.ts +13 -0
  19. package/dist/backward/pressure-property-region.js +262 -0
  20. package/dist/backward/ps.d.ts +6 -0
  21. package/dist/backward/ps.js +81 -28
  22. package/dist/backward/solution-validation.d.ts +7 -1
  23. package/dist/backward/solution-validation.js +138 -41
  24. package/dist/backward/th.d.ts +3 -5
  25. package/dist/backward/th.js +10 -71
  26. package/dist/backward/tolerances.d.ts +3 -0
  27. package/dist/backward/tolerances.js +9 -3
  28. package/dist/backward/ts.d.ts +3 -5
  29. package/dist/backward/ts.js +10 -71
  30. package/dist/constants.d.ts +11 -15
  31. package/dist/constants.js +14 -15
  32. package/dist/core/floating-point.d.ts +2 -0
  33. package/dist/core/floating-point.js +7 -0
  34. package/dist/core/formulation-seams.d.ts +27 -0
  35. package/dist/core/formulation-seams.js +289 -0
  36. package/dist/core/input-validation.d.ts +1 -0
  37. package/dist/core/input-validation.js +5 -0
  38. package/dist/core/region-detector.d.ts +3 -40
  39. package/dist/core/region-detector.js +37 -762
  40. package/dist/core/region3-pt.d.ts +1 -1
  41. package/dist/core/region3-pt.js +42 -16
  42. package/dist/core/solve-input-normalization.js +4 -1
  43. package/dist/core/solver.d.ts +7 -10
  44. package/dist/core/solver.js +26 -17
  45. package/dist/detect.d.ts +16 -2
  46. package/dist/detect.js +36 -2
  47. package/dist/errors.d.ts +11 -0
  48. package/dist/errors.js +16 -0
  49. package/dist/gas-constant.d.ts +5 -0
  50. package/dist/gas-constant.js +5 -0
  51. package/dist/index.d.ts +4 -5
  52. package/dist/index.js +10 -21
  53. package/dist/regions/region1.d.ts +2 -1
  54. package/dist/regions/region1.js +1 -1
  55. package/dist/regions/region2.d.ts +2 -1
  56. package/dist/regions/region2.js +1 -1
  57. package/dist/regions/region3-data.js +3 -3
  58. package/dist/regions/region3-subregions.js +15 -10
  59. package/dist/regions/region3.d.ts +4 -1
  60. package/dist/regions/region3.js +32 -2
  61. package/dist/regions/region4.d.ts +2 -0
  62. package/dist/regions/region4.js +39 -12
  63. package/dist/regions/region5.d.ts +2 -1
  64. package/dist/regions/region5.js +2 -2
  65. package/dist/saturation/common.d.ts +2 -8
  66. package/dist/saturation/common.js +64 -36
  67. package/dist/saturation/region4-boundaries.js +13 -13
  68. package/dist/saturation/region4-hs.js +282 -58
  69. package/dist/saturation/two-phase.d.ts +2 -2
  70. package/dist/saturation/two-phase.js +7 -7
  71. package/dist/solvers/bracketed-newton.d.ts +6 -3
  72. package/dist/solvers/bracketed-newton.js +15 -8
  73. package/dist/solvers/extended-arithmetic.d.ts +6 -0
  74. package/dist/solvers/extended-arithmetic.js +38 -0
  75. package/dist/transport/properties.d.ts +4 -2
  76. package/dist/transport/properties.js +14 -4
  77. package/dist/types.d.ts +18 -0
  78. package/dist/types.js +14 -0
  79. package/package.json +4 -4
  80. package/dist/core/public-normalization.d.ts +0 -3
  81. package/dist/core/public-normalization.js +0 -53
  82. /package/dist/{backward → core}/thermodynamic-derivatives.d.ts +0 -0
  83. /package/dist/{backward → core}/thermodynamic-derivatives.js +0 -0
@@ -7,18 +7,20 @@
7
7
  * Reference: IAPWS-IF97 Supplementary Release on Backward Equations
8
8
  */
9
9
  import { Region, IF97Error, OutOfRangeError } from '../types.js';
10
+ import { SaturationEndpointError } from '../errors.js';
10
11
  import * as C from '../constants.js';
11
12
  import { region1 } from '../regions/region1.js';
12
13
  import { region2 } from '../regions/region2.js';
13
- import { region3ByRhoT } from '../regions/region3.js';
14
+ import { region3ByRhoT, region3PressureResidual } from '../regions/region3.js';
14
15
  import { region5 } from '../regions/region5.js';
15
16
  import { saturationTemperature } from '../regions/region4.js';
16
- import { assertFiniteNumber } from '../core/input-validation.js';
17
- import { detectRegionPH } from '../core/region-detector.js';
17
+ import { assertFiniteNumber, withinClosedLowerBound } from '../core/input-validation.js';
18
+ import { assertPressurePropertyEnvelope, detectRegionPH, pressurePropertyBoundaryState, pressurePropertySeam } from './pressure-property-region.js';
18
19
  import { newtonRaphson } from '../solvers/newton-raphson.js';
20
+ import { bracketedNewton } from '../solvers/bracketed-newton.js';
19
21
  import { dampedNewton2D } from '../solvers/damped-newton-2d.js';
20
- import { validateBackwardState } from './solution-validation.js';
21
- import { helmholtzPropertyDerivatives } from './thermodynamic-derivatives.js';
22
+ import { validateBackwardState, validateRegion3PTReconstruction } from './solution-validation.js';
23
+ import { helmholtzPropertyDerivatives } from '../core/thermodynamic-derivatives.js';
22
24
  import { mixSaturationState, qualityFromSaturationProperty, saturationEndpointsAtPressure, } from '../saturation/common.js';
23
25
  import { assertCriticalRegion4PHInput, assertRegion4StateAllowed, } from '../saturation/region4-boundaries.js';
24
26
  // ─── Backward Polynomial Evaluator ─────────────────────────────────────────
@@ -60,7 +62,7 @@ const R2A_PH = [
60
62
  [2, 2, -0.31078046629583], [2, 7, 11.670873077107], [2, 36, 128127984.04046], [2, 38, -985549096.23276],
61
63
  [2, 40, 2822454697.3002], [2, 42, -3594897141.0703], [2, 44, 1722734991.3197], [3, 24, -13551.334240775],
62
64
  [3, 44, 12848734.66465], [4, 12, 1.3865724283226], [4, 32, 235988.32556514], [4, 44, -13105236.545054],
63
- [5, 32, 7399.9835474766], [5, 36, -551966.9703006], [5, 42, 3715408.5996233], [6, 34, 19127.7292396],
65
+ [5, 32, 7399.9835474766], [5, 36, -551966.9703006], [5, 42, 3715408.5996233], [6, 34, 19127.729239660],
64
66
  [6, 44, -415351.64835634], [7, 28, -62.459855192507],
65
67
  ];
66
68
  function r2aBackwardT(p, h) {
@@ -119,7 +121,7 @@ export function r2BackwardT(p, h) {
119
121
  }
120
122
  // ─── Region 3 Backward T(P,H) and v(P,H) ──────────────────────────────────
121
123
  /** B3ab boundary: P → H */
122
- function b3ab_P_to_H(p) {
124
+ export function b3ab_P_to_H(p) {
123
125
  return 2014.64004206875 + 3.74696550136983 * p - 0.0219921901054187 * p * p + 0.000087513168600995 * p * p * p;
124
126
  }
125
127
  const R3A_PH_T = [
@@ -183,15 +185,16 @@ export function r3BackwardPHInitial(p, h) {
183
185
  const v = 0.0088 * evalPoly(R3B_PH_V, -0.0661, 0.720, p / 100, h / 2800);
184
186
  return { rho: 1 / v, T };
185
187
  }
186
- function solveRegion3PH(p, h, rho0, T0) {
187
- const pScale = Math.max(1, Math.abs(p));
188
+ function solveRegion3PH(p, h, rho0, T0, refinePressure = false) {
189
+ // Scaling the 1e-13 solver tolerance by 1e-4 resolves pressure below one ULP before PT reconstruction.
190
+ const pScale = Math.max(1, Math.abs(p)) * (refinePressure ? 1e-4 : 1);
188
191
  const hScale = Math.max(1, Math.abs(h));
189
192
  const [rho, T] = dampedNewton2D(([candidateRho, candidateT]) => {
190
193
  const state = region3ByRhoT(candidateRho, candidateT);
191
194
  const derivatives = helmholtzPropertyDerivatives(state, candidateRho);
192
195
  return {
193
196
  residual: [
194
- (state.pressure - p) / pScale,
197
+ (refinePressure ? region3PressureResidual(candidateRho, candidateT, p) : state.pressure - p) / pScale,
195
198
  (state.enthalpy - h) / hScale,
196
199
  ],
197
200
  jacobian: [
@@ -200,6 +203,8 @@ function solveRegion3PH(p, h, rho0, T0) {
200
203
  ],
201
204
  };
202
205
  }, [rho0, T0], {
206
+ // Near critical pressure, the default residual can cross a stability boundary.
207
+ tolerance: 1e-13,
203
208
  isValid: ([candidateRho, candidateT]) => candidateRho > 0 && candidateT > 0,
204
209
  });
205
210
  return region3ByRhoT(rho, T);
@@ -216,11 +221,35 @@ export function solvePH(p, h) {
216
221
  if (p < C.P_MIN || p > C.P_MAX) {
217
222
  throw new OutOfRangeError('Pressure', p, C.P_MIN, C.P_MAX);
218
223
  }
219
- if (h < C.H_MIN || h > C.H_MAX) {
224
+ if (!withinClosedLowerBound(h, C.H_MIN, 'enthalpy') || h > C.H_MAX) {
220
225
  throw new OutOfRangeError('Enthalpy', h, C.H_MIN, C.H_MAX);
221
226
  }
222
227
  assertCriticalRegion4PHInput(p, h, 'solvePH');
223
- const region = detectRegionPH(p, h);
228
+ assertPressurePropertyEnvelope(p, h, 'enthalpy');
229
+ const seam = pressurePropertySeam(p, h, 'enthalpy');
230
+ if (seam !== null && seam.kind !== 'overlap') {
231
+ return validateBackwardState(seam.state, [
232
+ { label: 'pressure', expected: p },
233
+ { label: 'enthalpy', expected: h },
234
+ ], { solverName: 'solvePH', expectedRegion: seam.state.region, seam });
235
+ }
236
+ if (seam === null) {
237
+ const boundary = pressurePropertyBoundaryState(p, h, 'enthalpy');
238
+ if (boundary !== null)
239
+ return boundary;
240
+ }
241
+ let region;
242
+ let saturationFailure = null;
243
+ try {
244
+ region = seam?.state.region ?? detectRegionPH(p, h);
245
+ }
246
+ catch (error) {
247
+ if (!(error instanceof SaturationEndpointError)) {
248
+ throw error;
249
+ }
250
+ saturationFailure = error;
251
+ region = Region.Region3;
252
+ }
224
253
  switch (region) {
225
254
  case Region.Region1: {
226
255
  const T0 = r1BackwardT(p, h);
@@ -231,21 +260,42 @@ export function solvePH(p, h) {
231
260
  ], { solverName: 'solvePH', expectedRegion: Region.Region1 });
232
261
  }
233
262
  case Region.Region2: {
263
+ const targetH = Math.min(h, region2(p, C.R2_T_MAX).enthalpy);
234
264
  const T0 = r2BackwardT(p, h);
235
- const T = newtonRaphson((T_x) => region2(p, T_x).enthalpy - h, T0, (T_x) => region2(p, T_x).cp ?? Number.NaN);
265
+ const T = newtonRaphson((T_x) => region2(p, T_x).enthalpy - targetH, T0, (T_x) => region2(p, T_x).cp ?? Number.NaN);
236
266
  return validateBackwardState(region2(p, T), [
237
267
  { label: 'pressure', expected: p },
238
268
  { label: 'enthalpy', expected: h },
239
269
  ], { solverName: 'solvePH', expectedRegion: Region.Region2 });
240
270
  }
241
271
  case Region.Region3: {
242
- const pressureTolerance = 1e-5 * Math.max(1, Math.abs(p));
243
- const initial = r3BackwardPHInitial(p, h);
244
- const state = solveRegion3PH(p, h, initial.rho, initial.T);
245
- return validateBackwardState(state, [
246
- { label: 'pressure', expected: p, tolerance: pressureTolerance },
247
- { label: 'enthalpy', expected: h },
248
- ], { solverName: 'solvePH', expectedRegion: Region.Region3 });
272
+ try {
273
+ const initial = r3BackwardPHInitial(p, h);
274
+ const validate = (state) => validateBackwardState(state, [
275
+ { label: 'pressure', expected: p },
276
+ { label: 'enthalpy', expected: h },
277
+ ], { solverName: 'solvePH', expectedRegion: Region.Region3 });
278
+ const validated = validate(solveRegion3PH(p, h, initial.rho, initial.T));
279
+ // Compressibility can amplify an accepted pressure residual beyond PT's density accuracy.
280
+ const relativeDensityError = Math.abs(region3PressureResidual(1 / validated.specificVolume, validated.temperature, p)) * validated.isothermalCompressibility;
281
+ if (saturationFailure === null && relativeDensityError <= 1e-12)
282
+ return validated;
283
+ try {
284
+ const refined = validate(solveRegion3PH(p, h, 1 / validated.specificVolume, validated.temperature, true));
285
+ validateRegion3PTReconstruction(refined, 'solvePH');
286
+ return refined;
287
+ }
288
+ catch (error) {
289
+ if (!(error instanceof IF97Error))
290
+ throw error;
291
+ return saturationFailure === null ? validated : validateRegion3PTReconstruction(validated, 'solvePH');
292
+ }
293
+ }
294
+ catch (error) {
295
+ if (saturationFailure !== null && error instanceof IF97Error)
296
+ throw saturationFailure;
297
+ throw error;
298
+ }
249
299
  }
250
300
  case Region.Region4: {
251
301
  const endpoints = saturationEndpointsAtPressure(p);
@@ -257,7 +307,8 @@ export function solvePH(p, h) {
257
307
  ], { solverName: 'solvePH', expectedRegion: Region.Region4 });
258
308
  }
259
309
  case Region.Region5: {
260
- const T = newtonRaphson((T_x) => region5(p, T_x).enthalpy - h, 1500, (T_x) => region5(p, T_x).cp ?? Number.NaN);
310
+ const targetH = Math.min(h, region5(p, C.R5_T_MAX).enthalpy);
311
+ const T = bracketedNewton((T_x) => region5(p, T_x).enthalpy - targetH, C.R5_T_MIN + Number.EPSILON * C.R5_T_MIN, C.R5_T_MAX, 1500, { derivative: (T_x) => region5(p, T_x).cp ?? Number.NaN, tolerance: 1e-12 });
261
312
  return validateBackwardState(region5(p, T), [
262
313
  { label: 'pressure', expected: p },
263
314
  { label: 'enthalpy', expected: h },
@@ -0,0 +1,13 @@
1
+ import { Region } from '../types.js';
2
+ import type { BasicProperties } from '../types.js';
3
+ import { type FormulationSeamResolution } from '../core/formulation-seams.js';
4
+ /** Resolve the native saturation/B23 intersection before choosing one inverse region. */
5
+ export declare function pressurePropertyBoundaryState(p: number, target: number, property: 'enthalpy' | 'entropy'): BasicProperties | null;
6
+ /** Reject proven fixed-pressure envelope violations without classifying interior gaps. */
7
+ export declare function assertPressurePropertyEnvelope(p: number, target: number, property: 'enthalpy' | 'entropy'): void;
8
+ /** Share the fixed-pressure seam policy between detectors and backward solvers. */
9
+ export declare function pressurePropertySeam(p: number, target: number, property: 'enthalpy' | 'entropy'): FormulationSeamResolution | null;
10
+ /** Detect region for P-H inputs. */
11
+ export declare function detectRegionPH(p: number, h: number): Region | -1;
12
+ /** Detect region for P-S inputs. */
13
+ export declare function detectRegionPS(p: number, s: number): Region | -1;
@@ -0,0 +1,262 @@
1
+ import * as C from '../constants.js';
2
+ import { SaturationEndpointError } from '../errors.js';
3
+ import { region1 } from '../regions/region1.js';
4
+ import { region2 } from '../regions/region2.js';
5
+ import { region5 } from '../regions/region5.js';
6
+ import { boundary23_T_to_P } from '../regions/boundaries.js';
7
+ import { saturationPressure } from '../regions/region4.js';
8
+ import { saturationEndpointsAtPressure } from '../saturation/common.js';
9
+ import { OutOfRangeError, Region } from '../types.js';
10
+ import { detectRegionPT, nativeB23TemperatureBracket, nativeSaturationTemperatureBracket } from '../core/region-detector.js';
11
+ import { withinClosedLowerBound } from '../core/input-validation.js';
12
+ import { findPressureSeam } from '../core/formulation-seams.js';
13
+ import { nextPositiveFloat } from '../core/floating-point.js';
14
+ import { solveRegion3PTBasic } from '../core/region3-pt.js';
15
+ import { bracketedNewton } from '../solvers/bracketed-newton.js';
16
+ import { resolveBackwardCandidates } from './candidate-resolution.js';
17
+ import { BACKWARD_PRESSURE_PROPERTY_TEMPERATURE_TOLERANCE } from './tolerances.js';
18
+ const P13 = saturationPressure(C.R2_T_MIN);
19
+ const B23_P_AT_T13 = boundary23_T_to_P(C.R2_T_MIN);
20
+ /** Resolve the native saturation/B23 intersection before choosing one inverse region. */
21
+ export function pressurePropertyBoundaryState(p, target, property) {
22
+ if (p < C.B23_P_MIN || p > B23_P_AT_T13)
23
+ return null;
24
+ const references = [region1(p, C.R2_T_MIN), region2(p, C.R2_T_MIN)];
25
+ if (!references.some((state) => Math.abs(state[property] - target) <=
26
+ (property === 'enthalpy' ? 0.2 : 0.0002)))
27
+ return null;
28
+ const endpoints = saturationEndpointsAtPressure(p);
29
+ if (endpoints.liquid[property] <= target && target <= endpoints.vapor[property])
30
+ return null;
31
+ const tolerance = 128 * Number.EPSILON * Math.max(1, Math.abs(target));
32
+ const temperatures = new Set([
33
+ C.R2_T_MIN,
34
+ nextPositiveFloat(C.R2_T_MIN, -1),
35
+ nextPositiveFloat(C.R2_T_MIN, 1),
36
+ ...nativeSaturationTemperatureBracket(p),
37
+ ]);
38
+ const stateAt = (T) => {
39
+ const region = detectRegionPT(p, T);
40
+ return region === Region.Region1 ? region1(p, T)
41
+ : region === Region.Region2 ? region2(p, T)
42
+ : region === Region.Region3 ? solveRegion3PTBasic(p, T) : null;
43
+ };
44
+ return resolveBackwardCandidates([...temperatures].map((T) => () => {
45
+ const state = stateAt(T);
46
+ if (state === null)
47
+ return null;
48
+ if (Math.abs(state[property] - target) <= tolerance)
49
+ return state;
50
+ const slope = property === 'enthalpy' ? state.cp : state.cp / T;
51
+ const temperature = T + (target - state[property]) / slope;
52
+ if (detectRegionPT(p, temperature) !== state.region)
53
+ return null;
54
+ const corrected = stateAt(temperature);
55
+ return corrected !== null && Math.abs(corrected[property] - target) <= tolerance ? corrected : null;
56
+ }), [
57
+ { label: 'pressure', expected: p, tolerance: 0 },
58
+ { label: property, expected: target, tolerance },
59
+ ], { solverName: property === 'enthalpy' ? 'solvePH' : 'solvePS' });
60
+ }
61
+ /** Reject proven fixed-pressure envelope violations without classifying interior gaps. */
62
+ export function assertPressurePropertyEnvelope(p, target, property) {
63
+ const minimum = region1(p, C.T_MIN)[property];
64
+ const maximum = (p <= C.R5_P_MAX ? region5(p, C.R5_T_MAX) : region2(p, C.R2_T_MAX))[property];
65
+ const aboveMinimum = property === 'enthalpy'
66
+ ? target >= minimum || isClose(target, minimum)
67
+ : withinClosedLowerBound(target, minimum, 'entropy');
68
+ if (!aboveMinimum || !withinClosedUpperBound(target, maximum)) {
69
+ throw new OutOfRangeError(`${property === 'enthalpy' ? 'Enthalpy' : 'Entropy'} at p=${p} MPa`, target, minimum, maximum);
70
+ }
71
+ }
72
+ /** Share the fixed-pressure seam policy between detectors and backward solvers. */
73
+ export function pressurePropertySeam(p, target, property) {
74
+ const match = findPressureSeam(p, target, property);
75
+ if (match === null)
76
+ return null;
77
+ if (p >= C.Pt && p < C.Pc) {
78
+ try {
79
+ const endpoints = saturationEndpointsAtPressure(p);
80
+ if (target >= endpoints.liquid[property] && target <= endpoints.vapor[property])
81
+ return null;
82
+ }
83
+ catch (error) {
84
+ if (!(error instanceof SaturationEndpointError))
85
+ throw error;
86
+ }
87
+ }
88
+ const solverName = property === 'enthalpy' ? 'solvePH' : 'solvePS';
89
+ const constraints = [{ label: 'pressure', expected: p }, { label: property, expected: target }];
90
+ const state = resolveBackwardCandidates([
91
+ () => match.state[property] === target ? match.state : null,
92
+ () => pressureSeamRoot(p, target, property, match, match.pair.lower.region),
93
+ () => pressureSeamRoot(p, target, property, match, match.pair.upper.region),
94
+ ], constraints, { solverName, seam: match });
95
+ const resolved = state ?? (match.kind === 'canonical'
96
+ ? resolveBackwardCandidates([() => match.state], constraints, { solverName }) : null);
97
+ return resolved === null ? null : resolved === match.state ? match : { ...match, kind: 'canonical', state: resolved };
98
+ }
99
+ function pressureSeamRoot(p, target, property, seam, region) {
100
+ let lower;
101
+ let upper;
102
+ let evaluate;
103
+ if (region === Region.Region1) {
104
+ lower = C.T_MIN;
105
+ upper = C.R2_T_MIN;
106
+ evaluate = (T) => region1(p, T);
107
+ }
108
+ else if (region === Region.Region2) {
109
+ lower = p > C.B23_P_MIN ? nativeB23TemperatureBracket(p)[1] : C.T_MIN;
110
+ if (p < C.Pc)
111
+ lower = Math.max(lower, nativeSaturationTemperatureBracket(p)[1]);
112
+ upper = C.R2_T_MAX;
113
+ evaluate = (T) => region2(p, T);
114
+ }
115
+ else if (region === Region.Region3) {
116
+ lower = nextPositiveFloat(C.R2_T_MIN, 1);
117
+ upper = nativeB23TemperatureBracket(p)[0];
118
+ if (p < C.Pc) {
119
+ const [liquidT, vaporT] = nativeSaturationTemperatureBracket(p);
120
+ if (seam.pair.seam === '13')
121
+ upper = Math.min(upper, liquidT);
122
+ else
123
+ lower = Math.max(lower, vaporT);
124
+ }
125
+ evaluate = (T) => solveRegion3PTBasic(p, T);
126
+ }
127
+ else if (region === Region.Region5) {
128
+ lower = nextPositiveFloat(C.R5_T_MIN, 1);
129
+ upper = C.R5_T_MAX;
130
+ evaluate = (T) => region5(p, T);
131
+ }
132
+ else {
133
+ return null;
134
+ }
135
+ if (lower > upper)
136
+ return null;
137
+ const direction = region === seam.pair.lower.region ? -1 : 1;
138
+ const boundary = direction < 0 ? upper : lower;
139
+ const first = evaluate(boundary);
140
+ if (direction * (target - first[property]) < 0)
141
+ return null;
142
+ if (target === first[property])
143
+ return first;
144
+ if (lower === upper)
145
+ return null;
146
+ const slope = property === 'enthalpy' ? first.cp : first.cp / boundary;
147
+ let distance = Math.abs(target - first[property]) / slope;
148
+ let bracket;
149
+ while (true) {
150
+ bracket = Math.max(lower, Math.min(upper, boundary + direction * distance));
151
+ if (bracket === boundary)
152
+ bracket = nextPositiveFloat(boundary, direction);
153
+ const last = evaluate(bracket);
154
+ if (direction * (last[property] - target) >= 0)
155
+ break;
156
+ if (bracket === lower || bracket === upper)
157
+ return null;
158
+ distance *= 2;
159
+ }
160
+ lower = Math.min(boundary, bracket);
161
+ upper = Math.max(boundary, bracket);
162
+ const temperature = bracketedNewton((T) => evaluate(T)[property] - target, lower, upper, undefined, {
163
+ derivative: (T) => {
164
+ const state = evaluate(T);
165
+ return property === 'enthalpy' ? state.cp : state.cp / T;
166
+ },
167
+ tolerance: BACKWARD_PRESSURE_PROPERTY_TEMPERATURE_TOLERANCE,
168
+ residualTolerance: 0,
169
+ });
170
+ return evaluate(temperature);
171
+ }
172
+ /** Detect region for P-H inputs. */
173
+ export function detectRegionPH(p, h) {
174
+ return detectRegionPressureProperty(p, h, 'enthalpy');
175
+ }
176
+ /** Detect region for P-S inputs. */
177
+ export function detectRegionPS(p, s) {
178
+ return detectRegionPressureProperty(p, s, 'entropy');
179
+ }
180
+ function detectRegionPressureProperty(p, target, property) {
181
+ if (!Number.isFinite(p) || !Number.isFinite(target))
182
+ return -1;
183
+ const seam = pressurePropertySeam(p, target, property);
184
+ if (seam !== null)
185
+ return seam.state.region;
186
+ const boundary = pressurePropertyBoundaryState(p, target, property);
187
+ if (boundary !== null)
188
+ return boundary.region;
189
+ // Preserve PH's existing lower-bound tolerance; PS absorbs EOS rounding noise.
190
+ const aboveMinimum = (minimum) => property === 'enthalpy'
191
+ ? minimum <= target || isClose(target, minimum)
192
+ : withinClosedLowerBound(target, minimum, 'entropy');
193
+ if (p >= C.P_MIN && p <= P13) {
194
+ const endpoints = p >= C.Pt ? saturationEndpointsAtPressure(p) : null;
195
+ if (endpoints !== null && endpoints.liquid[property] <= target && target <= endpoints.vapor[property]) {
196
+ return Region.Region4;
197
+ }
198
+ const [liquidT, vaporT] = endpoints === null ? nativeSaturationTemperatureBracket(p)
199
+ : [endpoints.temperature, endpoints.temperature];
200
+ const lower = region1(p, C.T_MIN)[property];
201
+ const boundary14 = endpoints?.liquid[property] ?? region1(p, liquidT)[property];
202
+ const boundary24 = endpoints?.vapor[property] ?? region2(p, vaporT)[property];
203
+ const boundary25 = region2(p, C.R2_T_MAX)[property];
204
+ if (aboveMinimum(lower) && target <= boundary14)
205
+ return Region.Region1;
206
+ if (withinClosedLowerBound(target, boundary24, property) && withinClosedUpperBound(target, boundary25))
207
+ return Region.Region2;
208
+ if (p <= C.R5_P_MAX && region5(p, C.R5_T_MIN)[property] < target &&
209
+ withinClosedUpperBound(target, region5(p, C.R5_T_MAX)[property]))
210
+ return Region.Region5;
211
+ }
212
+ else if (p > P13 && p <= C.P_MAX) {
213
+ const lower = region1(p, C.T_MIN)[property];
214
+ const boundary13 = region1(p, C.R2_T_MIN)[property];
215
+ const [, region2T] = nativeB23TemperatureBracket(p);
216
+ const boundary32 = region2(p, region2T)[property];
217
+ const boundary25 = region2(p, C.R2_T_MAX)[property];
218
+ if (p < C.Pc) {
219
+ try {
220
+ const endpoints = saturationEndpointsAtPressure(p);
221
+ if (endpoints.liquid[property] <= target && target <= endpoints.vapor[property]) {
222
+ return Region.Region4;
223
+ }
224
+ }
225
+ catch (error) {
226
+ if (!(error instanceof SaturationEndpointError) ||
227
+ (target > boundary13 && target < boundary32))
228
+ throw error;
229
+ }
230
+ }
231
+ if (aboveMinimum(lower) && target <= boundary13)
232
+ return Region.Region1;
233
+ if (withinClosedLowerBound(target, boundary32, property) && withinClosedUpperBound(target, boundary25))
234
+ return Region.Region2;
235
+ if (p <= C.R5_P_MAX && region5(p, C.R5_T_MIN)[property] < target &&
236
+ withinClosedUpperBound(target, region5(p, C.R5_T_MAX)[property]))
237
+ return Region.Region5;
238
+ if (p < C.Pc) {
239
+ const endpoints = saturationEndpointsAtPressure(p);
240
+ const liquid = endpoints.liquid[property];
241
+ const vapor = endpoints.vapor[property];
242
+ if (boundary13 < target && target < liquid)
243
+ return Region.Region3;
244
+ if (liquid < target && target < vapor)
245
+ return Region.Region4;
246
+ if (vapor < target && target < boundary32)
247
+ return Region.Region3;
248
+ }
249
+ else if (boundary13 < target && target < boundary32) {
250
+ return Region.Region3;
251
+ }
252
+ }
253
+ return -1;
254
+ }
255
+ // Accept floating-point residue only at closed upper bounds.
256
+ function withinClosedUpperBound(value, boundary) {
257
+ return value <= boundary + 8 * Number.EPSILON * Math.max(1, Math.abs(boundary));
258
+ }
259
+ /** Check approximate equality with relative tolerance. */
260
+ function isClose(a, b) {
261
+ return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
262
+ }
@@ -4,6 +4,12 @@
4
4
  * Reference: IAPWS-IF97 Supplementary Release on Backward Equations
5
5
  */
6
6
  import type { BasicProperties } from '../types.js';
7
+ export declare function r1BackwardT(p: number, s: number): number;
8
+ export declare function r2BackwardT(p: number, s: number): number;
9
+ export declare function r3BackwardPSInitial(p: number, s: number): {
10
+ rho: number;
11
+ T: number;
12
+ };
7
13
  /**
8
14
  * Solve for thermodynamic state given P and S.
9
15
  * @param p - Pressure [MPa]
@@ -4,17 +4,19 @@
4
4
  * Reference: IAPWS-IF97 Supplementary Release on Backward Equations
5
5
  */
6
6
  import { Region, IF97Error, OutOfRangeError } from '../types.js';
7
+ import { SaturationEndpointError } from '../errors.js';
7
8
  import * as C from '../constants.js';
8
9
  import { region1 } from '../regions/region1.js';
9
10
  import { region2 } from '../regions/region2.js';
10
- import { region3ByRhoT } from '../regions/region3.js';
11
+ import { region3ByRhoT, region3PressureResidual } from '../regions/region3.js';
11
12
  import { region5 } from '../regions/region5.js';
12
- import { assertFiniteNumber } from '../core/input-validation.js';
13
- import { detectRegionPS } from '../core/region-detector.js';
13
+ import { assertFiniteNumber, withinClosedLowerBound } from '../core/input-validation.js';
14
+ import { assertPressurePropertyEnvelope, detectRegionPS, pressurePropertyBoundaryState, pressurePropertySeam } from './pressure-property-region.js';
14
15
  import { newtonRaphson } from '../solvers/newton-raphson.js';
16
+ import { bracketedNewton } from '../solvers/bracketed-newton.js';
15
17
  import { dampedNewton2D } from '../solvers/damped-newton-2d.js';
16
- import { validateBackwardState } from './solution-validation.js';
17
- import { helmholtzPropertyDerivatives } from './thermodynamic-derivatives.js';
18
+ import { validateBackwardState, validateRegion3PTReconstruction } from './solution-validation.js';
19
+ import { helmholtzPropertyDerivatives } from '../core/thermodynamic-derivatives.js';
18
20
  import { mixSaturationState, qualityFromSaturationProperty, saturationEndpointsAtPressure, } from '../saturation/common.js';
19
21
  import { assertCriticalRegion4PSInput, assertRegion4StateAllowed, } from '../saturation/region4-boundaries.js';
20
22
  function evalPoly(table, piShift, sigShift, pi, sig) {
@@ -33,7 +35,7 @@ const R1_PS = [
33
35
  [2, 31, -4.2522657042207e-26], [3, 10, 2.6400441360689e-13], [3, 32, 7.8124600459723e-29],
34
36
  [4, 32, -3.0732199903668e-31],
35
37
  ];
36
- function r1BackwardT(p, s) {
38
+ export function r1BackwardT(p, s) {
37
39
  return evalPoly(R1_PS, 0, -2, p, s);
38
40
  }
39
41
  // ─── Region 2 Backward T(P,S) — Subregions 2a/2b/2c ───────────────────────
@@ -41,7 +43,7 @@ const R2A_PS = [
41
43
  [-1.5, -24, -392359.83861984], [-1.5, -23, 515265.7382727], [-1.5, -19, 40482.443161048],
42
44
  [-1.5, -13, -321.93790923902], [-1.5, -11, 96.961424218694], [-1.5, -10, -22.867846371773],
43
45
  [-1.25, -19, -449429.14124357], [-1.25, -15, -5011.8336020166], [-1.25, -6, 0.35684463560015],
44
- [-1, -26, 44235.33584819], [-1, -21, -13673.388811708], [-1, -17, 421632.6027864],
46
+ [-1, -26, 44235.33584819], [-1, -21, -13673.388811708], [-1, -17, 421632.60207864],
45
47
  [-1, -16, 22516.925837475], [-1, -9, 474.42144865646], [-1, -8, -149.31130797647],
46
48
  [-0.75, -15, -197811.26320452], [-0.75, -14, -23554.39947076], [-0.5, -26, -19070.616302076],
47
49
  [-0.5, -13, 55375.669883164], [-0.5, -9, 3829.3691437363], [-0.5, -7, -603.91860580567],
@@ -84,7 +86,7 @@ const R2C_PS = [
84
86
  [6, 1, 1.0136618529763e-8], [7, 0, 5.9925719692351e-12], [7, 1, -2.0677870105164e-11],
85
87
  [7, 3, -2.0874278181886e-11], [7, 4, 1.0162166825089e-10], [7, 5, -1.6429828281347e-10],
86
88
  ];
87
- function r2BackwardT(p, s) {
89
+ export function r2BackwardT(p, s) {
88
90
  if (p <= C.R2_P_CRT) {
89
91
  return evalPoly(R2A_PS, 0, 2, p, s / 2);
90
92
  }
@@ -144,15 +146,26 @@ const R3B_PS_V_TABLE = [
144
146
  [0, 2, -19.1449143716586], [1, 0, -0.0150448002905284], [1, 2, 14.6407900162154],
145
147
  [2, 2, -3.2747778718823],
146
148
  ];
147
- function solveRegion3PS(p, s, rho0, T0) {
148
- const pScale = Math.max(1, Math.abs(p));
149
+ export function r3BackwardPSInitial(p, s) {
150
+ if (s <= C.R3_S_CRT) {
151
+ const T = 760 * evalPoly(R3A_PS_T_TABLE, -0.240, 0.703, p / 100, s / 4.4);
152
+ const v = 0.0028 * evalPoly(R3A_PS_V_TABLE, -0.187, 0.755, p / 100, s / 4.4);
153
+ return { rho: 1 / v, T };
154
+ }
155
+ const T = 860 * evalPoly(R3B_PS_T_TABLE, -0.760, 0.818, p / 100, s / 5.3);
156
+ const v = 0.0088 * evalPoly(R3B_PS_V_TABLE, -0.298, 0.816, p / 100, s / 5.3);
157
+ return { rho: 1 / v, T };
158
+ }
159
+ function solveRegion3PS(p, s, rho0, T0, refinePressure = false) {
160
+ // Scaling the 1e-13 solver tolerance by 1e-4 resolves pressure below one ULP before PT reconstruction.
161
+ const pScale = Math.max(1, Math.abs(p)) * (refinePressure ? 1e-4 : 1);
149
162
  const sScale = Math.max(1, Math.abs(s));
150
163
  const [rho, T] = dampedNewton2D(([candidateRho, candidateT]) => {
151
164
  const state = region3ByRhoT(candidateRho, candidateT);
152
165
  const derivatives = helmholtzPropertyDerivatives(state, candidateRho);
153
166
  return {
154
167
  residual: [
155
- (state.pressure - p) / pScale,
168
+ (refinePressure ? region3PressureResidual(candidateRho, candidateT, p) : state.pressure - p) / pScale,
156
169
  (state.entropy - s) / sScale,
157
170
  ],
158
171
  jacobian: [
@@ -161,6 +174,8 @@ function solveRegion3PS(p, s, rho0, T0) {
161
174
  ],
162
175
  };
163
176
  }, [rho0, T0], {
177
+ // Near saturation, the default residual can put the liquid root across Tsat.
178
+ tolerance: 1e-13,
164
179
  isValid: ([candidateRho, candidateT]) => candidateRho > 0 && candidateT > 0,
165
180
  });
166
181
  return region3ByRhoT(rho, T);
@@ -177,11 +192,35 @@ export function solvePS(p, s) {
177
192
  if (p < C.P_MIN || p > C.P_MAX) {
178
193
  throw new OutOfRangeError('Pressure', p, C.P_MIN, C.P_MAX);
179
194
  }
180
- if (s < C.S_MIN || s > C.S_MAX) {
195
+ if (!withinClosedLowerBound(s, C.S_MIN, 'entropy') || s > C.S_MAX) {
181
196
  throw new OutOfRangeError('Entropy', s, C.S_MIN, C.S_MAX);
182
197
  }
183
198
  assertCriticalRegion4PSInput(p, s, 'solvePS');
184
- const region = detectRegionPS(p, s);
199
+ assertPressurePropertyEnvelope(p, s, 'entropy');
200
+ const seam = pressurePropertySeam(p, s, 'entropy');
201
+ if (seam !== null && seam.kind !== 'overlap') {
202
+ return validateBackwardState(seam.state, [
203
+ { label: 'pressure', expected: p },
204
+ { label: 'entropy', expected: s },
205
+ ], { solverName: 'solvePS', expectedRegion: seam.state.region, seam });
206
+ }
207
+ if (seam === null) {
208
+ const boundary = pressurePropertyBoundaryState(p, s, 'entropy');
209
+ if (boundary !== null)
210
+ return boundary;
211
+ }
212
+ let region;
213
+ let saturationFailure = null;
214
+ try {
215
+ region = seam?.state.region ?? detectRegionPS(p, s);
216
+ }
217
+ catch (error) {
218
+ if (!(error instanceof SaturationEndpointError)) {
219
+ throw error;
220
+ }
221
+ saturationFailure = error;
222
+ region = Region.Region3;
223
+ }
185
224
  switch (region) {
186
225
  case Region.Region1: {
187
226
  const T0 = r1BackwardT(p, s);
@@ -192,29 +231,42 @@ export function solvePS(p, s) {
192
231
  ], { solverName: 'solvePS', expectedRegion: Region.Region1 });
193
232
  }
194
233
  case Region.Region2: {
234
+ const targetS = Math.min(s, region2(p, C.R2_T_MAX).entropy);
195
235
  const T0 = r2BackwardT(p, s);
196
- const T = newtonRaphson((T_x) => region2(p, T_x).entropy - s, T0, (T_x) => (region2(p, T_x).cp ?? Number.NaN) / T_x);
236
+ const T = newtonRaphson((T_x) => region2(p, T_x).entropy - targetS, T0, (T_x) => (region2(p, T_x).cp ?? Number.NaN) / T_x);
197
237
  return validateBackwardState(region2(p, T), [
198
238
  { label: 'pressure', expected: p },
199
239
  { label: 'entropy', expected: s },
200
240
  ], { solverName: 'solvePS', expectedRegion: Region.Region2 });
201
241
  }
202
242
  case Region.Region3: {
203
- const pressureTolerance = 1e-5 * Math.max(1, Math.abs(p));
204
- let T0, v0;
205
- if (s <= C.R3_S_CRT) {
206
- T0 = 760 * evalPoly(R3A_PS_T_TABLE, -0.240, 0.703, p / 100, s / 4.4);
207
- v0 = 0.0028 * evalPoly(R3A_PS_V_TABLE, -0.187, 0.755, p / 100, s / 4.4);
243
+ try {
244
+ const initial = r3BackwardPSInitial(p, s);
245
+ const validate = (state) => validateBackwardState(state, [
246
+ { label: 'pressure', expected: p },
247
+ { label: 'entropy', expected: s },
248
+ ], { solverName: 'solvePS', expectedRegion: Region.Region3 });
249
+ const validated = validate(solveRegion3PS(p, s, initial.rho, initial.T));
250
+ // Compressibility can amplify an accepted pressure residual beyond PT's density accuracy.
251
+ const relativeDensityError = Math.abs(region3PressureResidual(1 / validated.specificVolume, validated.temperature, p)) * validated.isothermalCompressibility;
252
+ if (saturationFailure === null && relativeDensityError <= 1e-12)
253
+ return validated;
254
+ try {
255
+ const refined = validate(solveRegion3PS(p, s, 1 / validated.specificVolume, validated.temperature, true));
256
+ validateRegion3PTReconstruction(refined, 'solvePS');
257
+ return refined;
258
+ }
259
+ catch (error) {
260
+ if (!(error instanceof IF97Error))
261
+ throw error;
262
+ return saturationFailure === null ? validated : validateRegion3PTReconstruction(validated, 'solvePS');
263
+ }
208
264
  }
209
- else {
210
- T0 = 860 * evalPoly(R3B_PS_T_TABLE, -0.760, 0.818, p / 100, s / 5.3);
211
- v0 = 0.0088 * evalPoly(R3B_PS_V_TABLE, -0.298, 0.816, p / 100, s / 5.3);
265
+ catch (error) {
266
+ if (saturationFailure !== null && error instanceof IF97Error)
267
+ throw saturationFailure;
268
+ throw error;
212
269
  }
213
- const state = solveRegion3PS(p, s, 1 / v0, T0);
214
- return validateBackwardState(state, [
215
- { label: 'pressure', expected: p, tolerance: pressureTolerance },
216
- { label: 'entropy', expected: s },
217
- ], { solverName: 'solvePS', expectedRegion: Region.Region3 });
218
270
  }
219
271
  case Region.Region4: {
220
272
  const endpoints = saturationEndpointsAtPressure(p);
@@ -226,7 +278,8 @@ export function solvePS(p, s) {
226
278
  ], { solverName: 'solvePS', expectedRegion: Region.Region4 });
227
279
  }
228
280
  case Region.Region5: {
229
- const T = newtonRaphson((T_x) => region5(p, T_x).entropy - s, 1500, (T_x) => (region5(p, T_x).cp ?? Number.NaN) / T_x);
281
+ const targetS = Math.min(s, region5(p, C.R5_T_MAX).entropy);
282
+ const T = bracketedNewton((T_x) => region5(p, T_x).entropy - targetS, C.R5_T_MIN + Number.EPSILON * C.R5_T_MIN, C.R5_T_MAX, 1500, { derivative: (T_x) => (region5(p, T_x).cp ?? Number.NaN) / T_x, tolerance: 1e-12 });
230
283
  return validateBackwardState(region5(p, T), [
231
284
  { label: 'pressure', expected: p },
232
285
  { label: 'entropy', expected: s },