pimf 0.1.0__py3-none-any.whl

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.
pimf/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Intrinsic multiscale filtering (IMF) for 1-D signals."""
2
+
3
+ from .contrasts import Contrast, Quadratic, SmoothAbs
4
+ from .decompose import IMFResult, StageInfo, imf, linear_imf, robust_imf
5
+ from .kernels import Kernel, SquaredTriangle
6
+ from .schedule import make_window_schedule
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "Contrast",
12
+ "IMFResult",
13
+ "Kernel",
14
+ "Quadratic",
15
+ "SmoothAbs",
16
+ "SquaredTriangle",
17
+ "StageInfo",
18
+ "__version__",
19
+ "imf",
20
+ "linear_imf",
21
+ "make_window_schedule",
22
+ "robust_imf",
23
+ ]
pimf/_erf.py ADDED
@@ -0,0 +1,28 @@
1
+ """Error function approximation shared by the contrast functions.
2
+
3
+ Abramowitz & Stegun formula 7.1.26 (max abs error ~1.5e-7), kept instead of
4
+ scipy.special.erf so results match the IMF research notebooks bit-for-bit.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+ SQRT_2 = np.sqrt(2.0)
10
+ SQRT_2_OVER_PI = np.sqrt(2.0 / np.pi)
11
+
12
+
13
+ def erf_approx(x):
14
+ """Vectorized Abramowitz-Stegun approximation to erf(x)."""
15
+ x = np.asarray(x, dtype=float)
16
+ sign = np.sign(x)
17
+ ax = np.abs(x)
18
+
19
+ p = 0.3275911
20
+ a1 = 0.254829592
21
+ a2 = -0.284496736
22
+ a3 = 1.421413741
23
+ a4 = -1.453152027
24
+ a5 = 1.061405429
25
+
26
+ z = 1.0 / (1.0 + p * ax)
27
+ poly = ((((a5 * z + a4) * z + a3) * z + a2) * z + a1) * z
28
+ return sign * (1.0 - poly * np.exp(-(ax**2)))
pimf/contrasts.py ADDED
@@ -0,0 +1,71 @@
1
+ """Contrast functions for the local location fits.
2
+
3
+ A contrast supplies the loss rho (via __call__), its derivative psi (the
4
+ score), and an upper bound on rho'' (curvature) that sets a stable gradient
5
+ step. A contrast with a closed-form minimizer may also provide
6
+ solve(windows, weights); the decomposition then skips gradient descent.
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ from ._erf import SQRT_2, SQRT_2_OVER_PI, erf_approx
12
+
13
+
14
+ class Contrast:
15
+ """Base contrast. Subclass and implement __call__, psi, and curvature."""
16
+
17
+ def __call__(self, r):
18
+ """Loss rho(r), vectorized."""
19
+ raise NotImplementedError
20
+
21
+ def psi(self, r):
22
+ """Score rho'(r), vectorized; drives the gradient-descent update."""
23
+ raise NotImplementedError
24
+
25
+ def curvature(self):
26
+ """Upper bound on rho''; the gradient step size is 0.95 / curvature()."""
27
+ raise NotImplementedError
28
+
29
+
30
+ class Quadratic(Contrast):
31
+ """rho(r) = r^2 / 2: the local weighted mean, i.e. the linear IMF."""
32
+
33
+ def __call__(self, r):
34
+ return 0.5 * np.asarray(r, dtype=float) ** 2
35
+
36
+ def psi(self, r):
37
+ return np.asarray(r, dtype=float)
38
+
39
+ def curvature(self):
40
+ return 1.0
41
+
42
+ def solve(self, windows, weights):
43
+ """Closed-form minimizer: the weighted mean of each window row."""
44
+ return windows @ weights
45
+
46
+
47
+ class SmoothAbs(Contrast):
48
+ """Smoothed absolute value: |r| convolved with a N(0, h^2) density.
49
+
50
+ rho_h(r) = r * erf(r / (sqrt(2) h)) + sqrt(2 / pi) * h * exp(-r^2 / (2 h^2))
51
+ psi_h(r) = erf(r / (sqrt(2) h)), bounded in [-1, 1].
52
+
53
+ h > 0 controls the transition from quadratic near zero to |r| in the
54
+ tails: smaller h is more median-like, larger h closer to the local mean.
55
+ h ~ 2 * noise sigma is the research-validated default choice.
56
+ """
57
+
58
+ def __init__(self, h):
59
+ if h <= 0:
60
+ raise ValueError("h must be positive")
61
+ self.h = float(h)
62
+
63
+ def __call__(self, r):
64
+ r = np.asarray(r, dtype=float)
65
+ return r * self.psi(r) + SQRT_2_OVER_PI * self.h * np.exp(-0.5 * (r / self.h) ** 2)
66
+
67
+ def psi(self, r):
68
+ return erf_approx(np.asarray(r, dtype=float) / (SQRT_2 * self.h))
69
+
70
+ def curvature(self):
71
+ return SQRT_2_OVER_PI / self.h
pimf/decompose.py ADDED
@@ -0,0 +1,138 @@
1
+ """Intrinsic multiscale filtering: the decomposition driver."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ from numpy.lib.stride_tricks import sliding_window_view
7
+
8
+ from .contrasts import Quadratic, SmoothAbs
9
+ from .kernels import SquaredTriangle
10
+ from .schedule import make_window_schedule
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class StageInfo:
15
+ """Per-stage diagnostics; the closed-form path reports iterations=1 and
16
+ final_max_delta=nan."""
17
+
18
+ stage: int
19
+ window_size: int
20
+ iterations: int
21
+ final_max_delta: float
22
+
23
+
24
+ @dataclass
25
+ class IMFResult:
26
+ """Decomposition output; y == imfs.sum(axis=0) + residual up to float rounding."""
27
+
28
+ imfs: np.ndarray
29
+ residual: np.ndarray
30
+ stages: list[StageInfo]
31
+ window_sizes: list[int]
32
+
33
+ @property
34
+ def reconstruction(self):
35
+ return self.imfs.sum(axis=0) + self.residual
36
+
37
+
38
+ def _gd_fit_windows(windows, weights, contrast, max_iter, tol):
39
+ """Minimize sum_u w_u * rho(window_u - x) per row by clipped gradient descent.
40
+
41
+ Port of robust_gd_fit_windows from the research notebooks, generalized to
42
+ any contrast via psi and curvature. Returns (x, iterations, max_delta).
43
+ """
44
+ weights = weights / weights.sum()
45
+ row_weights = weights.reshape(1, -1)
46
+
47
+ x = np.median(windows, axis=1)
48
+ lower = windows.min(axis=1)
49
+ upper = windows.max(axis=1)
50
+
51
+ # The weighted score is Lipschitz with constant curvature() because the
52
+ # weights are normalized, so this step size keeps the iteration stable.
53
+ step = 0.95 / contrast.curvature()
54
+
55
+ iterations = 0
56
+ max_delta = float("nan")
57
+ for _ in range(max_iter):
58
+ local_score = np.sum(row_weights * contrast.psi(windows - x[:, None]), axis=1)
59
+ x_next = np.clip(x + step * local_score, lower, upper)
60
+ max_delta = float(np.max(np.abs(x_next - x)))
61
+ x = x_next
62
+ iterations += 1
63
+ if max_delta <= tol * (1.0 + float(np.max(np.abs(x)))):
64
+ break
65
+
66
+ return x, iterations, max_delta
67
+
68
+
69
+ def _smooth_stage(residual, window_size, kernel, contrast, boundary, max_iter, tol):
70
+ """One smoothing pass: fit the local location at every position."""
71
+ weights = kernel.weights(window_size)
72
+ radius = window_size // 2
73
+ padded = np.pad(residual, pad_width=radius, mode=boundary)
74
+ windows = sliding_window_view(padded, window_size)
75
+
76
+ solve = getattr(contrast, "solve", None)
77
+ if callable(solve):
78
+ return solve(windows, weights), 1, float("nan")
79
+ return _gd_fit_windows(windows, weights, contrast, max_iter, tol)
80
+
81
+
82
+ def imf(y, window_sizes=None, contrast=None, kernel=None, boundary="wrap", max_iter=60, tol=1e-6):
83
+ """Decompose a 1-D signal into multiscale components plus a residual.
84
+
85
+ At each stage the current residual is smoothed by a local M-estimator
86
+ defined by kernel and contrast; the smooth becomes that stage's component
87
+ and the recursion continues on what is left:
88
+ r_1 = y, S_k = smooth(r_k), r_{k+1} = r_k - S_k.
89
+
90
+ Defaults: window_sizes = make_window_schedule(len(y)), contrast =
91
+ Quadratic() (the linear IMF), kernel = SquaredTriangle(). boundary is
92
+ passed to np.pad ("wrap", "reflect", "edge", ...). max_iter and tol apply
93
+ only when the contrast has no closed-form solve.
94
+ """
95
+ y = np.asarray(y, dtype=float)
96
+ if y.ndim != 1:
97
+ raise ValueError("y must be one-dimensional")
98
+ if len(y) == 0:
99
+ raise ValueError("y must not be empty")
100
+
101
+ if window_sizes is None:
102
+ window_sizes = make_window_schedule(len(y))
103
+ window_sizes = [int(size) for size in window_sizes]
104
+ if contrast is None:
105
+ contrast = Quadratic()
106
+ if kernel is None:
107
+ kernel = SquaredTriangle()
108
+
109
+ residual = y.copy()
110
+ imfs = []
111
+ stages = []
112
+ for stage, window_size in enumerate(window_sizes, start=1):
113
+ component, iterations, final_max_delta = _smooth_stage(
114
+ residual, window_size, kernel, contrast, boundary, max_iter, tol
115
+ )
116
+ imfs.append(component)
117
+ residual = residual - component
118
+ stages.append(StageInfo(stage, window_size, iterations, final_max_delta))
119
+
120
+ return IMFResult(np.array(imfs), residual, stages, window_sizes)
121
+
122
+
123
+ def linear_imf(y, window_sizes=None, kernel=None, boundary="wrap"):
124
+ """imf() with the Quadratic contrast: the linear (weighted local mean) IMF."""
125
+ return imf(y, window_sizes=window_sizes, contrast=Quadratic(), kernel=kernel, boundary=boundary)
126
+
127
+
128
+ def robust_imf(y, h, window_sizes=None, kernel=None, boundary="wrap", max_iter=60, tol=1e-6):
129
+ """imf() with the SmoothAbs(h) contrast; h ~ 2 * noise sigma works well."""
130
+ return imf(
131
+ y,
132
+ window_sizes=window_sizes,
133
+ contrast=SmoothAbs(h),
134
+ kernel=kernel,
135
+ boundary=boundary,
136
+ max_iter=max_iter,
137
+ tol=tol,
138
+ )
pimf/kernels.py ADDED
@@ -0,0 +1,54 @@
1
+ """Kernel window weights for the local fits.
2
+
3
+ A kernel is defined by its profile k(u) on [-1, 1]; the base class turns the
4
+ profile into a normalized, symmetric weight vector for an odd window size.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+
10
+ class Kernel:
11
+ """Base kernel. Subclass and implement profile(u).
12
+
13
+ profile(u) must be vectorized and nonnegative on [-1, 1]; any constant
14
+ factor cancels under normalization.
15
+ """
16
+
17
+ def profile(self, u):
18
+ """Unnormalized kernel profile k(u) on [-1, 1]."""
19
+ raise NotImplementedError
20
+
21
+ def weights(self, window_size):
22
+ """Normalized weight vector for an odd window size."""
23
+ if window_size % 2 == 0:
24
+ raise ValueError("window_size must be odd")
25
+
26
+ radius = window_size // 2
27
+ if radius == 0:
28
+ return np.array([1.0])
29
+
30
+ offsets = np.arange(-radius, radius + 1)
31
+ u = offsets / radius
32
+ weights = np.asarray(self.profile(u), dtype=float)
33
+ if np.any(weights < 0):
34
+ raise ValueError("kernel profile must be nonnegative")
35
+ total = weights.sum()
36
+ if total <= 0:
37
+ raise ValueError("kernel weights must have positive sum")
38
+ return weights / total
39
+
40
+ __call__ = weights
41
+
42
+
43
+ class SquaredTriangle(Kernel):
44
+ """Squared triangular profile k(u) = 0.75 * (1 - |u|)^2.
45
+
46
+ The default kernel of the IMF research project, where it is called
47
+ "Epanechnikov" — a misnomer: the classical Epanechnikov kernel is
48
+ (3/4)(1 - u^2). The 0.75 factor cancels under normalization and is kept
49
+ for parity with the research code. The endpoint weights (|u| = 1) are
50
+ exactly zero.
51
+ """
52
+
53
+ def profile(self, u):
54
+ return 0.75 * np.maximum(0.0, 1.0 - np.abs(u)) ** 2
pimf/schedule.py ADDED
@@ -0,0 +1,65 @@
1
+ """Geometric window-size schedule for the IMF decomposition.
2
+
3
+ Faithful port of make_window_schedule from the IMF research notebooks:
4
+ odd, strictly decreasing sizes from about n/2 down to min_window_size,
5
+ shrinking by factor each stage.
6
+ """
7
+
8
+ import numpy as np
9
+
10
+ from ._erf import SQRT_2
11
+
12
+
13
+ def odd_ceiling(value):
14
+ """Smallest odd integer >= ceil(value), at least 1."""
15
+ size = int(np.ceil(value))
16
+ if size % 2 == 0:
17
+ size += 1
18
+ return max(1, size)
19
+
20
+
21
+ def nearest_odd(value):
22
+ """Odd integer nearest to value (ties round down), at least 1."""
23
+ rounded = int(np.round(value))
24
+ if rounded % 2 == 1:
25
+ return max(1, rounded)
26
+
27
+ lower = max(1, rounded - 1)
28
+ upper = rounded + 1
29
+ if abs(value - lower) <= abs(upper - value):
30
+ return lower
31
+ return upper
32
+
33
+
34
+ def make_window_schedule(n, factor=SQRT_2, min_window_size=31):
35
+ """Window sizes for a length-n signal: first = odd_ceiling(n / 2), then
36
+ geometric shrink by factor, all odd, strictly decreasing, floored at
37
+ min_window_size."""
38
+ if n <= 0:
39
+ raise ValueError("n must be positive")
40
+ if factor <= 1:
41
+ raise ValueError("factor must be larger than 1")
42
+
43
+ first = odd_ceiling(n / 2)
44
+ if first > n:
45
+ first = n if n % 2 == 1 else n - 1
46
+
47
+ min_size = nearest_odd(min_window_size)
48
+ if min_size > first:
49
+ return [first]
50
+
51
+ sizes = [first]
52
+ current = first
53
+
54
+ while current > min_size:
55
+ candidate = nearest_odd(current / factor)
56
+ candidate = min(candidate, current - 2)
57
+ if candidate % 2 == 0:
58
+ candidate -= 1
59
+ if candidate < min_size:
60
+ candidate = min_size
61
+
62
+ sizes.append(candidate)
63
+ current = candidate
64
+
65
+ return sizes
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: pimf
3
+ Version: 0.1.0
4
+ Summary: Intrinsic multiscale filtering (IMF) for 1-D signals: linear and robust decompositions.
5
+ Project-URL: Homepage, https://github.com/mkuziuk/pimf
6
+ Project-URL: Source, https://github.com/mkuziuk/pimf
7
+ Project-URL: Issues, https://github.com/mkuziuk/pimf/issues
8
+ Author: Mikhail Kuziuk
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: decomposition,m-estimator,robust-statistics,signal-processing,smoothing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: numpy>=1.22
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pimf
25
+
26
+ Intrinsic multiscale filtering (IMF) for one-dimensional signals: a **linear**
27
+ decomposition (local weighted mean) and a **robust** variant that replaces the
28
+ mean with a smooth robust location fit solved by gradient descent.
29
+
30
+ Both are the same algorithm. Starting from `r_1 = y`, each stage smooths the
31
+ current residual with a local M-estimator and passes on what is left:
32
+
33
+ ```
34
+ S_k = argmin_x sum_u w_{t,u} * rho(r_k(u) - x) (per position t)
35
+ r_{k+1} = r_k - S_k
36
+ ```
37
+
38
+ The signal decomposes exactly: `y = S_1 + ... + S_K + r_{K+1}`. The only
39
+ difference between the variants is the contrast `rho`:
40
+
41
+ - **Quadratic** `rho(r) = r^2/2` — closed form, the kernel-weighted local mean
42
+ (the linear IMF).
43
+ - **SmoothAbs** `rho_h(r) = r*erf(r/(sqrt(2)h)) + sqrt(2/pi)*h*exp(-r^2/(2h^2))`
44
+ — a smoothed absolute value with bounded score `psi_h(r) = erf(r/(sqrt(2)h))`,
45
+ solved by clipped gradient descent (the robust IMF). Large contaminated
46
+ observations have bounded influence.
47
+
48
+ The library packages the algorithms validated in the IMF research project
49
+ (`Projects/imf`) and reproduces its numerics — the robust decomposition is
50
+ bit-identical to the reference notebook on the research example.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install pimf
56
+ ```
57
+
58
+ Requires Python >= 3.10. NumPy is the only dependency.
59
+
60
+ For development, clone the repo and install in editable mode:
61
+
62
+ ```bash
63
+ pip install -e .
64
+ ```
65
+
66
+ ## Quickstart
67
+
68
+ ```python
69
+ import numpy as np
70
+ import pimf
71
+
72
+ t = np.linspace(0.0, 1.0, 1000)
73
+ y = (
74
+ np.sin(2 * np.pi * t)
75
+ + 0.25 * np.sin(12 * np.pi * t)
76
+ + np.random.default_rng(0).normal(0, 0.1, 1000)
77
+ )
78
+
79
+ linear = pimf.linear_imf(y) # quadratic contrast, closed form
80
+ robust = pimf.robust_imf(y, h=0.2) # smooth-abs contrast, h ~ 2 * noise sigma
81
+
82
+ robust.imfs # (K, n) array of components, coarsest first
83
+ robust.residual # (n,) final residual
84
+ robust.stages # per-stage diagnostics (window size, GD iterations, ...)
85
+ robust.reconstruction # imfs.sum(axis=0) + residual == y to ~1e-15
86
+ ```
87
+
88
+ The general entry point is `pimf.imf(y, window_sizes=..., contrast=..., kernel=...,
89
+ boundary=...)`; `linear_imf` and `robust_imf` are one-line wrappers around it.
90
+
91
+ ## Concepts
92
+
93
+ **Window schedule.** `make_window_schedule(n)` generates the per-stage window
94
+ sizes: odd, strictly decreasing, starting at about `n/2` and shrinking
95
+ geometrically by `sqrt(2)` down to a floor of 31. Pass `window_sizes=` to
96
+ override. Windows must be odd.
97
+
98
+ **Kernel.** The window weights come from a kernel profile `k(u)` on `[-1, 1]`.
99
+ The default `SquaredTriangle` uses `k(u) = 0.75 * (1 - |u|)^2` — the square of
100
+ the triangular kernel. (The research project and IMF.pdf call this kernel
101
+ "Epanechnikov"; that is a misnomer — the classical Epanechnikov kernel is
102
+ `(3/4)(1 - u^2)` — so this library names it for what it is.) Endpoint weights
103
+ are exactly zero, and weights are normalized to sum to one.
104
+
105
+ **Contrast.** A contrast supplies the loss `rho` (via `__call__`), its
106
+ derivative `psi` (the score), and `curvature()` — an upper bound on `rho''`
107
+ that sets the stable gradient step `0.95 / curvature()`. A contrast with a
108
+ closed-form minimizer can provide `solve(windows, weights)`, which the driver
109
+ uses instead of gradient descent (that is what makes `Quadratic` the fast
110
+ linear path). For `SmoothAbs(h)`, `h ~ 2 * sigma` of the Gaussian noise is the
111
+ research-validated choice: smaller `h` behaves like a running median, larger
112
+ `h` like the local mean.
113
+
114
+ **Boundary.** `boundary="wrap"` (circular) is the default and the setting the
115
+ linear-operator theory of the research project assumes; it is passed straight
116
+ to `np.pad`, so `"reflect"` and `"edge"` also work.
117
+
118
+ ## Extending
119
+
120
+ Custom contrast — one small class:
121
+
122
+ ```python
123
+ import numpy as np
124
+ import pimf
125
+
126
+
127
+ class Huber(pimf.Contrast):
128
+ def __init__(self, delta):
129
+ self.delta = delta
130
+
131
+ def __call__(self, r):
132
+ a = np.abs(r)
133
+ return np.where(a <= self.delta, 0.5 * r**2, self.delta * (a - 0.5 * self.delta))
134
+
135
+ def psi(self, r):
136
+ return np.clip(r, -self.delta, self.delta)
137
+
138
+ def curvature(self):
139
+ return 1.0
140
+
141
+
142
+ result = pimf.imf(y, contrast=Huber(0.3))
143
+ ```
144
+
145
+ Custom kernel — one line:
146
+
147
+ ```python
148
+ class Triangle(pimf.Kernel):
149
+ def profile(self, u):
150
+ return 1.0 - np.abs(u)
151
+
152
+
153
+ result = pimf.imf(y, kernel=Triangle())
154
+ ```
155
+
156
+ ## Numerical guarantees
157
+
158
+ - Exact reconstruction: `imfs.sum(axis=0) + residual` matches the input to
159
+ ~1e-15 (float rounding only).
160
+ - Deterministic: no threading, no hidden state; the same input always gives
161
+ the same output.
162
+ - Research parity (verified by cross-check against the reference notebook on
163
+ the seed-777 example): kernel weights, the erf approximation
164
+ (Abramowitz–Stegun 7.1.26 — deliberately kept instead of SciPy), signal
165
+ generation, and the full robust decomposition are bit-identical; the linear
166
+ path matches the loop-based notebook to ~2e-15 (float summation order — it
167
+ is bit-identical to the vectorized `windows @ weights` notebooks).
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ uv sync # or: pip install -e . && pip install pytest ruff
173
+ python -m pytest
174
+ ruff check . && ruff format --check .
175
+ ```
176
+
177
+ See [AGENTS.md](AGENTS.md) for the project's code style and hard rules.
178
+
179
+ ## License
180
+
181
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ pimf/__init__.py,sha256=PoaP3c__2ImyPZrMbtiiTOewLsmsUmlRNY8GT78Np38,529
2
+ pimf/_erf.py,sha256=Af-8ML2eUHUpgHFtXIwB1r6Uui5ySyU9XM3EgRerRl4,732
3
+ pimf/contrasts.py,sha256=AItiF3W8m_MaIqI5Iv4LrYuGV3sLJjZJR2Xex3XdCQ4,2239
4
+ pimf/decompose.py,sha256=D1N3ugJo9EseKSou5cIs7PAgV2xBZLSj4WK5OeWFi5Y,4781
5
+ pimf/kernels.py,sha256=CTz5DMOh2Fif6hjakWFZnDJuVoifDSQxfuan59C-j_c,1736
6
+ pimf/schedule.py,sha256=AbKnXFwr09pmS9celGBY-vZu94Zv0m2tVydzrdFq4Sk,1734
7
+ pimf-0.1.0.dist-info/METADATA,sha256=ZqQY5GeeCLstSOb479-M5uTpTCrFgitdlxjHAWhbZDU,6220
8
+ pimf-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ pimf-0.1.0.dist-info/licenses/LICENSE,sha256=rS68B5Bud03naAkRCKHRRuCiwtzQt0ydaeSxpvDKlIY,1071
10
+ pimf-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Kuziuk
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.