feat: wavelets
This commit is contained in:
@@ -4,12 +4,15 @@ c:\\DataS\\InstrumentSimulator\\backend\\app\\wavelet.py (project "Macro Causal
|
|||||||
|
|
||||||
No project-specific dependencies — pure numpy/ssqueezepy, safe to call from any service.
|
No project-specific dependencies — pure numpy/ssqueezepy, safe to call from any service.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from ssqueezepy import cwt, icwt
|
from ssqueezepy import cwt, icwt
|
||||||
from ssqueezepy.utils.cwt_utils import center_frequency
|
from ssqueezepy.utils.cwt_utils import center_frequency
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MIN_SCALES_PER_BAND = 4
|
MIN_SCALES_PER_BAND = 4
|
||||||
|
|
||||||
# Fixed period (days) lower-bounds for bands 0..5, independent of the analysis
|
# Fixed period (days) lower-bounds for bands 0..5, independent of the analysis
|
||||||
@@ -72,6 +75,7 @@ def band_decompose(
|
|||||||
mask = (log_scales >= lo) & (log_scales <= hi if i == num_levels - 1 else log_scales < hi)
|
mask = (log_scales >= lo) & (log_scales <= hi if i == num_levels - 1 else log_scales < hi)
|
||||||
scales_band = scales[mask]
|
scales_band = scales[mask]
|
||||||
|
|
||||||
|
reconstruction_failed = False
|
||||||
if len(scales_band) >= MIN_SCALES_PER_BAND:
|
if len(scales_band) >= MIN_SCALES_PER_BAND:
|
||||||
try:
|
try:
|
||||||
recon = icwt(Wx[mask, :], wavelet=wavelet, scales=scales_band, x_len=n)
|
recon = icwt(Wx[mask, :], wavelet=wavelet, scales=scales_band, x_len=n)
|
||||||
@@ -79,14 +83,26 @@ def band_decompose(
|
|||||||
# ssqueezepy's internal scale-type inference (log vs linear spacing
|
# ssqueezepy's internal scale-type inference (log vs linear spacing
|
||||||
# detection) can raise on some narrow slices depending on how many
|
# detection) can raise on some narrow slices depending on how many
|
||||||
# scales land in this bucket for this specific series length.
|
# scales land in this bucket for this specific series length.
|
||||||
# Treat as a negligible band instead of failing the whole request.
|
# Treat as a negligible band instead of failing the whole request —
|
||||||
|
# but log it (previously silently swallowed) and flag it, since a flat
|
||||||
|
# zero band otherwise looks identical to "genuinely no energy here".
|
||||||
|
logger.warning(
|
||||||
|
"band_decompose: icwt failed for band %d (n=%d scales, wavelet=%s) — "
|
||||||
|
"falling back to a zero band", i, len(scales_band), wavelet, exc_info=True,
|
||||||
|
)
|
||||||
recon = np.zeros(n)
|
recon = np.zeros(n)
|
||||||
|
reconstruction_failed = True
|
||||||
scale_lo, scale_hi = float(scales_band.min()), float(scales_band.max())
|
scale_lo, scale_hi = float(scales_band.min()), float(scales_band.max())
|
||||||
else:
|
else:
|
||||||
# Too few scales in this bucket for a stable reconstruction; report a
|
# Too few scales in this bucket for a stable reconstruction; report a
|
||||||
# flat zero band (its content ends up folded into the residual) so the
|
# flat zero band (its content ends up folded into the residual) so the
|
||||||
# band list always stays num_levels long.
|
# band list always stays num_levels long.
|
||||||
|
logger.warning(
|
||||||
|
"band_decompose: only %d scale(s) in band %d (< %d minimum) — falling back to a zero band",
|
||||||
|
len(scales_band), i, MIN_SCALES_PER_BAND,
|
||||||
|
)
|
||||||
recon = np.zeros(n)
|
recon = np.zeros(n)
|
||||||
|
reconstruction_failed = True
|
||||||
scale_lo, scale_hi = float(np.exp(lo)), float(np.exp(hi))
|
scale_lo, scale_hi = float(np.exp(lo)), float(np.exp(hi))
|
||||||
|
|
||||||
freq_at_scale_hi = center_frequency(wavelet, scale=scale_hi, N=n)
|
freq_at_scale_hi = center_frequency(wavelet, scale=scale_hi, N=n)
|
||||||
@@ -105,6 +121,7 @@ def band_decompose(
|
|||||||
"period_low_days": period_low_days,
|
"period_low_days": period_low_days,
|
||||||
"period_high_days": period_high_days,
|
"period_high_days": period_high_days,
|
||||||
"series": [round(float(v), 6) for v in recon],
|
"series": [round(float(v), 6) for v in recon],
|
||||||
|
"reconstruction_failed": reconstruction_failed,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,6 +165,7 @@ def windowed_band_decompose(
|
|||||||
all_residual: list[float] = []
|
all_residual: list[float] = []
|
||||||
band_series: list[list[float]] = [[] for _ in range(num_levels)]
|
band_series: list[list[float]] = [[] for _ in range(num_levels)]
|
||||||
band_meta: list[dict | None] = [None] * num_levels
|
band_meta: list[dict | None] = [None] * num_levels
|
||||||
|
band_failed: list[bool] = [False] * num_levels
|
||||||
|
|
||||||
chunk_count = 0
|
chunk_count = 0
|
||||||
start = 0
|
start = 0
|
||||||
@@ -164,6 +182,10 @@ def windowed_band_decompose(
|
|||||||
for band in chunk["bands"]:
|
for band in chunk["bands"]:
|
||||||
band_series[band["index"]].extend(band["series"])
|
band_series[band["index"]].extend(band["series"])
|
||||||
band_meta[band["index"]] = band
|
band_meta[band["index"]] = band
|
||||||
|
# OR across chunks: if any slice couldn't reconstruct this band, flag the
|
||||||
|
# whole concatenated series as unreliable rather than losing that signal
|
||||||
|
# when a later chunk happens to succeed and overwrites band_meta.
|
||||||
|
band_failed[band["index"]] = band_failed[band["index"]] or band.get("reconstruction_failed", False)
|
||||||
start = end
|
start = end
|
||||||
|
|
||||||
bands = []
|
bands = []
|
||||||
@@ -176,6 +198,7 @@ def windowed_band_decompose(
|
|||||||
"period_low_days": meta["period_low_days"],
|
"period_low_days": meta["period_low_days"],
|
||||||
"period_high_days": meta["period_high_days"],
|
"period_high_days": meta["period_high_days"],
|
||||||
"series": band_series[i],
|
"series": band_series[i],
|
||||||
|
"reconstruction_failed": band_failed[i],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,6 +239,7 @@ def rolling_causal_bands(
|
|||||||
out_original: list[float] = []
|
out_original: list[float] = []
|
||||||
band_series: list[list[float]] = [[] for _ in range(num_levels)]
|
band_series: list[list[float]] = [[] for _ in range(num_levels)]
|
||||||
band_labels = [f"bande {i + 1}" for i in range(num_levels)]
|
band_labels = [f"bande {i + 1}" for i in range(num_levels)]
|
||||||
|
band_failed = [False] * num_levels
|
||||||
last_tip: list[float] | None = None
|
last_tip: list[float] | None = None
|
||||||
recomputations = 0
|
recomputations = 0
|
||||||
|
|
||||||
@@ -229,6 +253,8 @@ def rolling_causal_bands(
|
|||||||
result = band_decompose(window_values, window_dates, num_levels, wavelet)
|
result = band_decompose(window_values, window_dates, num_levels, wavelet)
|
||||||
last_tip = [band["series"][-1] for band in result["bands"]]
|
last_tip = [band["series"][-1] for band in result["bands"]]
|
||||||
band_labels = [band["label"] for band in result["bands"]]
|
band_labels = [band["label"] for band in result["bands"]]
|
||||||
|
for i, band in enumerate(result["bands"]):
|
||||||
|
band_failed[i] = band_failed[i] or band.get("reconstruction_failed", False)
|
||||||
recomputations += 1
|
recomputations += 1
|
||||||
out_dates.append(dates[t])
|
out_dates.append(dates[t])
|
||||||
out_original.append(values[t])
|
out_original.append(values[t])
|
||||||
@@ -242,6 +268,7 @@ def rolling_causal_bands(
|
|||||||
"period_low_days": None,
|
"period_low_days": None,
|
||||||
"period_high_days": None,
|
"period_high_days": None,
|
||||||
"series": band_series[i],
|
"series": band_series[i],
|
||||||
|
"reconstruction_failed": band_failed[i],
|
||||||
}
|
}
|
||||||
for i in range(num_levels)
|
for i in range(num_levels)
|
||||||
]
|
]
|
||||||
@@ -324,6 +351,7 @@ def band_decompose_ssq(
|
|||||||
hi_period = period_edges[i + 1] if i + 1 < len(period_edges) else float(periods.max())
|
hi_period = period_edges[i + 1] if i + 1 < len(period_edges) else float(periods.max())
|
||||||
mask = (periods >= lo_period) & (periods <= hi_period if i == num_levels - 1 else periods < hi_period)
|
mask = (periods >= lo_period) & (periods <= hi_period if i == num_levels - 1 else periods < hi_period)
|
||||||
|
|
||||||
|
reconstruction_failed = False
|
||||||
if mask.any():
|
if mask.any():
|
||||||
try:
|
try:
|
||||||
Tx_band = np.zeros_like(Tx)
|
Tx_band = np.zeros_like(Tx)
|
||||||
@@ -333,12 +361,20 @@ def band_decompose_ssq(
|
|||||||
recon = np.real(issq_cwt(Tx_band, wavelet=wavelet))
|
recon = np.real(issq_cwt(Tx_band, wavelet=wavelet))
|
||||||
except Exception:
|
except Exception:
|
||||||
# Defensive, mirroring band_decompose: never let one band's edge
|
# Defensive, mirroring band_decompose: never let one band's edge
|
||||||
# case fail the whole request.
|
# case fail the whole request — but log it and flag it (see
|
||||||
|
# band_decompose's identical fix for why this used to be silent).
|
||||||
|
logger.warning(
|
||||||
|
"band_decompose_ssq: issq_cwt failed for band %d (wavelet=%s) — "
|
||||||
|
"falling back to a zero band", i, wavelet, exc_info=True,
|
||||||
|
)
|
||||||
recon = np.zeros(n)
|
recon = np.zeros(n)
|
||||||
|
reconstruction_failed = True
|
||||||
energy = np.sum(np.abs(Tx[mask, :]) ** 2, axis=0)
|
energy = np.sum(np.abs(Tx[mask, :]) ** 2, axis=0)
|
||||||
else:
|
else:
|
||||||
|
logger.warning("band_decompose_ssq: no frequencies fall in band %d — falling back to a zero band", i)
|
||||||
recon = np.zeros(n)
|
recon = np.zeros(n)
|
||||||
energy = np.zeros(n)
|
energy = np.zeros(n)
|
||||||
|
reconstruction_failed = True
|
||||||
|
|
||||||
period_low_days = round(float(lo_period), 1)
|
period_low_days = round(float(lo_period), 1)
|
||||||
period_high_days = round(float(hi_period), 1)
|
period_high_days = round(float(hi_period), 1)
|
||||||
@@ -350,6 +386,7 @@ def band_decompose_ssq(
|
|||||||
"period_high_days": period_high_days,
|
"period_high_days": period_high_days,
|
||||||
"series": [round(float(v), 6) for v in recon],
|
"series": [round(float(v), 6) for v in recon],
|
||||||
"energy": [round(float(v), 8) for v in energy],
|
"energy": [round(float(v), 8) for v in energy],
|
||||||
|
"reconstruction_failed": reconstruction_failed,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,6 +434,7 @@ def rolling_causal_bands_ssq(
|
|||||||
energy_series: list[list[float]] = [[] for _ in range(num_levels)]
|
energy_series: list[list[float]] = [[] for _ in range(num_levels)]
|
||||||
ridge_series: list[float | None] = []
|
ridge_series: list[float | None] = []
|
||||||
band_labels = [f"bande {i + 1}" for i in range(num_levels)]
|
band_labels = [f"bande {i + 1}" for i in range(num_levels)]
|
||||||
|
band_failed = [False] * num_levels
|
||||||
last_tip: list[float] | None = None
|
last_tip: list[float] | None = None
|
||||||
last_energy_tip: list[float] | None = None
|
last_energy_tip: list[float] | None = None
|
||||||
last_ridge_tip: float | None = None
|
last_ridge_tip: float | None = None
|
||||||
@@ -414,6 +452,8 @@ def rolling_causal_bands_ssq(
|
|||||||
last_energy_tip = [band["energy"][-1] for band in result["bands"]]
|
last_energy_tip = [band["energy"][-1] for band in result["bands"]]
|
||||||
last_ridge_tip = result["ridge_period_days"][-1]
|
last_ridge_tip = result["ridge_period_days"][-1]
|
||||||
band_labels = [band["label"] for band in result["bands"]]
|
band_labels = [band["label"] for band in result["bands"]]
|
||||||
|
for i, band in enumerate(result["bands"]):
|
||||||
|
band_failed[i] = band_failed[i] or band.get("reconstruction_failed", False)
|
||||||
recomputations += 1
|
recomputations += 1
|
||||||
out_dates.append(dates[t])
|
out_dates.append(dates[t])
|
||||||
out_original.append(values[t])
|
out_original.append(values[t])
|
||||||
@@ -430,6 +470,7 @@ def rolling_causal_bands_ssq(
|
|||||||
"period_high_days": None,
|
"period_high_days": None,
|
||||||
"series": band_series[i],
|
"series": band_series[i],
|
||||||
"energy": energy_series[i],
|
"energy": energy_series[i],
|
||||||
|
"reconstruction_failed": band_failed[i],
|
||||||
}
|
}
|
||||||
for i in range(num_levels)
|
for i in range(num_levels)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1719,7 +1719,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const WAVELET_SLOPE_LAG = 5
|
const WAVELET_SLOPE_LAG = 5
|
||||||
|
|
||||||
const waveletEnergy = useMemo(() => {
|
const waveletEnergy = useMemo(() => {
|
||||||
const bands: { label: string; series: number[] }[] = waveletData?.bands ?? []
|
const bands: { label: string; series: number[]; reconstruction_failed?: boolean }[] = waveletData?.bands ?? []
|
||||||
const n = bands[0]?.series?.length ?? 0
|
const n = bands[0]?.series?.length ?? 0
|
||||||
if (!bands.length || n === 0) return null
|
if (!bands.length || n === 0) return null
|
||||||
|
|
||||||
@@ -2184,6 +2184,9 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span>
|
<span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span>
|
||||||
|
{b.reconstruction_failed && (
|
||||||
|
<span className="text-amber-400" title="Reconstruction indisponible pour cette bande (pas assez d'échelles, ou échec interne de la librairie ondelette) — la série est à zéro, ce n'est pas 'pas d'énergie'.">⚠</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2272,6 +2275,9 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span style={{ color }}>{b.label}</span>
|
<span style={{ color }}>{b.label}</span>
|
||||||
{isSlow && <span className="text-slate-600">(lente)</span>}
|
{isSlow && <span className="text-slate-600">(lente)</span>}
|
||||||
|
{b.reconstruction_failed && (
|
||||||
|
<span className="text-amber-400" title="Reconstruction indisponible pour cette bande — série à zéro, les chiffres ci-contre ne reflètent pas une vraie absence d'énergie.">⚠</span>
|
||||||
|
)}
|
||||||
<Sparkline values={sparkValues} color={color} />
|
<Sparkline values={sparkValues} color={color} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user