From 6d8fa047c9aacb9d752eab4e152a285aa797d03b Mon Sep 17 00:00:00 2001 From: Richard O'Shaughnessy Date: Wed, 8 Jul 2026 11:19:26 -0700 Subject: [PATCH] slowrot: time/frequency-dependent detector-response likelihoods (Path A/B rotation + Path D finite-size) Squashed feature import onto rift_O4d (dev history on rift_slowrot / calmarg_in_loop). Adds RIFT/likelihood/{factored_likelihood_with_rotation,factored_likelihood_freqresponse, slowrot_response,slowrot_freqresponse}.py, test_slowrot_*.py, and demo/rift/slowrot/. Wires --rotation-slow/--rotation-p-max (Path A/B) and --freqresponse (Path D) into integrate_likelihood_extrinsic_batchmode (CPU vectorized/NoLoop, guarded, opt-in). Validated: rotation NoLoop==baseline 3.6e-12; finite-size L->0==baseline 3e-9, Cauchy-Schwarz respected, V4 positive control +38.9 nats; cubic time-interp with precision-preserving time ref. Co-Authored-By: Claude Opus 4.8 --- .../Code/RIFT/likelihood/SLOWROT_HANDOFF.md | 207 ++++++ .../RIFT/likelihood/factored_likelihood.py | 9 +- .../factored_likelihood_freqresponse.py | 408 ++++++++++++ .../factored_likelihood_with_rotation.py | 619 ++++++++++++++++++ .../RIFT/likelihood/slowrot_freqresponse.py | 442 +++++++++++++ .../Code/RIFT/likelihood/slowrot_response.py | 271 ++++++++ .../RIFT/likelihood/test_slowrot_fd_ops.py | 144 ++++ .../likelihood/test_slowrot_freqresponse.py | 200 ++++++ .../test_slowrot_freqresponse_likelihood.py | 295 +++++++++ .../likelihood/test_slowrot_headtohead.py | 178 +++++ .../likelihood/test_slowrot_likelihood_v1.py | 180 +++++ .../RIFT/likelihood/test_slowrot_noloop.py | 244 +++++++ .../test_slowrot_noloop_bruteforce.py | 87 +++ .../RIFT/likelihood/test_slowrot_pathB.py | 66 ++ .../test_slowrot_pathB_bruteforce.py | 53 ++ .../test_slowrot_pathB_groundtruth.py | 69 ++ .../test_slowrot_precompute_integration.py | 125 ++++ .../RIFT/likelihood/test_slowrot_response.py | 135 ++++ .../integrate_likelihood_extrinsic_batchmode | 76 ++- .../Code/demo/rift/slowrot/Makefile | 38 ++ .../Code/demo/rift/slowrot/README.md | 85 +++ .../demo/rift/slowrot/demo_finite_size.py | 221 +++++++ .../Code/demo/rift/slowrot/demo_rotation.py | 162 +++++ .../outputs/finite_size_gain_vs_arm.png | Bin 0 -> 49592 bytes .../outputs/finite_size_gain_vs_arm.txt | 6 + .../outputs/rotation_gain_vs_duration.png | Bin 0 -> 47111 bytes .../outputs/rotation_gain_vs_duration.txt | 5 + 27 files changed, 4320 insertions(+), 5 deletions(-) create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_response.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_likelihood.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_headtohead.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_groundtruth.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_precompute_integration.py create mode 100644 MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_response.py create mode 100644 MonteCarloMarginalizeCode/Code/demo/rift/slowrot/Makefile create mode 100644 MonteCarloMarginalizeCode/Code/demo/rift/slowrot/README.md create mode 100644 MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_finite_size.py create mode 100644 MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_rotation.py create mode 100644 MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.png create mode 100644 MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.txt create mode 100644 MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/rotation_gain_vs_duration.png create mode 100644 MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/rotation_gain_vs_duration.txt diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md new file mode 100644 index 000000000..fc6821058 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md @@ -0,0 +1,207 @@ +# Slow-rotation RIFT likelihood — handoff / breadcrumb + +Branch: `rift_slowrot` (off `rift_O4d_fix_rvs_clear_fairdraw_batch`). Design notes + LaTeX +paper live in a **separate local repo** `~/rift-slow-rotation` (no git remote — local only; +21-page PDF via `latexmk -pdf notes/main.tex`). + +## PATH B DELAY PHYSICS — VALIDATED (2026-07 update) +Path B's propagation-delay drift is validated against an INDEPENDENT folded-template ground +truth (`test_slowrot_pathB_bruteforce.py`): data built as Re[F(t) Sigma(t-tau(t))] from the +SAME harmonic F(t),tau(t) model + the SAME modes the likelihood uses (no SimDetectorStrain +convention floor), at an inflated sidereal rate so the delay drift is large. At the REAL +90-min-BNS rate (= x340 inflation on a 16s test): p_max=0 deficit 3.43 -> p_max=1 0.23 -> +p_max=2 0.207 -> p_max=3 0.207: CONVERGES, bound-respected, NO blow-up. So Path B recovers +the delay drift and is production-ready for the target signals with p_max<=2. +KNOWN LIMIT: the p>=3 catastrophic cancellation (huge high-f U terms x tiny delta_tau^p +coefficients) only bites at x1000+ inflation (>2.6x faster than any physical signal): x1000 +gives p=2 deficit 5.9 but p=3 blows to 1e5. So the band-limit fix (low-pass the p>=1 +derivative templates) is a robustness nicety, NOT a blocker for real signals. +Separately validated vs LAL's SimDetectorStrainREAL8TimeSeries (`test_slowrot_pathB_groundtruth.py`): +baseline/PathA/PathB all agree with Jolien's full delay map to ~0.07 at fmax=256 (the ~26 +deficit at fmax=1024 was SimDetectorStrain's high-f TD delay-INTERPOLATION, not a bug -- +confirmed: it vanishes when fmax is lowered). NoLoop uses nearest-neighbor time sampling +(factored_likelihood.py ~L1691), so absolute peak comparisons at high SNR have a resolution +floor (~0.1-0.2); the time-interpolated NoLoop is in oshaughn/rift_O4d and +origin/rift_O4d_junior_calmarg_in_loop if a cleaner absolute comparison is wanted. + +## TWO PARALLEL THRUSTS (2026-07) +Path A + Path B are implemented, validated, and wired into the ILE. Next work is two tracks: +1. **Rotation PE value demo (near-term).** VERIFY-ANYWHERE quick-look DONE + (`~/RIFT_roboto_paper/analyses/slowrot_demo/`, `make demo` / `demo_local.py`): rotation-vs- + static lnL gain grows with Omega*T (null 0.004 -> 64s 0.19 at fixed SNR~30); figure + `outputs/gain_vs_duration.png`. REMAINING: the full DAG injection-recovery PE (posterior + bias + single-network sky map) on the cluster -> `paper/` figure cited from `sec:slowrot` + (structured Makefile targets inject/baseline/rotationA/rotationB/compare). +2. **Frequency-dependent (finite-size) response = Path D (3G regime where 'long' lives).** + RESPONSE FUNCTION IMPLEMENTED + VALIDATED: `slowrot_freqresponse.py` + + `test_slowrot_freqresponse.py`. F_k(f;RA,DEC,psi) from arXiv:2412.01693 single-arm sinc + transfer; f->0 == ComputeDetAMResponse to 6.7e-16; exact FSR null at c/2L=3747 Hz (40 km); + in-band SHAPE distortion 0.24%@1kHz/0.62%@2kHz (LIGO) vs 11.8%/42% (CE) -> negligible for + LIGO, first-order only for 3G. Key: the complex ratio ~1 for CE is dominated by a benign + common e^{-i2pi fL/c} delay (degenerate with tc), NOT sky shape. LAL has NO closed-form FD + response. ROUTE DECISION (notes/sec_freqdep.tex): CE -> route (b) sky-harmonic expansion + (keeps sky extrinsic; fold the common delay into geocenter time; few angular orders); + precessing+HM -> route (a) pinned-sky TD fold. + LIKELIHOOD INTEGRATION DONE + VALIDATED (route b): `factored_likelihood_freqresponse.py` + (+ `test_slowrot_freqresponse_likelihood.py`). Fold the common e^{-i2pi fL/c} delay into the + arrival time; power-series the residual transfer -> sky-independent W_p(f) folded into the FD + modes (reuse ComputeModeIP*) x analytic b_p(sky) -> SKY EXTRINSIC. MAINTAINED-STYLE NoLoop + entry point DiscreteFactoredLogLikelihoodFreqResponseNoLoop (NOT SingleDetectorLogLikelihood). + Validated (CE 40km, 16s): V1 finite-size(L->0) == maintained NoLoop baseline to 3.3e-9; V2 on + a finite-size injection the long-wavelength NoLoop deficit 2.71 -> finite-size 0.558 (converged + Qmax=2), residual ~= peak-resolution floor; V3 Cauchy-Schwarz respected. Scalar companion + agrees with the NoLoop to 0.156 (interp-vs-nearest floor). + REMAINING: ILE wiring (a --freqresponse flag mirroring --rotation-slow); full precessing+HM + (route a pinned-sky); a value demo (the finite-size effect only bites for CE/3G). + AUDIT NOTE (2026-07): all slowrot likelihoods route through the maintained NoLoop; there are + NO SingleDetectorLogLikelihood calls; the ILE wires only NoLoop paths. The scalar + FactoredLogLikelihood*/SingleDetectorLogLikelihood are used only as secondary references and + carry a peak-resolution floor at high SNR -- prefer NoLoop for any absolute comparison. + +## What this is +Generalizes RIFT's marginalized likelihood to account for the **time dependence of the +ground-based detector response over the signal** (Earth rotation), while reusing the +precompute-and-marginalize architecture. Two effects, both implemented (Path A + Path B): +- **Path A** — antenna amplitude drift `F(t)`: exact 5 sidereal harmonics, sky extrinsic. +- **Path B** — propagation-delay drift `tau(t)`: time-domain derivative expansion; adds + delay-derivative order `p` on top of Path A. Matters only for LONG signals. +- **Deferred (Path D)** — frequency-dependent (finite-size) response. Out of scope. + +## Files (all under RIFT/likelihood/ unless noted) +- `slowrot_response.py` — closed-form antenna harmonics `A_n` and delay harmonics `B_n` + (scalar + `*_vector`), derived from `vectorized_lal_tools` conventions. Validated vs LAL + to machine precision (`test_slowrot_response.py`). +- `factored_likelihood_with_rotation.py` — the core: + - `PrecomputeLikelihoodTermsWithRotation(...)` — FD-native precompute: builds each mode + once, applies derivative `(FT_SIGN*2pi i f)^p` (FT_SIGN=-1) and sidereal modulation + `exp(i n Omega t)` (sub-bin freq shift via LAL-FFT phase). Returns bank keyed by + elementary template `a=(p,n)`: `Q^a(t)`, `U^{(a,a')}`, `V^{(a,a')}`. + - `rotation_coefficients` / `rotation_coefficients_vector` — the analytic scalars + `C_{(p,ntilde)} = (1/p!) sum_{n+m=ntilde} A_tilde_n [(-D)^{*p}]_m` (Path A: `{(0,n): A_tilde_n}`). + - `FactoredLogLikelihoodWithRotation(...)` — scalar lnL (per-sample); term1 = `Re[sum_lm + conj(Ylm) sum_a conj(C_a) Q^a(t_det)]`, term2 with `U^{(a,a')}` (coef `conj(C_a)C_a'`) + and `V^{(a,a')}` (coef `C_{(p,-nu)} C_a'`). + - `pack_rotation_arrays` + `DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation` + — the MAINTAINED vectorized (NoLoop) path, mirroring + `factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopOrig`. +- `factored_likelihood.py` — one small FIX: non-numba `lalylm` fallback -> `np.vectorize` + (it broke `ComputeYlmsArrayVector` for the whole vectorized path in non-numba/non-GPU envs). +- `bin/integrate_likelihood_extrinsic_batchmode` — wiring: `--rotation-slow`, + `--rotation-n-harmonics`, `--rotation-p-max`. Guarded (requires `--vectorized`, CPU, no + dist/phase marg). Builds+packs the rotation bank after the baseline pack; the CPU + vectorized `likelihood_function` calls the rotation NoLoop. + +## How to run (RIFT venv; worktree first on PYTHONPATH) + source ~/RIFT_develUWM/bin/activate + export PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code + python RIFT/likelihood/test_slowrot_response.py # A_n,B_n vs LAL + python RIFT/likelihood/test_slowrot_fd_ops.py # FD derivative/modulation + python RIFT/likelihood/test_slowrot_likelihood_v1.py # scalar Path A vs baseline + brute force + python RIFT/likelihood/test_slowrot_noloop.py # vectorized Path A vs baseline NoLoop + python RIFT/likelihood/test_slowrot_noloop_bruteforce.py # vectorized Path A vs brute force + python RIFT/likelihood/test_slowrot_pathB.py # Path B reduction + bound + python RIFT/likelihood/test_slowrot_headtohead.py # matched-sample rotation vs baseline (cubic) + python RIFT/likelihood/test_slowrot_freqresponse.py # [Path D] finite-size response vs LAL + python RIFT/likelihood/test_slowrot_freqresponse_likelihood.py # [Path D] likelihood: V1/V3 + V4 positive control + +VALUE DEMOS (verify-anywhere, no condor/GPU) -- consolidated in the RIFT tree: + cd demo/rift/slowrot && make demo # rotation (Path A/B) + finite-size (Path D) + # demo_rotation.py : gain lnL_rot-lnL_static grows with Omega*T (null control at short T) + # demo_finite_size.py : gain lnL_finite-lnL_LWL grows with arm length fL/c (null at LIGO 4km, + # +39.6 nats at CE 40km); writes outputs/*.{txt,png}. See its README.md. + +End-to-end ILE head-to-head (ILE-GPU-Paper demo data), baseline vs rotation vs finite-size: + D=~/RIFT_develUWM/src/research-projects-RIT/.travis/ILE-GPU-Paper/demos + integrate_likelihood_extrinsic_batchmode --vectorized [std opts] ... # baseline + integrate_likelihood_extrinsic_batchmode --vectorized --rotation-slow ... # Path A + integrate_likelihood_extrinsic_batchmode --vectorized --rotation-slow --rotation-p-max 1 ... # Path B + integrate_likelihood_extrinsic_batchmode --vectorized --freqresponse \ + --freqresponse-arm-length 40000 --freqresponse-qmax 6 ... # Path D (finite-size) + # --interpolate-time selects cubic sub-bin time interpolation for all of the above (default nearest). + +## Validation status (all PASSING) +- Response harmonics vs LAL: ~1e-16. FD ops vs LAL round trips: ~1e-13. +- Path A scalar: V1a (Omega=0 vs baseline) 2.7e-12; V1b (real vs brute force) 2.6e-9. +- Path A vectorized: vs baseline NoLoop 3.6e-12; vs brute force 3.2e-10; V0 (precompute + recovery on real data) exact. +- Path B: scalar reduce-to-baseline 9e-13; respects 0.5; vectorized reduce 6.4e-12. +- Path D (finite-size, --freqresponse): response Sum_p b_p W_p == antenna_response_fd to 6e-11 + on both +/-f; likelihood L->0 reduces to baseline NoLoop 3e-9; Cauchy-Schwarz respected; + V4 positive control asserts finite-size beats LWL by +38.9 nats (15+13 Msun, fmax=2000, CE 40km). +- Cubic time-interp (from calmarg_in_loop, --interpolate-time): both slow-response NoLoops now + support time_interp='nearest'|'cubic'. Cubic exposed+fixed a sub-bin GPS-cancellation bug in + the time reference; head-to-head regression floor 1.6e-3 -> 4.5e-13, test_slowrot_noloop 3.6e-12. +- ILE runs clean (no inf/nan) for baseline / Path A / Path B / Path D; rotation ~ baseline for the + short demo signal (rotation effect ~0.008 in lnL, negligible as physics requires). + +## CRITICAL lesson (a bug that hid for a while) +The U/V template modulation MUST be referenced to the template's INTRINSIC time (epoch ~0), +not the absolute event time t_ev~1e9 -- else a ~1e4 rad spurious phase randomizes U/V and +inflates lnL ABOVE 0.5. This bug once made a short-BBH rotation shift look like ~660 in +lnL; corrected it is ~0.008. My independent "brute force" reference shared the same +convention, so `vec == brute-force` PASSED while both were wrong. **Always cross-check +against the Cauchy-Schwarz bound 0.5, not only against a reference that can share +conventions.** + +## PATH B STATUS (findings 2026-07-04, the systematic pass in progress) +- Matched-seed head-to-head DONE (test_slowrot_headtohead.py): rot(f_sid=0)==baseline 9e-13; + evidence shift ln Z_rot - ln Z_base = -1.1e-3 (MC-noise-free) for the short signal. +- Path B DELAY PHYSICS still only partially validated. In an INFLATED-Omega regime (f_sidereal + x3000, single-det 30+25 BBH) where the delay drift matters, scalar Path B gives + lnL(p_max=0..3) = [1794.39, 1781.31, 1781.63, 928.30]: p=0->1->2 captures a real ~13-in-lnL + delay effect and appears to converge (~1781.6) and respects 0.5=1938 -- BUT p_max=3 + BLOWS UP (increment 853). +- ROOT CAUSE of the p>=3 blow-up (likely): the FD derivative weight (2 pi i f)^p amplifies high + frequencies; in the model norm the integrand ~ (2 pi f)^{2p} |h(f)|^2 / S grows like f^{11/3} + for a chirp (|h|^2 ~ f^{-7/3}), so high-order terms are dominated by the f_max edge, not the + physical low-frequency delay drift. FIX for the systematic pass: BAND-LIMIT the delay- + derivative (p>=1) terms to low frequency (the delay drift physically matters in the long early + inspiral, i.e. low f, not at merger). Options: apply a low-pass / taper before the (2 pi i f)^p + weight, or use a reduced f_max for p>=1 templates, or work with dimensionless (f/f_ref) weights. +- Consequence: current Path B is trustworthy only through p_max<=2 and only after the band-limit + fix is validated. Do NOT ship Path B (p>=1) until this is fixed AND checked against an + independent time-varying-delay brute force (below). + +## OPEN / NEXT (the one remaining systematic pass — do it all together) +1. **Path B rigorous validation** (TWO parts, do together): + (a) FIX the p>=3 high-frequency derivative blow-up by band-limiting the delay-derivative + terms to low frequency (see PATH B STATUS above); re-check convergence is monotone. + (b) Validate vs an INDEPENDENT ground truth that uses LAL's OWN full delay-time map -- + lalsim.SimDetectorStrainREAL8TimeSeries (Jolien's code). KEY FACTS FOUND 2026-07-04: + - RIFT's data ALREADY uses it: non_herm_hoff -> hoft -> SimInspiralTD + + SimDetectorStrainREAL8TimeSeries (lalsimutils.py ~line 3020). So a LONG injection + already carries the real Earth-rotation response; baseline (static) will under-recover + it and Path A/B should recover it. + - GROUND TRUTH for the rotation likelihood must apply SimDetectorStrain to the SAME + RIFT modes the likelihood uses: hk = lsu.hoft_from_hlm(hlmsT, P_extr) (radec path, + applies SimDetectorStrain). Then convert to 2-sided FD exactly like non_herm_hoff + (copy REAL8 -> COMPLEX16, COMPLEX16TimeFreqFFT) and lnL = ComplexIP(d,hk).real - + 0.5*ComplexIP(hk,hk).real. + - DO NOT use non_herm_hoff(P_extr) as the template ground truth: it regenerates via + SimInspiralTD, whose modes differ from RIFT's hlmoff modes -- with IMRPhenomD the + mode-based baseline recovers only ~SNR 35 of the data's ~SNR 82 at the injection (a + waveform-convention mismatch, NOT rotation). Using hoft_from_hlm keeps the modes + identical so ONLY the rotation differs. + - Two gotchas to fix: (i) hoft_from_hlm output length = mode length (e.g. 16451) != + padded data length (16384) -> ResizeREAL8TimeSeries + align epoch (replicate + non_herm_hoff padding); (ii) the mode-based likelihood at a FIXED tref is OFF-PEAK + (epoch bookkeeping) -> compare at the time-MAXIMIZED / marginalized value, not at + extr.tref. + - Use a LONG signal (real sidereal rate) so delay drift matters, OR the inflated-Omega + path (already shows p=1,2 capture the delay, p>=3 needs the band-limit fix). + A convergence test alone is NOT enough: Path B could converge to a WRONG value if the C + coefficients are off (the delay analogue of the reference-time bug). Cross-check 0.5. +2. **Matched-seed quantitative ILE head-to-head** (fixed RNG seed) -> exact, regression-grade + baseline-vs-rotation comparison (currently only MC-noise-level agreement). +3. Cauchy-Schwarz bound checks everywhere; sweep for other shared-convention bugs. +4. Possible follow-ups: GPU (xpy) path for the rotation NoLoop; distance/phase/cal marg + support; Path B `t_star` reference-epoch choice; `rotation_coefficients` closed-form + `A_n`/`B_n` appendix (notes sec app_response has the A_n,B_n). + +## Also note +- A separate background session was fixing the py2-ism in + `factored_likelihood.PackLikelihoodDataStructuresAsArrays` (`rholm_intpArray = range(nKeys)` + fails in py3 when the interpolant arg is truthy). Our tests pass `None` for that arg to + dodge it. +- Subagents were unreliable for this work (deferred, reverted fixes, over-claimed). The + validated results above were done/checked directly. diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py index 44e32c115..2232e0c9a 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py @@ -1396,7 +1396,7 @@ def PackLikelihoodDataStructuresAsArrays(pairKeys, rholms_intpDictionaryForDetec rholmArray[indx1][:] = rholmsDictionaryForDetector[pair1].data.data # Copy the array of time values. ### Step 3: Create rholm_intp array-ized structure - rholm_intpArray = range(nKeys) # create a flexible python array of the desired size, to hold function pointers + rholm_intpArray = [None] * nKeys # create a flexible python array of the desired size, to hold function pointers if rholms_intpDictionaryForDetector: for pair1 in pairKeys: indx1 = lookupKeysToNumber[pair1] @@ -2402,9 +2402,10 @@ def lalT(det, RA, DEC,tref): # note tref is a SCALAR numba_on = False if log_loud: print(" Numba off ") - # Very inefficient - def lalylm(th,ph,s,l,m): - return lal.SpinWeightedSphericalHarmonic(th,ph,s,l,m) + # Very inefficient. np.vectorize replicates the numba @vectorize elementwise + # behavior (the plain scalar wrapper below breaks ComputeYlmsArrayVector, which + # passes numpy ARRAYS for th,ph,s,l,m -> LAL raises 'argument 1 of type REAL8'). + lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) def lalF(det, RA, DEC,psi,tref): if isinstance(RA, float): return ComplexAntennaFactor(det, RA, DEC, psi,tref) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py new file mode 100644 index 000000000..2391d75ee --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py @@ -0,0 +1,408 @@ +""" +factored_likelihood_freqresponse : finite-size (frequency-dependent detector response) +generalization of the RIFT precompute + scalar likelihood. "Thrust 2 / route (b)" +(sky-harmonic expansion) of the slow-rotation program -- KEEPS the sky extrinsic. + +Physics (see slowrot_freqresponse.py for the response derivation) +---------------------------------------------------------------- +Beyond the long-wavelength limit the detector strain in the frequency domain is a +per-frequency antenna pattern acting on the two polarizations, + + h_k(f) = F_+(f;sky) h_+(f) + F_x(f;sky) h_x(f) + = (1/2)[ F(f) Sigma(f) + Fbar(f) Sigma*(f) ] , + F = F_+ + i F_x, Fbar = F_+ - i F_x = conj(F(-f)), Sigma = sum_lm Y_lm h_lm. + +The finite-size response factors into a SKY-INDEPENDENT frequency basis W_p(f) times +an analytic SKY/pol scalar b_p (slowrot_freqresponse.finite_size_response_weights / +finite_size_beta): + + F(f;sky) = sum_p b_p(sky) W_p(f) , Fbar(f;sky) = sum_p conj(b_p) W_p(f) , + + p=0 : W_0 = 1, b_0 = F0 (exact lal ComputeDetAMResponse) + p=1+q : W_{1+q} = e^{-i2pi f T} c_q(f) - [q==0], b_{1+q} = beta_q (arm expansion) + +with T = L/c the common (direction-independent) light-crossing delay folded into W_p as +a linear FD phase, and c_q(f) the sky-independent power-series basis of the single-arm +transfer. Every W_p is Hermitian, so -- UNLIKE the sidereal-rotation case -- the V cross +term needs NO harmonic reflection. + +This module is the DIRECT analogue of factored_likelihood_with_rotation: it folds each +W_p(f) into the FD modes ONCE (h_lm(f) -> W_p(f) h_lm(f)) and reuses the EXISTING +ComputeModeIPTimeSeries / ComputeModeCrossTermIP to build + + Q^p_lm(t) = < W_p h_lm | d >, U^{p,p'} = < W_p h_lm | W_p' h_l'm' >, + V^{p,p'} = < conj(W_p h_lm) | W_p' h_l'm' > . + +MAINTAINED PATH: the production entry point is the vectorized (NoLoop) +DiscreteFactoredLogLikelihoodFreqResponseNoLoop -- the direct finite-size analogue of +factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop (the maintained +likelihood used by integrate_likelihood_extrinsic_batchmode). It is VALIDATED against that +NoLoop baseline: at L->0 it reduces to it (to ~1e-9), NOT to the older per-detector scalar +SingleDetectorLogLikelihood. FactoredLogLikelihoodFreqResponse (scalar) is a per-sample +validation companion only (mirrors factored_likelihood_with_rotation.FactoredLogLikelihoodWithRotation). + +The term1/term2 use the same factored form as the NoLoop path (all RIFT factored likelihoods +share this algebra; W_0=1 and b_0 = ComputeDetAMResponse at L->0): + term1 = Re[ (distRef/dist) sum_lm conj(Ylm) sum_p conj(b_p) Q^p_lm(t_det) ] + term2 = -(1/4)(distRef/dist)^2 Re[ sum_{p,p'} ( conj(b_p) b_p' U^{p,p'} conj(Y1)Y2 + + b_p b_p' V^{p,p'} Y1 Y2 ) ] + +Heavy RIFT imports are lazy so the light FD helpers stay importable with numpy alone. +""" +from __future__ import print_function, division + +import numpy as np + +from . import slowrot_freqresponse as sfr + + +# --------------------------------------------------------------------------- +# Low-level FD primitive: fold a per-bin weight into a COMPLEX16FrequencySeries. +# --------------------------------------------------------------------------- +def evaluate_fvals_from_length(npts, deltaF): + """Signed frequency array for a RIFT two-sided COMPLEX16FrequencySeries. + + Identical packing to factored_likelihood_with_rotation.evaluate_fvals_from_length / + RIFT.lalsimutils.evaluate_fvals: f[k] = deltaF*(npts/2 - k), descending from +fNyq. + """ + k = np.arange(npts) + return deltaF * (npts / 2.0 - k) + + +def _copy_freqseries(hf): + import lal + out = lal.CreateCOMPLEX16FrequencySeries( + hf.name, hf.epoch, hf.f0, hf.deltaF, hf.sampleUnits, hf.data.length) + out.data.data[:] = hf.data.data[:] + return out + + +def fd_apply_weight(hf, weight): + """COMPLEX16FrequencySeries -> new series with spectrum multiplied by weight[k].""" + out = _copy_freqseries(hf) + out.data.data[:] = hf.data.data * weight + return out + + +# --------------------------------------------------------------------------- +# The precompute (sky-independent; per detector uses only its arm length L). +# --------------------------------------------------------------------------- +def PrecomputeLikelihoodTermsFreqResponse( + event_time_geo, t_window, P, data_dict, psd_dict, Lmax, fMax, + Qmax=4, L_arm=None, + analyticPSD_Q=False, inv_spec_trunc_Q=False, T_spec=0., + verbose=True, quiet=False, skip_interpolation=False, **hlm_kwargs): + """Finite-size analogue of PrecomputeLikelihoodTerms(WithRotation). + + Builds each FD mode once and forms the generalized overlaps for every response + basis element p = 0..Qmax+1: + + rholms_intp_fr[det][p][(l,m)] : interpolant of Q^p_lm(t) = + crossTerms_fr[det][(p,p')] : { ((l,m),(l',m')) : } + crossTermsV_fr[det][(p,p')] : { ((l,m),(l',m')) : } + + Parameters mirror PrecomputeLikelihoodTerms; finite-size specific: + Qmax : highest power of the arm projection retained (basis size Qmax+2). + L_arm : arm-length override [m] (float -> all detectors, or dict det->L). + None -> each detector's native LAL arm length. + + The response coefficients b_p(det,RA,DEC,psi,tref) that contract these overlaps are + NOT computed here -- see FactoredLogLikelihoodFreqResponse. Returns the intrinsic- + only, sky-independent overlap bank plus meta (carries per-detector L, T, Qmax). + """ + import lal + from . import factored_likelihood as FL + from .. import lalsimutils as lsu + + assert data_dict.keys() == psd_dict.keys() + detectors = list(data_dict.keys()) + + def _L_of(det): + if isinstance(L_arm, dict): + return L_arm.get(det, None) + return L_arm + + P.dist = FL.distMpcRef * 1e6 * lsu.lsu_PC + P.deltaF = data_dict[detectors[0]].deltaF + + # --- build base FD modes ONCE --- + hlms, hlms_conj = FL.internal_hlm_generator(P, Lmax, verbose=verbose, quiet=quiet, + **hlm_kwargs) + modes = list(hlms.keys()) + npts = hlms[modes[0]].data.length + deltaF = hlms[modes[0]].deltaF + fvals = evaluate_fvals_from_length(npts, deltaF) + p_list = list(range(Qmax + 2)) + + rholms_fr = {} + rholms_intp_fr = {} + crossTerms_fr = {} + crossTermsV_fr = {} + Ldict = {} + Tdict = {} + + for det in detectors: + psd = psd_dict[det] + data = data_dict[det] + + # per-detector geometry (arm length only; sky enters later via b_p) + _, _, _, L = sfr.detector_geometry(det, L_arm=_L_of(det)) + Ldict[det] = float(L) + Tdict[det] = float(L / sfr.C_SI) + # sky-independent response basis weights W_p(f) on the signed fvals + geom_dummy = dict(L=float(L), T=float(L / sfr.C_SI)) + W = sfr.finite_size_response_weights(fvals, geom_dummy, Qmax) # (Qmax+2, npts) + + # weighted mode families eta^p = W_p h_lm , etac^p = W_p conj(h_lm) + eta = {p: {lm: fd_apply_weight(hlms[lm], W[p]) for lm in modes} for p in p_list} + etac = {p: {lm: fd_apply_weight(hlms_conj[lm], W[p]) for lm in modes} for p in p_list} + + t_det = FL.ComputeArrivalTimeAtDetector(det, P.phi, P.theta, event_time_geo) + rho_epoch = data.epoch - hlms[modes[0]].epoch + t_shift = float(float(t_det) - float(t_window) - float(rho_epoch)) + N_shift = int(t_shift / P.deltaT + 0.5) + N_window = int(2 * t_window / P.deltaT) + t = np.arange(N_window) * P.deltaT + float(rho_epoch + N_shift * P.deltaT) + + rholms_fr[det] = {} + rholms_intp_fr[det] = {} + for p in p_list: + rho = FL.ComputeModeIPTimeSeries( + eta[p], data, psd, P.fmin, fMax, 1. / 2. / P.deltaT, + N_shift, N_window, analyticPSD_Q, inv_spec_trunc_Q, T_spec) + rholms_fr[det][p] = rho + if not skip_interpolation: + rholms_intp_fr[det][p] = FL.InterpolateRholms(rho, t, verbose=verbose) + else: + rholms_intp_fr[det][p] = None + + crossTerms_fr[det] = {} + crossTermsV_fr[det] = {} + for p in p_list: + for pp in p_list: + crossTerms_fr[det][(p, pp)] = FL.ComputeModeCrossTermIP( + eta[p], eta[pp], psd, P.fmin, fMax, 1. / 2. / P.deltaT, P.deltaF, + analyticPSD_Q, inv_spec_trunc_Q, T_spec, verbose=False, + same_waveform_Q=False) + crossTermsV_fr[det][(p, pp)] = FL.ComputeModeCrossTermIP( + etac[p], eta[pp], psd, P.fmin, fMax, 1. / 2. / P.deltaT, P.deltaF, + analyticPSD_Q, inv_spec_trunc_Q, T_spec, prefix="V", verbose=False, + same_waveform_Q=False) + + meta = dict(Qmax=Qmax, p_list=p_list, modes=modes, + event_time_geo=float(event_time_geo), + L=Ldict, T=Tdict, L_arm=L_arm) + return rholms_intp_fr, crossTerms_fr, crossTermsV_fr, rholms_fr, meta + + +# --------------------------------------------------------------------------- +# Response coefficients b_p(det, RA, DEC, psi, tref) : b_0 = F0, b_{1+q} = beta_q. +# --------------------------------------------------------------------------- +def response_coefficients(det, RA, DEC, psi, tref, Qmax, L_arm=None): + """{p: b_p} response coefficients for the finite-size basis at (RA,DEC,psi,tref). + + b_0 = F0 = lal.ComputeDetAMResponse (exact LWL baseline); + b_{1+q} = beta_q(sky) = (1/2)[zx^2 a_x^q - zy^2 a_y^q]. + Uses gmst = GreenwichMeanSiderealTime(tref) exactly like ComplexAntennaFactor. + """ + import lal + gmst = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(tref)))) + geom = sfr.finite_size_geometry(det, RA, DEC, psi, gmst=gmst, L_arm=L_arm) + beta = sfr.finite_size_beta(geom, Qmax) + b = {0: geom['F0']} + for q in range(Qmax + 1): + b[1 + q] = complex(beta[q]) + return b + + +def FactoredLogLikelihoodFreqResponse(extr_params, rholms_intp_fr, crossTerms_fr, + crossTermsV_fr, meta, Lmax): + """Finite-size analogue of factored_likelihood.FactoredLogLikelihood. + + Contracts the response-basis precompute bank with b_p(det,RA,DEC,psi,tref) and the + Ylm. Reduces EXACTLY to the baseline FactoredLogLikelihood as L -> 0. + """ + from . import factored_likelihood as FL + from .. import lalsimutils as lsu + + Qmax = meta['Qmax'] + p_list = list(meta['p_list']) + L_arm = meta.get('L_arm', None) + + RA = extr_params.phi + DEC = extr_params.theta + tref = extr_params.tref + phiref = extr_params.phiref + incl = extr_params.incl + psi = extr_params.psi + dist = extr_params.dist + + detectors = list(rholms_intp_fr.keys()) + p0 = p_list[0] + modes = list(rholms_intp_fr[detectors[0]][p0].keys()) + Ylms = FL.ComputeYlms(Lmax, incl, -phiref, selected_modes=modes) + + distMpc = dist / (lsu.lsu_PC * 1e6) + invDistMpc = FL.distMpcRef / distMpc + + def _L_of(det): + if isinstance(L_arm, dict): + return L_arm.get(det, None) + return L_arm + + lnL = 0. + for det in detectors: + b = response_coefficients(det, RA, DEC, psi, tref, Qmax, L_arm=_L_of(det)) + t_det = FL.ComputeArrivalTimeAtDetector(det, RA, DEC, tref) + CT = crossTerms_fr[det] + CTV = crossTermsV_fr[det] + + Q = {p: {m: rholms_intp_fr[det][p][m](float(t_det)) + for m in modes} for p in p_list} + + term1 = 0. + for m in modes: + s = 0. + for p in p_list: + s += np.conj(b[p]) * Q[p][m] + term1 += np.conj(Ylms[m]) * s + term1 = np.real(term1) * invDistMpc + + term2 = 0. + for m1 in modes: + for m2 in modes: + u = 0. + v = 0. + for p in p_list: + for pp in p_list: + u += np.conj(b[p]) * b[pp] * CT[(p, pp)][(m1, m2)] + v += b[p] * b[pp] * CTV[(p, pp)][(m1, m2)] + term2 += u * np.conj(Ylms[m1]) * Ylms[m2] + v * Ylms[m1] * Ylms[m2] + term2 = -np.real(term2) / 4. / (distMpc / FL.distMpcRef) ** 2 + + lnL += term1 + term2 + + return lnL + + +# --------------------------------------------------------------------------- +# Dense-array packing + vectorized time-marginalized lnL (for peak / validation). +# --------------------------------------------------------------------------- +def pack_freqresponse_arrays(meta, rholms_fr, crossTerms_fr, crossTermsV_fr): + """Pack the basis precompute bank into dense arrays keyed per detector by p. + + Returns (lookupNKDict, rho_by_p, U_by_pp, V_by_pp, epochDict). + """ + p_list = list(meta['p_list']) + lookupNKDict = {}; rho_by_p = {}; U_by_pp = {}; V_by_pp = {}; epochDict = {} + for det in rholms_fr: + p0 = p_list[0] + modes = list(rholms_fr[det][p0].keys()) + n_lms = len(modes) + idx = {m: i for i, m in enumerate(modes)} + lookupNKDict[det] = np.array([[m[0], m[1]] for m in modes], dtype=int) + npts = rholms_fr[det][p0][modes[0]].data.length + epochDict[det] = float(rholms_fr[det][p0][modes[0]].epoch) + rho_by_p[det] = {} + for p in p_list: + arr = np.zeros((n_lms, npts), dtype=np.complex128) + for m in modes: + arr[idx[m]] = rholms_fr[det][p][m].data.data + rho_by_p[det][p] = arr + U_by_pp[det] = {}; V_by_pp[det] = {} + for p in p_list: + for pp in p_list: + Um = np.zeros((n_lms, n_lms), dtype=np.complex128) + Vm = np.zeros((n_lms, n_lms), dtype=np.complex128) + cU = crossTerms_fr[det][(p, pp)] + cV = crossTermsV_fr[det][(p, pp)] + for m1 in modes: + for m2 in modes: + Um[idx[m1], idx[m2]] = cU[(m1, m2)] + Vm[idx[m1], idx[m2]] = cV[(m1, m2)] + U_by_pp[det][(p, pp)] = Um + V_by_pp[det][(p, pp)] = Vm + return lookupNKDict, rho_by_p, U_by_pp, V_by_pp, epochDict + + +def DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, P_vec, meta, lookupNKDict, rho_by_p, U_by_pp, V_by_pp, epochDict, + Lmax=2, array_output=False, time_interp='nearest'): + """Vectorized finite-size lnL over a time window (single extrinsic point per call + is supported; P_vec fields RA,DEC,incl,phiref,psi,dist may be length-1 arrays). + + array_output=True returns lnL_t of shape (npts_ex, npts); else time-marginalized. + """ + import lal + from . import factored_likelihood as FL + + p_list = list(meta['p_list']) + Qmax = meta['Qmax'] + L_arm = meta.get('L_arm', None) + npts = len(tvals); npts_ex = len(np.atleast_1d(P_vec.phi)) + RA = np.atleast_1d(P_vec.phi); DEC = np.atleast_1d(P_vec.theta) + incl = np.atleast_1d(P_vec.incl); phiref = np.atleast_1d(P_vec.phiref) + psi = np.atleast_1d(P_vec.psi) + distMpc = np.atleast_1d(P_vec.dist) / (lal.PC_SI * 1e6) + lnL_t = np.zeros((npts_ex, npts), dtype=np.float64) + + def _L_of(det): + if isinstance(L_arm, dict): + return L_arm.get(det, None) + return L_arm + + for det in rho_by_p: + n_lms = len(lookupNKDict[det]) + Ylms = FL.ComputeYlmsArrayVector(lookupNKDict[det], incl, -phiref).T # (npts_ex,n_lms) + # per-sample response coefficients b_p (npts_ex,) + bvec = {} + for i in range(npts_ex): + bi = response_coefficients(det, float(RA[i]), float(DEC[i]), float(psi[i]), + P_vec.tref, Qmax, L_arm=_L_of(det)) + for p in p_list: + bvec.setdefault(p, np.zeros(npts_ex, dtype=complex)) + bvec[p][i] = bi[p] + + t_ref = epochDict[det] + # Precision-preserving time reference (see rotation NoLoop): keep (tref - epoch) and the + # geometric delay separate so the sub-bin fraction is exact under cubic interpolation. + detector_location = np.asarray(FL.lalsim.DetectorPrefixToLALDetector(det).location) + gmst_tref = float(lal.GreenwichMeanSiderealTime(P_vec.tref)) + t_det = float(P_vec.tref - float(t_ref)) + FL.TimeDelayFromEarthCenter( + detector_location, RA, DEC, gmst_tref) + sample_first = (t_det + tvals[0]) / P_vec.deltaT + if time_interp == 'nearest': + ifirst = (np.round(sample_first) + 0.5).astype(int) + else: + ifirst = np.floor(sample_first).astype(int) + frac_first = (sample_first - np.floor(sample_first)).astype(np.float64) + ilast = ifirst + npts + + term1 = np.zeros((npts_ex, npts), dtype=np.complex128) + for p in p_list: + det_rho = rho_by_p[det][p] + if time_interp == 'nearest': + Qa = np.empty((npts_ex, npts, n_lms), dtype=np.complex128) + for i in range(npts_ex): + Qa[i] = det_rho[..., ifirst[i]:ilast[i]].T + else: + Qa = FL._cubic_Q_window_numpy(det_rho.T, ifirst, frac_first, npts) + term1 += np.conj(bvec[p])[:, None] * np.einsum('xi,xti->xt', np.conj(Ylms), Qa) + term1 = term1.real * (FL.distMpcRef / distMpc)[:, None] + + term2 = np.zeros(npts_ex, dtype=np.complex128) + for p in p_list: + for pp in p_list: + term2 += np.conj(bvec[p]) * bvec[pp] * np.einsum( + 'xi,xj,ij->x', np.conj(Ylms), Ylms, U_by_pp[det][(p, pp)]) + term2 += bvec[p] * bvec[pp] * np.einsum( + 'xi,xj,ij->x', Ylms, Ylms, V_by_pp[det][(p, pp)]) + term2 = (-0.25 * term2.real) * (FL.distMpcRef / distMpc) ** 2 + + lnL_t += term1 + term2[:, None] + + if array_output: + return lnL_t + lnLmax = np.max(lnL_t, axis=-1, keepdims=True) + L = FL.my_simps(np.exp(lnL_t - lnLmax), dx=P_vec.deltaT, axis=-1) + return lnLmax[..., 0] + np.log(L) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py new file mode 100644 index 000000000..6f232bcfb --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py @@ -0,0 +1,619 @@ +""" +factored_likelihood_with_rotation : slow-rotation generalization of the RIFT precompute. + +This is a *separate realization* of factored_likelihood.PrecomputeLikelihoodTerms that +accounts for the time dependence of the ground-based detector response over long signals +(Earth rotation). It does NOT regenerate a family of time-domain templates. Instead it +builds each frequency-domain mode ONCE and realizes the two response modulations as cheap +frequency-domain operations on that single template: + + * delay-derivative order p (Path B): time derivative h^(p)(t) <-> (s 2 pi i f)^p H(f) + -- an exact per-bin weight in the FD. (s = FT_SIGN, fixed to match RIFT's FFT + convention; see evaluate_fvals, which itself warns the omega-sign is reversed.) + + * sidereal harmonic n (Paths A,B): modulation exp(i n Omega t) <-> frequency + shift H(f - n f_sid). Since f_sid = Omega/2pi ~ 1.16e-5 Hz is sub-bin, the shift is + realized exactly as a time-domain linear phase (one FFT round trip per template), not + an integer bin roll. + +The response harmonics themselves (the analytic scalars that multiply these precomputed +overlaps in the extrinsic layer) come from RIFT.likelihood.slowrot_response: + + F_k(t) = sum_{n=-2}^{2} A_n e^{i n g}, tau_k(t) = sum_{n=-1}^{1} B_n e^{i n g}, + g = GMST(t) - RA, A_n, B_n depend only on (detector, dec, psi/dec). + +See the design notes (rift-slow-rotation: sec_formalism 'master expansion', sec_amplitude +Path A, sec_delay Path B, app_response) for the derivation and the meaning of the indices. + +Index conventions in the returned structures +-------------------------------------------- +An "elementary modulated template" is labelled a = (p, n): + chi_a(t) = exp(i n Omega t) * d^p/dt^p h_lm(t - tau_0). +The physical data-term time series carries a post-phase (derived in the notes): + Q^a_lm(t) = exp(i n Omega t) * < chi_a(.-t) | d > [applied here] +while the cross terms are arrival-time independent: + U^{a,a'} = < chi_a | chi_a' >, V^{a,a'} = < chi_a^* | chi_a' >. + +Path A (default) uses only p = 0 (amplitude drift; exact 5-harmonic). Path B adds p >= 1. + +Heavy RIFT imports (factored_likelihood, lalsimutils) are done lazily inside the precompute +so the light FD primitives below are importable with numpy alone (used by the unit tests). +""" +from __future__ import print_function, division + +import numpy as np + +# Sidereal angular rate [rad/s] and frequency [Hz] +OMEGA_EARTH = 7.292115e-5 +F_SIDEREAL = OMEGA_EARTH / (2.0 * np.pi) + +# Sign in the time-derivative weight (s 2 pi i f)^p, matched to RIFT's evaluate_fvals FFT +# convention. VALIDATED empirically against a LAL FFT round trip in +# test_slowrot_fd_ops.py (which will fail loudly if this is wrong). +FT_SIGN = -1.0 + + +# --------------------------------------------------------------------------- +# Low-level FD primitives (numpy only; operate on a complex spectrum + its fvals). +# --------------------------------------------------------------------------- +def evaluate_fvals_from_length(npts, deltaF): + """Signed frequency array for a RIFT two-sided COMPLEX16FrequencySeries. + + Replicates RIFT.lalsimutils.evaluate_fvals (kept local so these primitives need no + heavyweight import). Note RIFT's expression ``npts/2 - k if k<=npts/2 else -k+npts/2`` + is the same in both branches, i.e. simply f[k] = deltaF*(npts/2 - k), descending from + +fNyq at k=0. + """ + k = np.arange(npts) + return deltaF * (npts / 2.0 - k) + + +def time_derivative_weight(fvals, p): + """(FT_SIGN * 2 pi i f)^p : exact FD weight for the p-th time derivative.""" + if p == 0: + return np.ones_like(fvals, dtype=complex) + return (FT_SIGN * 2.0j * np.pi * fvals) ** p + + +def apply_time_derivative_array(spectrum, fvals, p): + """Return the spectrum of d^p/dt^p h given the spectrum of h (both two-sided).""" + return spectrum * time_derivative_weight(fvals, p) + + +def apply_sidereal_modulation_array(spectrum, coef, deltaF, f_sidereal=F_SIDEREAL): + """Return the spectrum of exp(i coef Omega t) h(t) given the spectrum of h(t). + + exp(i coef Omega t) multiplication in time <-> frequency shift by coef*f_sidereal. + This is sub-bin (f_sidereal << deltaF for realistic segments), so it is applied exactly + as a time-domain linear phase via a DFT round trip that respects RIFT's fvals packing. + + WARNING: this reference implementation forms an explicit (npts x npts) DFT matrix -- it + is O(npts^2) and intended ONLY for unit tests at small npts. The production path uses + the LAL-FFT round trip in _lal_freq_modulate() below (O(npts log npts)). + """ + if coef == 0: + return spectrum.copy() + npts = len(spectrum) + dt = 1.0 / (npts * deltaF) + t = np.arange(npts) * dt + fvals = evaluate_fvals_from_length(npts, deltaF) + # Round-trip-consistent DFT pair for the packing f[k]=deltaF*(npts/2-k): + # h(t_j) = (1/npts) sum_k H_k exp(+2 pi i f_k t_j), H_k = sum_j h(t_j) exp(-2 pi i f_k t_j) + # LAL's convention (encoded by evaluate_fvals) reverses the omega sign: a tone + # exp(+2 pi i f0 t) lands at fvals = -f0. The consistent DFT pair is therefore + # h(t_j) = (1/npts) sum_k H_k exp(-2 pi i f_k t_j), H_k = sum_j h(t_j) exp(+2 pi i f_k t_j). + phase_inv = np.exp(-2.0j * np.pi * np.outer(t, fvals)) # (npts, npts): H -> h(t) + h_td = phase_inv.dot(spectrum) / npts + h_td *= np.exp(1.0j * coef * 2.0 * np.pi * f_sidereal * t) # exp(i coef Omega t) + phase_fwd = np.exp(2.0j * np.pi * np.outer(fvals, t)) # h(t) -> H + return phase_fwd.dot(h_td) + + +# --------------------------------------------------------------------------- +# LAL-series wrappers (used inside the precompute; import lal lazily). +# --------------------------------------------------------------------------- +def _copy_freqseries(hf): + import lal + out = lal.CreateCOMPLEX16FrequencySeries( + hf.name, hf.epoch, hf.f0, hf.deltaF, hf.sampleUnits, hf.data.length) + out.data.data[:] = hf.data.data[:] + return out + + +def fd_apply_time_derivative(hf, p): + """COMPLEX16FrequencySeries -> new series for the p-th time derivative.""" + if p == 0: + return _copy_freqseries(hf) + out = _copy_freqseries(hf) + fvals = evaluate_fvals_from_length(hf.data.length, hf.deltaF) + out.data.data[:] = apply_time_derivative_array(hf.data.data, fvals, p) + return out + + +def _lal_freq_modulate(hf, coef, f_sidereal=F_SIDEREAL, t_ref=0.0): + """Modulate by exp(i coef Omega (t_abs - t_ref)) via a LAL-FFT round trip (O(N log N)). + + Reverse-FFT the spectrum to the time domain, multiply by the sidereal linear phase + referenced to ABSOLUTE sample time t_abs = float(hf.epoch) + j*deltaT (minus t_ref), + forward-FFT back. Uses the same COMPLEX16 transforms RIFT uses for its overlaps. + + The reference t_ref is physical, not cosmetic: the true antenna phase is + exp(i n (GMST(t')-RA)) = exp(i n (GMST(t_ev)-RA)) * exp(i n Omega (t'-t_ev)), so the + precompute must carry exactly exp(i n Omega (t' - t_ev)) at absolute data time t', + with the constant GMST(t_ev) piece carried analytically by A_n (slowrot_response). + Hence callers pass t_ref = event_time_geo. + """ + import lal + if coef == 0: + return _copy_freqseries(hf) + npts = hf.data.length + deltaT = 1.0 / (npts * hf.deltaF) + ts = lal.CreateCOMPLEX16TimeSeries("m", hf.epoch, 0., deltaT, + lal.DimensionlessUnit, npts) + revplan = lal.CreateReverseCOMPLEX16FFTPlan(npts, 0) + fwdplan = lal.CreateForwardCOMPLEX16FFTPlan(npts, 0) + lal.COMPLEX16FreqTimeFFT(ts, hf, revplan) + t_abs = float(hf.epoch) + np.arange(npts) * deltaT + ts.data.data[:] = ts.data.data * np.exp( + 1.0j * coef * 2.0 * np.pi * f_sidereal * (t_abs - t_ref)) + out = _copy_freqseries(hf) + lal.COMPLEX16TimeFreqFFT(out, ts, fwdplan) + return out + + +def fd_apply_sidereal_modulation(hf, n, f_sidereal=F_SIDEREAL, t_ref=0.0): + """COMPLEX16FrequencySeries -> new series for exp(i n Omega (t_abs - t_ref)) h(t).""" + return _lal_freq_modulate(hf, n, f_sidereal, t_ref) + + +def build_elementary_template(hf, p, n, f_sidereal=F_SIDEREAL): + """chi_{(p,n)} spectrum from the base mode spectrum hf: derivative then modulation.""" + return fd_apply_sidereal_modulation(fd_apply_time_derivative(hf, p), n, f_sidereal) + + +def _elementary_index_set(harmonics, p_max): + """List of a=(p,n). Superset grid; unused (p,n) get zero response coefficients.""" + return [(p, n) for p in range(p_max + 1) for n in harmonics] + + +# --------------------------------------------------------------------------- +# The precompute. +# --------------------------------------------------------------------------- +def PrecomputeLikelihoodTermsWithRotation( + event_time_geo, t_window, P, data_dict, psd_dict, Lmax, fMax, + harmonics=(-2, -1, 0, 1, 2), p_max=0, f_sidereal=F_SIDEREAL, + analyticPSD_Q=False, inv_spec_trunc_Q=False, T_spec=0., + verbose=True, quiet=False, internal_fast_precompute=True, + skip_interpolation=False, **hlm_kwargs): + """Slow-rotation analogue of factored_likelihood.PrecomputeLikelihoodTerms. + + Builds each FD mode once (via factored_likelihood.internal_hlm_generator) and forms the + generalized overlaps for every elementary modulated template a=(p,n): + + rholms_intp_rot[det][a][(l,m)] : interpolant of Q^a_lm(t) = e^{inOmega t} + crossTerms_rot[det][(a,a')] : { ((l,m),(l',m')) : } + crossTermsV_rot[det][(a,a')] : { ((l,m),(l',m')) : } + + Parameters mirror PrecomputeLikelihoodTerms; rotation-specific: + harmonics : sidereal harmonic indices n to carry (antenna needs |n|<=2). + p_max : max delay-derivative order (0 = Path A amplitude-only; >=1 = Path B). + f_sidereal: sidereal frequency [Hz]. + + Response coefficients A_n(det,dec,psi), B_n(det,dec) that contract these overlaps are + NOT computed here -- see slowrot_response and the (forthcoming) rotation-aware + FactoredLogLikelihood assembly. This routine produces only the intrinsic-only, + sky-independent overlap bank. + + NOTE: full-stack numerical validation (V1 of the notes' validation matrix: agreement + with a brute-force time-varying-response likelihood) requires the data/PSD/waveform + environment and is done separately; the FD primitives used here are unit-tested in + test_slowrot_fd_ops.py. + """ + # Lazy heavy imports (need the full RIFT stack / lal). + import lal + from . import factored_likelihood as FL + from .. import lalsimutils as lsu + + assert data_dict.keys() == psd_dict.keys() + detectors = list(data_dict.keys()) + t_ev = float(event_time_geo) + # The exp(i n Omega t) modulation for the data term Q is applied to the DATA (shift by + # -n f_sidereal, referenced to t_ev), which is mode-independent: one shift per (det,n), + # and -- since the modulation lives on the fixed absolute data-time axis -- needs NO + # arrival-time-dependent post-phase. U,V use modulated templates (same t_ev reference). + + # Reference distance handling identical to the base precompute. + P.dist = FL.distMpcRef * 1e6 * lsu.lsu_PC + P.deltaF = data_dict[detectors[0]].deltaF + + # --- build base FD modes ONCE --- + hlms, hlms_conj = FL.internal_hlm_generator(P, Lmax, verbose=verbose, quiet=quiet, + **hlm_kwargs) + a_list = _elementary_index_set(harmonics, p_max) + p_values = sorted(set(p for (p, n) in a_list)) + + # --- derivative-weighted template families (per p), built once, reused for all n,det --- + # (the derivative is a trivial FD weight; no waveform regeneration) + hlms_p = {p: {lm: fd_apply_time_derivative(hlms[lm], p) for lm in hlms} for p in p_values} + hlms_conj_p = {p: {lm: fd_apply_time_derivative(hlms_conj[lm], p) for lm in hlms_conj} + for p in p_values} + + # For U,V we need the modulated templates chi_a (modulation cannot be pushed onto data + # in a overlap). Build them once per a (cheap FD op). + # + # CRITICAL reference-time note: the template time series carry the INTRINSIC epoch + # (hlms.epoch ~ -T_dur, i.e. near 0), NOT the absolute event time t_ev ~ 1e9. When the + # template is placed at the event, sample j sits at absolute time t' = t_ev + (hlms.epoch + # + j*dt), so the physical modulation exp(i n Omega (t' - t_ev)) = exp(i n Omega + # (hlms.epoch + j*dt)) -- i.e. reference the template modulation to 0, NOT to t_ev. + # (Referencing to t_ev with the tiny template epoch would apply exp(i n Omega * ~-1e9), + # a ~1e4 rad spurious phase that randomizes U,V and inflates lnL beyond 0.5.) + # The data-term route above keeps t_ev because the DATA epoch really is ~ t_ev. + chi = {a: {lm: fd_apply_sidereal_modulation(hlms_p[a[0]][lm], a[1], f_sidereal, 0.0) + for lm in hlms} for a in a_list} + chi_conj = {a: {lm: fd_apply_sidereal_modulation(hlms_conj_p[a[0]][lm], a[1], f_sidereal, 0.0) + for lm in hlms_conj} for a in a_list} + + rholms_rot = {} + rholms_intp_rot = {} + crossTerms_rot = {} + crossTermsV_rot = {} + + for det in detectors: + psd = psd_dict[det] + data = data_dict[det] + t_det = FL.ComputeArrivalTimeAtDetector(det, P.phi, P.theta, event_time_geo) + rho_epoch = data.epoch - hlms[list(hlms.keys())[0]].epoch + t_shift = float(float(t_det) - float(t_window) - float(rho_epoch)) + N_shift = int(t_shift / P.deltaT + 0.5) + N_window = int(2 * t_window / P.deltaT) + t = np.arange(N_window) * P.deltaT + float(rho_epoch + N_shift * P.deltaT) + + # ---- data-term overlaps Q^a_lm(t) ---- + # exp(i n Omega t) on the template is equivalent to shifting the data spectrum by + # -n f_sidereal (mode-independent). Realize it by modulating the DATA time series + # by exp(-i n Omega (t_abs - t_ev)) (round trip). Because the modulation lives on + # the absolute data-time axis, the resulting overlap is directly + # Q^a_lm(t) = int e^{-i n Omega (t'-t_ev)} [d^p h_lm]^*(t'-t) d(t') dt' + # with NO arrival-time-dependent post-phase. + rholms_rot[det] = {} + rholms_intp_rot[det] = {} + data_by_n = {} + for n in set(nn for (_, nn) in a_list): + data_by_n[n] = data if n == 0 else _lal_freq_modulate(data, -n, f_sidereal, t_ev) + + for a in a_list: + p, n = a + rho = FL.ComputeModeIPTimeSeries( + hlms_p[p], data_by_n[n], psd, P.fmin, fMax, 1. / 2. / P.deltaT, + N_shift, N_window, analyticPSD_Q, inv_spec_trunc_Q, T_spec) + rholms_rot[det][a] = rho + if not skip_interpolation: + rholms_intp_rot[det][a] = FL.InterpolateRholms(rho, t, verbose=verbose) + else: + rholms_intp_rot[det][a] = None + + # ---- cross terms U,V for each ordered pair (a,a') ---- + crossTerms_rot[det] = {} + crossTermsV_rot[det] = {} + for a in a_list: + for ap in a_list: + crossTerms_rot[det][(a, ap)] = FL.ComputeModeCrossTermIP( + chi[a], chi[ap], psd, P.fmin, fMax, 1. / 2. / P.deltaT, P.deltaF, + analyticPSD_Q, inv_spec_trunc_Q, T_spec, verbose=False, + same_waveform_Q=False) + crossTermsV_rot[det][(a, ap)] = FL.ComputeModeCrossTermIP( + chi_conj[a], chi[ap], psd, P.fmin, fMax, 1. / 2. / P.deltaT, P.deltaF, + analyticPSD_Q, inv_spec_trunc_Q, T_spec, prefix="V", verbose=False, + same_waveform_Q=False) + + meta = dict(harmonics=tuple(harmonics), p_max=p_max, f_sidereal=f_sidereal, + a_list=a_list, event_time_geo=float(event_time_geo), + omega_earth=OMEGA_EARTH, modes=list(hlms.keys())) + return rholms_intp_rot, crossTerms_rot, crossTermsV_rot, rholms_rot, meta + + +# --------------------------------------------------------------------------- +# Rotation-aware log-likelihood assembly (Path A: amplitude drift, p_max=0). +# --------------------------------------------------------------------------- +def antenna_harmonics_tilde(det, RA, DEC, psi, tref): + """Return {n: A_tilde_n} where F_k(t) = sum_n A_tilde_n exp(i n Omega (t - tref)). + + A_tilde_n = A_n(response, dec, psi) * exp(i n (GMST(tref) - RA)), with A_n the + RA/time-independent antenna harmonics from slowrot_response. So A_tilde_n is the + coefficient of the precompute's modulation exp(i n Omega (t - tref)); sum_n A_tilde_n + = F_k(tref) reproduces lal.ComputeDetAMResponse exactly. + """ + import lal + import lalsimulation as lalsim + from . import slowrot_response as srr + lald = lalsim.DetectorPrefixToLALDetector(det) + A = srr.antenna_harmonics(lald.response, DEC, psi) + g_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(tref)))) - RA + return {n: A[n] * np.exp(1.0j * n * g_ev) for n in A} + + +def _convolve_harmonics(a, b): + """Convolve two harmonic sequences (dicts {m: coef}) -> dict {m: coef}.""" + out = {} + for m1, c1 in a.items(): + for m2, c2 in b.items(): + out[m1 + m2] = out.get(m1 + m2, 0j) + c1 * c2 + return out + + +def rotation_coefficients(det, RA, DEC, psi, tref, p_max): + """Coefficients {(p, ntilde): C} of the elementary modulated templates + chi_{(p,n)} = exp(i n Omega (t-tref)) h^{(p)}(t-tau0) in the folded response+delay + template (Path B). For p_max=0 this is {(0,n): A_tilde_n} (Path A). + + C_{(p, ntilde)} = (1/p!) sum_{n+m=ntilde} A_tilde_n [(-D)^{*p}]_m + with A_tilde_n the antenna harmonics and D_m the delay-DRIFT harmonics: + delta_tau(t) = tau(t) - tau(tref) = sum_m D_m exp(i m Omega (t-tref)), + D_0 = -(B_tilde_1 + B_tilde_-1), D_{+-1} = B_tilde_{+-1} (B_tilde = delay harmonics). + At Omega->0 delta_tau=0 -> D=0 -> C_{(p,n)}=0 for p>=1, so Path B -> Path A. + """ + import math + import lal + import lalsimulation as lalsim + from . import slowrot_response as srr + lald = lalsim.DetectorPrefixToLALDetector(det) + g_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(tref)))) - RA + A = srr.antenna_harmonics(lald.response, DEC, psi) + Atil = {n: A[n] * np.exp(1.0j * n * g_ev) for n in A} + if p_max == 0: + return {(0, n): Atil[n] for n in Atil} + Bd = srr.delay_harmonics(lald.location, DEC) # {m: B_m}, m in -1,0,1 + Btil = {m: Bd[m] * np.exp(1.0j * m * g_ev) for m in Bd} + tau0 = np.real(sum(Btil.values())) # tau(tref) + D = {m: Btil[m] for m in Btil} + D[0] = D[0] - tau0 # = -(Btil_1 + Btil_-1) + negD = {m: -D[m] for m in D} + C = {} + E = {0: 1.0 + 0j} # (-D)^{*0} + for p in range(p_max + 1): + if p > 0: + E = _convolve_harmonics(E, negD) # (-D)^{*p} + inv = 1.0 / math.factorial(p) + for n, an in Atil.items(): + for m, em in E.items(): + key = (p, n + m) + C[key] = C.get(key, 0j) + inv * an * em + return C + + +def FactoredLogLikelihoodWithRotation(extr_params, rholms_intp_rot, crossTerms_rot, + crossTermsV_rot, meta, Lmax): + """Slow-rotation analogue of factored_likelihood.FactoredLogLikelihood (Path A). + + Contracts the harmonic-resolved precompute bank with the antenna harmonics + A_tilde_n(det, RA, DEC, psi, tref). Reduces EXACTLY to the baseline + FactoredLogLikelihood when f_sidereal -> 0 (all modulations become identity and + sum_n A_tilde_n -> F_k(tref)). + + Currently implements p_max=0 (amplitude drift only); the delay-derivative (Path B) + contraction with B_n is a TODO. + """ + import lal + from . import factored_likelihood as FL + from .. import lalsimutils as lsu + + p_max = meta['p_max'] + a_list = list(meta['a_list']) + harmonics = list(meta['harmonics']) + hset = set(harmonics) + for n in harmonics: + assert -n in hset, "harmonic set must be symmetric (need -n for the V term): %s" % harmonics + + RA = extr_params.phi + DEC = extr_params.theta + tref = extr_params.tref + phiref = extr_params.phiref + incl = extr_params.incl + psi = extr_params.psi + dist = extr_params.dist + + detectors = list(rholms_intp_rot.keys()) + a0 = a_list[0] + modes = list(rholms_intp_rot[detectors[0]][a0].keys()) + Ylms = FL.ComputeYlms(Lmax, incl, -phiref, selected_modes=modes) + + distMpc = dist / (lsu.lsu_PC * 1e6) + invDistMpc = FL.distMpcRef / distMpc + + lnL = 0. + for det in detectors: + C = rotation_coefficients(det, RA, DEC, psi, tref, p_max) # {(p,n): C_a} + t_det = FL.ComputeArrivalTimeAtDetector(det, RA, DEC, tref) + CT = crossTerms_rot[det] + CTV = crossTermsV_rot[det] + + # Q^a_lm(t_det) for each elementary template a=(p,n) + Q = {a: {m: rholms_intp_rot[det][a][m](float(t_det)) + for m in modes} for a in a_list} + + # term1 = Re[ sum_lm conj(Ylm) sum_a conj(C_a) Q^a_lm(t_det) ] + term1 = 0. + for m in modes: + s = 0. + for a in a_list: + s += np.conj(C.get(a, 0j)) * Q[a][m] + term1 += np.conj(Ylms[m]) * s + term1 = np.real(term1) * invDistMpc + + # term2 = -1/4 Re[ sum_{p1,p2} ( U-part conj(Y1)Y2 + V-part Y1 Y2 ) ] + # U-part = sum_{a,a'} conj(C_a) C_a' U^{(a,a')}[p1,p2] + # V-part = sum_{a=(p,nu),a'} C_{(p,-nu)} C_a' V^{(a,a')}[p1,p2] + term2 = 0. + for p1 in modes: + for p2 in modes: + u = 0. + v = 0. + for a in a_list: + aR = (a[0], -a[1]) + for ap in a_list: + u += np.conj(C.get(a, 0j)) * C.get(ap, 0j) * CT[(a, ap)][(p1, p2)] + v += C.get(aR, 0j) * C.get(ap, 0j) * CTV[(a, ap)][(p1, p2)] + term2 += u * np.conj(Ylms[p1]) * Ylms[p2] + v * Ylms[p1] * Ylms[p2] + term2 = -np.real(term2) / 4. / (distMpc / FL.distMpcRef) ** 2 + + lnL += term1 + term2 + + return lnL + + +# --------------------------------------------------------------------------- +# Vectorized (NoLoop) rotation likelihood -- the maintained batchmode path. +# --------------------------------------------------------------------------- +def rotation_coefficients_vector(det, RA, DEC, psi, tref, p_max): + """Vectorized rotation_coefficients: RA, DEC, psi are arrays (npts_ex,); returns + {(p, n): complex ndarray (npts_ex,)}. Same algebra as rotation_coefficients.""" + import math + import lal + import lalsimulation as lalsim + from . import slowrot_response as srr + lald = lalsim.DetectorPrefixToLALDetector(det) + RA = np.asarray(RA); DEC = np.asarray(DEC); psi = np.asarray(psi) + g_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(tref)))) - RA + A = srr.antenna_harmonics_vector(lald.response, DEC, psi) + Atil = {n: A[n] * np.exp(1.0j * n * g_ev) for n in A} + if p_max == 0: + return {(0, n): Atil[n] for n in Atil} + Bd = srr.delay_harmonics_vector(lald.location, DEC) + Btil = {m: Bd[m] * np.exp(1.0j * m * g_ev) for m in Bd} + tau0 = np.real(sum(Btil.values())) + D = {m: Btil[m] for m in Btil} + D[0] = D[0] - tau0 + negD = {m: -D[m] for m in D} + C = {} + E = {0: np.ones_like(g_ev, dtype=complex)} + for p in range(p_max + 1): + if p > 0: + E = _convolve_harmonics(E, negD) + inv = 1.0 / math.factorial(p) + for n, an in Atil.items(): + for m, em in E.items(): + key = (p, n + m) + C[key] = C.get(key, 0j) + inv * an * em + return C + + +def pack_rotation_arrays(meta, rholms_rot, crossTerms_rot, crossTermsV_rot): + """Pack the elementary-template precompute bank into dense arrays for the NoLoop path. + + Returns (lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDict), keyed per detector by + elementary template a=(p,n) (Path A: a=(0,n); Path B: also p>=1). + """ + a_list = list(meta['a_list']) + lookupNKDict = {}; rho_by_a = {}; U_by_aa = {}; V_by_aa = {}; epochDict = {} + for det in rholms_rot: + a0 = a_list[0] + modes = list(rholms_rot[det][a0].keys()) + n_lms = len(modes) + idx = {m: i for i, m in enumerate(modes)} + lookupNKDict[det] = np.array([[m[0], m[1]] for m in modes], dtype=int) + npts = rholms_rot[det][a0][modes[0]].data.length + epochDict[det] = float(rholms_rot[det][a0][modes[0]].epoch) + rho_by_a[det] = {} + for a in a_list: + arr = np.zeros((n_lms, npts), dtype=np.complex128) + for m in modes: + arr[idx[m]] = rholms_rot[det][a][m].data.data + rho_by_a[det][a] = arr + U_by_aa[det] = {}; V_by_aa[det] = {} + for a in a_list: + for ap in a_list: + Um = np.zeros((n_lms, n_lms), dtype=np.complex128) + Vm = np.zeros((n_lms, n_lms), dtype=np.complex128) + cU = crossTerms_rot[det][(a, ap)] + cV = crossTermsV_rot[det][(a, ap)] + for m1 in modes: + for m2 in modes: + Um[idx[m1], idx[m2]] = cU[(m1, m2)] + Vm[idx[m1], idx[m2]] = cV[(m1, m2)] + U_by_aa[det][(a, ap)] = Um + V_by_aa[det][(a, ap)] = Vm + return lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDict + + +def DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, meta, lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDict, + Lmax=2, array_output=False, time_interp='nearest'): + """Vectorized rotation-aware lnL (Path A). + + Mirrors factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopOrig with + sums over sidereal harmonics and the per-sample antenna harmonics + A_tilde_n = A_n(det,dec,psi) exp(i n (GMST(tref)-RA)). Extrinsic params in P_vec are + ARRAYS of length npts_ex (RA, DEC, incl, phiref, psi, dist); tref, deltaT scalar. + + array_output=True returns lnL_t of shape (npts_ex, npts) (before time marginalization); + array_output=False returns the time-marginalized lnL of shape (npts_ex,). + """ + import lal + from . import factored_likelihood as FL + + a_list = list(meta['a_list']) + p_max = meta['p_max'] + npts = len(tvals); npts_ex = len(P_vec.phi) + RA = P_vec.phi; DEC = P_vec.theta + incl = P_vec.incl; phiref = P_vec.phiref; psi = P_vec.psi + distMpc = P_vec.dist / (lal.PC_SI * 1e6) + lnL_t = np.zeros((npts_ex, npts), dtype=np.float64) + + for det in rho_by_a: + n_lms = len(lookupNKDict[det]) + Ylms = FL.ComputeYlmsArrayVector(lookupNKDict[det], incl, -phiref).T # (npts_ex, n_lms) + C = rotation_coefficients_vector(det, RA, DEC, psi, P_vec.tref, p_max) # {(p,n): (npts_ex,)} + zeroC = np.zeros(npts_ex, dtype=complex) + + def Cg(a): + return C[a] if a in C else zeroC + t_ref = epochDict[det] + # Match the maintained baseline NoLoop's precision-preserving time reference: keep the + # tiny (tref - epoch) offset and the small geometric delay separate, rather than + # subtracting two ~1e9 s absolute arrival times. The absolute-difference form loses + # ~1e-3 of a sample bin, which is harmless for nearest-neighbour snapping but shows up + # directly in the sub-bin fraction used by cubic interpolation. + detector_location = np.asarray(FL.lalsim.DetectorPrefixToLALDetector(det).location) + gmst_tref = float(lal.GreenwichMeanSiderealTime(P_vec.tref)) + t_det = float(P_vec.tref - float(t_ref)) + FL.TimeDelayFromEarthCenter( + detector_location, RA, DEC, gmst_tref) + sample_first = (t_det + tvals[0]) / P_vec.deltaT + if time_interp == 'nearest': + ifirst = (np.round(sample_first) + 0.5).astype(int) + else: + ifirst = np.floor(sample_first).astype(int) + frac_first = (sample_first - np.floor(sample_first)).astype(np.float64) + ilast = ifirst + npts + + term1 = np.zeros((npts_ex, npts), dtype=np.complex128) + for a in a_list: + det_rho = rho_by_a[det][a] + if time_interp == 'nearest': + Qa = np.empty((npts_ex, npts, n_lms), dtype=np.complex128) + for i in range(npts_ex): + Qa[i] = det_rho[..., ifirst[i]:ilast[i]].T + else: + # cubic sub-sample interpolation (calmarg time_interp='cubic'): + # _cubic_Q_window_numpy expects Q_block shape (n_time, n_lm). + Qa = FL._cubic_Q_window_numpy(det_rho.T, ifirst, frac_first, npts) + term1 += np.conj(Cg(a))[:, None] * np.einsum('xi,xti->xt', np.conj(Ylms), Qa) + term1 = term1.real * (FL.distMpcRef / distMpc)[:, None] + + term2 = np.zeros(npts_ex, dtype=np.complex128) + for a in a_list: + aR = (a[0], -a[1]) + for ap in a_list: + term2 += np.conj(Cg(a)) * Cg(ap) * np.einsum( + 'xi,xj,ij->x', np.conj(Ylms), Ylms, U_by_aa[det][(a, ap)]) + term2 += Cg(aR) * Cg(ap) * np.einsum( + 'xi,xj,ij->x', Ylms, Ylms, V_by_aa[det][(a, ap)]) + term2 = (-0.25 * term2.real) * (FL.distMpcRef / distMpc) ** 2 + + lnL_t += term1 + term2[:, None] + + if array_output: + return lnL_t + lnLmax = np.max(lnL_t, axis=-1, keepdims=True) + L = FL.my_simps(np.exp(lnL_t - lnLmax), dx=P_vec.deltaT, axis=-1) + return lnLmax[..., 0] + np.log(L) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py new file mode 100644 index 000000000..a9950d275 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py @@ -0,0 +1,442 @@ +""" +slowrot_freqresponse : closed-form FREQUENCY-DEPENDENT (finite-size) detector antenna +response of a ground-based Michelson interferometer, beyond the long-wavelength +approximation. "Path D" of the slow-rotation generalization of the RIFT likelihood. + +Motivation +---------- +The usual antenna response F_+(RA,DEC,psi), F_x(RA,DEC,psi) returned by +lal.ComputeDetAMResponse is the LONG-WAVELENGTH LIMIT: it assumes the GW strain is +uniform across the detector while a photon traverses an arm. Beyond that limit the +finite light-travel time T = L/c across an arm imprints genuine in-band frequency +structure on the response, controlled by the free spectral range + + f_FSR = c / (2 L) ( 37.5 kHz for 4-km LIGO ; 3.75 kHz for a 40-km CE arm ). + +The response becomes F_A -> F_A(f; RA, DEC, psi). This matters for 3G detectors +(Cosmic Explorer, 40 km) whose long signals reach a non-negligible fraction of f_FSR +in band; it is completely negligible for the 4-km LIGO instruments. + +Formula and convention (arXiv:2412.01693, "Beyond the Long Wavelength Approximation", +Eqs. 4-6; equivalent to Rakhmanov-Romano-Whelan 2008, arXiv:0808.3805, and +Essick-Vitale-Evans 2017, PRD 96 084004 / arXiv:1708.06843) +------------------------------------------------------------------------------------- +Single-arm round-trip transfer function, for arm unit vector a-hat and source direction +n-hat (direction TO the source, Sathyaprakash-Schutz convention -- the SAME n-hat used +by lal.ComputeDetAMResponse / TimeDelayFromEarthCenter), with T = L/c the one-way +photon time of flight and T_pm^a = T (1 +/- a-hat . n-hat): + + D~(a-hat, n-hat, f) = (e^{-i 2 pi f T} / 2) + * [ e^{+i pi f T_-^a} sinc(pi f T_+^a) + + e^{-i pi f T_+^a} sinc(pi f T_-^a) ] (Eq. 6) + +with sinc(z) = sin(z)/z, so D~ -> 1 as f -> 0. The frequency-dependent antenna +patterns are then (Eqs. 4-5) + + F_A(f, n-hat) = (1/2) [ D~(x-hat, n-hat, f) (eps^A : x-hat x-hat) + - D~(y-hat, n-hat, f) (eps^A : y-hat y-hat) ] , A in {+, x} + +where eps^+ , eps^x are the GW polarization tensors set by (RA, DEC, psi) and +x-hat, y-hat are the two arm unit vectors. At f -> 0 both D~ -> 1 and this collapses to + F_A(0) = eps^A : (1/2)(x-hat x-hat - y-hat y-hat) = eps^A : D_response + = lal.ComputeDetAMResponse , +since the detector response tensor is exactly D_response = (1/2)(x-hat x-hat - y-hat y-hat). + +Implementation note (exactness of the f -> 0 limit) +--------------------------------------------------- +LAL stores the detector response tensor `d.response` in single precision (REAL4). +Reconstructing (1/2)(x x - y y) from the geodetic arm geometry therefore agrees with +`d.response` only to ~1e-7. To reproduce lal.ComputeDetAMResponse to MACHINE PRECISION +at f = 0 we write the response as an EXACT long-wavelength baseline plus a finite-size +correction that vanishes identically at f = 0: + + F_A(f) = F_A^LWL + (1/2)[ (D~_x - 1)(eps^A : x x) - (D~_y - 1)(eps^A : y y) ] + +The baseline F_A^LWL is computed from the SAME `d.response` and triad algebra as +lal.ComputeDetAMResponse (see slowrot_response.py), so at f = 0 (where D~_x = D~_y = 1 +EXACTLY, sinc(0)=1) the correction is exactly zero and F_A(0) == ComputeDetAMResponse. +The tiny (~1e-7) geodetic imprecision only enters the O((f/f_FSR)^2) correction, i.e. +utterly negligibly. + +Pure numpy + lal; importable without the heavy RIFT stack (mirrors slowrot_response). + +Conventions (identical to slowrot_response.py / vectorized_lal_tools.py): + g = GMST - RA (Greenwich hour angle) + nhat = (cos_dec cos g, -cos_dec sin g, sin_dec) (direction to source) + X = (-cp sg - sp cg sd, -cp cg + sp sg sd, sp cd) (polarization triad) + Y = ( sp sg - cp cg sd, sp cg + cp sg sd, cp cd) + F_+ = X.D.X - Y.D.Y , F_x = X.D.Y + Y.D.X + (cp,sp)=(cos,sin)psi ; (cd,sd)=(cos,sin)dec ; (cg,sg)=(cos,sin)g +""" +from __future__ import print_function, division + +import numpy as np + +try: + import lalsimulation as _lalsim + _HAVE_LAL = True +except Exception: # pragma: no cover + _HAVE_LAL = False + +C_SI = 299792458.0 # m/s, matches vectorized_lal_tools / slowrot_response +DEFAULT_L_LIGO = 3994.5 # m, nominal LIGO arm length (override for CE etc.) + + +# --------------------------------------------------------------------------- +# Detector geometry: arm unit vectors and arm length from a LAL detector. +# --------------------------------------------------------------------------- +def _cartesian_arm(cosAlt, sinAlt, cosAz, sinAz, cosLat, sinLat, cosLon, sinLon): + """Earth-fixed Cartesian unit vector of a detector arm. + + Exact port of LAL's getCartesianComponents (LALDetectors.c): azimuth measured + from local North toward East, altitude above the local horizontal. + """ + uNorth = cosAlt * cosAz + uEast = cosAlt * sinAz + uRho = -sinLat * uNorth + cosLat * sinAlt + return np.array([ + cosLon * uRho - sinLon * uEast, + sinLon * uRho + cosLon * uEast, + cosLat * uNorth + sinLat * sinAlt, + ]) + + +def detector_geometry(det, L_arm=None): + """Return (response, x_arm, y_arm, L) for a detector prefix ('H1','L1','V1',...). + + Parameters + ---------- + det : str detector prefix understood by lalsimulation + L_arm : float or None arm length [m] override (e.g. 40000. for a 40-km CE arm); + default = 2 * xArmMidpoint from the LAL detector. + + Returns + ------- + response : (3,3) float ndarray Earth-fixed detector response tensor D (from LAL) + x_arm, y_arm : (3,) float ndarray Earth-fixed arm unit vectors + L : float arm length [m] used for the finite-size transfer + """ + if not _HAVE_LAL: # pragma: no cover + raise RuntimeError("lalsimulation is required for detector_geometry") + d = _lalsim.DetectorPrefixToLALDetector(det) + fr = d.frDetector + lat, lon = fr.vertexLatitudeRadians, fr.vertexLongitudeRadians + cL, sL, cO, sO = np.cos(lat), np.sin(lat), np.cos(lon), np.sin(lon) + x_arm = _cartesian_arm(np.cos(fr.xArmAltitudeRadians), np.sin(fr.xArmAltitudeRadians), + np.cos(fr.xArmAzimuthRadians), np.sin(fr.xArmAzimuthRadians), + cL, sL, cO, sO) + y_arm = _cartesian_arm(np.cos(fr.yArmAltitudeRadians), np.sin(fr.yArmAltitudeRadians), + np.cos(fr.yArmAzimuthRadians), np.sin(fr.yArmAzimuthRadians), + cL, sL, cO, sO) + L = float(L_arm) if L_arm is not None else 2.0 * float(fr.xArmMidpoint) + return np.asarray(d.response, dtype=float), x_arm, y_arm, L + + +# --------------------------------------------------------------------------- +# Kinematic pieces (triad + source direction), matching ComputeDetAMResponse. +# --------------------------------------------------------------------------- +def _triad(dec, psi, g): + """Polarization triad X, Y and source direction nhat at hour angle g = GMST - RA. + + dec, psi, g may be scalars or broadcastable arrays; returned vectors carry the + 3-component along the last axis. + """ + cd, sd = np.cos(dec), np.sin(dec) + cp, sp = np.cos(psi), np.sin(psi) + cg, sg = np.cos(g), np.sin(g) + + X = np.stack([-cp * sg - sp * cg * sd, + -cp * cg + sp * sg * sd, + sp * cd * np.ones_like(sg)], axis=-1) + Y = np.stack([ sp * sg - cp * cg * sd, + sp * cg + cp * sg * sd, + cp * cd * np.ones_like(sg)], axis=-1) + nhat = np.stack([cd * cg, -cd * sg, sd * np.ones_like(sg)], axis=-1) + return X, Y, nhat + + +# --------------------------------------------------------------------------- +# Single-arm finite-size transfer function (Eq. 6 of arXiv:2412.01693). +# --------------------------------------------------------------------------- +def single_arm_transfer(a_dot_n, f, L): + """Round-trip single-arm transfer function D~(a-hat, n-hat, f). + + Parameters + ---------- + a_dot_n : float or ndarray a-hat . n-hat (arm unit vector dotted with source dir) + f : float or ndarray frequency [Hz] + L : float arm length [m] + + Returns + ------- + complex ndarray broadcast over (a_dot_n, f). D~ -> 1 as f -> 0. + + D~ = (e^{-i 2 pi f T}/2)[ e^{+i pi f T_-} sinc(pi f T_+) + e^{-i pi f T_+} sinc(pi f T_-) ] + with T = L/c, T_pm = T(1 +/- a_dot_n), sinc(z)=sin(z)/z. + (np.sinc(u) = sin(pi u)/(pi u), so sinc(pi f T_pm) = np.sinc(f T_pm).) + """ + a = np.asarray(a_dot_n, dtype=float) + f = np.asarray(f, dtype=float) + T = L / C_SI + Tp = T * (1.0 + a) # T_+ + Tm = T * (1.0 - a) # T_- + pref = np.exp(-1j * 2.0 * np.pi * f * T) / 2.0 + term_p = np.exp(1j * np.pi * f * Tm) * np.sinc(f * Tp) + term_m = np.exp(-1j * np.pi * f * Tp) * np.sinc(f * Tm) + return pref * (term_p + term_m) + + +# --------------------------------------------------------------------------- +# Frequency-dependent antenna response. +# --------------------------------------------------------------------------- +def _lwl_response(response, X, Y): + """Long-wavelength F_+, F_x from triad and response tensor (== ComputeDetAMResponse). + + F_+ = X.D.X - Y.D.Y , F_x = X.D.Y + Y.D.X . Vectorized over leading axes of X,Y. + """ + D = response + XDX = np.einsum('...i,ij,...j->...', X, D, X) + YDY = np.einsum('...i,ij,...j->...', Y, D, Y) + XDY = np.einsum('...i,ij,...j->...', X, D, Y) + YDX = np.einsum('...i,ij,...j->...', Y, D, X) + return XDX - YDY, XDY + YDX + + +def antenna_response_fd(det, ra, dec, psi, f, gmst=0.0, L_arm=None): + """Frequency-dependent antenna response F_+(f), F_x(f) for a ground-based detector. + + Beyond the long-wavelength limit: includes the finite light-travel-time transfer + across the arms. Reduces EXACTLY to lal.ComputeDetAMResponse as f -> 0. + + Parameters + ---------- + det : str detector prefix ('H1','L1','V1','K1',...) + ra, dec, psi : float right ascension, declination, polarization angle [rad] + f : float or (Nf,) ndarray frequency [Hz] (scalar or 1-D array) + gmst : float Greenwich mean sidereal time [rad] (default 0). Enters + only through g = gmst - ra, exactly as ComputeDetAMResponse. + L_arm : float or None arm-length override [m] (e.g. 40000. for 40-km CE); + default = LAL detector arm length (~4 km for LIGO). + + Returns + ------- + (Fp, Fc) : complex ndarray, same shape as f. At f=0, imag part -> 0 and the real + parts equal lal.ComputeDetAMResponse(response, ra, dec, psi, gmst). + """ + response, x_arm, y_arm, L = detector_geometry(det, L_arm=L_arm) + return antenna_response_fd_geom(response, x_arm, y_arm, L, ra, dec, psi, f, gmst=gmst) + + +def antenna_response_fd_geom(response, x_arm, y_arm, L, ra, dec, psi, f, gmst=0.0): + """Same as antenna_response_fd but with detector geometry supplied explicitly. + + Useful for exotic geometries (custom L, custom arms). See antenna_response_fd. + """ + g = gmst - ra + X, Y, nhat = _triad(dec, psi, g) # shape (3,) + + # Exact long-wavelength baseline (matches ComputeDetAMResponse to machine precision). + Fp_lwl, Fc_lwl = _lwl_response(response, X, Y) + + # Arm-basis polarization projections eps^A : (a a). + Xx, Yx = float(X @ x_arm), float(Y @ x_arm) + Xy, Yy = float(X @ y_arm), float(Y @ y_arm) + Ppx = Xx * Xx - Yx * Yx # eps^+ : x x + Ppy = Xy * Xy - Yy * Yy # eps^+ : y y + Pcx = 2.0 * Xx * Yx # eps^x : x x + Pcy = 2.0 * Xy * Yy # eps^x : y y + + # Finite-size transfer per arm (vanishes-minus-one -> 0 at f=0). + ax = float(x_arm @ nhat) + ay = float(y_arm @ nhat) + Dx = single_arm_transfer(ax, f, L) + Dy = single_arm_transfer(ay, f, L) + + Fp = Fp_lwl + 0.5 * ((Dx - 1.0) * Ppx - (Dy - 1.0) * Ppy) + Fc = Fc_lwl + 0.5 * ((Dx - 1.0) * Pcx - (Dy - 1.0) * Pcy) + return Fp, Fc + + +def free_spectral_range(L): + """Free spectral range f_FSR = c / (2 L) [Hz].""" + return C_SI / (2.0 * L) + + +# =========================================================================== +# Step 1 : sky-harmonic / power-series expansion of the finite-size response. +# =========================================================================== +# The finite-size antenna response is (pure Eqs. 4-6, no LWL split) +# +# F_A(f;sky) = (1/2)[ D~(x,n,f) (eps^A:xx) - D~(y,n,f) (eps^A:yy) ] . +# +# Factor out the common one-way light-crossing delay T = L/c that both arms +# share (DIRECTION-INDEPENDENT, degenerate with coalescence time): +# +# D~(a,n,f) = e^{-i 2 pi f T} g(f; a) , a = a-hat . n-hat , +# g(f; a) = (1/2)[ e^{+i u(1-a)} sinc(u(1+a)) + e^{-i u(1+a)} sinc(u(1-a)) ] , +# u = pi f T , sinc(x) = sin(x)/x , g(f;0) = sinc(2u) , g -> 1 as f->0. +# +# Taylor-expand the residual g in the arm projection a (the small in-band +# parameter is eps*a with eps = f L/c = f T): +# +# g(f; a) = sum_{q>=0} c_q(f) a^q . (c_q sky-INDEPENDENT) +# +# The arm projections a_x = x-hat.n, a_y = y-hat.n and the arm-basis polarization +# contractions combine into a single COMPLEX analytic sky/pol scalar per power q, +# +# beta_q(sky) = (1/2)[ zx^2 a_x^q - zy^2 a_y^q ] , +# zx = X.x-hat + i Y.x-hat , zy = X.y-hat + i Y.y-hat (X,Y polarization triad) +# +# so that G_A(f;sky) := F_A e^{+i2 pi f T} gives the complex response +# +# G(f) = G_+ + i G_x = sum_q c_q(f) beta_q(sky) , (the analogue of A_n) +# beta_0 = (1/2)(zx^2 - zy^2) = F_+^LWL + i F_x^LWL (== ComputeDetAMResponse, up +# to the ~1e-7 REAL4 geodety). +# +# The full response, EXACTLY reproducing antenna_response_fd, is then +# +# F(f) = F0_lal + e^{-i2 pi f T} G(f) - beta_0 (Fp+iFc form) +# Fbar(f) = conj(F0_lal) + e^{-i2 pi f T} Gbar(f) - conj(beta_0), Gbar=sum_q c_q conj(beta_q) +# F_+ = (F+Fbar)/2 , F_x = (F-Fbar)/(2i) +# +# with F0_lal the EXACT lal.ComputeDetAMResponse baseline (so f->0 is machine +# precise even though beta_0 from the arm triads carries the ~1e-7 geodetic error; +# the arm-based pieces enter only the finite-size correction, which vanishes at f=0). +# --------------------------------------------------------------------------- +from math import comb as _comb, factorial as _factorial + + +def _sinc_shift_apoly(u, s, Qmax, jmax=64): + """a-power coefficients d_m(u), m=0..Qmax, of sinc(u(1 + s a)) (s = +/-1). + + Entire-series form (stable at u=0): sinc(x) = sum_j (-1)^j x^{2j}/(2j+1)!, + x = u(1+s a) => coeff of a^m is s^m sum_{j>=ceil(m/2)} (-1)^j u^{2j}/(2j+1)! C(2j,m). + u may be a numpy array (frequencies); returns shape (Qmax+1,) + u.shape. + """ + u = np.asarray(u, dtype=float) + out = np.zeros((Qmax + 1,) + u.shape, dtype=float) + u2 = u * u + # precompute u^{2j} + upow = np.ones_like(u) # u^{0} + for j in range(0, jmax + 1): + coef = ((-1) ** j) / float(_factorial(2 * j + 1)) + for m in range(0, min(Qmax, 2 * j) + 1): + out[m] += coef * _comb(2 * j, m) * upow + upow = upow * u2 + for m in range(Qmax + 1): + out[m] *= float(s) ** m + return out.astype(complex) + + +def _poly_mul(A, B, Qmax): + """Truncated product (to order Qmax) of two a-power stacks A[k],B[k] over freq axis.""" + out = np.zeros_like(A[:Qmax + 1]) + for i in range(min(len(A), Qmax + 1)): + for j in range(min(len(B), Qmax + 1 - i)): + out[i + j] += A[i] * B[j] + return out + + +def finite_size_c_coeffs(f, L, Qmax): + """Sky-independent frequency basis c_q(f), q = 0..Qmax, of g(f;a)=sum_q c_q(f) a^q. + + Parameters + ---------- + f : ndarray frequency [Hz] (signed values allowed; c_q(-f)=conj(c_q(f))) + L : float arm length [m] + Qmax : int highest power of the arm projection a retained + + Returns + ------- + c : complex ndarray, shape (Qmax+1,) + f.shape. c_0(f)=sinc(2 pi f T), + c_{q>=1} carry the finite-size shape distortion; all -> delta_{q0} as f->0. + """ + f = np.asarray(f, dtype=float) + T = L / C_SI + u = np.pi * f * T + # exp(-i u a): coeff of a^k is (-i u)^k / k! + exp_neg = np.zeros((Qmax + 1,) + u.shape, dtype=complex) + term = np.ones_like(u, dtype=complex) + for k in range(Qmax + 1): + exp_neg[k] = term / float(_factorial(k)) + term = term * (-1j * u) + sinc_p = _sinc_shift_apoly(u, +1.0, Qmax) # sinc(u(1+a)) + sinc_m = _sinc_shift_apoly(u, -1.0, Qmax) # sinc(u(1-a)) + eiu = np.exp(1j * u) + term1 = eiu * _poly_mul(exp_neg, sinc_p, Qmax) # e^{iu} e^{-iua} sinc(u(1+a)) + term2 = np.conj(eiu) * _poly_mul(exp_neg, sinc_m, Qmax) # e^{-iu} e^{-iua} sinc(u(1-a)) + return 0.5 * (term1 + term2) + + +def finite_size_geometry(det, ra, dec, psi, gmst=0.0, L_arm=None): + """Geometric scalars for the finite-size expansion at (det, ra, dec, psi, gmst). + + Returns dict with: + T = L/c common one-way delay [s] + L arm length [m] + ax, ay arm projections x-hat.n, y-hat.n + zx, zy = X.a-hat + i Y.a-hat (complex pol/arm scalars) + F0 = Fp_lwl + i Fc_lwl (EXACT lal baseline response, machine precise f=0) + """ + response, x_arm, y_arm, L = detector_geometry(det, L_arm=L_arm) + g = gmst - ra + X, Y, nhat = _triad(dec, psi, g) + Fp_lwl, Fc_lwl = _lwl_response(response, X, Y) + Xx, Yx = float(X @ x_arm), float(Y @ x_arm) + Xy, Yy = float(X @ y_arm), float(Y @ y_arm) + zx = Xx + 1j * Yx + zy = Xy + 1j * Yy + ax = float(x_arm @ nhat) + ay = float(y_arm @ nhat) + return dict(T=L / C_SI, L=L, ax=ax, ay=ay, zx=zx, zy=zy, + F0=complex(Fp_lwl) + 1j * complex(Fc_lwl)) + + +def finite_size_beta(geom, Qmax): + """Analytic sky/pol coefficients beta_q = (1/2)[zx^2 a_x^q - zy^2 a_y^q], q=0..Qmax.""" + zx2, zy2, ax, ay = geom['zx'] ** 2, geom['zy'] ** 2, geom['ax'], geom['ay'] + return np.array([0.5 * (zx2 * ax ** q - zy2 * ay ** q) for q in range(Qmax + 1)], + dtype=complex) + + +def F_fd_expanded(det, ra, dec, psi, f, Qmax, gmst=0.0, L_arm=None): + """Order-Qmax reconstruction of the finite-size response F_+(f), F_x(f). + + Converges to antenna_response_fd(det,ra,dec,psi,f,...) as Qmax -> infinity. + Uses the EXACT lal baseline F0 for the constant part and the power-series + finite-size correction e^{-i2 pi f T}(sum_q c_q beta_q) - beta_0. + """ + f = np.asarray(f, dtype=float) + geom = finite_size_geometry(det, ra, dec, psi, gmst=gmst, L_arm=L_arm) + beta = finite_size_beta(geom, Qmax) + c = finite_size_c_coeffs(f, geom['L'], Qmax) # (Qmax+1,)+f.shape + G = np.tensordot(beta, c, axes=(0, 0)) # sum_q beta_q c_q(f) + Gbar = np.tensordot(np.conj(beta), c, axes=(0, 0)) + phase = np.exp(-1j * 2.0 * np.pi * f * geom['T']) + F = geom['F0'] + phase * G - beta[0] + Fbar = np.conj(geom['F0']) + phase * Gbar - np.conj(beta[0]) + Fp = 0.5 * (F + Fbar) + Fc = (F - Fbar) / (2.0j) + return Fp, Fc + + +def finite_size_response_weights(fvals, geom, Qmax): + """Per-basis frequency weights W_p(f) folded into the FD modes for the likelihood. + + Response basis (p = 0..Qmax+1) reproducing F(f) = sum_p b_p W_p(f): + p=0 ("baseline") : W_0(f) = 1 b_0 = F0 (exact lal) + p=1+q : W_{1+q}(f) = e^{-i2pi f T} c_q(f) - [q==0] + b_{1+q} = beta_q (arm) + Each W_p is Hermitian (W_p(-f)=conj(W_p(f))) so the V cross term needs NO + harmonic reflection. The common delay e^{-i2 pi f T} (= a T=L/c arrival-time + shift of the finite-size correction relative to the LWL baseline) is carried + inside the correction weights. Returns (weights (Npbasis, Nf) complex, coeff-builder). + """ + fvals = np.asarray(fvals, dtype=float) + c = finite_size_c_coeffs(fvals, geom['L'], Qmax) + phase = np.exp(-1j * 2.0 * np.pi * fvals * geom['T']) + W = np.empty((Qmax + 2, fvals.shape[0]), dtype=complex) + W[0] = 1.0 + for q in range(Qmax + 1): + W[1 + q] = phase * c[q] - (1.0 if q == 0 else 0.0) + return W diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_response.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_response.py new file mode 100644 index 000000000..66220571d --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_response.py @@ -0,0 +1,271 @@ +""" +slowrot_response : closed-form sidereal-harmonic decomposition of the ground-based +detector response, for the "slow-rotation" generalization of the RIFT likelihood. + +Over a long signal the Earth rotates, so the antenna response and the geometric +propagation delay drift. Both are exactly band-limited in harmonics of the Greenwich +hour angle g(t) = GMST(t) - RA: + + F(t) = F_+(t) + i F_x(t) = sum_{n=-2}^{+2} A_n exp(i n g(t)) (5 harmonics) + tau(t) = -(1/c) r_det . nhat(t) = sum_{n=-1}^{+1} B_n exp(i n g(t)) (3 harmonics) + +The key structural facts (see notes/ sec_amplitude, sec_delay, and the response appendix): + + * The complex antenna harmonics A_n depend ONLY on the detector response tensor, the + source declination, and the polarization angle -- NOT on right ascension or time. + RA and time enter only through the phase exp(i n g(t)) = exp(i n (GMST(t)-RA)). + Since GMST(t) ~ GMST(t_ref) + Omega (t - t_ref), this is exactly the sidereal-harmonic + modulation exp(i n Omega t) of the design notes, with sky/time in analytic scalars. + + * The delay harmonics B_n depend only on the detector location and the declination. + +This module derives A_n, B_n from the SAME algebra used by +RIFT.likelihood.vectorized_lal_tools.ComputeDetAMResponse / TimeDelayFromEarthCenter, so +the reconstruction + + F(t) = sum_n A_n exp(i n g) matches lal.ComputeDetAMResponse + tau(t) = sum_n B_n exp(i n g) matches lal.TimeDelayFromEarthCenter + +to machine precision for any g. This is an exact algebraic identity in g, not an +approximation; the ONLY approximation in the slow-rotation program is the near-linearity +of GMST(t) (handled elsewhere) and the truncation of the delay-derivative expansion +(Path B), neither of which enters here. + +Conventions mirror vectorized_lal_tools.py exactly: + g = GMST - RA (Greenwich hour angle) + nhat = (cos_dec cos g, -cos_dec sin g, sin_dec) + X, Y polarization triads as in ComputeDetAMResponse + F_+ = X.D.X - Y.D.Y, F_x = X.D.Y + Y.D.X (D = detector response tensor, symmetric) +""" +from __future__ import print_function, division + +import numpy as np + +C_SI = 299792458.0 # m/s, matches vectorized_lal_tools + + +# --------------------------------------------------------------------------- +# Constant polarization-triad basis vectors (independent of hour angle g). +# +# Writing X = Xc cos g + Xs sin g + X0 (and likewise Y), read off directly from +# vectorized_lal_tools.ComputeDetAMResponse: +# X[0] = -cos_psi sin_g - sin_psi cos_g sin_dec +# X[1] = -cos_psi cos_g + sin_psi sin_g sin_dec +# X[2] = sin_psi cos_dec +# Y[0] = sin_psi sin_g - cos_psi cos_g sin_dec +# Y[1] = sin_psi cos_g + cos_psi sin_g sin_dec +# Y[2] = cos_psi cos_dec +# --------------------------------------------------------------------------- +def _triad_components(dec, psi): + """Return the six constant 3-vectors (Xc, Xs, X0, Yc, Ys, Y0).""" + cd, sd = np.cos(dec), np.sin(dec) + cp, sp = np.cos(psi), np.sin(psi) + + Xc = np.array([-sp * sd, -cp, 0.0]) # coeff of cos g in X + Xs = np.array([-cp, sp * sd, 0.0]) # coeff of sin g in X + X0 = np.array([ 0.0, 0.0, sp * cd]) + + Yc = np.array([-cp * sd, sp, 0.0]) # coeff of cos g in Y + Ys = np.array([ sp, cp * sd, 0.0]) # coeff of sin g in Y + Y0 = np.array([ 0.0, 0.0, cp * cd]) + return Xc, Xs, X0, Yc, Ys, Y0 + + +def antenna_harmonics(response_matrix, dec, psi): + """Complex antenna-pattern harmonics A_n, n = -2..2. + + F(t) = F_+(t) + i F_x(t) = sum_{n=-2}^{2} A_n exp(i n g), g = GMST(t) - RA. + + Parameters + ---------- + response_matrix : (3,3) array detector response tensor (Earth-fixed), symmetric + dec, psi : float declination and polarization angle [rad] + + Returns + ------- + A : dict {n: complex} for n in (-2,-1,0,1,2). Independent of RA and time. + """ + D = np.asarray(response_matrix, dtype=float) + Xc, Xs, X0, Yc, Ys, Y0 = _triad_components(dec, psi) + + def B(u, v): + return float(u @ D @ v) # bilinear form; D symmetric => B(u,v)=B(v,u) + + # --- F_+ = X.D.X - Y.D.Y, expanded in cos/sin of (n g) --- + # Using cos^2 = 1/2 + 1/2 cos2g, sin^2 = 1/2 - 1/2 cos2g, cos sin = 1/2 sin2g. + Pp0 = (0.5 * (B(Xc, Xc) + B(Xs, Xs)) + B(X0, X0) + - (0.5 * (B(Yc, Yc) + B(Ys, Ys)) + B(Y0, Y0))) + Pp1 = 2.0 * (B(Xc, X0) - B(Yc, Y0)) # cos g + Qp1 = 2.0 * (B(Xs, X0) - B(Ys, Y0)) # sin g + Pp2 = 0.5 * ((B(Xc, Xc) - B(Xs, Xs)) - (B(Yc, Yc) - B(Ys, Ys))) # cos 2g + Qp2 = B(Xc, Xs) - B(Yc, Ys) # sin 2g + + # --- F_x = X.D.Y + Y.D.X = 2 X.D.Y (D symmetric) --- + Pc0 = B(Xc, Yc) + B(Xs, Ys) + 2.0 * B(X0, Y0) + Pc1 = 2.0 * (B(Xc, Y0) + B(X0, Yc)) # cos g + Qc1 = 2.0 * (B(Xs, Y0) + B(X0, Ys)) # sin g + Pc2 = B(Xc, Yc) - B(Xs, Ys) # cos 2g + Qc2 = B(Xc, Ys) + B(Xs, Yc) # sin 2g + + # Complex combine: F = F_+ + i F_x. For harmonic n>=1, the real signal piece + # P_n cos n g + Q_n sin n g -> A_n exp(i n g) + A_{-n} exp(-i n g) + # with A_{+n} = (P_n - i Q_n)/2, A_{-n} = (P_n + i Q_n)/2, and here P_n, Q_n are + # THEMSELVES complex (P_n = Pp_n + i Pc_n, Q_n = Qp_n + i Qc_n). + P0 = Pp0 + 1j * Pc0 + P1 = Pp1 + 1j * Pc1 + Q1 = Qp1 + 1j * Qc1 + P2 = Pp2 + 1j * Pc2 + Q2 = Qp2 + 1j * Qc2 + + A = { + 0: P0, + 1: 0.5 * (P1 - 1j * Q1), + -1: 0.5 * (P1 + 1j * Q1), + 2: 0.5 * (P2 - 1j * Q2), + -2: 0.5 * (P2 + 1j * Q2), + } + return A + + +def antenna_harmonics_vector(response_matrix, dec, psi): + """Vectorized antenna_harmonics: dec, psi arrays (broadcast shape S). + + Returns {n: complex ndarray shape S} for n in -2..2. Mirrors antenna_harmonics + exactly (same algebra), but dec/psi may be numpy arrays (or plain Python floats/ + 0-d arrays) of a common broadcastable shape S. + """ + D = np.asarray(response_matrix, dtype=float) + dec = np.asarray(dec, dtype=float) + psi = np.asarray(psi, dtype=float) + cd, sd = np.cos(dec), np.sin(dec) + cp, sp = np.cos(psi), np.sin(psi) + + # np.broadcast(dec, psi) misbehaves/raises for plain Python floats or 0-d arrays + # in some numpy versions (it wants array-like objects, and its .shape attribute + # is not the intuitive broadcast shape for scalar inputs). np.broadcast_shapes + # operates purely on shapes and works uniformly for scalars (shape ()), 0-d + # arrays, and full arrays. + out_shape = np.broadcast_shapes(np.shape(dec), np.shape(psi)) + zero = np.zeros(out_shape) + + def vec(a, b, c): + return np.stack(np.broadcast_arrays(a, b, c), axis=-1) + + Xc = vec(-sp * sd, -cp, zero) + Xs = vec(-cp, sp * sd, zero) + X0 = vec(zero, zero, sp * cd) + + Yc = vec(-cp * sd, sp, zero) + Ys = vec( sp, cp * sd, zero) + Y0 = vec(zero, zero, cp * cd) + + def B(u, v): + return np.einsum('...i,ij,...j->...', u, D, v) + + Pp0 = 0.5 * (B(Xc, Xc) + B(Xs, Xs)) + B(X0, X0) - (0.5 * (B(Yc, Yc) + B(Ys, Ys)) + B(Y0, Y0)) + Pp1 = 2.0 * (B(Xc, X0) - B(Yc, Y0)) + Qp1 = 2.0 * (B(Xs, X0) - B(Ys, Y0)) + Pp2 = 0.5 * ((B(Xc, Xc) - B(Xs, Xs)) - (B(Yc, Yc) - B(Ys, Ys))) + Qp2 = B(Xc, Xs) - B(Yc, Ys) + + Pc0 = B(Xc, Yc) + B(Xs, Ys) + 2.0 * B(X0, Y0) + Pc1 = 2.0 * (B(Xc, Y0) + B(X0, Yc)) + Qc1 = 2.0 * (B(Xs, Y0) + B(X0, Ys)) + Pc2 = B(Xc, Yc) - B(Xs, Ys) + Qc2 = B(Xc, Ys) + B(Xs, Yc) + + P0 = Pp0 + 1j * Pc0 + P1 = Pp1 + 1j * Pc1 + Q1 = Qp1 + 1j * Qc1 + P2 = Pp2 + 1j * Pc2 + Q2 = Qp2 + 1j * Qc2 + + # B(u,v) already contracts down to shape out_shape (einsum drops the trailing + # vector index), so no additional broadcasting of P0/P1/... is actually needed; + # multiply by ones defensively/explicitly to guarantee the advertised return shape. + ones = np.ones(out_shape) + return { + 0: P0 * ones, + 1: 0.5 * (P1 - 1j * Q1) * ones, + -1: 0.5 * (P1 + 1j * Q1) * ones, + 2: 0.5 * (P2 - 1j * Q2) * ones, + -2: 0.5 * (P2 + 1j * Q2) * ones, + } + + +def delay_harmonics(location_xyz, dec): + """Geometric-delay harmonics B_n, n = -1..1 [seconds]. + + tau(t) = -(1/c) r_det . nhat(t) = sum_{n=-1}^{1} B_n exp(i n g), g = GMST(t) - RA, + with nhat = (cos_dec cos g, -cos_dec sin g, sin_dec) as in + vectorized_lal_tools.TimeDelayFromEarthCenter. + + Parameters + ---------- + location_xyz : (3,) array detector position relative to Earth center [m], Earth-fixed + dec : float declination [rad] + + Returns + ------- + B : dict {n: complex} for n in (-1,0,1). B_0 real; B_{-1} = conj(B_1). + Independent of RA and time. + """ + r = np.asarray(location_xyz, dtype=float) + cd, sd = np.cos(dec), np.sin(dec) + + # tau = -(1/c)[ r_x cos_dec cos g - r_y cos_dec sin g + r_z sin_dec ] + # = T0 + T1c cos g + T1s sin g + T0 = -(r[2] * sd) / C_SI + T1c = -(cd * r[0]) / C_SI + T1s = +(cd * r[1]) / C_SI + + B = { + 0: complex(T0), + 1: 0.5 * (T1c - 1j * T1s), + -1: 0.5 * (T1c + 1j * T1s), + } + return B + + +def delay_harmonics_vector(location_xyz, dec): + """Vectorized delay_harmonics: dec is an array (shape S). Returns {n: complex ndarray + shape S} for n=-1,0,1. Same algebra as delay_harmonics.""" + r = np.asarray(location_xyz, dtype=float) + dec = np.asarray(dec, dtype=float) + cd, sd = np.cos(dec), np.sin(dec) + T0 = -(r[2] * sd) / C_SI + T1c = -(cd * r[0]) / C_SI + T1s = (cd * r[1]) / C_SI + return {0: T0 + 0j, 1: 0.5 * (T1c - 1j * T1s), -1: 0.5 * (T1c + 1j * T1s)} + + +# --------------------------------------------------------------------------- +# Reconstruction helpers (evaluate the harmonic series at given hour angle g). +# --------------------------------------------------------------------------- +def greenwich_hour_angle(gmst, ra): + """g = GMST - RA.""" + return gmst - ra + + +def antenna_from_harmonics(A, g): + """F_+ + i F_x = sum_n A_n exp(i n g). g may be scalar or array.""" + g = np.asarray(g, dtype=float) + F = np.zeros(g.shape, dtype=complex) if g.shape else 0j + for n, An in A.items(): + F = F + An * np.exp(1j * n * g) + return F + + +def delay_from_harmonics(B, g): + """tau(t) = sum_n B_n exp(i n g) (imaginary part cancels to ~0). g scalar or array.""" + g = np.asarray(g, dtype=float) + tau = np.zeros(g.shape, dtype=complex) if g.shape else 0j + for n, Bn in B.items(): + tau = tau + Bn * np.exp(1j * n * g) + return np.real(tau) + + +def antenna_response(A, gmst, ra): + """Convenience: (F_plus, F_cross) from harmonics at (gmst, ra).""" + F = antenna_from_harmonics(A, greenwich_hour_angle(gmst, ra)) + return np.real(F), np.imag(F) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py new file mode 100644 index 000000000..7c42a1890 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py @@ -0,0 +1,144 @@ +""" +Validate the frequency-domain primitives of factored_likelihood_with_rotation against +LAL FFT round trips (numpy + lal only; no glue / full RIFT stack needed). + +Checks: + 1. LAL forward/reverse COMPLEX16 FFT round-trips to identity (normalization sanity). + 2. Which signed frequency LAL assigns to a tone, vs evaluate_fvals_from_length -> fixes + the sign FT_SIGN in the time-derivative weight. + 3. fd_apply_time_derivative reproduces d^p/dt^p exactly for a multi-tone signal. + 4. _lal_freq_modulate reproduces exp(i coef Omega t) multiplication exactly. + 5. the O(N^2) reference apply_sidereal_modulation_array agrees with the LAL round trip. + +Run: python test_slowrot_fd_ops.py (also usable under pytest) +""" +from __future__ import print_function, division + +import os +import importlib.util + +import numpy as np +import lal + +_here = os.path.dirname(os.path.abspath(__file__)) +_spec = importlib.util.spec_from_file_location( + "flwr", os.path.join(_here, "factored_likelihood_with_rotation.py")) +flwr = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(flwr) + +N = 256 +DELTA_T = 1.0 / 512.0 +DELTA_F = 1.0 / (N * DELTA_T) +_T = np.arange(N) * DELTA_T + + +def _make_timeseries(cvec): + ts = lal.CreateCOMPLEX16TimeSeries("h", lal.LIGOTimeGPS(0.), 0., DELTA_T, + lal.DimensionlessUnit, N) + ts.data.data[:] = cvec.astype(complex) + return ts + + +def _forward(ts): + """DataFourier-style forward transform.""" + fwd = lal.CreateForwardCOMPLEX16FFTPlan(N, 0) + hf = lal.CreateCOMPLEX16FrequencySeries("hf", ts.epoch, 0., DELTA_F, + lal.HertzUnit, N) + lal.COMPLEX16TimeFreqFFT(hf, ts, fwd) + return hf + + +def _reverse(hf): + rev = lal.CreateReverseCOMPLEX16FFTPlan(N, 0) + ts = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., DELTA_T, + lal.DimensionlessUnit, N) + lal.COMPLEX16FreqTimeFFT(ts, hf, rev) + return ts + + +def _multitone(): + """h(t) = sum_j c_j exp(2 pi i f_j t), f_j on distinct grid bins.""" + bins = [3, 7, -5, 12, -11] + coeffs = [1.0, 0.5 - 0.3j, -0.8j, 0.4, 0.2 + 0.1j] + h = np.zeros(N, dtype=complex) + for b, c in zip(bins, coeffs): + h += c * np.exp(2.0j * np.pi * (b * DELTA_F) * _T) + return h, bins, coeffs + + +def test_roundtrip_identity(): + h, _, _ = _multitone() + back = _reverse(_forward(_make_timeseries(h))).data.data + assert np.max(np.abs(back - h)) < 1e-10, "LAL round trip not identity" + print("roundtrip: max err = %.2e" % np.max(np.abs(back - h))) + + +def test_tone_frequency_assignment_and_FT_SIGN(): + """Determine FT_SIGN so that (FT_SIGN 2 pi i f)^1 gives the true derivative.""" + f0_bin = 7 + h = np.exp(2.0j * np.pi * (f0_bin * DELTA_F) * _T) + hf = _forward(_make_timeseries(h)) + H = hf.data.data + fvals = flwr.evaluate_fvals_from_length(N, DELTA_F) + kpeak = int(np.argmax(np.abs(H))) + f_assigned = fvals[kpeak] + # true single-tone first derivative is (2 pi i f0) h; the reconstructed weight is + # (FT_SIGN 2 pi i f_assigned). Equate -> FT_SIGN = f0 / f_assigned. + f0 = f0_bin * DELTA_F + needed_sign = np.sign(f0 / f_assigned) + print("tone at bin %d: f0=%.4f, evaluate_fvals assigns %.4f -> FT_SIGN should be %+d" + % (f0_bin, f0, f_assigned, int(needed_sign))) + assert abs(abs(f_assigned) - abs(f0)) < 1e-9, "tone landed at wrong |f|" + assert flwr.FT_SIGN == needed_sign, ( + "module FT_SIGN=%g but LAL convention requires %g" % (flwr.FT_SIGN, needed_sign)) + + +def test_time_derivative_exact(): + h, bins, coeffs = _multitone() + for p in (1, 2, 3): + # analytic derivative + dh = np.zeros(N, dtype=complex) + for b, c in zip(bins, coeffs): + w = (2.0j * np.pi * b * DELTA_F) + dh += c * (w ** p) * np.exp(2.0j * np.pi * (b * DELTA_F) * _T) + hf = _forward(_make_timeseries(h)) + hf_d = flwr.fd_apply_time_derivative(hf, p) + num = _reverse(hf_d).data.data + err = np.max(np.abs(num - dh)) / np.max(np.abs(dh)) + print("derivative p=%d: rel err = %.2e" % (p, err)) + assert err < 1e-9, "derivative order %d inexact: %g" % (p, err) + + +def test_sidereal_modulation_exact(): + h, _, _ = _multitone() + f_sid = 0.05 * DELTA_F # exaggerated so coef*f_sid is an appreciable sub-bin shift + for coef in (1, 2, -1, -2): + expected = np.exp(2.0j * np.pi * coef * f_sid * _T) * h + hf = _forward(_make_timeseries(h)) + hf_mod = flwr._lal_freq_modulate(hf, coef, f_sidereal=f_sid) + num = _reverse(hf_mod).data.data + err = np.max(np.abs(num - expected)) / np.max(np.abs(expected)) + print("modulation coef=%+d: rel err = %.2e" % (coef, err)) + assert err < 1e-9, "modulation coef %d inexact: %g" % (coef, err) + + +def test_reference_matrix_matches_lal_modulation(): + """The O(N^2) reference and the LAL round trip must agree.""" + h, _, _ = _multitone() + f_sid = 0.05 * DELTA_F + hf = _forward(_make_timeseries(h)) + for coef in (1, -2): + via_lal = flwr._lal_freq_modulate(hf, coef, f_sidereal=f_sid).data.data + via_mat = flwr.apply_sidereal_modulation_array(hf.data.data, coef, DELTA_F, f_sid) + err = np.max(np.abs(via_lal - via_mat)) / np.max(np.abs(via_lal)) + print("reference-vs-LAL coef=%+d: rel err = %.2e" % (coef, err)) + assert err < 1e-9, "reference matrix disagrees with LAL: %g" % err + + +if __name__ == "__main__": + test_roundtrip_identity() + test_tone_frequency_assignment_and_FT_SIGN() + test_time_derivative_exact() + test_sidereal_modulation_exact() + test_reference_matrix_matches_lal_modulation() + print("ALL FD-PRIMITIVE CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py new file mode 100644 index 000000000..5d295859e --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py @@ -0,0 +1,200 @@ +""" +Validate slowrot_freqresponse (Path D, frequency-dependent finite-size antenna response). + +Checks: + (A) LONG-WAVELENGTH LIMIT: antenna_response_fd(..., f=0) == lal.ComputeDetAMResponse to + machine precision, over many random (ra,dec,psi) and H1/L1/V1/K1. KEY CHECK. + (B) FREE-SPECTRAL-RANGE STRUCTURE: the single-arm transfer's first null sits at the + expected frequency c / (L (1 + a.n)); |F(f)| departs from |F(0)| on the f_FSR scale. + (C) IN-BAND MAGNITUDE: fractional response change |F(f)/F(0) - 1| at 1 kHz and 2 kHz for + (i) 4-km LIGO and (ii) a 40-km CE arm -- quantifies whether the effect matters in band. + +Run directly: + python test_slowrot_freqresponse.py +or under pytest (the test_* functions assert). +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +try: + import RIFT.likelihood.slowrot_freqresponse as fr +except Exception: + import importlib.util, os + _here = os.path.dirname(os.path.abspath(__file__)) + _spec = importlib.util.spec_from_file_location( + "slowrot_freqresponse", os.path.join(_here, "slowrot_freqresponse.py")) + fr = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(fr) + +DETECTORS = ["H1", "L1", "V1", "K1"] +_TOL = 1e-11 # machine-precision agreement vs ComputeDetAMResponse + +_RNG = np.random.RandomState(20260707) +_CASES = [] +for _det in DETECTORS: + for _ in range(8): + _ra = _RNG.uniform(0, 2 * np.pi) + _dec = np.arcsin(_RNG.uniform(-1, 1)) + _psi = _RNG.uniform(0, np.pi) + _gmst = _RNG.uniform(0, 2 * np.pi) + _CASES.append((_det, _ra, _dec, _psi, _gmst)) + + +# ---- (A) long-wavelength limit vs LAL ------------------------------------------------ +def test_long_wavelength_limit_matches_lal(): + worst = 0.0 + for det, ra, dec, psi, gmst in _CASES: + lald = lalsim.DetectorPrefixToLALDetector(det) + Fp_ref, Fc_ref = lal.ComputeDetAMResponse(lald.response, ra, dec, psi, gmst) + Fp, Fc = fr.antenna_response_fd(det, ra, dec, psi, 0.0, gmst=gmst) + e = max(abs(Fp - Fp_ref), abs(Fc - Fc_ref)) + worst = max(worst, e) + assert e < _TOL, "FD(f=0) mismatch %g for %s ra=%.3f dec=%.3f psi=%.3f" % ( + e, det, ra, dec, psi) + print("(A) long-wavelength limit: worst |F_fd(0) - ComputeDetAMResponse| = %.3e" % worst) + return worst + + +def test_zero_frequency_is_real(): + """At f=0 the response must be purely real (imag part = 0 to machine precision).""" + worst = 0.0 + for det, ra, dec, psi, gmst in _CASES: + Fp, Fc = fr.antenna_response_fd(det, ra, dec, psi, 0.0, gmst=gmst) + worst = max(worst, abs(Fp.imag), abs(Fc.imag)) + assert worst < 1e-14, "imag(F(0)) = %g" % worst + print("(A') Im F(0): worst = %.3e" % worst) + + +# ---- (B) free-spectral-range / sinc-null structure ----------------------------------- +def _first_local_min(a, L, fmax_frac=3.5, N=700000): + """Frequency and depth of the first local minimum of |D~| in (0, fmax_frac*f_FSR].""" + fFSR = fr.free_spectral_range(L) + fg = np.linspace(1.0, fmax_frac * fFSR, N) + mag = np.abs(fr.single_arm_transfer(a, fg, L)) + # first interior local minimum + lo = (mag[1:-1] < mag[:-2]) & (mag[1:-1] < mag[2:]) + idx = np.nonzero(lo)[0] + i = idx[0] + 1 if len(idx) else int(np.argmin(mag)) + return fg[i], mag[i], fFSR + + +def test_single_arm_null_at_fsr_for_transverse(): + """a.n = 0 (source transverse to arm): D~ = e^{-i2pi fT} sinc(2fT), EXACT null at f_FSR.""" + L = 40000.0 + T = L / fr.C_SI + fFSR = fr.free_spectral_range(L) + fg = np.linspace(1.0, 4.0 * fFSR, 200001) + D = fr.single_arm_transfer(0.0, fg, L) + ref = np.exp(-1j * 2.0 * np.pi * fg * T) * np.sinc(2.0 * fg * T) + ident = np.max(np.abs(D - ref)) + assert ident < 1e-13, "a=0 closed form mismatch %g" % ident + # exact first null at f_FSR + val_at_fsr = abs(fr.single_arm_transfer(0.0, fFSR, L)) + assert val_at_fsr < 1e-12, "|D~(a=0, f_FSR)| = %g (should vanish)" % val_at_fsr + print("(B) a.n=0: D~=e^{-i2pi fT} sinc(2fT) to %.2e; EXACT first null at f_FSR=c/2L=%.5g Hz" + % (ident, fFSR)) + + +def test_single_arm_dip_structure(): + """General a.n: |D~| dips on the f_FSR scale (exact nulls only for a.n=0).""" + L = 40000.0 + for a in [-0.7, -0.3, 0.0, 0.4, 0.8]: + fmin, depth, fFSR = _first_local_min(a, L) + print("(B') a.n=%+.2f: first |D~| dip at %.4g Hz = %.3f f_FSR (|D~|min=%.2e)" + % (a, fmin, fmin / fFSR, depth)) + assert 0.3 * fFSR < fmin < 3.5 * fFSR, "dip off the f_FSR scale for a=%g" % a + + +def test_fsr_scale_departure(): + """|F(f)| departs from |F(0)| by O(1) once f ~ f_FSR (use a 40-km CE arm).""" + det, ra, dec, psi, gmst = "H1", 1.2, 0.5, 0.7, 2.0 + L = 40000.0 + fFSR = fr.free_spectral_range(L) + Fp0, Fc0 = fr.antenna_response_fd(det, ra, dec, psi, 0.0, gmst=gmst, L_arm=L) + F0 = abs(Fp0 + 1j * Fc0) + for frac in [0.01, 0.1, 0.5, 1.0]: + f = frac * fFSR + Fp, Fc = fr.antenna_response_fd(det, ra, dec, psi, f, gmst=gmst, L_arm=L) + rel = abs(abs(Fp + 1j * Fc) - F0) / F0 + print("(B') f/f_FSR=%.2f (f=%.4g Hz): ||F(f)|-|F(0)||/|F(0)| = %.3e" % (frac, f, rel)) + + +# ---- (C) in-band magnitude: LIGO vs CE ---------------------------------------------- +def _fractional_change(det, L, freqs, n_sky=4000): + """Median-over-sky of complex |F(f)/F(0)-1| AND amplitude-only ||F(f)|-|F(0)||/|F(0)|, + excluding sky positions near antenna-pattern nulls (|F(0)|<0.3) where the ratio blows + up for reasons unrelated to the finite-size effect. + + Returns (complex_med, amp_med) arrays over freqs. The complex ratio is DOMINATED by + the overall light-crossing phase e^{-i2 pi f L/c} (a benign direction-independent + delay of L/c, degenerate with coalescence time); the amplitude-only change is the + physically meaningful measure of antenna-pattern SHAPE distortion. + """ + rng = np.random.RandomState(1234) + comp = [[] for _ in freqs] + amp = [[] for _ in freqs] + for _ in range(n_sky): + ra = rng.uniform(0, 2 * np.pi) + dec = np.arcsin(rng.uniform(-1, 1)) + psi = rng.uniform(0, np.pi) + gmst = rng.uniform(0, 2 * np.pi) + Fp0, Fc0 = fr.antenna_response_fd(det, ra, dec, psi, 0.0, gmst=gmst, L_arm=L) + F0 = complex(Fp0) + 1j * complex(Fc0) + if abs(F0) < 0.3: + continue + for i, f in enumerate(freqs): + Fp, Fc = fr.antenna_response_fd(det, ra, dec, psi, f, gmst=gmst, L_arm=L) + Ff = complex(Fp) + 1j * complex(Fc) + comp[i].append(abs(Ff / F0 - 1.0)) + amp[i].append(abs(abs(Ff) - abs(F0)) / abs(F0)) + return (np.array([np.median(c) for c in comp]), + np.array([np.median(a) for a in amp])) + + +def test_in_band_magnitude_ligo_vs_ce(): + freqs = [1000.0, 2000.0] + ligo_c, ligo_a = _fractional_change("H1", 3994.5, freqs) + ce_c, ce_a = _fractional_change("H1", 40000.0, freqs) + print("(C) IN-BAND fractional response change (median over sky, away from nulls):") + print(" complex |F(f)/F(0)-1| (incl. benign overall e^{-i2pi f L/c} delay phase):") + for i, f in enumerate(freqs): + print(" f=%5.0f Hz : LIGO(4km) = %.3e CE(40km) = %.3e" % (f, ligo_c[i], ce_c[i])) + print(" amplitude-only ||F(f)|-|F(0)||/|F(0)| (pattern-SHAPE distortion, physical):") + for i, f in enumerate(freqs): + print(" f=%5.0f Hz : LIGO(4km) = %.3e CE(40km) = %.3e (CE/LIGO ~%.0fx)" + % (f, ligo_a[i], ce_a[i], ce_a[i] / max(ligo_a[i], 1e-30))) + # LIGO amplitude distortion is sub-percent (tiny); CE is tens of percent (>> LIGO). + assert ligo_a[0] < 1e-2, "LIGO 1kHz amplitude change unexpectedly large: %g" % ligo_a[0] + assert ligo_a[1] < 2e-2, "LIGO 2kHz amplitude change unexpectedly large: %g" % ligo_a[1] + assert ce_a[0] > 3e-2, "CE 1kHz amplitude change unexpectedly small: %g" % ce_a[0] + assert ce_a[1] > ligo_a[1] * 30, "CE should be >> LIGO at 2 kHz" + return ligo_a, ce_a + + +def test_ce_is_100x_longer_effect(): + """Finite-size amplitude distortion scales ~ (f L / c)^2 ; 10x arm -> ~10^2x effect.""" + f = 2000.0 + _, ligo_a = _fractional_change("H1", 3994.5, [f]) + _, ce_a = _fractional_change("H1", 40000.0, [f]) + ratio = ce_a[0] / max(ligo_a[0], 1e-30) + print("(C') CE/LIGO amplitude-distortion ratio at 2 kHz = %.1f (expect ~10^2 for 10x arm)" + % ratio) + assert 30 < ratio < 250, "unexpected CE/LIGO scaling: %g" % ratio + + +if __name__ == "__main__": + print("=" * 78) + wA = test_long_wavelength_limit_matches_lal() + test_zero_frequency_is_real() + print("-" * 78) + test_single_arm_null_at_fsr_for_transverse() + test_single_arm_dip_structure() + test_fsr_scale_departure() + print("-" * 78) + test_in_band_magnitude_ligo_vs_ce() + test_ce_is_100x_longer_effect() + print("=" * 78) + print("ALL SLOWROT FREQ-RESPONSE CHECKS PASSED (worst f=0 residual %.3e)" % wA) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_likelihood.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_likelihood.py new file mode 100644 index 000000000..d83001403 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_likelihood.py @@ -0,0 +1,295 @@ +""" +Validate the finite-size (frequency-dependent response) likelihood (Thrust-2 route (b)). + +Builds an EXACT finite-size detector-strain injection from the SAME modes +(internal_hlm_generator -> IFFT), h_k(f) = F_+(f;sky) h_+(f) + F_x(f;sky) h_x(f) with the +VALIDATED antenna_response_fd, using a 40-km CE arm. Then: + + (V1) REDUCE TO BASELINE : with L -> 0 the finite-size NoLoop likelihood + (DiscreteFactoredLogLikelihoodFreqResponseNoLoop) == the MAINTAINED baseline + DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop (NOT the older scalar + SingleDetectorLogLikelihood/FactoredLogLikelihood). + (V2) GROUND TRUTH : the finite-size NoLoop likelihood reconstructs the finite-size + injection -- deficit vs 0.5 converges as Qmax grows. NOTE this default config + (1.6+1.4 BNS, fmax=1024) is a WEAK demonstration: the model-distinguishing part of + the response (beyond the common e^{-i2pi f L/c} light-crossing delay, which the + baseline absorbs by time-marginalization) is only ~0.1% in-band, so finite-size ~= + baseline here and no gain is expected. The meaningful demonstration is run_strong (V4). + (V3) CAUCHY-SCHWARZ : every lnL <= 0.5 (network). A violation => a wrong term. + (V4) POSITIVE CONTROL : run_strong -- in an in-band-effect config (15+13 Msun, fmax=2000, + loud CE) the finite-size likelihood beats the long-wavelength baseline by a large, + ASSERTED margin. This is what validates the finite-L assembly (V1 only tests L->0). + +All comparisons use the maintained NoLoop path, time-MAXIMIZED (NoLoop point eval is off-peak), +with cubic sub-bin time interpolation. Measured: V1 |diff| ~3e-9; V2 (BNS/1024, weak) baseline +deficit 7.76 -> finite-size 7.80 (no gain, as expected); V3 bound respected; V4 (15+13/2000) +baseline deficit 55.8 -> finite-size 16.9, GAIN +38.9 nats (Qmax=6; residual is series +truncation at fL/c~0.27). +""" +from __future__ import print_function, division +import sys +import numpy as np +import lal, lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.slowrot_freqresponse as sfr +import RIFT.likelihood.factored_likelihood_freqresponse as flfr + +event_time = 1e9; Lmax = 2; t_window = 0.1; det = 'H1' +psd = lalsim.SimNoisePSDaLIGOZeroDetHighPower +apx = lalsim.GetApproximantFromString("IMRPhenomD") +L_CE = 40000.0 # 40-km Cosmic Explorer arm (geometry override) +SCALE = 40. # loudness: data-mode distance = distMpcRef/SCALE + + +def _ifft(hf_d): + out = {} + for lm, hf in hf_d.items(): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ht = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ht, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)); out[lm] = ht + return out + + +def _fwd_fd(re_series, epoch, dt, N): + ht = lal.CreateCOMPLEX16TimeSeries("h", epoch, 0., dt, lal.DimensionlessUnit, N) + ht.data.data[:] = re_series[:N] + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., 1. / dt / N, lsu.lsu_HertzUnit, N) + lal.COMPLEX16TimeFreqFFT(hf, ht, lal.CreateForwardCOMPLEX16FFTPlan(N, 0)); return hf + + +def _rev_td(hf): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ht = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ht, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)); return ht + + +from scipy.interpolate import InterpolatedUnivariateSpline + + +def _peak(lt): + lt = np.asarray(lt, float); x = np.arange(len(lt)) + sp = InterpolatedUnivariateSpline(x, lt, k=4) + xs = np.linspace(0, len(lt) - 1, len(lt) * 32) + return float(np.max(sp(xs))) + + +def run(Qlist=(0, 2, 4, 6, 8)): + fmin, fmax, deltaT, seglen = 30., 1024., 1. / 2048., 16. + deltaF = 1. / seglen; fNyq = 1. / 2. / deltaT; N = int(round(seglen / deltaT)) + RA, DEC, PSI, INCL, PHIREF = 1.2, 0.3, 0.5, 0.4, 0.0 + DLOUD = fl.distMpcRef * 1e6 * lsu.lsu_PC / SCALE + + Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=1.6 * lal.MSUN_SI, m2=1.4 * lal.MSUN_SI, detector=det, dist=200e6 * lal.PC_SI, + deltaT=deltaT, tref=event_time, deltaF=deltaF) + Psig.approx = apx + Pm = Psig.manual_copy(); Pm.dist = DLOUD + + # --- base FD modes ONCE; build detector strain from the SAME modes --- + hlms_fd, _ = fl.internal_hlm_generator(Pm, Lmax, verbose=False, quiet=True) + hlmsT = _ifft(hlms_fd) + lm0 = list(hlmsT.keys())[0] + nn = hlmsT[lm0].data.length; dt = hlmsT[lm0].deltaT; e0 = float(hlmsT[lm0].epoch) + + # complex Sigma(t) = sum_lm Y_lm h_lm(t) on intrinsic axis (epoch e0) + Sig = np.zeros(nn, complex) + for lm in hlmsT: + Sig += hlmsT[lm].data.data * lal.SpinWeightedSphericalHarmonic(INCL, -PHIREF, -2, lm[0], lm[1]) + + # geometric arrival delay for this sky (SAME function the likelihood uses) + dt_geo = float(fl.ComputeArrivalTimeAtDetector(det, RA, DEC, event_time)) - event_time + data_epoch = lal.LIGOTimeGPS(e0 + event_time) + + # Sigma delayed by the geometric delay: e^{-i2pi f dt_geo} in FD (packed fvals) + fvals = flfr.evaluate_fvals_from_length(nn, deltaF) + Sig_fd = _fwd_fd(Sig, data_epoch, dt, nn).data.data # FD of complex Sigma(t) + Sig_del_fd = Sig_fd * np.exp(-1j * 2.0 * np.pi * fvals * dt_geo) + Sig_del = _rev_td(_wrap_fd(Sig_del_fd, data_epoch, deltaF)).data.data # complex TD, delayed + hplus_t = np.real(Sig_del); hcross_t = -np.imag(Sig_del) # real polarization series + + hplus_f = _fwd_fd(hplus_t, data_epoch, dt, nn).data.data + hcross_f = _fwd_fd(hcross_t, data_epoch, dt, nn).data.data + gmst_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) + + def make_data(Larm, finite=True): + if finite: + Fp, Fc = sfr.antenna_response_fd(det, RA, DEC, PSI, fvals, gmst=gmst_ev, L_arm=Larm) + else: + F0p, F0c = lal.ComputeDetAMResponse( + lalsim.DetectorPrefixToLALDetector(det).response, RA, DEC, PSI, gmst_ev) + Fp = np.full(nn, F0p, complex); Fc = np.full(nn, F0c, complex) + hk_f = Fp * hplus_f + Fc * hcross_f + return _wrap_fd(hk_f, data_epoch, deltaF) + + data = make_data(L_CE, finite=True) + data_dict = {det: data}; psd_dict = {det: psd} + IPc = lsu.ComplexIP(fmin, fmax, fNyq, data.deltaF, psd, True, False, 0.) + HALF_DD = 0.5 * IPc.ip(data, data).real + print("finite-size CE injection (L=%.0f km): seglen=%.0fs 0.5 = %.5f" + % (L_CE / 1e3, seglen, HALF_DD)) + + # --- extrinsic evaluation setup --- + Pv = Psig.manual_copy() + for k, v in [('phi', RA), ('theta', DEC), ('incl', INCL), ('phiref', PHIREF), + ('psi', PSI), ('dist', DLOUD)]: + setattr(Pv, k, np.ones(1) * v) + Pv.tref = event_time; Pv.deltaT = deltaT + Nw = int(0.03 / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + + # --- baseline standard RIFT likelihood (long-wavelength) --- + ri, ct, ctV, rho, snr, rest = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lkB = {}; rAB = {}; cuB = {}; cvB = {}; epB = {} + for d in data_dict: + a, b, c, U, V, rA, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho[d].keys()), None, rho[d], ct[d], ctV[d]) + lkB[d] = a; rAB[d] = rA; cuB[d] = U; cvB[d] = V; epB[d] = e + lnL_base = _peak(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lkB, rAB, cuB, cvB, epB, Lmax=Lmax, xpy=np, return_lnLt=True,time_interp='cubic')[0]) + print(" baseline (long-wavelength) lnL = %.5f deficit = %.5f [<= 0.5 ? %s]" + % (lnL_base, HALF_DD - lnL_base, lnL_base <= HALF_DD + 1e-6)) + + # --- finite-size likelihood, scan Qmax --- + print("\n(V2) finite-size likelihood vs 0.5 as Qmax grows:") + worst_excess = -1e99 + for Qmax in Qlist: + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + Qmax=Qmax, L_arm=L_CE, analyticPSD_Q=True, verbose=False, quiet=True, + skip_interpolation=True) + lk, rbp, ubp, vbp, ep = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + lnLt = flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, bk[4], lk, rbp, ubp, vbp, ep, Lmax=Lmax, array_output=True,time_interp='cubic')[0] + lnL = _peak(lnLt) + excess = lnL - HALF_DD + worst_excess = max(worst_excess, excess) + print(" Qmax=%2d (basis %d): lnL=%.5f deficit=%.5f excess vs 0.5=%+.2e %s" + % (Qmax, Qmax + 2, lnL, HALF_DD - lnL, excess, + "" if excess <= 1e-6 else " <-- BOUND VIOLATION")) + + # --- (V1) reduce to baseline with L -> 0 --- + print("\n(V1) reduce-to-baseline: finite-size(L->0) vs standard RIFT (same data):") + # rebuild data with the LONG-WAVELENGTH (constant F) response so both agree + data_lwl = make_data(L_CE, finite=False) + dd_lwl = {det: data_lwl} + ri2, ct2, ctV2, rho2, snr2, rest2 = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, dd_lwl, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lkB2 = {}; rAB2 = {}; cuB2 = {}; cvB2 = {}; epB2 = {} + for d in dd_lwl: + a, b, c, U, V, rA, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho2[d].keys()), None, rho2[d], ct2[d], ctV2[d]) + lkB2[d] = a; rAB2[d] = rA; cuB2[d] = U; cvB2[d] = V; epB2[d] = e + lnL_base2 = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lkB2, rAB2, cuB2, cvB2, epB2, Lmax=Lmax, xpy=np, return_lnLt=True,time_interp='cubic')[0] + for Qmax in [0, 4]: + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + event_time, t_window, Psig, dd_lwl, psd_dict, Lmax, fmax, + Qmax=Qmax, L_arm=1e-6, analyticPSD_Q=True, verbose=False, quiet=True, + skip_interpolation=True) + lk, rbp, ubp, vbp, ep = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + lnLt = flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, bk[4], lk, rbp, ubp, vbp, ep, Lmax=Lmax, array_output=True,time_interp='cubic')[0] + worst = np.max(np.abs(lnLt - lnL_base2)) + print(" Qmax=%d L->0: max|lnL_fr - lnL_baseline| over window = %.3e" % (Qmax, worst)) + + print("\n(V3) worst excess over 0.5 across all finite-size evals = %+.3e (%s)" + % (worst_excess, "BOUND RESPECTED" if worst_excess <= 1e-6 else "VIOLATED")) + return HALF_DD, lnL_base, worst_excess + + +def _wrap_fd(arr, epoch, deltaF): + n = len(arr) + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., deltaF, lsu.lsu_HertzUnit, n) + hf.data.data[:] = arr + return hf + + +def run_strong(fmax=2000., seglen=8., SCALE_strong=40., m1=15., m2=13., Qmax=6): + """(V4) POSITIVE CONTROL: in a config where the direction-dependent finite-size effect + is actually in-band with SNR behind it (heavier system -> high-f power, higher fmax, + loud), the finite-size likelihood must reconstruct the finite-size injection much better + than the long-wavelength baseline. + + This is the meaningful demonstration of the finite-size likelihood. The default `run()` + at BNS/fmax=1024 is a WEAK config: there the model-distinguishing effect (beyond the + common e^{-i2pi f L/c} light-crossing delay, which the baseline absorbs by + time-marginalization) is ~0.1% in-band -- below the peak-resolution floor -- so + finite-size ~= baseline there and NO gain is expected (or asserted). + + Measured (H1, 40-km CE arm, 15+13 Msun, fmax=2000, loud): baseline deficit ~55.8, + finite-size deficit ~16.9 -> gain ~+38.9 nats (Qmax=6; residual is series truncation at + fL/c~0.27, shrinks with Qmax). + """ + event_time = 1e9; t_window = 0.1; det = 'H1'; L_arm = L_CE + deltaT = 1. / (2. * fmax); fmin = 30. + deltaF = 1. / seglen; fNyq = 1. / 2. / deltaT + RA, DEC, PSI, INCL, PHIREF = 1.2, 0.3, 0.5, 0.4, 0.0 + DLOUD = fl.distMpcRef * 1e6 * lsu.lsu_PC / SCALE_strong + + Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=m1 * lal.MSUN_SI, m2=m2 * lal.MSUN_SI, detector=det, dist=200e6 * lal.PC_SI, + deltaT=deltaT, tref=event_time, deltaF=deltaF) + Psig.approx = apx + Pm = Psig.manual_copy(); Pm.dist = DLOUD + + hlms_fd, _ = fl.internal_hlm_generator(Pm, Lmax, verbose=False, quiet=True) + hlmsT = _ifft(hlms_fd); lm0 = list(hlmsT.keys())[0] + nn = hlmsT[lm0].data.length; dt = hlmsT[lm0].deltaT; e0 = float(hlmsT[lm0].epoch) + Sig = np.zeros(nn, complex) + for lm in hlmsT: + Sig += hlmsT[lm].data.data * lal.SpinWeightedSphericalHarmonic(INCL, -PHIREF, -2, lm[0], lm[1]) + dt_geo = float(fl.ComputeArrivalTimeAtDetector(det, RA, DEC, event_time)) - event_time + data_epoch = lal.LIGOTimeGPS(e0 + event_time) + fvals = flfr.evaluate_fvals_from_length(nn, deltaF) + Sig_fd = _fwd_fd(Sig, data_epoch, dt, nn).data.data + Sig_del = _rev_td(_wrap_fd(Sig_fd * np.exp(-1j * 2 * np.pi * fvals * dt_geo), data_epoch, deltaF)).data.data + hpf = _fwd_fd(np.real(Sig_del), data_epoch, dt, nn).data.data + hcf = _fwd_fd(-np.imag(Sig_del), data_epoch, dt, nn).data.data + gmst = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) + Fp, Fc = sfr.antenna_response_fd(det, RA, DEC, PSI, fvals, gmst=gmst, L_arm=L_arm) + data = _wrap_fd(Fp * hpf + Fc * hcf, data_epoch, deltaF) + dd = {det: data}; pdd = {det: psd} + IPc = lsu.ComplexIP(fmin, fmax, fNyq, data.deltaF, psd, True, False, 0.) + HALF = 0.5 * IPc.ip(data, data).real + + Pv = Psig.manual_copy() + for k, v in [('phi', RA), ('theta', DEC), ('incl', INCL), ('phiref', PHIREF), + ('psi', PSI), ('dist', DLOUD)]: + setattr(Pv, k, np.ones(1) * v) + Pv.tref = event_time; Pv.deltaT = deltaT + Nw = int(0.03 / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + + ri, ct, ctV, rho, snr, rest = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, dd, pdd, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lk = {}; rA = {}; cu = {}; cv = {}; ep = {} + for d in dd: + a, b, c, U, V, r, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho[d].keys()), None, rho[d], ct[d], ctV[d]) + lk[d] = a; rA[d] = r; cu[d] = U; cv[d] = V; ep[d] = e + base = _peak(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lk, rA, cu, cv, ep, Lmax=Lmax, xpy=np, return_lnLt=True, time_interp='cubic')[0]) + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + event_time, t_window, Psig, dd, pdd, Lmax, fmax, Qmax=Qmax, L_arm=L_arm, + analyticPSD_Q=True, verbose=False, quiet=True, skip_interpolation=True) + lkf, rbp, ubp, vbp, epf = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + fin = _peak(flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, bk[4], lkf, rbp, ubp, vbp, epf, Lmax=Lmax, array_output=True, time_interp='cubic')[0]) + gain = (HALF - base) - (HALF - fin) + print("\n(V4) POSITIVE CONTROL %g+%g Msun, fmax=%g, CE %gkm, loud:" % (m1, m2, fmax, L_arm / 1e3)) + print(" 0.5=%.1f baseline_deficit=%.3f finite_deficit=%.3f GAIN=%+.3f nats" + % (HALF, HALF - base, HALF - fin, gain)) + assert base <= HALF + 1e-6 and fin <= HALF + 1e-6, "Cauchy-Schwarz bound violated" + assert gain > 10.0, "finite-size failed to beat baseline where the effect is in-band: gain=%g" % gain + return HALF, base, fin, gain + + +if __name__ == "__main__": + run() + run_strong() + print("\nALL SLOWROT FREQRESPONSE LIKELIHOOD CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_headtohead.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_headtohead.py new file mode 100644 index 000000000..f2930aca4 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_headtohead.py @@ -0,0 +1,178 @@ +""" +test_slowrot_headtohead : matched-seed quantitative baseline-vs-rotation comparison. + +Instead of running the full ILE twice (whose adaptive sampler makes exact seed-matching +fragile), we draw ONE fixed set of extrinsic samples from the standard priors and evaluate +BOTH the baseline vectorized (NoLoop) likelihood and the rotation (NoLoop) likelihood on the +SAME samples and the SAME time grid. Because the samples are identical, the difference is +the genuine slow-rotation effect, with zero Monte-Carlo noise in the difference. + +Checks (all on the same matched samples): + (R) REGRESSION: rotation at f_sidereal=0 == baseline, per-sample, to ~machine precision. + (P) PHYSICS: rotation at the real sidereal rate differs from baseline only by the tiny + genuine effect (short signal => negligible); report per-sample max and the + importance-weighted evidence shift ln Z_rot - ln Z_base (matched-sample, MC-noise-free). + (B) BOUND: every lnL (baseline, rot0, rotW) respects the Cauchy-Schwarz bound + 0.5 * sum_det . + +Run under the RIFT venv with this worktree first on PYTHONPATH. +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1e9 +t_window = 0.1 +Lmax = 2 +deltaT = 1. / fSample +deltaF = 1. / 4. +HARM = (-2, -1, 0, 1, 2) +DMIN, DMAX = 10.0, 1000.0 # Mpc, for the d^2 distance prior + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, + detector='H1', dist=200e6 * lal.PC_SI, deltaT=deltaT, + tref=event_time, deltaF=deltaF) + +data_dict = {} +for det in ("H1", "L1", "V1"): + P = Psig.manual_copy(); P.detector = det + data_dict[det] = lsu.non_herm_hoff(P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +def _network_half_dd(): + tot = 0.0 + for det in data_dict: + IP = lsu.ComplexIP(fmin, fmax, 1. / 2. / deltaT, deltaF, psd_dict[det], True, False, 0.) + tot += IP.ip(data_dict[det], data_dict[det]).real + return 0.5 * tot + + +def _draw_extrinsic(K, seed=20260704): + rng = np.random.RandomState(seed) + RA = rng.uniform(0, 2 * np.pi, K) + DEC = np.arcsin(rng.uniform(-1, 1, K)) + PSI = rng.uniform(0, np.pi, K) + INCL = np.arccos(rng.uniform(-1, 1, K)) + PHIREF = rng.uniform(0, 2 * np.pi, K) + # d^2 prior in [DMIN, DMAX] + u = rng.uniform(0, 1, K) + DIST = (DMIN ** 3 + u * (DMAX ** 3 - DMIN ** 3)) ** (1. / 3.) + return RA, DEC, PSI, INCL, PHIREF, DIST + + +def _make_P_vec(RA, DEC, PSI, INCL, PHIREF, DIST_Mpc): + Pv = Psig.manual_copy() + Pv.phi = RA.astype(float) + Pv.theta = DEC.astype(float) + Pv.psi = PSI.astype(float) + Pv.incl = INCL.astype(float) + Pv.phiref = PHIREF.astype(float) + Pv.dist = (DIST_Mpc * 1e6 * lsu.lsu_PC).astype(float) + Pv.tref = float(event_time) + Pv.deltaT = deltaT + return Pv + + +def _pack_baseline(): + ri, ct, ctV, rho, snr, rest = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lk = {}; rA = {}; cu = {}; cv = {}; ep = {} + for det in data_dict: + a, b, c, U, V, rArr, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho[det].keys()), None, rho[det], ct[det], ctV[det]) + lk[det] = a; rA[det] = rArr; cu[det] = U; cv[det] = V; ep[det] = e + return lk, rA, cu, cv, ep + + +def _pack_rotation(f_sidereal): + ri, ct, ctV, rho, meta = flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=f_sidereal, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=True) + lk, rbn, ubn, vbn, ep = flwr.pack_rotation_arrays(meta, rho, ct, ctV) + return meta, lk, rbn, ubn, vbn, ep + + +K = 300 +tvals = np.arange(int(2 * 0.03 / deltaT)) * deltaT - 0.03 +RA, DEC, PSI, INCL, PHIREF, DIST = _draw_extrinsic(K) +P_vec = _make_P_vec(RA, DEC, PSI, INCL, PHIREF, DIST) +HALF_DD = _network_half_dd() + + +def _lnZ(lnL): + m = np.max(lnL) + return m + np.log(np.mean(np.exp(lnL - m))) + + +# precompute once +_lkB, _rAB, _cuB, _cvB, _epB = _pack_baseline() +_metaR0, _lkR0, _rbn0, _ubn0, _vbn0, _epR0 = _pack_rotation(0.0) +_metaRW, _lkRW, _rbnW, _ubnW, _vbnW, _epRW = _pack_rotation(flwr.F_SIDEREAL) + +# Use cubic time interpolation for the sharpest per-sample regression (both paths agree +# on the same sub-bin-resolved time series, so the regression floor is set by arithmetic +# rather than by nearest-neighbour bin snapping). +TIME_INTERP = 'cubic' +lnL_base = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, P_vec, _lkB, _rAB, _cuB, _cvB, _epB, Lmax=Lmax, xpy=np, time_interp=TIME_INTERP) +lnL_rot0 = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, _metaR0, _lkR0, _rbn0, _ubn0, _vbn0, _epR0, Lmax=Lmax, time_interp=TIME_INTERP) +lnL_rotW = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, _metaRW, _lkRW, _rbnW, _ubnW, _vbnW, _epRW, Lmax=Lmax, time_interp=TIME_INTERP) + + +# NOTE: the baseline DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop uses a GLOBAL lnLmax +# offset in its time marginalization, so uniform-prior samples more than ~745 (float64 +# underflow) below the global peak return -inf. Our rotation NoLoop uses a PER-SAMPLE +# offset (always finite). So the regression is anchored on the samples where the baseline +# is finite (near the peak); the physics head-to-head uses rotation-OFF (f_sid=0, which we +# have just anchored == baseline) vs rotation-ON, both always finite. +_base = np.asarray(lnL_base); _r0 = np.asarray(lnL_rot0); _rW = np.asarray(lnL_rotW) +_finite = np.isfinite(_base) + + +def test_R_rotation_zero_rate_matches_baseline_per_sample(): + assert _finite.sum() > 0, "baseline finite nowhere -- cannot anchor" + d = np.max(np.abs(_r0[_finite] - _base[_finite])) + print("(R) regression anchor: max|lnL_rot(f_sid=0) - lnL_base| over %d finite-baseline " + "samples (of %d) = %.3e" % (int(_finite.sum()), K, d)) + assert d < 1e-6, "rotation at f_sid=0 disagrees with baseline: %g" % d + + +def test_P_physics_effect_and_evidence_shift(): + # matched-sample baseline(=f_sid=0) vs rotation(real); both finite everywhere. + d = np.max(np.abs(_rW - _r0)) + dZ = _lnZ(_rW) - _lnZ(_r0) + print("(P) matched-sample max|lnL_rot(real) - lnL_rot(0)| over %d samples = %.3e" % (K, d)) + print("(P) evidence shift ln Z_rot - ln Z_base (matched, MC-noise-free) = %+.6e" % dZ) + print("(P) [short 30+25 BBH => tiny; ln Z(f_sid=0)=%.4f ln Z(real)=%.4f]" + % (_lnZ(_r0), _lnZ(_rW))) + assert d > 0, "rotation had literally zero effect (suspicious)" + assert d < 1.0, "short-signal rotation effect unexpectedly large: %g" % d + + +def test_B_cauchy_schwarz_bound(): + worst = max(np.max(_r0), np.max(_rW)) + print("(B) max lnL (rot0/rotW) = %.4f 0.5_network = %.4f" % (worst, HALF_DD)) + assert worst <= HALF_DD + 1e-6, "lnL exceeds Cauchy-Schwarz bound 0.5: %g > %g" % (worst, HALF_DD) + + +if __name__ == "__main__": + test_R_rotation_zero_rate_matches_baseline_per_sample() + test_P_physics_effect_and_evidence_shift() + test_B_cauchy_schwarz_bound() + print("ALL SLOWROT HEAD-TO-HEAD CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py new file mode 100644 index 000000000..abb2adb0f --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py @@ -0,0 +1,180 @@ +""" +V1: validate the rotation-aware log-likelihood assembly against (a) the baseline in the +no-rotation limit and (b) an independent brute-force time-varying-response likelihood. + +Run under the RIFT venv with this worktree first on PYTHONPATH: + source ~/RIFT_develUWM/bin/activate + PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code \ + python .../RIFT/likelihood/test_slowrot_likelihood_v1.py + +Checks: + V1a (assembly algebra): with f_sidereal -> 0 the rotation lnL must equal the baseline + FactoredLogLikelihood exactly (all modulations become identity, sum_n A_tilde_n -> + F(tref)). This validates the whole harmonic contraction incl. the V-term's A_{-nu}. + V1b (rotation physics): with the real sidereal rate, the rotation lnL must equal a + brute-force Path-R likelihood that applies the FULL time-varying antenna pattern + F_k(t) (sampled from lal.ComputeDetAMResponse, independent of the A_n harmonic + decomposition) directly to the data (term1) and to the modes (term2). This validates + that Q^{(n)} is paired with the correct conj(A_tilde_n), i.e. the physics. +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1000000000.0 +t_window = 0.1 +Lmax = 2 + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, + detector='H1', dist=200e6 * lal.PC_SI, deltaT=1. / fSample, + tref=event_time, deltaF=1. / 4.) + +data_dict = {} +for det in ("H1", "L1", "V1"): + P = Psig.manual_copy(); P.detector = det + data_dict[det] = lsu.non_herm_hoff(P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + +extr = lsu.ChooseWaveformParams( + radec=True, phi=Psig.phi, theta=Psig.theta, psi=0.5, incl=0.7, phiref=0.9, + tref=event_time, dist=300e6 * lal.PC_SI) + +HARM = (-2, -1, 0, 1, 2) + + +def _precompute_rot(f_sidereal): + return flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=f_sidereal, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=False) + + +def test_V1a_reduces_to_baseline(): + rint, ct, ctV, rho, meta = _precompute_rot(f_sidereal=0.0) + lnL_rot = flwr.FactoredLogLikelihoodWithRotation(extr, rint, ct, ctV, meta, Lmax) + rint_b, ct_b, ctV_b, rho_b, _, _ = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lnL_base = fl.FactoredLogLikelihood(extr, rho_b, rint_b, ct_b, ctV_b, Lmax) + print("V1a: lnL_rot=%.10g lnL_base=%.10g |diff|=%.2e" + % (lnL_rot, lnL_base, abs(lnL_rot - lnL_base))) + assert abs(lnL_rot - lnL_base) < 1e-6 * (1 + abs(lnL_base)), \ + "rotation lnL does not reduce to baseline: %g vs %g" % (lnL_rot, lnL_base) + + +def _to_td(fs): + npts = fs.data.length + dt = 1.0 / (npts * fs.deltaF) + ts = lal.CreateCOMPLEX16TimeSeries("x", fs.epoch, 0., dt, lal.DimensionlessUnit, npts) + lal.COMPLEX16FreqTimeFFT(ts, fs, lal.CreateReverseCOMPLEX16FFTPlan(npts, 0)) + return ts + + +def _sample_F(det, epoch, npts, dt): + resp = lalsim.DetectorPrefixToLALDetector(det).response + RA, DEC, psi = extr.phi, extr.theta, extr.psi + gmst_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) + tabs = float(epoch) + np.arange(npts) * dt + nc = 128 + tc = np.linspace(tabs[0], tabs[-1], nc) + Fc = np.empty(nc, dtype=complex) + for i, tt in enumerate(tc): + gmst = gmst_ev + flwr.OMEGA_EARTH * (tt - event_time) + fp, fx = lal.ComputeDetAMResponse(resp, RA, DEC, psi, gmst) + Fc[i] = fp + 1j * fx + return (np.interp(tabs, tc, Fc.real) + 1j * np.interp(tabs, tc, Fc.imag)) + + +def _det_window(det, data, hlms): + t_det = fl.ComputeArrivalTimeAtDetector(det, extr.phi, extr.theta, extr.tref) + rho_epoch = data.epoch - hlms[list(hlms.keys())[0]].epoch + t_shift = float(float(t_det) - float(t_window) - float(rho_epoch)) + N_shift = int(t_shift / Psig.deltaT + 0.5) + N_window = int(2 * t_window / Psig.deltaT) + t = np.arange(N_window) * Psig.deltaT + float(rho_epoch + N_shift * Psig.deltaT) + return t_det, N_shift, N_window, t + + +def _pathR_lnL(modes): + Pm = Psig.manual_copy() + Pm.dist = fl.distMpcRef * 1e6 * lsu.lsu_PC + Pm.deltaF = data_dict['H1'].deltaF + hlms, hlms_conj = fl.internal_hlm_generator(Pm, Lmax, verbose=False, quiet=True) + modes = [m for m in modes if m in hlms] + Ylms = fl.ComputeYlms(Lmax, extr.incl, -extr.phiref, selected_modes=modes) + distMpc = extr.dist / (lsu.lsu_PC * 1e6) + invDistMpc = fl.distMpcRef / distMpc + lnL = 0. + for det in data_dict: + data = data_dict[det] + psd = psd_dict[det] + npts = data.data.length + dt = 1.0 / (npts * data.deltaF) + fNyq = 1. / 2. / Psig.deltaT + t_det, N_shift, N_window, t = _det_window(det, data, hlms) + F_data = _sample_F(det, float(data.epoch), npts, dt) + d_td = _to_td(data) + dtld = lal.CreateCOMPLEX16TimeSeries("dF", data.epoch, 0., dt, + lal.DimensionlessUnit, npts) + dtld.data.data[:] = np.conj(F_data) * d_td.data.data + dtld_f = lsu.DataFourier(dtld) + rho = fl.ComputeModeIPTimeSeries(hlms, dtld_f, psd, Psig.fmin, fmax, fNyq, + N_shift, N_window, True, False, 0.) + rint = fl.InterpolateRholms(rho, t, verbose=False) + term1 = 0. + for m in modes: + term1 += np.conj(Ylms[m]) * rint[m](float(t_det)) + term1 = np.real(term1) * invDistMpc + IP = lsu.ComplexIP(Psig.fmin, fmax, fNyq, data.deltaF, psd, True, False, 0.) + modF = {} + modFc = {} + for m in modes: + h_td = _to_td(hlms[m]) + # template modes carry the INTRINSIC epoch (~0); their absolute time when + # placed at the event is event_time + (hlms.epoch + j*dt), so sample F there. + F_mode = _sample_F(det, event_time + float(hlms[m].epoch), hlms[m].data.length, dt) + prod = lal.CreateCOMPLEX16TimeSeries("Fh", hlms[m].epoch, 0., dt, + lal.DimensionlessUnit, hlms[m].data.length) + prod.data.data[:] = F_mode * h_td.data.data + modF[m] = lsu.DataFourier(prod) + prodc = lal.CreateCOMPLEX16TimeSeries("Fhc", hlms[m].epoch, 0., dt, + lal.DimensionlessUnit, hlms[m].data.length) + prodc.data.data[:] = np.conj(F_mode * h_td.data.data) + modFc[m] = lsu.DataFourier(prodc) + term2 = 0. + for p1 in modes: + for p2 in modes: + U = IP.ip(modF[p1], modF[p2]) + V = IP.ip(modFc[p1], modF[p2]) + term2 += U * np.conj(Ylms[p1]) * Ylms[p2] + V * Ylms[p1] * Ylms[p2] + term2 = -np.real(term2) / 4. / (distMpc / fl.distMpcRef) ** 2 + lnL += term1 + term2 + return lnL + + +def test_V1b_matches_bruteforce_rotation(): + rint, ct, ctV, rho, meta = _precompute_rot(f_sidereal=flwr.F_SIDEREAL) + lnL_rot = flwr.FactoredLogLikelihoodWithRotation(extr, rint, ct, ctV, meta, Lmax) + modes = list(rint[list(rint.keys())[0]][(0, 0)].keys()) + lnL_R = _pathR_lnL(modes) + print("V1b: lnL_rot=%.10g lnL_pathR=%.10g |diff|=%.2e" + % (lnL_rot, lnL_R, abs(lnL_rot - lnL_R))) + assert abs(lnL_rot - lnL_R) < 1e-4 * (1 + abs(lnL_R)), \ + "rotation lnL disagrees with brute force: %g vs %g" % (lnL_rot, lnL_R) + + +if __name__ == "__main__": + test_V1a_reduces_to_baseline() + test_V1b_matches_bruteforce_rotation() + print("ALL SLOWROT V1 LIKELIHOOD CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop.py new file mode 100644 index 000000000..b6d200833 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop.py @@ -0,0 +1,244 @@ +""" +test_slowrot_noloop : validate the vectorized ("NoLoop") slow-rotation likelihood path. + +Run under the RIFT venv with this worktree first on PYTHONPATH: + source ~/RIFT_develUWM/bin/activate + export PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code + python RIFT/likelihood/test_slowrot_noloop.py + +Checks: + (A) VECTORIZED-vs-SCALAR ANTENNA: antenna_harmonics_vector must match the scalar + antenna_harmonics elementwise over many random (dec, psi), for H1/L1/V1. + (B) PRIMARY RIGOROUS: at f_sidereal=0 (all sidereal harmonics degenerate), the + rotation-aware NoLoop lnL_t array must match the baseline NoLoop lnL_t array to + ~machine precision -- this validates the whole vectorized harmonic contraction + (packing, antenna, indexing, U/V terms) against the already-validated baseline path. + (C) SANITY: with the real sidereal rate, the rotation NoLoop lnL differs from the + f_sidereal=0 case by a nonzero, finite amount (modulation is active). For this + particular high-SNR, near-noiseless toy configuration the shift is NOT small + (order tens of percent) -- see the long comment above test_C for why this is + real, validated physics (cross-checked against test_slowrot_likelihood_v1's + independent brute-force Path-R likelihood) and not a bug. +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +import RIFT.likelihood.slowrot_response as srr + +# --------------------------------------------------------------------------- +# Pre-existing environment issue (NOT introduced by this test, and NOT fixed by +# editing factored_likelihood.py, which must not be modified): when +# RIFT_LOWLATENCY is set in the environment (as it is in this venv), or numba's +# @vectorize decoration otherwise fails at import time, factored_likelihood.py +# falls back to a plain scalar `def lalylm(th,ph,s,l,m): return +# lal.SpinWeightedSphericalHarmonic(...)`. That scalar function is called by +# fl.ComputeYlmsArrayVector with numpy ARRAY arguments, which +# lal.SpinWeightedSphericalHarmonic cannot accept ("argument 1 of type +# 'REAL8'"). This breaks fl.ComputeYlmsArrayVector for BOTH the baseline NoLoop +# path and the new rotation NoLoop path equally -- it is not specific to the +# rotation code added here. We work around it locally, only for this test +# process, by rebinding fl.lalylm to an elementwise-vectorized wrapper +# equivalent to the numba @vectorize branch's behavior. This does not alter +# factored_likelihood.py on disk or any of its validated numerical behavior; +# it merely makes the already-array-shaped call sites in that module work in +# this environment. +if not getattr(fl, "numba_on", True): + fl.lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1e9 +t_window = 0.1 +Lmax = 2 +deltaT = 1. / fSample +deltaF = 1. / 4. + +HARM = (-2, -1, 0, 1, 2) + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, + detector='H1', dist=200e6 * lal.PC_SI, deltaT=deltaT, + tref=event_time, deltaF=deltaF) + +data_dict = {} +for det in ("H1", "L1", "V1"): + P = Psig.manual_copy(); P.detector = det + data_dict[det] = lsu.non_herm_hoff(P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +# --------------------------------------------------------------------------- +# (A) vectorized-vs-scalar antenna harmonics +# --------------------------------------------------------------------------- +def test_A_antenna_vectorized_matches_scalar(): + rng = np.random.RandomState(20260703) + ndraw = 50 + decs = np.arcsin(rng.uniform(-1, 1, ndraw)) + psis = rng.uniform(0, np.pi, ndraw) + + worst = 0.0 + for det in ("H1", "L1", "V1"): + resp = lalsim.DetectorPrefixToLALDetector(det).response + A_vec = srr.antenna_harmonics_vector(resp, decs, psis) + for i in range(ndraw): + A_scalar = srr.antenna_harmonics(resp, decs[i], psis[i]) + for n in (-2, -1, 0, 1, 2): + d = abs(A_vec[n][i] - A_scalar[n]) + worst = max(worst, d) + print("(A) vectorized-vs-scalar antenna: worst |diff| = %.3e" % worst) + assert worst < 1e-12, "vectorized antenna_harmonics disagrees with scalar: %g" % worst + + +# --------------------------------------------------------------------------- +# (B) rotation NoLoop (f_sidereal=0) vs baseline NoLoop +# --------------------------------------------------------------------------- +def _make_P_vec(K=3): + """Build an extrinsic-parameter object with array-valued extrinsic fields + (identical value repeated K times), scalar tref/deltaT, for the NoLoop calls.""" + P_vec = Psig.manual_copy() + phi = 1.0 + theta = 0.2 + incl = 0.7 + phiref = 0.9 + psi = 0.5 + dist = 300e6 * lal.PC_SI + P_vec.phi = np.ones(K) * phi + P_vec.theta = np.ones(K) * theta + P_vec.incl = np.ones(K) * incl + P_vec.phiref = np.ones(K) * phiref + P_vec.psi = np.ones(K) * psi + P_vec.dist = np.ones(K) * dist + P_vec.tref = event_time + P_vec.deltaT = deltaT + return P_vec + + +def test_B_rotation_matches_baseline_at_zero_sidereal_rate(): + P_vec = _make_P_vec(K=3) + tvals = np.linspace(-0.05, 0.05, 200) + + # ---- baseline precompute + pack + NoLoop (return_lnLt=True gives raw lnL_t) ---- + rholms_intp_b, crossTerms_b, crossTermsV_b, rholms_b, _, _ = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + + lookupNKDict_b = {} + rholmArrayDict_b = {} + ctUArrayDict_b = {} + ctVArrayDict_b = {} + epochDict_b = {} + for det in data_dict: + pairKeys = list(rholms_b[det].keys()) + # NOTE: pass None (not the interpolant dict) for the interpolant argument -- + # PackLikelihoodDataStructuresAsArrays has a pre-existing py2-ism + # (`rholm_intpArray = range(nKeys)`, immutable in py3) that raises TypeError + # whenever that argument is truthy. We only need the array-packed pieces + # (rholmArray, ctU, ctV, epoch) for the NoLoop array path, not the interpolants. + lookupNK, lookupKeysToNumber, lookupConj, ctU, ctV, rholmArray, rholm_intpArray, epoch = \ + fl.PackLikelihoodDataStructuresAsArrays( + pairKeys, None, rholms_b[det], crossTerms_b[det], crossTermsV_b[det]) + lookupNKDict_b[det] = lookupNK + rholmArrayDict_b[det] = rholmArray + ctUArrayDict_b[det] = ctU + ctVArrayDict_b[det] = ctV + epochDict_b[det] = epoch + + lnL_t_base = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, P_vec, lookupNKDict_b, rholmArrayDict_b, ctUArrayDict_b, ctVArrayDict_b, + epochDict_b, Lmax=Lmax, xpy=np, return_lnLt=True,time_interp='cubic') + + # ---- rotation precompute (f_sidereal=0) + pack + rotation NoLoop ---- + rholms_intp_r, crossTerms_r, crossTermsV_r, rholms_r, meta = \ + flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=0.0, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=False) + + lookupNKDict_r, rho_by_n, U_by_nn, V_by_nn, epochDict_r = flwr.pack_rotation_arrays( + meta, rholms_r, crossTerms_r, crossTermsV_r) + + # time_interp='cubic' MUST match the baseline call above (see note there). + lnL_t_rot = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, meta, lookupNKDict_r, rho_by_n, U_by_nn, V_by_nn, epochDict_r, + Lmax=Lmax, array_output=True, time_interp='cubic') + + assert lnL_t_base.shape == lnL_t_rot.shape, \ + "shape mismatch: base %s vs rot %s" % (lnL_t_base.shape, lnL_t_rot.shape) + + diff = np.abs(lnL_t_base - lnL_t_rot) + worst = np.max(diff) + print("(B) baseline-vs-rotation(f_sidereal=0) lnL_t: shape=%s max|diff|=%.3e " + "(max|lnL_t_base|=%.3e)" % (lnL_t_base.shape, worst, np.max(np.abs(lnL_t_base)))) + assert worst < 1e-8, "rotation NoLoop (f_sidereal=0) does not match baseline NoLoop: %g" % worst + return lnL_t_base, lnL_t_rot + + +# --------------------------------------------------------------------------- +# (C) sanity: real Omega gives a nonzero shift relative to f_sidereal=0 +# +# NOTE on magnitude: this is a very high SNR (~SNR~120), essentially noiseless +# (analytic PSD, no noise draw) toy signal. term2 (the U/V quadratic cross term) is a +# pure template-template self-overlap, independent of the data/distance -- so its +# *fractional* sensitivity to f_sidereal is set purely by the antenna-harmonic phase +# structure at this sky location, not by SNR (rescaling distance leaves the fractional +# shift unchanged; verified numerically). For this configuration the shift is order +# unity in relative terms (tens of percent), NOT a small perturbation, and this has been +# independently cross-checked: flwr.FactoredLogLikelihoodWithRotation (the validated +# scalar assembly) at f_sidereal=flwr.F_SIDEREAL agrees with test_slowrot_likelihood_v1's +# V1b brute-force Path-R likelihood (built from scratch using lal.ComputeDetAMResponse +# sampled directly, with NO harmonic decomposition at all) to |diff|=1.58e-09. So a large +# relative shift here reflects real, validated physics of this particular high-SNR toy +# configuration, not a bug -- we therefore only assert the shift is nonzero and finite, +# not that it is small. +# --------------------------------------------------------------------------- +def test_C_real_sidereal_rate_gives_nonzero_shift(): + P_vec = _make_P_vec(K=3) + tvals = np.linspace(-0.05, 0.05, 200) + + # f_sidereal = 0 case (reuse rotation precompute machinery) + rholms_intp_0, crossTerms_0, crossTermsV_0, rholms_0, meta_0 = \ + flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=0.0, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=False) + lookupNKDict_0, rho_by_n_0, U_by_nn_0, V_by_nn_0, epochDict_0 = flwr.pack_rotation_arrays( + meta_0, rholms_0, crossTerms_0, crossTermsV_0) + lnL_0 = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, meta_0, lookupNKDict_0, rho_by_n_0, U_by_nn_0, V_by_nn_0, epochDict_0, + Lmax=Lmax, array_output=False) + + # real sidereal rate + rholms_intp_w, crossTerms_w, crossTermsV_w, rholms_w, meta_w = \ + flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=flwr.F_SIDEREAL, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=False) + lookupNKDict_w, rho_by_n_w, U_by_nn_w, V_by_nn_w, epochDict_w = flwr.pack_rotation_arrays( + meta_w, rholms_w, crossTerms_w, crossTermsV_w) + lnL_w = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, meta_w, lookupNKDict_w, rho_by_n_w, U_by_nn_w, V_by_nn_w, epochDict_w, + Lmax=Lmax, array_output=False) + + diff = lnL_w - lnL_0 + rel = np.abs(diff) / (1.0 + np.abs(lnL_0)) + print("(C) lnL(f_sidereal=0) = %s" % np.array2string(lnL_0, precision=10)) + print("(C) lnL(f_sidereal=F_SIDEREAL) = %s" % np.array2string(lnL_w, precision=10)) + print("(C) diff = %s |rel| = %s" % (np.array2string(diff, precision=6), + np.array2string(rel, precision=6))) + assert np.all(np.isfinite(lnL_w)), "non-finite lnL at real sidereal rate: %s" % lnL_w + assert np.all(np.abs(diff) > 0), "expected a nonzero shift from Earth rotation: %s" % diff + + +if __name__ == "__main__": + test_A_antenna_vectorized_matches_scalar() + test_B_rotation_matches_baseline_at_zero_sidereal_rate() + test_C_real_sidereal_rate_gives_nonzero_shift() + print("ALL SLOWROT NOLOOP CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py new file mode 100644 index 000000000..675d4cf44 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py @@ -0,0 +1,87 @@ +""" +test_slowrot_noloop_bruteforce : the definitive rotation-physics validation. + +The vectorized rotation NoLoop lnL_t (real sidereal rate) is compared, over the full +time window, to an INDEPENDENT brute-force likelihood that applies the true time-varying +antenna pattern F_k(t) -- sampled directly from lal.ComputeDetAMResponse -- to the data +(term1) and to the modes (term2), reusing RIFT's own overlaps. This confirms both that +the vectorized harmonic contraction is correct AND that the (large, at high SNR) shift the +sidereal rotation induces in the marginalized lnL is genuine physics, not an artifact. + +Run: source ~/RIFT_develUWM/bin/activate; + PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code python +""" +import numpy as np, lal, lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +fmin=30.;fmax=1700.;event_time=1e9;t_window=0.1;Lmax=2;deltaT=1/4096.;deltaF=1/4.;fNyq=1/2./deltaT +HARM=(-2,-1,0,1,2); OM=flwr.OMEGA_EARTH +Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=0.3,phiref=0.0,theta=0.2,phi=1.0,psi=0.4, + m1=30*lal.MSUN_SI,m2=25*lal.MSUN_SI,detector='H1',dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF) +data_dict={} +for det in ("H1","L1","V1"): + P=Psig.manual_copy();P.detector=det;data_dict[det]=lsu.non_herm_hoff(P) +psd_dict={det:lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} +RA,DEC,PSI,INCL,PHIREF,DIST=1.0,0.2,0.5,0.7,0.9,300e6*lal.PC_SI +gmst_ev=float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) +def to_td(fs): + n=fs.data.length;dt=1./(n*fs.deltaF);ts=lal.CreateCOMPLEX16TimeSeries("x",fs.epoch,0.,dt,lal.DimensionlessUnit,n) + lal.COMPLEX16FreqTimeFFT(ts,fs,lal.CreateReverseCOMPLEX16FFTPlan(n,0));return ts +def Fsample(det,epoch,n,dt): + resp=lalsim.DetectorPrefixToLALDetector(det).response + t=float(epoch)+np.arange(n)*dt + return np.array([ (lambda a,b:a+1j*b)(*lal.ComputeDetAMResponse(resp,RA,DEC,PSI,gmst_ev+OM*(tt-event_time))) for tt in t]) +# ---- brute force lnL_t over the standard window (true time-varying F) ---- +Pm=Psig.manual_copy();Pm.dist=fl.distMpcRef*1e6*lsu.lsu_PC;Pm.deltaF=deltaF +hlms,hlms_conj=fl.internal_hlm_generator(Pm,Lmax,verbose=False,quiet=True) +Ylms=fl.ComputeYlms(Lmax,INCL,-PHIREF,selected_modes=list(hlms.keys())) +distMpc=DIST/(lsu.lsu_PC*1e6);invD=fl.distMpcRef/distMpc +npts=400 +def bf_lnLt(det): + data=data_dict[det];psd=psd_dict[det];n=data.data.length;dt=1./(n*data.deltaF) + t_det=fl.ComputeArrivalTimeAtDetector(det,RA,DEC,event_time) + rho_epoch=data.epoch-hlms[list(hlms.keys())[0]].epoch + t_shift=float(float(t_det)-float(t_window)-float(rho_epoch));N_shift=int(t_shift/deltaT+0.5);N_window=int(2*t_window/deltaT) + tgrid=np.arange(N_window)*deltaT+float(rho_epoch+N_shift*deltaT) + Fd=Fsample(det,float(data.epoch),n,dt);dtd=to_td(data) + df=lal.CreateCOMPLEX16TimeSeries("dF",data.epoch,0.,dt,lal.DimensionlessUnit,n);df.data.data[:]=np.conj(Fd)*dtd.data.data + rr=fl.ComputeModeIPTimeSeries(hlms,lsu.DataFourier(df),psd,fmin,fmax,fNyq,N_shift,N_window,True,False,0.) + ri=fl.InterpolateRholms(rr,tgrid,verbose=False) + modes=list(ri.keys()) + # window aligned like NoLoop + ifirst=int(round((float(t_det)-0.02-float(rr[list(rr.keys())[0]].epoch))/deltaT)+0.5) + tsel=np.array([float(rr[list(rr.keys())[0]].epoch)+(ifirst+j)*deltaT for j in range(npts)]) + term1=np.zeros(npts,dtype=complex) + for m in modes: + term1+=np.conj(Ylms[m])*np.array([ri[m](tt) for tt in tsel]) + term1=term1.real*invD + IP=lsu.ComplexIP(fmin,fmax,fNyq,data.deltaF,psd,True,False,0.) + modF={};modC={} + for m in modes: + htd=to_td(hlms[m]);Fm=Fsample(det,event_time+float(hlms[m].epoch),hlms[m].data.length,dt) + pr=lal.CreateCOMPLEX16TimeSeries("Fh",hlms[m].epoch,0.,dt,lal.DimensionlessUnit,hlms[m].data.length);pr.data.data[:]=Fm*htd.data.data;modF[m]=lsu.DataFourier(pr) + pc=lal.CreateCOMPLEX16TimeSeries("Fc",hlms[m].epoch,0.,dt,lal.DimensionlessUnit,hlms[m].data.length);pc.data.data[:]=np.conj(Fm*htd.data.data);modC[m]=lsu.DataFourier(pc) + t2=0j + for p1 in modes: + for p2 in modes: + t2+=IP.ip(modF[p1],modF[p2])*np.conj(Ylms[p1])*Ylms[p2]+IP.ip(modC[p1],modF[p2])*Ylms[p1]*Ylms[p2] + t2=-t2.real/4./(distMpc/fl.distMpcRef)**2 + return term1+t2 +bf=sum(bf_lnLt(det) for det in data_dict) +m=np.max(bf);bf_marg=m+np.log(np.trapz(np.exp(bf-m),dx=deltaT)) +# ---- vec rotation, real Omega, same window ---- +rint,ct,ctV,rho,meta=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=HARM,p_max=0,f_sidereal=flwr.F_SIDEREAL,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=False) +lk,rbn,ubn,vbn,ep=flwr.pack_rotation_arrays(meta,rho,ct,ctV) +Pv=Psig.manual_copy() +for k,v in [('phi',RA),('theta',DEC),('incl',INCL),('phiref',PHIREF),('psi',PSI),('dist',DIST)]: setattr(Pv,k,np.ones(1)*v) +Pv.tref=event_time;Pv.deltaT=deltaT +tvals=np.arange(npts)*deltaT-0.02 +vec=flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,meta,lk,rbn,ubn,vbn,ep,Lmax=Lmax,array_output=True)[0] +mv=np.max(vec);vec_marg=mv+np.log(np.trapz(np.exp(vec-mv),dx=deltaT)) +worst = float(np.max(np.abs(vec - bf))) +print("brute-force(real F(t)) : peak=%.4f marg=%.4f" % (np.max(bf), bf_marg)) +print("vec rotation : peak=%.4f marg=%.4f" % (np.max(vec), vec_marg)) +print("max|vec_lnL_t - bruteforce_lnL_t| over window = %.3e" % worst) +assert worst < 1e-6, "vectorized rotation NoLoop disagrees with brute-force time-varying response: %g" % worst +print("PASS: vectorized rotation NoLoop == brute-force time-varying-response likelihood") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB.py new file mode 100644 index 000000000..a60cf89eb --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB.py @@ -0,0 +1,66 @@ +""" +test_slowrot_pathB : Path B (delay-derivative) sanity checks. + + (1) scalar Path B (p_max=1) at f_sidereal=0 reduces EXACTLY to the baseline + FactoredLogLikelihood (the p>=1 coefficient sums vanish at Omega=0). + (2) scalar Path B (p_max=1) at the real sidereal rate respects the Cauchy-Schwarz + bound lnL <= 0.5 at the injection parameters. + (3) vectorized Path B (p_max=1) at f_sidereal=0 reduces to the baseline NoLoop. + +Rigorous validation of the p>=1 delay physics vs a brute force with TIME-VARYING delay on +a LONG signal is deferred to the systematic final-validation pass. +Run: PYTHONPATH=.../Code python RIFT/likelihood/test_slowrot_pathB.py +""" +from __future__ import print_function, division +import numpy as np, lal, lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +fmin=30.;fmax=1700.;event_time=1e9;t_window=0.1;Lmax=2;deltaT=1/4096.;deltaF=1/4. +HB=tuple(range(-3,4)) +Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=0.3,phiref=0.0,theta=0.2,phi=1.0,psi=0.4, + m1=30*lal.MSUN_SI,m2=25*lal.MSUN_SI,detector='H1',dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF) +data_dict={} +for d in ["H1","L1","V1"]: + P=Psig.manual_copy();P.detector=d;data_dict[d]=lsu.non_herm_hoff(P) +psd_dict={d:lalsim.SimNoisePSDaLIGOZeroDetHighPower for d in data_dict} + +def test_scalar_reduces_to_baseline(): + extr=lsu.ChooseWaveformParams(radec=True,phi=1.0,theta=0.2,psi=0.5,incl=0.7,phiref=0.9,tref=event_time,dist=300e6*lal.PC_SI) + rb=fl.PrecomputeLikelihoodTerms(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,analyticPSD_Q=True,verbose=False,quiet=True,ignore_threshold=None) + lnLb=fl.FactoredLogLikelihood(extr,rb[3],rb[0],rb[1],rb[2],Lmax) + rr=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=HB,p_max=1,f_sidereal=0.0,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=False) + lnLB=flwr.FactoredLogLikelihoodWithRotation(extr,rr[0],rr[1],rr[2],rr[4],Lmax) + print("(1) scalar PathB(p1,fsid0)=%.8f baseline=%.8f |d|=%.2e"%(lnLB,lnLb,abs(lnLB-lnLb))) + assert abs(lnLB-lnLb)<1e-6*(1+abs(lnLb)) + +def test_scalar_respects_bound(): + extr=lsu.ChooseWaveformParams(radec=True,phi=1.0,theta=0.2,psi=0.4,incl=0.3,phiref=0.0,tref=event_time,dist=200e6*lal.PC_SI) + rr=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,{'H1':data_dict['H1']},{'H1':psd_dict['H1']},Lmax,fmax,harmonics=HB,p_max=1,f_sidereal=flwr.F_SIDEREAL,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=False) + lnL=flwr.FactoredLogLikelihoodWithRotation(extr,rr[0],rr[1],rr[2],rr[4],Lmax) + IP=lsu.ComplexIP(fmin,fmax,1/2./deltaT,deltaF,psd_dict['H1'],True,False,0.);dd=IP.ip(data_dict['H1'],data_dict['H1']).real + print("(2) scalar PathB(p1,real,H1) lnL=%.4f 0.5=%.4f"%(lnL,0.5*dd)) + assert lnL<=0.5*dd + +def test_vec_reduces_to_baseline(): + tvals=np.arange(400)*deltaT-0.02 + rb=fl.PrecomputeLikelihoodTerms(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,analyticPSD_Q=True,verbose=False,quiet=True,ignore_threshold=None) + lk={};ra={};cu={};cv={};ep={} + for d in data_dict: + a,b,c,U,V,rA,rI,e=fl.PackLikelihoodDataStructuresAsArrays(list(rb[3][d].keys()),None,rb[3][d],rb[1][d],rb[2][d]);lk[d]=a;ra[d]=rA;cu[d]=U;cv[d]=V;ep[d]=e + Pv=Psig.manual_copy() + for k,v in [('phi',1.0),('theta',0.2),('incl',0.7),('phiref',0.9),('psi',0.5),('dist',300e6*lal.PC_SI)]: setattr(Pv,k,np.ones(1)*v) + Pv.tref=event_time;Pv.deltaT=deltaT + bl=fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals,Pv,lk,ra,cu,cv,ep,Lmax=Lmax,xpy=np)[0] + rr0=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=HB,p_max=1,f_sidereal=0.0,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=False) + lkr,rar,uar,var,epr=flwr.pack_rotation_arrays(rr0[4],rr0[3],rr0[1],rr0[2]) + vv=flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,rr0[4],lkr,rar,uar,var,epr,Lmax=Lmax,array_output=False)[0] + print("(3) vec PathB(p1,fsid0)=%.8f baseline NoLoop=%.8f |d|=%.2e"%(vv,bl,abs(vv-bl))) + assert abs(vv-bl)<1e-7*(1+abs(bl)) + +if __name__=="__main__": + test_scalar_reduces_to_baseline() + test_scalar_respects_bound() + test_vec_reduces_to_baseline() + print("ALL SLOWROT PATH B CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py new file mode 100644 index 000000000..262a552a3 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py @@ -0,0 +1,53 @@ +from __future__ import print_function, division +import numpy as np, lal, lalsimulation as lalsim +from scipy.interpolate import CubicSpline +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +import RIFT.likelihood.slowrot_response as srr +event_time=1e9; Lmax=2; t_window=0.1; det='H1' +psd=lalsim.SimNoisePSDaLIGOZeroDetHighPower; apx=lalsim.GetApproximantFromString("IMRPhenomD") +import os +OMEGA_INF=flwr.OMEGA_EARTH*float(os.environ.get("INFL","340")); FSID_INF=OMEGA_INF/(2*np.pi) +def _ifft(hf_d): + o={} + for lm,hf in hf_d.items(): + n=hf.data.length; dt=1./(n*hf.deltaF); ht=lal.CreateCOMPLEX16TimeSeries("h",hf.epoch,0.,dt,lal.DimensionlessUnit,n) + lal.COMPLEX16FreqTimeFFT(ht,hf,lal.CreateReverseCOMPLEX16FFTPlan(n,0)); o[lm]=ht + return o +def _to_fd(re,epoch,dt,N): + ht=lal.CreateCOMPLEX16TimeSeries("h",epoch,0.,dt,lal.DimensionlessUnit,N); ht.data.data[:]=re[:N] + hf=lal.CreateCOMPLEX16FrequencySeries("hf",epoch,0.,1./dt/N,lsu.lsu_HertzUnit,N) + lal.COMPLEX16TimeFreqFFT(hf,ht,lal.CreateForwardCOMPLEX16FFTPlan(N,0)); return hf +from scipy.interpolate import InterpolatedUnivariateSpline +def _peak(lt): + lt=np.asarray(lt,float); x=np.arange(len(lt)); sp=InterpolatedUnivariateSpline(x,lt,k=4) + xs=np.linspace(0,len(lt)-1,len(lt)*32); return float(np.max(sp(xs))) +fmin,fmax,deltaT,seglen=25.,512.,1/2048.,16.; deltaF=1./seglen; fNyq=1/2./deltaT; N=int(round(seglen/deltaT)) +RA,DEC,PSI,INCL,PHIREF=1.2,0.3,0.5,0.4,0.0; DLOUD=fl.distMpcRef*1e6*lsu.lsu_PC/30. +Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=INCL,phiref=PHIREF,theta=DEC,phi=RA,psi=PSI, + m1=2.2*lal.MSUN_SI,m2=1.8*lal.MSUN_SI,detector=det,dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF); Psig.approx=apx +Pm=Psig.manual_copy(); Pm.dist=DLOUD +hlms_fd,_=fl.internal_hlm_generator(Pm,Lmax,verbose=False,quiet=True); hlmsT=_ifft(hlms_fd) +lm0=list(hlmsT.keys())[0]; nn=hlmsT[lm0].data.length; dt=hlmsT[lm0].deltaT; ep=float(hlmsT[lm0].epoch); tt=ep+np.arange(nn)*dt +Sig=np.zeros(nn,complex) +for lm in hlmsT: Sig+=hlmsT[lm].data.data*lal.SpinWeightedSphericalHarmonic(INCL,-PHIREF,-2,lm[0],lm[1]) +reS=CubicSpline(tt,Sig.real,extrapolate=False); imS=CubicSpline(tt,Sig.imag,extrapolate=False) +lald=lalsim.DetectorPrefixToLALDetector(det); g_ev=lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))-RA +A=srr.antenna_harmonics(lald.response,DEC,PSI); At={k:A[k]*np.exp(1j*k*g_ev) for k in A} +B=srr.delay_harmonics(lald.location,DEC); Bt={k:B[k]*np.exp(1j*k*g_ev) for k in B} +tau_t=np.real(sum(Bt[k]*np.exp(1j*k*OMEGA_INF*tt) for k in Bt)) +F_t=sum(At[k]*np.exp(1j*k*OMEGA_INF*tt) for k in At) +Sig_d=np.nan_to_num(reS(tt-tau_t)+1j*imS(tt-tau_t)) +data=_to_fd(np.real(F_t*Sig_d),lal.LIGOTimeGPS(float(hlmsT[lm0].epoch)+event_time),dt,N); data_dict={det:data}; psd_dict={det:psd} +IPc=lsu.ComplexIP(fmin,fmax,fNyq,data.deltaF,psd,True,False,0.); HALF_DD=0.5*IPc.ip(data,data).real +print("inflated seglen=%.0fs 0.5=%.4f"%(seglen,HALF_DD)) +Pv=Psig.manual_copy() +for k,v in [('phi',RA),('theta',DEC),('incl',INCL),('phiref',PHIREF),('psi',PSI),('dist',DLOUD)]: setattr(Pv,k,np.ones(1)*v) +Pv.tref=event_time; Pv.deltaT=deltaT; Nw=int(0.02/deltaT); tvals=np.arange(-Nw,Nw)*deltaT +for pmax in [0,1,2,3]: + nh=2+pmax + bk=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=tuple(range(-nh,nh+1)),p_max=pmax,f_sidereal=FSID_INF,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=True) + lk,rbn,ubn,vbn,epd=flwr.pack_rotation_arrays(bk[4],bk[3],bk[1],bk[2]) + lnL=_peak(flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,bk[4],lk,rbn,ubn,vbn,epd,Lmax=Lmax,array_output=True)[0]) + print(" p_max=%d : lnL=%.5f deficit=%.5f"%(pmax,lnL,HALF_DD-lnL)) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_groundtruth.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_groundtruth.py new file mode 100644 index 000000000..7e2ad583f --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_groundtruth.py @@ -0,0 +1,69 @@ +from __future__ import print_function, division +import sys +import numpy as np +import lal, lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +event_time=1e9; Lmax=2; t_window=0.1; det='H1' +psd=lalsim.SimNoisePSDaLIGOZeroDetHighPower +apx=lalsim.GetApproximantFromString("IMRPhenomD") +SCALE=40. # loudness: data-mode distance = distMpcRef/SCALE + +def _ifft(hlms_fd): + out={} + for lm,hf in hlms_fd.items(): + n=hf.data.length; dt=1./(n*hf.deltaF) + ht=lal.CreateCOMPLEX16TimeSeries("h",hf.epoch,0.,dt,lal.DimensionlessUnit,n) + lal.COMPLEX16FreqTimeFFT(ht,hf,lal.CreateReverseCOMPLEX16FFTPlan(n,0)); out[lm]=ht + return out +def _fd(hk,TDlen): + if hk.data.length!=TDlen: hk=lal.ResizeREAL8TimeSeries(hk,0,TDlen) + n=TDlen + htC=lal.CreateCOMPLEX16TimeSeries("h",hk.epoch,hk.f0,hk.deltaT,hk.sampleUnits,n); htC.data.data[:]=hk.data.data + hf=lal.CreateCOMPLEX16FrequencySeries("hf",hk.epoch,hk.f0,1./hk.deltaT/n,lsu.lsu_HertzUnit,n) + lal.COMPLEX16TimeFreqFFT(hf,htC,lal.CreateForwardCOMPLEX16FFTPlan(n,0)); return hf +def _peak(lt,up=16): + lt=np.asarray(lt,dtype=float); N=len(lt) + L=np.fft.rfft(lt); Lp=np.zeros(N*up//2+1,dtype=complex); Lp[:len(L)]=L + return float(np.max(np.fft.irfft(Lp,N*up)*up)) + +def run(mode="short"): + if mode=="long": fmin,fmax,deltaT,seglen,m1,m2=25.,256.,1/2048.,32.,2.2*lal.MSUN_SI,1.8*lal.MSUN_SI + else: fmin,fmax,deltaT,seglen,m1,m2=30.,1700.,1/4096.,4.,30*lal.MSUN_SI,25*lal.MSUN_SI + deltaF=1./seglen; fNyq=1/2./deltaT; TDlen=int(round(seglen/deltaT)) + DLOUD=fl.distMpcRef*1e6*lsu.lsu_PC/SCALE + Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=0.4,phiref=0.0,theta=0.3,phi=1.2,psi=0.5, + m1=m1,m2=m2,detector=det,dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF); Psig.approx=apx + Pm=Psig.manual_copy(); Pm.dist=DLOUD + hlms_fd,_=fl.internal_hlm_generator(Pm,Lmax,verbose=False,quiet=True) + hlmsT=_ifft(hlms_fd) + data=_fd(lsu.hoft_from_hlm(hlmsT,Psig.manual_copy()),TDlen) + data_dict={det:data}; psd_dict={det:psd} + IPc=lsu.ComplexIP(fmin,fmax,fNyq,data.deltaF,psd,True,False,0.); HALF_DD=0.5*IPc.ip(data,data).real + print("[%s] seglen=%.0fs data_len=%d 0.5=%.4f"%(mode,seglen,data.data.length,HALF_DD)) + RA,DEC,PSI,INCL,PHIREF,DIST=1.2,0.3,0.5,0.4,0.0,DLOUD + ri,ct,ctV,rho,snr,rest=fl.PrecomputeLikelihoodTerms(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,analyticPSD_Q=True,verbose=False,quiet=True,ignore_threshold=None) + Pe=Psig.manual_copy(); Pe.incl=INCL;Pe.phiref=PHIREF;Pe.psi=PSI;Pe.phi=RA;Pe.theta=DEC;Pe.dist=DIST + hk_gt=_fd(lsu.hoft_from_hlm(hlmsT,Pe),TDlen); lnL_gt=IPc.ip(data,hk_gt).real-0.5*IPc.ip(hk_gt,hk_gt).real + Pv=Psig.manual_copy() + for k,v in [('phi',RA),('theta',DEC),('incl',INCL),('phiref',PHIREF),('psi',PSI),('dist',DIST)]: + setattr(Pv,k,np.ones(1)*v) + Pv.tref=event_time; Pv.deltaT=deltaT + Nw=int(0.02/deltaT); tvals=np.arange(-Nw,Nw)*deltaT + lkB={};rAB={};cuB={};cvB={};epB={} + for d in data_dict: + a,b,c,U,V,rA,rI,e=fl.PackLikelihoodDataStructuresAsArrays(list(rho[d].keys()),None,rho[d],ct[d],ctV[d]) + lkB[d]=a;rAB[d]=rA;cuB[d]=U;cvB[d]=V;epB[d]=e + lnL_base=_peak(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals,Pv,lkB,rAB,cuB,cvB,epB,Lmax=Lmax,xpy=np,return_lnLt=True)[0]) + def rot(pmax): + nh=2+pmax + bk=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=tuple(range(-nh,nh+1)),p_max=pmax,f_sidereal=flwr.F_SIDEREAL,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=True) + lk,rbn,ubn,vbn,ep=flwr.pack_rotation_arrays(bk[4],bk[3],bk[1],bk[2]) + return _peak(flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,bk[4],lk,rbn,ubn,vbn,ep,Lmax=Lmax,array_output=True)[0]) + lnL_A=rot(0); lnL_B=rot(1) + print(" ground truth (SimDetStrain) lnL = %.5f"%lnL_gt) + print(" baseline NoLoop peak lnL = %.5f deficit=%.5f"%(lnL_base,lnL_gt-lnL_base)) + print(" Path A NoLoop (F(t)) lnL = %.5f deficit=%.5f"%(lnL_A,lnL_gt-lnL_A)) + print(" Path B NoLoop (F(t)+p=1) lnL = %.5f deficit=%.5f"%(lnL_B,lnL_gt-lnL_B)) +if __name__=="__main__": run(sys.argv[1] if len(sys.argv)>1 else "short") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_precompute_integration.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_precompute_integration.py new file mode 100644 index 000000000..62a77c062 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_precompute_integration.py @@ -0,0 +1,125 @@ +""" +Full-stack integration test for factored_likelihood_with_rotation on real waveforms/data. + +Run under the RIFT venv with this worktree first on PYTHONPATH, e.g. + + source ~/RIFT_develUWM/bin/activate + PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code \ + python .../RIFT/likelihood/test_slowrot_precompute_integration.py + +Inputs are built exactly like RIFT.calmarg.test_precompute_alignment (a short BBH injection, +noiseless detector data via non_herm_hoff, analytic aLIGO PSD), i.e. the same machinery the +ILE-GPU-Paper CI demo exercises. + +Checks: + V0 (recovery): PrecomputeLikelihoodTermsWithRotation(harmonics=(0,), p_max=0) reproduces + the baseline PrecomputeLikelihoodTerms bit-for-bit -- Q, U, V. This validates all of + the new plumbing (mode generation, p=0 / n=0 identity, cross-term assembly). + Vmag (magnitude sanity): running Path A (harmonics -2..2) end-to-end on real data, the + a=(0,0) overlap still equals the baseline exactly, and the modulated a=(0,+-1) overlaps + differ from it by a small but nonzero amount of the expected order Omega*(signal + duration) -- confirming the sidereal modulation is applied, at the right (tiny) scale. + +Full physical validation (V1: the rotation-aware lnL vs a brute-force time-varying-response +likelihood) needs the lnL assembly that contracts this bank with A_n,B_n, and is the next +step. +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lalsimutils +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1000000000.0 +t_window = 0.1 +Lmax = 2 + +Psig = lalsimutils.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, + detector='H1', dist=200e6 * lal.PC_SI, deltaT=1. / fSample, + tref=event_time, deltaF=1. / 4.) + +data_dict = {} +for det in ("H1", "L1", "V1"): + P = Psig.manual_copy(); P.detector = det + data_dict[det] = lalsimutils.non_herm_hoff(P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +def _maxrel(a, b): + a = np.asarray(a); b = np.asarray(b) + scale = max(np.max(np.abs(a)), np.max(np.abs(b)), 1e-300) + return float(np.max(np.abs(a - b)) / scale) + + +def _run_base(): + # ignore_threshold=None so no mode pruning -> same mode set as the rotation path + return fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, skip_interpolation=True, + ignore_threshold=None) + + +def _run_rot(harmonics, p_max): + return flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=harmonics, p_max=p_max, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=True) + + +def test_V0_recovers_baseline(): + _, ct_b, ctV_b, rho_b, _, _ = _run_base() + _, ct_r, ctV_r, rho_r, meta = _run_rot(harmonics=(0,), p_max=0) + a0 = (0, 0) + worst_q = worst_u = worst_v = 0.0 + for det in data_dict: + for mode in rho_b[det]: + worst_q = max(worst_q, _maxrel(rho_r[det][a0][mode].data.data, + rho_b[det][mode].data.data)) + for key in ct_b[det]: + worst_u = max(worst_u, abs(ct_r[det][(a0, a0)][key] - ct_b[det][key])) + worst_v = max(worst_v, abs(ctV_r[det][(a0, a0)][key] - ctV_b[det][key])) + print("V0 recovery: max rel|dQ|=%.2e max|dU|=%.2e max|dV|=%.2e" + % (worst_q, worst_u, worst_v)) + assert worst_q < 1e-8, "Q mismatch vs baseline: %g" % worst_q + assert worst_u < 1e-8 * (1 + worst_u), "U mismatch vs baseline: %g" % worst_u + assert worst_v < 1e-8 * (1 + worst_v), "V mismatch vs baseline: %g" % worst_v + + +def test_Vmag_modulation_applied_at_right_scale(): + _, ct_b, _, rho_b, _, _ = _run_base() + _, _, _, rho_r, meta = _run_rot(harmonics=(-2, -1, 0, 1, 2), p_max=0) + # a=(0,0) must still equal baseline exactly even with the extra harmonics present + worst_0 = 0.0 + for det in data_dict: + for mode in rho_b[det]: + worst_0 = max(worst_0, _maxrel(rho_r[det][(0, 0)][mode].data.data, + rho_b[det][mode].data.data)) + print("Vmag a=(0,0) vs baseline: max rel|dQ|=%.2e" % worst_0) + assert worst_0 < 1e-8, "modulated run corrupts the n=0 overlap: %g" % worst_0 + + # modulated harmonics differ from n=0 by ~ Omega * (signal duration), tiny but nonzero. + # Signal duration scale for a 30+25 BBH from 30 Hz is ~ 0.1-1 s -> Omega*T ~ 1e-5..1e-4. + for n in (1, -1, 2, -2): + worst_n = 0.0 + for det in data_dict: + for mode in rho_b[det]: + worst_n = max(worst_n, _maxrel(rho_r[det][(0, n)][mode].data.data, + rho_r[det][(0, 0)][mode].data.data)) + print("Vmag a=(0,%+d) vs a=(0,0): max rel|dQ|=%.2e" % (n, worst_n)) + assert worst_n > 1e-9, "harmonic n=%d has no effect (modulation not applied?)" % n + assert worst_n < 1e-2, "harmonic n=%d far too large (bug in modulation scale?): %g" % (n, worst_n) + + +if __name__ == "__main__": + test_V0_recovers_baseline() + test_Vmag_modulation_applied_at_right_scale() + print("ALL SLOWROT PRECOMPUTE INTEGRATION CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_response.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_response.py new file mode 100644 index 000000000..7bdeed3e1 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_response.py @@ -0,0 +1,135 @@ +""" +Validate slowrot_response against LAL over a full sidereal day. + +The harmonic decomposition is an exact algebraic identity in the hour angle g, so the +reconstruction must match lal.ComputeDetAMResponse and lal.TimeDelayFromEarthCenter to +machine precision (up to LAL's own internal rounding). Run directly: + + python test_slowrot_response.py + +or under pytest (the test_* functions assert). +""" +from __future__ import print_function, division + +import numpy as np + +import lal +import lalsimulation as lalsim + +# Load the leaf module directly by path so this test needs only numpy+lal, not the full +# RIFT package stack (whose __init__ pulls in glue/lalsimutils). When the package is +# properly installed, `import RIFT.likelihood.slowrot_response` is equivalent. +try: + import RIFT.likelihood.slowrot_response as sr +except Exception: + import importlib.util + import os + _here = os.path.dirname(os.path.abspath(__file__)) + _spec = importlib.util.spec_from_file_location( + "slowrot_response", os.path.join(_here, "slowrot_response.py")) + sr = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(sr) + +DETECTORS = ["H1", "L1", "V1", "K1"] +SIDEREAL_DAY = 86164.0905 # s +T0_GPS = 1000000000.0 # arbitrary reference epoch + +_TOL = 1e-11 # radians of F, seconds of tau -- essentially machine precision + + +def _lal_detector(det): + return lalsim.DetectorPrefixToLALDetector(det) + + +def _sample_gmst(n=512): + tgps = T0_GPS + np.linspace(0.0, SIDEREAL_DAY, n, endpoint=False) + gmst = np.array([lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(t))) for t in tgps]) + return tgps, gmst + + +def _max_antenna_error(det, ra, dec, psi): + lald = _lal_detector(det) + A = sr.antenna_harmonics(lald.response, dec, psi) + _, gmst = _sample_gmst() + # model + Fp_mod, Fc_mod = sr.antenna_response(A, gmst, ra) + # reference (LAL), sampled at the same GMST values + Fp_ref = np.empty_like(gmst) + Fc_ref = np.empty_like(gmst) + for i, g in enumerate(gmst): + Fp_ref[i], Fc_ref[i] = lal.ComputeDetAMResponse(lald.response, ra, dec, psi, float(g)) + return max(np.max(np.abs(Fp_mod - Fp_ref)), np.max(np.abs(Fc_mod - Fc_ref))) + + +def _max_delay_error(det, ra, dec): + lald = _lal_detector(det) + B = sr.delay_harmonics(lald.location, dec) + tgps, gmst = _sample_gmst() + g = sr.greenwich_hour_angle(gmst, ra) + tau_mod = sr.delay_from_harmonics(B, g) + tau_ref = np.array([ + lal.TimeDelayFromEarthCenter(lald.location, ra, dec, lal.LIGOTimeGPS(float(t))) + for t in tgps + ]) + return np.max(np.abs(tau_mod - tau_ref)) + + +# ---- deterministic parameter sweep ------------------------------------------------- +_RNG = np.random.RandomState(20260703) +_CASES = [] +for _det in DETECTORS: + for _ in range(5): + _ra = _RNG.uniform(0, 2 * np.pi) + _dec = np.arcsin(_RNG.uniform(-1, 1)) + _psi = _RNG.uniform(0, np.pi) + _CASES.append((_det, _ra, _dec, _psi)) + + +def test_antenna_harmonics_match_lal(): + worst = 0.0 + for det, ra, dec, psi in _CASES: + e = _max_antenna_error(det, ra, dec, psi) + worst = max(worst, e) + assert e < _TOL, "antenna mismatch %g for %s ra=%.3f dec=%.3f psi=%.3f" % ( + e, det, ra, dec, psi) + print("antenna: worst |dF| = %.3e" % worst) + + +def test_delay_harmonics_match_lal(): + worst = 0.0 + for det, ra, dec, psi in _CASES: + e = _max_delay_error(det, ra, dec) + worst = max(worst, e) + assert e < _TOL, "delay mismatch %g s for %s ra=%.3f dec=%.3f" % (e, det, ra, dec) + print("delay: worst |dtau| = %.3e s" % worst) + + +def test_only_five_and_three_harmonics(): + """Confirm there is genuinely no content beyond |n|=2 (antenna) / |n|=1 (delay).""" + lald = _lal_detector("H1") + ra, dec, psi = 1.0, 0.4, 0.7 + _, gmst = _sample_gmst(1024) + g = sr.greenwich_hour_angle(gmst, ra) + # antenna: DFT of F(g) sampled uniformly in g should have power only at |n|<=2 + gg = np.linspace(0, 2 * np.pi, 1024, endpoint=False) + F = sr.antenna_from_harmonics(sr.antenna_harmonics(lald.response, dec, psi), gg) + coeffs = np.fft.fft(F) / len(gg) + power = np.abs(coeffs) + keep = np.zeros_like(power, dtype=bool) + for n in (-2, -1, 0, 1, 2): + keep[n % len(gg)] = True + assert np.max(power[~keep]) < 1e-12, "antenna has power beyond |n|=2: %g" % np.max(power[~keep]) + tau = sr.delay_from_harmonics(sr.delay_harmonics(lald.location, dec), gg) + tcoef = np.abs(np.fft.fft(tau) / len(gg)) + keep = np.zeros_like(tcoef, dtype=bool) + for n in (-1, 0, 1): + keep[n % len(gg)] = True + assert np.max(tcoef[~keep]) < 1e-15, "delay has power beyond |n|=1: %g" % np.max(tcoef[~keep]) + print("harmonic content confirmed: antenna |n|<=2, delay |n|<=1") + + +if __name__ == "__main__": + test_antenna_harmonics_match_lal() + test_delay_harmonics_match_lal() + test_only_five_and_three_harmonics() + print("ALL SLOWROT RESPONSE CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode index 8ce0ee353..ad5aaabd0 100755 --- a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode +++ b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode @@ -254,6 +254,12 @@ optp.add_option("--extrinsic-proposal-adapt",action='store_true',default=False, optp.add_option("--vectorized", action="store_true", help="Perform manipulations of lm and timeseries using numpy arrays, not LAL data structures. (Combine with --gpu to enable GPU use, where available)") optp.add_option("--gpu", action="store_true", help="Perform manipulations of lm and timeseries using numpy arrays, CONVERTING TO GPU when available. You MUST use this option with --vectorized (otherwise it is a no-op). You MUST have a suitable version of cupy installed, your cuda operational, etc") optp.add_option("--force-gpu-only", action="store_true", help="Hard fail if no GPU present (assessed by cupy not loading)") +optp.add_option("--rotation-slow", action="store_true", help="[Path A] Slow-rotation likelihood: account for the sidereal time-dependence of the antenna pattern F(t) over the signal (harmonic modulation). Requires --vectorized and CPU (not --gpu). Uses factored_likelihood_with_rotation.") +optp.add_option("--rotation-n-harmonics", type=int, default=2, help="Number of sidereal harmonics for --rotation-slow (antenna pattern needs 2, i.e. n=-2..2).") +optp.add_option("--rotation-p-max", type=int, default=0, help="[Path B] Max delay-derivative order for --rotation-slow (0 = amplitude drift only; >=1 adds propagation-delay drift).") +optp.add_option("--freqresponse", action="store_true", help="[Path D] Finite-size (frequency-dependent) detector-response likelihood: account for the finite light-travel-time transfer across the arms (matters for 3G/CE-ET). Requires --vectorized and CPU (not --gpu). Uses factored_likelihood_freqresponse (sky-harmonic route b, sky stays extrinsic).") +optp.add_option("--freqresponse-qmax", type=int, default=4, help="Highest power of the arm projection retained for --freqresponse (basis size Qmax+2). Higher for larger fL/c (heavier systems / higher fmax).") +optp.add_option("--freqresponse-arm-length", type=float, default=None, help="Arm-length override [m] for --freqresponse (e.g. 40000 for 40-km CE). Default: each detector's native LAL arm length.") optp.add_option("--force-xpy", action="store_true", help="Use the xpy code path. Use with --vectorized --gpu to use the fallback CPU-based code path. Useful for debugging.") optp.add_option("-o", "--output-file", help="Save result to this file.") optp.add_option("-O", "--output-format", default='xml', help="[xml|hdf5]") @@ -385,6 +391,26 @@ def _truthy_option(value): opts._noloop_time_interp = "cubic" if _truthy_option(opts.interpolate_time) else "nearest" +if opts.rotation_slow: + # Path A slow-rotation is currently wired only into the CPU vectorized branch. + if not opts.vectorized: + raise ValueError("--rotation-slow requires --vectorized") + if opts.gpu: + raise ValueError("--rotation-slow is CPU-only for now; do not combine with --gpu") + if getattr(opts, 'distance_marginalization', False) or getattr(opts, 'phase_marginalization', False): + raise ValueError("--rotation-slow does not yet support distance/phase marginalization") + +if opts.freqresponse: + # Path D finite-size response is currently wired only into the CPU vectorized branch. + if opts.rotation_slow: + raise ValueError("--freqresponse and --rotation-slow both replace the likelihood; use at most one") + if not opts.vectorized: + raise ValueError("--freqresponse requires --vectorized") + if opts.gpu: + raise ValueError("--freqresponse is CPU-only for now; do not combine with --gpu") + if getattr(opts, 'distance_marginalization', False) or getattr(opts, 'phase_marginalization', False): + raise ValueError("--freqresponse does not yet support distance/phase marginalization") + # cosmo d prior tools for interpolation: not used normally, but set if needed final_scipy_interpolate=None if 'cosmo' in opts.d_prior: @@ -431,6 +457,8 @@ if cupy_success and opts.gpu and opts.sampler_method == 'adaptive_cartesian_gpu' if not(opts.use_gwsignal): os.environ['RIFT_NO_GWSIGNAL'] = 'True' import RIFT.likelihood.factored_likelihood as factored_likelihood +import RIFT.likelihood.factored_likelihood_with_rotation as factored_likelihood_with_rotation +import RIFT.likelihood.factored_likelihood_freqresponse as factored_likelihood_freqresponse if opts.use_gwsignal and not(factored_likelihood.has_GWS): print(" HARD FAILURE: this node could not import gwsignal ! ") @@ -1822,6 +1850,39 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t ctVArrayDict[det] = cupy.asarray(ctVArrayDict[det]) epochDict[det] = cupy.asarray(epochDict[det]) + # [Path A] slow-rotation precompute: build the harmonic-indexed bank and pack it. + rotation_slow_data = None + if opts.rotation_slow: + _pmax = int(opts.rotation_p_max) + _nh = max(int(opts.rotation_n_harmonics), 2 + _pmax) # wide enough to cover all C_{(p,ntilde)} + _harm = tuple(range(-_nh, _nh + 1)) + _rint_r, _ct_r, _ctV_r, _rho_r, _meta_r = factored_likelihood_with_rotation.PrecomputeLikelihoodTermsWithRotation( + fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax, + harmonics=_harm, p_max=_pmax, analyticPSD_Q=False, + inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec, + verbose=opts.verbose, quiet=not opts.verbose, skip_interpolation=True) + _lkR, _rhoN, _uNN, _vNN, _epR = factored_likelihood_with_rotation.pack_rotation_arrays( + _meta_r, _rho_r, _ct_r, _ctV_r) + rotation_slow_data = dict(meta=_meta_r, lookupNKDict=_lkR, rho_by_n=_rhoN, + U_by_nn=_uNN, V_by_nn=_vNN, epochDict=_epR) + print(" [rotation-slow] precompute complete; p_max", _pmax, "sidereal harmonics", _harm) + + # [Path D] finite-size (frequency-dependent) response precompute: fold each W_p(f) + # into the modes once and pack the response-basis overlap bank. + freqresponse_data = None + if opts.freqresponse: + _qmax = int(opts.freqresponse_qmax) + _rint_f, _ct_f, _ctV_f, _rho_f, _meta_f = factored_likelihood_freqresponse.PrecomputeLikelihoodTermsFreqResponse( + fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax, + Qmax=_qmax, L_arm=opts.freqresponse_arm_length, analyticPSD_Q=False, + inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec, + verbose=opts.verbose, quiet=not opts.verbose, skip_interpolation=True) + _lkF, _rhoP, _uPP, _vPP, _epF = factored_likelihood_freqresponse.pack_freqresponse_arrays( + _meta_f, _rho_f, _ct_f, _ctV_f) + freqresponse_data = dict(meta=_meta_f, lookupNKDict=_lkF, rho_by_p=_rhoP, + U_by_pp=_uPP, V_by_pp=_vPP, epochDict=_epF) + print(" [freqresponse] precompute complete; Qmax", _qmax, "arm-length", opts.freqresponse_arm_length) + def _cal_error_probe(n_cal_now, n_start=256, n_cap=None, rel_tol=0.1): """Estimate (sigma_lnZ_cal, neff_cal, n_used, dist_mode): the calibration MC error of lnZ and the effective cal draw count, from per-realization @@ -2091,7 +2152,20 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t P.psi= psi_true P.phiref = phi_orb_true - lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVector(tvals, + if opts.rotation_slow: + lnL = factored_likelihood_with_rotation.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P, rotation_slow_data['meta'], rotation_slow_data['lookupNKDict'], + rotation_slow_data['rho_by_n'], rotation_slow_data['U_by_nn'], + rotation_slow_data['V_by_nn'], rotation_slow_data['epochDict'], Lmax=opts.l_max, + time_interp=opts._noloop_time_interp) + elif opts.freqresponse: + lnL = factored_likelihood_freqresponse.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, P, freqresponse_data['meta'], freqresponse_data['lookupNKDict'], + freqresponse_data['rho_by_p'], freqresponse_data['U_by_pp'], + freqresponse_data['V_by_pp'], freqresponse_data['epochDict'], Lmax=opts.l_max, + time_interp=opts._noloop_time_interp) + else: + lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVector(tvals, P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict,epochDict,Lmax=opts.l_max) # nEvals +=len(right_ascension) if supplemental_ln_likelihood: diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/Makefile b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/Makefile new file mode 100644 index 000000000..55d0e0f7a --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/Makefile @@ -0,0 +1,38 @@ +# demo/rift/slowrot -- verify-anywhere value demos for the slow-response likelihoods. +# +# make demo run BOTH quick-looks (no condor, no GPU); writes outputs/*.{txt,png} +# make demo-rotation Path A/B : rotation-vs-static lnL gain grows with signal duration Omega*T +# make demo-finite-size Path D : finite-size-vs-LWL lnL gain grows with detector arm length (fL/c) +# make clean remove outputs/ +# +# Both show the maintained vectorized (NoLoop) likelihood RECOVERS SNR the standard analysis +# loses, on the SAME data at the true parameters. They exercise the exact functions wired into +# integrate_likelihood_extrinsic_batchmode as --rotation-slow / --rotation-p-max / --freqresponse. +# Backing code: RIFT/likelihood/{factored_likelihood_with_rotation,factored_likelihood_freqresponse, +# slowrot_freqresponse}.py; validation RIFT/likelihood/test_slowrot_*.py; SLOWROT_HANDOFF.md. + +PYTHON ?= python +# Code root is four levels up (demo/rift/slowrot -> demo/rift -> demo -> Code); put it on +# PYTHONPATH so `import RIFT...` resolves this worktree even without an editable install. +CODE_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../../..) +RUN = PYTHONPATH=$(CODE_ROOT):$$PYTHONPATH $(PYTHON) + +.PHONY: help demo demo-rotation demo-finite-size clean +help: + @echo "targets:" + @echo " demo both verify-anywhere quick-looks (rotation + finite-size)" + @echo " demo-rotation Path A/B: gain vs signal duration (Omega*T); null control at short T" + @echo " demo-finite-size Path D: gain vs arm length (fL/c); null control at LIGO 4 km" + @echo " clean remove outputs/" + @echo "(uses PYTHON=$(PYTHON); CODE_ROOT=$(CODE_ROOT))" + +demo: demo-rotation demo-finite-size + +demo-rotation: + $(RUN) demo_rotation.py + +demo-finite-size: + $(RUN) demo_finite_size.py + +clean: + rm -rf outputs diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/README.md b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/README.md new file mode 100644 index 000000000..adedaf8fe --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/README.md @@ -0,0 +1,85 @@ +# demo/rift/slowrot — slow-response likelihood value demos + +Verify-anywhere (no condor, no GPU) quick-looks that RIFT's **time-/frequency-dependent +detector-response** likelihoods *add value*, not merely run. Both generalize the maintained +marginalized ILE likelihood so a long or long-armed signal is modeled with the correct +detector response instead of a single constant antenna pattern. + +``` +make demo # both demos below -> outputs/*.{txt,png} +make demo-rotation # Path A/B (Earth rotation) +make demo-finite-size # Path D (finite-size / frequency-dependent response) +``` + +Each demo builds data that carries the true response, then compares, **at the true +parameters on the same data and time grid**, the recovered time-maximized log-likelihood of +the standard analysis vs the response-aware one. The difference `gain = lnL_aware − lnL_standard` +is the SNR the standard analysis throws away. Both use the maintained vectorized (NoLoop) +path with cubic sub-bin time interpolation, so the peak-resolution floor cancels in the gain. + +--- + +## Path A/B — Earth rotation (`demo_rotation.py`) + +Over a long signal the antenna pattern `F(t)` drifts as the Earth rotates; the static +(Earth-fixed-response) likelihood loses match. RIFT injections already carry the true +time-varying response (`hoft → SimDetectorStrainREAL8TimeSeries`), so a standard injection is +"rotating". The gain grows with the rotation phase `Ω⊕·T` over the signal; a short signal is a +null control. Single H1, SNR≈30 held fixed so the gain tracks `Ω⊕·T`, not loudness: + +| config | seglen | Ω⊕·T | gain `lnL_rot − lnL_static` | +|---|---|---|---| +| null_bbh (30+25) | 2 s | 1.5e-4 | +0.004 (null control) | +| bbh_8_8 | 16 s | 1.2e-3 | +0.043 | +| bbh_4_4 | 64 s | 4.7e-3 | +0.187 | + +Grows ~50× from the short null to a minute-scale signal; extrapolated to a 90-minute XG BNS +(`Ω⊕·T`≈0.4) it is orders of magnitude larger — the 3G headline. Figure: +`outputs/rotation_gain_vs_duration.png`. ILE: `--rotation-slow` (Path A), +`--rotation-slow --rotation-p-max 2` (Path B, adds propagation-delay drift). + +## Path D — finite-size / frequency-dependent response (`demo_finite_size.py`) + +On a multi-km arm the light-travel time across the arm is not negligible vs the GW period, so +the response is per-frequency: `h_k(f) = F₊(f;sky) h₊(f) + Fₓ(f;sky) hₓ(f)`. We inject an exact +finite-size signal (`antenna_response_fd`) and compare the standard long-wavelength (LWL, +constant-response) likelihood to the finite-size one (`--freqresponse`, sky-harmonic route (b), +sky stays extrinsic). The gain grows with the arm length — i.e. with `fL/c`, the in-band +light-crossing phase. 15+13 M☉, fmax=2000 Hz, loud (SNR≈320), Qmax=6: + +| detector | arm L | fL/c @ fmax | gain `lnL_finite − lnL_LWL` | +|---|---|---|---| +| LIGO | 4 km | 0.027 | +0.25 (null control) | +| ET | 10 km | 0.067 | +3.5 | +| CE | 20 km | 0.133 | +12.4 | +| CE | 40 km | 0.267 | +39.6 | + +Null at the 4-km LIGO arm; tens of nats at a 40-km Cosmic Explorer. Only the +**direction-dependent** part of the response contributes — the common `e^{−i2πfL/c}` +light-crossing delay is degenerate with arrival time and is absorbed by both likelihoods' +time maximization, so it does not inflate the gain. Figure: +`outputs/finite_size_gain_vs_arm.png`. ILE: `--freqresponse` +`--freqresponse-arm-length 40000` `--freqresponse-qmax 6`. + +Higher `fL/c` (heavier system / higher fmax / longer arm) needs higher `--freqresponse-qmax` +(the response is a power series in `fL/c · (â·n̂)`); Qmax=6 converges through CE-40 km at this +config. + +--- + +## Full injection–recovery PE (cluster) + +These quick-looks are the likelihood-level core. The headline parameter-bias / single-network +sky-localization figures are DAG PE runs (structurally the standard RIFT pipeline with the +extra ILE flag appended: `--rotation-slow` / `--freqresponse`). The three analyses per event +differ *only* in that appended option. See `RIFT/likelihood/SLOWROT_HANDOFF.md` and +`~/RIFT_roboto_paper/analyses/slowrot_demo/` for the pipeline scaffolding. + +## Validation (the demos show value; these prove correctness) + +`RIFT/likelihood/test_slowrot_*.py` (run under the venv with this Code dir on `PYTHONPATH`): +- `test_slowrot_noloop.py` — rotation NoLoop == baseline at f_sidereal=0 (3.6e-12). +- `test_slowrot_headtohead.py` — matched-sample rotation vs baseline; Cauchy-Schwarz bound. +- `test_slowrot_freqresponse_likelihood.py` — finite-size: L→0 reduces to baseline (3e-9), + bound respected, and a **V4 positive control** asserting finite-size beats LWL by +38.9 nats + in an in-band-effect config. diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_finite_size.py b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_finite_size.py new file mode 100644 index 000000000..eee5ec0c7 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_finite_size.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python +""" +slowrot demo / demo_finite_size.py -- verify-anywhere (no condor, no GPU) quick-look that the +finite-size (frequency-dependent detector response) likelihood [Path D] ADDS VALUE on a +long-armed 3G detector. + +Beyond the long-wavelength (LWL) limit the detector strain is a per-frequency antenna +pattern, h_k(f) = F_+(f;sky) h_+(f) + F_x(f;sky) h_x(f), because the light-travel time across +a multi-km arm is no longer negligible vs the GW period. We build an EXACT finite-size +injection (from the same modes the likelihood uses -> internal_hlm_generator -> IFFT -> +antenna_response_fd) and, at the true parameters, compare the recovered log-likelihood of + + lnL_LWL : the standard RIFT likelihood (constant, frequency-independent response) + lnL_finite : the finite-size likelihood (--freqresponse, sky-harmonic route (b)) + +both TIME-MAXIMIZED on the SAME data and SAME time grid. The gain + + gain(L) = lnL_finite(truth) - lnL_LWL(truth) + +is ~0 for a LIGO 4-km arm (null control: the finite-size effect is below the floor) and grows +monotonically with the arm length -- i.e. with fL/c, the in-band light-crossing phase -- to +tens of nats for a 40-km Cosmic Explorer. That is exactly the SNR the LWL analysis throws +away on a 3G detector, and what --freqresponse recovers. + +The effect that matters is the DIRECTION-DEPENDENT part of the response; the common +e^{-i2pi f L/c} light-crossing delay is degenerate with the arrival time and is absorbed by +BOTH likelihoods' time maximization, so it does not inflate the gain. + +Run: python demo_finite_size.py (writes outputs/finite_size_gain_vs_arm.{txt,png}) + +Backing code: branch rift_slowrot; --freqresponse (+ --freqresponse-qmax/-arm-length) on +integrate_likelihood_extrinsic_batchmode; RIFT/likelihood/factored_likelihood_freqresponse.py, +slowrot_freqresponse.py. See RIFT/likelihood/SLOWROT_HANDOFF.md. +""" +from __future__ import print_function, division +import os +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.slowrot_freqresponse as sfr +import RIFT.likelihood.factored_likelihood_freqresponse as flfr + +EVENT_TIME = 1e9 +LMAX = 2 +DET = "H1" +PSD = lalsim.SimNoisePSDaLIGOZeroDetHighPower +# sky/pol/masses where the in-band finite-size effect is resolvable (heavier system -> high-f +# power; loud so the effect sits above the peak-resolution floor). +RA, DEC, PSI, INCL, PHIREF = 1.2, 0.3, 0.5, 0.4, 0.0 +M1, M2 = 15.0, 13.0 +FMAX = 2000.0 +SCALE = 40.0 # loudness: data-mode distance = distMpcRef/SCALE (SNR ~ 320) +SEGLEN = 8.0 +QMAX = 6 # response-basis order; higher needed as fL/c grows + +# (label, arm-length[m]) -- LIGO 4 km is the null control; ET ~10 km; CE 20/40 km. +CONFIGS = [ + ("LIGO_4km", 4000.0), + ("ET_10km", 10000.0), + ("CE_20km", 20000.0), + ("CE_40km", 40000.0), +] + +C_SI = sfr.C_SI + + +# --- minimal FD helpers (self-contained; same conventions as the validation test) --- +def _ifft(hf_d): + out = {} + for lm, hf in hf_d.items(): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ht = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ht, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)); out[lm] = ht + return out + + +def _fwd_fd(re_series, epoch, dt, N): + ht = lal.CreateCOMPLEX16TimeSeries("h", epoch, 0., dt, lal.DimensionlessUnit, N) + ht.data.data[:] = re_series[:N] + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., 1. / dt / N, lsu.lsu_HertzUnit, N) + lal.COMPLEX16TimeFreqFFT(hf, ht, lal.CreateForwardCOMPLEX16FFTPlan(N, 0)); return hf + + +def _wrap_fd(arr, epoch, deltaF): + n = len(arr) + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., deltaF, lsu.lsu_HertzUnit, n) + hf.data.data[:] = arr + return hf + + +def _rev_td(hf): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ht = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ht, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)); return ht + + +def _peak(lt): + """Time-maximized value via a fine spline over the sampled lnL(t) window.""" + from scipy.interpolate import InterpolatedUnivariateSpline + lt = np.asarray(lt, float); x = np.arange(len(lt)) + sp = InterpolatedUnivariateSpline(x, lt, k=4) + xs = np.linspace(0, len(lt) - 1, len(lt) * 32) + return float(np.max(sp(xs))) + + +def run_config(label, L_arm): + deltaT = 1.0 / (2 * FMAX); fmin = 30.0; deltaF = 1.0 / SEGLEN + fNyq = 1.0 / 2.0 / deltaT; t_window = 0.1 + DLOUD = fl.distMpcRef * 1e6 * lsu.lsu_PC / SCALE + + Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=M1 * lal.MSUN_SI, m2=M2 * lal.MSUN_SI, detector=DET, dist=200e6 * lal.PC_SI, + deltaT=deltaT, tref=EVENT_TIME, deltaF=deltaF) + Psig.approx = lalsim.GetApproximantFromString("IMRPhenomD") + Pm = Psig.manual_copy(); Pm.dist = DLOUD + + # --- exact finite-size injection from the SAME modes --- + hlms_fd, _ = fl.internal_hlm_generator(Pm, LMAX, verbose=False, quiet=True) + hlmsT = _ifft(hlms_fd); lm0 = list(hlmsT.keys())[0] + nn = hlmsT[lm0].data.length; dt = hlmsT[lm0].deltaT; e0 = float(hlmsT[lm0].epoch) + Sig = np.zeros(nn, complex) + for lm in hlmsT: + Sig += hlmsT[lm].data.data * lal.SpinWeightedSphericalHarmonic(INCL, -PHIREF, -2, lm[0], lm[1]) + dt_geo = float(fl.ComputeArrivalTimeAtDetector(DET, RA, DEC, EVENT_TIME)) - EVENT_TIME + data_epoch = lal.LIGOTimeGPS(e0 + EVENT_TIME) + fvals = flfr.evaluate_fvals_from_length(nn, deltaF) + Sig_fd = _fwd_fd(Sig, data_epoch, dt, nn).data.data + Sig_del = _rev_td(_wrap_fd(Sig_fd * np.exp(-1j * 2 * np.pi * fvals * dt_geo), data_epoch, deltaF)).data.data + hpf = _fwd_fd(np.real(Sig_del), data_epoch, dt, nn).data.data + hcf = _fwd_fd(-np.imag(Sig_del), data_epoch, dt, nn).data.data + gmst = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(EVENT_TIME))) + Fp, Fc = sfr.antenna_response_fd(DET, RA, DEC, PSI, fvals, gmst=gmst, L_arm=L_arm) + data = _wrap_fd(Fp * hpf + Fc * hcf, data_epoch, deltaF) + data_dict = {DET: data}; psd_dict = {DET: PSD} + IP = lsu.ComplexIP(fmin, FMAX, fNyq, data.deltaF, PSD, True, False, 0.) + snr = np.sqrt(IP.ip(data, data).real) + + Pv = Psig.manual_copy() + for k, v in [('phi', RA), ('theta', DEC), ('incl', INCL), ('phiref', PHIREF), + ('psi', PSI), ('dist', DLOUD)]: + setattr(Pv, k, np.ones(1) * v) + Pv.tref = EVENT_TIME; Pv.deltaT = deltaT + Nw = int(0.02 / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + + # baseline (long-wavelength) NoLoop, time-maximized, at the truth + ri, ct, ctV, rho, _, _ = fl.PrecomputeLikelihoodTerms( + EVENT_TIME, t_window, Psig, data_dict, psd_dict, LMAX, FMAX, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lk = {}; rA = {}; cu = {}; cv = {}; ep = {} + for d in data_dict: + a, b, c, U, V, r, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho[d].keys()), None, rho[d], ct[d], ctV[d]) + lk[d] = a; rA[d] = r; cu[d] = U; cv[d] = V; ep[d] = e + lnL_lwl = _peak(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lk, rA, cu, cv, ep, Lmax=LMAX, xpy=np, return_lnLt=True, time_interp='cubic')[0]) + + # finite-size (Path D) NoLoop, time-maximized, at the truth + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + EVENT_TIME, t_window, Psig, data_dict, psd_dict, LMAX, FMAX, + Qmax=QMAX, L_arm=L_arm, analyticPSD_Q=True, verbose=False, quiet=True, skip_interpolation=True) + lkf, rbp, ubp, vbp, epf = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + lnL_fin = _peak(flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, bk[4], lkf, rbp, ubp, vbp, epf, Lmax=LMAX, array_output=True, time_interp='cubic')[0]) + + fLc = FMAX * L_arm / C_SI + return dict(label=label, L_arm=L_arm, fLc=fLc, snr=float(snr), + lnL_lwl=lnL_lwl, lnL_fin=lnL_fin, gain=lnL_fin - lnL_lwl) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + outdir = os.path.join(here, "outputs"); os.makedirs(outdir, exist_ok=True) + print("finite-size (Path D) value demo: %g+%g Msun, fmax=%g Hz, loudness SCALE=%g (SNR per row), Qmax=%d\n" + % (M1, M2, FMAX, SCALE, QMAX)) + rows = [] + for cfg in CONFIGS: + try: + r = run_config(*cfg) + except Exception as e: + print("%-9s SKIPPED (%s)" % (cfg[0], e)); continue + rows.append(r) + print("%-9s L=%6.0fm fL/c@fmax=%.3f SNR=%6.1f lnL_LWL=%11.3f lnL_finite=%11.3f GAIN=%+8.3f" + % (r['label'], r['L_arm'], r['fLc'], r['snr'], r['lnL_lwl'], r['lnL_fin'], r['gain'])) + if not rows: + print("no configs succeeded"); return + txt = os.path.join(outdir, "finite_size_gain_vs_arm.txt") + with open(txt, "w") as f: + f.write("# finite-size (Path D) demo: --freqresponse recovers SNR the long-wavelength analysis loses\n") + f.write("# label L_arm[m] fL/c@fmax SNR lnL_LWL lnL_finite gain=lnL_finite-lnL_LWL\n") + for r in rows: + f.write("%s %g %g %g %g %g %g\n" % (r['label'], r['L_arm'], r['fLc'], + r['snr'], r['lnL_lwl'], r['lnL_fin'], r['gain'])) + print("\nwrote", txt) + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + Lkm = [r['L_arm'] / 1e3 for r in rows]; gain = [r['gain'] for r in rows] + labs = [r['label'] for r in rows] + fig, ax = plt.subplots(figsize=(5, 3.4)) + ax.plot(Lkm, gain, 'o-', color="#9d0208") + for x, y, l in zip(Lkm, gain, labs): + ax.annotate(l, (x, y), textcoords="offset points", xytext=(4, 4), fontsize=8) + ax.set_xlabel(r"detector arm length $L$ [km] ($\propto fL/c$, in-band light-crossing)") + ax.set_ylabel(r"$\ln\mathcal{L}_{\rm finite}-\ln\mathcal{L}_{\rm LWL}$ (recovered)") + ax.set_title("Finite-size detector response recovers SNR on 3G arms") + ax.axhline(0, color="0.7", lw=0.8) + fig.tight_layout() + png = os.path.join(outdir, "finite_size_gain_vs_arm.png") + fig.savefig(png, dpi=140) + print("wrote", png) + except Exception as e: + print("(plot skipped:", e, ")") + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_rotation.py b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_rotation.py new file mode 100644 index 000000000..d1c7bc7c2 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_rotation.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python +""" +slowrot demo / demo_rotation.py -- verify-anywhere (no condor, no GPU) quick-look that the +slow-rotation likelihood [Path A/B] ADDS VALUE: on a long signal the static +(Earth-fixed-response) likelihood LOSES match with the data (which carries the true +time-varying antenna pattern, since RIFT injections go through +SimDetectorStrainREAL8TimeSeries), and --rotation-slow recovers it. The recovered +log-likelihood at the true parameters, + + gain(config) = lnL_rotation(truth) - lnL_static(truth), (both time-marginalized) + +grows with the rotation phase Omega*T over the signal; a short signal is a null control +(gain ~ 0). This is the likelihood-level core of the full injection-recovery PE; the headline +sky-localization / parameter-bias figure is a cluster run. + +Both likelihoods are the maintained vectorized (NoLoop) path evaluated on the SAME data and +SAME time grid, so the gain is a clean difference (peak-resolution floor cancels). + +Run: python demo_rotation.py (writes outputs/rotation_gain_vs_duration.{txt,png}) + +Backing code: branch rift_slowrot; --rotation-slow (Path A) and --rotation-p-max N (Path B) on +integrate_likelihood_extrinsic_batchmode. See RIFT/likelihood/SLOWROT_HANDOFF.md. +""" +from __future__ import print_function, division +import os +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +EVENT_TIME = 1e9 +LMAX = 2 +DET = "H1" # single detector: rotation's sky-info value is clearest here +PSD = lalsim.SimNoisePSDaLIGOZeroDetHighPower +# sky/pol chosen where the antenna pattern drifts appreciably over a sidereal day +RA, DEC, PSI, INCL, PHIREF = 1.0, -0.6, 0.4, 0.5, 0.0 +HARM = (-2, -1, 0, 1, 2) + +# (label, m1, m2, fmin, fmax, dist[Mpc]) -- increasing duration via lower mass / fmin. +# Segment length is AUTO-FIT to each waveform (next power of two). Distances tuned for a +# comparable (loud) SNR (~30) so the gain tracks Omega*T, not loudness. +CONFIGS = [ + ("null_bbh", 30.0, 25.0, 30.0, 1024.0, 900.0), # short BBH, ~2 s: control, gain~0 + ("bbh_8_8", 8.0, 8.0, 25.0, 1024.0, 380.0), # ~16 s + ("bbh_4_4", 4.0, 4.0, 22.0, 1024.0, 210.0), # ~64 s -> larger Omega*T +] +OMEGA_EARTH = flwr.OMEGA_EARTH + + +def _P(m1, m2, fmin, deltaT, deltaF): + P = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=m1 * lal.MSUN_SI, m2=m2 * lal.MSUN_SI, detector=DET, + dist=200e6 * lal.PC_SI, deltaT=deltaT, tref=EVENT_TIME, deltaF=deltaF) + P.approx = lalsim.GetApproximantFromString("IMRPhenomD") + return P + + +def _P_vec(Psig, dist_SI, K=1): + Pv = Psig.manual_copy() + Pv.phi = np.ones(K) * RA; Pv.theta = np.ones(K) * DEC; Pv.psi = np.ones(K) * PSI + Pv.incl = np.ones(K) * INCL; Pv.phiref = np.ones(K) * PHIREF + Pv.dist = np.ones(K) * dist_SI + Pv.tref = float(EVENT_TIME); Pv.deltaT = Psig.deltaT + return Pv + + +def run_config(label, m1, m2, fmin, fmax, dist_Mpc): + deltaT = 1.0 / (2 * fmax) if fmax > 1024 else 1.0 / 2048.0 + dist_SI = dist_Mpc * 1e6 * lal.PC_SI + t_window = 0.1 + # AUTO-FIT seglen: generate with deltaF=None so non_herm_hoff pads to the next power of + # two >= the waveform length; then read back the segment length / deltaF. + Psig = _P(m1, m2, fmin, deltaT, None) + Psig.dist = dist_SI + data = lsu.non_herm_hoff(Psig) # RIFT injection -> carries the true time-varying response + seglen = data.data.length * deltaT + Psig.deltaF = data.deltaF + data_dict = {DET: data}; psd_dict = {DET: PSD} + fNyq = 1.0 / (2 * deltaT) + IP = lsu.ComplexIP(fmin, fmax, fNyq, data.deltaF, PSD, True, False, 0.) + snr = np.sqrt(IP.ip(data, data).real) + Pv = _P_vec(Psig, dist_SI) + Nw = int(0.02 / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + + # baseline (static) NoLoop, time-marginalized, at the truth + rib, ctb, ctVb, rhob, _, _ = fl.PrecomputeLikelihoodTerms( + EVENT_TIME, t_window, Psig, data_dict, psd_dict, LMAX, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lk = {}; rA = {}; cu = {}; cv = {}; ep = {} + for d in data_dict: + a, b, c, U, V, rAr, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rhob[d].keys()), None, rhob[d], ctb[d], ctVb[d]) + lk[d] = a; rA[d] = rAr; cu[d] = U; cv[d] = V; ep[d] = e + lnL_static = float(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lk, rA, cu, cv, ep, Lmax=LMAX, xpy=np, time_interp='cubic')[0]) + + # rotation (Path A) NoLoop at the truth + bk = flwr.PrecomputeLikelihoodTermsWithRotation( + EVENT_TIME, t_window, Psig, data_dict, psd_dict, LMAX, fmax, + harmonics=HARM, p_max=0, f_sidereal=flwr.F_SIDEREAL, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=True) + lkr, rbn, ubn, vbn, epr = flwr.pack_rotation_arrays(bk[4], bk[3], bk[1], bk[2]) + lnL_rot = float(flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, Pv, bk[4], lkr, rbn, ubn, vbn, epr, Lmax=LMAX, time_interp='cubic')[0]) + + OmegaT = OMEGA_EARTH * seglen + return dict(label=label, seglen=seglen, snr=float(snr), OmegaT=OmegaT, + lnL_static=lnL_static, lnL_rot=lnL_rot, gain=lnL_rot - lnL_static) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + outdir = os.path.join(here, "outputs"); os.makedirs(outdir, exist_ok=True) + print("slow-rotation (Path A) value demo: single %s, SNR~30 held fixed so gain tracks Omega*T\n" % DET) + rows = [] + for cfg in CONFIGS: + try: + r = run_config(*cfg) + except Exception as e: + print("%-9s SKIPPED (%s)" % (cfg[0], e)); continue + rows.append(r) + print("%-9s seglen=%5.0fs SNR=%6.1f Omega*T=%.2e lnL_static=%12.4f lnL_rot=%12.4f GAIN=%+.4f" + % (r['label'], r['seglen'], r['snr'], r['OmegaT'], + r['lnL_static'], r['lnL_rot'], r['gain'])) + if not rows: + print("no configs succeeded"); return + txt = os.path.join(outdir, "rotation_gain_vs_duration.txt") + with open(txt, "w") as f: + f.write("# slow-rotation demo: --rotation-slow recovers SNR the static analysis loses on long signals\n") + f.write("# label seglen[s] SNR Omega*T lnL_static lnL_rot gain=lnL_rot-lnL_static\n") + for r in rows: + f.write("%s %g %g %g %g %g %g\n" % (r['label'], r['seglen'], r['snr'], + r['OmegaT'], r['lnL_static'], r['lnL_rot'], r['gain'])) + print("\nwrote", txt) + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + OmT = [r['OmegaT'] for r in rows]; gain = [r['gain'] for r in rows] + labs = [r['label'] for r in rows] + fig, ax = plt.subplots(figsize=(5, 3.4)) + ax.plot(OmT, gain, 'o-', color="#2a6f97") + for x, y, l in zip(OmT, gain, labs): + ax.annotate(l, (x, y), textcoords="offset points", xytext=(4, 4), fontsize=8) + ax.set_xlabel(r"$\Omega_\oplus T$ (rotation phase over the signal)") + ax.set_ylabel(r"$\ln\mathcal{L}_{\rm rot}-\ln\mathcal{L}_{\rm static}$ (recovered)") + ax.set_title("Accounting for Earth rotation recovers SNR on long signals") + ax.axhline(0, color="0.7", lw=0.8) + fig.tight_layout() + png = os.path.join(outdir, "rotation_gain_vs_duration.png") + fig.savefig(png, dpi=140) + print("wrote", png) + except Exception as e: + print("(plot skipped:", e, ")") + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.png b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.png new file mode 100644 index 0000000000000000000000000000000000000000..6ab85ae0f646355bdc250be39dbf1d5d2720632e GIT binary patch literal 49592 zcmd43cRZH=|2KT1K^h8)jAVpDN*QH@WTos)QJH0zGAp}i7@;D2ld@-7g^(RWNM&SY z&+B=7e)r@0=f3aj@2kh7@3%hA&ht2r_v`(7J=gmPxU70%*AAu~Bob+tqJo?{i9}vX zB9VQk+=l-moOIV4|0m{jUe`&(_J-4)s}9#m7q2?m+1NVSSehQXeci#)()QL#fl~s< zj~%jbatHS@<)+DpH=(jq&~qe_=>3V`WYt~LZ%Cw%T#9n%G+kaz z^f~Klb|2h6J@u76=)sjMrlwbpM0`77oza&wHa3&9&0JNHa)qPjK!Z0GBU<$rmFwxNY^PA`gJNGAmCfE z^U?3$zh5ykqmYo0hyvf2r7yTYNqAy~uI9yu7>^qqtYX5(POq z4GoRg-`)bV_7pYU9Mki`6}GC6f`jG#cC*;c{!~nyou0PC#c$QgGW@tQKW;zwM@=(L zlegpV_cB)oskLc}rf7+mrh0B}XHiGfe_A*udh)2bltMq?y1&Mqe`GTdQ>ygWh-u>Q zU;D9k-nr4X?OS)yDHL>^=H%S+yE(=$Ir#`lnmgp`mk1%3mAO%!PjQ))!kr8EH40eKxp{d$ zD%`X^Jw0*iiAttFKHN9$D|R0G8Y?@5Wvpy&zI4`Y>GCTnPh)0rCk2~tZ_lBkbv3gL z^(^}q{`Nk4DPsFep|rohpWKUdC|bfboMY;orH0{2_tk~Q{F}G>OZCLFqfE!zQ^~z} zj7tlPW!dA-4xvt4&Gnk2CC!gM6Lm#1%PcIxF?Jaclx zGCVaciiwHgx%>uKwsYss>ds7qHn(QnBq~=|S2F28qoV;R;_D6NKb@_L8a`E5`=gkY zjud*VubATg3=MyJ64|dUC+Gbw*D}Q7+uPW$t*!g;!KOpi0f)J{xed&2RHl0n{Z8}X zS(mq@Lr%`l=PfKOqMSz@ic&OFpOX$niP%P)79Ae^+m`&~ZC)NdNp5*|q^&gjhI!>t zZyEssfmU(!L`MA!v@Bu{ay@$l1qH*L_wC!~otm0TI#j;7u}arKzbJ`2F*rIJ8M|2K zJRbhFrDZQkuHaT*xfRc*VU3$S-!j!xzwYrpg_s365;jkdPC*cuQdDj@3-j5<=xF99%}kBja6Uc?9#~C z7+=RxwYV+UPDIvA#w4r0uDmngz8&}N&-i%!m5&eXoSkcW@~oddefl&YKD71qnLEer zen^K37%}$-64{CYd9e#0M%EvbLaOid4aY_8uaEH19>>0w$~ zS$SJfaN&2;Yx{|BImf0#P=dv8GlW) zz$Z^W1=1hATyV=sS6BDLhYut!T&P(p%3>56x_2mV5W^F^);birzS7)lMRP zFLyt9>(;H~LPGaTN?Ze1BE{J1t}lq|Fc%0I6_ZE;#-;xF!-u9ORdIob+jdo=#@fDD z=wusT#SP#wDL?x-C`j#%G?`|~WiqMtIa=cik4y)anktD4!OUc&wSUc07A>Mhzdgx}w%Sj!`2w4F zSX*0@J`n97&sqn!;f+;C8t>TlZQD%OmS$2e$CBgYubP@Zzz1Bx1+|*HT1|d0dbqK^ z8t*bQ^tG$Y&&Ovg(`g%j=kdU0GWieqvcWJUO{LQpLB@hdSA1 z^UI|9tgFJcYn%@rJ`{49zCw3Q_wq`l@t}268K0m@`4$o`A;BeEudp7{>(@?#w^|dZ&@{7W z#aCx-5ApHsz;}~RR0>OQG|4uW2wU{aJ$rXnZlz%8{Xb>&I39!iW28f(qPmumFJ8Qu zBMn@VS`QtREYeETJeO@;cIEnYAM^)y4D2@hC#(`os;a73%4e)@PPcE@{;Ug`nd~c} z8^1QUa%rO|^668r^N;q&Vz3@RaY80m#^-CIGQ;-@Pm?rHjG@A!BE@C-(bhyE%hm+V z>__dB>{gbY4tMS_oU-m*OViG1*Z)N~^6S^Hq0zSFxNCLK)yo7;9Dg@y=`VJ_H*Y;* zYirBy?a(m!B0E(hS!o>oCJ+N4NuR;X?)L5dA3uK7>lv6I?@}J<)GSj`QBhy}8LFS| zG;7;CTK0S@o zqD-@^AU}UUiZE>KNQFna+f8)sGzNNluATzhm|q*kMMVKADJgV9Teofv;L}P=D2^^K zFaL%L@@;xFbdOHP|I-t;NBg*x67+SrLjpF}r#yLwMtcjgNr!5K4ju_VrWcpn)S9T= zCUGIq?AOG^F*kR2pPZbWTn`|C@-a7_Ya_%4X?lI;5oVjRR8^XIzw5$;T9GX)D{JNF z&paKAr%s&MhZig5M8|PWOb=EmNzr;P^I@J)?%b)VzgQP?r1IlO_C{-Klri6M@@pX=y`*pL9rVuEo!~YiAiojxCcNViLAEgb^KT zT9)xD21A>Y6o-A5I(f&%r4UvBVnd+(wp7S~Y_z01UwtSq$9hF%WTfVlb(6c*y@d(h z%+((sA3BX=a|D<-M+dwRG)dA;D$Md+o4LU5O=DDYo1Wwy92{I|KkQro_xc@b+p>G} zTb|C*mKzE*e-)SE%nM%_?-Fb@Gbp>~j1oJOIRAWe7qNh29qGx%8yLnXnVSP>k5oUA z+K8B3K4wy0N_t)#&)HSxdS_JBjnPq6?Dj;&@#Du8{vFUaFnCmQXNoWLMa#H}z5Cz3 z5*>6>xse5GI-Wzfz8A9pYK{>k+Pe6~C}G9Du2G9mUrE}fs&NX6g-vT`=&AvTp2xoQ zM{NgSx(8A-2>A1ABq@mOFfuZl`PF#P(nyQSR#P){dU27VH~(fx_-ipo`K8s>M^B&9 zSoXzuZs?H|-J?C02DcB6jBo*sRHMRcfFBcRJ27%prpD2z6J8P{qUKlQ%bK8rBGq9> zb*fP!i7_(uF9fr5jjgC`(bez?m*z(%Ck0s8OiWBH%e=j}sG)z8N$cwC2Z}lV@^5${ z_-J);>e2e@A~itU==!fOks*WYt}{bExqFCFjd~aws(Ca7sABrJC$F_przLs3!F%!Z zGmh37XGcd`^ccFusR4!;0!I7Vk}ti;ziDR1;ZAcwPL9&k(^JTE!;SYw&Bl?<<%yE1 za$0G1lYfdK+=}k?=nj11cP5Wzp1Jm!CNW}YXeeQi3L2r1{g9mfXe*cf-=0%B4kJox zDVNzTBP%K@TJJKRvI->~0!mGcprdjIu5gNP$1Fz&rw2-9xHHvHf9CcC4Q@raseYbS zn7T@!cUO0}o`t1l0Qz(QcKf4ro$RDl+7z12^_%p9#)fo!nhJCRhPvP-7nW9503S6N*x_a#QkNoQ3QcCyjD2@&>VgUq9K)Ag=HV>(do-YO|}=FFpt%?$(a0Uein z_X2^76&tqgJn)qN_wVeAjpzAyN9yUrR?Dt8>`zsleR)TF@#L~Zf_A39465catAswY zl!qZNRpVIEr3UP%#CfF`0{mwsBu<9$a4%l3T*ZzX71hbq*Q);XDOvZ(8Cqjt^U+5N zQ~hNp=~uC96Bexi|3vM6cmaz?PtVS(D|wH%r^dH8>&=KWy<4}KvXG~mE*Q+NP#=q-qleTT!HUt36+gWz|Zd_3$1Evk?&oMju(f7I)TM z*s5h1gPp1rv#2K_1vb!HB}2=ta=AzXaGwjIbL4w`j3#sNz0%s(MKibbPPqsaJ`o`S+^>kzTef^egNvUpA zXvoUW&o|v(cX;3V{FI&C>rZ>CM%3!}ETc1Ioa<*}7z1rJQ}cW)?*5ZMW>|Qc_?=oK z{+?mDw+nCf<~t3X6c*OBDpMSP={9@q$a3DB zrNaTySH=Q?}1otx{0dN+QUZmY~C?uFFGA9U`p8Y@}Gi{ zD%=2hoOy+b9e-7{Dvwrs_Q}o7%~fN@hJ{}j9&G%Rj+aI%-S(jwdST+Bcye&>Vcs0= zvu|}Unp4j%4@g&dtZQbW86;*In%rJ&c>J?sH=i-~lWNQB-?JkPt+PM7{=5KV4V#=% zi5^d=K`$fJl)1mXA{{1_#CHu_Y$S?j?T&j#hMvaAf2Go^n7LPNCf#WEtYDd|zdoye zTW4p}+%Z1SjpZXyF%r9gb6brr%+NHvfZi36x~TfZ)U8ZDh$&Go`|iJAq&IVyTOKh8 zPGgVKWitpGD>ySVGba>N??0x{GT7hLv>V{?)6&fFRde$fiS91Tvs{?(j|&S$g>QWq zt+_G)!V=25qG&C(Yv)cB_(QtY$SAy@((BRfyO=_)${>EEF)QoZPhc{Hm`?osN%v_3 zz?AXcn_R8$g zU$dTc9l_B7FlyzT((4W37`sc}9F*?#4o9=gD8`?$2b;xxF*bs)Kxb{&zAuG3hz$ zbcyR?R;xHb*aZl5sFTnQ)ruQZAId+$Rr|>m|m=KSYzXsZreQY^i&Fw~`>LC{@Khyy*_( zotaJrAwBakhD6g1KO$8N3ah1eVWxohxrA_Zzl0n=mF+BvEQOgxxWPeD0*E|gD; zFZuSleYCV35@M1!zC;KQvq{_&Che3{6cQ zyD7(hSRTUJbowGYNa^I{BpR^<>g_F19-TP7{2gO$)t$lk0(+*o*Di(4R^O;Dr-a6AZF%x7CE-DEUn zQXAgsIslEjaq;x2Qx|9Y1D)GSZoNEZy-h?!gzl*J&JSHN1dmcMk)w-I-Vt~$8|&~k zH}^~Ux{5&Fa72e=4CBJW!hy3c#|ga}H)RkAUH9EJD*apwb}m)yH95$9Lbm;-J8cUT zo*mZBwb<*GAh@I#$$TU);0V;L!SV4&m=@Xt#s|g5@Y$vlXaVul}eBHAM>A~o>Kqg@A2U+GBGIw0jyDlDg}|2~KJz<~olze)0V`}kbH_ZrMp zrP1-Gxw#*Rzasw*u=;QLHxFW_KR}<12L+8dd)Fb!z<~}j*AskR<+pDKK#KV)KHj)4 zRaI5hmiceA?Fh*$Cr4=Ku3c>z>1laf$wMnqf4NOW&KOWUZ=Qb9|5i58EYZrO?MxHn zGols>-BAlyuRfdn{owGR7PE4VXWN-*>7Gx*M`KPIFySUU<5{^v+oh3ld!(^$4ig@;ma zU6$Fa&H+8`CUnf=kx(Ud!OR7MDyy-5snTHPGj+<0N?U!6-x$}7(|#LxhsN3_k$6&~&+ z#fmjvkNIP2ao(CDnZp^GnXku$*4LIDlZJ~uHzl|2VSS86u0o?H{t-BJj?<_*_8%KP zNYtH!-S#nc834^iZSosFl}JX0y!iItKmh#Y6~|V5 zG@+!)$;}@t_8M^Z>I+5LfxlN7nOZ>(Ay1FQ$Yybxw-alPia}1{lbJE zS_2vGQY{dFj`O&-Vc9(`%eBh6FHxtcA$`Dws)AY_rtkY)xjY8cROw%`9~tG_|Z_rjq82c*RNdh!eAVPd*cHhry3)P%6E96(4K=&C+l%a3eQev(c?9ugC^-1b_Y(|956LABixwOUfp|h z^YerRJ=lEV(4rKFR!e6m;ZxadtlizRb?acwBL*I$V&N}ezFYlN+)60>lX4Wm=#>y<`~f^E6s)*ZGB*uX-@kq9h4+Gtf5`jt@_X0Yd=S#*gP8-K zaa0(TU%lpgeiq0nuwuC0xO-v~FfIs9MSyX(r}y@!q++qt^%w+byL!BqvMP=nLY za}a3 zsW%hOD=Sk2Mm(&j@Qjig@l3rOE8_y()OzxG8mCEgTg)@?#ag`akIBhkA+y>9eZjj7 zUVGRi6}pTeC%y%uMxA`Ds^Vx|oqK^5IKeWtM{3OrMfa_zX9%q)1%_o(K`fOfXi971 zIr{T4x!ChJQaon&!~xO%($pPoZD~0d#-~N%Z1jH~4#xhmua57{s{k{Of zPHEr5)WDOjY?I-&<$omg+vZC>>tFmcqE~ZySks3g z8d~|NTHnqQgq6dqvHt#k!uI3z6?7`EM1?x8>9o+RwI$ry(-PsW({k^{GutGS`bnwW zFY0a)8!%i_CCirPMm1m_Q(*K)PO{}b6AxdnHeeBO4p84(ig?3ldJij>p`l6;6U3&c* zNF(1kc8zIkg5vb_^k+?xh+vj8U3PBbt!=GG}k_ zcz;0qCFA6J2Z3m`mhjl-UmKL%=Euy^?8Hcqbn?)=xco8%IPlshXGv(9pGRit2rX)i z{K~Cc0upp%Lqmu2q7$7RFRw*iBd~$RF-H5z?_$K)g!Sf<7GqLpkVe1yGNjA(p19K= z63lT|CudJ+8|I1CxJxjWMHEq4!&K`8}r!eK{ zp*G&9A#ZAGf@`UsKWQ$14T44@`mK8F87$6DiRKx&5(=BD8&93&EiEmtwk2JJ0^h9e z5ccdZ#pm|W!ocPd$P`1ci?#KBgJ88%JSv)5PH~ZLYG_a%6?-fQe!fdueP6U2-D{W< z3j9@1*raL+mNoe>2xj~eCfLdE*VWmV-6it&uY%x>Qb#5!F0wu!leQF_Xo&1QcI;T| ztHMF$x=(A$k^(mR!)e^x>CM`%J2CeQ9(hl>dv`duJP)>WVB{-;wAY6nJ>r!R`9RWR zEh5%k^AEhdYohAD)M28WbqmpSJBcw3K-W3D@$tWzBelP$d1y(ox`E*!n#D)Vp?t z-LQXNcWr2*`@K>_h>75{XU~)xSS9Xp?jo7wS!pE(aCs{*ai|FiWW~qFx619t^b5`J zg`k@l`DJK$IC17MnSY*j7vD`Cp&QpfKG>?koc{CSgOb9+1kvsAF8qYszPzA4##1}E z{_br2iIsw|4no6NYY>uLogYsY?r^+zEul3x9S~8i=hp1_I2KNmX-R^cTRuul%6 zqLAPH&|y0fn{Ss_zArnR2#1}aMC$}vp)$(s*0`*uprG({Lz~{LJ?T`ICB)uLSO3Z` zpo@wAGJ_uCyNcQmdDq{AmSze+p!K)+%a;a$;;BJZ>Gil(VH+D>6OsCbYVq>q3S!;L z`tZgQ6pyN0yr{wa@WK;TstXq`j7}X{j%Z|DKlu6cfSW_z5k>gj7jyn%F3b!+ky3Jo z0{g=BpuN4lk|P7MDvHdKZn`5s&w(Jd?tB8NJ7gp4#EIS6kd8V#JITH5930f6E^1S< zL*xxH-R<*pSw%j}lf6~Sm$9fTrAHooB1qBYAWSGwTRBwx$=nUhNKMvnuasQCfq41T zHIjn~79r}k^fq@5sgG7o{}Yia?P*%dZi{_);-IFqZbzslxK*6d89*%YZst+VltY9= z@x8Av>_%r!L4gia%9%W=AA(Y^TJO1wdHs9wULRJZiqqtGJ*LkG6`1HuI#g^dM>{iL z=9Z(BEV|z%LU#+{(a%jGJ^%GrTZMWVV);-Fbt>yq;m>Z+7|y@H(HNTaK=Tyu$NLo9 z6Gr&;b7_#A(1>EWfiI!{JR9@~B%G(%Y4mVo^%l-XizF-V{GW<9?~`wpOV`en18WO4 zMeIsZ=%V?oUJvm)kCo9QiF^+4@N8?Ko2BTspwWxwp5LDi#bGNuk^(5Hz%9`lA z5)u;=dGy{TIF&j5`Fbw*X0LXXkMc~x>c}w@n2YKswsE?*SPYbx5x?gK_i#2om>apa zNAIGG4~^0kIt#CU!7YAZc|PI_FOoPy>Z;j1*Piiz65(eo>-O{WQ>M)%(O~xM#df2E zsju{&9b{FarWiO>D{5aZ$aEYG6XM>>pk_#l?=#%RO4_kN)`nUO}zm|h_mj>io<}6gLK>KzC&T{3#OkGaF3$x_CGJP=)rTnlBgXh%dd5Fvt+Zqm!=p*6~k>ywQ_? zGyMcNHT5G+bHCN4nRwyzDQVU+l$WTkhJ|URaA1Kk&;4| zM#1^C@cH!DL%tDf5T`f@d9mFpWBSy8ekExCqdA|k1|5hd)gX=U=9iMVPrg6l3u736 zO?%w?`LlvbYwa$;d59YP;Lcw3KILQYMt zU)8tz=}<%n;F?XHI&h`!QGR>b*aX+Dc5z-DKIXjwuc5Wv@3Z&k>Xari1%Ve;9Ub%# z;E*MI7#Yck;DI-8=SO6G&?H@Ee^LdrN(92Km8B$ui_DC$6O$ugisGP1I<+1YzwBtf2C zO5gsX=~#`O5IiDjO-(w)SG++W62zuGn$T!g@W{QS9vf#JnYwh}Kd($dn> zAs!w|pe7{Am<^4L+T0#2VO?PKkr2Gu1uULx-K83pbDkXzCMMG-i2Rr;WXj6QM9u~x zz$K9+SWkqsM0`mY30G`w_vq+!39pKJg5twnt_G97diCo4sq$5Uny(jHT3V(T7HXmK z64)99S=g$bM~B<64*dAZ7vtY3i7LFCTB(bDYI6^^lpUcKtS#*&ghi~-Nnx55a67w| zIZI1nKSC*i2*$|dE0~Tp7@_`B}D&Bc{r@%3{nVbK1%3jT29+=p7;DA5A+%S6T z)4FQ}F^&?pBzJWD=hQX-?_bsOzuz;UZ3hk>tg5S{g7Ttr=QOYLtC;NW<~g^D+P;L$+C;{jf;vI;>@e)F(u$U&| ziiBE-JyZycB4)37OZhKroDh}Z)46sF#CPoed}Sx49eHut+Zr52YGa84P_VJ%K!C#g z6eS{-{JbqC+XdOs4+Mci{EvuY_4Mdl@?C|$hL770-u3~sE@I-psB*FJjiG>_VEO{i z8lV8QNQi8qprAk+*OOMNiikq1Ewp_077;_CK-aUVp7Z=K(Y$DJ&5@u?zPnihu?C2+ zP$CtFDkqVS>E}{n@U?!6lq5pX3hyo2Wvx*jf4bivfg??}w&)dwEmTnf!(iZT1TCIRfvyZQZ>_eA*f>9d1^S~GL-l8Z}ysk3G70Niz?1`gcp$^8hoIzc>;U0lOe;_fV`@{|PFL6~fyfMFpq z`tCzm1b)~{N5{!O-j&S;pZndM$5@;Ye<=j@D*wG4ZxR!IaF-PwW2`Y!^WeJvyH;S` zwHqYc8~u^>tjn9GuS6pJ9jBxuD-w@|u8RV-!7PukrtHY)Le#9qHP}JWX;0VLgBKv# zSA*-+yp;@u7RlP&H88*i0}km?Q37p;p(C?mjcNg&)p9M=h)`~LczB`RAh}n9w+Qw# zJjmvi;NK^L?poZ0#(-i!AZqs}bRsDUl!MsYu(!PNqG9YFglBIFoL3@p=o0&n>3SX2 z$s#(8QV8Sx-(ChJQ)*Mx;_cQ~oJn4ZiP!V-JpMuVkerhJxZ^LC#|K4rSFFua zliuL&r>H0HXo`_~f)KNL!|LzTL8Gm901PB)_`fGK2axqZSKCHH7K7u{5cb63dMZS{ zb<5qv{!4nEyaQr*1@R&X<<+>p_v>ZAz+M2xSN?pBRT{y&RN{6KDYMis*rTw6?jyxk ze-qi!E!dCW-d(5mO4$Dc;V!~kLEh8{X}udwuL3@NkOmz1h^!|NBHB;W!n#sWZSJ(Z zoE(XSXx*zpXF3fZzRt!excNSR{#YPilXiv6A)2p-IE##QuZxywj8+zJ0c`Yt9*Aoq#*|?y||cK zfgs5nfK!qePBVP$?iLe~+45ho6@jW$NnHkJ=Eq0~KvFt47JmYwe05FDi2h@2p@ij$ zi3t)315u}F4HGUx)c$amQ3-Cmz0KD*WT@RlG#9fTS4Ivx`~MJ$M7ct zz9|~Xs`;fRQ0xegB2fobtCpcF^h&~YKcKHP0((Tq1&6+%KZUp^;&~Te zDkz_@A!V?u@~b0hg_HIOJ{9(vMl=6fa>q79KW_&SBu7FwVMJV9oamMWPDCyJ8mqDj z4DZPecXe_3P+3V1sPST@7q2lzeWw_eoCbgoCW3{T7gvbyAVvg}5b9zEGOtGB=rXdt zaMTE`2x+Zt*qrc}+9es8m;$i~%Cbe=Q{?)wGFu@b9BW)`?drOK>~&~8RQ9bz&}gad#L1n94=DhS@B8jLn53(76*n3R@ZoR)qd>go#;E8|;UFa!dcv51)J{Qy6|onc z1>FMbwzPr4nXX~AolVTNv=0eKc=!v~@4-Pb_ASMCrUbZ3cECB8!Ed{Nzb@ju3s>{* z9%7hxHz13Scz|xHi_q=K?*YJXRgm8C!UqKfA_O_@01Onsx^k^=B0rhnnQd64Zn?!T zn^;By8Xnco_y9?azz2|=xjR-c89t(=9Y-E~=Yivg3`{@YVmg^;hr53$r0y+5#8ldX zB;5)R34Bdoc3IyyXvlvCDx}bBlG;rYQ3b@9#D?&}4-&uCLT|`HYebMxs^%*TW8#7M zCzm#?qET`2@O8&_KWu;8MaDa=zm(4Gu#s`ueIyH`*L^Sw}`FX$nSt} zTDf$=#ln7P1$JLBiZat_Q7@eRCns0t{?crUg^lgW=g*&oO@OP#7B-?(&Gs4BRGDL- z=s0rL`|e>2_V=gEFfL<4_As^6b#>~zjkR^ULs8t?&E3WmL@lwh7u>3fzajD6;2pF5 zrYcV?4a0t4SLN{GD?!DSMb2j~^b^UT*3Z?i%}dktw?HyBYkXqB^c$PRScp>g0U2$l5w^<%pn(Z@6lw#&Io;{S3V7TL}Gq z_Vm9qm}m6wy$>)l4#FCMcW>w08B%vGRdvhEDX`LqfP7c515< zhjU$H=tfq;!~ZPIDu%ON(>R+e&zLMkm2KK{cB}JlR>J%T>Wqa$#K>w~_5C|D@oE14 zUU_-Wfg^#ork3**+&Gq4%7tPKtGlIdDl5C!ZGD@~LA!|7@3@guQ1Cif9&jEyL?Zow z^U*lBkO&{s3?2r_3p_SfE?}R_`pPMCa`MaXuGP`a!yMa1;!%r}+kO5b%BF~4a!8}I zllsZ$UkW_@m_HytI(_U4P!ZK|b}g&{T)`X3f_PY9M6Tkg@ToVj29T3|gyt3x35d6| z1g**|LGcu>o5%C(sZ(`5_iiM0GgBp=H}mCERt9v>DmTHNMPi9IEE@$!LBd4h2 zB6nPoK6U|4b+WSe)d^<6sRu2TLp5w)XeA4*qM< ziPUfY+PN*P_Hlh|3ePE~w#t{7Tu`b$w6)=6Dg?m&XoL!C&E4IM7#&N~gA|}HgTuq| zz7|^J;8*(v3@O>S0J8aFfqTb2rvgP*! z2fooMIU30t9LyEH$J2Y{^_w|Ml}ap|ykUh0%ZbRH#O3<=ZHKoT8P|_E(2w80{h$is z{0J)mRYQj;bn@-&ThK@o9hNcGXOKofbapR+4lqAwW=E6>$f+~TiCUeYXJFkul{GLt z`z?ljb8-3baNPN?wZG}C_#>zntr^sCJ%0@Jc`~u80ZPNQZ>l<|-=+;E1b?l#%hZ;5 z^?x&+Lzp(`J~+)sXe-3wFjgs#$G{p%;w;(X-+Ft^kX}R2CODbm=SG9+u}<2U=xqwu z&+8Y(w!~CaeW>3-x^m6jPoMf%+{)Yl4faiz_m=p)W?tPhI_5D(r`jU+_am_d;5ql_ zS?mfp1u>(&HhtGnfQRc=~Xrwk<$L;FF?F1??`@uROfW*11(NCE$tn0R%N9eRP9WH_3b<3qn; zo6U0ykAt_74(J-hkF?09NdDEau(VDWld0zm8m;QhwbURe4H1Wf{zkl!u+7rIrETyY zd}zpxjg6On$9N*uK!Vmlq?Cfx@XgdbXg0pF8TZ{gJkey7`X%X{MB&7@9cE3>qkf|% z|HF+wP)cyv<^j;cf4TW%)&QCDnn>FLftiAdr08Tv7gyrEcAVRxE6v8Q4xVMEdiG3R zZzRU^!M`t-Ut2r0%&vtR2`4F2{6DyjfGWfZW@OBO$pzv15o#O|TrI+BqLCCtJJ2(= zIVKRy`sk#@J>|NrXrHZ7EZ-kR{kw6pyYYf>nvV`~K#IT`q(i4q?}yZZ^NaVvOLMzD zeNlqtt;y!igMDR39%QeJgjYCUG%uX&*-h&=WcB#5!vk>CESTsx@};7d1-TD;nXZAs z$!_91WG~Eq-Rshxk{&vteL#%)rdwIW#4io3Rxq>JK0+Msx9opb zl2yke(p!V2LiT$_kWDc+w_RV}F6VH;`}xt)_b3vW;|VdT-Tf*Od=_0RB=5H(bB6KuDM7>M_4T`%yz+lHf5=d=L}?eD$9q7YxYG1W z3`doDiDTu+8&qPx%TVI{MvxIJF)cKH1_Tst^g6w=Xd}yEXD5+ZI5>oR-cjx9%m&_y zjp9fkW}K}90TCc_5PgQg@Er1wrIGfdIe3sbPVw@8-s_^;xpUJt-P;Pxnj@?ePqdmzh>>X=Ecnf*lkXn5Sj2anc?%(=fUOWq>f+}p70KH&@^L3t76qwo^`7nPo# z9>sySdD!_8)o$NQG51}D@5=C<uuW<`&?870j@+Yhx6a`P4 zA>O*Mx@*`Btmu@%&}DZcqowYcixNptof0stj$|#HS)M=}1!gty4xfb9+NN21WbLY2|P;jKnBRM@j5s&Wqs zqF2mzJEmq>yzI2dz0NVm&)>mt!15Tr#iyWyemKck1*@np7B%^7+di2egT=cF*V+W1 zZuZWrC1nONIo^2FddciiTkBGbw&F!$?m?qMyV!c-JE7me7Xf~7{Qrch@@dvt1wlF^a9RQ9TpWOVo;Shg^9UI;q0f1rRnC`_-Ag& zEVk`Z{;iPuojd3sH01NYwXRo8=5fTG>ML^4I{G3k4Fa?f z$mH@mdQvP*w$@_ev+r zR!conD=*E$bDo@8{Pty3v5BS1o@pd2el$yMR-QwN`DtkLk?6!q>ADFIfy6_ODlX=hWysrX_h;`1fSPCJq}XyT(=4x zYq=QotffFYexEVUfD>_NXaTj5V+fM;=FK6?qO2@|G|g0U7`g=RKqA5FaX1>(iNj-m z4PK3QGn^~`op9oj68&JY>zOYNVrfDrR01DIB_vV)%a%o>wBe0iM97uIQCySk9kBT%U$;-0)gvFf}hU=?~YK~5G z{H-b8cObtanzIMT6sc#X*c2SF6-YFgMNlvZ?GL#@y<2{Y_*$uy;;y?{o1CW?+AUKv zs9wunQ3aW4(EUX2hKQY45FU~{GbUbN@dRWo1vEXrA-Bp|J8LV`DtCHxO zy2-z9Z)auIe)fNEp+Zd@&?0)!-Pxa-A$>m~wsCS5K>__TI!%mTpFKJ_<|c2Hc&$L! z@Ae4iKbf~T6 zTxQL`7N>z*e_p;!7m<#;hVGa5_AQB2dg-*P$vteo?Iiepjn5+Ri9Csg({?U*#seD6 zYGqm3_K=PXm_9jet{T@G(b6jWOD>8)P;i0CNvCgpkBD7?pb%Po0$rK^Kf5Gu)BAgI zkZj)A@NzkYdZc^9{y%o>`*w6>#^d{{Lmilcv;^T4lZ`mrf+$^*{vu!l0w5BYvuedo zmhG7aA`Bcrk1GpEtyv7SiK=G{9gaS`mGn6`_s3b+ZTie&BnoTmQx_CM#e*0VV-Zat z@?yYXyN?+a(}BDD!Br!SRXj-n3Ai7S0b!{4q9kBHyh%@ggiysyGA%KK=-3zos5#_^ zM;BhJN5*FI8|H5YR0;&OA7E6g{ZH#9PA&mR<=mUMDs^21$*{SscZrg4mu>n>8MCso zikP|3mBUBL=ht<5|qb-gb0X>As(|JDk>_C z;fO;~YA7cnxQIJRWNq46PGGN;&;QJ35j8CN=vkMg;+v+y9h>-DO64~1rIwWa#yqgNgt3}eQVXh=LQt4V=CshbR31T4Yvl5Z9x+tgZB6dj-*!S9%%27 z#Ru_tC4e(gXvTQN4@P)H7~%N78|xlewM)4nfg+z>%CT_o38@npu>_z%CLwCjhld2sqq4w>N} zqoY*pTaa*hM1NE}A?5eP#7>CJ#Hj(CB|^Bh?wJeDoB1OCD2oRh0Y)t?&2YfFcm$08 z2C5iBit_N+LpvEI-9#Z`5!&z;9C<~60rn+f#ZcgEG!9N4zhH{(P@i_G+2lt|?ZufZ z^Wv#rishZ13UBYN=_w#;tos7CC9KdPjAEb-h{4;Tupf8$`I&A$NhOjThowW0>g-0w z1m2f!aP@l}=f>#~LZdn$Wa>Tl7IqB_I_y^XEkpzqwgia;e+B1PC@@ZlTx>>028jd& zN1T2kT|w)y8NpQr%@7PvwCmh-$GEzl&$FtQ+AeBtF6MTI18S$+BE0YX|7adxaSQ;u zeUJ4AAdMc z6ib3)jjC|pNhCUcea#Vv^VC^!o-^R1BoeMgThBzz1F2b?pb+%Kladu4LGc$2h9=_@ z)gyg0uIE-buyU2$R>NHvy8spNkw2d82CH7QY8=AN(ae{xTgMI_^ZtNkMBcZbG`%!q zMv_LDLNhC##}fziZu??_g7i6`O>46>iTUJiP;cjQ=*@&+TxQ5gIKUpsA(TqwL!gPULlQ+KOa@bijAT1dzLoSX>BPUsafWJ7aK#0m zX=&}v#&){o-xB015to;@C|tfH{OzqTc9kn6IYQx{7^q+alDU*;AngEZQVF<4M#9r^ zK95d+jlZymc<2=-3dsxbQ*lo`*$0UAWF#D#;_DJwTdd;5Ltgq8)9_HM`dJ95W_Vyg zWFU4=`OOxb5>v_cpqvxu2#b6nxV@{d_o`aXgB5a)`tz7tgh)fMJS;*BsvU-RqW&UO zHp2QNy@4EtlZ;I>OXL(3iPsnutO6Dm7ya;v9MU0nb~4hN%*;#&_w&nLs?t}1Q_x*J zJ5KYg){QtsGYmOP@4HyE-`jBaUAMExuaS=V?IdAd$ejo25&17`3Tow&F-&HPy$peI*|;uDid4H75L=XiU2U%_*`ObSkh{v{qdhOL%* zW~cf|HFnHrNyv}h%yCzC7!$_>t~By(PgY=0 zF_3^hsS&$6ruS|OM7|p2))J)Z(GS(qj{L`zi1SMS<2<&(NW)&uF|FA_bS4a#O0fdpdpZ88wN)Mb3vKrJn5B!{;pN}|CQy5B+ z>3{L|=FwcX?f&o;nKBQRF;U5wA<>|cp=8Jq4KkI8jEPW0nWZFCNXZl`geXHqC{h_q zW~C%W%2eU~obG2o`+48J_IiJR{npygTKleiUEk|Euk$>P&vb}3;jn~O)$6yhN60*0 zoh1}hKTH%Mohd7juM#75Xw1-XP$>$0=xZBt-bD%zv}bI~jYwL(uB@&$JQb*{vEU@? zWl=kW>aOhl>xrCqOQxqt-puc@=3(LsDDpj-(PQ*JwOK4&`i08~6xh`Q~ z6Ew)#>ChpK<2nULV#{$p{C}2GGrw5@uU%Tnz`oZ;^GM0XKa)D*#$|E*Yu2<#lZF%k z$jsl_jYP2fuCp_Ho1pGZ6+Xi7LbgOW3(~8i6d>mfv7r#z7HlE#QleGczMVW{#Neo5 zhZqM1kOB(;&b6obSyBSv?8x@c!_(WzL6C{dKqg*aUD(#0p?gH&RpFhqiAN8v+S}`% zTM@MzEoKP{a0u(y{!4swcbgzYfxO_3YmoDD3gIB2TQyBH!BQim(HdT6?XSN^JGlYT zU01Z{0wW+6d&Ja~Py(ZsNUgM#nQvWxLs*GxN!gT^&F(Wn0|nMnjsi;P1T_+Ma2Wo- z$-kJpt#o%XaR`t?6E3>TfUDMk0)W94y^4K~Id@&8FvL4&`*C#QU=9Yyj8~q)+WLdp zm77SyOnOr`-QPZWPD5oXzF{;`Y1ap*q@#zju97?Lh@I^~wh7Jk%0sa7&5@~`z15cH?eX<#9z*^=H|V03VbQR#Rfe7fh%S*evD{xD{UY7o7lqSAQec+2nc$`s^GAxcZRqKk zQ&Dv0yL4!fGeig%Wq~xAFAG5&C&L3#+H4Hmd6os}#hTGxU!c=ReII>)w`>ioS5=aD z9v+Y`JEz-+OydLj zXY?L@|I17lz7oijhIf+b?#wgD?Sf9@wBsLmPqVO0t~h~p6))Sqv;TtnrV+_?Wy}2p z!#YmvlkZ&jTl?f*FIHAoBB~>Dsrvf$UB%}hv{ZEHV1XfHH&p8dPGz{aTQpY*M>c*~ zUv*c<^I1>`E#FujjoVR<{uM9yMYJQfuWw!k8+sIlFRZon#P5insqNljZj>Ft&~Ky35D8R; z76__YP8{<@<%S>}v}C|2aq{wRbr%=YzZoH_8J=ZbLZE@sc6t4oDL(&qZw#_uo_fBz z&7zcJD?L3xdGQ6quj&q{h-;x1APR$f(BmOKQj5|L`kL(qtJFIZv^P zA9Z%V$!rJ~!zNipWQlTS)3fBq%;^e3*4Ay7Wn39&r^uT%8^1fE1xU&TqQ(G*z8HIn zw+X7@4|r{ffR_j`DTtzW;m9#~;^R$3xahw%p&e@%+>TASPJ@nqQ1|r{#)&Obu?Yw7 z`IFP7+-D0}GnMul)k$VxYzz=X0(BVbr2D;RlbvN91 zDk=I-xDZMjoBycslDZ~4cb0$`6iX`UAUQ@jTim* zlpIOYZP=~G7#g@#(4mD(RGyPc$+#2pUS~y0cfJTtk#zc#30@b;HHEV>*GNo{t=8AN zdaZ|st+sjU-pU4c;fCY!T6&8)yEXyr2lJkyxi8;35ovtjp7{|{(06RQf#t)w7`Xd; znHuBFeTz$e{7Sn%{#vA)WP!F>F|8v<2^b%&s30QUiqaDs>7mwzadS z1v-+N?_iB*r(u7TyzVhegoRZC5567y(L8p+*!F7+R&87rspNdE;n)3pZ@j(=iw}0FVNWx( zUA+HBB7-mXdwHN;JyMcUpyuIFWAoWk+@L6n{EC;y;_30EWmUS$gjX$Y90j?;PcV&oMyL3K{QuFUri z8gB7zCNzom0A$=UaG?+q2!TfK9fKL-Mg z51dim*>?B|FY0}~8li49WX1osCB1+{hgo;x76uL)D#ghz^z7Y(DY|_YC$C%ybqn9U zzFfxixbq4Dq}&LSQ(~fY*mKM^)6;M1&8fJCmx7I{FK!CuXBiuk`@gSsIB*&zMS{g1 zvFIVywYVAabh9@Drl%`7g?QJ-#*Q|#PQJZ=(fI<+;xz=|^E3-X1TDWSl(^;dLk#MbFlc}$-< zgjh9le@g6sJ>y2FUkmsB4}jGq+*!l>`5&G5fdR8U5}h)=4`{IuKR+HnERfzTQ|>o6 zxf052dJ0CQwJ;DgBPoMqo7UCsZfYh(45wO^ORc2Ytc`+(8V#kInHFG5ioz`6cR?41 z1u~kM#`^pgSP?e?So8x_>4fw{v9y#Tjs)oQFCty7s1$_jcOLovQ6f@`KtI1ayc2)*7%%J*P#Tzc_d_cJ(ZYb!417BjH_xfCDac$t zZyXfrw)f^I!vuKMJZZQzCK{SZeHL=?WV!j{KzjgEGEc~Ttg9($N z;iJvJ*Uz}j@0Z*teu(4p!h4oKp9p)%X}|UIKCiw?KXzE9VESglD$~UI4PABW_k8TM z@j;}5!quOdt~{~Hk-sgV@ydO<;u2EIfJOWD;coPL{oNoO`oTX!ZB&D}F2e7Ek%)lE zbr9B$MK0FuP}TR!0YkDMADN9mW#>T1&3Q~9}ana)rdKTJ0S@z z7)einRd5~Ek1K#XKbET>!Maw^%mIekP2`R+jcnQj?9lQ-{?nyOsWX-s1ZW>|z zH_CB3N-= zUL;shQ8_sdfF-aSGLYo(CA9QflMuF$K$E3Qm#Tth1z!W9ZGDk_D}fEr&LOj0xodEZ zXoIY%Wkoe1tLF#(E;GfM+<1eM<9ghN46>E&d^5bFm#Cv4jsu=k?NT{Mk9~%hpFb5= zb)s7Y6GHeQlBSM7Y3ewxtfC?aVJr+hG!PM9hKRo0`zH}!JVRM|6=Ev{t4ZjcK(3K1 zt}O+f9Q--YfQkR;e@crlNKaI(aJ3iL?$L;`EZ=dS25mQ(-1?`q%~OB*#L6^cWE8g! zY)7;duxSF)kOvK`&mLD-IT8ebfQ(f5D7H-X0?1K^mJG<@1D{zpNbzYYNb5*qiw{Ci z5L!X9oGA*n09fVyAtxzz>0?7^^dhIXxFZu=1yOEG+d>AYar3JG*UEi0Va4A=S(%nl z!@PWuI{Br{mzunhS2RWy7h`GX9v=)lCj{?&?@wNroPcnG@`sK*$83jH6$N>)dd|Pq zylg~ZS&GH|A9`Lopzp6c`@=#N&5W&9zxp*2lCtrMQG$2SglkWQ(2C^^i|ag6u{5Xz zVhZG^0r!f;_I|waTqVTX6b$nu&7VSwz*36z<bfDNVX}^(b%Gv#@+k!?98|b6oUmeM~H(Au=Sh#qw2AZ%WG~em%XW|@d|k8 z5(;?7egKdNRg88FiwqUdBeiW)LgWV-(>1?)wo(4Y7nUHrCt&xH^C90W&Fg`mC++sM zH@E}rJ+pKapMensN>o)-6Kj>9Om%o%pR7c~@=3gv>*Sw49sjbO0`M5qpsN#q@heVt zsMD|#hmTYRDn}!FEF3qp8fG3qAf(}-`r!qmuGt3duO06Tkp;HZ;v0#BCPx~6@aa_3A#i8b3>Q;Kh!N5}# zC81xkkn=bGBPi8Sd4^^g7KY#spP9SGyE^qZ#Q!Yk$fI43^Oz`K52>)zFT~k@85t}r zR#GX%zci1w&&yXkf6?l)k%&FG6DgwG4Zw|tG?8zfSH1;m%=~} z#@H@HAMTACH#Rnd{AiPb`1K!w)C$xnLb>LD#jcr)wy5y;RCj@1(QTjp`LnTO8i8*+ z#P;0~uPr!QLvL>!EE_9sNCTJEZnz16Mf86;@A!2 zxRG=v1h7;Jj3 zf^LrmA(qis?xgdFF2EYN;JZq{onXB06iGuZw0_?Wii&+9$7yJ>;_T-Q<)!B%85b7n z_)1)QSuW52rVXZJ2E{F!x)vu`W{2-;#MlR=78?OX4~ZW6=~k$P{6osPh! z%Z6e|!`ML&$#m{ zVJzm$SzBAX0?(BvWfR%|RZYa4I{B4yIB|mE#EBCm-5;Wh-7rBRuQ)l|2|`A)Ch-tn z-6of{9SBi%**RICM?NN!g*g_ii@l<8Lv}09pyc7Ck%&oE*wGb|M=fywG1>q| z<-NaSQ7@}o%$;UgJfVBsYJyXm4$G1g`UJiY9*H=Zy^BbFm%dMsVaiGvtKJcKL)F6< zm4nCs>36?A;!AJXdPub&4Y{-h9&sygI*)XrDDraP(b_F{+`aCMu!v5`@Z>}D*KO&1)}P- zj>7_of+KEUlm&xW#P49gguXyr20E%$2!5wPvk>W=DUJr6g)?q$4U><;LMOI*9}{(c zeKRC!qb=>ie03(e+fu3c+l~u6NOvoKF8g87N($fdG8|h+AvPznRt_39M&dUIA3ZY^ zZwaey;I(x+WTPfV=8B4nqVqU{+j+Nh8uoI}&2QgVbdumodtcNPu+ zRQtNk07G#OiC!LQxylEY45eTd1h{QN%Q&Oz2p!YV{@-$bhaWzQJtax#g?#r<=rZR? z;^2N)ajvAaX0>RG_^E+`&B}JXH^+90$MUUw5b`zGN!+;P3b;j4qiB_6*qZmsa&oR? zdhE>%UFBeF>%vVJAV*x6=l=@P6G2r(sFv@9Bf*D$N8*%JcC3#2y}_tJsh?2xZGwx7T|4L3FDp|tLa(Qhcmbod3|Fl<)5qS6U-cm% z#OUyV*ap|mV!B`@rN!TLnqXaqtA;8)wz0jnpRp{Ef@nQvubuZ_M*No4;{@%Bsb5rT z4Br}`iIg!)xbScjztbG8rm50FODkwgwLg&DVm5I1ct)gkLuP=kXEG4hk7~Gxk(K|U zHos>uSL)jHV0j(jVJs-orVNOSk91@}W_l`c4 zqt%?ZuLYwDQ6~GP5^GR0Sg{ z-rk9LQW#k2Ac_MdGuWKAr=bxA;KPw; za{2j-u+RcMA0HLu&W0m`h&Tj^-UIDXzV}aCMY}f?8t7f%urPj2NT^xUPNaJwtmw(% zP6gM*U(6?crp<$UWf7Htzy)F}B<&qIu$Rf(R+7{}#Gql!(Pj8-k5>6FnZal#MC(W3 zPis(JgCZbhs z@NZg1Ix0skMy^}*Dc8*euG-U5k`aPe5}7}#9kJY5Glt0Z^BNi&{V*HDBTh#lfr1e# z6EdZNP#@&zL#td1MNGyJko6i+OAy<=cI-Zfi@C$ubO&7*bRcAhLr{X@6Jj7m_xk}c z;|I0REKx=J07(r+Q?@U1)g@&6iV*qpe~ay37^7lEdZDYkJ2=_ZU)~m>4<^rtu!%(R zj#vh*MmUrMk*@L-pxYm3L)%?s!@Ch*3MB#rCnWJVXvYi^kF}G6Egh_R2Xrr z!hF7M{{wDL8-~kTUJoBen{GfMoBc@(PrU<&3JJO(!Fxj!yJMxSbsa@5AjhY!(#qX4 z=G1$2L*wZI8BRXFFgz^{O--Ab?Fhr#L!v3QZAdUJJa#A%$45Iit`!rjZf-WrCb27o z@$`9t_J%M;@DGrEJG=Y!Yj#M9>(ECpT43R20E0|y-6&j%&;a%$QDAH}|6J>EivhB6 zAXKD9?O{OlQ(HjO9Hb})h7g`ci*q*vU2ZK2-K%7DdFRq6m8XTW4wz);Lc@SaTZLaY z@vQd$UV!(Pg!{AvxM3MEP+;Pv+R+iz;P~BtuM0Vhgc?Nzg^}qL?gE*+MsPGDNk&9k z8s^Zz1RYQ`2W)Q;zZx+i?!m{_i^8T5H9j@mOp6b#zpWnf=HB6r9wF))G1fe58i&OM z4)3~oJrqaTz|@V|s>((W2)PbCTVle>0;OyNPgiqmYh%kf7ewlEuiqDl;Y^5w$!WW+ zyCGhxJsx)TH3PWOw*e?Xg~^Af8#{P=5J14~=atq=kSjrQWYAs5$E`%}C3SV&MVWQ+ zy)fq|rvy=~LDUQx-T}|exMg*4uur%n|voPrBJVJ2F-G|he_4Mh} zq=vh2!Uurz$_u?aS|;8Edz_M++0GJf+jq|%kp~1c0$IEi9y&6+3ag`K0y%cj_#LtD zHmAJ)&d5W)g%LD(@R-^ciY}7}SIk%NpVTushM? z<-nGc{7Gleo;7D}H~yseD$=FU3uu@p+~9$vCLh!pQp_8KCOS!eA6LL>A~pZ_r~comE|)_PCy7PUa2kGZ2@O=9%~wK8+g`@J@>J#?7oYQU^xVhK zj%$cIuaLW_KYWfi&mssSiKoCx5rz}f@%SGPc81$I97@liD`@rY!@EZ2Jq?{fQeOld z-$YPIG6cxkh8>k89S}v^ul;;K-vX|!oSPCLQUIu|Y{oH4Gdn~#f6J!VP~UJm zikA!u`fE(l@=p{54%;oYEMpUiUo$+q<PLr89^>~=PtNoF2d`nTp? z2Kfh?^3L`_ed)gb6rkGRfyxIQoW6Gz2XAizVmACUNtxKJM{K@_jPYGp{;hNcI1 z?Ig@*y+tjAr%6Mk{ld9f>3;LT``WyHXO zt@&i$V7P3sy4uy{g&W+~W|GnN4mI9W?#8Q>S18;cwRgb$%atg|uiAnb{@~~>i_4Hc z$lZ%^N}rye**FoS0F}+`-%oluFLEX6m4etKr+8w%roDEu`QV$VXUm0%tY>dP*TqQigi_H%xGea^aayPE2>fqZnHQw{+Cx)3$5N7QW5^jD>8N0(~eV( zeE~7_L3WUk;8)Y!*WZ#WdbsOSS_n*^@ikH0oQ9_0keikDA#zyXb!KqthW8B5xUPL? zd|2*9lXgkh$}^nParXlkKJDZb_}yn<1E02dYbCqnWj+v*p<1+HQd3nebjD?t`H$p*+Q+-pV?mRz6MSR!VXM2hD}xQguO?SOry7a+X-B+Hyu7}I;zgqh znMAuFE171R@mK{xLO60rq6XlBqfSn&*lqnz=Zy-qS2mTYf`mvj!-~egi>PrF%A)HgGm8s}fWRIjM>yA|B9ADj+2Lj!7sP z!5<*DG~f5n8BB3lLLs^Ofm0WtTKH~|+ji^2DQ@4dz&pnmP9J$6_PwS|d|SRSH+lsZ ze**B9;D%ZwxsfmD>wg7SEJEBi{{!Owd3#0b4T1x-ek*fznCgx(Unv;A-ot#jHF__( z!eE(ldy@M?ra@JVw(#ozgujdO2RL`vELVId2=XZg?O&JopKKPAJM>ty#7n--vXVYo zxclhh7YxUbli(uAF@G)CZ+D&gx4?D7!I+%@d<534{ayTmn+u4ObN9{dD>?OVO3FwX zlpksdo6h$aKRk(!b* zD&zRuM|PZTM+&*jSM{hr7NG?g$7z@;KhPwGB8Gv2cz9=R0cxfjn@`bDC^HMqE??4$ ze!Pq(izi4ec@46`4tLw!EJ9D=`+M=WRHYind{%a}7 zw@jcI;yaIGnf#x|5wvMX4fiH)XCH`MB_*?QgXvNevH6SGl;xUns5sUJDho{hucE>} zX>zC1_vw0V9DsW8O9vmSL)hmpp#MYvObphI$ygGfRd>HJj6t$OERGC>2!B@hgwq+eX-cfu-DM*W{GF4lu~?$t?nnyD_y zD)OAKr$+U(-Gz4;nbv7Q)ze16Y0(A&@{v(uWKj3vxC5yU$6Z1sf!ua3nOpn|Q)op} zb)#|bW?`X#&cnxN+`yg>n}%qMBBtJo*DPL=#WTpHT4F6yzbgJX5hL`5oyf$(uY%F` zApgi@;+Y1-Lpvp!C(or6MTBj@p}U;?MUWyU4pqDUF8NABKC*!sM?jRu^C7Ry;nEbz z=H!`;5U#%_mpj2DbRm}&z;UqH)>Xp%PawQG=>@3WX1rgZjrM^o;uB;_qO;7i66Azs>62yHR^xSx%1 z>@biU`8#eXA@D6Cs8Dz77rw#~WQed0v+z)@pBeGPTlIL^>vz$nSleZh7a-Kl0;&uQ zEDFE@@Bz0G5lH$E{-T?Qm)5TIew9WajY#|l*fbw)I?Y!7z31RRE9l84?@zZMB9Vy? zhJIq9K{z>1UIvQ5>2SU_mwTTktUacb8=X$q7)j&x7_fS1I{v+5zeMMzqKBH_Y%Yx_ zR|-pB`F-I9zLW39325fC4A+p5D0KD#xI1_d@Qxt`49NvQ&}?HXphs@_$ty8hn^vw| z`TTf+u_eG4?bDA@5P(%NcHVYhV>&K@XRL7Qj(61L8&m z;6BOINO(5ggSB$1idOl=t6lGU&S^KYI5`>Gj*8#wc}RPpqeH^f9nEtjMrm3BY(=WF zIFTA4g9;zWY+05BNQQr%{y0MpO`^KW)p_g!NmKS@IXe*JK5PWVghsQWUG#Q z;}xNxzJ0fz%^uze6Yv#u>yml0OG(xr<`L~xpEChPi6453y~YqbcV4F+{DPOSohVg= zrFjs~*S^Dha%4Fk+i3}c>X7F%`F?!lcLij8b`F;yI_xi7e?Cibgy9k3FI;pAz!-^Q zGCYom0ssp{H6Z+x;1yop-k8~jk$kGKy&~fU(>;cc!YfTQ1{lIj!cXyd2qA^8_+Kgu zK*WX9tMLqMjIaO^37a9{bQ0+PGGwQAGlhyX!>|VkCSh>-^5wCg&W0PVWZU`{kM#XJ ztuW_W;XS+R-*A9MB0V-bs=R``uc3}vv=C_z0b-AiTjz=17gNPp!8EGj4y3!GpBCKe z&Iq5JDk$3dG z1Pla$?&5I7@9fJ+1}szy$ksG1b_|0Mh^UZY^rWLu0~|vG34KFIZmx6YF$5emY{2W@ z6bi#xmabT+q9)RMFb1H3X9%f(+6%HD-PUSo4h#+W^*?lqY*E0~7|#!_j}cH1ZwP?s;VJx-ksuxrz@O;a6%#1RQ3|90j`d$m>UH%NgT!4|E;FLbY0PRBdy5 zulS`eTwZIx&e22KRK}Zgyw9b0T%)VQHP-!@7L`9(OXHk_(6$T@52xT2bM^GB zPv09&!dnnqc)~#iZ||195MrRv#hkUR9+E(8(Qk@!Od8HR0vF6`_yU|~$ z$ZB2m(-Z@d&2tmn5%Dq?G=r9P-W-)ANd_nhf)FwYYE#a-js-S<(D~HM=8tFJQLQF~HDU_yy2ko@sd(=!yRV$a{U`2{xFm6?_5^nE{|; zKii%?F>6J(@;`l>i=&Y^r6DTbuRFlJ)}wLu^yud9zdQlZ&c?>^@kjieY;o zy+N8~I!?Mi`rEdFUyWX9@n{iIAki)_en`%~El785=`uv?tij;%TeivAJ^BF$CA`Nu zW_UjH0h0@Gkk-F`q8>AgScS8j{8;aYhRs8VU$87&p^L^xX)WH(Y9ic&AtB>%eM^gi zPL~*-XC8>KbuAh|EOeDS=Zwza_du_%mTi>rUe&j(`0i8u|MlfdJ z@pv3p;Dhnxe(cip*15>-7w(U z@0gT{e&HrB`8y7X(UnK}`MUR`d6!=v4~evT=MN5wGph7sg8d7ESV1L6Rwf!(vNf4) z(Enl7KBDt$Qy>Q;7%_I`<4(UKXU0Pvk+ui(z?#=Z<2PYqN9IipQWDyQmif|hrKWKX zd(g{93f97{E;`cGCGDc~_NAf#*nH8{mkk@4@<0xwE-S4`@sXC4AhoY8teggj-|%ZJ&F#}B^4@Is&SW3MF-DRx0p@0Ji*dP0qh0FC2vKvX3C=J~*67@+CM?lam$@bDR0FX96vSTWG zSd{-Hlvd?rTWyp5M*02*I>F^kM%xpLl^g{&sW#D_e$zRoRH{-M^8V7wl&NcUa`_T5 zx2F|=!eq#&6$nZJg0hKJX{|no;56vTmjY&e24|fi0x`(^_AIz`$jFr(5jE}095f`I z1mny}UuGgm5(2%(zz=xc{ z2%e5=sQmMN8%7ulko!keDyK1^%ge0Xixcq`yP*P!8JPy(fTpk>1~?Kw89R1|*JPl5 zXT6cT$@ta!H9QNf4kt`A6~l|2SZkd8Qb)23UFZ51T6%=<=9u)K|0eof<_Ob5lb@*4 zugcp*L1LKcCv42WF`QJKQNho#DtR6RwS=r<$yHWG>^mJ#<|hy^1dmp8V}WdZd?o`Auhwvf4IeC(R#NefPQ~@!=x@atLb2b{v#?-_&Y2Wf$XcJ9E3# z!Ky2j(F(I%7#|{+i1^qZ;$TUfeuE;T7B_t$sIRUPg<@VJake>kU zCp?_KM-o{x5P9``_fscT1;~5X)oPJ_B`4*CZE&?rl67tSrI`oj+ls!wSt4L&e$>=- zlBWV>Vm$yOB9+4%~lD-L-EHcNh_KJvLC7RU*#=F(VuaP3rZOCn+{86Q*4@V|ly{dTZVovU| zACj4IaX_In&znoU*mPQ2N(Q<fNy0{v2g;v(q0g&(>x7&_EuX;%ZN_5gTl>nzH3Mm1^ca&X@+O4G$ z{>KbIlyriW>#<(7>)jQoV5`gggtr#TE9?%N`qnCF0!uWwE!ZJ3(1VYpTh4o)gL;QZ z90w{te7p&G1f^{?SX%iLuLNHvDeK#BLqM}c@%@|#EE&a1xG)ssWRxIA$&d@faf=HR zD^JY8UCb{is0OLL|JMf-5^Jz}>3m;ra=oU>q#w2LQf{>1uA^lC710f=H%K^#3W#sW z${2`Cu&=XrKKeK&Q=(G0@odXq`)ipt{Ez%ETzoX%AIunfaeylwLlO&WX6K%6oK~)R zStZB3k)?0?Ywg#J)2|;-zwDO%eE#w2?_2iXa@t)h?5E0J#h#>WO>e1?pg7jT=Xdye z_3AHTO2=)gGM#Ep4`ya$41g0xx;Xc_3aFkJbF~KtDGCtq4tSI=<>W{@_LMFMz50pE z6=OdF6)jDZ#&-ZsrTQ^oYs#;soN+0Q&&)swcpo9~NLSFsP+3AbEe`d(xo zkG)O1;_D6s_lCVi0(t=OL_%su)3azyPiXUGW{PB#vm7*JwHzK$$$30cDD5=f;vbUt z)6(kIJ=F;Qjr7w?Y5tBkZ6Gl}L@!wGUD)je-jU}|rK?YVeEdo<(`y?LjA;eO<#lD3 zRgmf-<68A`q$chV$G5bHOk|cOaXpiMmm&o$6ie2qdHJ3BmiWVraTF7Rx$+jkmvvV@ zrdz!)`RTUT%vOhMYBCVhzl?>RI(sov-Qts(lM^BC{?AFiH~a zE;R2ydOZ8cCv}%sjBxSr1mc(-uw3}Pl7zIMl_rlXE51Xl9PR5gzotC7cD`L5$R$okX@g;wXknZU2E%Ip`3o@RYGDB^=?szms9@i zR=D_+v$Q9GuUmQ8p7oj;zdiF~)~CDNd;g~?fTr4`>KzR(fgEa#&C>VvypofXe;{CS z5UK$UDC8*rBR5pLhK7bnL?7{XlUxl*&ie0{^j2fzV?hd|p0a*UPK(h;^U4bi)uM>H zx&81C!_tN1iWHG`ICv}d_q=#W1%5cxM}NZP>Onm{R&8xgf^VPp{Vp*5fudyRcUh5rM6(7}1QNxmkme zT6Z%G&L&3(6y;U!s+$kU@|wMNt_Mk-=AY@FezR<}8th+vV2^uN^@sf@#s}+~O3Q}Y z%&Cic)zjxEM@(4w6|ZT+-fo02(3o44xpg7f*mI-OI!tGJ5eTMOx4B0!otj? z19>2o*GNe*DjQ;Uqpy8l>!43(aXpaSw0wY9L8-k z=DlyXY*C0dH?(=}cFOIla@FUw-N#SL8s@CZSuKkq4$Xb~-YeuKxcW)wF1LjtcJq2D zU^ewcoDB*I36Kv4M&b4%_bT3@-&5Z%rKhh&R*8w@UQ<1-Kfjacqs?#gM};s1wQVuR z+yh1tjo3uYM*oFm#jrpl0V1QIU>z$=2|K!P?-B`UK6APqU^l-=#ov| z-j4Gu6>$vv3v|d=u+>cS^aA=7gzxSz9}Gen!yWRUZY9E^g}<*CtT00mmHOuc$6DzZ z{_fxQ`Cd_bnTF-cxM*II7mB`7ZH+FS2 z&j|mimbf#VR#fAGDmD4+0{+>=&z~kwEX^|?1S$g_*VNP`Q4E@zbS*6{WL5}fk(%WG z9;=vM1+?*j)l(Yhqs#QTUoG545EHq3%Ouz@gk5_W^9|yqtG8}N;@uN(Zg?+WzURH? z$+moYbq9x+UOtxJYsF0yfgHh>VA%XDAdX>j@y)&YlFo4PgA7_=Q%6B3vx zxVLtpRuV8-zQMvy=6;wl+3PC{d? z`@OWYvlGd1G?DeKIrdvfVmY<>{NG2iK1ZB^!(Kgd3_ za({LzqLL$kFLJELp=(8H;Xf~fej2kr88;FkuZx65h?lF*ja7so@=s~A?yk)|G!)LG z_8#5GC{)3-up#9q{Moxf65N)b&jC~<1nW82R6MEb9q$?SUe!NLAFPx=vYu7FOuoTZ z^|ZT6Mb16^kO>Cex+ScjpnxIn7C3<8yOhvKkw`rtHktqv9-rN`X;XJ)Pz_@dq9v9< zkrGU%)&fZ*lg)7vr--d!hYv3jTu?mU%}m<6c;i#;eC!8sgFc|gBU1pD(k$V%wu%su zm1ReXdFbKEWZ=%! zzP=|nhXb_C&2gHCjdW-Q1<=gY;&(t}`)b{j>RV9Lz|qA@0&WA>+{#I_ds3=l-?icYxfgh z0hLwkmcEnAcl;e!*Hg~h8yFm%T2OGUtW2*6Nj;ZMJ9O4JG1o5POg*&P|M%c(W#yHC zfB|U*HB8^XTQtJv;tQoWCkKcBVt^Q8kn@3-;r((bKB|Wy0a^}Yb|uFxeSXkUtJkj8 zsF4v#)cDu?-n*HDaVbJ}jAqY!94>rZBc-=@FB6#qfxY2msYE&*W;1e7NDA8ZA3vpf zD#fV?Y8lOh>(~1sK1_6`C$A)9^RcF_19T^&EyzYstP@jAvWtHqGI={Np4@@JVMT2( zvLC30?sMoUR9yTsjuwv6n#E_P=FY#aeDOAG+_CS`cEGB7bow(}K|z6mv2ozY$O!#c z3@q8St8wx3wN9sRvi6dalq9Ouzkm7`c3W87?4I;eS(THM)Amkb@hVzw*w+g6PE5EF z_`fAX=gqahN&j34<9#hJPhh*KqiIN>_XYMHGZa`Xq}0&g(yxj5=Q0P2M3eqvr=ut| zJ0xfBfnBqx7p8Rpc7>vmb)KWen2hcj_fqMuICs!Cp}bs$nEOE3$SEn2gxY+>reg?# z;!&Nj9CM@^p|>QeSRiu5z(Hn9186pC+%GtsNj7Bk%7mGdlOx%>D`YL8A92hcl3+8v zf}IGEt2O#jqmup7cD$`LIQ5SC?6@Vg^SoKBo=Ca{^rtI^QJq!vkNyp$4f+OTgt%!zlFGvz_|`r+s7C_K`M z&dWp;<}lT@yH@W&wEzc46@|M{_W<{%j@KG5qYY@3qtrX}(n$p`H0YKsTXr1<4s8{n z&${qSm-wtBfI@}SF)*B7nI99wlgn0n;rok*hGSUv_#zKrf+|Gh6h3RqmMxA1zoEYN zRaPDn3J-|;fF}K8E_(}?ML1nkYk1(>*4Ahg#Vdq_?tjVT5ET;>3zji^boFb@h>q7r z1qI&7dTSWoq<8MLbUXsb32ha!=XCsDC~pMIWldd&zNGD&OU2|0+A4@tp7W+{eq#cu zy)AVe2{g}&Q5rx)P)FrsbdX$~IGgD;oNTtxF>D;jIP`|iR6cmRNx5&SH4FALa#xP+ zyQva}7gQ8O#Ge+RGq;L3iU*3Ol&k9#T&_5^Me%w=-C}a^bQnK6HHrdTw!z~M=0w*v z7-Es&x6@GQ!`BvL#w9&S6grAz_))dHbe{6ofKoF&a3C14wziD=gA}16(E;3R2_&Qh zph-i{)`8Y0kw7%i{ORfGt~+rmC^TC9cM%tq2Y1QgN!E2IF_L!yMHvQ@dX&LDOr<<} z#7os)$UQs%cMFw*Uu#qXR*0gMABtf`uY==Sr+73OKNq^f)916_p8@ zdj>*rwS)wdjg3vBGrv7F-=iRA>gKSd8>N$?qTo&Ktte~zR{a+IZQk!-u7{lv6k-aJ zy!)_0b{A}UY*crIJP$+Z7ko#nh^Mf%9}}=5(xUQftk3dO@x_ZY zB!ZHXJ2QHS;$e_!!YfI(7j!2?5Q$Yuqk8HM7qM?)#u%9sNg{9y1#2&!c@rKO-+tKq z{`UnYdp0sfkp#9xmG)qy4P{_t)NQPE zBwUfAU}hc1%|G2G!RFotr;00VOQ@CgMjkmE@-V;OCB@Z?ZOEaNDNtN*MlWDkc|~eq zUh7&w-`?I^V8*&4`(%a&BVs}BYkGNYo|apN=R-u=8(#08=L-D=r6AZj40x<_+ zYBY>w`Cw>Se}jQw5esHmiOb6FIdEVFCcB;U!S~|bc19Y))(dz_K@tdCFCW3U|f)5gj8>PX6 z3B$Au6c!I))_aKw8(1E77^gw`52_xl1r&%rw+`IIV+ke!HM|Al1M4(2fpSyS;lqcG zN?(}4%p;1>?4=cbryOWtoPe(_mz`cU%9%cRV zLlvjh8vG8W_XnCSCLx+pE>n#f6OMurI!X(r$1sqEo^|q1R^6t7g--I-ikh& zT3DD=#D1&*JpgLRbZ4A*Y8Ye+g>LQTp^mq2?;LuwLa+3mz^%Lq;qBWiGJTlSgFwn( zdt5XQhVUtsk0M^jQA$R}23}bfs`1q;=kQFf<=%k$_$Vbz@_XEXqvs~JQ`_UmpHcZ} zJF{)jeq+l<@36S1rNSYoV{Ur=*sg8(hV*sUP0uqn)1yTWz&S>|7|^zdIwARwdl@q` zNuWg->B+1h4NlbLw3N^IQwF!#JDcBZr@27&_V!XIg$r`CvpKskn5VW$XeU@aylEd7 z$h*4Ap*rFR(M~j;|NJr`EpCp>C?3ZkRN;ZYez|Ag{X{@?STcfvwP4}{&B22Q$*u$Z z6ZU6$Q0HG9?%ICD>(PC%9TOG%f7QPwtZ-apWoKi6;7WMVmkJ07 zpir`gi9~$E1{i<+52>l2dQ(8O>Hv~z0FD#v{(%z@vuVl~XEE-UzwOHDyU1Ak#ly2Z z5-9ei@bHt>aa<$Ir4!l6k_~$*jB9Gkb5Gm zXfPAo(>=YI>?N>malm$g<8kx0Z8fk`WM*b2I7DNI$ zIbQ-fL~rlq>V>u3RcMV!PD{&Rjzq7EkrCJQ7#Lphhj|!mpIcEa4DYXVS#k2t<|#%$ z#3{79-Ahd5Crt~^E=@hX%cfUuM-Yr9Fp#1M@IeG=IG-dWB((JOG=csbb4dYr&boJx zOkxElogJPjHHyrlZtuuTmmFHG9lFLY=4_gpdm)*ZdpiB=Ex*&ZzxVCB&@lZ@1M3Y5 z+YGn_V2lo>Td1N!y>#zpcNRqFGE=KL7;*GvZO9TOGx#VnDh(nM<3|;NcGoihIEe~>`)WVLgCK9TcY|QXI^6efe*aFa zrQA*-nAe)*)KDqdJ0MpOoScAfI?aieh(y5I67r zm9z*@YTOjb@6U$Z{8&a#PPjmw+^Ip0b*6Xup}oLVrwedkKtHjmM^H4V_~x3@sk_0Y zuU!f&umBD31HVZ_t>8aV!#9G3ZPYbRnaFS4NTwrGWaz9@{p{c+Spze(2#~ploazmB zqvbH#zkdmZ(6lJi-OC@iLBRq{)c^Ho>*_$JW#80e-F;;xg!V}k&<$=cd{tx$6Cpm3#z&^7DIgi2rw3Kuvh&T7rFbM6;6G)=H{(Gjo_NfFo@X{}kn8!w5h=horj7kEpJ z|N63@q=;inMj3GtTP7KshCL!^hm^^V{LE!2OFm^*PfSmLwq<(LaVWYT7mS+eB2rrJ zpHmf+k-7g=q545-PU!NZ)nCWPEL|QW)nx?%JOKER%4xm+&6Lzs8qC!uGqM3d(8J&v z^nSH=2kpk3VQzk@HEU>rCx6ZiC@d+dN7@!aBoaJ$zVw+)&c`$@`PlwOT`&AsMa%Q9 z63G@9WsTl)T01l66s_+f7&*7DeD+fkqPl4jQE(L8IE>RouLtz<;Nk5a^K1%rRAxGA zmrvimHBnk~r`+6*;rS2F%jT4pCd!x=Y1x`nFKcQ70JfjflNPGDkELqfM=fj8je*XL!efR{-RihswciK{i0GT{KJMrx5@E<)L9cwI3g|B3{ zcJmER1qWN-9zS)i`4DjP?s-s-s~=Q6`MGlW@><*+R20tLn-5r6Y%tVMejuho<`--G zv84w7h)GP$F_;?}Ia!QOK?$S3JHR=U@*^%LX6*s7ojY&&{{2x;dO6%E_3yrYWuB24 z>FN2vkv38%F=vjB0{#Z+V31Md*KGJ(<^QifWI`G;-MvDPmkgLiV+@Bfo_{9!oPk zp%ISY@ly=^NE}CQwG{@oj>3@fvc5iKi4`_-qX17R^SXmhAZu$BZQLjjFTnSwN0U8! zkE*PS+{UQ}i^_Sv(1kPwBa zISVjh;B*&kaPP8|^neLV^Fm2~*wmCCb7gt{2;R|466;ts@B*&>V-Vok009`iB>Th) z3uzQQqVu?5Y+PIc-V+7tWlPI7r_O>Pw25IEnVAK+kHxM7YTw_1X2Zfd^s1vHE+H|o z00RXI(G3-l3&7zkhY;QGyyUad<~afgla`POL+M>u(sw?o>-)EFJ^kmnwr3XYGj9GV z+YJnH`(Z4r1ho0gtgJSB_wF6Vt2-L4zvC!=-puUt$B&`y2xNKk&O74D6~j&>+R9BjkEKsb?YYzN=i;ph>`xr zZ=%0Kle-st47%}`lpOXnwE1EWZ*H~L6Ji=eVZuk*9zFULh(FK!dOVGFTfIHTYBiTbj*z#FK2vq5QGNXpV71*{C2q(J=^3b&9BCEPD5_;xGMM0TOL3! z=jRe9hnoYISV6YxRwA)=YvQ(EKxY}n_7HfPB|xxwD(}*~!q#ou3W0xQPuc*$8W>@g zjqs@W`Dzj>l+P&77;^6s=)+$Ev@oaFlw6yLqVL+=VsOxyPW@9=B?qt23UDywKhXJ4MZV*=q)R5G_Mn1*rYL!5j>& z^liP_4bPujfeTt(eRu&6A30KZxPf3s*`B3#*N12b4?$HFL~&9 z=+absc*p+wu@pN;@lC-1%ql1f$yWkt@7MQjbKe5 z>pX>a_#FCvMxt;GZ~CeTc`_gp;BSz|c1Y%Zel_o*{DD`o4fRHp4 z&=XSRSu%qavtZMG&FI%Z}ylA1#OI%FEFm|O7uyO{1)&XsFSaM0P0&i!pFyV z6yehol~HH!;opKQkM;7!IRq|2zqwIeVAY{A3_l_g5)=a{y&;*S0f#Wz(d>S26A6fU zE2c?q3JBb4KqX{CJQOScPj}b;2<5s)r(MZbBx)y@q`gr{NJ`2jiAYTta+#7iMIyPI z$lg*$q|-#naSfqSD8?<93b`ebB$pH^G&DODhO^%G`72I8YB0X}zV~^awbrwi0D%hd zlwO*hJ%`M5F%maIcFxcvrvdsGOEx#0o15_@ZB6LtcB+;&kH;gigZh)-d%*GBM*odp z0s%&teu|8^(AJ`Z)TbB((N-?jOGvm35O*P!J};sPuX5JHtZPa$M`%;IJgQNz z+{iS0Kldv-9*REPgdn9;I^@aiE+J_T#Pty+Fk__NFn*-K;!(sI3ZJN{TwLS}_v2V2 zvojuo{i72&gejpMj{l=NiCsaOXjtp%Dg&ua72#no7yj~#^AUF(@U_k68^;zKeGhHI z&v53F)d!rBrpn=YISs7RW9bGCc$4)+S*|XWH;2C2Si@E!PdunS1&?z|#+1uck z4tAY4v|2^n(^%7`?DTn5D9W&29q!@1cq*?ZCel6MO0Mtv5$Oic$K5k+-xi+2^Gy+}yiesneYyuG=lV_|4XDfg5V4V21RW0n3BzS0Sc>s9 zEoA|f24o8Ud9bqofF>&{FHa5Zx;@*| z%hxv?0eVm8R^}=$ce&Rnq*x9ZuiMfO%Z(Fp6XWBeODDm^kxMsYi=Hhs zix0jL7kAbdy{+(@;jzR_BLU|(+$9!w($ z8HhHS40sq3ggaPMox;t>a-M4+pslfodhyy!G5!+hHsnGtws^rU>-vEJV3b)_{8G)} zOT?vVgNn+9q@=Yucke>`Zit`a9q6iyg_l z0q?fLzM>@mLcL0I3sQT^+EKM4)#Bml#cr=3Jeatnra4b;4+F-jCzeLC@w!8eiu{@zkCDA}OXhTn4R&qqTBjPJ>Q5Jvp{{SMT}x zVCkI!qUdV{g@krv(7<(B3N;>_C9OVo4@Y*eE@`}W3BRNn(W~L{$%6?pt8*R*^P5i} zVsG5Ijl;eAY{O_-N=hihJk~#S&y@1WeMh<07Sk>tRbhEbL3&2UvHr*5c^nR*Oo%QF z-d5zqFflVTge9G1_ocKt57V5e+}d+8-8a3vU-$)TPTn29D9DmP^@;WW&I`b{M{mf? zX%fz%|K*z|8gnW2;pzps$$~5vV z4jrn_eXnq8d)S%9+n`{9&P~&8emHZuPsdd{sbU zaX&Nu&Va;nqg}nJXIWRd0MeXSDqQ-I8CXp&hE^%IJGbU6!RhMzLZsNcbv_8GO!U#fij~k*5f!Gx*FZ zqNs&J80<(y^lRF~B&Ao;!r|ga0JliMww{Jzm zsQp6^!=sFWfq_tU0V$~jfw6OTwzfoi6dM~0|D{8ZT*BW@Oia+uDvD;wEVspVMW+j~ zxrxkpu5$Ytd)``3syBP|i~y|+$J*4p(9E@m#TCl*zZhNuv9lL#AjzUdljNFio&$2| zBC629(2%kfiBl&V!lb4`l9&P>9)7;W0u=K&`3phXtcO^c9OAg@Z?o=;KLx4iq7W^Eo5<=WFq zjL{vnLUlxnDDrx`Pm>J}4)$m|9$dFcNlA?H$yn;- z^q*VxXE%ER*yS<@FtHO$F-ccejxRDbC@T*H@cDd_vqIQQf>>aim{{=x1tTvczBo{A zK_DE70*%d>@Uo>FVlo39zo6x%gu-9HLFM;zYQ_PISs5#IeU#0Ef_6+9*W;ew+EuBH zI&R_3Wh|H&lnFgsVvM3pQUfF<8)|NfCb!h#RV2@wTkUu(VP)Xv45$n-|+q){l)FHk@8(r>Llr)1l)-68NCP<|{lZXiA(Ft2d} zCT3!4`aZ6su&}TO4--jX3kV9*8Wy>~(<@~^8zyO&8E5WS3|=t4B3;jCKpEJ*(B1b{ zd;2;}LWfSD6oJk5MsN2I!03fiiZ)M8O}!47zlZ8@#ZQw{VPirMT#^#Nl@k@jj`v^K zgeJITT*-MrLzH(Fsg@6uN+W77S{U2G#A(~JAe0`l^ND0%@(ZUg&dwjr_Q|$PVN^SH z03FQAax6J40gz*5Z4GB}0bol1E|qna3DcMffdY!ek)fNyN{wxr;jWO`?fAGBJWGl+ zvP&SH1Pp5+ZbN$Md(g~IZ&6(%VOzo-iG*6iG`KSQ75tXD(!sbwV*WQQEG)1Sp**=} z>(sz*T7JeeuhZ`CR7=Y+e7~uWL*@LO4jRh3Xa;+1HT~b!vyoztBrq($2gfuq*UmXo z0>t~KBlr%A+mH|PigUYSvOOLz$r}iEIuZweDI`BiWA^_3wN6&PVy-AJuL?iT>r2dmA;Z(@`49b;W351mq*!ay$1qB^(CJ{vhnYm+hrz4aW2tM6Sj-km9?YJ$cE*8u=)id|@N*7lmXcfh z$BeCPpmktQ%=$VuBMM4@0-T)?Z8SASncr0VbMc}@5u21pKY!j>)Ks9sFH`DG*3i~B zOg8S}O-y(;e0$U|nJY}68B8_ll^ZLu)SUVb7I{r_%Z=?Ec#bvI)EKE`f}e}McjsQ8 zfvVPU?xs5cnz_IHK7AYrNd=ZtCSiot=spUf3T{RT?a$f3*PI%=nZ#oG0d6DZquT^m z^=vy{*toS}_{^ncYaVk!)l(Ay#?)aYNx`=g(Tysqst24Z8x+b>Z`=`*<+NafWCSWX z8-ga^vED>RFeIAMJBo_r%cC~>&C$HPfLJ*LH5aRsR(D#2*qMz$shxl7EB5r@s>}T9 z85C67-g2ix*4514^mgP0qDyS*?7X?a5g*qYM{Vq}Y;Fr`XD%#q%88b9%aN+(2 z5V97c`F+{|O5+LrFi^G)HVN>$=(IT+Tm#fP9!Rvf;376EF&q85JM!s9F|MGf>`y!5 z4cB@M{gd$1(gORzY`F&I2>@wpDs`Q;P5d04M%ulUik90RKQ2yhGx|Bz2j1iJPP+HD z-aM7LzGTVqC_dEWP0ybna6{|1OAGr7TG2rsP(Zr*}l zF*~F;Uyac*AABCOl=qbCjgF=+m_7lA^b7?lPd9{oRslTj9~ek~so2DUL=g4ocX&iY z&tm;1q_78o=2vdnSQ=(Vn4!gY)qR0fBozw~M1tBIG_t91tr;DIbQrR9%aK?NNE4)$ z0cKR^xYs^Ei;yXK3aGToTs#7sjl5&9DdAA+^D7`GG6i6{9cfVvEVrli2Hswkdq-8t z4D9P?+&AHPdY^Q3{02QloLy#C7FpCXz%4}RIXo1VO#3%J8~S63>Y1FfuqGp7`z~?4 z)BR&(F?f%1aLoY~!s?2Fq--nME`vx#SXKSNExlm|Qzb1wb$ln3ZC+?h>u?pShyGS4 zqUpO*;gI^FVFj;tQ|;Ja0(Sc}p>q<#u={m9p1&7uZ3x%8e9>Nke(2B&pa=q#&caixNxvA|2TxkmKq-JZ z7w+6S5$0{gswLZJxc<)qY6DD-+13T+Y_ z6aI!L+anPEPrzAP$63SP+}X{@(F~<*pAo%7BOOJ`>X zCqWJl+yCna*zFxHIK;d)S>Z!)9ONE5p-|+Z$p6sVM#Mj&P*u+IQV%rUKder&X_CJ)%NRiv~%=68@p^CntPB-3=v${Amxv z;UIxOaaKz1VaPuznY1V(|6oQMTkyaGRGvZ3fAJB@1vncVO{eHdN=zc?=;(u2e_;LIUH&=Xbp87E!gYt(w~2|N%RfJ5xUP+J za&r2Ym6g>>qN4&sLfGm<@OJucT@!TnS7K_-QOg*-rjX0^Z1DD1(|gWKeGjJ6`mk;Z z2wbJPZ{uHAC-iJE2aSP&VQFP0Lp@7jsV@z~_js9E*yot1_joMoVt;>MPF`N^S>3it zU|pS8q*-NU<>l)e<>lp1pFP91u&_AYY$XZ}55MzQPHD;_O`c1wfEv>En`H#!=n!1to_; zb8jz^)8dczmzVK2mp`R0OiVX;9sF8bi>Ps0q|p9)h5nMUi3tfAS>VTyH}Z8#&_zW> zmnLhRO6=cbtD3aXz(1TEY+#^vcX!{sd-v*^kn7X2QnS9FKQ**<%sMKpM>wsA@1O)o z4fOT(6W_nbK$TZiC<>O}9Bc66Lw(fv^zd+R#L&phENdau@!dT$T+x$NZkLegPjI78 zTZ0K4TwLxt;S{B&(#b^9KE8uvZ(vJ9L(@7rNlVW5NOSpqnB&gotfceuz~z#Xk~dwZ zOA}QbqQ^@NgZYn1=ey%t%Pe|-ZEQpheb)Zn{+cpT*yA32KiBiQHtz}jY6p5u0_sax zSRn_I{bOTS-}Bl3aCz+QE!@)5ve_$k@w7KZBs3x-C?o_+LPCOP+x=l)T0z0xd79eV z+Gi2cwm3zntA&w~gy!Gh1wl0Z-dh>rcld=mKG-NKEEdJK9WQ^Nq!eD;dOKFT$N&u$ zOhALl%*@Oy8zaQc%{`c_Av9TJL)c#VyXOGBR% zTDIW|KPo1$Cknc-);cf0{H$Fx_ujto#iviqO3KPkwi-{K&@3-6Q*oQ)zkK;}T8N&D zho|R&YP8G;Ch1WqWIN7-YKD7P%zP3s8Hhug*~DQ`uVXxsMf6_x%Cuw%A-$j=HHy=?4LdP0 z@uO_ib(B8_7U8e&@A(F5<>lqw&rbzX#QY-L+N5%Y>FLqLE>5{=X=zo{Bra1?Q>Xi$ z9Uq?V^lts>iV?258`un|*jM9Zp`)WCZfHoS_4!fz8+u8H*_I&h-=f}!oS67jCpTLQ3&M!Qh!F)JrDeCmz`Iib0)k;8huXsGGvM1yPLm$#a4cQzr7_0 zFFHOxIEmXrf+^-pTidOf`_n#le`KPr6C^2NK6x@hy8`J%`MWbpc5S@E$kH&wZL(BdU_UDRJ3Qx#}Jc~YvuU7ZSsZOm6egfT{2r&K`QC5v4UP@;oZQ@`Pz%MQu8XMFIGQaj zFMnxnM#1HTK+^25O=xFU;?+LRDn5y6Q1-a#xH5bfpXNS3gkL~P%5_%lLVOMmj>&ot z?gM6FqK(Qr>k)Q-etvNk6(Ys>J6$E`TfaNGEcv8WTiYR86~i3t;O68Gd>^kjK$4URte1qK%SS3&bi>k-1>;9$s}yp~!%yUFHG zI=^s1m%q(@eZ=thIod_nMUVcF1_lMqpXk-IL7+lJqWpC$o{?8K#>B)phsU{H>xoE6 zpgi7R!@IG7kbmf5miQON*I0@Xr20VGC;hbb6jBikZq_#E?#dX%lv(9ozj zpQ7>$3bORp;aa66B+!Qoo-hyfo+s;NF3RP6`S>8<63PhT(`Z4mtF8yHY`hZx1i!oo7LvAI)lv7TYv{%Q^ypg<)J9UX_D+xiC!k-K;AmRRb#AYt8Dtm6Qcm|0s<<5g$dx?(f=(aP<)}Z? z(IF%3u`RWH20`w1uugtxm!^UIen=}@yhI#^=5+74#?QdfKz_unYW%acsi zy0C3}_#B+&D@|-K4h;?QcKTA9`IS~34QU&O!AIaRFl6Yhz@OL9xmeBwP3_&b=Gt*> z&-N$m2RyG{y=vSNh;t;W9T^!32gTHRUt#-@nw*>wn$Bg^Rx6R%pPCuJ)2)!f4k&*! zUSbwMKCqxd(J(MW*CwjiXq`kaPMxe8;Kmodgzw$IPXP}@dm)Qd!n7f(qP+YPs=wC7 zIyx!o@zZ2xm6XupV$Q9ZSbm+NM4vI>FUzkhe~?mO9EW2+E>hC@P3e525y zffXX)(`NSW&W=XT0UWmcxA6*VX4j4ByLMBxj1NDN0I8g4!Iy z$-_f-aWZq!2CZFMRrQTd@zWPMs%b9=&*3kEh$Y(E+ryB{v7Z*UdMqawij9Lq^8ESp zqYkY>+wSivB2RROb2XS}S60}NFCnDmzftSD#_FFRh@&bxOF%?KNl#C&UbnOGofQ7# zrm(P{Zs*8|wypuBbcy+Q>gN!zBve$lA(j|@h?SI-00{?5oNRmk`0)cOsbV-aufpm? zmC9uGb4gQEQ!=X5yereIVR;D&3H;7WZ>l|Zvibea>vc*^!-^{_Da0QQY%cXP1l4YH z+yrchCXN73o5^Z5y&6a9w$9Gi5Dc&3c4lX1=?j?sR5G)&SjyEJJY68*13!QMJX&R+ zihSm1jngLwM@QBH%GlF}yHVe#ru1!Q8uj&>eD!ZJGvm>hPL}~dMECFO?%GZ$6YAY)x6$;;$YfD5#WYzSil-UikgVbw(l?3W2F#tz0>*0J7 zm%oG2@u>n%*{ZgSd(zs2XI<0T@87@YgxCf2YYb;MxA6V_Tw54v3qZoWiH{F3vJA02 z>L5XoBjT~2*2_0&(7kl|G7Bwp)A_MAnz*I4^=lQeGtCde9_j!zWk2vgmk7Tm^hglX zZDU#lQtJ4SRi$vp>$;%x(v3AYSLrDgkCurXH3lP7(_4QBa~b0AJihrORg8o24^+js zZ?`=6{)V}%42Q947lr~-el_EJqAVCZJ~6>|c8o($CK>=3MT;MkqN5I{h*%M=xOON z0{{#N>yBf?6F&+(fFM-&)q=KI;(g@!d}~hn`FuAy5;)6sQT`^3Yr95^v-Uy2sSVIb z8vu{CWFfaUfb4B!Wfnnj^tU%mi)9+N;8C*2R@QkP*yXM6lU*LQiMqWO`!WP%{2K%F0w$`udb_8bcs`TpD%5)C{0`@0MhVj89Zea;{2A*oaQw$kt7TyLVwmvJ4isCtcH zWYJiY;`&rwLdG;q8JU#{fa2et#WL5U0s%R+P}WUeoF9mxi32DK`}*}O?Kka0J(da* zTG|gQ`%X^ByH+gG@pKvbdU|b87=uz%Q;}IQFM0Bc0ezsedP`p)=ScN5->fq9rGt$b z%IWFpfw7G!v2&f=#Ce{hvMiR)7e_mbit}}@Yu#!aaIh_dgPOXsV}X1pQRxute{_5| ze?71|>d%nP+OEGc4FKE7$cP>l3gd=?_DPx7!7DR69DAdbj4LYc0Bbl<*cuud$YV0S zmt^@fJ3^<-JVK%}bs}p5esy`Ix1?P!9Dt;r$=YxXnmvUe*y$DB$T()&&0IZk@v= zRI!NFy`S0HJ=`)`_AkhTI|#W`MN~D(oa%kkKmQxe7}1^WT0j&*uVmRzLTWgp+(>hx zM33d%Jw0E+>^fhY4BbvR72}99IxQ^?X14F#kIb$Z+`U^M+)LY4)KWi@d`oQmz_sGp zkfXBsJ#OwSsT>Frk4h)VN~;SP&yuZIYZ$XqefE^|e> zS4!9Bv=Yx4NDK$1{kQt$?xxew94){Hg$s z(W_3raWu*O%R?7uXXi||fU)+@PQ{HCA-8q8c@M^V8a}(&ZYRZ$LBu!d=^xMw^s>!r z?F+E>zaIv!AZu)Jy51uIVu^vDp9;Xqm(dcFe+WEB6%FNh?HkAK+t|=SQT}lH*y6)F zi6$N4FPfUfU_M3$JSiEObcHz9!}F63G*oVG?&iS()|(-Du_B=}v9nzbT0v)QA=g!L z7&uUJdjLc6t*uw|R;;Y71WnDE*=3m+KJ2R~~Qp>TN_ zeh*|RBD3*w#|JlQ8JRriE2LU$Q2^}q^!1$ztgWmb|2fRl%zX*pvrr58#spPr@cGnv zZJZpHNn+4lJph5a0JP=Qd8esp(YeK;Ye+95*X}Eyq zx`=0{^8^(90XYJgAaqKMrPGYr@Y&edcX$8KZ3TkVu`4jt}Sl>Hk!9=f+u&l zQq<(Iv!D#nRIy9a(lUp~$JjvS4hotOj)>Zm6{zseRWV^haXPMv=E6_)e zti0Mwl#i@6`gR6kaJ|_XOLMgH6CNXs5hD4{`v;14Zg;hF)#n>bk_4QXq#p65q0=2j zIB)H*J<)6QzWSufu6=c^3_w%bAbw!pD3;keisMzi@i$SE*wWOJ({;gK6$J%cyUA)a zIy$jq{{BDD+WV=i2Wk3L0 z&cWf|`T5JJYRCCkEujq17QeugL=!i98+S0)yV~>pyV299Z>;lS{CV5afJ_N$fCupD zL}>v$eSxmMJ*8p>++w-S*cEPzAAyic22H*Ztsz9e;k<&Aldqw~v0p&5{Lb5FwEo&R`uImV=N=+yGqf=$S! z{hf$xw|yO)XK-}1TH3g)o15(Scf4q($#OY*HTN(HX+r_@{f55(wWsHfdj7_HZn6ca z3SrEpO*LXie`3%_X3DKd-`%%mJ-ayGvqn9B+?1APl(M(FwpL<2Qb@apB0CPx@umu| z$6Sw(>Ai(6ZluGj^>w9L_XBR7vRUecQv9V;CnVC6zhvP+tV!$KaWMRR9M%j{J z=!WwnqPeRz0y;7vmF$=WC2+BD;5ERf_wN**wC&sRse+S=NlLM{$a?I0x6=4>k$ z5M4psz0yF41wz};fF_YB<|m4dfw6V8n99J(N!;JxPbK7P^Fp$vtIPBrG)fBKh@@eJ z?}(rYfia8qn&JaemC=cb3_!97TMR4?Cvbic4|=(ky#L8jE!Vp3vg?pyaEr(E`scxUD|=X0z&I>y>3Ib;+a;KiOBZRI%<#Xd=@Rv^Gg|q(vce!D($HKcK9)e~T1kC0-!PzbPD{BfoRyW8>s+_Z zL2veBpsT82eVJe;?!$+0C^QNf#e9~1D44ZnfsZw^w~vBRK20JB*UWJsVpTq{G?mND zP>lW(5_h!Y5Cj#4*;l*NC;VoD4;0DJD?3UsZdk zt*u?qscm9nvio-k52=#To9K@dT*@{kPF5|9c;IV+E|k{Mp@P}WA0}@48#hpJ$e=tl zBNX)W-!jPH5H9~d0m$ik@1rYmtXiy_;W|KDJqLhQ{%i;rYLV>Ir#GNP##(4+xxr{n z1f>)?JO_Bl$TOnzJ1tRniQ|P*d3bb0fAc0f(RE?fXTjYAyJPCw`D0?Q+qd-fvJ}Xn z#$!ZBM-QBg&&<$)3=$d^_66v?Vvikj_(gJe^GDYNB*R>+zWTZz0D3(#SO+%z4(b;q zDnIZknkv(D3_$A5Dhu?qkNhOta5Cf56gjXP{ZopT=Uk|*#u~DVdmzBKw4e#d& z7cgubhK-t@KFV3EJFzo6h94$;_E(d3zx0=TOlTMAGBVb^rnuVzRk+xF%h>yLtJC56 z^XTJ>1nDtDJTozq?nYl2+M?f1aWo#H!yF6U;WE?1521wD1aE)f)r^~)Or0}*_KcM= zKmr>frE^2#vK?c``K`Q7}Ow>#UIJ;TXUBS z4VxlaG_rxVoQ3;TP{|dt_(8k7I;Nqpgl7x~KUIY?-P7U-xB8j=1qM#kCJgtp$f%NM zNPLv_9HnrO#-W^g*QGsg)ivhr+wBkG@>k>7v^$_6l)%9PhjR%o`f%pr97{w*1lV!o z?LS=#ow{$~sBhH+bQsWV0WJlJGa%=a7fk>osZT)#k((d$Gt+(i_`#DWLL~%1BWn;AlDS zrC9UrMfqtG5k84fi75xo#I@kSz+XU4dK4y{m3{#ENWsM25Do<6%~0lC7=PvfLt{LFvwiGO)% zNp7lh`vf1O8RQ3iH#fJQR53dL%#6gu9?oSV7yA` zb$stg^i?oJn*!tv)K^dT(u33=b^zX3#adSu7BGRu#HQ!8pRU&%kyloZYb;+~T~%%J z^@;1)Z2Xh(bEhus7mQ4|a~%|7;r!qhF5OsBs`4g_cf0$zRLV1@)URWk!EmOz`9|;h z4Zx+b$16o#P76K4BV70IW1_NpX}=wu0IiqEWg_!p1%Tym7*Q|3anVpQA~m?dCXg!X zBj}&MJU6eMrb&O|%jVTp%i~iyNN(N!iacwr5ZZDtDI**;yYmE4?9C&Fei0BA!m^P} z%zlR7OiR&cCboNxK&}FG6}KWt$*%XtPE;%CioE+gmY}VzEzoY45Mp+3Yo}LCaVNz> zg#yAb3hDEJEgl`Z%v3)&3G)2{gHZe7*6*x3hq6?Xl>4^SFiR0rQicG(zvap~tLA^=8dMMY6iUnF$n&q}|CU*#tIA#QGJ ziiFE3FqS25>DpQsZKwFuJaU3~TOfTb9%@k#6Z?meGAo41s&SAseg=|eV3Q5ftqp`o zGSP5&q=?A1KHY$aLaMc9>C|Ms=N>Lnn~&+6GH=nfFha`>A)--~k}nAWhM>TvS$b$*Rz z$TIuO-)PRfG3@YnkOg=?3?M}YRL%1y0oBP;L%5OMw;6q7as;7WV)l&!)lC$F97t}{ z7Gy1=D=*(Us#~9|2>_KyaV7%vD8;n7-u07>CZyS-iDzlwoDU=M7d~L%%UUkWD%7#) zNkjvA1DM_~t*w%?SUxaIHJ9t5u6FK}@0(c*aBFPrNc$EbayMT(%rYwiL?c6SVFh3s0fi4?jRZ{XM-gu z9gaPJ$bchN&czbvu^rb$LZIA!#^4>73@~MYtma3}n|#m6Felarb7M9)H;I8rBNZS1 zLnRrE-^I0gcA}(9A2`<|i;$0oK6`P%R@yY$Hf`Jok@0qS0tZJ$f4VdtGNbmFnkfPm zDg_^q^4U{dO?1`KdM2KPDS@~lR0XSKDEBx(X!Zj_2^z8e`i4H}nf6ubWcQmV zJCwAv`J6=6z}Umy~tph}t6s37)vzG?WzFqj?y;KRFN?HC3_`|Dhd zPp_7iEVCxw5Y-yShB4#5bA2p|hW3W{g$!tE?aePRq~YSTw&97t?(6HLb(v4)l9ZGL zlKd8zSvMKI)bPp86RtG13>o8Qf3%jNA+2|_pY-co2W)KMp``m=oa1hs)sIu$ya*G1 zRV#K>!SZQ!vAlQeNg%T4fFwVw(A0M=|w@bg{k%2+?@c4Kk=X2!m&w9_E z<+Y&pFfj3N$De5~a3eDq%p@O+d*QE$*Vorurl$>9i`27Txo`iGZ+vb}NlMB919Rr_ zy(A?-VB!$)vhE%pVGyBg!Xv0QBC*pq8OilbDPI-gV_NX-^AdIWfl7FHxp!!&LX}iC zHoG^z))=XXL-BGa$fS>y6)NqyC92?vpkoin;U7zlOl%tg>$RQ%4ZxkP~O623?v-O>C zCKN_Iy{a8(nXJNJ#^^x9`_480c@p3U*}}cv^BgFwg&J76^ino9`Ag6P3irT-zyjc{ z9h7gUTbDQ9dv|nnDD1d@qub6btOnw)^zHO&*Qe9|OnFk0D_3rQH!>_TXb_(0{a$yn zSqMQAR8v+a79Jkn2Br*0ksOLay9b)lo4C=u0qS-e-;lco()e{xj}lU|+3v*j3TD`} zcp)U!@uHidg$r}N1a^td*u9`k#*+-lKaU@%DgXpQR$VVH2(5u&R6BI+^8ElUCm2R^ ziudv3b1PrbdIcwo*$azKD5KBVcpjl z>urXhcD$9kAxhcZ=sL#8%={Xm8kvaRD}t zxI$*BRQX%-nTyv!f1$n*BBBHd z9x>KSm?m#pLGDIYf~$s}9kToE&G65kUK@*YOj6gAxfFDuB0dKN0MLlI(PY~xzkt9j zP!@+v8Bu-E$)IAT!-#PCNBrA&?{LM$#9%_eg5D;br4ZK#y-e8RM)x`}_0FDYj zj}{vRUH7l&hXG<1U^+G-p^Uov6%dO1;oi{2*xc49zW^eQe)9%pZEcNYnr?hb3L$b1 z^&X@wEG(e>OM}S+k&X+Tpj+wHyOY4U)B=JsqDE(pvlpJJ@zGxb=o|=!lUbOQKubf1 zQt)eTPW{)WJ9IjVp_A25_iRD2or6x{zW0}WcW=+g!~{cIdqkqQc)t)L7s&E!RrWJ7 z&dxlLDIh?D4EC(Kt82Mof`dTeAI5BiJ`Py1C5BlAj8L!O8$ZGc$tfsE)Hu%H2yBLN zIqo{%W8`UJ;l{R5uq*$o^0ad@h!OIQx*vE{9$5Qnz zJ^I&o$|IefSof0f5!yQ?rlz{O`q$~v4i%3jai#stxxvJp{-ojU41h_0{+I>^2eT$` zc9;3M^hn9PGB!5;=aG6WnekOjX{^Ao$$*;Xv9C5Sgk5e2Jkt`l4Fh=OSco&DTKBT4 z{);n6HZ;?SUulu1lwWJNnR>56AaEJA1k$b`su^|G19E|Yjt{f8BZb#~BnySW7{4H7 zmplpWzaJD&Ae=x{LkE}J?eiNfDnQg0S5?VcTHX$ih(K5l=k>{}fZ9VNBRe3tilN?Q zW@o1Z>BLGG1-`!}Nw?F(=Lpd$KHT*Qe0Em|Xzqhj%npngQk*`B`cR`b;e>O&DH>WV zCo1p$ND;vU{|aJQb8vDBh>If!PZ>h8+|jEh-rCyA(fUjQl*++$S-+K1%gJJ@pF-;+ zg6qOmpp|2>KFUvAsg;eS{Q^~Y3&u1mpK8%W$YG}I1BoMp(J=?g`^1-eKi zI0O*l9A+I(;C>;w`>X5?y^oha39y$ClaNIJy@ZJgRt$1%Y;185D-k20nOP9%F^IJW z(NSt8FQINRF(GQ=Ke8tS83xqK%#~rHCIxlkDp)c!OYcU=yT8b{+6NC#Ff=VR@j|z* z?$(Op;%1mIB0wdKwG97f%dEB36!V7~X#!3*ei(S6dt!t0B4{~yZ+EvZPm2xaOT%{r zAyBSz@^OiX5Ytl-9;KuHI{5z}`xYmH3knLH%ighq*qjC<{Kk|y+$$&O_Tq9UT!Eg}~%hT*MBs8%+&!IT2#I0f*MB$qpDM9f0)GleN4}NS(B4 zZee4CkAV?z&?poI-yb^N3q$TEOePBgu7&r~Gcve9TfUM^rof<*aC~$G!Hr`%oPQOX z=Topz_4TQunn8O-q*c(yKMN=z_S3gu6C>SS>c>F5Jq8VepwRLK&4Xhx=SdYiK0ZEF zvX=l1Z{MMmK&){9;zN>>t|Dv*%sdk9Z{YERGHDD#7Bm6Y?M5VEmqtqvJoIW(#>i^$ z>NN04CU6(XY5HH-sebd_83$xg0vhS*?oSo4kNKo&a~MWuX5Ip>KUathF0ES< zDhTQ+gsy{wLvttzg9G$EZ%~Pck^!{`NtpeO86qIZguV8a1Yg%0 zx8owdBDnHEFhT+L0uzp0bs#Y^v-w_}9nh`_2ntHb%3=Yf1`~M*G&SnVmJq_+cWhZWqshj;x_LgmXVRU2?Dn6K9sC5 z@LM7xH6DE+c%p(2=U#J={jIC5eFbi(+t*|vkJ=Ge-~|*R`h_3KPjY;c_@AdkbJvJA zai^pHnPmjr1o%)7L3hU!ZU-1|iy(*yYF-Hk2TpJ$M1eGwpqh!KC*#T#cl!PN_dy&M zhaQ)opYPbC|J2y{B|OmOi-TpSI+IR9q)o%{iLl82`F5Y6R?ErBi9?@sOctWA1A4&t z?063_&C5Z!4NUNIAqW7*3pg}NfsU7!N(sSm!)X2kl6s?KV`*TXLcPf_xexiN_IUgB z$SLRoQ258JtjnPNO3BJDCMq04+On=lIKq&KP^$2JLV!Af*02TgY|jS)Qoy|KyGv>> zY!QS7wGPrUX|MyV2*s{zx>PhY3^#6wGnOn!L)>+=x1*uJ=a=uC47A$qLBu4^`nu9? z>J>m6#S8n%Y7!9Y{nOIufx^c#G&I~@o1jE(0_K@)@}o=jJ04l{XdhQ{7WkX^pon2YVGO@6LJdUWZNo-kio3dj01=-8?qVHt z-TOO)IBkON!Q_CnABb_SJOVbh&$Lsl(4VjYa%D~Yd;SlmvD~01DHQmvjq?IKjfT=K zw^Xxv4hK$>`#vg)=)a^=wf1)%#)Lo$TuOHzb@h^z@~SANfA&u3XI2KpKk^lb&v`{E76WA zolH+NW|UWJ9f+RGv#5Jqmh^Ei&x?w__O3b4xaLX(3;0Sw`#&lKBt)dIKP8;N#$DM2 zUQnpNugEf3;j8gbkoF)ikRx<9z_q7v>jRvW;D)a$Pa#GJa(cA-IfI^_-XQtEE~CCK z)AqFGFNXWo)L4_pdg{2tskMYNieGd@()d&EKn5fTzs`(5}puH4ZnMgiRa=2TNx zSI47hXCMlqG|*f1a$I>YWf<$B=Y#3B6SN+rt)FC%wa zd_X{}S5<$pktDg4dOeSMEGEp<_*9%Is7D&NM@B|MDk^vZ8YHel5UAz8{qQbQpK_f% z??+*?m)yheO)7WDBRg)@1O3+t2$rmt6Tk>boOxBM6x-y->~Ov?mg;LH`bs1|i9AfR zaWmeo`>T(6WhJ0SWm4mG-y+ zv^^yE2xhe?0K!d_TWJ6#MFkN8R>1z*J^#M#gOxlB1fYp?@6^sJw0*ez{r!==OUsz-0QT! z%Ss*Lai2rKHgmJdn_2`eCVk*0uLy>9r2d!G&Z3(lzniZ1IG3Bs_1@vHD&f4}7D}l6 zio1>IEgT1sqZ)45NF|nbU7qE3x8IQ+eXWz5|DlmxbmQlh?=}UT%e|6Ek)75?HlahL zUI~Z0Ep+H`2+&`1*NG9(g1e?{j7xE1;NN>xhdY0bW^69xg{_gT`gPPVZrdC8QyO3B zzc|=r+G_HOg&S?nF!Kpt>I>|$k_SNS7J!jhcOSFz?WaV zc!3OtpymJ+SNr_vE`qPYmPsYxcnb>n&^1;#M<4%`hF6`vqG+3SJ36c}U8vss1Y8wW z+~EX!JNechKVfw0#wp%ZI`*TE+idhbqXu_eJGg97r*6M>_YGx%b!dmFU=uh-BWOLq zi+s{GhPs|kq#|R^bpK?gWdn=jCdS-os%!JoCLOciq0@ni?eOO- z2{wJ-Clxv*js%9CoSg$PWb#wkPa*CY&BBxb8WZZ$0K< zUQN9JrmGP1XJVim&_>m;(PDbmo$G?LyB_C1>_=Mal55H-6!-D_95qp)8ZWH zc7xBhSsXsKif;21%>=!`p&O#|YD)jo@L9n|qLx<{v1LPMJjF$?9Ptokq!+YKG zF-Y|A>?yJOPi_<{*i-PZqk*?lX)OwK=*|!=eeTN+XDe7X;_d+50ula!bq2$s7;q2F zcg5T{VG85-*uI5`!@IlAR8&+?VQzXB{tk{vA>%NU?2do~$;6N8j*DKW%P30=Ljp0v zXGApIKNfW3|EEUdK=(g2nmCD0`@q?RWHk3JCN)-W4*SsM>$ZIv9=3$Mk1(w?Ti9Z{ zI6)3Wyd6Lobi$NLLP5cFccj1n0rVX3pS%DFxoOS|B}!da(*}KERXBi@ua&$TtFp2) z!LC5HU(QhEg<8qaoIveDD1AB&p1i0Vumb|@d@y7Otox)Dd{0t>o0Idnnv^u^x=$Ft zO4K$H%adE59fwmdGLa!|OT#N8%lH7E0buR9j408UVPRnsHI0p;`2_-eNRK8r*epxf zoL-1piAqs46CBs28p#sqaIqrv(T|W1%AvCAxlI7Pj0}+c2xJeyB0{k76g^UaS>m9v zWMQFmSiKHJazb+w2 z)Md2={le}UUythKy7=Dq%;sYV$4O;N^P~N~fjMO>I_e04RdMV2IAwt-ysPufK#ba0EP>s}rU@quy8OiYANRth(Ai!fX5~!Zn(O7BfW@ z3kiU1=YWxP6Sl)(=mr?yC=P5FkW1h_7@dCsAK}Ot=)aNmbT_Gt+$-Q^mZNpdwvK2e z7Dd!w%x|ye2dU_D-^1sO2?z)nLFl`~xB_mB%`WED=I(A>Ak;vs!~$%jTA<5|ELK%J zEk@S1A^?}y;|giWmLF}i+xv%edlRN8elvz(Mw;-*2%90A8&;Phl-$d#e`XUP zb0XLP=HSB+SPGJ9COZ-6xN2_ya-?P8-o4`eHm&O3iAbjdhD=8x8Yi2iak`%(;~zh~ zNTFu9rJSzDS1>ONi#uS|ms3=fgyN5Q^Ife0?;fM3yid`&tBz;S%M#R+UKA3FF3*ON zTze;;V6y*Hh(~l*84O&AI0c(G$m9sB8`_&8Ls`&?!MmX86o@41m1(9=a|ZzrMz1*F zB}vH5v%6|-H9c5Uok~xq&R|f^j>fTv=>$gBC>T2DAm!zhdfu(*VuMhHNl8fw;(-w$ zcbKTvoyw8Vn|wIq8<^^P=?3fl2dvz8E&T0I>OMx9=zle(^?9+K&4sf8%M8UpdLgzK zz$~(GOaA${C{eW6u0cV*3^bl*fv#3^8Ul83!hClqoZM2|qpj0$=g&U;on7%QFw&hy z>ZesY7ful%qkH%6p-vP2fD}h;U|_Jhy)6MIfuXC{A*SO#_9&sH;<=T$&s=U}{-iKJ zbceOs%fYKWuQSFlQyA(uY{;RZfQmrWNZ6h10}#e5i>V^ZVx#^6_q86q1z$bo0C!n?0TD7b?egA)WwNM_XG#sY5*@Lz}z z5g-PvS)dIU8Acg2c;+%_!d-b1Nnq199up&v;oIS@+{wIjl192dLj8AxnkLSg!-i%37uEM4k}b^-cAG zGVQIyE@srZ+jT{=dslyrWz%Qxy9Sq}HBx6}l*&P^gK2CQDh-U?i9A+UV3fj^Z2e%e zqMRyv4OyrXcIQME41ukmg|;6*z&o{>W{BkYq)#Wrh6z`^UBDW zC3WngE`WnXp#a~sg2TsA*%h(GhXBF=fB!5ns@2ZR>heMI5Czf>d^a(zmair@E!M}b z{#~~GU1sobl*{%;j6Ullc$y$JT!p!Ul=-3NGasm8pjybg0Ie*yb1OU3Xm)yMQ?H&h zY0U2X^E{J)sTus1K)cAkpZ$*v{dXVqC9)F$-G+`rM6+cxI99={==2_S*$`h)Gm0~2!{*&wi@tKS$`2T4GIVb_3&uq z*Ny7KRa)vrES~?`#t3q>y?b?E<=|z_^XVyyuvY=ld4Z`yp^)v2+&#F8SFp*05(jne z^<8%4u|yK1*I{g=^-dPxcG;10o1Ai`Q974bB(A>Ih2|>cedqun1s0N(k_E}3T6z>B ztHQwkprHVf^p%=@dy9({az~Q$n-m$^hjSr*Dh%vgD?)lt6odP(y*KWy3h4h~aK!#v zDleN$pE?-->Pw)Re*ycV11qyYsuRKt1p6bjaF+x8|7IHyt$@PA0iRI5UXA)x8knqn ziUC>_um9maeZGqe|CzZRJamT(5^i7X?jY4QH44aZw5KqzQ*jwz0udqzxEE+NtRs7Y z#IW4h3IZZf1R-w;keorGv|H1EHGC%`NmN?U>)L(sGsi6* zp}V}j5px+n5ZKUjUJ^@q!qQtaxSx>SRsfP%e0){LQ7o{vNXn$-53DsfN{lQlLSWgA zo|m`ctL-{C%^+w%uKDJa41fE8DQ5lzl7qz&9|<|~)Yz9}LZ@7(2O@CxCzwMe8$v4K zt9`Lu>{etEf~{;LFuo%sKI#oDx{r?PAghLhgJin8j0b41UcHh4U67cX8W$*e*b}^Q z>((V8JrE=XiAM-ixkMFPswsmQ(CQF+1-j(N$2BS?IR)RJf#XkfC6MR5G00s~xI;&h@I>G^y zKFkgOcF15S9MQrMSpdwh6nAv|LAoW&`wsP?b!4QfdGET|k9UTrus{Slyg~FFGlX7T z&giAnIVVhJ`yjShA{*N%jw#)=bhUM6i%^~y9s2g8uu%$DRa_`F$S#S|W8hxFl@Jcr zbR*zy-C?>dX$O5485v+{f(#tv!BC6*^VR6@+m2sC>C`&23=9n5+N{9WQi=HrBdjN^ zo3K=zty&KVVAZsX$wz4nT^yYae%T+SU+j?ZO6|X?s{-4ki!}hI=xik zxA_ElsB(yt{P5(Y6|952*29_}cfdXh5C{4Z)ORhNh1Jz?C{DlOo2uQnZjP5*#n?Xq zV=A(PF)%?P@e9;MPA;zKA9fJbapk`(IJlhN)7c4o(#I`ZS{r#MEKrRMz0g3{$W7z2 zaF3!DcnMoh1QOVYhZ2PJpx3Wow;&`$wa>|Y#4iH$;BVlqsYE=TOWK^)Ck0UcpFdl} z#m>Wgcel7;qw$20&we`gI{eO2;RO(dV5j{E2N;CF>!mZsPuV72=i>Of)f)6;E;(iq z$%aI2pG1-qSJiXV;R@y@-@t9+!-oZ>8Le}dfa%o)d@cwX0EP%qp#9;{(-Folf*BMO z)b@u39fNalrRybFSlHSU0HL;g@zTyY=3vGQc?kvMnuNop`l^%P@=6~x&HoSRsCjG& zG#S(<5Vm>}xfp<#5rX2t(j@}xb=|+C4~TxGUDHpt8Q{u7ZkAS2=d#onqm)=?CZ>hjQ=hN)Iun2qQ6GWL%F)cda+87KAI_Sn zB7#TK4k7@q&1cx_s)=uM-%9UeP$DK#8)FD;KL19aSam{c(4-vSU7+0k8z_bTlIVit zmkjD8P_C4fl}WB$ivX|hzZIw<4_Kgwoki6lAb-*Rhzu9hpZYL;{QCM7zX+Jb^YasX z#KQ5;(ikk^2tCdTMpwjd?&|6)Jsnw7cc+p4T}V-w`P_;+C%yk} zkCLf5u96E+7i%oq_`R!8-ro?rk~SLgX;q3eGBLty80vK%V#Xt=MG(|@czh7A1cI;& z^XnLjS4{0aWWjHK3l`VG#EAy8NKMK-EHt5^U|7rXo&fs^Y#efpSK5B=3mFA14speU z)A%`L=$$8(_%KNIRu$COdLcetaKX00O7sT{eM7^UD@orYaYKq;FU(q}Cmq)QxqFuZ z1EI^hTN9!`#Iwguj3t`HC$Ld#SFy&gZS%5Rg%j6u!={!sIo^z<;Ukp5wwb6TfWa*0 zO84YsqYn+}Xt3mg3HyUFi)JSWAd{n`LSMiB=2CN-pz&PEzaN~50HE&o(k#FV0Iqlq z$xY}8rFBP#mxU?UT8wAJS;lz#hQ1`&-7Q&Ti!xSp4I7jP1{{pQ|27H`h5|`Rq}BlE z3${=~Ae=KleF8QS{nNwb7NA((fM5?b87#bX?e`*#fZl*M{Q`)yL6GV?w?jj*ts34k zZFJn&IDlPK%{NtDxybN*A8S%-e|BAeHE*u-!J684iM=U|FrFX)0H|W4R!q>Kj6g+1 z-m`EHZ&J`3A(d!`mpCBoSKf)F8lo%#N&-bfnAc`8J^U`T@C!4@W$0 zx;PhNH*5@tME?S6iv;#Em~VAYH`W?YEzvd+j32byU;}^;hhZ=VtPHnCRWVTw_^8 zFJ+Q}WCf}4AIZvA1H@NQW#xTje$LRiM}GiHDik$-w{==rFOGoTqxjAD{Kx{9h0`Fj z2mTO)1}66m9DWT4Kf7ur_WMFf-PR64pjpiG4#Pn4Z)ZLn@h$ z)JKI0%yIu8#Nlh+NB}`MK~e$ZR_9wr#Y{(dg$eTRf%!AHd1$q-;4KDE!A*xm5jeux z?{f3-ARed9ri-&8ol0dG{xuYI1&3*yUhyKVEQ1ECy?QGybWFA04!wwelH3H~-+}4q z0JQZS=t0=(`h=#sWatc)0CuQ}V10}fnK^*Irm$9mylq8Z`?1tV4{I9D5Lv(s-3G^B zi|6)vM>jamI2@7z&C$bxIJ^Y|_G2Evgqo*a6ba}HkWl&UIv}^r2D3tX0nX7GaTsTn zIv&?1j(=hlp5XDXjF*`zmDHRAGW>rtmaw^tP5*0QAsCb*$N&@KUl%niYA-aL zT+7Z_M;^YrweyDX@Pw3)sZJ{BBd`zv{^$b{b<5#!bJlyL;rZ`io+11H(%Y`k!y_QC zOFWeFZrI{yDijUR zDgu)+!(3#v*Utu|#+z=MM`dkyRJ2F6D^yLKrOZMgpB+J@>;Oayu74I5_p@VOuyG-8 zzOuC?Y=HdBni(p-4XR6PK+cGIQd$b3=*oO})Hv_U$jXjMb4ks=AS3~AfcTw3w*CTd z^kDrbb@5{TWCAIdUX(;89RzLVRHa~doBH5$#%3(U%Zh@=vLN~{?WbRxc|{Ln^T zgGWi79(h9BCG(Qsg{5ho+qhh)HkrLL}yTL{fP)97VtLtAl{7pKMI z)p})l4AhdP^=!;~*Rf ztfzUl=DO-P>gfP|wTW>}sJZ{tz6R*|Ef+UW3O>VRnbiyoEl^sWF^T-SJ)`18hWoZb zFA7%Evf6!XrT!vG0R!>iHCR(pi>`kt*YdJqv4rooPFlTJnQr2A6Tuy}cnu9xsY{ls zEkf`*7MBolXhF9jlACB>dersK0vnn;Ii4x(OEpRXYFQo!|IN3+768E4d?y^~5df^< zugNp)TqP!q;7aT8UQdBEgyzg(NM9Yir-1AR!mAk6Y}V)m94`SHXdf1Z5x@GsNYdTZ zDoOgWiZZUwCPVk9wX!sj$kZ}-af-lR0GAyM!a2O`OvP+11+%2Wm6L7{O~15U&G7?n_8Lqv8C=i6UP25wF32 zNTf7)XB7|>0pMQ>Bu1Q=z{UlA{VIp}3LZRo0MIpn7#%Djh-8h}Y@m$gnN&gI;tRU4 z(Tbe_`?_DCX|DSNHM8gTz`nEg!?dP5VrXv6vn!v^b45chDa>BqANPM<%cVSiVb;gJ!dG}QE+NMj>Fei(}$Lz16 zImJB2v6$d6LAd>YyLYfKj93r>cK`~!eqGEr9K5zDe=wRrmnQV`@^m%P5} z==U?7Sk3H=tRSxB_r?<1vQ$e}lOs$d|BJIX567}y+r}@cq{tAV(m+&(M5L4G*0 zUFUV4$FU##u){s(JOJ|Ug!lxbn$eWAHgf{?yTua5{tF(<@>+x#98Mfuubch%`uHbgjrSIlk)RA`t!`Q0wis%< z%Si}gDYwfHyzaHNbCr9|VCVPoTSd$m1;%l;>HE6*!?_vcRj=f!=~lF{p`5CF_9ScZ zqZS<+t6u)ECv05E;ulsuTjo;Rx2b4-WhI;HeH)$i?>97T%+}x^SQX%-%(nP#HcNiV zC}Qf@G^-O)g-LcFK=z(rW_0(DKPmt2lNc|G%{5MpmtTfu+&-rCjUdyml)pFrTF>rn zDwF)RbC={E;J^y;I~R+EhBfl~4&AOoEe;#4e8CYR*x}wp>9#h_Gvp6lUHn68t8TEY z8XlTo;XHC&;8RlwRD532U8}u<3sdp)cup){xN<_n1jC%`*g6a&Z{oiu6&Oeydp~Ad zb}QxEeNZ7Na?{mT>M;73yf?q@$oX4KV0Xi(HO96g<{2+*$NR>~EIARqCSi$7AL zH&k_`eO}INzsr%>j;w=rbGu$}o4pTcWaDBn5hI@!KZ=RtbzX)d%aJbyTY9t%%Q4`}-Faj3Cl|lD+A<`}%!%uixwcQFS50=dS9N4+ zjyr_V+uDuFDB+i^3;nS9^;e_cJ+bzmdyeWv8lBgq`~Jf?MO2DMz+Cx6i3>_vTfBx{ zb^x1hp?<%sf)gPA%Dbc=XYIa-fkhLD{y(V$Q@#Sa{J89_B(kpRlN+`cfG_A2{W&ZX zM0uR(^kf*4Y&(B_>7}8O|2R2*n|jg+!(*ZBLA_6+F}{#AQ7>M+xYi5-j(57Kcl;I} zffKshwi?WLj<;~IJyYp?e9rt@bJwzP_Ufb^VmPbumj=Pu1WLyvB*Xwb>3#YERP>Ou z9zid#H3Bd0DDLRCg{u$ zpI>o(QdM9#@P((++WN0djlN&8NfhM~*q9*Dv&;>GY|`NmtiI*rb9Z9TKK7zb1wJ)2 zIh)_!SRB^%J(^6sbowA_;ySw620rpqI6ur|4~%hL!puIau{eVpp2>fdvp9ph65!(( zQ_f#NtUXW<0zoCzd!i^vaz<~0ymH1t5!{Hs0MtX6XlkJb7gWCkf7sv|pi0vZi~P$9IhbP}3WRCqB)Z+drBj!z z%TDgkCV|_q5ehv{Z2x)|Le{QXa|_kcM#5@E(?NK6*Cy(Y+yj}7u*Mp`g0a2JaYVlZ zMvM3kkvhN=>=AUQ(@1Lvz&>7#TUa>YM+e)QJgE}7Q$|JBFuncM^7s@`jj_K)TNMr; zj>QqXf|1b^vof%#m|0nufFwr>b5y|Isi{|E+y7!7)R1k?!=rxe=&_2Jiqzh;HJZWE z!8^hvpP#1hj2`+|AIlOJ=erFgbZ{m6t~H~7qsRYH&#uAni8P~dHB|ud8(pkdzQb@F z1URrrses~f<>#+}e_=(I5{}DH()xJoGUtn8x*Ho_3-RUmPuPu?7H3a?8^3exT9q)nWe{@^BS9mMW%7`2=pn} z)G1xqR3I8-rzY{X2CeB94>y=10#jiR-?xANV>nt8F*rmSXZ8ODTQh>_sC=y*Tf
%Q6+rmZesQZM>>bhbiTgW_)vDr{e~ZH6av*(O_ftmPyxd zzB^A`N!};V6W3w#^Mon23yML{1%NtHgT$ea4Dty9l?)g!w4)KGg1&8EIW~q~o~|o2KaaEC53c(yUv8|aW?HsohlGwmnvuERx#(15jZacBZ3+qlPwVK@ za&mGea`37^UNL^$+5Op;_t*da)(kV7MJGW$3L?;RMA}p3fezemdxvNRqpy2pJA8eD zt0rt;iYDdrD%zQ?l;$=kYd?O&?URnDqNJ8>PN_W$Sdffh12*gvt8i{>Ya@Jhbl)%4 zY(w9+=bZbbzmAKmKd%E`mSoU_MZn&i4YF^P=^yZdVJs2Aqo|~CQ3s?ajSYn!x1U;U z-rX+E_QB4)Tl$ssm9vqFD8J+6>%V(Qx@>9!e>V<`${5sXU$gzv(t{aM+4jFKi+t$^ z=Qww#2xI#mNy%CVpO?`1BzUPj4k>FsJbpK;dFz=K@s}^eJ)tgTr!3u^l9rkszV>C- zg;!QvD9MHmkHxCm236ON0D*;hBtgpaN%Rukp&h*=nepIMTne)t(D0sbs75yy|6A=uR6Bnh)3Is)vuLu8e7v=A^=4yJh=GDP z|8%^|D$XA^D{XiL{wmk(Ex`G92x42y`8g*X(qxbWm=>)CF&6b;hLD*!lBj3A24@~` zie6Fl;27iq6a}u#vydT^s%+oyr9qL=?y`ZpiVJ`CvxR)d+%tWsn&w_pjzp*qkHBsm zuCq9TXebz%5QPyX2L+}T5H)CCj9}PDyy&WCW`LrxQ!lXTZ>WYafWTaoT*OWtwG8PK zt$SF)T(QCKvW#USWhm-Hsu)AjuDty2E|Hni!YdzA)zDO)!*N#&ofTMvi(=EzQ<7$o za6pJ&ik|-BA@kq=1(JRK;wLs+#q;MkV7}sslY^o_T=s(;4Wr4{La(#ND&~yzIB~)Nf6A!ZZxPooyy_U|@jwUCro$D1$Po z7{GK&$PnnM2J>@&IQN)w9*~wEh5a=ya3c`ImW+$5FqCEI&jqFC-^i;c*)OmE=Mr!s z@YL6GuX{ap%RfJ8^g7Oe#>fLc>M7XGG&D4Tcogksh0hG|$J1xG?b~-QVa-7XZYegD zedP2aK80bB92vl_q^!+bgDj2=UV%L{Mbi)806AFMPpjSDcPvLE(KtRWx)`K372HWE z&NfFKT;_&JB{8Tbaz^;G7hdCWe|%*}fMU7};$@$9mx zsi0l@b|pv0SIW4J6R833kDE!`tUtE;yn>-}q?&GZ)zZ%BRLf^N=Kv9~ggsLY9Rbw5 z9!`+oRlB1d1}WEY5tDJ^Y8d+-PE-KZ zRL>wucXy#J9NxnGJ&Pen>;OWPe3`VacJvC%f1=K zCVU%2-c(}J`^`?C_3N^kr3qkJO53$+IkVEjqVgc*%3 zd=V}z0q(-PW$EOyWshsgbT~EZsM9mf<~9C`b{A1WPArh?yr?M0EN>}v6nz}2Hh&Jy z@7*+~Ch!Tx=1W)J<3IWXtkIAAC>_KD41cIO z)sWeH(dugU+bP;fsO))a66)xO;djM8FY7vEn@VhDh*1el2Lv%?(_Rux9_ji!(Kv!` zER}WH5ytg>p!-v_FsX5Ev-Zs_VL7cUF4gmUax#2A{N07U_>kzMB0&&SxH_ZFAhogDee(^e`jYMF_T3ek6f^YniXFN>@H)8X|}xyIE|LE+DTfgvE$q(+)K8=5R7lWF%AFtX>$^p4?J$K1 zuOhA=kk=9aVMs_yAxy_`F138SuaDo?gAIZKXR|pn9hNG+OAQQPTkc?2{L5nb?p8i9 zp%T6_48uu#1QYXnk&((50TCTmQd&Xqr+fD}@nq<*mzKd?qJeVejHEQcXJQVH7Xap$;WUo7!`mNkxfZMc%mG&}@0e>#m@TJ#)%Zhcbj{cG zj(2|8wWmRoJhhA3cspc~7XVX<6&Sm?gCz^eu_#2Jo_0IWzoMX$lQ^uTaQBR}BLAL0 zU2n$@9aK{OYr%FuFRX@?K#`4K?%49g^=e#U?Su!jK6)tB3c^%zb@efWCJ7MH$xsmb z(1De~K1y$*w$+hog#(Xayco*0;{(5ZPIEtyKXpW#?3Av1aiXrGLitaWz^Bey=j*^g z@-9VmJ9&iYRn~K?#c^i2m}gK`)LX!1e(qteqdU36J>WP^PHsDM#66xcO$o_zpymIXCxF`|_x#f|VH^@+Ho(l_kPubN zWfTtSinzo6AK{{gSvALbcb~+2m?M;Z@1G4q_rsUprX?2eqk+Mtko!I038h1c!adUV zKRI#a)PdjsDmRxn*TGbMndZ`u(8NIBVFcgrvJHGJr%xvR;F|9HesLnEi1r{8Zvvra zkrHJ(*+P(W;778kJam&M&2`pc;O+-_@H@_M2?=(H%gb?rqWZ;D!oaDAdx?UK_`1JO zWmHfzO!ZJKamkn6sQ6b>AmI3Nf+uXsGF@seoTa;$mnZIg;!x!VXiGpZ>O=ny37}Va z_*!6&skYe02aoMe1*VX*SX%tL(^E98u4J#XUd=l-dyiNC9s}&(@v#l|w%O_F?!cn8 za<1O)?p|y>Po6yKd4(5VUD$bcK3U=GlW|pfzq2&kRht}x_T2rdxw+|E*53YklfBRR zChu()7Lz?$c8k<@bV({tPjWY4So-mIEwtJ&#DmWGQFj=&n$2T&ORl#%Eq{8I5S^}o z=+ptCQ*fd468seT)7mBb4N8_m2)Y`*5(rlUwtq4F;i8zixt|Yw1OyrpeBbx^<*P>T z-2Ih~9}TSnmQ>vSi9Ik&kibAZ0zz4Q1%S~L&pcfnDQaWi>neaCExqne=WyHS0x?S)Lzw>FQso5@s*Gj$=j&Lb|uP@h?|XDGx%^T z=@7#(fx-c{t?*Jg2xo+`XB_av_-k%qp~o7_dYUT&Y;yG@JB|J(4Jy^Xzgxv^s$Ty7 z&Cu!g$jtq$EMXc~PB;n+2@$af2rP8y)Cd#}Ae>j&1|JBF9U?X+akCbcBkDyOoFD5qYH1Y+-u6ipKQqe-W1faLAcF6L2Mefq zaw}+%a$;ymoQ|)J*GL-I$J4_o#uNHOdemx(Fh@}<0)^p9K(vo?CZPuv{@Gfd+6LnJ zq_M_&(kv(1fgXe{$*m3EPNsR?(2jSbO}+<9O59l1St`qn38E0?)i-#_nx^3<4ftCO8&@t)_(aIMuNC9txeBdPVQBgzqyPYL_gztNUU5)7l;GzCKm! zK<~}Wgj^iQT6~h1-fQP&H*Va(=MiU#S5|$?F*Zi$(ul_%w%mk^tHp9)xyHs^kGn4! zk~%(=Z^$e-U-v36i~zB5z47Vv!};K?cSE`Z$1T%OM|zhQUI zEbnydv84BMnshOfX6`+yw_{UkV#{&DW#C{ctp4y}H6g@3efpFTr5S$%#5%@w{%!c~ zoS@fJq5eAR?>BU8RIk|=D2a;gQ3HPgr4JiM5Py?U71hgu3E0OGLn)LMhE=_73l$=7 zlFw2;$Q1p?<-w+s$6ZPn3EJbSeataW{L9^91%<5!7>^~uNdAJ*6PefD=YV5f+g<*}xCoOVTo;HbPp&tgQ7YLd9rTl9%5c`fda+K)YJ`X55x_I0b-G zJNnb^rkX~b(k6msI$YilNdn+dpOvS0S#`~ao*fH?xrnOGR$QFus4ohfeH-#-{U<;AjIc^N^-jDY-u$=Tf^F3xWO%LCfg9Fn1Vn8 z%VkHHK!Dm{00A(G<+%qbHB69KV))kiR#DCCJpFAC&;%HMi}f%xCgDYax=e?= z^m2A=%}1+!_CT_rM!+Bxva@*fj}P8j4%*KK!lnZ$P=2@;$l*uyIcS$ixYs>Ni;WO} zto*zh{lm$1R$dMWx*1r#adf4*+;%$rBbIP?hNI`dMEJm`j)%$|;S#Wq^+NpB$oABd0eX@fnC zyrw1xz81iNJ8lQ!FMI*p9@5O|eqicX{{bfnML}NYS$O@I@4?B`%?gn`AS7$oc9i=| zS_H5Ay>9J-Y{pq(!8K4dl%(5VOlU``!cB6q@QTt=h;niVw~gd$DQ_#lL3%48fe)Cd zfa@jrc%&mKiVV9pt+@TY{!tQfWdH?{7?9vB1b3PXo_zl3XG%b2UbR+ed$v6WDh$+K ztKOv>4>$%9rny?e?6HYdGX2K7LQ01M=Eyb^&!zJtWCb}qOcRWtir9h9=$(i1>T&-dm~>B z0M#?gqt6hmxUYtRKG8A2(5@KnU+SGuR=`Nw-Pe~MV=A?rGYXzx1_u0~!$gF}8o*gd zmy+HEb9hP)q&Cz;v`>F+Z223v;dtIDIYnhwLyIVPpd81mxE}e1ah=>C%>cm^1QD$G zf_bC<258=X1vp9o`^U zzBX^o>-TjVNxeL_2h}Rw=D}66lBhmkIsX=b%a1=$sl=C2Fph=O?J`tZPBA*0oh{(y zhbTL+sEEn}nmKZIuNo~M(aG9GJu)@(vQ{5_bD)p$+GWQ#CWB^#t{f|P5WZb28xzQN zkPlxgq8k8jj&7WBBr%Q%T8>8_IG3~sMABP&*Z(ZTfB1;216CYiOAuSI?!LaqwnAju z6=qzb4HuoToO9>@cN zz=Tcg{>%NGy04+y$`I%jNboN7+Vf$618gxob6ahDxe*<||V zy>^{lOO5?JWNK`@f>JtuT+M0!*!m{>pDi>Lu?8bUJups)N)O0mDUkZE*(d95Y{swA zjn=|Oynge7T7hz}UGTQNqQ8?5CN|bozJHt7D5#Qu(p)^v+>blr%*WdA^w^q~-T%p# zZ9Y#258h&MCou-|hj|XEAh$FDUhhSruUtla#{oAOAk%}Gsi_r$+f0%G@PEotJy{tU zB?Q#9%>DybBIO3Mbl8E2xo5`wi^eXhUj->e9{`+|d#8V`}8SSzhPkyGzPMMlTfrV4 zKerAisb^sk4kFTdk!>Ff)DZEGXTh2Zp1h;P_4mn~k8sM3BdThgw$%?xp0sbD_&MY? z7JJ$9sk{Inby0*rl1&hBY6VDB24)Wz)ZTceJO92yjMj*|2t@y<({!chK&SFt7jA~f zf}Xq_6H!84$6L9K;KT6Hc$Bf%*GDN+w5R44Hb#t$U{M<}IPrSy*fR85SeekPD!{gH z+zjS+NpJ}j!giF>P{_=#VZ7v{$yddPE`Qc$(-tkI?uAJf=lpGdlyiu?^1|x`J8T$H zrt8Nt-?m1{xaO%?K@&pQ>7ZNL#}2$Ug#ipU$QGBZgTM(n)^r{~t4`dzF)uz0r!i|G zxK{-=3HsV+ggO`zPmFpyA8Ip@YOq>)x9nr@XPo%>B9f0`VtzkEfg`l4_EBZ&gRv`r z&+KnE&>|G6hy`}Wi+ue25L)l$(Ru-&0gvN3czWKjX^>gQ* zMBo%kc^QS(OXv(xi-M1)m}*{LQ9(-y2@8jHZJBu_edk26V$p*ZyPl1`A*%6XE4^k$ zjMX=lwo7_-MI9l0H;*q1Gok;U!be~6uC#$m6&ks5oy%`-F1xqKTr0X7#ib2SK&dVg z!*d(|l4FM=jjVp z@c0k6?4J49aogvOM6i*Pk)2z?*$mnVA$$uO~OHwcjwO4n01Hn zdppid>>z<76fuv1qnm$2U{cZ!lBkJdsuzE>0>th|3BS86-$ANh&zXnm>5k5A?#duI z(mV0zw_ZGD|DY++YSKWeI zn7GT}Wi3U(DhS=~U?Gy%+~h9_w_r7&+^y+pPDg#Mjk$v+=;!Br4j?h`iCBThW(Dsh z@f z8h0qP7s#-Wk~wmb!wdmu5{#D+Zi%XxKz@bRrpt2H{dvvgogY1r*=6sb9o#o3a-NBoU<)e6f*r4Mu%J}H&^vl)5q8QV0KTsN6*aH z?6)U+>M~>$ZH-HSxB65Gk&g&qF1~Ea)c3Z=($o4|-k#~s$HD_&#Q0vQmst~A4*n_I zk|7`Cty>e~3Su{|)5y9Iql241K7Hbdn)24CPx^*Kn^ku3(r&vd6GjwgL_%Np`kZ;? zg+Hh0%^p21J<10{<*ibW2&Ga$2}Jnw*>&|Py)q*W7gNeDFng_{gPEU|4!6olJz2035a9s6e(4~Tx^A7NiH=QIJ+roun~ zB$tk-T)E1i!z~Oov8MZH4&0>egzw16<5Ap`!j!H*>~!G9xCVIOjpZ#LYDAx$*}Y-E zwGZZhET^~Z>fx(r-V&TPQvG2!;SDa1FQ%X9nW~AV5fYRaE`a6pl|Ya0e=l6PWcclm%USEq%oeO?;>X68 zJKwASM34Am`6JrL{`qTIoA^s9k7^NircV6 z&KM}j`URX%h7J)LDk{#xCB^Cp; zT8-Y>Yx6~58WqNd=PF<;`eg@U`UX{Ms>0)e!QR9TOzqP6hwcOtik3yhPP z1cO!Jl!{y_lKL78*&iP#m+SVBGoMDM-kC1W;o>hU^*8bN6aF%h23!!c*TXhgFP?D8 zEdKv7?~P;fVqUV%!Yg(-t^Gys2p>3uV3xkxS(9#&$KfoVR!ZJer9)wnyJ6m(&}2}5 zFqsXScN>SOvimNl*s9u_(!5|Lqi=dKjtu#IluSTWQ7^~2qz~WeU%L_+93O;u$==a@ zGxMo=k#a_6H>83wDqS~c1@@(yvaxN+{{NBY6fSo`O#|=k^uQsZ7MG2+T?JuPzwNR_ zQ~hc2-mavTzCGW9XIr;leC9!Wd%O0GHcSrCggNoQrc_|uJ%?a}vQ3;}NB`G%>$@v1 z!UZEi>fGhe0N6cHh!~L6y{1nXvOr!9+5e*i^(((hc^O&$^BXwdtoh$xL7&bDELyac z^<7A!AZ6XYFPuroFFh$Pgt1$}@RCIs^Z)Ti1`FUK9()GM(a60dYzq*?zKE#Hmcnzn z@=hY&QXs#u3*5h}35f!sy5c^EjYBsBFvAkYGi3UOT9P=Biz4lSVZ&aP&pp9j{;m%K zx`0JbjynXo#^*bS)$B5o)85;dzBs-ET^DmdK@Z~TNhEtKwmf?N9BFNpe0<188$L*E%8Z5FewBu+U0fe+L7MJUlPt6lHR+z9zdAi2o&m z^Qn1}bw@Hk$Up-;3pgFi2{bn~DPqn?LJNo*j7+2vwHH^ebo9*Ix7&IpyX1c6I_kv| zIyKp*BHJ`n7jKzb2!1Qha~+8Ik8VL2IKrNx;e|7yI{gvFNPdWL7nXOF`;|rn1%cQ3 z;O_3?Z?B_7AE~<<+e~BN6vw29k!@C17DBVbhoy-BS<}~Ds+%(W0m)r%KIfrO7*h4} zf*8ZqPt+M_sWMk=5>InMid_nOAv`Aj=6*QU5WQ0!#ucoA0Sy=7%7_+- zj?({8CHv@cig%shHTICtmm~xYW;JO?irk0TLUcgtgeRD6*Cf(0#-nnFvBu#U;^XJ% zTq})oA+A>qgJ1~ldBwz7Q8~RuALx$;_rj%1JOTnMF=~N~eicYCtv9APPC1Z0bSJMU0F>M~c~&7F zDh8}ZoZktyg@0TMXb3<31)9qwZa`k3O|lZ%9scpZO5-?;ClB8M(Bq?Y5eqp5FDg(y zP$|xRjt%mYfISeHvdbvqoB^^Tv8jD|euaCeWT`0VsqP9DY>iVJE zGH=n-#X(O`KMlSb8GlizQw7lx+y?uv8!0w?s?5HT7> zVhOL?v&?!lk@8@O5S!5HZ2&fX)KVL4O$fk(P2dUkf6mG^Cn=)!fsI%Fr_4M7qP%+B zNH^Nrb!22hz2K$p{@E+v8(P*%2nm)b>E$T3!|_NSsT%PMDM&a%$=;0xxHA1+s18K{ zI9OX?eGHxNOH9y_?s<`zNC60>@Y8dZ=Q-ENoWk`KaxiE{wWkd@sin4(gB7PopHD3kzD~-6S4l;srd&W|&ld-V(I(vlHuWy-A%zw`w)F2x~i8R7maR z8ea!;5N+i^Rmf*R5Aj976w_3qUC$lN&=V!@BB_JmlhRUX=Tcq>=VC2>`A%zQYWmJ~hNaN$ zVrp38V&RWVSEkG2FCd2yFo(?^a@YxxfuyG&Mcmeb^&)y`6PW*S92l^k6#I5nfr0;- zj2p~i_l1|?r2^$)x?$GY7WhBOcS1 zQ3htV!ASQT$ogo5Bwm}5z3^3r;^5#))knj;+DXBcp z$AlF@mLlo*!KZ^|$URKPU}xk1np@pGfA2qLwGW~&pr4yPykL0L9&5(O`f;|0BuJE) zRg~jhA_WmRUW+DHAciy}U~VlE3cC#bMRBZ>gBGk6UqCo-aK6ANWMp!ZB@nLAK63KjE*jan5n3?U{MGbi+SRRo_h679;=v5(xGK@F zX_=Y3G2aY?-%EFInZJMqxgS@7D*9>EzA*kxZiAn^Iei)omH>R-!njT!L~hT>C;=FY zARY^>4wdWTe}#s$l))99amhaatG zjSE{+4C^y2Z5$*j(A>axfEZQ_y?Ai5KyWDqV2k6J8Iez9Tn((gT~P4otbhIDM>0&< zcGn3^agO)aWfYdYHzjwm?8Ni?-q`X2VN8TmeKRb7AieriH2G`4P_p1(+Gz*3*tYFF zHZhe!b<&I*_eWQ+v z+g)}#TvHHpYV$Pa$nw1&XJ04Vz#sVy=29S4=y=C!+D4BoIq_+kH#q*VmzhRL6X__h znvYzF!n{@=sQ(H^#xlI7B))XJdI^96ftF8ak1)jb0H0>DGa-? zLCIk}i0X5zRXch$eXK#TDH^J_O0T_qwXQuwf8w~1Ao%IZ#yeG>GPX9IN>Qqk6 z@UiuKxvFmUl{J4AhT?7{o_Go1K)!j)ckb6pj-GJ$9swDjxS<@zZGx<*@-<0Sa%uiPN6`NzR6K6q zX2j+X|5qF zYh)e_vE|zPW14l=cRzdKFK_YisW6IM_qCV;v`lhQuE}gd-QW!iBa*-aw>%synQf#kda~_L-zctnA!h}d~OkQYV(Npu<0uonzQ|_9Ri%XABR8lfRDqb6Q2^6># z3JHIifB&=6XMEcZFQ_~)z5}NzT}GW`rthoMHsEKoAkMJ2*N`;MSf4L4RIjlvTl^?? zzK0=a>))~~NG9q`F#Y?DQHUUa@!`XV#DhNS-#7lHM%v#)rsSS(@lZNMFS2-hMU9=C z6Ao@>-VE|5D%Qkj&lHS|9=|syvpLU7vL@s!2bC;XJM;3SNJJE|hV%3H@9zw(=sR6< zZ1E3lt@l5@4oe$y(xvcOQ{omlwqfI)ZOuCRi;JpW>PHzjXbw}os4gfsY@mdyOnfcP z%*^yJD0~iBTtq%&6FfZ#L)uR1!{rW`!IL*3|NM)#?`1=ue*QEYaXj!$J0~ziXYpg> zG@OG+)~#PpOJOh^+o!a6DL%1v{-oezr&QF`;{WqM#4C}jA3%Viyw#pT%_N!VP2Z>` z61jA|>jiWW`vg)8tWJ5M3M88ceEhI5)SF{j;+n5^6*A%}qte7vuJ`+XxP-Vt63#Ww zDqLxk-QMR~c+-hz9_SEQQ^j~0pv;xm(TPeu#G8iX5}(vV##vbCJs?q{uY3IRB^x(p ze)wDXhr~aLP&e~dr#DL3d}Y8`j1W*k!7o8n2@yc*>PF*(DEwZ+%n%nBCs)$8OL}zT z#~E}eyDnY1Qbiat_6W$EgyKxcyD^!KjqL^4=)0(Z+HaXa8+LEJQ$f98;Z=NrhpFSO zH2*DsxX_5=q{73(5(X``!ZI?%E}5IxP%mJjm64n*P|j)8-`{TrONEZ#p%4)CDQU;R z)xf&hGc1gK5M{+wJ~#o@m6adue4clL_m#WnKJ<>MrCXl+=x!|li?74KqR)43=io(P zfrBHAUNA|dErvbRS^RFJ0QmX*5=BtVayR%+s8_ILUqw4Av};$8DU-6J9k$e)H2Tm( z7)BJ${>o>dRxvO%SYLRqef8?jzVSZ+6@BR=BbFL^dbONJLb9?kx4CwE(ddJ$QD=4c zc{^&GdpNQo7v44zcZ}9lxghskQ{=+VTK~kI`}eOWd^@Nm0f!ivny#W?;8^O=FHZBnA;%$%G)k&){V;vm=D6QrRe!XG^i(yJY} ze2N2rtX<_K?Fm7F{zBtpVQ4t8A_-`)$1Gy6>9sqjAkazM@qF`kzSNVH?! zXw&muEX|r&onntS5$0b)^3o`%`MM0x0eBR(?3O#9XG;%-6Z|-ut!*!0#S)7~nYoce zjxH{J(Bb8+_I)XKcZ8KH29b=0f|i~w{~DlANa)tW=v|&ZEpYGNy_WCTsF6R{xD`J+ zn|HhlelUOjAqoOoD$ij!wdRGEbkB=$Y?ti$=ck{uy9y?{{`vFgELdAGJw_(cYK+EI zo3{kxXS3?Yxgh?EqOe_SlX8V=lT3rwagXnh;m@RW9)un*(EgWDh&4)l9<;&@Cz|s4 za?Vn8xisQ-pLp7`pjB5>Q|sE34Bk_YZ8mv;2hy zZI7rpoDgsmWlqQ)tlUHnJ0_WFYHGNPjsnrK8}t(7AKeM$<;4keK4TT->FJ3dsEFlk z%c(w?0skd7c6OkF^1V6U=-ST-lfkX&#;NFU@6u8?He8VaOYn;XO#)ExwuXN+@WAlJVEhEpS zCUnH))6>$@R`c?DF#iA(!w(h{cf-T|3M_Eh7y-wpdT=%b12v20O zw78}C?|=FK?;ll~#wRDQgbzF+JA#1dONt%=>~nj+f5j*j#2FgoO2 z_d{f$UXBz%agR`2+^Xp7LA|BgEVy%LIZ)>ApFbI}XF<(m0PDfTl$0}9iogJ?xVz&F zk~;;*3PVHJC@U-9708r$w3Bi1+EyZ9-sipD%2lgKY4YXk*PFH9N}+PVSx6`-NJj%d zf|7|z`*?k5Xo$E;(HiHqYo9+4BJovXH0g+hwy-4S6gdaS#UhT4nTyL89aiOeiy!p~ zl{GblX0dYR$`MHCcm)NC{XbmESrJQ~q>7hA6qv|cJjosysMN~L9#0r!;KEVQQVgjgylTt6p#KFp>eWJa3akb)fwUVB@0B70!%)&hop5PfJrzk1%^+n#Dj7 zS~p3b!gYG1Le?#CbO%sH3^f6Cm7-V#RrMB(x-l2f8XQG>VQ=3Z*|qs*@ClqoBTP}i z7ty;tZuyPmUr5u1Ge6k#a=^qJWPaY}ujW_is{506F6P=8 z{rvB=8zd-~3XbSSu#|WZ3ybok8614)r zb}5DUp!k-*w=qvacuxtg7Um(lI`SQu0A_@qy@Lsd^*3e2+g+dTq5G98N=*zjuA;su z|J3GF^a;m!!;eOvx(6P6b!~0Lt6$LW-3Nt_wphycd$F~(^-InqGo0oZsk5l}@5ycc z_`NXtfKv%MKX8^lt^2DqhFc&;JwvUlB|Hm1)QeCCyl-j>Pl%6CbnTiUA#NRZ2emj% zz7=hW;>gPj7Zea6-$z?~;J|?v04&OWpfG|W63!e8b9KrzYZMq8Rk4PGunJM6LBP8_6n2e-ZOY!F(J4;LE z{J@dF(7x!zX3#NkQW%R~o9^+heE06ndW!;gpbmtUiyA}$PfTr9r`+jHx^e2*`o3O= z046B%=gJzmYpl1}1(r6%nd|&du|W8D_|PF*w15m_Dpa27gx|AZ82teAbE!qq8v@cwU&pUKRd7O?`JZj_@B1sV4 z4H<{7|NeI+-dY|^<*+p+2%ZAAj~*21aYE&sglPz|uI)erJ?01`U=!blQPO9_os+Pj zyjjslwr$D{m!x!Uf65Ec}_13QYrkIIMl>sqK9I4W^%|MCg4YhIPQTk2L(IeYkGWe$1U-N zndYKbH&(JI80V*+%Uq6|%FoZQI?VGVA;Id~Cl0-9vDP|lujeDrd}(7<{aT>%M0ZSO z;#%-GEkfYGjpBsF@Q^+VF9_z*WLv9^VcK}7xp(+^L9-2znuv)@7cYW6w+f@xTd2kG z!jXvWgfwmwY{4Rvu4K-ta%Bk-1#N&`DVdI;pCIX4n7W1thWfo8^ZOz!`ey&+rNgodYL)zPrdEGkedDeIjYnOoL33q=9e#5!0en|U>3x1 z!>OlcuDa5fP}p^hvQNR~;6cYwLqP`)95I~P=@(&=lm6nxuJraL`mq0b20=cnkre!* z{9OfY<{r!*b+?DcW--5cdhYw1O){(w6)s}|A6XTOuazICt&;2*BZr{);J=E zEN^T;y~);Dh_{r10@C}euuzwlMc@*0gBhsZd-hzyYqG8#iVO!%s;SV`C7r39exU__ zI;yq3oj@0v(D2y*sOJOXTjcv}KOoOwrarjdga-$|>Fw=p!R>!_5#8JB9Qd=-Ly!5D z_1d;=+i+8AGBPuBor|vh3BgD0e)OZK1WPI%WN|!4owb`b)neDo&3GVdyX{nKz0B6I zq*LQYUweAqbRr3xfFYQ7mO&j@hE}YKm=TlPN9~e{5&8W_*{a`upuzW?mX;XIA+y2w z4v&ni!hE^~)mLucQa8fLF3T>-ewmqRgmwXwJQE>4K2Kr2!iS@d2bQ~GvkZKVD{I$i z?Gs_c`VfYsW0$|LuK`rzmvFWk;Y{hBZ-v)nT%|pDF8!(QIIc5MaD?B#{|+;kR+Qqo zj)?W_m%1)rm2HaJg^St)NYwXY{;OBJC^MAvSCanyr1y!aB}Ir=MUzL3zEUn;r?0QC z74w=_w0A8qz{j%x{(ZYdPuP?*4Bm|hf-~>Il(NHls8ZQ6-}Xm|@B$Q;+CmF>5V;qk zjW?nwGsWg!t}}<~OViN>Tlv-5k`j4Fg~WRRgmzQU@D$g5K84Z@}ZWnqUDkWZfKcO z9;T1175CU=-hV_)I!fe2Kc(>Fd$SQX_ozcO61^Nz2FTu!K_9-^J}Rfvd6io)ld)1< z>I{h}Kt!l;XUy))tEX(xK$VQvAeQ;eljA0%g&5-DtXcl(#{uP&C)chIwLD()6~dAa zf6rr&`}=HQ6xqZ}KTbTl{fh1=2SQNrA`@2n5o||pPefyvEuq0cfyk=UXCFcnNg@ac zM}jr~LyDS-rojWe{yn(i!rA_Tfv8PvNnlFuRHHhFNppBav6B10pd zl{?aA`~hM)U>4S8{I$Q|b3$UU6syn+i0UKkkW}Q1n!ii&@zx@pn#(P7!#5VNHK8t| zksD~qS8yfU(kE=0zrFzs^335_{n_x+r6$9sb>^SG(wdgU)<$lUJsN+z+Y%cNbz)Os z$AYKIerWg=(b|*#B zYl}B6ym}X>oR8Ni;+XwBT3#QsQqEE|j5O5n>#2>$kI!?4ozbzYn^+<6@0Dlb!8@#E zYJ=C%7NNtJ&V{6Y!ws>L714UR0&+)+B1%uJo)R$6&^_D+&2fu}$f*n37Z>d5?+A`m zbXmpX5O^qiBii5npVu2hpI0%kl}Y`W4YcWeiQt@(;2pFA2)N^$cI|ZRW6UaRdL^DCjZF>03M43`7M-EETD!!Xo1&@+-@_UzsWfFnpo>d7wRe=et={ z<_euf*8M9ZDp!PZ+ch7q|NV5Q-$U{i%Z-j7j)Z#4y1nv>jngj=zx?Q;KVR3AI<;dM zK*gGKR{@!k@#oz1C;>UZRPqBznKGAyMu7=8hKlqGzXsGW#|*lw#Kgp67^{e1?+oHc z2l*Vi+dR|PWZa(Yd#CqWa5LWH8?}Fhewp@as%vN%Ld;2+3TVI6_1`Q3bXE%OBpjjK zU>{d<=%mEcn*9YOX`lL!I`VzyHj(+8@GQTt=4mpUeR~j8eamsA)nR>O_-TmI9pn`B zus{w94t56&QK=IyH1O;W@Xxm1DQL< z7}Q(v!-n1WdaARO5MXehYoRtv%JA(qej*Vk0f30GeXh@q9zo^aC%f?H&fBz@$TcgL z5;SpY7Q~_W)UK8mABRpSIy}O1tO&IIZqW9uBuNZJv;~+Mw|o-<)aB3zk&&OJj)#LI zzzn_*agVS1&D|OifS~Eu<#R?dd-QhB(H#y)%`iPP6PK5_4t4AZrjh8;IzE5?mr)Ff za05#+)|%bX^U2`j)pmAvJbDlOwO1!@PPaiVXwgmgxFe;IS~x401(69ZVja{{AYSa6i5z~_4nT!Y+2y@G<6i9+?OPB3^#n}N{Cor&4rqqFSj z>Wv3{PyzDp*%LnaEh0SJ3;PS&Z5rr*h;9uk;y}+;9KCp-S4uC{)Ybjqy6owX{Cv4j z3NQ~>` z{qt5;SH?AGn*`)rvbxoi&+jiux|V<}fS&7}IToAH3d$hpPvTQ!qN6{!@-+Z${$-+Tf{mOGeZkIF_6mUCSk^G}OfS({|FRWnEs`R6 z0`zH6Pc}E7Z3;MELbn5Y;jPWjq)X0Sr@%Nl+e_yq#>=e~_iiT9f5d%@6}rpX-)VO* zPcWLONzcPuvL(xugH)?wypaETki#7U$&Szg#KL$2v2d8Xc;feK5V?`#b!>xG>^{4! zL7cf5f~x512Ew-oStJyNqq!|E#rMfToM~iogxEKK@oO?M>ucn+9s=Pdri$;U*6CahmzOVxRXd!V%m2Z^X_Os(n}%A_Z@D-lCUyxB%$stXjng|(4D7P%RZ!E=Ak8W; zoHb4(Cmx1`u;MZ+8`TXlC=iE?bx+-XvwNO zMuQ}TlU2s)M1gperJljl!_eW~re_<()i>GUdER*|6F{V3G~#@YV-rUs6dU^pSd3*% zN*iDv;jdy)!z?jYzTzgU$bfBp(^i?X=+Mwxz!w=XKMBcSNRk38q&XE~6f_j>1~H|U ztNz6xADN705vPl~dF<_dRD+}LUoqQbm5`7St5)^cGjC77sRR_lmq+GJwZJOj(bkPo_AvQ@2Yuz@s zP1>Q0V_asH6G?7c+X~UeKA-LEU$K64zUMgS-ud z28%4%+lVddQ_YOGP!Nk+t<(bvm{H-0Cjc2Ps7V7d5zd?4Q<2WKJ@iTdI3Wx~qjp>F zWU>RMzCwdJRU_TMNY&B{u7ueQRBu3=&&O9#XIk1ah@sb{3G+wQEIrs(`7t+|8yM6< zaA)#*_DuC4%$k%Eyo*W94#mz^W+Y+vtjC2yVbu8tnSH+wv9G7qHZm(8A|NNPct!E9 zoIUy1L2`x1`uiJh9@VGJr&4paGka6THmP+H?GErWxNzo-v7Bj4EkPF~Dr1RHp0v8R z5J+IG3`;aIxcrBPSkELT_8C(Crvj`Km->zR$>OV_Mao|Ec_KdV2gZM5{BFZWR)*Y@ub z&Xzsm%ET3?4nl=co)4(!o4Jqs>0*=NO%q57v!bB=I=x zr^mmBPK^&nQ!J4*`Aepz;$Hp6hx}8!EkGY6o5qZK0L@Q%mt;njWZpZ}A+19lRiSF! z4F|-{$Jo6_{@rLzGR}7XBsJ{QZqQH)5baepbP5^u`^^3RkmSk7THNmV?bvazJ7L1V zG!m{EDf;u~tu)BU<6j+m6P#R%9(BE@re^6Is3yuLM_(o_8*oB9&~K*=6 z;+-ydkYZ?iq04mZR{#Dfh0@h<)~)i&>HR3zWXGKxmXsl|k{;xDgZ>|~IrP3Yx1RjS=@2U=+HJnnfcU7&or)9EDB zj>YBb0_7nK`gr<+O|b=E2V!aB$iV{Kin>EmlJVtaA*D~|zmdJb$J=`P#nr?(v-sQY zv1fYZnq;5xyYR5SL*!lnO}R;e1tGK>QQft%?_erK zjIDY?LxpM$&D8F&uw?W=q1H|f&?}I@bTof50mtjHA(vMI zf{Y4PFjkh&4qCxdv~*9aX>+zMN77>U3h&r)=s-2TT)bMe%Tu=KfaD^zL0?~=#K7UQ zIHRNy@E0PFT=YiwA3fRzHwegf>lZp>;RaGx$RUKjwK0|?#s?et$Zs36F;Hj3&{u6| zv&E=L-W*OYv`4qbm<(){BY%o)fM@}#_Ln$w9dV?J#I&?VD5_X&_VUPVxEU>h_7J2` zD^GB`NueN19N1?W>foiPw;!9sb>c2fwj>GjV1O&J1~vAgZ9Qfo8Rm_zP~H}SC4yve zEsaJC{7mXG5rK@1m1m=q)w