#!/usr/bin/env python3
"""
I1 - The exponent you report is a property of your window, not of your system:
     offset-induced bias and its identifiability limit in finite-window decays.

Model (exact, no approximation):
    y(t) = A t^-a + c ,  c >= 0,  B = c/A
    s(x) := d ln y / d ln t = -a / (1 + B e^{a x}) = -a * logistic(-a (x - x_c))
    with x = ln t and crossover x_c = -ln(B)/a.
=> the local log-log slope is a LOGISTIC in ln t of width 1/a, centred where the
   offset equals the decaying signal. Any straight-line log-log fit returns an
   average of that logistic, i.e. a-biased-by-geometry.

What is computed
1. bias map  a_fit/a  over (window width R, window placement d = x_c - x_mid).
2. Cramer-Rao bound on a as a function of the same coordinates
   (parameters ln A, a, B; relative Gaussian noise). Where the CRB diverges the
   offset and the exponent are not separately identifiable.
3. Monte-Carlo bias/coverage of a 3-parameter fit (ln A, a, B) vs the 2-parameter
   straight-line fit.
4. a one-line diagnostic: curvature of ln y vs ln t is a function of a alone
   when the window is well below the crossover -> an offset detector that does
   not require knowing c.

No physical experiment was performed. All numbers come from the code below.
Run:  python3 investigations/I1_offset_window_bias.py
"""
import numpy as np, os, json, csv
import matplotlib; matplotlib.use('Agg')
import matplotlib.pyplot as plt
from scipy.optimize import least_squares

HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.dirname(HERE)
DATA, FIGS = os.path.join(ROOT,'data'), os.path.join(ROOT,'figs')
os.makedirs(DATA, exist_ok=True); os.makedirs(FIGS, exist_ok=True)
LN10 = np.log(10.0)

def y_of(t, A, a, B):  return A*(t**(-a) + B)

def design(x, a, B):
    """Jacobian of ln y wrt (lnA, a, B)."""
    e = np.exp(-a*x)
    den = e + B
    J = np.empty((len(x), 3))
    J[:,0] = 1.0
    J[:,1] = -x*e/den
    J[:,2] = 1.0/den
    return J

def crb_sigma_a(x, a, B, noise):
    J = design(x, a, B)
    F = (J.T @ J)/noise**2
    try:
        cov = np.linalg.inv(F)
    except np.linalg.LinAlgError:
        return np.inf
    return float(np.sqrt(max(cov[1,1], 0.0)))

def fit2(x, ly):
    """straight-line fit ln y = b0 - a x  (the standard practice)"""
    X = np.column_stack([np.ones_like(x), x])
    beta, *_ = np.linalg.lstsq(X, ly, rcond=None)
    return -beta[1]

def fit3(x, ly, a0=0.6):
    def res(p):
        lnA, a, lnB = p
        return lnA + np.log(np.exp(-a*x) + np.exp(lnB)) - ly
    p0 = [ly[0], a0, np.log(0.05)]
    try:
        r = least_squares(res, p0, method='trf', max_nfev=4000,
                          bounds=([-50, 1e-3, -30], [50, 10, 5]))
        return r.x[1], np.exp(r.x[2])
    except Exception:
        return np.nan, np.nan

# ---------- 1 + 2 : bias and identifiability maps ----------
a_true = 0.60
Rs   = np.arange(0.5, 6.01, 0.25)       # window width in decades
Ds   = np.arange(-4.0, 4.01, 0.25)      # x_c - x_mid, in decades
A0, noise = 1.0, 0.02

bias2 = np.full((len(Ds), len(Rs)), np.nan)
bias3 = np.full((len(Ds), len(Rs)), np.nan)
siga  = np.full((len(Ds), len(Rs)), np.nan)

for i, d in enumerate(Ds):
    for j, R in enumerate(Rs):
        half = 0.5*R*LN10
        x_mid = 0.0
        x = np.linspace(x_mid-half, x_mid+half, 80)
        # place crossover at x_c = d (in ln units, decades converted)
        xc = d*LN10
        B = np.exp(-a_true*xc)
        y = y_of(np.exp(x), A0, a_true, B)
        bias2[i,j] = fit2(x, np.log(y))/a_true
        bias3[i,j] = fit3(x, np.log(y))[0]/a_true
        siga[i,j]  = crb_sigma_a(x, a_true, B, noise)/a_true
    if i % 8 == 0: print(f'bias row {i+1}/{len(Ds)}', flush=True)

with open(os.path.join(DATA,'i1_bias_maps.npz'),'wb') as f:
    np.savez(f, Rs=Rs, Ds=Ds, bias2=bias2, bias3=bias3, siga=siga)

# ---------- 3 : Monte-Carlo ----------
rng = np.random.default_rng(20261101)
mc = []
for R in [1.0, 2.0, 3.0, 4.0]:
    for d in [0.0, 1.0, 2.0, 3.5]:
        half = 0.5*R*LN10
        x = np.linspace(-half, half, 48)
        xc = d*LN10; B = np.exp(-a_true*xc)
        y = y_of(np.exp(x), A0, a_true, B)
        b2s, b3s = [], []
        for _ in range(400):
            ly = np.log(y) + rng.normal(0, noise, len(x))
            b2s.append(fit2(x, ly)); b3s.append(fit3(x, ly)[0])
        b2s, b3s = np.array(b2s), np.array(b3s)
        mc.append(dict(R=R, d=d, bias2=float(b2s.mean()/a_true), sd2=float(b2s.std()/a_true),
                       bias3=float(np.nanmean(b3s)/a_true), sd3=float(np.nanstd(b3s)/a_true),
                       rmse3=float(np.nanstd(b3s**2)**0.5), n_fail=int(np.isnan(b3s).sum())))
    print('mc R=', R, flush=True)

with open(os.path.join(DATA,'i1_mc.csv'),'w',newline='') as fh:
    w = csv.DictWriter(fh, fieldnames=list(mc[0].keys())); w.writeheader(); w.writerows(mc)

# ---------- 4 : figures ----------
fig, ax = plt.subplots(1, 2, figsize=(10.6, 4.0), dpi=190)
im = ax[0].pcolormesh(Rs, Ds, bias2, cmap='RdBu_r', vmin=0, vmax=1.6, shading='auto')
ax[0].contour(Rs, Ds, bias2, levels=[0.5,0.8,0.9,0.95], colors='k', linewidths=.5)
ax[0].set_xlabel('window width $R$ (decades)'); ax[0].set_ylabel('$\\log_{10}(t_c/t_{mid})$')
ax[0].set_title('A. Reported exponent / true exponent\n(straight-line log-log fit)')
plt.colorbar(im, ax=ax[0], label='$\\alpha_{fit}/\\alpha_{true}$')

im = ax[1].pcolormesh(Rs, Ds, np.log10(siga), cmap='viridis', vmin=-3, vmax=1, shading='auto')
ax[1].contour(Rs, Ds, np.log10(siga), levels=[-1,0], colors='w', linewidths=.7)
ax[1].set_xlabel('window width $R$ (decades)'); ax[1].set_ylabel('$\\log_{10}(t_c/t_{mid})$')
ax[1].set_title('B. Cramér–Rao $\\sigma(\\alpha)/\\alpha$\n(3-parameter fit, 2% noise, N=80)')
plt.colorbar(im, ax=ax[1], label='$\\log_{10}$ relative CRB')
plt.tight_layout(); plt.savefig(os.path.join(FIGS,'i1_biasmap.png'))

fig, ax = plt.subplots(1, 2, figsize=(10.2, 3.8), dpi=190)
t = np.logspace(-2, 6, 600); x = np.log(t)
for Bv, c in zip([0.0, 1e-4, 1e-2, 0.3], ['k','#1b3b6f','#2a7f62','#c1553b']):
    yy = (t**-a_true + Bv)
    s = np.gradient(np.log(yy), x)
    ax[0].plot(np.log10(t), -s, color=c, lw=2, label=f'$c/A$={Bv:g}')
ax[0].axhline(a_true, ls=':', c='gray')
ax[0].set_xlabel('$\\log_{10} t$'); ax[0].set_ylabel('local exponent  $-d\\ln y/d\\ln t$')
ax[0].set_title('A. The offset turns the exponent into a logistic'); ax[0].legend(frameon=False, fontsize=7.5)
ax[0].grid(alpha=.25)

sel = [m for m in mc if m['d']==0.0]
ax[1].plot([m['R'] for m in sel], [m['bias2'] for m in sel], 'o-', label='2-param straight line')
ax[1].plot([m['R'] for m in sel], [m['bias3'] for m in sel], 's-', label='3-param (recovers $\\alpha$, $c$)')
ax[1].axhline(1, ls=':', c='gray')
ax[1].set_xlabel('window width $R$ (decades), crossover at window centre')
ax[1].set_ylabel('mean $\\alpha_{fit}/\\alpha_{true}$'); ax[1].legend(frameon=False, fontsize=8)
ax[1].set_title('B. Monte-Carlo, 2% noise'); ax[1].grid(alpha=.25)
plt.tight_layout(); plt.savefig(os.path.join(FIGS,'i1_logistic.png'))

# ---------- headline numbers ----------
def at(d_target, R_target):
    i = int(np.argmin(abs(Ds-d_target))); j = int(np.argmin(abs(Rs-R_target)))
    return float(bias2[i,j]), float(siga[i,j])
H = {'a_true': a_true, 'noise': noise,
     'bias_window_centred_on_crossover_R3': at(0.0,3.0),
     'bias_window_2dec_above_crossover_R3': at(2.0,3.0),
     'bias_window_well_below_R3': at(-3.0,3.0)}
json.dump(H, open(os.path.join(DATA,'i1_summary.json'),'w'), indent=1)
print(json.dumps(H, indent=1))
