
8.6 Material properties and the tensile test
We now have a measure of internal force and a measure of deformation, and they are logically independent: nothing said so far connects them. The connection is supplied by the material, through a constitutive relation, and unlike equilibrium and kinematics it cannot be derived. It has to be measured. This chapter covers the simplest such relation, linear elasticity, the experiment that produces it, and what to do when the material in front of you does not obey it.
That last part carries most of the weight. Linear elasticity describes a material that is linear and isotropic, and the specimens in this chapter are neither.
They are made by fused filament fabrication, or FFF, which is what an ordinary desktop 3D printer does: a thermoplastic filament is melted and laid down as a sequence of adjacent roads, stacked in layers, each fusing to its neighbours as it cools. The material is polylactic acid, PLA, a stiff thermoplastic polyester that is the usual filament on such machines and the one the laboratory uses. A printed part is therefore not a block of PLA. It is an assembly of PLA roads together with the bonds between them, and that distinction runs through everything below.
Metals fit this theory far better, and they are not absent here. The idealised curve below is a ductile metal, the offset yield convention comes from the metals standards, and the moduli tabulated in the next section are for steel, aluminium and titanium, because that is where linear elasticity is most convincing and where its whole vocabulary was invented.
The measurements are printed PLA for two reasons. It is the material our students actually manufacture with, so it is the one whose stiffness they will need to know. And a material that fits the model exactly teaches nothing about the model: evaluate a mild steel bar and linear elasticity looks like a fact about nature, whereas evaluating something that resists the assumptions makes the edges of the model visible, which is the more useful thing to come away with. Why printing runs through the book at all is set out in the introduction.
We fit the linear model anyway, because no part can be sized without a stiffness, but fitting it is a modelling decision with conditions attached and not the measurement of a constant. Keeping those two things apart is the whole subject of this chapter.
Hooke’s law and Young’s modulus
The simplest constitutive relation is linear,
\[ \boxed{\sigma = E\varepsilon} \tag{8.6.1}\]
known as Hooke’s law, in which the constant of proportionality \(E\) is the Young’s modulus, or modulus of elasticity. It has the same units as stress, since strain is dimensionless, and it is the stiffness of the material in the same way that a spring constant is the stiffness of a spring.
Code
from scipy.interpolate import PchipInterpolator
# Anchor points for a schematic ductile response. Not to scale.
e_pl, s_pl = 0.13, 0.55 # proportional limit
e_el, s_el = 0.16, 0.60 # elastic limit
e_Y, s_Y = 0.22, 0.62 # yield stress, start of the plateau
e_p2 = 0.55 # end of the yield plateau
e_u, s_u = 1.40, 1.00 # ultimate stress
e_f, s_f = 2.00, 0.80 # fracture, engineering
s_tf = 1.62 # true fracture stress
eng = PchipInterpolator([0, e_pl, e_el, e_Y, e_p2, 0.80, 1.05, e_u, 1.70, e_f],
[0, s_pl, s_el, s_Y, s_Y, 0.82, 0.93, s_u, 0.97, s_f])
tru = PchipInterpolator([0, e_pl, e_Y, e_p2, 0.80, 1.05, e_u, 1.70, e_f],
[0, s_pl, s_Y, 0.70, 0.90, 1.02, 1.16, 1.36, s_tf])
x = np.linspace(0, e_f, 700)
fig, ax = plt.subplots(figsize=(8.4, 5.8))
CURVE = '#1F7A99'
for x0, x1, colour, label in [(0.0, e_Y, '#F0C89A', 'elastic\nregion'),
(e_Y, e_p2, '#D89A4E', 'yielding'),
(e_p2, e_u, '#B7D5B4', 'strain\nhardening'),
(e_u, e_f, '#7FB185', 'necking')]:
xs = np.linspace(x0, x1, 200)
ax.fill_between(xs, 0, eng(xs), color=colour, zorder=1)
ax.annotate(label, ((x0 + x1)/2, -0.09), ha='center', va='top', fontsize=9.5)
ax.plot(x, tru(x), color=CURVE, lw=2.2, zorder=3)
ax.plot(x, eng(x), color=CURVE, lw=2.6, zorder=4)
for px, py in [(e_pl, s_pl), (e_el, s_el), (e_Y, s_Y), (e_u, s_u), (e_f, s_f), (e_f, s_tf)]:
ax.plot(px, py, 'o', ms=6, mfc='white', mec=CURVE, mew=1.6, zorder=5)
for y, lab in [(s_pl, r'$\sigma_{pl}$'), (s_Y, r'$\sigma_Y$'), (s_f, r'$\sigma_f$'),
(s_u, r'$\sigma_u$'), (s_tf, r"$\sigma_f'$")]:
ax.plot([0, e_f], [y, y], color='0.55', lw=0.6, zorder=2)
ax.annotate(lab, (-0.04, y), ha='right', va='center', fontsize=11)
ann = dict(fontsize=10, arrowprops=dict(arrowstyle='-', lw=0.8, color='0.35'))
ax.annotate('proportional limit', (e_pl, s_pl), xytext=(0.42, 1.10), **ann)
ax.annotate('elastic limit', (e_el, s_el), xytext=(0.42, 0.97), **ann)
ax.annotate('yield stress', (e_Y, s_Y), xytext=(0.42, 0.84), **ann)
ax.annotate('ultimate stress', (e_u, s_u), xytext=(1.52, 1.28), ha='left', **ann)
ax.annotate('fracture stress', (e_f, s_f), xytext=(1.62, 0.44), ha='center', **ann)
ax.annotate('true fracture stress', (e_f, s_tf), xytext=(1.05, 1.80), ha='center', **ann)
xa, xb = 0.035, 0.095 # the modulus as a slope triangle
ax.plot([xa, xb, xb], [eng(xa), eng(xa), eng(xb)], color='0.25', lw=1.0, zorder=5)
ax.annotate('$E$', (xa - 0.015, (eng(xa) + eng(xb))/2), ha='right', va='center', fontsize=12)
for x0, x1, label in [(0.0, e_Y, 'elastic\nbehaviour'), (e_Y, e_f, 'plastic behaviour')]:
ax.annotate('', (x0, -0.30), xytext=(x1, -0.30),
arrowprops=dict(arrowstyle='<->', lw=0.9, color='0.35'))
ax.annotate(label, ((x0 + x1)/2, -0.33), ha='center', va='top', fontsize=9.5)
for xb_ in (0.0, e_Y, e_f):
ax.plot([xb_, xb_], [-0.06, -0.33], color='0.55', lw=0.6, zorder=2)
ax.set_xlim(-0.30, 2.30); ax.set_ylim(-0.62, 2.00)
ax.set_xlabel(r'$\varepsilon$', fontsize=13, loc='right')
ax.set_ylabel(r'$\sigma$', fontsize=13, rotation=0, loc='top', labelpad=12)
ax.spines['top'].set_visible(False); ax.spines['right'].set_visible(False)
ax.spines['left'].set_position(('data', 0)); ax.spines['bottom'].set_position(('data', 0))
ax.set_xticks([]); ax.set_yticks([])
plt.tight_layout()
plt.show()8.6.1 holds only in the elastic region, the part of the response in which deformation grows linearly with load and vanishes when the load is removed. Not every material has a well defined elastic region, and some have none worth speaking of, so the law is a model rather than a fact about matter. Where it applies, the definition of the modulus follows from the definitions of stress and strain,
\[ E := \frac{\Delta\sigma}{\Delta\varepsilon} = \frac{F/A_0}{\Delta L / L_0} = \frac{F L_0}{A_0 \Delta L} \tag{8.6.2}\]
valid as long as \(\Delta L \ll L_0\), which is the same small-deformation assumption that produced 8.5.1. Typical values are \(E \approx 210\,000~\text{MPa}\) for steel, \(68\,000~\text{MPa}\) for aluminium and \(115\,000~\text{MPa}\) for titanium. Printed polymers sit two orders of magnitude lower, around \(2\,000\) to \(3\,500~\text{MPa}\) for PLA and for ABS, the other filament in common use. These figures vary with composition, temperature, strain rate and, for cast or printed material, with position inside the part.
Two assumptions that printed parts break
Figure 8.6.1 is a ductile metal, and metals are what the machinery of linear elasticity was built around. A printed polymer departs from that picture in two ways, and both survive into every number this chapter produces.
It is not linear
A metal has a straight portion. Its slope comes from the stiffness of the atomic bonding, it is very nearly the same on loading and on unloading, and a line fitted anywhere inside that portion gives the same answer. None of it holds for a printed PLA specimen, whose slope falls continuously from the first measurement onwards, as Figure 8.6.4 shows, so no region exists in which a line is uniquely determined, and no slope exists that a second laboratory would recover from a different but equally reasonable choice.
What we report as \(E\) is therefore a secant: the average stiffness between two strains we picked. Quoted without those two strains it means nothing, and this is not a caveat to be tucked into a footnote. It is what the number is.
It is not isotropic
A material that behaves the same in every direction is isotropic, and that is what Hooke’s law with a single \(E\) describes. A rolled steel plate, a drawn aluminium extrusion, a fibre composite and a 3D printed part are all anisotropic to some degree: they have different stiffness and different strength along different axes.
For printed parts the anisotropy is severe and it is a design variable rather than a nuisance. Material deposited within a layer is continuous, whereas adjacent layers are joined only where they fused, so a specimen loaded across the layers can fail at a fraction of the strength of one loaded along them. A tensile test therefore characterises a material and a print orientation together, and reporting one without the other says little. This is the reason the specimens in the laboratory are printed to a specified orientation, and the reason that comparing results between groups requires the print parameters to be reported alongside the mechanical data.
What that costs the standard
ISO 527 is the tensile standard for plastics and it is a good one. ISO 527-2 fixes the test conditions for moulding and extrusion plastics, with specimens moulded to shape or machined from moulded plate [1]. There is the mismatch, and it is worth stating precisely because the imprecise version is easy to refute: the material class is right, the manufacturing route is not ours. A moulded bar is dense, close enough to isotropic, and carries a skin the process gives it. A printed bar is a bonded assembly of extruded roads with a deliberate internal architecture and a skin whose thickness is a slicer setting.
A standard written for one is not automatically wrong for the other, but it stops being self-evidently right, and the parts of it that encode assumptions about the material have to be checked rather than followed. The strain window prescribed for fitting the modulus is exactly such a part. The end of this chapter checks it against our own measurements and rejects it.
A standard does exist for our process. ISO/ASTM 52903-2 covers material extrusion of plastics and includes guidance on building and testing specimens so that direction-dependent properties are captured [2]. It does not settle the fit-window question, which is why the rest of this chapter is needed, but it is the right place to start on specimen design and on what to report alongside a number.
Why compute a modulus at all
If the material is neither linear nor isotropic, fitting a linear isotropic constant to it invites the objection that the result describes nothing. The objection is sound and the answer is short: no part can be sized without a stiffness. Deflection under load, buckling, natural frequency and every finite element model require one, and a number carrying its conditions is worth more than no number at all.
What the objection does rule out is treating the result as a constant of the material in the way \(210\,000~\text{MPa}\) is a constant for steel. For a printed part the stiffness is a property of the material, the print orientation, the print settings and the strain range over which it was measured. All four belong in the report, and a value quoted without them cannot be checked, compared or reused.
The tensile test
A real record looks less tidy than Figure 8.6.1. Figure 8.6.2 is one, a printed polymer specimen taken to failure on our own machine, with the elastic line fitted rather than drawn on by hand.
Code
def read_utm(path):
"""Read a UTM export: header comments into a dict, columns into arrays."""
meta, rows, names = {}, [], None
for line in open(path, encoding='utf-8'):
line = line.rstrip('\n')
if line.startswith('#'):
body = line[1:].strip()
if ':' in body:
key, value = body.split(':', 1)
meta[key.strip()] = value.strip()
elif line.strip():
if names is None:
names = line.split(',')
else:
rows.append(line.split(','))
table = np.array(rows, dtype=float)
return meta, {name: table[:, i] for i, name in enumerate(names)}
meta, col = read_utm('testData/UTM_Test_20260830_122259.csv')
tracked = (col['DIC_Blobs'] == 2) & (col['DIC_Cauchy'] != 0.0)
eps_m, sigma_m = col['DIC_Cauchy'][tracked], col['Stress_MPa'][tracked]
fit = (eps_m >= 0.0005) & (eps_m <= 0.0025) # the ISO 527 strain window
E_m, b_m = np.polyfit(eps_m[fit], sigma_m[fit], 1)
i_u = np.argmax(sigma_m)
fig, ax = plt.subplots(figsize=(7.4, 5.0))
ax.plot(col['DIC_Cauchy'][~tracked]*100, col['Stress_MPa'][~tracked], 'x', ms=4,
color='#C25B4E', alpha=.55, label=f'markers lost ({(~tracked).sum()} points)')
ax.plot(eps_m*100, sigma_m, '.', ms=3, color='#236B8E', label='measured')
ax.plot(eps_m[fit]*100, sigma_m[fit], '.', ms=5, color='#C98A2B', label='ISO 527 fit window')
line = np.array([0, 0.0125])
ax.plot(line*100, E_m*line + b_m, '--', lw=1.2, color='k',
label=f'elastic fit, $E$ = {E_m:.0f} MPa')
ax.plot(eps_m[i_u]*100, sigma_m[i_u], 'o', ms=8, mfc='none', mec='k', mew=1.4)
ax.annotate(f'$R_m$ = {sigma_m[i_u]:.1f} MPa', (eps_m[i_u]*100, sigma_m[i_u]),
xytext=(8, -4), textcoords='offset points', fontsize=9)
ax.set_xlabel(r'engineering strain $\varepsilon$ (%)')
ax.set_ylabel(r'engineering stress $\sigma$ (MPa)')
ax.set_xlim(-0.15, 3.2); ax.set_ylim(-2, 48)
ax.legend(fontsize=8.5, loc='lower right')
ax.grid(alpha=.25)
plt.tight_layout()
plt.show()
The tensile test is the experiment that produces \(E\) and everything else on Figure 8.6.1. A specimen of known cross section is gripped at both ends and pulled apart at a controlled rate while the machine records the force \(F\) it applies and the extension \(\Delta L\) of a gauge length \(L_0\). The specimen is waisted so that failure occurs in the middle rather than in the grips, which is why tensile specimens have the dogbone shape.
The machine measures force and displacement, but neither is a material property: both depend on how big the specimen happens to be. Dividing by the original cross-sectional area \(A_0\) and the original gauge length \(L_0\) removes the geometry and leaves the engineering stress and engineering strain
\[ \sigma = \frac{F}{A_0}, \qquad \varepsilon = \frac{\Delta L}{L_0} \tag{8.6.3}\]
which are properties of the material alone. Both use the original geometry, which is why they are called engineering rather than true quantities. As the specimen stretches its cross section shrinks, so the true stress on the actual area is higher than the engineering stress, and the difference becomes large once necking begins. For determining the elastic constants the distinction does not matter, because the strains involved are a fraction of a percent.
Reading the curve
Four quantities are normally extracted from a test record. The Young’s modulus is the slope of the initial straight portion. The yield strength marks the end of elastic behaviour. The ultimate tensile strength \(R_m\) is the highest engineering stress the specimen reaches, and the elongation at break is the strain at which it separates, a measure of ductility.
The yield strength needs care because most materials do not yield abruptly. Mild steel is the exception, showing a distinct upper and lower yield point that can simply be read off. Aluminium, titanium and most polymers curve over gradually, with no point that is obviously the end of the elastic region. The convention adopted by the testing standards is the offset yield strength \(R_{p0.2}\): draw a line parallel to the elastic slope but shifted to the right by a plastic strain of \(0.2\%\), and take the stress where that line crosses the measured curve. The definition is arbitrary, which is exactly why it is standardised: everyone who follows it gets the same number from the same data. The construction and the \(0.2\%\) value are prescribed for metals by ISO 6892-1 [3] and ASTM E8 [4], and for plastics by ISO 527-1 [1], which also fixes the strain window used to fit the modulus.
The offset construction depends on the fitted modulus. An error in \(E\) shifts the offset line and moves \(R_{p0.2}\) with it, so the modulus must be fitted before the yield strength is read, not the other way around.
Example: processing a test record
The evaluation is four steps. First read the file and throw away the samples the instrument could not measure. Second fit a straight line to the elastic part and read its slope as \(E\). Third shift the strain axis so that the fitted line passes through the origin. Fourth construct the offset line and find where it crosses the measurement.
We work through them on one specimen of printed PLA, nominal section \(80~\text{mm}^2\), pulled to fracture on the laboratory machine.
Step 1: The data
The machine writes a block of # comment lines carrying the specimen area, the gauge length and the load at which the strain measurement was zeroed, then one header row, then the data. Because it has already divided force by the original area and marker separation by its original value, the two columns we need are 8.6.3 ready made.
Two columns carry strain and only one of them belongs to the material. DIC_Cauchy is the separation of two markers on the specimen, tracked by a camera, relative to their separation at zero load. Motor_Strain is crosshead travel divided by gauge length, and the crosshead also moves the frame, the fixture, the grips and the shoulders of the specimen, so it overstates the strain of the gauge section by a factor of three or more. Use DIC_Cauchy.
The camera also drops frames. When the tracker cannot see exactly two markers it writes a blob count other than two, and when a frame is lost it writes a strain of exactly zero. Both appear in a raw plot as a stripe of points stacked on the \(\varepsilon = 0\) axis, and both have to go before anything is fitted.
path = 'testData/UTM_Test_20260830_122259.csv'
n_header = sum(1 for line in open(path) if line.startswith('#'))
data = np.genfromtxt(path, delimiter=',', skip_header=n_header, names=True)
tracked = (data['DIC_Blobs'] == 2) & (data['DIC_Cauchy'] != 0.0)
sigma = data['Stress_MPa'][tracked] # engineering stress [MPa]
eps = data['DIC_Cauchy'][tracked] # engineering strain [-]
ltx(r"\text{rows in the file} &=", len(tracked),
r"\\ \text{rows with both markers tracked} &=", int(tracked.sum()), aligned=True)\[ \begin{aligned}\text{rows in the file} &=1229\\ \text{rows with both markers tracked} &=779\end{aligned} \]
Step 2: Fit the modulus
The modulus is the slope of the elastic part, and the only real decision in the whole evaluation is which points belong to it. Too few and the slope is noise. Too many and the line is dragged down as the curve bends over.
A printed polymer makes this harder than the idealised picture suggests, because it has no straight portion worth the name: the slope falls continuously from the first measurement onwards. There is no window that is right in the sense of capturing a genuinely linear region, only windows that are defensible and reproducible. We use one defined as a fraction of the strain at which the specimen reaches its ultimate stress,
\[ 0.10\,\varepsilon_u \le \varepsilon \le 0.50\,\varepsilon_u \tag{8.6.4}\]
which lands on the same part of the curve whatever the material, and scales itself to something brittle as readily as to something ductile. Why this window rather than the one the testing standard prescribes, and what difference the choice makes, is taken up at the end of the chapter.
Finding \(\varepsilon_u\) comes first, because the window depends on it.
i_u = np.argmax(sigma) # the ultimate point
R_m, eps_u = sigma[i_u], eps[i_u] # ultimate strength and the strain there
window = (eps >= 0.10*eps_u) & (eps <= 0.50*eps_u)
E_fit, intercept = np.polyfit(eps[window], sigma[window], 1)
ltx(r"R_m &=", R_m, r"~\text{MPa}",
r"\\ \varepsilon_u &=", 100*eps_u, r"~\%",
r"\\ \text{fit window} &=", 100*0.10*eps_u, r"~\%\text{ to }", 100*0.50*eps_u, r"~\%",
r"\\ E &=", E_fit, r"~\text{MPa}",
r"\\ \text{points used} &=", int(window.sum()), aligned=True)\[ \begin{aligned}R_m &=40.93~\text{MPa}\\ \varepsilon_u &=1.88~\%\\ \text{fit window} &=0.19~\%\text{ to }0.94~\%\\ E &=3067.62~\text{MPa}\\ \text{points used} &=264\end{aligned} \]
Step 3: Remove the toe
The fitted line does not pass through the origin. Its intercept is the signature of the toe region, the initial slack while the grips settle and the specimen straightens, which adds extension without adding load. Anything that happened before the strain measurement was zeroed ends up there too: this file records that the camera was zeroed after a preload of \(49~\text{N}\), which on \(80~\text{mm}^2\) is \(0.61~\text{MPa}\) the specimen already carried at the moment it was told its strain was zero, and that accounts for about a quarter of the intercept.
The toe belongs to the fixture and to the procedure, not to the material, so we slide the strain axis until the fitted line does pass through the origin. The shift needed is the intercept divided by the slope.
eps_corrected = eps + intercept/E_fitStep 4: The offset construction
The offset line is the elastic line moved to the right by a plastic strain of \(0.002\), that is \(\sigma = E(\varepsilon - 0.002)\). We want the stress where the measurement crosses it. Rather than solving anything, we look at the sign of the difference between the two curves, find where it changes, and interpolate linearly across that one interval. A record that rises to a peak and then softens crosses the offset line more than once, so we take the first crossing beyond the fit window and not the last.
offset_line = E_fit * (eps_corrected - 0.002)
difference = sigma - offset_line
crossings = np.where(np.diff(np.sign(difference)))[0] # every sign change
crossing = crossings[eps_corrected[crossings] > 0.50*eps_u][0] # first beyond the window
t = -difference[crossing] / (difference[crossing+1] - difference[crossing])
R_p02 = sigma[crossing] + t*(sigma[crossing+1] - sigma[crossing])
eps_last = eps_corrected[-1] # last strain read before the markers were lost
ltx(r"E &=", E_fit, r"~\text{MPa}",
r"\\ R_{p0.2} &=", R_p02, r"~\text{MPa}",
r"\\ R_m &=", R_m, r"~\text{MPa}",
r"\\ \varepsilon_{\text{last}} &=", 100*eps_last, r"~\%", aligned=True)\[ \begin{aligned}E &=3067.62~\text{MPa}\\ R_{p0.2} &=38.26~\text{MPa}\\ R_m &=40.93~\text{MPa}\\ \varepsilon_{\text{last}} &=2.96~\%\end{aligned} \]
Reading the result
The evaluation gives a modulus of \(3068~\text{MPa}\), inside the range quoted for PLA, an offset yield strength of \(38.3~\text{MPa}\) and an ultimate strength of \(40.9~\text{MPa}\).
Two of those numbers deserve comment. The yield strength sits at \(93\%\) of the ultimate, which looks wrong and is not: a curve that bends over this gradually is not met by a line offset by \(0.2\%\) of plastic strain until the material is almost at its peak. And the last strain we can report is \(3.0\%\), which is not the elongation at break. The markers separate beyond what the tracker will follow at the moment the specimen fails, so an optical extensometer measures everything except the quantity that needs the specimen to be in one piece.
Figure 8.6.3 shows the record with both constructions drawn on it.
Code
fig, (ax, axz) = plt.subplots(1, 2, figsize=(11, 4.5))
# Left: the whole record with both results marked
ax.plot(100*eps_corrected, sigma, 'k', lw=1.2, label='measurement')
ax.plot(100*eps_corrected, offset_line, 'b--', lw=1, label=r'$0.2\%$ offset line')
ax.plot(100*eps_corrected, E_fit*eps_corrected, 'g:', lw=1.4, label='fitted elastic slope')
ax.plot(100*eps_corrected[i_u], R_m, 'ro')
ax.annotate(rf'$R_m$ = {R_m:.1f} MPa', (100*eps_corrected[i_u], R_m),
xytext=(6, -12), textcoords='offset points', fontsize=9)
ax.plot(100*(eps_corrected[crossing] + t*np.diff(eps_corrected)[crossing]), R_p02, 'bo')
ax.annotate(rf'$R_{{p0.2}}$ = {R_p02:.1f} MPa',
(100*eps_corrected[crossing], R_p02),
xytext=(-8, 12), textcoords='offset points', fontsize=9, ha='right', color='b')
ax.set_xlim(0, 100*eps_corrected.max()*1.05)
ax.set_ylim(0, 1.18*R_m)
ax.set_title('Full record', fontsize=11)
ax.legend(loc='lower right', fontsize=9)
# Right: the elastic region, showing which points were actually fitted
axz.plot(100*eps_corrected, sigma, 'k', lw=1.2, label='measurement')
axz.plot(100*eps_corrected[window], sigma[window], 'o', ms=3, color='#C98A2B',
label=r'fitted points, $0.10\,\varepsilon_u$ to $0.50\,\varepsilon_u$')
axz.plot(100*eps_corrected, E_fit*eps_corrected, 'g:', lw=1.4, label='fitted elastic slope')
axz.set_xlim(0, 120*0.50*eps_u)
axz.set_ylim(0, 1.15*sigma[window].max())
axz.set_title('The elastic region and the fit window', fontsize=11)
axz.legend(loc='lower right', fontsize=9)
for a in (ax, axz):
a.grid(True)
a.set_xlabel(r'$\varepsilon$ [%]')
a.set_ylabel(r'$\sigma$ [MPa]')
plt.tight_layout()
plt.show()
The zoomed view on the right is where the judgment lies. The offset line runs parallel to the fitted slope, so any error in the fit tilts it, and the crossing then slides along a shallow part of the curve where a small vertical error means a large horizontal one. Two students who fit different windows will report different moduli and, through them, different yield strengths. Reporting the window, the strain source and the specimen geometry alongside the numbers is what makes two sets of results comparable at all.
Choosing the fit window
Everything above rests on the decision made in step 2, and a material with no straight portion offers nothing to make that decision for us. Any window is a convention. The question is only which convention, and whether the person quoting a number says which one they used.
What the standards prescribe, and why we do not follow it
ISO 527-1 removes the decision by fixing the window at \(0.05\%\) to \(0.25\%\) strain [1], and ASTM D3039 does the same for composites at \(0.1\%\) to \(0.3\%\) [5]. Fixing it is the right instinct, and we keep it: everyone who follows a rule gets the same number from the same data, which is the entire purpose of a testing standard.
What we do not keep is the particular window, because it rests on an assumption our specimens do not satisfy. It supposes that the material has a straight portion and that the portion lies between those two strains. Figure 8.6.4 shows what is there instead.
Code
half = 0.0004 # half width of the sliding fit, in strain
centres = np.arange(0.0006, 0.0100, 0.0002)
slopes = []
for c in centres:
near = (eps >= c - half) & (eps <= c + half)
slopes.append(np.polyfit(eps[near], sigma[near], 1)[0] if near.sum() >= 8 else np.nan)
fig, ax = plt.subplots(figsize=(7, 4.2))
ax.plot(100*centres, slopes, 'o-', ms=3, lw=1.2, color='#236B8E')
ax.axvspan(0.05, 0.25, color='#C25B4E', alpha=.14, label='ISO 527 window')
ax.axvspan(100*0.10*eps_u, 100*0.50*eps_u, color='#7A9E3F', alpha=.18,
label=r'$0.10\,\varepsilon_u$ to $0.50\,\varepsilon_u$')
ax.axhline(E_fit, color='k', ls='--', lw=0.9, label=f'fitted $E$ = {E_fit:.0f} MPa')
ax.set_xlabel(r'strain $\varepsilon$ (%)')
ax.set_ylabel(r'local slope $\mathrm{d}\sigma/\mathrm{d}\varepsilon$ (MPa)')
ax.set_xlim(0, 1.0)
ax.legend(fontsize=9)
ax.grid(alpha=.25)
plt.tight_layout()
plt.show()
A material does not become \(40\%\) more compliant and then stop. The steep part at the left is the instrument: the optical extensometer resolves strain in steps of about \(0.006\%\), and the ISO window spans only three pixels of marker separation, so the line is being fitted through a staircase. The standard’s window sits inside that artefact. The window used above, \(0.19\%\) to \(0.94\%\) for this specimen, sits on the plateau.
What difference it makes
The test of a window is not whether it looks right on one record but whether it gives the same answer on specimens that should be the same. Four were printed and pulled together.
Code
records = ['122259', '123728', '124746', '125231']
def evaluate(rec, lo, hi, by='strain'):
p = f'testData/UTM_Test_20260830_{rec}.csv'
n = sum(1 for line in open(p) if line.startswith('#'))
d = np.genfromtxt(p, delimiter=',', skip_header=n, names=True)
keep = (d['DIC_Blobs'] == 2) & (d['DIC_Cauchy'] != 0.0)
e, s = d['DIC_Cauchy'][keep], d['Stress_MPa'][keep]
peak = np.argmax(s)
if by == 'strain': m = (e >= lo) & (e <= hi)
elif by == 'eps_u': m = (e >= lo*e[peak]) & (e <= hi*e[peak])
else: # fraction of the ultimate stress
m = (s >= lo*s[peak]) & (s <= hi*s[peak]); m[peak+1:] = False
return np.polyfit(e[m], s[m], 1)[0], s[peak]
methods = [(r"\text{ISO 527, } 0.05\text{ to }0.25\,\%", 0.0005, 0.0025, 'strain'),
(r"\text{fixed, } 0.25\text{ to }1.00\,\%", 0.0025, 0.0100, 'strain'),
(r"0.30\,R_m \text{ to } 0.70\,R_m", 0.30, 0.70, 'stress'),
(r"0.10\,\varepsilon_u \text{ to } 0.50\,\varepsilon_u", 0.10, 0.50, 'eps_u')]
rows = []
for label, lo, hi, by in methods:
v = np.array([evaluate(r, lo, hi, by)[0] for r in records])
rows.append(r"%s & %s & %.0f & %.1f\,\%% \\" % (label, ' & '.join(f'{x:.0f}' for x in v),
v.mean(), 100*v.std(ddof=1)/v.mean()))
Rm = np.array([evaluate(r, 0.10, 0.50, 'eps_u')[1] for r in records])
rows.append(r"\hline \text{ultimate strength } R_m & %s & %.1f & %.1f\,\%% \\"
% (' & '.join(f'{x:.1f}' for x in Rm), Rm.mean(), 100*Rm.std(ddof=1)/Rm.mean()))
ltx(r"\begin{array}{l r r r r r r} \text{fit window} & " +
' & '.join(records) + r" & \text{mean} & \text{CV} \\ \hline " +
' '.join(rows) + r"\end{array}")\[ \begin{array}{l r r r r r r} \text{fit window} & 122259 & 123728 & 124746 & 125231 & \text{mean} & \text{CV} \\ \hline \text{ISO 527, } 0.05\text{ to }0.25\,\% & 3462 & 4098 & 4604 & 3772 & 3984 & 12.3\,\% \\ \text{fixed, } 0.25\text{ to }1.00\,\% & 3024 & 2572 & 2457 & 2510 & 2641 & 9.8\,\% \\ 0.30\,R_m \text{ to } 0.70\,R_m & 3112 & 3010 & 3721 & 2868 & 3177 & 11.8\,\% \\ 0.10\,\varepsilon_u \text{ to } 0.50\,\varepsilon_u & 3068 & 2755 & 3034 & 2701 & 2889 & 6.5\,\% \\ \hline \text{ultimate strength } R_m & 40.9 & 40.2 & 40.8 & 39.0 & 40.2 & 2.2\,\% \\\end{array} \]
The four specimens agree on ultimate strength to about two percent. They agree on the modulus to between six and twelve percent depending only on how it was evaluated, and the standard’s window is the worst of the four. A rule that sorts nominally identical specimens by twelve percent is measuring something other than the material.
The last column is the coefficient of variation, the standard deviation divided by the mean. It is the natural measure here because the question is not how large the modulus is but how repeatable the procedure that produced it.
What to take from this
Strength is easy to measure and stiffness is hard, and most of the difficulty sits in the evaluation rather than in the specimen. That is worth carrying into any test you run.
The deeper point is the one this chapter opened with. There is no true modulus here waiting to be recovered by a sufficiently careful experiment. There is a curve, and there are conventions for reducing it to a number, and the conventions disagree with one another by more than the specimens disagree among themselves. Following a standard does not escape that. It only means the convention was chosen by a committee, for a material made a different way, and then not stated in the report because everyone assumed it.
For the laboratory, use the window of 8.6.4 and report it alongside the number. A modulus quoted without the window it was fitted over, the strain source and the number of specimens behind it cannot be checked by anyone else.
For honesty, note that the fixed \(0.25\%\) to \(1.00\%\) window scores similarly on these four specimens, and four specimens cannot separate them. We prefer the normalised form because it needs no adjustment when the material changes, not because the evidence here is decisive. Whether it survives more specimens, more materials and a second testing machine is an open question that this department is working on.