kews 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.
kews/__init__.py ADDED
@@ -0,0 +1,119 @@
1
+ """KEWS: compact Koopman early warning signals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, Mapping, Optional, Union
6
+
7
+ import jax
8
+ import jax.numpy as jnp
9
+ from jax.typing import ArrayLike
10
+
11
+ from ._core import rolling_residuals
12
+ from .observables import _RFF, time_delay
13
+
14
+
15
+ def _window_length(n_time: int, window: Union[int, float]) -> int:
16
+ """Resolve a relative or absolute window length."""
17
+ length = int(window * n_time) if window <= 1 else int(window)
18
+ if not 1 < length < n_time:
19
+ raise ValueError(
20
+ f"window resolves to {length}; expected a value in (1, {n_time})"
21
+ )
22
+ return length
23
+
24
+
25
+ def _windows(data: jax.Array, length: int) -> jax.Array:
26
+ """Return ``(window, time)`` or ``(window, state, time)`` snapshots."""
27
+ indices = jnp.arange(data.shape[-1] - length + 1)[:, None] + jnp.arange(length)
28
+ if data.ndim == 1:
29
+ return data[indices]
30
+ return jnp.transpose(data[:, indices], (1, 0, 2))
31
+
32
+
33
+ def resdmd(
34
+ data: ArrayLike,
35
+ window: Union[int, float] = 0.5,
36
+ observable: Optional[Any] = None,
37
+ rank: Union[int, float] = 0.9,
38
+ control: Optional[ArrayLike] = None,
39
+ *,
40
+ control_method: Literal["dmdc", "augmented"] = "dmdc",
41
+ future_input: Literal["future", "zero"] = "future",
42
+ fit_kwargs: Optional[Mapping[str, Any]] = None,
43
+ ) -> jax.Array:
44
+ """Compute a rolling Koopman early warning signal.
45
+
46
+ The same call covers autonomous and controlled dynamics. Leave ``control``
47
+ unset for autonomous ResDMD or pass an aligned control trajectory to use a
48
+ control-aware formulation. Each residual is aligned to the right edge of
49
+ its window; earlier entries are ``NaN``.
50
+
51
+ A learned observable is fitted on the first window and then held fixed.
52
+ The first window also determines the retained rank. Random Fourier
53
+ features use one seeded basis for the complete trajectory.
54
+
55
+ Args:
56
+ data: Scalar state shaped ``(time,)`` or multivariate state shaped
57
+ ``(state, time)``.
58
+ window: Fraction of the trajectory in ``(0, 1)`` or absolute sample
59
+ count. It must resolve to at least two samples and less than the
60
+ complete trajectory.
61
+ observable: Observable created by :mod:`kews.observables`. Defaults to
62
+ ``time_delay()``.
63
+ rank: Retained SVD energy fraction in ``(0, 1)`` or an absolute rank.
64
+ control: Optional input shaped ``(time,)`` or ``(control, time)``.
65
+ control_method: ``"dmdc"`` fits DMD with raw control in lifted state
66
+ space. ``"augmented"`` fits ``[phi(x), phi(x)u, u]`` snapshots.
67
+ future_input: For the augmented method, use observed next inputs with
68
+ ``"future"`` or replace them with zero using ``"zero"``.
69
+ fit_kwargs: Training arguments for a learned observable, such as
70
+ ``epochs``, ``batch_size``, and ``lr`` for ``observables.mlp``.
71
+
72
+ Returns:
73
+ One-dimensional JAX array with the same time length as ``data``.
74
+ Positions before the first completed window are ``NaN``.
75
+
76
+ Raises:
77
+ ValueError: If array orientation, window, rank, control alignment, or a
78
+ method option is invalid.
79
+ """
80
+ data = jnp.asarray(data)
81
+ if data.ndim not in (1, 2):
82
+ raise ValueError("data must have shape (time,) or (state, time)")
83
+ length = _window_length(data.shape[-1], window)
84
+
85
+ control_windows = None
86
+ if control is not None:
87
+ control = jnp.asarray(control)
88
+ if control.ndim not in (1, 2) or control.shape[-1] != data.shape[-1]:
89
+ raise ValueError(
90
+ "control must have shape (time,) or (control, time) and match data"
91
+ )
92
+ control_windows = _windows(control, length)
93
+
94
+ observable = time_delay() if observable is None else observable
95
+ if not callable(getattr(observable, "transform", None)):
96
+ raise ValueError("observable must come from kews.observables")
97
+
98
+ if isinstance(observable, _RFF):
99
+ # Lift once to preserve the paper's shared basis and floating-point evaluation.
100
+ state_windows = _windows(observable.transform(data), length)
101
+ observable = None
102
+ else:
103
+ state_windows = _windows(data, length)
104
+
105
+ residuals = rolling_residuals(
106
+ state_windows,
107
+ observable,
108
+ rank,
109
+ control_windows,
110
+ control_method,
111
+ future_input,
112
+ fit_kwargs,
113
+ )
114
+ result = jnp.full((data.shape[-1],), jnp.nan, dtype=jnp.float32)
115
+ return result.at[length - 1 :].set(residuals)
116
+
117
+
118
+ __version__ = "0.1.0"
119
+ __all__ = ["resdmd"]
kews/_core.py ADDED
@@ -0,0 +1,441 @@
1
+ """Koopman and ResDMD numerical kernels.
2
+
3
+ Snapshot matrices have shape ``(features, time)``; rolling evaluation adds a
4
+ leading window axis. Normalization and numerical tolerances follow the paper
5
+ reference so that refactoring does not change the plotted indicators.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from numbers import Integral, Real
11
+ from typing import Any, Literal, Mapping, Optional, Union
12
+
13
+ import jax
14
+ import jax.numpy as jnp
15
+
16
+ Rank = Union[int, float]
17
+
18
+
19
+ def delay_embed(x: jax.Array, n_delay: int) -> jax.Array:
20
+ """Stack delayed state copies with time on the final axis."""
21
+ if not isinstance(n_delay, Integral) or isinstance(n_delay, bool) or n_delay < 1:
22
+ raise ValueError("n_delay must be a positive integer")
23
+ x = jnp.atleast_2d(x)
24
+ n_time = x.shape[1]
25
+ if n_delay > n_time:
26
+ raise ValueError(
27
+ f"n_delay must not exceed the time length ({n_time}), got {n_delay}"
28
+ )
29
+ return jnp.vstack(
30
+ [x[:, offset : n_time - n_delay + offset + 1] for offset in range(n_delay)]
31
+ )
32
+
33
+
34
+ def random_fourier_features(
35
+ x: jax.Array,
36
+ n_features: int,
37
+ scale: float,
38
+ key: jax.Array,
39
+ ) -> jax.Array:
40
+ """Map state columns to random cosine features."""
41
+ x = jnp.atleast_2d(x)
42
+ projection_key, phase_key = jax.random.split(key)
43
+ weights = jax.random.normal(projection_key, (n_features, x.shape[0])) / scale
44
+ phase = jax.random.uniform(
45
+ phase_key, (n_features,), maxval=2 * jnp.pi
46
+ )
47
+ return jnp.sqrt(2 / n_features) * jnp.cos(weights @ x + phase[:, None])
48
+
49
+
50
+ def _rank(singular_values: jax.Array, low_rank: Rank) -> int:
51
+ """Resolve an energy fraction or absolute SVD rank to a concrete integer."""
52
+ n_modes = singular_values.shape[0]
53
+ if isinstance(low_rank, Integral) and not isinstance(low_rank, bool):
54
+ if low_rank < 1:
55
+ raise ValueError("rank must be a fraction in (0, 1) or an integer >= 1")
56
+ return min(int(low_rank), n_modes)
57
+ if isinstance(low_rank, Real) and 0 < low_rank < 1:
58
+ energy = singular_values**2
59
+ cumulative = jnp.cumsum(energy) / jnp.maximum(jnp.sum(energy), 1e-12)
60
+ index = int(jax.device_get(jnp.searchsorted(cumulative, low_rank)))
61
+ return min(index + 1, n_modes)
62
+ raise ValueError("rank must be a fraction in (0, 1) or an integer >= 1")
63
+
64
+
65
+ def _svd_inverse(singular_values: jax.Array) -> jax.Array:
66
+ """Return a stable diagonal inverse for retained singular values."""
67
+ return jnp.diag(
68
+ jnp.where(singular_values > 1e-12, 1.0 / singular_values, 0.0)
69
+ )
70
+
71
+
72
+ def _edmd_matrices(
73
+ current: jax.Array,
74
+ future: jax.Array,
75
+ rank: int,
76
+ ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
77
+ """Build EDMD bases, Gram matrix, and cross matrix."""
78
+ # Preserve the paper's snapshot normalization before truncating the SVD.
79
+ _, singular_values, vh = jnp.linalg.svd(
80
+ current / current.shape[1], full_matrices=False
81
+ )
82
+ values = singular_values[:rank]
83
+ right = vh[:rank].T
84
+ inverse = _svd_inverse(values)
85
+ basis_current = current @ right @ inverse
86
+ basis_future = future @ right @ inverse
87
+ gram = basis_current.T @ basis_current
88
+ gram = (gram + gram.T) / 2
89
+ cross = basis_current.T @ basis_future
90
+ return basis_current, basis_future, gram, cross
91
+
92
+
93
+ def _mean_residual(
94
+ gram: jax.Array,
95
+ cross: jax.Array,
96
+ future_gram: jax.Array,
97
+ operator: jax.Array,
98
+ ) -> jax.Array:
99
+ """Average the ResDMD residual over Koopman eigenpairs."""
100
+ eigenvalues, eigenvectors = jnp.linalg.eig(operator)
101
+
102
+ def one(eigenvalue: jax.Array, eigenvector: jax.Array) -> jax.Array:
103
+ # Residual energy is normalized by this eigenfunction's Gram norm.
104
+ residual_matrix = (
105
+ future_gram
106
+ - eigenvalue * cross.conj().T
107
+ - eigenvalue.conj() * cross
108
+ + jnp.abs(eigenvalue) ** 2 * gram
109
+ )
110
+ conjugate = eigenvector.conj()
111
+ numerator = (conjugate @ residual_matrix @ eigenvector).real
112
+ denominator = (conjugate @ gram @ eigenvector).real
113
+ return jnp.where(
114
+ jnp.abs(denominator) > 1e-10,
115
+ jnp.abs(numerator / denominator),
116
+ 0.0,
117
+ )
118
+
119
+ return jnp.mean(jax.vmap(one)(eigenvalues, eigenvectors.T))
120
+
121
+
122
+ def _edmd_residual(current: jax.Array, future: jax.Array, rank: int) -> jax.Array:
123
+ """Compute one autonomous or augmented EDMD residual."""
124
+ _, basis_future, gram, cross = _edmd_matrices(current, future, rank)
125
+ future_gram = basis_future.T @ basis_future
126
+ future_gram = (future_gram + future_gram.T) / 2
127
+ operator = jnp.linalg.pinv(gram, rtol=1e-4) @ cross
128
+ residual = _mean_residual(gram, cross, future_gram, operator)
129
+ valid = jnp.all(jnp.isfinite(operator)) & jnp.isfinite(residual)
130
+ return jnp.where(valid, residual, 0.0)
131
+
132
+
133
+ def _dmdc(
134
+ current: jax.Array,
135
+ future: jax.Array,
136
+ control: jax.Array,
137
+ input_rank: int,
138
+ output_rank: int,
139
+ ) -> tuple[jax.Array, jax.Array]:
140
+ """Fit DMD with control from aligned snapshot matrices."""
141
+ n_state = current.shape[0]
142
+ omega = jnp.vstack([current, control])
143
+ left, singular_values, vh = jnp.linalg.svd(omega, full_matrices=False)
144
+ left = left[:, :input_rank]
145
+ projection = future @ vh[:input_rank].T @ _svd_inverse(
146
+ singular_values[:input_rank]
147
+ )
148
+ state_operator = projection @ left[:n_state].T
149
+ control_operator = projection @ left[n_state:].T
150
+
151
+ # Match the reduced-order lift used by the paper reference implementation.
152
+ output_basis = jnp.linalg.svd(future, full_matrices=False)[0][:, :output_rank]
153
+ state_operator = (
154
+ output_basis
155
+ @ (output_basis.T @ state_operator @ output_basis)
156
+ @ output_basis.T
157
+ )
158
+ control_operator = output_basis @ (output_basis.T @ control_operator)
159
+ return state_operator, control_operator
160
+
161
+
162
+ def _control_residual(
163
+ current: jax.Array,
164
+ future: jax.Array,
165
+ control: jax.Array,
166
+ input_rank: int,
167
+ output_rank: int,
168
+ ) -> jax.Array:
169
+ """Compute one DMDc residual in lifted state space."""
170
+ operator, _ = _dmdc(current, future, control, input_rank, output_rank)
171
+ gram = current @ current.T
172
+ cross = current @ future.T
173
+ future_gram = future @ future.T
174
+ future_gram = (future_gram + future_gram.T) / 2
175
+ residual = _mean_residual(gram, cross, future_gram, operator)
176
+ valid = jnp.all(jnp.isfinite(operator)) & jnp.isfinite(residual)
177
+ return jnp.where(valid, residual, 0.0)
178
+
179
+
180
+ def _augment(
181
+ lifted: jax.Array,
182
+ control: jax.Array,
183
+ future_input: Literal["future", "zero"],
184
+ ) -> tuple[jax.Array, jax.Array]:
185
+ """Build ``[phi(x), phi(x)u, u]`` current and future snapshots."""
186
+ state_current, state_future = lifted[:, :-1], lifted[:, 1:]
187
+ control_current = control[:, :-1]
188
+ if future_input == "future":
189
+ control_future = control[:, 1:]
190
+ elif future_input == "zero":
191
+ control_future = jnp.zeros_like(control_current)
192
+ else:
193
+ raise ValueError("future_input must be 'future' or 'zero'")
194
+
195
+ interaction_current = jnp.einsum(
196
+ "it,jt->ijt", state_current, control_current
197
+ ).reshape((-1, state_current.shape[1]))
198
+ interaction_future = jnp.einsum(
199
+ "it,jt->ijt", state_future, control_future
200
+ ).reshape((-1, state_future.shape[1]))
201
+ return (
202
+ jnp.vstack([state_current, interaction_current, control_current]),
203
+ jnp.vstack([state_future, interaction_future, control_future]),
204
+ )
205
+
206
+
207
+ def _lift(observable: Any, x: jax.Array) -> jax.Array:
208
+ """Apply an observable, or pass through features lifted before windowing."""
209
+ return x if observable is None else observable.transform(x)
210
+
211
+
212
+ def rolling_residuals(
213
+ windows: jax.Array,
214
+ observable: Any,
215
+ low_rank: Rank,
216
+ control_windows: Optional[jax.Array] = None,
217
+ control_method: Literal["dmdc", "augmented"] = "dmdc",
218
+ future_input: Literal["future", "zero"] = "future",
219
+ fit_kwargs: Optional[Mapping[str, Any]] = None,
220
+ ) -> jax.Array:
221
+ """Compute one residual for every pre-built rolling window."""
222
+ if getattr(observable, "fit_on_first_window", False):
223
+ # Freeze learned features after the initial window to avoid future-data leakage.
224
+ observable.fit(windows[0], **dict(fit_kwargs or {}))
225
+
226
+ first_lifted = _lift(observable, windows[0])
227
+ if first_lifted.shape[1] < 2:
228
+ raise ValueError("observable must produce at least two time columns per window")
229
+
230
+ if control_windows is None:
231
+ first_current = first_lifted[:, :-1]
232
+ singular_values = jnp.linalg.svd(
233
+ first_current / first_current.shape[1], full_matrices=False
234
+ )[1]
235
+ rank = _rank(singular_values, low_rank)
236
+
237
+ # Hold rank fixed so later windows use the same approximation dimension.
238
+ @jax.jit
239
+ def one(window: jax.Array) -> jax.Array:
240
+ lifted = _lift(observable, window)
241
+ return _edmd_residual(lifted[:, :-1], lifted[:, 1:], rank)
242
+
243
+ return jax.vmap(one)(windows)
244
+
245
+ if control_method not in ("dmdc", "augmented"):
246
+ raise ValueError("control_method must be 'dmdc' or 'augmented'")
247
+
248
+ first_control = control_windows[0]
249
+ first_control = (
250
+ first_control.reshape((1, -1))
251
+ if first_control.ndim == 1
252
+ else first_control
253
+ )
254
+ n_lifted = first_lifted.shape[1]
255
+
256
+ # Delay coordinates end at the latest sample; align control to those endpoints.
257
+ if control_method == "dmdc":
258
+ first_current, first_future = first_lifted[:, :-1], first_lifted[:, 1:]
259
+ aligned = first_control[:, first_control.shape[1] - n_lifted : -1]
260
+ input_rank = _rank(
261
+ jnp.linalg.svd(jnp.vstack([first_current, aligned]), full_matrices=False)[1],
262
+ low_rank,
263
+ )
264
+ output_rank = _rank(
265
+ jnp.linalg.svd(first_future, full_matrices=False)[1], low_rank
266
+ )
267
+
268
+ @jax.jit
269
+ def one(window: jax.Array, control_window: jax.Array) -> jax.Array:
270
+ lifted = _lift(observable, window)
271
+ control = (
272
+ control_window.reshape((1, -1))
273
+ if control_window.ndim == 1
274
+ else control_window
275
+ )
276
+ aligned = control[:, control.shape[1] - lifted.shape[1] : -1]
277
+ return _control_residual(
278
+ lifted[:, :-1],
279
+ lifted[:, 1:],
280
+ aligned,
281
+ input_rank,
282
+ output_rank,
283
+ )
284
+
285
+ return jax.vmap(one)(windows, control_windows)
286
+
287
+ first_aligned = first_control[:, first_control.shape[1] - n_lifted :]
288
+ first_current, _ = _augment(first_lifted, first_aligned, future_input)
289
+ rank = _rank(
290
+ jnp.linalg.svd(
291
+ first_current / first_current.shape[1], full_matrices=False
292
+ )[1],
293
+ low_rank,
294
+ )
295
+
296
+ @jax.jit
297
+ def one(window: jax.Array, control_window: jax.Array) -> jax.Array:
298
+ lifted = _lift(observable, window)
299
+ control = (
300
+ control_window.reshape((1, -1))
301
+ if control_window.ndim == 1
302
+ else control_window
303
+ )
304
+ aligned = control[:, control.shape[1] - lifted.shape[1] :]
305
+ current, future = _augment(lifted, aligned, future_input)
306
+ return _edmd_residual(current, future, rank)
307
+
308
+ return jax.vmap(one)(windows, control_windows)
309
+
310
+
311
+ def fit_koopman_edmd(
312
+ x: jax.Array,
313
+ observable: Any,
314
+ low_rank: Rank = 0.9,
315
+ fit_kwargs: Optional[Mapping[str, Any]] = None,
316
+ ) -> dict[str, Any]:
317
+ """Fit an autonomous Koopman operator in a supplied observable space."""
318
+ x = jnp.asarray(x)
319
+ if getattr(observable, "fit_on_first_window", False):
320
+ observable.fit(x, **dict(fit_kwargs or {}))
321
+ lifted = _lift(observable, x)
322
+ if lifted.shape[1] < 2:
323
+ raise ValueError("observable must produce at least two time columns")
324
+ current, future = lifted[:, :-1], lifted[:, 1:]
325
+ singular_values = jnp.linalg.svd(
326
+ current / current.shape[1], full_matrices=False
327
+ )[1]
328
+ rank = _rank(singular_values, low_rank)
329
+ basis_current, basis_future, gram, cross = _edmd_matrices(
330
+ current, future, rank
331
+ )
332
+ operator = jnp.linalg.lstsq(gram, cross, rcond=1e-10)[0]
333
+ eigenvalues, eigenvectors = jnp.linalg.eig(operator)
334
+ return {
335
+ "K": operator,
336
+ "eigvals": eigenvalues,
337
+ "eigvecs": eigenvectors.T,
338
+ "Psi_X": basis_current.T @ current,
339
+ "Psi_Y": basis_current.T @ future,
340
+ "r": rank,
341
+ }
342
+
343
+
344
+ def _aligned_control(x: jax.Array, u: jax.Array) -> jax.Array:
345
+ """Normalize control orientation and validate raw time alignment."""
346
+ u = jnp.atleast_2d(u)
347
+ if u.shape[1] != x.shape[-1]:
348
+ raise ValueError("control must have the same time length as state")
349
+ return u
350
+
351
+
352
+ def compute_dmdc(
353
+ x: jax.Array,
354
+ u: jax.Array,
355
+ dim_delay: int = 5,
356
+ low_rank: Rank = 0.9,
357
+ ) -> tuple[jax.Array, jax.Array]:
358
+ """Fit DMDc in delay-coordinate state space."""
359
+ x = jnp.atleast_1d(x).flatten()
360
+ u = _aligned_control(x, u)
361
+ if x.size < dim_delay + 2:
362
+ raise ValueError(f"data needs at least {dim_delay + 2} time samples")
363
+ lifted = delay_embed(x, dim_delay)
364
+ current, future = lifted[:, :-1], lifted[:, 1:]
365
+ control = u[:, dim_delay - 1 : -1]
366
+ input_rank = _rank(
367
+ jnp.linalg.svd(jnp.vstack([current, control]), full_matrices=False)[1],
368
+ low_rank,
369
+ )
370
+ output_rank = _rank(jnp.linalg.svd(future, full_matrices=False)[1], low_rank)
371
+ return _dmdc(current, future, control, input_rank, output_rank)
372
+
373
+
374
+ def compute_edmdwc(
375
+ x: jax.Array,
376
+ u: jax.Array,
377
+ dim_delay: int = 5,
378
+ low_rank: Rank = 0.9,
379
+ ) -> tuple[jax.Array, jax.Array]:
380
+ """Fit reduced EDMD with control in delay-coordinate state space."""
381
+ x = jnp.atleast_1d(x).flatten()
382
+ u = _aligned_control(x, u)
383
+ if x.size < dim_delay + 2:
384
+ raise ValueError(f"data needs at least {dim_delay + 2} time samples")
385
+ lifted = delay_embed(x, dim_delay)
386
+ current, future = lifted[:, :-1], lifted[:, 1:]
387
+ control = u[:, dim_delay - 1 : -1]
388
+ left, singular_values, _ = jnp.linalg.svd(current, full_matrices=False)
389
+ rank = _rank(singular_values, low_rank)
390
+ basis = left[:, :rank]
391
+ current_reduced, future_reduced = basis.T @ current, basis.T @ future
392
+ fit = future_reduced @ jnp.linalg.pinv(
393
+ jnp.vstack([current_reduced, control]), rtol=1e-4
394
+ )
395
+ return (
396
+ basis @ fit[:, :rank] @ basis.T,
397
+ basis @ fit[:, rank:],
398
+ )
399
+
400
+
401
+ def generator_eigenvalues(eigenvalues: jax.Array, dt: float = 1.0) -> jax.Array:
402
+ """Convert discrete Koopman eigenvalues to infinitesimal-generator values."""
403
+ if not isinstance(dt, Real) or dt <= 0:
404
+ raise ValueError("dt must be positive")
405
+ eigenvalues = jnp.asarray(eigenvalues)
406
+ safe = jnp.where(jnp.abs(eigenvalues) < 1e-12, jnp.nan + 0j, eigenvalues)
407
+ return jnp.log(safe) / dt
408
+
409
+
410
+ def generator_dominant_mode(
411
+ eigenvalues: jax.Array, dt: float = 1.0
412
+ ) -> dict[str, Any]:
413
+ """Summarize the slowest non-constant finite generator mode."""
414
+ if not isinstance(dt, Real) or dt <= 0:
415
+ raise ValueError("dt must be positive")
416
+ eigenvalues = jnp.asarray(eigenvalues)
417
+ finite = jnp.isfinite(eigenvalues.real) & jnp.isfinite(eigenvalues.imag)
418
+ valid = finite & (jnp.abs(eigenvalues) > 1e-10)
419
+ if not bool(jnp.any(valid)):
420
+ return {
421
+ "decay_rate": float("nan"),
422
+ "angular_freq": float("nan"),
423
+ "period": float("nan"),
424
+ "e_folding_time": float("nan"),
425
+ "is_oscillatory": False,
426
+ "dominant_idx": -1,
427
+ }
428
+ index = int(jnp.argmin(jnp.where(valid, jnp.abs(eigenvalues.real), jnp.inf)))
429
+ value = eigenvalues[index]
430
+ decay = float(value.real)
431
+ frequency = float(value.imag)
432
+ period = 2 * jnp.pi / abs(frequency) if abs(frequency) > 1e-10 else jnp.nan
433
+ period = float(period)
434
+ return {
435
+ "decay_rate": decay,
436
+ "angular_freq": frequency,
437
+ "period": period,
438
+ "e_folding_time": 1 / abs(decay) if decay < -1e-10 else float("nan"),
439
+ "is_oscillatory": bool(2 * dt <= period <= 1e4 * dt),
440
+ "dominant_idx": index,
441
+ }
kews/diagnostics.py ADDED
@@ -0,0 +1,116 @@
1
+ """Optional operator and spectrum diagnostics kept outside the main API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, Optional, Union
6
+
7
+ import jax
8
+
9
+ from ._core import (
10
+ compute_dmdc as _dmdc,
11
+ compute_edmdwc as _edmdc,
12
+ fit_koopman_edmd as _fit_operator,
13
+ generator_dominant_mode as _dominant_mode,
14
+ generator_eigenvalues as _generator_eigenvalues,
15
+ )
16
+ from .observables import time_delay
17
+
18
+
19
+ def fit_operator(
20
+ data: jax.Array,
21
+ observable: Optional[Any] = None,
22
+ rank: Union[int, float] = 0.9,
23
+ fit_kwargs: Optional[Mapping[str, Any]] = None,
24
+ ) -> dict[str, Any]:
25
+ """Fit an autonomous EDMD Koopman operator for diagnostics.
26
+
27
+ Args:
28
+ data: State trajectory shaped ``(time,)`` or ``(state, time)``.
29
+ observable: Observable from :mod:`kews.observables`; defaults to
30
+ ``time_delay()``.
31
+ rank: Retained SVD energy fraction or absolute rank.
32
+ fit_kwargs: Optional training arguments for learned observables.
33
+
34
+ Returns:
35
+ Dictionary containing ``K``, ``eigvals``, row-wise ``eigvecs``,
36
+ ``Psi_X``, ``Psi_Y``, and the retained rank ``r``.
37
+ """
38
+ return _fit_operator(
39
+ data,
40
+ time_delay() if observable is None else observable,
41
+ rank,
42
+ fit_kwargs,
43
+ )
44
+
45
+
46
+ def dmdc(
47
+ data: jax.Array,
48
+ control: jax.Array,
49
+ n_delay: int = 5,
50
+ rank: Union[int, float] = 0.9,
51
+ ) -> tuple[jax.Array, jax.Array]:
52
+ """Fit Dynamic Mode Decomposition with control.
53
+
54
+ Args:
55
+ data: Scalar state trajectory shaped ``(time,)``.
56
+ control: Input trajectory shaped ``(time,)`` or ``(control, time)``.
57
+ n_delay: Delay-coordinate dimension for state.
58
+ rank: Retained SVD energy fraction or absolute rank.
59
+
60
+ Returns:
61
+ Tuple ``(A, B)`` for ``x_next = A @ x + B @ u`` in delay space.
62
+ """
63
+ return _dmdc(data, control, n_delay, rank)
64
+
65
+
66
+ def edmdc(
67
+ data: jax.Array,
68
+ control: jax.Array,
69
+ n_delay: int = 5,
70
+ rank: Union[int, float] = 0.9,
71
+ ) -> tuple[jax.Array, jax.Array]:
72
+ """Fit reduced EDMD with control in delay-coordinate space.
73
+
74
+ Args:
75
+ data: Scalar state trajectory shaped ``(time,)``.
76
+ control: Input trajectory shaped ``(time,)`` or ``(control, time)``.
77
+ n_delay: Delay-coordinate dimension for state.
78
+ rank: Retained SVD energy fraction or absolute rank.
79
+
80
+ Returns:
81
+ Tuple ``(K, B)`` containing lifted state and control operators.
82
+ """
83
+ return _edmdc(data, control, n_delay, rank)
84
+
85
+
86
+ def generator_eigenvalues(
87
+ eigenvalues: jax.Array, dt: float = 1.0
88
+ ) -> jax.Array:
89
+ """Convert discrete Koopman eigenvalues to generator eigenvalues.
90
+
91
+ Args:
92
+ eigenvalues: Complex eigenvalues of a discrete Koopman operator.
93
+ dt: Positive sampling interval.
94
+
95
+ Returns:
96
+ Complex generator values ``log(eigenvalues) / dt``. Values near zero
97
+ map to ``NaN``.
98
+ """
99
+ return _generator_eigenvalues(eigenvalues, dt)
100
+
101
+
102
+ def dominant_mode(eigenvalues: jax.Array, dt: float = 1.0) -> dict[str, Any]:
103
+ """Summarize the slowest finite non-constant generator mode.
104
+
105
+ Args:
106
+ eigenvalues: Complex generator eigenvalues.
107
+ dt: Positive sampling interval used for oscillation bounds.
108
+
109
+ Returns:
110
+ Decay rate, angular frequency, period, e-folding time, oscillatory flag,
111
+ and selected eigenvalue index.
112
+ """
113
+ return _dominant_mode(eigenvalues, dt)
114
+
115
+
116
+ __all__ = ["dmdc", "dominant_mode", "edmdc", "fit_operator", "generator_eigenvalues"]
kews/observables.py ADDED
@@ -0,0 +1,254 @@
1
+ """Observable factories for :func:`kews.resdmd`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from typing import Any, Callable, Optional, Sequence
7
+
8
+ import flax.linen as nn
9
+ import jax
10
+ import jax.numpy as jnp
11
+ import optax
12
+
13
+ from ._core import delay_embed, random_fourier_features
14
+
15
+
16
+ def _matrix(x: jax.Array) -> jax.Array:
17
+ """Orient time-series input as ``(state, time)``."""
18
+ return jnp.atleast_2d(x)
19
+
20
+
21
+ class _TimeDelay:
22
+ """Time-delay observable returned by :func:`kews.observables.time_delay`."""
23
+
24
+ def __init__(self, n_delay: int):
25
+ if n_delay < 1:
26
+ raise ValueError("n_delay must be >= 1")
27
+ self.n_delay = n_delay
28
+
29
+ def transform(self, x: jax.Array) -> jax.Array:
30
+ """Return delayed state columns."""
31
+ return delay_embed(x, self.n_delay)
32
+
33
+
34
+ class _RFF:
35
+ """Random Fourier observable returned by :func:`kews.observables.rff`."""
36
+
37
+ def __init__(self, n_features: int, scale: float, seed: int):
38
+ if n_features < 1:
39
+ raise ValueError("n_features must be >= 1")
40
+ if scale <= 0:
41
+ raise ValueError("scale must be positive")
42
+ self.n_features = n_features
43
+ self.scale = scale
44
+ self.seed = seed
45
+
46
+ def transform(self, x: jax.Array) -> jax.Array:
47
+ """Return a fixed random cosine feature matrix."""
48
+ key = jax.random.PRNGKey(self.seed)
49
+ return random_fourier_features(x, self.n_features, self.scale, key)
50
+
51
+
52
+ def _activation(name: str) -> Callable[[jax.Array], jax.Array]:
53
+ """Resolve a supported activation name."""
54
+ activations = {
55
+ "leaky_relu": nn.leaky_relu,
56
+ "relu": nn.relu,
57
+ "sigmoid": nn.sigmoid,
58
+ "tanh": nn.tanh,
59
+ }
60
+ if name not in activations:
61
+ raise ValueError(f"activation must be one of {tuple(activations)}")
62
+ return activations[name]
63
+
64
+
65
+ class _AutoEncoder(nn.Module):
66
+ """Symmetric dense autoencoder with an exposed encoder.
67
+
68
+ Layer creation order preserves the paper's seeded parameter initialization.
69
+ """
70
+
71
+ input_dim: int
72
+ hidden_layers: tuple[int, ...]
73
+ bottleneck_dim: int
74
+ activation: Callable[[jax.Array], jax.Array]
75
+
76
+ @nn.compact
77
+ def encode(self, x: jax.Array) -> jax.Array:
78
+ """Return bottleneck features shaped ``(features, time)``."""
79
+ hidden = x.T
80
+ for index, width in enumerate(self.hidden_layers):
81
+ hidden = nn.Dense(width, use_bias=False)(hidden)
82
+ if index < len(self.hidden_layers) - 1:
83
+ hidden = self.activation(hidden)
84
+ return nn.Dense(self.bottleneck_dim, use_bias=False)(hidden).T
85
+
86
+ @nn.compact
87
+ def __call__(self, x: jax.Array) -> jax.Array:
88
+ """Reconstruct input shaped ``(state, time)``."""
89
+ hidden = self.encode(x).T
90
+ for index, width in enumerate(reversed(self.hidden_layers)):
91
+ hidden = nn.Dense(width, use_bias=False)(hidden)
92
+ if index < len(self.hidden_layers) - 1:
93
+ hidden = self.activation(hidden)
94
+ return nn.Dense(self.input_dim, use_bias=False)(hidden).T
95
+
96
+
97
+ @lru_cache(maxsize=32)
98
+ def _train_step(module: _AutoEncoder, lr: float):
99
+ """Reuse compilation across the paper's same-architecture ensemble fits."""
100
+ optimizer = optax.adamw(lr)
101
+
102
+ @jax.jit
103
+ def step(params: Any, state: Any, batch: jax.Array):
104
+ def loss(candidate: Any) -> jax.Array:
105
+ reconstruction = module.apply(candidate, batch.T)
106
+ return jnp.mean((batch.T - reconstruction) ** 2)
107
+
108
+ _, gradients = jax.value_and_grad(loss)(params)
109
+ updates, state = optimizer.update(gradients, state, params)
110
+ return optax.apply_updates(params, updates), state
111
+
112
+ return step, optimizer
113
+
114
+
115
+ class _MLP:
116
+ """Trainable observable returned by :func:`kews.observables.mlp`."""
117
+
118
+ fit_on_first_window = True
119
+
120
+ def __init__(
121
+ self,
122
+ hidden_layers: Sequence[int],
123
+ bottleneck_dim: int,
124
+ activation: str,
125
+ seed: int,
126
+ ):
127
+ if not hidden_layers or any(width < 1 for width in hidden_layers):
128
+ raise ValueError("hidden_layers must contain positive widths")
129
+ if bottleneck_dim < 1:
130
+ raise ValueError("bottleneck_dim must be >= 1")
131
+ self.hidden_layers = tuple(hidden_layers)
132
+ self.bottleneck_dim = bottleneck_dim
133
+ self.activation = _activation(activation)
134
+ self.seed = seed
135
+ self._input_dim: Optional[int] = None
136
+ self._module: Optional[_AutoEncoder] = None
137
+ self._params: Any = None
138
+
139
+ def fit(
140
+ self,
141
+ x: jax.Array,
142
+ *,
143
+ lr: float = 1e-3,
144
+ epochs: int = 1000,
145
+ batch_size: int = 32,
146
+ key: Optional[jax.Array] = None,
147
+ shuffle: bool = True,
148
+ ) -> "_MLP":
149
+ """Train on time columns using AdamW and return this observable.
150
+
151
+ The seed controls both initialization and epoch shuffles. Repeated
152
+ calls continue from existing parameters with a fresh optimizer state.
153
+ ``resdmd`` supplies only the first analysis window to this method.
154
+ """
155
+ if lr <= 0 or epochs < 1 or batch_size < 1:
156
+ raise ValueError("lr, epochs, and batch_size must be positive")
157
+ x = _matrix(x)
158
+ if self._input_dim not in (None, x.shape[0]):
159
+ raise ValueError(
160
+ f"MLP was fitted with {self._input_dim} states, got {x.shape[0]}"
161
+ )
162
+ self._input_dim = x.shape[0]
163
+ self._module = _AutoEncoder(
164
+ self._input_dim,
165
+ self.hidden_layers,
166
+ self.bottleneck_dim,
167
+ self.activation,
168
+ )
169
+ key = jax.random.PRNGKey(self.seed) if key is None else key
170
+ if self._params is None:
171
+ self._params = self._module.init(jax.random.PRNGKey(self.seed), x)
172
+ params = self._params
173
+ step, optimizer = _train_step(self._module, lr)
174
+ state = optimizer.init(params)
175
+
176
+ samples = x.T
177
+ batch_size = min(batch_size, samples.shape[0])
178
+ for _ in range(epochs):
179
+ epoch_samples = samples
180
+ if shuffle:
181
+ key, shuffle_key = jax.random.split(key)
182
+ # Shuffle the original sample order, preserving the seeded training path.
183
+ epoch_samples = samples[jax.random.permutation(shuffle_key, samples.shape[0])]
184
+ for start in range(0, samples.shape[0], batch_size):
185
+ params, state = step(params, state, epoch_samples[start : start + batch_size])
186
+ self._params = params
187
+ return self
188
+
189
+ def transform(self, x: jax.Array) -> jax.Array:
190
+ """Encode a trajectory after fitting."""
191
+ x = _matrix(x)
192
+ if self._module is None or self._params is None:
193
+ raise RuntimeError("MLP must be fitted before transform")
194
+ if x.shape[0] != self._input_dim:
195
+ raise ValueError(
196
+ f"MLP was fitted with {self._input_dim} states, got {x.shape[0]}"
197
+ )
198
+ return self._module.apply(self._params, x, method=self._module.encode)
199
+
200
+
201
+ def time_delay(n_delay: int = 5) -> _TimeDelay:
202
+ """Create a delay-coordinate observable.
203
+
204
+ Args:
205
+ n_delay: Number of delayed state copies in each feature vector.
206
+
207
+ Returns:
208
+ An observable accepted by :func:`kews.resdmd`.
209
+ """
210
+ return _TimeDelay(n_delay)
211
+
212
+
213
+ # Common abbreviation for time-delay features.
214
+ tdf = time_delay
215
+
216
+
217
+ def rff(
218
+ n_features: int = 500, scale: float = 1.0, seed: int = 0
219
+ ) -> _RFF:
220
+ """Create a random Fourier feature observable.
221
+
222
+ Args:
223
+ n_features: Number of random cosine features.
224
+ scale: Positive RBF-kernel length scale.
225
+ seed: Random projection seed.
226
+
227
+ Returns:
228
+ An observable accepted by :func:`kews.resdmd`.
229
+ """
230
+ return _RFF(n_features, scale, seed)
231
+
232
+
233
+ def mlp(
234
+ hidden_layers: Sequence[int] = (16, 32),
235
+ bottleneck_dim: int = 3,
236
+ activation: str = "sigmoid",
237
+ seed: int = 0,
238
+ ) -> _MLP:
239
+ """Create a learned autoencoder observable.
240
+
241
+ Args:
242
+ hidden_layers: Encoder widths; the decoder mirrors them in reverse.
243
+ bottleneck_dim: Number of learned observable features.
244
+ activation: ``"relu"``, ``"tanh"``, ``"sigmoid"``, or
245
+ ``"leaky_relu"``.
246
+ seed: Parameter initialization and training seed.
247
+
248
+ Returns:
249
+ A trainable observable accepted by :func:`kews.resdmd`.
250
+ """
251
+ return _MLP(hidden_layers, bottleneck_dim, activation, seed)
252
+
253
+
254
+ __all__ = ["mlp", "rff", "tdf", "time_delay"]
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: kews
3
+ Version: 0.1.0
4
+ Summary: Koopman operator early warning signals
5
+ Author-email: Juan Nathaniel <jn2808@columbia.edu>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/juannat7/kews
8
+ Project-URL: Issues, https://github.com/juannat7/kews/issues
9
+ Project-URL: Paper, https://arxiv.org/abs/2608.14716
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE.txt
16
+ Requires-Dist: flax==0.12.4
17
+ Requires-Dist: jax==0.9.0.1
18
+ Requires-Dist: jaxlib==0.9.0.1
19
+ Requires-Dist: optax==0.2.6
20
+ Provides-Extra: paper
21
+ Requires-Dist: numpy==2.3.5; extra == "paper"
22
+ Requires-Dist: scipy==1.16.3; extra == "paper"
23
+ Requires-Dist: matplotlib==3.10.8; extra == "paper"
24
+ Requires-Dist: scikit-learn==1.8.0; extra == "paper"
25
+ Requires-Dist: pandas==3.0.0; extra == "paper"
26
+ Requires-Dist: xarray==2026.1.0; extra == "paper"
27
+ Requires-Dist: netCDF4==1.7.3; extra == "paper"
28
+ Requires-Dist: cmocean==4.0.3; extra == "paper"
29
+ Requires-Dist: requests==2.32.5; extra == "paper"
30
+ Requires-Dist: tqdm==4.67.1; extra == "paper"
31
+ Requires-Dist: jupyterlab==4.5.0; extra == "paper"
32
+ Requires-Dist: nbconvert==7.16.6; extra == "paper"
33
+ Requires-Dist: ipykernel==7.1.0; extra == "paper"
34
+ Provides-Extra: dev
35
+ Requires-Dist: build>=1.2; extra == "dev"
36
+ Requires-Dist: twine>=6; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # KEWS: Koopman Early Warning Signals
40
+
41
+ **KEWS** (pronounced “cues,” as in cues to potential tipping) is a JAX package for Koopman-based early warning signals of bifurcation- and rate-induced tipping.
42
+
43
+ <div align="center">
44
+
45
+ [![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
46
+ [![JAX](https://img.shields.io/badge/JAX-0.9-000000)](https://docs.jax.dev/)
47
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/juannat7/kews/blob/main/LICENSE.txt)
48
+ [![PyPI version](https://img.shields.io/pypi/v/kews)](https://pypi.org/project/kews/)
49
+ [![PyPI downloads](https://img.shields.io/pypi/dm/kews)](https://pypi.org/project/kews/)
50
+ [![arXiv](https://img.shields.io/badge/arXiv-2608.14716-b31b1b.svg)](https://arxiv.org/abs/2608.14716)
51
+
52
+ </div>
53
+
54
+ ## Abstract
55
+
56
+ Abrupt transitions in complex systems are often preceded by early warning signals. However, most indicators rely on the notion of critical slowing down and do not generally extend to rate-induced tipping where transitions can occur without local loss of stability. This is problematic in stochastic, nonautonomous systems where internal variability and time-varying variables interact to shape tipping onset.
57
+
58
+ We use Koopman operator theory to develop a unified early warning framework for both bifurcation and rate-induced tipping in stochastic systems. Our approach builds on residual Koopman mode decomposition that measures discrepancies between dynamics and their finite-dimensional approximation, and extends it to the control setting by augmenting the observable space with time-varying control variables.
59
+
60
+ In idealized examples, the resulting indicators recover expected signatures near bifurcation points and improve detection in rate-induced regimes where classical indicators fail. We further show that learned embeddings through deep learning outperform prescribed dictionaries, especially in a high-dimensional setting.
61
+
62
+ Applied to simulations of the Atlantic Meridional Overturning Circulation, our Koopman-based indicators distinguish tipping from non-tipping trajectories and reveal interpretable spectral signatures prior to critical transition.
63
+
64
+ ## Installation
65
+
66
+ Python 3.11 or newer is required. Install the library from PyPI:
67
+
68
+ ```bash
69
+ pip install kews
70
+ ```
71
+
72
+ To reproduce the paper or develop the code:
73
+
74
+ ```bash
75
+ git clone https://github.com/juannat7/kews.git
76
+ cd kews
77
+ pip install -e ".[paper,dev]"
78
+ ```
79
+
80
+ Reproduction instructions are in [examples](https://github.com/juannat7/kews/tree/main/examples) for the model systems and [experiments](https://github.com/juannat7/kews/tree/main/experiments) for the coupled AMOC and empirical cases.
81
+
82
+ ## Quickstart
83
+
84
+ Compute rolling ResKMD residuals from a scalar time series:
85
+
86
+ ```python
87
+ import jax.numpy as jnp
88
+ from jax import random
89
+
90
+ import kews
91
+ from kews.observables import time_delay
92
+
93
+ time = jnp.linspace(0, 10 * jnp.pi, 100)
94
+ state = jnp.sin(time) + 0.01 * random.normal(random.key(7), time.shape)
95
+
96
+ signal = kews.resdmd(
97
+ state,
98
+ window=50, # Window length in samples (or a fraction, e.g. 0.5)
99
+ observable=time_delay(n_delay=10), # Time-delay dictionary with 10 coordinates
100
+ rank=0.9, # Retain 90% of the SVD energy
101
+ )
102
+
103
+ # One residual per time step; the first 49 entries are NaN.
104
+ print(signal.shape) # (100,)
105
+ ```
106
+
107
+ ## Control-aware residuals
108
+
109
+ Pass a control time series aligned with the state:
110
+
111
+ ```python
112
+ control = jnp.linspace(-1, 1, state.size)
113
+ controlled_signal = kews.resdmd(
114
+ state,
115
+ window=50,
116
+ observable=time_delay(10),
117
+ control=control,
118
+ )
119
+ ```
120
+
121
+ ## Observable choices
122
+
123
+ Use fixed random Fourier features instead of delay coordinates:
124
+
125
+ ```python
126
+ from kews.observables import rff
127
+
128
+ random_signal = kews.resdmd(
129
+ state,
130
+ window=50,
131
+ observable=rff(n_features=100, scale=0.2, seed=7),
132
+ )
133
+ ```
134
+
135
+ The random basis stays fixed across windows. An autoencoder can learn the features:
136
+
137
+ ```python
138
+ from kews.observables import mlp
139
+
140
+ learned_signal = kews.resdmd(
141
+ state,
142
+ window=50,
143
+ observable=mlp(hidden_layers=(16, 8), bottleneck_dim=3, seed=7),
144
+ fit_kwargs={"epochs": 200, "batch_size": 32, "lr": 1e-3},
145
+ )
146
+ ```
147
+
148
+ Training uses only the first window; the encoder is then frozen. Create a new observable for each independent fit. Paper notebooks specify their full training settings.
149
+
150
+ ## Spectral diagnostics
151
+
152
+ ```python
153
+ from kews import diagnostics
154
+
155
+ fit = diagnostics.fit_operator(state, observable=time_delay(10), rank=0.9)
156
+ generator = diagnostics.generator_eigenvalues(fit["eigvals"], dt=1.0)
157
+ dominant = diagnostics.dominant_mode(generator, dt=1.0)
158
+ ```
159
+
160
+ `dt` is the sampling interval in the units of the input data. The mode summary includes decay rate, oscillation period, and e-folding time.
161
+
162
+ ## Citation
163
+
164
+ ```bibtex
165
+ @article{nathaniel2026koopman,
166
+ title={Koopman early warning signals for bifurcation and rate-induced tipping},
167
+ author={Nathaniel, Juan and Roesch, Carla and DeSantis, Derek and Kooloth, Parvathi and Fan, Hang and Lucarini, Valerio and Romanou, Anastasia and Gentine, Pierre},
168
+ journal={arXiv preprint arXiv:2608.14716},
169
+ year={2026}
170
+ }
171
+ ```
172
+
173
+ Code uses the [MIT license](https://github.com/juannat7/kews/blob/main/LICENSE.txt). External datasets retain their original terms.
@@ -0,0 +1,9 @@
1
+ kews/__init__.py,sha256=WIc9QuyQNB71Iks-KQdfTOx61V5SVcbXWWv9Nvk8Fgg,4576
2
+ kews/_core.py,sha256=gxT-5wfqMIfSXtCG-dp2AO5m-ytdw3qVxXPXFGOlhus,16130
3
+ kews/diagnostics.py,sha256=sD1D6B3kO1rEwDzAZyxQb0VpD_wKvnaGI6g-RpdqR6c,3520
4
+ kews/observables.py,sha256=jq_HlqwVXTJULD0Zbr5VtvaOmAc2WGZ5C1UL91pv3u4,8486
5
+ kews-0.1.0.dist-info/licenses/LICENSE.txt,sha256=D5YkCzfzd33EgQ7LxPRC19TDIfk-K6EMCv6-feQ-6eg,1076
6
+ kews-0.1.0.dist-info/METADATA,sha256=F5Gf9pHOjDTZkvX0mBIIXMT9e_mCtZA-XeRctQRNK6U,6763
7
+ kews-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ kews-0.1.0.dist-info/top_level.txt,sha256=0ENNba47A1oAS2t06IstrNB_TvoPVKq_T6lRHGrrdDo,5
9
+ kews-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KEWS Developer Team
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 @@
1
+ kews