stresspy 0.0.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.
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: stresspy
3
+ Version: 0.0.1
4
+ Summary: Geometric stress criterion modeling and tangent space decomposition
5
+ License: CC BY-NC 4.0
6
+ Classifier: Programming Language :: Python :: 3
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: numpy
10
+ Requires-Dist: scipy
11
+ Requires-Dist: pandas
12
+
13
+ # StressPy: Geometric Stress Criterion (GSC)
14
+
15
+ `stresspy` is a reference Python implementation of the deterministic numerical core of the **Geometric Stress Criterion (GSC)**. It decomposes model–data discrepancies into components that are locally accessible (tangent) and inaccessible (normal) through parameter variation.
16
+
17
+ ---
18
+
19
+ ## 1. Mathematical Background
20
+
21
+ At a specified parameter point $\hat{\theta}$, let $r = y - f(\hat{\theta})$ be the model–data discrepancy and $J = \left.\frac{\partial f}{\partial x}\right\vert{}_{\hat{\theta}}$ be the Jacobian with respect to parameter coordinates $x$.
22
+
23
+ After applying an observation-space whitening transformation $L$:
24
+ $$r_W = Lr, \qquad J_W = LJ$$
25
+
26
+ If $U_r$ contains the retained left singular vectors of $J_W$, the orthogonal components are:
27
+ $$r_{\parallel,W} = U_r U_r^\top r_W, \qquad r_{\perp,W} = r_W - r_{\parallel,W}$$
28
+
29
+ The reported stresses and normal fraction are:
30
+ $$S_{\mathrm{total}} = \Vert{}r_W\Vert{}_2^2, \quad S_{\parallel} = \Vert{}r_{\parallel,W}\Vert{}_2^2, \quad S_{\perp} = \Vert{}r_{\perp,W}\Vert{}_2^2, \quad F_{\perp} = \frac{S_{\perp}}{S_{\mathrm{total}}}$$
31
+
32
+ The minimum-norm local repair vector $\Delta x$ satisfies:
33
+ $$\Delta x = V_r \Sigma_r^{-1} U_r^\top r_W$$
34
+
35
+ Under the local linear approximation, $r - J \Delta x = r_\perp$.
36
+
37
+ ---
38
+
39
+ ## 2. Installation
40
+
41
+ ```bash
42
+ pip install stresspy
@@ -0,0 +1,30 @@
1
+ # StressPy: Geometric Stress Criterion (GSC)
2
+
3
+ `stresspy` is a reference Python implementation of the deterministic numerical core of the **Geometric Stress Criterion (GSC)**. It decomposes model–data discrepancies into components that are locally accessible (tangent) and inaccessible (normal) through parameter variation.
4
+
5
+ ---
6
+
7
+ ## 1. Mathematical Background
8
+
9
+ At a specified parameter point $\hat{\theta}$, let $r = y - f(\hat{\theta})$ be the model–data discrepancy and $J = \left.\frac{\partial f}{\partial x}\right\vert{}_{\hat{\theta}}$ be the Jacobian with respect to parameter coordinates $x$.
10
+
11
+ After applying an observation-space whitening transformation $L$:
12
+ $$r_W = Lr, \qquad J_W = LJ$$
13
+
14
+ If $U_r$ contains the retained left singular vectors of $J_W$, the orthogonal components are:
15
+ $$r_{\parallel,W} = U_r U_r^\top r_W, \qquad r_{\perp,W} = r_W - r_{\parallel,W}$$
16
+
17
+ The reported stresses and normal fraction are:
18
+ $$S_{\mathrm{total}} = \Vert{}r_W\Vert{}_2^2, \quad S_{\parallel} = \Vert{}r_{\parallel,W}\Vert{}_2^2, \quad S_{\perp} = \Vert{}r_{\perp,W}\Vert{}_2^2, \quad F_{\perp} = \frac{S_{\perp}}{S_{\mathrm{total}}}$$
19
+
20
+ The minimum-norm local repair vector $\Delta x$ satisfies:
21
+ $$\Delta x = V_r \Sigma_r^{-1} U_r^\top r_W$$
22
+
23
+ Under the local linear approximation, $r - J \Delta x = r_\perp$.
24
+
25
+ ---
26
+
27
+ ## 2. Installation
28
+
29
+ ```bash
30
+ pip install stresspy
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stresspy"
7
+ version = "0.0.1"
8
+ description = "Geometric stress criterion modeling and tangent space decomposition"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "CC BY-NC 4.0" }
12
+
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ ]
16
+
17
+ # Add any dependencies your core code needs, e.g. "numpy", "scipy"
18
+ dependencies = [
19
+ "numpy",
20
+ "scipy",
21
+ "pandas",
22
+
23
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ from .core import *
@@ -0,0 +1,374 @@
1
+ """StressPy core routines for the Geometric Stress Criterion (GSC).
2
+
3
+ This module implements the deterministic local tangent--normal decomposition.
4
+ It deliberately does not fit models, construct Jacobians, prepare biological
5
+ replicates, or perform bootstrap calibration. Those operations belong in
6
+ separate modules built around this small, testable numerical kernel.
7
+
8
+ Conventions
9
+ -----------
10
+ The discrepancy is defined as ``residual = observed - predicted``. If ``J``
11
+ is the Jacobian of the predictions with respect to the chosen parameter
12
+ coordinates, the minimum-norm local repair ``delta`` satisfies
13
+ ``J @ delta = tangent_component`` in the selected observation-space metric.
14
+ Consequently, under the linear approximation, ``residual - J @ delta`` is the
15
+ normal component.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, fields
21
+ from typing import Any, Optional, Sequence
22
+
23
+ import numpy as np
24
+ from numpy.typing import ArrayLike, NDArray
25
+
26
+
27
+ FloatArray = NDArray[np.float64]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class GSCResult:
32
+ """Result of a local GSC tangent--normal decomposition.
33
+
34
+ Stress values and the ``weighted_*`` vectors are expressed in whitened
35
+ observation coordinates. The unprefixed component vectors are mapped back
36
+ to the original observation coordinates; they are orthogonal under the
37
+ selected metric, not necessarily under the ordinary Euclidean metric.
38
+
39
+ The repair vector is minimum-norm in the parameter coordinates used to
40
+ construct the supplied Jacobian. Its magnitude is coordinate dependent.
41
+ """
42
+
43
+ n_observations: int
44
+ n_parameters: int
45
+ rank: int
46
+ rank_threshold: float
47
+ singular_values: FloatArray
48
+ total_stress: float
49
+ tangent_stress: float
50
+ normal_stress: float
51
+ normal_fraction: float
52
+ residual: FloatArray
53
+ tangent_component: FloatArray
54
+ normal_component: FloatArray
55
+ weighted_residual: FloatArray
56
+ weighted_tangent_component: FloatArray
57
+ weighted_normal_component: FloatArray
58
+ repair_vector: FloatArray
59
+ repair_norm: float
60
+ condition_number: float
61
+ tangent_basis: FloatArray
62
+ parameter_basis: FloatArray
63
+
64
+ @property
65
+ def normal_fraction_pct(self) -> float:
66
+ """Normal fraction expressed as a percentage."""
67
+
68
+ return 100.0 * self.normal_fraction
69
+
70
+ def as_dict(self) -> dict[str, Any]:
71
+ """Return a shallow dictionary representation of the result."""
72
+
73
+ result = {item.name: getattr(self, item.name) for item in fields(self)}
74
+ result["normal_fraction_pct"] = self.normal_fraction_pct
75
+ return result
76
+
77
+
78
+ def _as_vector(name: str, values: ArrayLike) -> FloatArray:
79
+ array = np.asarray(values, dtype=float)
80
+ if array.ndim != 1:
81
+ raise ValueError(f"{name} must be one-dimensional; got shape {array.shape}.")
82
+ if array.size == 0:
83
+ raise ValueError(f"{name} must not be empty.")
84
+ if not np.all(np.isfinite(array)):
85
+ raise ValueError(f"{name} must contain only finite values.")
86
+ return array
87
+
88
+
89
+ def _as_jacobian(values: ArrayLike, n_observations: int) -> FloatArray:
90
+ jacobian = np.asarray(values, dtype=float)
91
+ if jacobian.ndim != 2:
92
+ raise ValueError(
93
+ f"jacobian must be two-dimensional; got shape {jacobian.shape}."
94
+ )
95
+ if jacobian.shape[0] != n_observations:
96
+ raise ValueError(
97
+ "jacobian row count must equal the number of observations; "
98
+ f"got {jacobian.shape[0]} and {n_observations}."
99
+ )
100
+ if jacobian.shape[1] == 0:
101
+ raise ValueError("jacobian must contain at least one parameter column.")
102
+ if not np.all(np.isfinite(jacobian)):
103
+ raise ValueError("jacobian must contain only finite values.")
104
+ return jacobian
105
+
106
+
107
+ def _build_whitener(
108
+ n: int,
109
+ *,
110
+ sigma: Optional[ArrayLike],
111
+ covariance: Optional[ArrayLike],
112
+ whitener: Optional[ArrayLike],
113
+ ) -> tuple[FloatArray, FloatArray]:
114
+ supplied = sum(item is not None for item in (sigma, covariance, whitener))
115
+ if supplied > 1:
116
+ raise ValueError("Supply at most one of sigma, covariance, or whitener.")
117
+
118
+ if sigma is not None:
119
+ scale = _as_vector("sigma", sigma)
120
+ if scale.size != n:
121
+ raise ValueError(f"sigma must have length {n}; got {scale.size}.")
122
+ if np.any(scale <= 0.0):
123
+ raise ValueError("sigma values must all be strictly positive.")
124
+ return np.diag(1.0 / scale), np.diag(scale)
125
+
126
+ if covariance is not None:
127
+ cov = np.asarray(covariance, dtype=float)
128
+ if cov.shape != (n, n):
129
+ raise ValueError(f"covariance must have shape {(n, n)}; got {cov.shape}.")
130
+ if not np.all(np.isfinite(cov)):
131
+ raise ValueError("covariance must contain only finite values.")
132
+ if not np.allclose(cov, cov.T, rtol=1e-10, atol=1e-12):
133
+ raise ValueError("covariance must be symmetric.")
134
+ try:
135
+ chol = np.linalg.cholesky(cov)
136
+ except np.linalg.LinAlgError as exc:
137
+ raise ValueError("covariance must be positive definite.") from exc
138
+ whitening = np.linalg.solve(chol, np.eye(n))
139
+ return whitening, chol
140
+
141
+ if whitener is not None:
142
+ whitening = np.asarray(whitener, dtype=float)
143
+ if whitening.shape != (n, n):
144
+ raise ValueError(f"whitener must have shape {(n, n)}; got {whitening.shape}.")
145
+ if not np.all(np.isfinite(whitening)):
146
+ raise ValueError("whitener must contain only finite values.")
147
+ try:
148
+ unwhitening = np.linalg.inv(whitening)
149
+ except np.linalg.LinAlgError as exc:
150
+ raise ValueError("whitener must be nonsingular.") from exc
151
+ return whitening, unwhitening
152
+
153
+ identity = np.eye(n)
154
+ return identity, identity
155
+
156
+
157
+ def decompose(
158
+ observed: ArrayLike,
159
+ predicted: ArrayLike,
160
+ jacobian: ArrayLike,
161
+ *,
162
+ sigma: Optional[ArrayLike] = None,
163
+ covariance: Optional[ArrayLike] = None,
164
+ whitener: Optional[ArrayLike] = None,
165
+ rank_rtol: Optional[float] = None,
166
+ rank_atol: float = 0.0,
167
+ ) -> GSCResult:
168
+ """Decompose model--data discrepancy into tangent and normal components.
169
+
170
+ ``jacobian`` must be evaluated at the parameter point associated with
171
+ ``predicted`` and use the same parameter coordinates in which the repair
172
+ vector is to be reported.
173
+
174
+ Supply at most one of ``sigma``, ``covariance``, or ``whitener``. A
175
+ singular value is retained when
176
+ ``s > max(rank_atol, rank_rtol * s_max)``. If ``rank_rtol`` is omitted,
177
+ ``max(jacobian.shape) * machine_epsilon`` is used.
178
+ """
179
+
180
+ y = _as_vector("observed", observed)
181
+ y_hat = _as_vector("predicted", predicted)
182
+ if y_hat.size != y.size:
183
+ raise ValueError(
184
+ f"observed and predicted must have equal length; got {y.size} and {y_hat.size}."
185
+ )
186
+ J = _as_jacobian(jacobian, y.size)
187
+
188
+ if rank_rtol is not None and (not np.isfinite(rank_rtol) or rank_rtol < 0.0):
189
+ raise ValueError("rank_rtol must be finite and non-negative.")
190
+ if not np.isfinite(rank_atol) or rank_atol < 0.0:
191
+ raise ValueError("rank_atol must be finite and non-negative.")
192
+
193
+ L, L_inv = _build_whitener(
194
+ y.size, sigma=sigma, covariance=covariance, whitener=whitener
195
+ )
196
+ residual = y - y_hat
197
+ residual_w = L @ residual
198
+ jacobian_w = L @ J
199
+
200
+ U, singular_values, Vt = np.linalg.svd(jacobian_w, full_matrices=False)
201
+ s_max = float(singular_values[0]) if singular_values.size else 0.0
202
+ if rank_rtol is None:
203
+ rank_rtol = max(jacobian_w.shape) * np.finfo(float).eps
204
+ threshold = max(float(rank_atol), float(rank_rtol) * s_max)
205
+ rank = int(np.count_nonzero(singular_values > threshold))
206
+
207
+ U_r = U[:, :rank]
208
+ s_r = singular_values[:rank]
209
+ V_r = Vt[:rank, :].T
210
+
211
+ if rank:
212
+ tangent_w = U_r @ (U_r.T @ residual_w)
213
+ repair = V_r @ ((U_r.T @ residual_w) / s_r)
214
+ condition_number = float(s_r[0] / s_r[-1])
215
+ else:
216
+ tangent_w = np.zeros_like(residual_w)
217
+ repair = np.zeros(J.shape[1], dtype=float)
218
+ condition_number = float("inf")
219
+
220
+ normal_w = residual_w - tangent_w
221
+ tangent = L_inv @ tangent_w
222
+ normal = L_inv @ normal_w
223
+
224
+ total_stress = float(residual_w @ residual_w)
225
+ tangent_stress = float(tangent_w @ tangent_w)
226
+ normal_stress = float(normal_w @ normal_w)
227
+ normal_fraction = (
228
+ normal_stress / total_stress if total_stress > 0.0 else float("nan")
229
+ )
230
+
231
+ return GSCResult(
232
+ n_observations=y.size,
233
+ n_parameters=J.shape[1],
234
+ rank=rank,
235
+ rank_threshold=threshold,
236
+ singular_values=singular_values,
237
+ total_stress=total_stress,
238
+ tangent_stress=tangent_stress,
239
+ normal_stress=normal_stress,
240
+ normal_fraction=normal_fraction,
241
+ residual=residual,
242
+ tangent_component=tangent,
243
+ normal_component=normal,
244
+ weighted_residual=residual_w,
245
+ weighted_tangent_component=tangent_w,
246
+ weighted_normal_component=normal_w,
247
+ repair_vector=repair,
248
+ repair_norm=float(np.linalg.norm(repair)),
249
+ condition_number=condition_number,
250
+ tangent_basis=U_r,
251
+ parameter_basis=V_r,
252
+ )
253
+
254
+
255
+ def decompose_blocks(
256
+ observed_blocks: Sequence[ArrayLike],
257
+ predicted_blocks: Sequence[ArrayLike],
258
+ jacobian_blocks: Sequence[ArrayLike],
259
+ *,
260
+ sigma_blocks: Optional[Sequence[ArrayLike]] = None,
261
+ rank_rtol: Optional[float] = None,
262
+ rank_atol: float = 0.0,
263
+ ) -> GSCResult:
264
+ """Stack independent blocks and perform one joint GSC interrogation.
265
+
266
+ This convenience function supports diagonal weighting only. Use
267
+ :func:`decompose` with a full covariance matrix when errors are correlated
268
+ across blocks.
269
+ """
270
+
271
+ if len(observed_blocks) == 0:
272
+ raise ValueError("At least one observation block is required.")
273
+ if not (
274
+ len(observed_blocks) == len(predicted_blocks) == len(jacobian_blocks)
275
+ ):
276
+ raise ValueError(
277
+ "observed_blocks, predicted_blocks, and jacobian_blocks must have equal length."
278
+ )
279
+ if sigma_blocks is not None and len(sigma_blocks) != len(observed_blocks):
280
+ raise ValueError("sigma_blocks must have the same number of blocks as observed_blocks.")
281
+
282
+ observed = np.concatenate(
283
+ [_as_vector("observation block", item) for item in observed_blocks]
284
+ )
285
+ predicted = np.concatenate(
286
+ [_as_vector("prediction block", item) for item in predicted_blocks]
287
+ )
288
+ jacobian = np.vstack([np.asarray(item, dtype=float) for item in jacobian_blocks])
289
+ sigma = None
290
+ if sigma_blocks is not None:
291
+ sigma = np.concatenate(
292
+ [_as_vector("sigma block", item) for item in sigma_blocks]
293
+ )
294
+
295
+ return decompose(
296
+ observed,
297
+ predicted,
298
+ jacobian,
299
+ sigma=sigma,
300
+ rank_rtol=rank_rtol,
301
+ rank_atol=rank_atol,
302
+ )
303
+
304
+
305
+ def floor_sigma(
306
+ sigma: ArrayLike,
307
+ *,
308
+ quantile: float = 0.10,
309
+ floor: Optional[float] = None,
310
+ ) -> tuple[FloatArray, float]:
311
+ """Apply an explicit lower floor to valid positive standard deviations.
312
+
313
+ This is optional preprocessing, not part of the GSC definition. If
314
+ ``floor`` is omitted it is calculated from the requested quantile.
315
+ """
316
+
317
+ scale = _as_vector("sigma", sigma)
318
+ if np.any(scale <= 0.0):
319
+ raise ValueError("sigma values must all be strictly positive before flooring.")
320
+ if not np.isfinite(quantile) or not 0.0 <= quantile <= 1.0:
321
+ raise ValueError("quantile must be finite and lie between 0 and 1.")
322
+ if floor is None:
323
+ floor_value = float(np.quantile(scale, quantile))
324
+ else:
325
+ floor_value = float(floor)
326
+ if not np.isfinite(floor_value) or floor_value <= 0.0:
327
+ raise ValueError("floor must be finite and strictly positive.")
328
+ return np.maximum(scale, floor_value), floor_value
329
+
330
+
331
+ def jacobian_to_log_coordinates(
332
+ jacobian: ArrayLike,
333
+ parameters: ArrayLike,
334
+ *,
335
+ log_mask: Optional[ArrayLike] = None,
336
+ ) -> FloatArray:
337
+ """Convert a raw-parameter Jacobian to mixed identity/log coordinates.
338
+
339
+ For a log-transformed parameter, ``x_j = log(theta_j)``, the corresponding
340
+ Jacobian column is multiplied by ``theta_j``. Columns not selected by
341
+ ``log_mask`` are unchanged. If ``log_mask`` is omitted, every parameter is
342
+ treated as log-transformed.
343
+ """
344
+
345
+ raw = np.asarray(jacobian, dtype=float)
346
+ if raw.ndim != 2:
347
+ raise ValueError(f"jacobian must be two-dimensional; got shape {raw.shape}.")
348
+ J = _as_jacobian(raw, raw.shape[0])
349
+ theta = _as_vector("parameters", parameters)
350
+ if J.shape[1] != theta.size:
351
+ raise ValueError(
352
+ f"jacobian has {J.shape[1]} columns but parameters has length {theta.size}."
353
+ )
354
+ if log_mask is None:
355
+ mask = np.ones(theta.size, dtype=bool)
356
+ else:
357
+ mask = np.asarray(log_mask, dtype=bool)
358
+ if mask.shape != theta.shape:
359
+ raise ValueError(f"log_mask must have shape {theta.shape}; got {mask.shape}.")
360
+ if np.any(theta[mask] <= 0.0):
361
+ raise ValueError("Parameters selected for log transformation must be positive.")
362
+
363
+ column_scale = np.ones(theta.size, dtype=float)
364
+ column_scale[mask] = theta[mask]
365
+ return J * column_scale[None, :]
366
+
367
+
368
+ __all__ = [
369
+ "GSCResult",
370
+ "decompose",
371
+ "decompose_blocks",
372
+ "floor_sigma",
373
+ "jacobian_to_log_coordinates",
374
+ ]
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: stresspy
3
+ Version: 0.0.1
4
+ Summary: Geometric stress criterion modeling and tangent space decomposition
5
+ License: CC BY-NC 4.0
6
+ Classifier: Programming Language :: Python :: 3
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: numpy
10
+ Requires-Dist: scipy
11
+ Requires-Dist: pandas
12
+
13
+ # StressPy: Geometric Stress Criterion (GSC)
14
+
15
+ `stresspy` is a reference Python implementation of the deterministic numerical core of the **Geometric Stress Criterion (GSC)**. It decomposes model–data discrepancies into components that are locally accessible (tangent) and inaccessible (normal) through parameter variation.
16
+
17
+ ---
18
+
19
+ ## 1. Mathematical Background
20
+
21
+ At a specified parameter point $\hat{\theta}$, let $r = y - f(\hat{\theta})$ be the model–data discrepancy and $J = \left.\frac{\partial f}{\partial x}\right\vert{}_{\hat{\theta}}$ be the Jacobian with respect to parameter coordinates $x$.
22
+
23
+ After applying an observation-space whitening transformation $L$:
24
+ $$r_W = Lr, \qquad J_W = LJ$$
25
+
26
+ If $U_r$ contains the retained left singular vectors of $J_W$, the orthogonal components are:
27
+ $$r_{\parallel,W} = U_r U_r^\top r_W, \qquad r_{\perp,W} = r_W - r_{\parallel,W}$$
28
+
29
+ The reported stresses and normal fraction are:
30
+ $$S_{\mathrm{total}} = \Vert{}r_W\Vert{}_2^2, \quad S_{\parallel} = \Vert{}r_{\parallel,W}\Vert{}_2^2, \quad S_{\perp} = \Vert{}r_{\perp,W}\Vert{}_2^2, \quad F_{\perp} = \frac{S_{\perp}}{S_{\mathrm{total}}}$$
31
+
32
+ The minimum-norm local repair vector $\Delta x$ satisfies:
33
+ $$\Delta x = V_r \Sigma_r^{-1} U_r^\top r_W$$
34
+
35
+ Under the local linear approximation, $r - J \Delta x = r_\perp$.
36
+
37
+ ---
38
+
39
+ ## 2. Installation
40
+
41
+ ```bash
42
+ pip install stresspy
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/stresspy/__init__.py
4
+ src/stresspy/core.py
5
+ src/stresspy.egg-info/PKG-INFO
6
+ src/stresspy.egg-info/SOURCES.txt
7
+ src/stresspy.egg-info/dependency_links.txt
8
+ src/stresspy.egg-info/requires.txt
9
+ src/stresspy.egg-info/top_level.txt
10
+ tests/test_core.py
@@ -0,0 +1,3 @@
1
+ numpy
2
+ scipy
3
+ pandas
@@ -0,0 +1 @@
1
+ stresspy
@@ -0,0 +1,113 @@
1
+ import numpy as np
2
+ import pytest
3
+ from stresspy import (
4
+ decompose,
5
+ decompose_blocks,
6
+ floor_sigma,
7
+ jacobian_to_log_coordinates,
8
+ )
9
+
10
+ @pytest.fixture
11
+ def basic_setup():
12
+ J = np.array([[1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
13
+ predicted = np.array([1.0, 2.0, 1.0])
14
+ observed = np.array([1.1, 1.9, 0.9])
15
+ sigma = np.array([0.1, 0.1, 0.1])
16
+ return observed, predicted, J, sigma
17
+
18
+ # 1. Stress additive identity: total = tangent + normal
19
+ def test_stress_addition(basic_setup):
20
+ y, y_hat, J, sigma = basic_setup
21
+ res = decompose(y, y_hat, J, sigma=sigma)
22
+ np.testing.assert_allclose(res.total_stress, res.tangent_stress + res.normal_stress)
23
+
24
+ # 2. Local linear repair residual identity: (r - J @ repair) == normal_component
25
+ def test_linear_repair_identity(basic_setup):
26
+ y, y_hat, J, sigma = basic_setup
27
+ res = decompose(y, y_hat, J, sigma=sigma)
28
+ residual = y - y_hat
29
+ np.testing.assert_allclose(residual - J @ res.repair_vector, res.normal_component, atol=1e-12)
30
+
31
+ # 3. Pure tangent residual yields zero normal stress
32
+ def test_pure_tangent_residual(basic_setup):
33
+ _, y_hat, J, sigma = basic_setup
34
+ dx_true = np.array([0.2, -0.1])
35
+ y_tangent = y_hat + J @ dx_true
36
+ res = decompose(y_tangent, y_hat, J, sigma=sigma)
37
+ np.testing.assert_allclose(res.normal_stress, 0.0, atol=1e-12)
38
+
39
+ # 4. Pure normal residual yields zero tangent stress and zero repair
40
+ def test_pure_normal_residual():
41
+ J = np.array([[1.0], [0.0]])
42
+ y_hat = np.array([0.0, 0.0])
43
+ y_normal = np.array([0.0, 2.0]) # Orthogonal to column space of J
44
+ res = decompose(y_normal, y_hat, J)
45
+ np.testing.assert_allclose(res.tangent_stress, 0.0, atol=1e-12)
46
+ np.testing.assert_allclose(res.repair_vector, np.array([0.0]), atol=1e-12)
47
+
48
+ # 5. Diagonal covariance and equivalent sigma weighting agree
49
+ def test_sigma_vs_covariance_equivalence(basic_setup):
50
+ y, y_hat, J, sigma = basic_setup
51
+ res_sigma = decompose(y, y_hat, J, sigma=sigma)
52
+
53
+ cov = np.diag(sigma**2)
54
+ res_cov = decompose(y, y_hat, J, covariance=cov)
55
+
56
+ np.testing.assert_allclose(res_sigma.total_stress, res_cov.total_stress)
57
+ np.testing.assert_allclose(res_sigma.normal_fraction_pct, res_cov.normal_fraction_pct)
58
+
59
+ # 6. Rank-deficient and zero Jacobians handled cleanly
60
+ def test_zero_jacobian(basic_setup):
61
+ y, y_hat, _, sigma = basic_setup
62
+ J_zero = np.zeros((3, 2))
63
+ res = decompose(y, y_hat, J_zero, sigma=sigma)
64
+ assert res.rank == 0
65
+ np.testing.assert_allclose(res.normal_stress, res.total_stress)
66
+ np.testing.assert_allclose(res.repair_vector, np.zeros(2))
67
+
68
+ # 7. Permuting observations leaves scalar stress results unchanged
69
+ def test_observation_permutation(basic_setup):
70
+ y, y_hat, J, sigma = basic_setup
71
+ perm = [2, 0, 1]
72
+
73
+ res_orig = decompose(y, y_hat, J, sigma=sigma)
74
+ res_perm = decompose(y[perm], y_hat[perm], J[perm], sigma=sigma[perm])
75
+
76
+ np.testing.assert_allclose(res_orig.total_stress, res_perm.total_stress)
77
+ np.testing.assert_allclose(res_orig.normal_fraction_pct, res_perm.normal_fraction_pct)
78
+ np.testing.assert_allclose(res_orig.repair_vector, res_perm.repair_vector)
79
+
80
+ # 8. Joint block analysis agrees with explicit array stacking
81
+ def test_joint_block_stacking(basic_setup):
82
+ y1, f1, J1, s1 = basic_setup
83
+ y2, f2, J2, s2 = y1 * 0.5, f1 * 0.5, J1 * 0.5, s1 * 0.5
84
+
85
+ joint_res = decompose_blocks(
86
+ observed_blocks=[y1, y2],
87
+ predicted_blocks=[f1, f2],
88
+ jacobian_blocks=[J1, J2],
89
+ sigma_blocks=[s1, s2]
90
+ )
91
+
92
+ y_stack = np.hstack([y1, y2])
93
+ f_stack = np.hstack([f1, f2])
94
+ J_stack = np.vstack([J1, J2])
95
+ s_stack = np.hstack([s1, s2])
96
+ stack_res = decompose(y_stack, f_stack, J_stack, sigma=s_stack)
97
+
98
+ np.testing.assert_allclose(joint_res.total_stress, stack_res.total_stress)
99
+ np.testing.assert_allclose(joint_res.repair_vector, stack_res.repair_vector)
100
+
101
+ # 9. Log-coordinate transformation spans same tangent space when un-truncated
102
+ def test_log_coordinate_span(basic_setup):
103
+ y, y_hat, J_raw, sigma = basic_setup
104
+ p_hat = np.array([2.0, 0.5])
105
+ log_mask = np.array([True, True])
106
+
107
+ J_log = jacobian_to_log_coordinates(J_raw, parameters=p_hat, log_mask=log_mask)
108
+
109
+ res_raw = decompose(y, y_hat, J_raw, sigma=sigma)
110
+ res_log = decompose(y, y_hat, J_log, sigma=sigma)
111
+
112
+ np.testing.assert_allclose(res_raw.normal_stress, res_log.normal_stress)
113
+ np.testing.assert_allclose(res_raw.normal_fraction_pct, res_log.normal_fraction_pct)