mechprops-extractor 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wyatt Ho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: mechprops-extractor
3
+ Version: 0.1.0
4
+ Summary: Extract Young's modulus, ultimate point, and break-point from stress-strain curves
5
+ Author-email: wyatthoho <wyatthoho@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Wyatt Ho
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/wyatthoho/mechprops-extractor
29
+ Project-URL: Repository, https://github.com/wyatthoho/mechprops-extractor
30
+ Keywords: stress-strain,mechanical-properties,materials-testing,youngs-modulus
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Science/Research
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Topic :: Scientific/Engineering
37
+ Requires-Python: >=3.12
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Requires-Dist: pandas>=2.3
41
+ Requires-Dist: matplotlib>=3.10
42
+ Requires-Dist: scipy>=1.16
43
+ Dynamic: license-file
44
+
45
+ # Mechanical Properties Extractor
46
+
47
+ Extracts Young's modulus, ultimate point, and break-point properties from
48
+ stress-strain curve data, via ISO 527 linear regression, a custom
49
+ RMSProp-based curve fit, or break-point detection.
50
+
51
+ ## Features
52
+
53
+ - Young's modulus via ISO 527 secant-line linear regression
54
+ - Young's modulus via a custom RMSProp-based enclosed-area fit
55
+ - Ultimate (peak stress) point detection on the stress-strain curve
56
+ - Break-point (failure) detection on the stress-strain curve
57
+ - Built-in plots and an animation of the RMSProp fitting process
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ pip install mechprops-extractor
63
+ ```
64
+
65
+ For development (editable install):
66
+
67
+ ```bash
68
+ pip install -e .
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ Load a stress-strain CSV (`strain`, `stress` columns) into a `StressStrainCurve`,
74
+ then call any combination of its analysis methods. The example below uses one
75
+ of the sample datasets bundled in [`data/`](data):
76
+
77
+ ```python
78
+ import pandas as pd
79
+
80
+ from mechprops import StressStrainCurve
81
+
82
+ df = pd.read_csv("data/polyimide-stress-strain.csv")
83
+ curve = StressStrainCurve(df["strain"], df["stress"])
84
+
85
+ curve.fit_modulus_iso527() # Young's modulus via ISO 527 linear regression
86
+ curve.fit_modulus_rmsprop() # Young's modulus via RMSProp area minimization
87
+ curve.find_ultimate_point() # (strain, stress) at the ultimate (peak stress) point
88
+ curve.find_break_point() # (strain, stress) at the detected break point
89
+ ```
90
+
91
+ Each method returns its computed value and, by default, opens a plot window
92
+ illustrating the result — close it to continue. Pass `show=False` to skip the
93
+ plot and get the value only.
@@ -0,0 +1,49 @@
1
+ # Mechanical Properties Extractor
2
+
3
+ Extracts Young's modulus, ultimate point, and break-point properties from
4
+ stress-strain curve data, via ISO 527 linear regression, a custom
5
+ RMSProp-based curve fit, or break-point detection.
6
+
7
+ ## Features
8
+
9
+ - Young's modulus via ISO 527 secant-line linear regression
10
+ - Young's modulus via a custom RMSProp-based enclosed-area fit
11
+ - Ultimate (peak stress) point detection on the stress-strain curve
12
+ - Break-point (failure) detection on the stress-strain curve
13
+ - Built-in plots and an animation of the RMSProp fitting process
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install mechprops-extractor
19
+ ```
20
+
21
+ For development (editable install):
22
+
23
+ ```bash
24
+ pip install -e .
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Load a stress-strain CSV (`strain`, `stress` columns) into a `StressStrainCurve`,
30
+ then call any combination of its analysis methods. The example below uses one
31
+ of the sample datasets bundled in [`data/`](data):
32
+
33
+ ```python
34
+ import pandas as pd
35
+
36
+ from mechprops import StressStrainCurve
37
+
38
+ df = pd.read_csv("data/polyimide-stress-strain.csv")
39
+ curve = StressStrainCurve(df["strain"], df["stress"])
40
+
41
+ curve.fit_modulus_iso527() # Young's modulus via ISO 527 linear regression
42
+ curve.fit_modulus_rmsprop() # Young's modulus via RMSProp area minimization
43
+ curve.find_ultimate_point() # (strain, stress) at the ultimate (peak stress) point
44
+ curve.find_break_point() # (strain, stress) at the detected break point
45
+ ```
46
+
47
+ Each method returns its computed value and, by default, opens a plot window
48
+ illustrating the result — close it to continue. Pass `show=False` to skip the
49
+ plot and get the value only.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mechprops-extractor"
7
+ version = "0.1.0"
8
+ description = "Extract Young's modulus, ultimate point, and break-point from stress-strain curves"
9
+ authors = [{ name = "wyatthoho", email = "wyatthoho@gmail.com" }]
10
+ dependencies = ["pandas >= 2.3", "matplotlib >= 3.10", "scipy >= 1.16"]
11
+ requires-python = ">=3.12"
12
+ readme = "README.md"
13
+ license = { file = "LICENSE" }
14
+ keywords = [
15
+ "stress-strain",
16
+ "mechanical-properties",
17
+ "materials-testing",
18
+ "youngs-modulus",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 3 - Alpha",
22
+ "Intended Audience :: Science/Research",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Scientific/Engineering",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/wyatthoho/mechprops-extractor"
31
+ Repository = "https://github.com/wyatthoho/mechprops-extractor"
32
+
33
+ [tool.setuptools]
34
+ package-dir = { "" = "src" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from mechprops.curve import StressStrainCurve
2
+
3
+ __all__ = ["StressStrainCurve"]
@@ -0,0 +1,193 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from scipy import integrate, stats
4
+
5
+ from mechprops.plotter import (
6
+ BreakDetectGraph,
7
+ Iso527Graph,
8
+ RmsPropAnimation,
9
+ UltimatePointGraph,
10
+ )
11
+
12
+ # Analysis Thresholds
13
+ TINY_MODULUS = 1.0e-5
14
+ CONVERGENCE_EPS = 1e-9
15
+ BREAK_THRESHOLD = 0.1
16
+
17
+ # RMSProp Hyperparameters
18
+ RMS_PROP_BASE_LR = 0.01
19
+ RMS_PROP_ALPHA = 0.9
20
+ RMS_PROP_EPS = 1e-8
21
+
22
+ # ISO-527
23
+ STRAIN_LOWER = 0.0005
24
+ STRAIN_UPPER = 0.0025
25
+
26
+ # Modulus Fitting Defaults
27
+ DEFAULT_TOL = 2.0e-5
28
+ DEFAULT_MAX_ITER = 20_000
29
+
30
+
31
+ class StressStrainCurve:
32
+ """Fits Young's modulus from a stress-strain curve, via ISO 527 linear
33
+ regression or a custom RMSProp-based enclosed-area minimization."""
34
+
35
+ def __init__(
36
+ self,
37
+ strain: pd.Series,
38
+ stress: pd.Series,
39
+ ) -> None:
40
+ self.strain_raw = strain
41
+ self.stress_raw = stress
42
+
43
+ idx = int(self.stress_raw.idxmax())
44
+ self.ultimate_x = float(self.strain_raw[idx])
45
+ self.ultimate_y = float(self.stress_raw[idx])
46
+
47
+ self.strain_norm = self.strain_raw / self.ultimate_x
48
+ self.stress_norm = self.stress_raw / self.ultimate_y
49
+
50
+ def find_ultimate_point(self, show: bool = True) -> tuple[float, float]:
51
+ """Returns the (strain, stress) at maximum stress, the ultimate
52
+ point of the curve.
53
+
54
+ Args:
55
+ show: Plots the ultimate point over the raw input curve.
56
+
57
+ Returns:
58
+ The (strain, stress) of the ultimate point in raw units.
59
+ """
60
+ ultimate_point = (self.ultimate_x, self.ultimate_y)
61
+ if show:
62
+ graph = UltimatePointGraph(self.strain_raw, self.stress_raw, ultimate_point)
63
+ graph.show()
64
+
65
+ return ultimate_point
66
+
67
+ def fit_modulus_iso527(self, show: bool = True) -> float:
68
+ """Fits modulus via ISO 527 linear regression over the
69
+ [STRAIN_LOWER, STRAIN_UPPER] strain range.
70
+
71
+ Args:
72
+ show: Plots the fitted secant line over the raw input curve.
73
+
74
+ Raises:
75
+ ValueError: Fewer than 2 raw strain samples fall in the range.
76
+ """
77
+ xs = self.strain_raw
78
+ ys = self.stress_raw
79
+
80
+ target = xs.between(STRAIN_LOWER, STRAIN_UPPER)
81
+ if target.sum() < 2:
82
+ raise ValueError("Not enough raw strain data in the range for regression.")
83
+
84
+ res = stats.linregress(xs[target], ys[target], "greater")
85
+ m = res.slope
86
+ shift = res.intercept
87
+
88
+ if show:
89
+ secant = m * xs + shift
90
+ graph = Iso527Graph(xs, ys, secant, m)
91
+ graph.show()
92
+
93
+ return m
94
+
95
+ def fit_modulus_rmsprop(
96
+ self,
97
+ tol: float = DEFAULT_TOL,
98
+ max_iter: int = DEFAULT_MAX_ITER,
99
+ show: bool = True,
100
+ ) -> float:
101
+ """Fits modulus by using RMSProp to minimize the area enclosed between
102
+ the normalized curve and its secant line.
103
+
104
+ Args:
105
+ tol: Stops iterating once the relative change in modulus is below this.
106
+ max_iter: Maximum number of RMSProp iterations to run.
107
+ show: Plays an animation of the fitting process.
108
+
109
+ Returns:
110
+ The fitted modulus, rescaled back to raw strain/stress units.
111
+ """
112
+ m = 1.0
113
+ v = 0.0
114
+
115
+ xs = self.strain_norm
116
+ ys = self.stress_norm
117
+
118
+ x0 = float(self.strain_norm.iloc[0])
119
+ y0 = float(self.stress_norm.iloc[0])
120
+
121
+ m_records = []
122
+ loss_records = []
123
+ lr_records = []
124
+
125
+ for _ in range(max_iter):
126
+ area = self._compute_enclosed_area(m, x0, y0, xs, ys)
127
+ area_eps = self._compute_enclosed_area(m + TINY_MODULUS, x0, y0, xs, ys)
128
+ gradient = (area_eps - area) / TINY_MODULUS
129
+ v, lr = self._update_rmsprop(v, gradient)
130
+
131
+ m_records.append(m)
132
+ loss_records.append(area)
133
+ lr_records.append(lr)
134
+
135
+ m_new = m - lr * gradient
136
+ if abs(m_new - m) / (abs(m) + CONVERGENCE_EPS) < tol:
137
+ break
138
+ m = m_new
139
+
140
+ m_scale = m * self.ultimate_y / self.ultimate_x
141
+
142
+ if show:
143
+ ani = RmsPropAnimation(xs, ys, m_records, lr_records, loss_records)
144
+ ani.play()
145
+
146
+ return m_scale
147
+
148
+ def find_break_point(
149
+ self,
150
+ threshold: float = BREAK_THRESHOLD,
151
+ show: bool = True,
152
+ ) -> tuple[float, float]:
153
+ """Locates the first point where the normalized stress curve's slope
154
+ drops below -threshold, signaling the specimen break.
155
+
156
+ Args:
157
+ threshold: Slope drop (in normalized stress per sample) that
158
+ signals a break.
159
+ show: Plots the detected break point over the normalized curve.
160
+
161
+ Returns:
162
+ The (strain, stress) of the break point in raw units, or the last
163
+ sample's coordinates if no break is detected.
164
+ """
165
+ xs = self.strain_norm
166
+ ys = self.stress_norm
167
+
168
+ gradient = -np.gradient(ys)
169
+ positions = np.where(gradient > threshold)[0]
170
+ position = int(positions[0]) if positions.size > 0 else -1
171
+ label = int(xs.index[position])
172
+
173
+ if show:
174
+ break_pt_norm = float(xs[label]), float(ys[label])
175
+ graph = BreakDetectGraph(xs, ys, gradient, threshold, break_pt_norm)
176
+ graph.show()
177
+
178
+ return float(self.strain_raw[label]), float(self.stress_raw[label])
179
+
180
+ @staticmethod
181
+ def _compute_enclosed_area(
182
+ m: float, x0: float, y0: float, xs: pd.Series, ys: pd.Series
183
+ ) -> float:
184
+ shift = y0 - m * x0
185
+ secant = m * xs + shift
186
+ clipped = np.clip(ys - secant, 0, None)
187
+ return float(integrate.trapezoid(clipped, xs))
188
+
189
+ @staticmethod
190
+ def _update_rmsprop(v: float, gradient: float) -> tuple[float, float]:
191
+ v_new = RMS_PROP_ALPHA * v + (1 - RMS_PROP_ALPHA) * (gradient**2)
192
+ lr = RMS_PROP_BASE_LR / (v_new**0.5 + RMS_PROP_EPS)
193
+ return v_new, lr
@@ -0,0 +1,379 @@
1
+ import matplotlib.pyplot as plt
2
+ import numpy as np
3
+ import pandas as pd
4
+ from matplotlib import animation
5
+ from matplotlib.collections import PathCollection, PolyCollection
6
+ from matplotlib.lines import Line2D
7
+ from matplotlib.text import Annotation
8
+
9
+ # Shared constants
10
+ MARKER_SIZE = 49
11
+ MARKER_ZORDER = 5
12
+ LINE_WIDTH = 1.5
13
+ SECANT_POSITION_DIVISOR = 50
14
+ ANNOTATION_OFFSET_LEFT = (5, 0)
15
+ ANNOTATION_OFFSET_RIGHT = (-10, 0)
16
+ COLOR_BLUE = "tab:blue"
17
+ COLOR_ORANGE = "tab:orange"
18
+
19
+ # RmsPropAnimation constants
20
+ FIG_SIZE_RMSPROP = (4.8, 6)
21
+ HEIGHT_RATIOS_RMSPROP = [2.4, 0.8, 0.8]
22
+ ANI_INTERVAL = 50
23
+ FRAME_SIZE_REQUEST = 50
24
+ ALPHA_FILL = 0.3
25
+ COLOR_FILL = "lightsteelblue"
26
+ PROGRESS_MARKER_EDGECOLOR = "white"
27
+
28
+ # Iso527Graph constants
29
+ FIG_SIZE_ISO527 = (4.8, 3.6)
30
+
31
+ # UltimatePointGraph constants
32
+ FIG_SIZE_ULTIMATE = (4.8, 3.6)
33
+
34
+ # BreakDetectGraph constants
35
+ FIG_SIZE_BREAK = (4.8, 4.8)
36
+ HEIGHT_RATIOS_BREAK = [2.4, 0.8]
37
+
38
+ Point = tuple[float, float]
39
+
40
+
41
+ def _get_secant_line(modulus: float, strain: pd.Series, stress: pd.Series) -> pd.Series:
42
+ """Calculates a secant line pinned directly to the initial data point."""
43
+ intercept = stress.values[0] - modulus * strain.values[0]
44
+ return modulus * strain + intercept
45
+
46
+
47
+ def _get_annotation_pos(strain: pd.Series, secant: pd.Series) -> Point:
48
+ """Determines a stable, indexed coordinate for placing text annotations."""
49
+ idx = max(1, len(strain) // SECANT_POSITION_DIVISOR)
50
+ return float(strain.iloc[idx]), float(secant.iloc[idx])
51
+
52
+
53
+ def _show_figure(fig: plt.Figure) -> None:
54
+ """Displays only the given figure, leaving any other open figures untouched."""
55
+ fig.canvas.manager.show()
56
+ fig.canvas.mpl_connect("close_event", lambda _: fig.canvas.stop_event_loop())
57
+ fig.canvas.start_event_loop()
58
+
59
+
60
+ class RmsPropAnimation:
61
+ """Animates the RMSProp fit: a moving secant line over the stress-strain
62
+ curve alongside the learning-rate and loss traces. Call play() to start."""
63
+
64
+ _fig: plt.Figure
65
+ _ax_norm: plt.Axes
66
+ _ax_lr: plt.Axes
67
+ _ax_loss: plt.Axes
68
+ _line_secant: Line2D
69
+ _ann_secant: Annotation
70
+ _lr_marker: PathCollection
71
+ _loss_marker: PathCollection
72
+ _fill: PolyCollection | None
73
+ _ani: animation.FuncAnimation | None
74
+ _rids: list[int]
75
+
76
+ def __init__(
77
+ self,
78
+ strain: pd.Series,
79
+ stress: pd.Series,
80
+ m_records: list[float],
81
+ lr_records: list[float],
82
+ loss_records: list[float],
83
+ ):
84
+ self.strain = strain
85
+ self.stress = stress
86
+ self.m_records = m_records
87
+ self.lr_records = lr_records
88
+ self.loss_records = loss_records
89
+
90
+ self._fig, axs = plt.subplots(
91
+ nrows=3,
92
+ ncols=1,
93
+ figsize=FIG_SIZE_RMSPROP,
94
+ tight_layout=True,
95
+ height_ratios=HEIGHT_RATIOS_RMSPROP,
96
+ )
97
+ self._ax_norm, self._ax_lr, self._ax_loss = axs
98
+
99
+ self._setup_norm_axis()
100
+ self._setup_lr_axis()
101
+ self._setup_loss_axis()
102
+ self._fill = None
103
+ self._ani = None
104
+ self._rids = []
105
+
106
+ def _setup_norm_axis(self) -> None:
107
+ self._ax_norm.plot(
108
+ self.strain,
109
+ self.stress,
110
+ label="Stress-Strain Curve",
111
+ linewidth=LINE_WIDTH,
112
+ color=COLOR_BLUE,
113
+ )
114
+
115
+ # Placeholder y=x line, overwritten with the real secant on the first animation frame
116
+ (self._line_secant,) = self._ax_norm.plot(
117
+ self.strain,
118
+ self.strain,
119
+ label="RMSProp Fit",
120
+ linewidth=LINE_WIDTH,
121
+ linestyle="--",
122
+ color=COLOR_ORANGE,
123
+ )
124
+ self._ann_secant = self._ax_norm.annotate(
125
+ text="",
126
+ xy=_get_annotation_pos(self.strain, self.strain),
127
+ xytext=ANNOTATION_OFFSET_LEFT,
128
+ textcoords="offset points",
129
+ color=COLOR_ORANGE,
130
+ )
131
+
132
+ self._ax_norm.legend()
133
+ self._ax_norm.set(
134
+ xlabel="Normalized Strain",
135
+ ylabel="Normalized Stress",
136
+ title="RMSProp Optimization",
137
+ )
138
+ self._ax_norm.grid(True)
139
+ self.initial_ybound = self._ax_norm.get_ybound()
140
+
141
+ def _setup_lr_axis(self) -> None:
142
+ self._ax_lr.semilogy(self.lr_records, linewidth=LINE_WIDTH, color=COLOR_BLUE)
143
+ self._lr_marker = self._ax_lr.scatter(
144
+ 0,
145
+ self.lr_records[0],
146
+ facecolors=COLOR_ORANGE,
147
+ edgecolors=PROGRESS_MARKER_EDGECOLOR,
148
+ linewidth=LINE_WIDTH,
149
+ zorder=MARKER_ZORDER,
150
+ s=MARKER_SIZE,
151
+ )
152
+ self._ax_lr.set(xlabel="Iteration", ylabel="LR")
153
+ self._ax_lr.grid(True)
154
+
155
+ def _setup_loss_axis(self) -> None:
156
+ self._ax_loss.plot(self.loss_records, linewidth=LINE_WIDTH, color=COLOR_BLUE)
157
+ self._loss_marker = self._ax_loss.scatter(
158
+ 0,
159
+ self.loss_records[0],
160
+ facecolors=COLOR_ORANGE,
161
+ edgecolors=PROGRESS_MARKER_EDGECOLOR,
162
+ linewidth=LINE_WIDTH,
163
+ zorder=MARKER_ZORDER,
164
+ s=MARKER_SIZE,
165
+ )
166
+ self._ax_loss.set(xlabel="Iteration", ylabel="Loss")
167
+ self._ax_loss.grid(True)
168
+
169
+ def _get_record_indices(self) -> list[int]:
170
+ total = len(self.m_records)
171
+ step = max(1, total // FRAME_SIZE_REQUEST)
172
+ return list(range((total - 1) % step, total, step))
173
+
174
+ def _update_frame(self, fid: int) -> None:
175
+ rid = self._rids[fid]
176
+ m = self.m_records[rid]
177
+ secant = _get_secant_line(m, self.strain, self.stress)
178
+
179
+ # Redraw secant profile line adjustments
180
+ self._line_secant.set_ydata(secant)
181
+ self._ann_secant.set_text(f"E = {m:.2f}")
182
+ self._ann_secant.xy = _get_annotation_pos(self.strain, secant)
183
+
184
+ # Update shaded regional error tracking
185
+ if self._fill:
186
+ self._fill.remove()
187
+
188
+ self._fill = self._ax_norm.fill_between(
189
+ x=self.strain,
190
+ y1=self.stress,
191
+ y2=secant,
192
+ where=(self.stress > secant),
193
+ alpha=ALPHA_FILL,
194
+ color=COLOR_FILL,
195
+ )
196
+
197
+ # Update dynamic progress indicator markers
198
+ self._lr_marker.set_offsets(np.array([[rid, self.lr_records[rid]]]))
199
+ self._loss_marker.set_offsets(np.array([[rid, self.loss_records[rid]]]))
200
+ self._ax_norm.set_ybound(*self.initial_ybound)
201
+
202
+ def play(self) -> None:
203
+ self._rids = self._get_record_indices()
204
+ self._ani = animation.FuncAnimation(
205
+ fig=self._fig,
206
+ func=self._update_frame,
207
+ frames=len(self._rids),
208
+ interval=ANI_INTERVAL,
209
+ blit=False,
210
+ repeat=False,
211
+ )
212
+ _show_figure(self._fig)
213
+
214
+
215
+ class Iso527Graph:
216
+ """Renders the static ISO 527 stress-strain curve with the fitted secant
217
+ line. Call show() to display it."""
218
+
219
+ _fig: plt.Figure
220
+
221
+ def __init__(
222
+ self,
223
+ strain: pd.Series,
224
+ stress: pd.Series,
225
+ secant: pd.Series,
226
+ m: float,
227
+ ):
228
+ self._fig, ax = plt.subplots(figsize=FIG_SIZE_ISO527, tight_layout=True)
229
+
230
+ ax.plot(
231
+ strain,
232
+ stress,
233
+ label="Stress-Strain Curve",
234
+ linewidth=LINE_WIDTH,
235
+ color=COLOR_BLUE,
236
+ )
237
+ ybound = ax.get_ybound()
238
+
239
+ ax.plot(
240
+ strain,
241
+ secant,
242
+ label="ISO 527 Fit",
243
+ linewidth=LINE_WIDTH,
244
+ linestyle="--",
245
+ color=COLOR_ORANGE,
246
+ )
247
+
248
+ ax.annotate(
249
+ text=f"E = {m:.1f}",
250
+ xy=_get_annotation_pos(strain, secant),
251
+ xytext=ANNOTATION_OFFSET_LEFT,
252
+ textcoords="offset points",
253
+ color=COLOR_ORANGE,
254
+ )
255
+
256
+ ax.set(xlabel="Strain", ylabel="Stress", title="ISO 527 Regression")
257
+ ax.grid(True)
258
+ ax.set_ybound(*ybound)
259
+ ax.legend()
260
+
261
+ def show(self) -> None:
262
+ _show_figure(self._fig)
263
+
264
+
265
+ class UltimatePointGraph:
266
+ """Renders the stress-strain curve with the ultimate (peak stress) point
267
+ marked. Call show() to display it."""
268
+
269
+ _fig: plt.Figure
270
+
271
+ def __init__(
272
+ self,
273
+ strain: pd.Series,
274
+ stress: pd.Series,
275
+ ultimate_point: Point,
276
+ ):
277
+ self._fig, ax = plt.subplots(figsize=FIG_SIZE_ULTIMATE, tight_layout=True)
278
+
279
+ ax.plot(
280
+ strain,
281
+ stress,
282
+ label="Stress-Strain Curve",
283
+ linewidth=LINE_WIDTH,
284
+ color=COLOR_BLUE,
285
+ )
286
+
287
+ ux, uy = ultimate_point
288
+ ax.scatter(
289
+ ux,
290
+ uy,
291
+ marker="^",
292
+ s=MARKER_SIZE,
293
+ c=COLOR_ORANGE,
294
+ label="Ultimate Point",
295
+ zorder=MARKER_ZORDER,
296
+ )
297
+ ax.annotate(
298
+ f"({ux:.2f}, {uy:.2f})",
299
+ xy=(ux, uy),
300
+ xytext=ANNOTATION_OFFSET_RIGHT,
301
+ textcoords="offset points",
302
+ ha="right",
303
+ va="center",
304
+ color=COLOR_ORANGE,
305
+ )
306
+
307
+ ax.set(xlabel="Strain", ylabel="Stress", title="Ultimate Point Detection")
308
+ ax.grid(True)
309
+ ax.legend()
310
+
311
+ def show(self) -> None:
312
+ _show_figure(self._fig)
313
+
314
+
315
+ class BreakDetectGraph:
316
+ """Renders the stress-strain curve with the detected break point and its
317
+ underlying gradient trace. Call show() to display it."""
318
+
319
+ _fig: plt.Figure
320
+ _ax_norm: plt.Axes
321
+ _ax_break: plt.Axes
322
+
323
+ def __init__(
324
+ self,
325
+ strain: pd.Series,
326
+ stress: pd.Series,
327
+ bindex: np.ndarray,
328
+ threshold: float,
329
+ break_point: tuple[float, float],
330
+ ):
331
+ self._fig, axs = plt.subplots(
332
+ nrows=2,
333
+ ncols=1,
334
+ figsize=FIG_SIZE_BREAK,
335
+ tight_layout=True,
336
+ height_ratios=HEIGHT_RATIOS_BREAK,
337
+ )
338
+
339
+ self._ax_norm, self._ax_break = axs
340
+
341
+ self._ax_norm.plot(
342
+ strain,
343
+ stress,
344
+ label="Stress-Strain Curve",
345
+ linewidth=LINE_WIDTH,
346
+ color=COLOR_BLUE,
347
+ )
348
+
349
+ self._ax_norm.set(ylabel="Normalized Stress", title="Break Point Detection")
350
+ self._ax_norm.grid(True)
351
+
352
+ bx, by = break_point
353
+ self._ax_norm.scatter(
354
+ bx,
355
+ by,
356
+ marker="x",
357
+ s=MARKER_SIZE,
358
+ c=COLOR_ORANGE,
359
+ label="Break Point",
360
+ zorder=MARKER_ZORDER,
361
+ )
362
+ self._ax_norm.annotate(
363
+ f"({bx:.2f}, {by:.2f})",
364
+ xy=(bx, by),
365
+ xytext=ANNOTATION_OFFSET_RIGHT,
366
+ textcoords="offset points",
367
+ ha="right",
368
+ va="center",
369
+ color=COLOR_ORANGE,
370
+ )
371
+ self._ax_norm.legend()
372
+
373
+ self._ax_break.plot(strain, bindex)
374
+ self._ax_break.axhline(threshold, color=COLOR_ORANGE, linestyle="--")
375
+ self._ax_break.grid(True)
376
+ self._ax_break.set(xlabel="Normalized Strain", ylabel="Gradient")
377
+
378
+ def show(self) -> None:
379
+ _show_figure(self._fig)
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: mechprops-extractor
3
+ Version: 0.1.0
4
+ Summary: Extract Young's modulus, ultimate point, and break-point from stress-strain curves
5
+ Author-email: wyatthoho <wyatthoho@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Wyatt Ho
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/wyatthoho/mechprops-extractor
29
+ Project-URL: Repository, https://github.com/wyatthoho/mechprops-extractor
30
+ Keywords: stress-strain,mechanical-properties,materials-testing,youngs-modulus
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Science/Research
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Topic :: Scientific/Engineering
37
+ Requires-Python: >=3.12
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Requires-Dist: pandas>=2.3
41
+ Requires-Dist: matplotlib>=3.10
42
+ Requires-Dist: scipy>=1.16
43
+ Dynamic: license-file
44
+
45
+ # Mechanical Properties Extractor
46
+
47
+ Extracts Young's modulus, ultimate point, and break-point properties from
48
+ stress-strain curve data, via ISO 527 linear regression, a custom
49
+ RMSProp-based curve fit, or break-point detection.
50
+
51
+ ## Features
52
+
53
+ - Young's modulus via ISO 527 secant-line linear regression
54
+ - Young's modulus via a custom RMSProp-based enclosed-area fit
55
+ - Ultimate (peak stress) point detection on the stress-strain curve
56
+ - Break-point (failure) detection on the stress-strain curve
57
+ - Built-in plots and an animation of the RMSProp fitting process
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ pip install mechprops-extractor
63
+ ```
64
+
65
+ For development (editable install):
66
+
67
+ ```bash
68
+ pip install -e .
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ Load a stress-strain CSV (`strain`, `stress` columns) into a `StressStrainCurve`,
74
+ then call any combination of its analysis methods. The example below uses one
75
+ of the sample datasets bundled in [`data/`](data):
76
+
77
+ ```python
78
+ import pandas as pd
79
+
80
+ from mechprops import StressStrainCurve
81
+
82
+ df = pd.read_csv("data/polyimide-stress-strain.csv")
83
+ curve = StressStrainCurve(df["strain"], df["stress"])
84
+
85
+ curve.fit_modulus_iso527() # Young's modulus via ISO 527 linear regression
86
+ curve.fit_modulus_rmsprop() # Young's modulus via RMSProp area minimization
87
+ curve.find_ultimate_point() # (strain, stress) at the ultimate (peak stress) point
88
+ curve.find_break_point() # (strain, stress) at the detected break point
89
+ ```
90
+
91
+ Each method returns its computed value and, by default, opens a plot window
92
+ illustrating the result — close it to continue. Pass `show=False` to skip the
93
+ plot and get the value only.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/mechprops/__init__.py
5
+ src/mechprops/curve.py
6
+ src/mechprops/plotter.py
7
+ src/mechprops_extractor.egg-info/PKG-INFO
8
+ src/mechprops_extractor.egg-info/SOURCES.txt
9
+ src/mechprops_extractor.egg-info/dependency_links.txt
10
+ src/mechprops_extractor.egg-info/requires.txt
11
+ src/mechprops_extractor.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ pandas>=2.3
2
+ matplotlib>=3.10
3
+ scipy>=1.16