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 +28 -0
- pyelogp-0.1.1/PKG-INFO +105 -0
- pyelogp-0.1.1/README.md +80 -0
- pyelogp-0.1.1/data/example.csv +14 -0
- pyelogp-0.1.1/pyelogp/__init__.py +5 -0
- pyelogp-0.1.1/pyelogp/data.py +202 -0
- pyelogp-0.1.1/pyelogp/find_pc.py +316 -0
- pyelogp-0.1.1/pyproject.toml +43 -0
- pyelogp-0.1.1/requirements.txt +4 -0
- pyelogp-0.1.1/run.py +40 -0
- pyelogp-0.1.1/tests/test_core.py +283 -0
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
|
+
<br/>
|
|
31
|
+
<br/>
|
|
32
|
+

|
|
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.
|
pyelogp-0.1.1/README.md
ADDED
|
@@ -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
|
+
<br/>
|
|
6
|
+
<br/>
|
|
7
|
+

|
|
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,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"]
|
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
|
+
|