pyelogp 0.1.1__tar.gz

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.
pyelogp-0.1.1/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, DongDong
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
pyelogp-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyelogp
3
+ Version: 0.1.1
4
+ Summary: A lightweight Python library to estimate preconsolidation pressure using volumetric strain energy method with knee detection in the e-log(P) space.
5
+ Project-URL: Homepage, https://github.com/liangchow/pyelogp
6
+ Project-URL: Issues, https://github.com/liangchow/pyelogp/issues
7
+ Author-email: Liang Chern Chow <liangchern@gmail.com>, Thierno Kane <tkane83@gmail.com>
8
+ License: BSD-3-Clause
9
+ License-File: LICENSE
10
+ Keywords: consolidation,geotechnical,settlement
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: BSD License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: kneed>=0.8.0
21
+ Requires-Dist: matplotlib>=3.10
22
+ Requires-Dist: numpy>=1.21.0
23
+ Requires-Dist: scipy>=1.7.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # PyelogP
27
+
28
+ "Py e-log(P)" is a lightweight Python library for estimating the preconsolidation pressure (*P'c*) of oedometer test data using the strain-energy method with knee-point detection and thresholding in the e-log(P) space.
29
+
30
+ ![San Francisco Bay Mud (Bonaparte & Mitchell, 1976)](https://raw.githubusercontent.com/liangchow/PyelogP/main/images/sfbaymud.png)<br/>
31
+ ![Wallaceburg clay (Becker et al., 1987)](https://raw.githubusercontent.com/liangchow/PyelogP/main/images/wallaceburgclay.png)<br/>
32
+ ![Louiseville clay (TPM, 1996)](https://raw.githubusercontent.com/liangchow/PyelogP/main/images/louisevilleclay.png)
33
+
34
+ ## Features
35
+
36
+ - Manual data input or import from a `.csv` file.
37
+ - Automatic removal of unload–reload cycles from input data.
38
+ - (*Beta*) Threshold-based fitting range selection for S-shaped curves.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install pyelogp
44
+ ```
45
+
46
+ ### Clone from GitHub
47
+
48
+ ```bash
49
+ git clone https://github.com/liangchow/pyelogp.git
50
+ cd pyelogp
51
+ python -m venv .venv # Recommended to use venv
52
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
53
+ pip install -e .
54
+ ```
55
+
56
+ ## Quick start
57
+
58
+ See `run.py` for a full example, including loading data from a CSV file via `Data.from_csv`.
59
+
60
+ ```python
61
+ from pyelogp import Data
62
+
63
+ # Louiseville clay
64
+ pressure = [59, 90, 120, 150, 165, 172, 184, 222, 300, 400]
65
+ void_ratio = [2.115, 2.113, 2.098, 2.083, 2.055, 2, 1.8, 1.5, 1.3, 1.193]
66
+ e0 = 1.21 (Optional)
67
+
68
+ d = Data(pressure=pressure, void_ratio=void_ratio, e0=e0)
69
+
70
+ # Run analysis
71
+ result = d.find_pc()
72
+ print(f"Pc = {result.pc:.1f}")
73
+ print(f"e @ Pc = {result.e_pc:.4f}")
74
+ print(f"R²(seg1) = {result.r2_seg1:.4f}")
75
+ print(f"R²(seg2) = {result.r2_seg2:.4f}")
76
+
77
+ # Return
78
+ Preconsolidation pressure (Pc): 164.49
79
+ Void ratio at pc (e_pc): 2.0573
80
+ R² segment 1: 0.893768
81
+ R² segment 2: 0.96937
82
+ ```
83
+
84
+ ## Contributing
85
+
86
+ Contributions are welcome, please refer to [CONTRIBUTING](https://github.com/liangchow/pyelogp/blob/main/CONTRIBUTING.md)
87
+ to learn more about how to contribute.
88
+
89
+ ## References
90
+
91
+ Becker, D. E., Crooks, J. H. A., Been, K., & Jefferies, M. G. (1987).
92
+ *Work as a Criterion for Determining in situ and Yield Stresses in Clays.*
93
+ Canadian Geotechnical Journal, 24(4), 549–564.
94
+
95
+ Bonaparte, R. and Mitchell, J.K. (1979).
96
+ *The Properties of San Francisco Bay Mud at Hamilton Air Force Base, California.*
97
+ Geotechnical Engineering Report, University of California, Berkeley, April 1979.
98
+
99
+ Satopa, V., Albrecht, J., Irwin, D., and Raghavan, B. (2011).
100
+ *Finding a 'Kneedle' in a Haystack: Detecting Knee Points in System Behavior.*
101
+ 31st International Conference on Distributed Computing Systems Workshops, 166-171.
102
+
103
+ Terzaghi, K., Peck, R.B., & Mesri, G. (1996).
104
+ *Soil Mechanics in Engineering Practice (3rd ed.).*
105
+ John Wiley & Sons, Inc.
@@ -0,0 +1,80 @@
1
+ # PyelogP
2
+
3
+ "Py e-log(P)" is a lightweight Python library for estimating the preconsolidation pressure (*P'c*) of oedometer test data using the strain-energy method with knee-point detection and thresholding in the e-log(P) space.
4
+
5
+ ![San Francisco Bay Mud (Bonaparte & Mitchell, 1976)](https://raw.githubusercontent.com/liangchow/PyelogP/main/images/sfbaymud.png)<br/>
6
+ ![Wallaceburg clay (Becker et al., 1987)](https://raw.githubusercontent.com/liangchow/PyelogP/main/images/wallaceburgclay.png)<br/>
7
+ ![Louiseville clay (TPM, 1996)](https://raw.githubusercontent.com/liangchow/PyelogP/main/images/louisevilleclay.png)
8
+
9
+ ## Features
10
+
11
+ - Manual data input or import from a `.csv` file.
12
+ - Automatic removal of unload–reload cycles from input data.
13
+ - (*Beta*) Threshold-based fitting range selection for S-shaped curves.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install pyelogp
19
+ ```
20
+
21
+ ### Clone from GitHub
22
+
23
+ ```bash
24
+ git clone https://github.com/liangchow/pyelogp.git
25
+ cd pyelogp
26
+ python -m venv .venv # Recommended to use venv
27
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
28
+ pip install -e .
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ See `run.py` for a full example, including loading data from a CSV file via `Data.from_csv`.
34
+
35
+ ```python
36
+ from pyelogp import Data
37
+
38
+ # Louiseville clay
39
+ pressure = [59, 90, 120, 150, 165, 172, 184, 222, 300, 400]
40
+ void_ratio = [2.115, 2.113, 2.098, 2.083, 2.055, 2, 1.8, 1.5, 1.3, 1.193]
41
+ e0 = 1.21 (Optional)
42
+
43
+ d = Data(pressure=pressure, void_ratio=void_ratio, e0=e0)
44
+
45
+ # Run analysis
46
+ result = d.find_pc()
47
+ print(f"Pc = {result.pc:.1f}")
48
+ print(f"e @ Pc = {result.e_pc:.4f}")
49
+ print(f"R²(seg1) = {result.r2_seg1:.4f}")
50
+ print(f"R²(seg2) = {result.r2_seg2:.4f}")
51
+
52
+ # Return
53
+ Preconsolidation pressure (Pc): 164.49
54
+ Void ratio at pc (e_pc): 2.0573
55
+ R² segment 1: 0.893768
56
+ R² segment 2: 0.96937
57
+ ```
58
+
59
+ ## Contributing
60
+
61
+ Contributions are welcome, please refer to [CONTRIBUTING](https://github.com/liangchow/pyelogp/blob/main/CONTRIBUTING.md)
62
+ to learn more about how to contribute.
63
+
64
+ ## References
65
+
66
+ Becker, D. E., Crooks, J. H. A., Been, K., & Jefferies, M. G. (1987).
67
+ *Work as a Criterion for Determining in situ and Yield Stresses in Clays.*
68
+ Canadian Geotechnical Journal, 24(4), 549–564.
69
+
70
+ Bonaparte, R. and Mitchell, J.K. (1979).
71
+ *The Properties of San Francisco Bay Mud at Hamilton Air Force Base, California.*
72
+ Geotechnical Engineering Report, University of California, Berkeley, April 1979.
73
+
74
+ Satopa, V., Albrecht, J., Irwin, D., and Raghavan, B. (2011).
75
+ *Finding a 'Kneedle' in a Haystack: Detecting Knee Points in System Behavior.*
76
+ 31st International Conference on Distributed Computing Systems Workshops, 166-171.
77
+
78
+ Terzaghi, K., Peck, R.B., & Mesri, G. (1996).
79
+ *Soil Mechanics in Engineering Practice (3rd ed.).*
80
+ John Wiley & Sons, Inc.
@@ -0,0 +1,14 @@
1
+ # Below is an example data of Wallaceburg Clay (Becker et al., 1987) with a "," delimeter.
2
+ # e0 = 1.24
3
+ # pressure, void ratio
4
+ 10.0, 1.212
5
+ 24.6, 1.180
6
+ 48.5, 1.148
7
+ 97.2, 1.098
8
+ 189.2, 1.005
9
+ 382.3, 0.871
10
+ 755.8, 0.756
11
+ 1493.6, 0.647
12
+ 386.0, 0.687
13
+ 96.4, 0.743
14
+ 10.0, 0.849
@@ -0,0 +1,5 @@
1
+ from .data import Data
2
+ from .find_pc import FindPc, PcResult
3
+
4
+ __all__ = ["Data", "FindPc", "PcResult"]
5
+ __version__ = "0.1.1"
@@ -0,0 +1,202 @@
1
+ """
2
+ pyelogp.data
3
+ ============
4
+ Data ingestion and pre-processing for consolidation test analysis.
5
+
6
+ Typical usage
7
+ -------------
8
+ >>> from pyelogp import Data
9
+ >>> d = Data(
10
+ pressure=[59, 90, 120, 150, 165, 172, 184, 222, 300, 400],
11
+ void_ratio=[2.115, 2.113, 2.098, 2.083, 2.055, 2, 1.8, 1.5, 1.3, 1.193])
12
+ >>> d.log_p # log10 of pressure (1-D ndarray)
13
+ >>> result = d.find_pc()
14
+ >>> print(result.pc)
15
+ """
16
+
17
+ import numpy as np
18
+
19
+ DATA_DTYPE = np.dtype([
20
+ ("idx", np.intp),
21
+ ("p", np.float64),
22
+ ("log_p", np.float64),
23
+ ("e", np.float64),
24
+ ("epsilon", np.float64),
25
+ ("dwork", np.float64),
26
+ ("work", np.float64),
27
+ ])
28
+
29
+ class Data:
30
+ """
31
+ Container and pre-processor for a consolidation test dataset.
32
+
33
+ Parameters
34
+ ----------
35
+ pressure : array-like
36
+ Applied vertical effective stress values (any consistent pressure unit).
37
+ Must be non-negative. Zero is allowed and is handled internally.
38
+ void_ratio : array-like
39
+ Measured void ratios corresponding to each pressure step.
40
+ e0 : float, optional
41
+ Initial void ratio at the start of the test. Defaults to
42
+ ``void_ratio[0]`` when not supplied.
43
+
44
+ Attributes (available after construction)
45
+ -----------------------------------------
46
+ dc : NDArray
47
+ Full structured array for the entire dataset (all loading/unloading
48
+ steps), fields: idx, p, log_p, e, epsilon, dwork, work.
49
+ lc : NDArray
50
+ Loading-curve only; unloading–reloading rows removed and rows with
51
+ non-finite log_p dropped.
52
+
53
+ Column accessors (dot-access shortcuts on `dc`)
54
+ ------------------------------------------------
55
+ p, log_p, e, epsilon, dwork, work
56
+ Each returns the corresponding column of ``dc`` as a plain ndarray.
57
+ """
58
+
59
+ def __init__(self, pressure, void_ratio, e0=None):
60
+ x = np.asarray(pressure, dtype=np.float64)
61
+ y = np.asarray(void_ratio, dtype=np.float64)
62
+
63
+ if x.shape != y.shape:
64
+ raise ValueError("pressure and void_ratio must have the same length.")
65
+ if np.any(x < 0):
66
+ raise ValueError("All pressure values must be non-negative.")
67
+
68
+ self.e0 = float(y[0]) if e0 is None else float(e0)
69
+ self.dc = self._preprocess(x, y, self.e0)
70
+ self.lc = self._remove_loginf_rl_unl_rows(self.dc)
71
+
72
+ def __getattr__(self, name):
73
+ if name in DATA_DTYPE.names:
74
+ return self.dc[name]
75
+ raise AttributeError(f"'Data' object has no attribute '{name}'")
76
+
77
+ def find_pc(self, **kwargs):
78
+ from .find_pc import FindPc
79
+ return FindPc(self, **kwargs).run()
80
+
81
+ @classmethod
82
+ def from_csv(cls, path, pressure_col=0, void_ratio_col=1, delimiter=",", e0=None):
83
+ """
84
+ Construct a Data instance from a CSV file.
85
+
86
+ Parameters
87
+ ----------
88
+ path : str
89
+ Path to the CSV file (e.g. ``"data/example.csv"``).
90
+ pressure_col : int, optional
91
+ Column index for pressure values. Default is 0 (first column).
92
+ void_ratio_col : int, optional
93
+ Column index for void-ratio values. Default is 1 (second column).
94
+ delimiter : str, optional
95
+ Field delimiter. Default is ``","``.
96
+ e0 : float, optional
97
+ Initial void ratio. Defaults to ``void_ratio[0]`` when not supplied.
98
+
99
+ Returns
100
+ -------
101
+ Data
102
+
103
+ Examples
104
+ --------
105
+ >>> d = Data.from_csv("data/example.csv")
106
+ >>> d = Data.from_csv("data/example.csv", pressure_col=0, void_ratio_col=1)
107
+ """
108
+ raw = np.loadtxt(path, delimiter=delimiter)
109
+
110
+ if raw.ndim != 2:
111
+ raise ValueError(
112
+ f"Expected a 2-D array from '{path}', got shape {raw.shape}. "
113
+ "Check your delimiter settings."
114
+ )
115
+
116
+ n_cols = raw.shape[1]
117
+ for col_name, col_idx in (("pressure_col", pressure_col), ("void_ratio_col", void_ratio_col)):
118
+ if col_idx >= n_cols:
119
+ raise ValueError(
120
+ f"{col_name}={col_idx} is out of range — file only has {n_cols} column(s)."
121
+ )
122
+
123
+ return cls(
124
+ pressure=raw[:, pressure_col],
125
+ void_ratio=raw[:, void_ratio_col],
126
+ e0=e0,
127
+ )
128
+
129
+ @staticmethod
130
+ def vol_strain_work(p, epsilon):
131
+ """
132
+ Compute incremental and cumulative volumetric strain work.
133
+ """
134
+ dwork = 0.5 * (epsilon[1:] - epsilon[:-1]) * (p[1:] + p[:-1])
135
+ dwork = np.hstack((0.0, dwork))
136
+ work = np.cumsum(dwork)
137
+ return dwork, work
138
+
139
+ @staticmethod
140
+ def _preprocess(x, y, e0):
141
+ """
142
+ Build the internal structured array from raw pressure / void-ratio arrays.
143
+ """
144
+ if e0 is None:
145
+ e0 = float(y[0])
146
+
147
+ n = len(x)
148
+ if n == 0:
149
+ return np.array([], dtype=DATA_DTYPE)
150
+
151
+ # Check order of magnitude of p
152
+ rx = float(np.max(x) - np.min(x))
153
+ order = int(np.floor(np.log10(rx))) if rx > 0 else 0
154
+
155
+ log_p = np.full(n, np.nan, dtype=np.float64)
156
+
157
+ if order > 2:
158
+ # Large range: treat 0 as NaN (filtered later); keep 0 < x ≤ 1 as-is
159
+ mask = x > 0
160
+ log_p[mask] = np.log10(x[mask])
161
+ else:
162
+ # Small range: shift zero by half the next step
163
+ if x[0] == 0:
164
+ c = np.min(x[x > 0]) / 2
165
+ log_p = np.log10(np.where(x > 0, x, np.nan))
166
+ log_p[0] = np.log10(c)
167
+ else:
168
+ log_p = np.log10(x)
169
+
170
+ epsilon = (e0 - y) / (1.0 + e0)
171
+ dwork, work = Data.vol_strain_work(x, epsilon)
172
+
173
+ data = np.empty(n, dtype=DATA_DTYPE)
174
+ data["idx"] = np.arange(n)
175
+ data["p"] = x
176
+ data["log_p"] = log_p
177
+ data["e"] = np.round(y, 4)
178
+ data["epsilon"] = np.round(epsilon, 5)
179
+ data["dwork"] = dwork
180
+ data["work"] = work
181
+
182
+ return data
183
+
184
+ @staticmethod
185
+ def _remove_loginf_rl_unl_rows(data):
186
+ """
187
+ Strip unloading–reloading rows and non-finite log_p entries.
188
+
189
+ A row is considered part of the primary loading curve when its
190
+ pressure strictly exceeds all previously seen pressure values.
191
+ """
192
+ max_p = -np.inf
193
+ loading = []
194
+
195
+ for i, row in enumerate(data):
196
+ if row["p"] > max_p:
197
+ max_p = float(row["p"])
198
+ loading.append(i)
199
+
200
+ result = data[loading]
201
+ mask = np.isfinite(result["log_p"])
202
+ return result[mask]
@@ -0,0 +1,316 @@
1
+ """
2
+ pyelogp.find_pc
3
+ ===============
4
+ Preconsolidation pressure estimation using bilinear fitting in work-p
5
+ space (Becker et al., 1987) with a knee-point detected in e–log(P) space.
6
+
7
+ Typical usage (via Data)
8
+ ------------------------
9
+ >>> from pyelogp import Data
10
+ >>> d = Data.from_csv("data/example.csv")
11
+ >>> result = d.find_pc()
12
+ >>> print(result.pc) # preconsolidation pressure
13
+
14
+ Direct usage
15
+ ------------
16
+ >>> from pyelogp import Data, FindPc
17
+ >>> d = Data(pressure=[...], void_ratio=[...])
18
+ >>> result = d.find_pc()
19
+ >>> print(result.pc)
20
+ """
21
+
22
+ import numpy as np
23
+ from scipy.interpolate import CubicSpline
24
+ from kneed import KneeLocator
25
+
26
+ class BilinearResult:
27
+ """
28
+ Internal result container for :meth:`FindPc._bilinear_w_knee`.
29
+ """
30
+ def __init__(self, x_int, y_int, seg1, seg2, r2_seg1, r2_seg2):
31
+ self.x_int = x_int
32
+ self.y_int = y_int
33
+ self.seg1 = seg1
34
+ self.seg2 = seg2
35
+ self.r2_seg1 = r2_seg1
36
+ self.r2_seg2 = r2_seg2
37
+
38
+ class PcResult:
39
+ """
40
+ Final output of :meth:`FindPc.run`.
41
+
42
+ Attributes
43
+ ----------
44
+ pc : float
45
+ Estimated preconsolidation pressure.
46
+ e_pc : float
47
+ Void ratio at ``pc``. ``nan`` when ``pc`` could not be validated
48
+ (see ``warnings``).
49
+ seg1 : list of tuple
50
+ ``(x, y_fit)`` pairs describing the fitted segment-1 line.
51
+ seg2 : list of tuple
52
+ ``(x, y_fit)`` pairs describing the fitted segment-2 line.
53
+ r2_seg1 : float
54
+ Coefficient of determination (R2) for segment 1.
55
+ r2_seg2 : float
56
+ Coefficient of determination (R2) for segment 2.
57
+ warnings : list of str
58
+ Non-fatal issues encountered while producing this result (e.g. a
59
+ non-physical ``pc``). Empty when none occurred.
60
+ """
61
+ def __init__(self, pc, e_pc, seg1, seg2, r2_seg1, r2_seg2, warnings=None):
62
+ self.pc = pc
63
+ self.e_pc = e_pc
64
+ self.seg1 = seg1
65
+ self.seg2 = seg2
66
+ self.r2_seg1 = r2_seg1
67
+ self.r2_seg2 = r2_seg2
68
+ self.warnings = warnings if warnings is not None else []
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Main analysis class
72
+ # ---------------------------------------------------------------------------
73
+ class FindPc:
74
+ """
75
+ Estimate the preconsolidation pressure.
76
+
77
+ Algorithm summary
78
+ -----------------
79
+ 1. Fit a cubic spline to the primary loading curve in e–log(P) space.
80
+ 2. Locate the knee of the e–log(P) curve using :class:`kneed.KneeLocator`.
81
+ 3. Perform a bilinear least-squares fit in work–pressure (W–P) space,
82
+ splitting at the detected knee.
83
+ 4. Compute the intersection of the two lines → preconsolidation pressure (yield stress).
84
+
85
+ Parameters
86
+ ----------
87
+ data : Data
88
+ Pre-processed consolidation dataset.
89
+ n_spline_points : int, optional
90
+ Number of points used when evaluating the cubic spline for knee
91
+ detection. Default is 50.
92
+ """
93
+ def __init__(self, data, n_spline_points=50):
94
+ self._data = data
95
+ self._n_spline_points = n_spline_points
96
+
97
+ def run(self):
98
+ """
99
+ Execute the full analysis pipeline and return a :class:`PcResult`.
100
+
101
+ Raises
102
+ ------
103
+ ValueError
104
+ If the dataset is too small or the fitting fails.
105
+ """
106
+ warnings = []
107
+ lc = self._data.lc
108
+
109
+ if len(lc) < 6:
110
+ raise ValueError("At least 6 valid loading-curve points are required.")
111
+
112
+ cs_fit, cs_curve, log_p_max_d2y = self._cs_profile(
113
+ lc["log_p"], lc["e"], n_points=self._n_spline_points
114
+ )
115
+ p_threshold = np.floor(np.power(10, log_p_max_d2y))
116
+
117
+ kl = self._find_knee(lc, cs_curve)
118
+ if kl.knee is None:
119
+ raise ValueError("Could not detect a knee in the e–log(P) curve.")
120
+ knee = np.power(10, kl.knee)
121
+
122
+ fit = self._bilinear_w_knee(lc["p"], lc["work"], knee, threshold=p_threshold)
123
+
124
+ pc = fit.x_int
125
+
126
+ if not np.isfinite(pc) or pc <= 0:
127
+ warnings.append(
128
+ f"Computed pc={pc} is non-physical (must be > 0); "
129
+ "e_pc could not be determined."
130
+ )
131
+ e_pc = np.nan
132
+ else:
133
+ e_pc = self._find_e(lc, cs_fit, pc)
134
+
135
+ def _to_pairs(seg):
136
+ return [(x, y) for x, y in zip(seg["x"], seg["y_fit"])]
137
+
138
+ return PcResult(
139
+ pc=pc,
140
+ e_pc=e_pc,
141
+ seg1=_to_pairs(fit.seg1),
142
+ seg2=_to_pairs(fit.seg2),
143
+ r2_seg1=fit.r2_seg1,
144
+ r2_seg2=fit.r2_seg2,
145
+ warnings=warnings,
146
+ )
147
+
148
+ @staticmethod
149
+ def _r2(y_actual, y_pred):
150
+ """
151
+ Coefficient of determination (R2).
152
+ """
153
+ ss_res = np.sum((y_actual - y_pred) ** 2)
154
+ ss_tot = np.sum((y_actual - np.mean(y_actual)) ** 2)
155
+ return (1.0 - ss_res / ss_tot) if ss_tot != 0 else 1.0
156
+
157
+ @staticmethod
158
+ def _find_e(lc, cs, sigma_v):
159
+ """
160
+ Interpolate void ratio at ``sigma_v`` using the cubic spline ``cs``.
161
+
162
+ Falls back to linear extrapolation when ``sigma_v`` is below the
163
+ spline's fitted range.
164
+
165
+ Parameters
166
+ ----------
167
+ lc : NDArray
168
+ Loading-curve structured array.
169
+ cs : CubicSpline
170
+ Spline fitted in log(P) space.
171
+ sigma_v : float
172
+ Target pressure.
173
+
174
+ Returns
175
+ -------
176
+ float
177
+ """
178
+ x = np.log10(sigma_v)
179
+ x_min, x_max = cs.x[0], cs.x[-1]
180
+
181
+ if x < x_min:
182
+ # Linear extrapolation from the first two LC points
183
+ p0, p1 = lc["p"][:2]
184
+ e0, e1 = lc["e"][:2]
185
+ slope = (e1 - e0) / (p1 - p0)
186
+ e = e0 + slope * (sigma_v - p0)
187
+ else:
188
+ e = cs(np.clip(x, x_min, x_max))
189
+
190
+ return np.round(e, 4)
191
+
192
+ @staticmethod
193
+ def _cs_profile(x, y, n_points=50):
194
+ """
195
+ Fit a cubic spline and return curvature information.
196
+
197
+ Parameters
198
+ ----------
199
+ x : ndarray
200
+ log(P) values of the loading curve.
201
+ y : ndarray
202
+ Void-ratio values.
203
+ n_points : int
204
+ Evaluation density for the dense curve.
205
+
206
+ Returns
207
+ -------
208
+ cs : CubicSpline
209
+ cs_curve : ndarray, shape (n_points, 5)
210
+ Columns: x_dense, y_dense
211
+ log_p_max_d2y : float
212
+ log(P) at the location of maximum second derivative.
213
+ """
214
+ order = np.argsort(x)
215
+ x_fit, y_fit = x[order], y[order]
216
+
217
+ cs = CubicSpline(x_fit, y_fit)
218
+ x_dense = np.linspace(x_fit.min(), x_fit.max(), n_points)
219
+ y_dense = cs(x_dense)
220
+ d2y = cs(x_dense, 2)
221
+
222
+ idx_max_d2y = int(np.argmax(d2y))
223
+ log_p_max_d2y = x_dense[idx_max_d2y]
224
+
225
+ return cs, np.column_stack((x_dense, y_dense)), log_p_max_d2y
226
+
227
+ @staticmethod
228
+ def _find_knee(lc, cs_curve, min_len=10):
229
+ """
230
+ Locate the knee of the e–log(P) curve.
231
+
232
+ Uses the dense spline curve for small datasets and the raw loading-curve
233
+ data for larger ones.
234
+
235
+ Parameters
236
+ ----------
237
+ lc : NDArray
238
+ Loading-curve structured array.
239
+ cs_curve : ndarray
240
+ Dense curve array from :meth:`_cs_profile`.
241
+ min_len : int
242
+ Dataset-length threshold for switching strategy.
243
+
244
+ Returns
245
+ -------
246
+ KneeLocator
247
+ """
248
+ x, y = lc["log_p"], lc["e"]
249
+
250
+ if len(lc) <= min_len:
251
+ kl = KneeLocator(
252
+ cs_curve[:, 0], cs_curve[:, 1],
253
+ curve="concave", direction="decreasing", online=True
254
+ )
255
+ if kl.knee is not None and kl.knee != cs_curve[:, 0][-1]:
256
+ return kl
257
+
258
+ return KneeLocator(x, y, curve="concave", direction="decreasing", online=True)
259
+
260
+ @staticmethod
261
+ def _bilinear_w_knee(x, y, knee, threshold=None):
262
+ """
263
+ Bilinear least-squares fit split at the knee point.
264
+
265
+ Parameters
266
+ ----------
267
+ x : ndarray
268
+ Pressure values of the loading curve.
269
+ y : ndarray
270
+ Cumulative work values.
271
+ knee : float
272
+ Pressure value of the detected knee (in native pressure units).
273
+ threshold : float, optional
274
+ Upper pressure limit for segment 2 (uses max curvature location).
275
+
276
+ Returns
277
+ -------
278
+ BilinearResult
279
+
280
+ Raises
281
+ ------
282
+ ValueError
283
+ When segments contain too few points or the lines are parallel.
284
+ """
285
+ knee_idx = int(np.searchsorted(x, knee, side="right") - 1)
286
+
287
+ if knee_idx < 1:
288
+ raise ValueError("Not enough points in segment 1.")
289
+ best_k = knee_idx + 1
290
+
291
+ if best_k > len(x) - 2:
292
+ raise ValueError(
293
+ "Not enough points in segment 2; the detected knee is too close "
294
+ "to the end of the dataset."
295
+ )
296
+
297
+ end_k = min(len(x), max(int(np.searchsorted(x, threshold, side="right")) if threshold is not None else len(x), best_k + 3))
298
+
299
+ coeffs1 = np.polyfit(x[:best_k], y[:best_k], deg=1)
300
+ coeffs2 = np.polyfit(x[best_k:end_k], y[best_k:end_k], deg=1)
301
+
302
+ dslope = coeffs1[0] - coeffs2[0]
303
+
304
+ if np.isclose(dslope, 0, atol=1e-10):
305
+ raise ValueError("Lines are parallel; cannot determine intersection.")
306
+
307
+ x_int = (coeffs2[1] - coeffs1[1]) / dslope
308
+
309
+ return BilinearResult(
310
+ x_int=np.round(x_int, 2), y_int=np.polyval(coeffs1, x_int),
311
+ seg1={"x": np.array([x[0], x_int]), "y_fit": np.polyval(coeffs1, np.array([x[0], x_int]))},
312
+ seg2={"x": np.array([x_int, x[end_k - 1]]), "y_fit": np.polyval(coeffs2, np.array([x_int, x[end_k - 1]]))},
313
+ r2_seg1=np.round(FindPc._r2(y[:best_k], np.polyval(coeffs1, x[:best_k])), 6),
314
+ r2_seg2=np.round(FindPc._r2(y[best_k:end_k], np.polyval(coeffs2, x[best_k:end_k])), 6),
315
+ )
316
+
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pyelogp"
7
+ version = "0.1.1"
8
+ authors = [
9
+ {name="Liang Chern Chow", email="liangchern@gmail.com"},
10
+ {name="Thierno Kane", email="tkane83@gmail.com"}
11
+ ]
12
+ description = "A lightweight Python library to estimate preconsolidation pressure using volumetric strain energy method with knee detection in the e-log(P) space."
13
+ readme = "README.md"
14
+ requires-python = ">=3.10"
15
+ license = { text = "BSD-3-Clause" }
16
+ keywords = [
17
+ "geotechnical",
18
+ "consolidation",
19
+ "settlement",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 3 - Alpha",
23
+ "Intended Audience :: Science/Research",
24
+ "Topic :: Scientific/Engineering",
25
+ "License :: OSI Approved :: BSD License",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ ]
31
+ dependencies = [
32
+ "numpy>=1.21.0",
33
+ "scipy>=1.7.0",
34
+ "kneed>=0.8.0",
35
+ "matplotlib>=3.10",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/liangchow/pyelogp"
40
+ Issues = "https://github.com/liangchow/pyelogp/issues"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["pyelogp"]
@@ -0,0 +1,4 @@
1
+ numpy>=1.21.0
2
+ scipy>=1.7.0
3
+ kneed>=0.8.0
4
+ matplotlib>=3.10
pyelogp-0.1.1/run.py ADDED
@@ -0,0 +1,40 @@
1
+ from pyelogp import Data
2
+
3
+ # Example 1 --- Run analysis on .csv file.
4
+ # Becker et al. (1987): Wallaceburg Clay, P'c (Casangrade)=115 kPa, P'vy=120 kPa, OCR=1.33
5
+ # See data/example.csv
6
+ d1 = Data.from_csv("data/example.csv", e0=1.24)
7
+ result = d1.find_pc()
8
+
9
+ print("Preconsolidation pressure (Pc):", result.pc)
10
+ print("Void ratio at pc (e_pc):", result.e_pc)
11
+ print("R² segment 1:", result.r2_seg1)
12
+ print("R² segment 2:", result.r2_seg2)
13
+
14
+
15
+ # Example 2 --- Run analysis directly on pressure and void_ratio arrays.
16
+ # TPM (1996): Louiseville Clay, P'c=165 kPa, OCR=2.8
17
+ pressure = [59, 90, 120, 150, 165, 172, 184, 222, 300, 400]
18
+ void_ratio = [2.115, 2.113, 2.098, 2.083, 2.055, 2, 1.8, 1.5, 1.3, 1.193]
19
+ e0=1.21
20
+
21
+ d2 = Data(pressure=pressure, void_ratio=void_ratio, e0=e0)
22
+ result = d2.find_pc()
23
+
24
+ print("Preconsolidation pressure (Pc):", result.pc)
25
+ print("Void ratio at pc (e_pc):", result.e_pc)
26
+ print("R² segment 1:", result.r2_seg1)
27
+ print("R² segment 2:", result.r2_seg2)
28
+
29
+ # returns:
30
+ # Wallaceburg Clay:
31
+ # Pc = 113.86
32
+ # e_pc = 1.0804
33
+ # R2,seg1 = 0.983665
34
+ # R2,seg2 = 0.998988
35
+
36
+ # Louiseville Clay:
37
+ # Pc = 164.49
38
+ # e_pc = 2.0573
39
+ # R2,seg1 = 0.89377
40
+ # R2,seg2 = 0.96937
@@ -0,0 +1,283 @@
1
+ import warnings
2
+ import pytest
3
+ import numpy as np
4
+ from pyelogp import Data
5
+ from pyelogp.find_pc import FindPc
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Original tests
9
+ # ---------------------------------------------------------------------------
10
+
11
+ def test_data_initialization():
12
+ """Verify that Data object calculates expected columns."""
13
+ pressure = [10, 20, 40, 80, 160]
14
+ void_ratio = [1.2, 1.1, 1.0, 0.9, 0.8]
15
+ d = Data(pressure, void_ratio)
16
+
17
+ # Check if dot access works
18
+ assert len(d.log_p) == 5
19
+ assert isinstance(d.dc, np.ndarray)
20
+
21
+ def test_data_initialization_w_e0():
22
+ """Verify that Data object calculates expected columns."""
23
+ pressure = [10, 20, 40, 80, 160]
24
+ void_ratio = [1.2, 1.1, 1.0, 0.9, 0.8]
25
+ e0 = 1.21
26
+ d = Data(pressure, void_ratio, e0)
27
+
28
+ # Check if dot access works
29
+ assert len(d.log_p) == 5
30
+ assert isinstance(d.dc, np.ndarray)
31
+
32
+
33
+ def test_find_pc_logic():
34
+ """Verify find_pc runs without error on valid input."""
35
+ pressure = [10, 20, 40, 80, 160, 320, 640]
36
+ void_ratio = [1.2, 1.12, 1.03, 0.93, 0.81, 0.68, 0.54]
37
+
38
+ d = Data(pressure, void_ratio)
39
+ result = d.find_pc()
40
+
41
+ # Check if a result object is returned
42
+ assert result.pc > 0
43
+ assert result.e_pc > 0
44
+
45
+
46
+ def test_insufficient_data_raises_error():
47
+ """Verify that too few points raise a ValueError."""
48
+ pressure = [10, 20]
49
+ void_ratio = [1.2, 1.1]
50
+
51
+ d = Data(pressure, void_ratio)
52
+ with pytest.raises(ValueError, match="At least 6 valid loading-curve points are required."):
53
+ d.find_pc()
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Shared fixtures: shape-preserving datasets scaled to span a "small" range
58
+ # (0-10) and a "large" range (0-10,000+), each including pressure points
59
+ # between 0 and 1 (with decimals), and each starting at exactly 0.
60
+ #
61
+ # Both datasets are derived from the same known-good e-log(P) shape used in
62
+ # test_find_pc_logic above, just rescaled, so the underlying curvature/knee
63
+ # behavior is preserved and find_pc() is expected to converge cleanly.
64
+ # ---------------------------------------------------------------------------
65
+
66
+ _BASE_P = np.array([10.0, 20.0, 40.0, 80.0, 160.0, 320.0, 640.0])
67
+ _BASE_E = [1.20, 1.12, 1.03, 0.93, 0.81, 0.68, 0.54]
68
+
69
+
70
+ @pytest.fixture
71
+ def small_range_data():
72
+ """Pressure spans 0 -> 10, including sub-1 decimal points."""
73
+ pressure = [0, 0.05] + (_BASE_P / 64.0).tolist()
74
+ void_ratio = [1.205, 1.203] + _BASE_E
75
+ return pressure, void_ratio
76
+
77
+
78
+ @pytest.fixture
79
+ def large_range_data():
80
+ """Pressure spans 0 -> ~10,240, including sub-1 decimal points."""
81
+ pressure = [0, 0.5, 1] + (_BASE_P * 16.0).tolist()
82
+ void_ratio = [1.205, 1.203, 1.201] + _BASE_E
83
+ return pressure, void_ratio
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Small pressure range (0-10), with decimal points between 0 and 1
88
+ # ---------------------------------------------------------------------------
89
+
90
+ class TestSmallPressureRange:
91
+
92
+ def test_initializes_and_preserves_point_count(self, small_range_data):
93
+ pressure, void_ratio = small_range_data
94
+ d = Data(pressure, void_ratio)
95
+ assert len(d.dc) == len(pressure)
96
+ assert isinstance(d.dc, np.ndarray)
97
+
98
+ def test_decimal_points_below_one_are_finite_in_loading_curve(self, small_range_data):
99
+ pressure, void_ratio = small_range_data
100
+ d = Data(pressure, void_ratio)
101
+ # every pressure < 1 in the input (0.05, 0.15625, 0.3125, 0.625) must
102
+ # survive into the loading curve with a finite log_p
103
+ sub_one = [p for p in pressure if 0 < p < 1]
104
+ assert len(sub_one) > 0
105
+ assert np.all(np.isfinite(d.lc["log_p"]))
106
+ lc_p = set(np.round(d.lc["p"], 6))
107
+ for p in sub_one:
108
+ assert round(p, 6) in lc_p
109
+
110
+ def test_zero_pressure_point_is_retained_via_shift_trick(self, small_range_data):
111
+ pressure, void_ratio = small_range_data
112
+ d = Data(pressure, void_ratio)
113
+ # in the "small range" branch (order <= 2), a leading zero pressure
114
+ # is NOT dropped: log_p[0] is computed via a half-step shift
115
+ # (log10(x[1] / 2)), so it survives into the loading curve with a
116
+ # finite log_p -- unlike the large-range branch (see
117
+ # TestLargePressureRange.test_zero_pressure_point_is_dropped_from_loading_curve)
118
+ assert 0.0 in d.lc["p"]
119
+ assert np.isfinite(d.dc["log_p"][0])
120
+
121
+ def test_find_pc_runs_and_returns_physical_result(self, small_range_data):
122
+ pressure, void_ratio = small_range_data
123
+ d = Data(pressure, void_ratio)
124
+ result = d.find_pc()
125
+ assert result.pc > 0
126
+ assert result.pc < max(pressure)
127
+ assert not np.isnan(result.e_pc)
128
+ assert 0 < result.e_pc < void_ratio[0]
129
+
130
+ def test_work_is_monotonically_nondecreasing(self, small_range_data):
131
+ pressure, void_ratio = small_range_data
132
+ d = Data(pressure, void_ratio)
133
+ # cumulative volumetric strain work should never decrease for a
134
+ # consolidating (compressing) loading curve
135
+ assert np.all(np.diff(d.lc["work"]) >= -1e-9)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Large pressure range (0-10,000+), with decimal points between 0 and 1
140
+ # ---------------------------------------------------------------------------
141
+
142
+ class TestLargePressureRange:
143
+
144
+ def test_initializes_and_preserves_point_count(self, large_range_data):
145
+ pressure, void_ratio = large_range_data
146
+ d = Data(pressure, void_ratio)
147
+ assert len(d.dc) == len(pressure)
148
+ assert isinstance(d.dc, np.ndarray)
149
+
150
+ def test_decimal_points_below_one_are_finite_in_loading_curve(self, large_range_data):
151
+ pressure, void_ratio = large_range_data
152
+ d = Data(pressure, void_ratio)
153
+ sub_one = [p for p in pressure if 0 < p < 1]
154
+ assert len(sub_one) > 0
155
+ assert np.all(np.isfinite(d.lc["log_p"]))
156
+ lc_p = set(np.round(d.lc["p"], 6))
157
+ for p in sub_one:
158
+ assert round(p, 6) in lc_p
159
+
160
+ def test_zero_pressure_point_is_dropped_from_loading_curve(self, large_range_data):
161
+ pressure, void_ratio = large_range_data
162
+ d = Data(pressure, void_ratio)
163
+ assert 0.0 not in d.lc["p"]
164
+
165
+ def test_find_pc_runs_and_returns_physical_result(self, large_range_data):
166
+ pressure, void_ratio = large_range_data
167
+ d = Data(pressure, void_ratio)
168
+ result = d.find_pc()
169
+ assert result.pc > 0
170
+ assert result.pc < max(pressure)
171
+ assert not np.isnan(result.e_pc)
172
+ assert 0 < result.e_pc < void_ratio[0]
173
+
174
+ def test_large_and_small_range_use_different_log_p_strategies(
175
+ self, small_range_data, large_range_data
176
+ ):
177
+ # documents the order > 2 branch split in Data._preprocess: large
178
+ # range data must NOT use the index-0 zero-shift trick the small
179
+ # range branch uses, since order = floor(log10(rx)) > 2 here
180
+ s_pressure, s_void = small_range_data
181
+ l_pressure, l_void = large_range_data
182
+ d_small = Data(s_pressure, s_void)
183
+ d_large = Data(l_pressure, l_void)
184
+ # small range's first (zero) point gets a finite shifted log_p
185
+ assert np.isfinite(d_small.dc["log_p"][0])
186
+ # large range's first (zero) point is NaN by design (mask = x > 0)
187
+ assert np.isnan(d_large.dc["log_p"][0])
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Cross-cutting / shared behavior across both ranges
192
+ # ---------------------------------------------------------------------------
193
+
194
+ @pytest.mark.parametrize("range_fixture", ["small_range_data", "large_range_data"])
195
+ def test_e0_defaults_to_first_void_ratio(range_fixture, request):
196
+ pressure, void_ratio = request.getfixturevalue(range_fixture)
197
+ d = Data(pressure, void_ratio)
198
+ assert d.e0 == pytest.approx(void_ratio[0])
199
+
200
+
201
+ @pytest.mark.parametrize("range_fixture", ["small_range_data", "large_range_data"])
202
+ def test_explicit_e0_overrides_default(range_fixture, request):
203
+ pressure, void_ratio = request.getfixturevalue(range_fixture)
204
+ d = Data(pressure, void_ratio, e0=2.0)
205
+ assert d.e0 == 2.0
206
+ assert d.dc["epsilon"][0] != pytest.approx((void_ratio[0] - void_ratio[0]) / (1 + void_ratio[0]))
207
+
208
+
209
+ @pytest.mark.parametrize("range_fixture", ["small_range_data", "large_range_data"])
210
+ def test_mismatched_lengths_raise_value_error(range_fixture, request):
211
+ pressure, void_ratio = request.getfixturevalue(range_fixture)
212
+ with pytest.raises(ValueError, match="same length"):
213
+ Data(pressure, void_ratio[:-1])
214
+
215
+
216
+ @pytest.mark.parametrize("range_fixture", ["small_range_data", "large_range_data"])
217
+ def test_negative_pressure_raises_value_error(range_fixture, request):
218
+ pressure, void_ratio = request.getfixturevalue(range_fixture)
219
+ bad_pressure = list(pressure)
220
+ bad_pressure[1] = -bad_pressure[1] - 0.01 if bad_pressure[1] != 0 else -1.0
221
+ with pytest.raises(ValueError, match="non-negative"):
222
+ Data(bad_pressure, void_ratio)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Fixed issues (regression tests).
227
+ #
228
+ # These tests previously pinned buggy behavior (see git history / CHANGELOG).
229
+ # The underlying bugs have since been fixed in find_pc.py; these tests now
230
+ # pin the corrected behavior so any regression is caught.
231
+ # ---------------------------------------------------------------------------
232
+
233
+ class TestFixedIssues:
234
+
235
+ def test_knee_at_last_point_raises_value_error_gracefully(self):
236
+ """
237
+ FIXED: when the detected knee lands on (or one point before) the
238
+ final loading-curve sample, segment 2 of the bilinear fit would
239
+ become empty and np.polyfit raised an unhandled TypeError. This is
240
+ now caught early and raised as a clean, documented ValueError.
241
+ """
242
+ x = np.array([1.0, 2, 4, 8, 16, 32])
243
+ y = np.array([0, 0.5, 1.5, 3.5, 8, 20.0])
244
+ knee = 32.0 # forces knee_idx = len(x) - 1
245
+ with pytest.raises(ValueError, match="Not enough points in segment 2"):
246
+ FindPc._bilinear_w_knee(x, y, knee, threshold=None)
247
+
248
+ def test_threshold_of_exactly_zero_is_respected(self):
249
+ """
250
+ FIXED: `if threshold else len(x)` treated a legitimate
251
+ threshold of 0.0 the same as no threshold (falsy check instead of
252
+ `is not None`). Now `threshold=0.0` is honored and produces a
253
+ different, tighter segment-2 boundary than `threshold=None`.
254
+ """
255
+ x = np.array([1.0, 2, 4, 8, 16, 32, 64, 128])
256
+ y = np.array([0, 0.5, 1.5, 3.5, 8, 20, 50, 130.0])
257
+ knee = 8.0
258
+ result_zero = FindPc._bilinear_w_knee(x, y, knee, threshold=0.0)
259
+ result_none = FindPc._bilinear_w_knee(x, y, knee, threshold=None)
260
+ assert not np.allclose(result_zero.seg2["x"], result_none.seg2["x"])
261
+ # threshold=0.0 forces the earliest possible split (best_k + 3)
262
+ assert result_zero.seg2["x"][-1] == pytest.approx(64.0)
263
+ assert result_none.seg2["x"][-1] == pytest.approx(128.0)
264
+
265
+ def test_negative_pc_is_validated_and_recorded_as_warning(self):
266
+ """
267
+ FIXED: when the bilinear intersection extrapolates to a negative
268
+ pressure, find_pc() now validates `pc` before computing `e_pc`,
269
+ avoiding the invalid `log10` call and recording the issue in
270
+ `result.warnings` instead of silently returning a corrupted,
271
+ unexplained `nan`.
272
+ """
273
+ pressure = [0, 0.5, 1, 10, 100, 500, 1000, 2500, 5000, 10000]
274
+ void_ratio = [1.50, 1.498, 1.495, 1.48, 1.40, 1.10, 0.85, 0.70, 0.60, 0.52]
275
+ d = Data(pressure, void_ratio)
276
+ with warnings.catch_warnings():
277
+ warnings.simplefilter("error", RuntimeWarning)
278
+ result = d.find_pc()
279
+ assert result.pc < 0
280
+ assert np.isnan(result.e_pc)
281
+ assert len(result.warnings) == 1
282
+ assert "non-physical" in result.warnings[0]
283
+