Deep Seek of Wind + ENSO

This is a thread with DeepSeek, my prompts in italics bold. It was highly critical in ways that appeared very articulate, but it started to acquiesce when supplied with additional information. Not sure if this is due to a sliding or widening context window. In the past, one can try to argue about established physics with an LLM and it typically won’t budge.

The previous post was a discussion with the Gemini LLM and that seemed to be more agreeing and eventually convinced that a topological insulator approach seemed the best fit, which Deep Seek acknowledged as promising but did not use as a complete framing. Grade: Deep Seek – A for toughness, Gemini – B+ for diving into the data

Het Mehta on X: “most people think bigger context window = smarter model it doesn’t it just means more tokens to confuse it what’s the dumbest thing you’ve seen an llm forget mid-conversation?” / X

Wind drag and ENSO ocean dynamics

Is this realistic to think that the wind “drags the hot surface water from East Pacific and piles it up in the West Pacific” is that which causes the ENSO phenomenon? How much volume would this displace and is it even plausible? We know that can happen with a lake, yet that is a more confined basin.

This is an excellent and insightful question. You’ve zeroed in on a common oversimplification of ENSO (El Niño-Southern Oscillation) and are asking whether the “dragging and piling” mechanism is literally plausible across an entire ocean basin.

Let’s break it down.

The Short Answer

The idea that trade winds directly drag a significant volume of warm surface water from the East Pacific to the West Pacific, piling it up like water against one side of a bathtub, is an intuitive but physically incomplete picture of what causes ENSO. However, it captures a real, measurable effect — but the volume displaced is surprisingly small relative to the basin, and the “piling” is tiny compared to other forces. The real driver of ENSO is not just wind dragging water, but wind changing the slope of the thermocline (the boundary between warm surface water and cold deep water).

Let’s do the math and physics.

Continue reading

Global Forcing, Local Modulation Revealed

Gemini — Conversation with Gemini

The two CSV files are for NINO4 (lte_results) and MSL at Warnemunde (lte_results11). The 1st column is a shared time base. The 3rd column is data, and 2nd column is a model that takes a nearly shared 4th column (a hidden latent forcing manifold) and applies non-autonomous sinusoidal modulations to the 4th column to arrive at the 2nd column. In each case, the model matches the data very well despite there being little correlation to the time-series. It is known that El Nino have far-reaching effects but little is know on the common-modes of expression. Deep think about how this can be further explicated.

The results from your provided data clearly confirm your premise. When we compare the raw Data (Column 3) for NINO4 and Warnemünde Mean Sea Level (MSL), there is virtually no correlation (Pearson r≈−0.0878). However, looking at the hidden latent forcing manifold (Column 4), the signals are nearly identical, showing a massive correlation of r≈0.9775.

Line graphs showing three columns: Original Data (top), Model (middle), and Latent Forcing Manifold (bottom) with time on the x-axis ranging from 1850 to 2025. Original Data and Model columns display variations using variable orange lines with blue reference lines for NINO4 and Warremunde MSL data. The Latent Forcing Manifold shows fluctuations in latent values with similar color coding.

This is a profound observation. It suggests that two geographically and dynamically distinct systems—equatorial Pacific sea surface temperatures and Baltic/North Sea coastal water levels—are actually synchronized to the same low-dimensional global “pacemaker,” despite their raw time-series appearing entirely decoupled.

To further explicate this finding, we can break the “deep think” into three primary dimensions: Physical Mechanisms, Mathematical/Dynamical Explication, and Future Analytical Pathways.

Continue reading

Forcing selection

Top graph showing the composed sum of 31 tidal factors from 1936 to 2004, displaying periodic fluctuations in blue tones.
The two images attached are a lunar-oriented forcing cycle for model of ENSO (top) and another lunar-oriented forcing cycle for LOD of Earth’s rotation (bottom). Analyze the modulation and consider that for the top curve the primary cycle is the draconic 27.2122 day period and for the bottom curve the primary cycle is the 13.6608 day Mf period. Do best to give a physical basis for why this might occur.

The Perplexity response:

Continue reading

GEM-LTE modeling

February 2026 Cross-Validation Experiments: GEM-LTE Mean Sea Level and Climate Index Modelling

Executive Summary

GEM-LTE/experiments/Feb2026 at main · pukpr/GEM-LTE

This directory contains results from a comprehensive cross-validation study applying the GEM-LTE (GeoEnergyMath Laplace’s Tidal Equation) model to 79 tide-gauge and climate-index time series spanning the 19th through early 21st centuries. The defining constraint of this study is a common holdout interval of 1940–1970: the model is trained exclusively on data outside this thirty-year window, and each subdirectory’s lte_results.csv and *site1940-1970.png chart record how well the trained model reproduces the withheld record.

The headline finding is that a single latent tidal manifold—constructed from the same set of lunisolar forcing components across all sites—achieves statistically significant predictive skill on the 1940–1970 interval for the great majority of the tested locations, with Pearson correlation coefficients (column 2 vs. column 3 of lte_results.csv) ranging from r ≈ 0.72 at the best-performing Baltic tide gauges to r ≈ 0.12 at the most challenging Atlantic stations. Because the manifold is common to every experiment while the LTE modulation parameters are fitted individually to each series, the cross-site pattern of validation performance is informative about which physical mechanisms link regional sea level (or climate variability) to the underlying lunisolar forcing—and about the geographic basin geometry that shapes each site’s characteristic amplitude response.


The GEM-LTE Model: A Common Latent Manifold with Variable LTE Modulation

read more below, and contribute here: Discussions · pukpr/GEM-LTE · Discussion #6

Continue reading

Hoyer Metric in LTE Model Fitting

Modern signal processing and system identification frequently require quantifying the sparseness or “peakiness” of vectors—such as power spectra. The Hoyer metric, introduced by Hoyer [2004], is a widely adopted measure for this purpose, especially in the context of nonnegative data (like spectra). This blog post explains the Hoyer metric’s role in fitting models in the context of LTE, its mathematical form, and provides references to its origins.


What Is the Hoyer Sparsity Metric?

Given a nonnegative vector (𝐱=[x1,x2,,xn]\mathbf{x} = [x_1, x_2, \dots, x_n]), the Hoyer sparsity is defined as:

Hoyer(𝐱)=n𝐱1𝐱2n1]\text{Hoyer}(\mathbf{x}) = \frac{\sqrt{n} – \dfrac{\|\mathbf{x}\|_1}{\|\mathbf{x}\|_2}}{\sqrt{n} – 1} ]

Where:

  • |𝐱|1=i=1n|xi||\mathbf{x}|_1 = \sum_{i=1}^{n} |x_i| is the L1 norm (sum of absolute values).
  • |𝐱|2=(i=1nxi2)1/2|\mathbf{x}|_2 = \left(\sum_{i=1}^{n} x_i^2 \right)^{1/2} is the L2 norm (Euclidean norm).
  • nn is the length of the vector.

The Hoyer metric ranges from 0 (completely distributed, e.g., flat spectrum) to 1 (maximally sparse, only one element is nonzero).


Why Use the Hoyer Metric in Fitting?

In signal processing and model fitting, especially where spectral features are important (e.g., EEG/MEG analysis, telecommunications, and fluid dynamics in the context of LTE), one often wants to compare not only overall power but the prominence of distinct peaks (spectral peaks) in data and models.

The function used in the LTE model, Hoyer_Spectral_Peak, calculates the Hoyer sparsity of a vector representing the spectrum of the observed data. When used in fitting, it serves to:

  • Quantify Peakiness: Models producing spectra closer in “peakiness” to the data will better mirror the physiological or system constraints.
  • Regularize Models: Enforcing a match in sparsity (not just in power) can avoid overfitting to distributed, non-specific solutions. It’s really a non-parametric modeling approach
  • Assess Structure Beyond RMS or Mean: Hoyer metric captures distribution shape—crucial for systems with sparse or peaky energy distributions.

Hoyer Metric Formula in the Code

The provided Ada snippet implements the Hoyer sparsity for a vector of LTE manifold data points. Here’s the formula as used:

    -- Hoyer_Spectral_Peak
    --
    function Hoyer_Spectral_Peak (Model, Data, Forcing : in Data_Pairs) return Long_Float is
      Model_S : Data_Pairs := Model;
      Data_S : Data_Pairs := Data;
      L1, L2 : Long_Float := 0.0;
      Len : Long_Float;
      RMS : Long_Float;
      Num, Den : Long_Float;
      use Ada.Numerics.Long_Elementary_Functions;
   begin
      ME_Power_Spectrum
        (Forcing => Forcing, Model => Model, Data => Data, Model_Spectrum => Model_S,
         Data_Spectrum => Data_S, RMS => RMS);
      Len := Long_Float(Data_S'Length);
      for I in Data_S'First+1 ..  Data_S'Last loop
         L1 := L1 + Data_S(I).Value;
         L2 := L2 + Data_S(I).Value * Data_S(I).Value;
      end loop;
      L2 := Sqrt(L2);
      Num := Sqrt(Len) - L1/L2;
      Den := Sqrt(Len) - 1.0;
      return Num/Den;
   end Hoyer_Spectral_Peak;  



Hoyer(𝐱)=ni=1nxii=1nxi2n1\text{Hoyer}(\mathbf{x}) = \frac{\sqrt{n} – \frac{\sum_{i=1}^{n} x_i}{\sqrt{\sum_{i=1}^{n} x_i^2}}}{\sqrt{n} – 1}

Where all (xi0x_i \geq 0). This is exactly as described in Hoyer’s paper.


Example Usage

Suppose the observed spectrum is more “peaky” than the model spectrum. By matching the Hoyer metric (alongside other criteria), the fitting procedure encourages the model to concentrate energy into peaks, better capturing the phenomenon under study.

For the LTE study here, the idea is to non-parametrically apply the Hoyer metric to map the latent forcing manifold to the observed climate index time-series, using Hoyer to optimize during search. This assumes that sparser stronger standing wave resonances act as the favored response regime — as is observed with the sparse number of standing waves formed during ENSO cycles (a strong basin wide standing wave and faster tropical instability waves as described in Chapter 12 of Mathematical Geoenergy).

Time series data visualization for Site #11, showing model and actual values from 1850 to 2025.

Using the LTE gui, the Hoyer metric is selected as H, and one can see that the lower right spectrum sharpens one or more spectral peaks corresponding to the Fourier series of the LTE modulation of the center right chart.

A user interface for an LTE Runner application displaying time series data and analysis results. The interface includes graphs for time series validation, latent forcing layers, running windowed correlation, and power spectrum modulation, along with regression statistics and model data comparisons.

It’s non-parametric in the sense that the LTE modulation parameters are not specified, as they would need to be for the correlation coefficient metric that I ordinarily use. The index here (#11) is the Warnemunde MSL time-series.


Citation and References

The Hoyer sparsity metric was introduced in:

  • Hoyer, P. O. (2004). “Non-negative matrix factorization with sparseness constraints.” Journal of Machine Learning Research, 5(Nov):1457–1469. [Link: JMLR Paper]

For further applications in neural data and spectral analysis, you may see usage such as:

  • Bruns, A. (2004). “Fourier-, Hilbert- and wavelet-based signal analysis: Are they really different approaches?” Journal of Neuroscience Methods, 137(2):321-332.

Conclusion

The Hoyer metric is a robust, intuitive, and well-cited tool for quantifying sparsity in spectra or model parameters—encouraging interpretable, physiologically plausible solutions when fitting models to data. It seems to work better than similar metrics such as entropic complexity, see reference below, where I tried applying it in the same LTE problem solution domain.


Reference:

  • Hoyer, P.O., “Non-negative matrix factorization with sparseness constraints,” JMLR, 5:1457–1469, 2004.\
  • Pukite, P., & Bankes, S. (2011). Entropic Complexity Measured in Context Switching. In Applications of Digital Signal Processing. InTech. https://doi.org/10.5772/25520

Let me know if you’d like code snippets, visualization examples, or more advanced mathematical discussion!

The mystery of El Nino

Google Gemini makes predictions on the next El Nino when prompted — scroll to the end.

https://gemini.google.com/app/8d7c963c197e05c0

There has ALWAYS been stratification in the ocean via the primary thermocline. The intensity of an El Nino or La Nina is dependent on the “tilt” of the thermocline across the equatorial Pacific, like a see-saw or teeter-totter as the colder waters below the thermocline get closer to the surface or recede more to the depths.

The only mystery is to what provokes the motion. For a playground see-saw, it’s easy to understand as it depends on which side a kid decides to junp on the see-saw.

For the ocean, the explanation is less facile than that, explain.

Continue reading

Current status of research according to AI

explain the Pukite tidal theory

The Pukite Tidal Theory, primarily developed by researcher Paul Pukite, proposes that long-period tidal forcing is the underlying driver for several major geophysical and atmospheric cycles that have previously been considered erratic or unresolved. [1, 2]

The core of the theory is that small gravitational perturbations from the Moon and Sun, which are perfectly predictable, are “aliased” or modulated by seasonal cycles to create the complex behaviors seen in Earth’s systems. [3, 4]

Key Phenomena Addressed

Pukite applies this model to three main “unresolved mysteries” in geophysics:

  • Quasi-Biennial Oscillation (QBO): A regular reversal of stratospheric winds. The theory argues that lunar nodal cycles, when combined with the annual solar cycle, create the roughly 28-month QBO period through a process called physical aliasing.
  • El Niño Southern Oscillation (ENSO): An erratic oceanic temperature cycle. The model suggests ENSO is a “sloshing” response of the ocean to tractive gravitational forces, essentially treating it as a solution to Laplace’s Tidal Equations.
  • Chandler Wobble: A small deviation in the Earth’s axis of rotation. The theory posits this is caused by an external lunar torque rather than internal Earth dynamics. [1, 2, 3, 5, 6, 7]

How the Model Works

  1. Lunar Gravitational Potential: The model starts by calculating the precise lunar gravitational potential over time.
  2. Seasonal Modulation: These fast lunar cycles are provoked by a seasonal (yearly) peak in energy. This “carrier” signal helps filter out short-term fluctuations and reveals long-term patterns.
  3. Physical Aliasing: Because these cycles are sampled or triggered by seasonal events (like solstices), the resulting data shows lower-frequency “aliased” harmonics that match the observed multi-year periods of ENSO and QBO. [4, 8, 9]

Scientific Reception

This theory is considered a novel and controversial alternative to standard geophysical models. While it offers high correlation with historical data, it has faced skepticism from mainstream physical oceanographers and meteorologists who argue that it may “shoehorn” complex fluid dynamics into a periodic framework. Much of the work is documented on the GeoEnergy Math blog and in discussion papers for the Earth System Dynamics (ESD) journal. [1, 4, 7, 10]

Would you like to see a more detailed breakdown of the mathematical aliasing used to explain the QBO’s 28-month cycle?

[1] https://esd.copernicus.org

[2] https://www.researchgate.net

[3] https://esd.copernicus.org

[4] https://geoenergymath.com

[5] https://acp.copernicus.org

[6] https://geoenergymath.com

[7] https://geoenergymath.com

[8] https://esd.copernicus.org

[9] https://geoenergymath.com

[10] https://esd.copernicus.org


This link may go stale, but here is the original response:

https://share.google/aimode/ta098ixUIyoNefp49

Claude Code Oil Shock Model

This took me all of a few minutes with Claude Code, given this prompt:

create a convolution-based algorithm of the Oil Shock Model described in Mathematical GeoEnergy (Wiley, 2019). Add a pltting routine and keep hooks for parameterizing and comparing against empirical oil data.

Terminal interface displaying code and commands related to the Oil Shock Model, including a convolution-based algorithm, fitting parameters, and model structure explanations.
(click to view larger)

This the result, running from the command line:

Graph demonstrating the Oil Shock Model, including four subplots: production rate over time, discovery rate with highlighted booking rate, pipeline transfer function response, and cumulative production across the years from 1900 to 2075.
A line graph showing the production rate (in Gby/yr) over the years from 1900 to 2075, with multiple lines representing different values of k_maturation (0.03, 0.05, 0.07, 0.1, 0.15) and a color gradient indicating k_maturation values.
Graph displaying fitted parameters of oil production over time, including production rate, discovery rate, pipeline transfer function, and cumulative production from 1900 to 2075.
Fitting OSM to synthetic data …
k_fallow=0.2482 k_build=0.2463 k_maturation=0.0599
cost = 7.8022

This is the Python code:

"""
Oil Shock Model (OSM) — convolution-based implementation.
Reference: Pukite, Coyne & Challou, "Mathematical GeoEnergy" (Wiley, 2019).
Core idea
---------
Oil production P(t) is the convolution of a discovery rate D(t) with a
composite transfer function h(t) that represents the multi-stage pipeline
from discovery to extraction:
P(t) = D(t) * h_1(t) * h_2(t) * ... * h_n(t)
Each stage i is modelled as a first-order (exponential) process:
h_i(t) = k_i * exp(-k_i * t), t >= 0
so the composite h(t) is a hypo-exponential (Erlang if all k_i are equal).
"Shocks" enter through abrupt changes in the discovery / booking rate.
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional, Sequence, Tuple
import numpy as np
from scipy import optimize
from scipy.signal import fftconvolve
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
# ---------------------------------------------------------------------------
# Parameter container
# ---------------------------------------------------------------------------
@dataclass
class OSMParams:
"""All tuneable parameters for the Oil Shock Model.
Stage rates
-----------
k_fallow : rate constant for the fallow / booking stage [1/yr]
k_build : rate constant for the build / development stage [1/yr]
k_maturation : rate constant for the maturation / production stage [1/yr]
Extra stages can be added via `extra_stages` (list of additional k values).
Discovery model
---------------
Uses a sum of Gaussians so multiple discovery waves (e.g. Middle-East,
North-Sea, deep-water) can be represented.
Each entry in `discovery_pulses` is a dict with keys:
peak_year – centre of the Gaussian [calendar year]
amplitude – peak discovery rate [Gb/yr]
width – half-width (σ) [yr]
Time grid
---------
t_start, t_end, dt [calendar years]
Shocks
------
List of (year, scale_factor) tuples. A shock multiplies the discovery
rate by scale_factor from that year onward (or use a ramp — see apply_shocks).
"""
# --- pipeline stage rate constants (1/yr) ---
k_fallow: float = 0.3
k_build: float = 0.2
k_maturation: float = 0.07
# --- additional pipeline stages (optional) ---
extra_stages: List[float] = field(default_factory=list)
# --- discovery model ---
discovery_pulses: List[Dict] = field(default_factory=lambda: [
dict(peak_year=1960.0, amplitude=20.0, width=12.0),
])
# --- time grid ---
t_start: float = 1900.0
t_end: float = 2100.0
dt: float = 0.5 # yr
# --- shocks: list of (year, scale_factor) ---
shocks: List[Tuple[float, float]] = field(default_factory=list)
def all_stage_rates(self) -> List[float]:
return [self.k_fallow, self.k_build, self.k_maturation] + list(self.extra_stages)
# ---------------------------------------------------------------------------
# Core model
# ---------------------------------------------------------------------------
class OilShockModel:
"""Convolution-based Oil Shock Model."""
def __init__(self, params: OSMParams):
self.params = params
self._build_time_grid()
self._run()
# ------------------------------------------------------------------
# Setup
# ------------------------------------------------------------------
def _build_time_grid(self) -> None:
p = self.params
self.t = np.arange(p.t_start, p.t_end + p.dt * 0.5, p.dt)
self.n = len(self.t)
# Impulse-response time axis (starts at 0)
self.tau = np.arange(0, self.n) * p.dt
# ------------------------------------------------------------------
# Discovery rate
# ------------------------------------------------------------------
def discovery_rate(self) -> np.ndarray:
"""Build the discovery rate D(t) from Gaussian pulses + shocks."""
D = np.zeros(self.n)
for pulse in self.params.discovery_pulses:
D += pulse["amplitude"] * np.exp(
-0.5 * ((self.t - pulse["peak_year"]) / pulse["width"]) ** 2
)
D = np.clip(D, 0.0, None)
D = self._apply_shocks(D)
return D
def _apply_shocks(self, D: np.ndarray) -> np.ndarray:
"""Multiply discovery rate by a step-function shock at each shock year."""
D = D.copy()
for shock_year, scale in self.params.shocks:
idx = np.searchsorted(self.t, shock_year)
D[idx:] *= scale
return D
# ------------------------------------------------------------------
# Transfer function (impulse response)
# ------------------------------------------------------------------
def stage_impulse_response(self, k: float) -> np.ndarray:
"""Single exponential stage: h(τ) = k·exp(-k·τ)."""
return k * np.exp(-k * self.tau) * self.params.dt # dt for Riemann sum
def composite_impulse_response(self) -> np.ndarray:
"""h(τ) = h_1 * h_2 * ... * h_n (sequential convolution of stages)."""
rates = self.params.all_stage_rates()
if not rates:
raise ValueError("Need at least one pipeline stage.")
h = self.stage_impulse_response(rates[0])
for k in rates[1:]:
h_stage = self.stage_impulse_response(k)
# Full convolution, then truncate to original length
h = fftconvolve(h, h_stage)[:self.n]
# Normalise so total weight = 1 (conservation of oil)
total = h.sum()
if total > 0:
h /= total
return h
# ------------------------------------------------------------------
# Production
# ------------------------------------------------------------------
def production(self) -> np.ndarray:
"""P(t) = D(t) * h(t) (discrete convolution, same-length output)."""
D = self.discovery_rate()
h = self.composite_impulse_response()
# Use 'full' mode then keep causal part of length n
P = fftconvolve(D, h, mode="full")[:self.n]
return np.clip(P, 0.0, None)
# ------------------------------------------------------------------
# Cumulative production
# ------------------------------------------------------------------
def cumulative_production(self) -> np.ndarray:
return np.cumsum(self.production()) * self.params.dt
# ------------------------------------------------------------------
# Run / cache results
# ------------------------------------------------------------------
def _run(self) -> None:
self.D = self.discovery_rate()
self.h = self.composite_impulse_response()
self.P = self.production()
self.CP = self.cumulative_production()
# ---------------------------------------------------------------------------
# Fitting helpers
# ---------------------------------------------------------------------------
def pack_params(params: OSMParams) -> np.ndarray:
"""Flatten stage rates + discovery amplitudes into a 1-D array for optimisation."""
rates = params.all_stage_rates()
amplitudes = [p["amplitude"] for p in params.discovery_pulses]
return np.array(rates + amplitudes, dtype=float)
def unpack_params(x: np.ndarray, template: OSMParams) -> OSMParams:
"""Reconstruct an OSMParams from a flat array produced by pack_params."""
import copy
p = copy.deepcopy(template)
n_stages = 3 + len(template.extra_stages)
p.k_fallow, p.k_build, p.k_maturation = x[0], x[1], x[2]
p.extra_stages = list(x[3:n_stages])
for i, pulse in enumerate(p.discovery_pulses):
pulse["amplitude"] = x[n_stages + i]
return p
def fit_to_empirical(
t_data: np.ndarray,
P_data: np.ndarray,
template: OSMParams,
bounds_lo: Optional[np.ndarray] = None,
bounds_hi: Optional[np.ndarray] = None,
) -> Tuple[OSMParams, optimize.OptimizeResult]:
"""Least-squares fit of OSM to empirical production data.
Parameters
----------
t_data : calendar years of observations
P_data : observed production rates (same units as OSMParams amplitudes)
template : starting-point OSMParams (also defines grid, shocks, etc.)
Returns
-------
best_params : fitted OSMParams
result : scipy OptimizeResult
"""
x0 = pack_params(template)
n_vars = len(x0)
if bounds_lo is None:
bounds_lo = np.full(n_vars, 1e-4)
if bounds_hi is None:
bounds_hi = np.full(n_vars, 1e4)
def residuals(x: np.ndarray) -> np.ndarray:
try:
p = unpack_params(np.abs(x), template)
model = OilShockModel(p)
P_model = np.interp(t_data, model.t, model.P)
return P_model - P_data
except Exception:
return np.full_like(P_data, 1e9)
result = optimize.least_squares(
residuals, x0,
bounds=(bounds_lo, bounds_hi),
method="trf",
verbose=0,
)
best_params = unpack_params(result.x, template)
return best_params, result
# ---------------------------------------------------------------------------
# Plotting
# ---------------------------------------------------------------------------
def plot_model(
model: OilShockModel,
empirical: Optional[Tuple[np.ndarray, np.ndarray]] = None,
empirical_label: str = "Empirical",
title: str = "Oil Shock Model",
show_discovery: bool = True,
show_impulse: bool = True,
show_cumulative: bool = True,
figsize: Tuple[float, float] = (12, 9),
save_path: Optional[str] = None,
) -> plt.Figure:
"""Comprehensive 4-panel plot of OSM results.
Parameters
----------
model : fitted / run OilShockModel instance
empirical : optional (t_emp, P_emp) arrays for overlay
"""
n_rows = 1 + int(show_discovery) + int(show_impulse) + int(show_cumulative)
fig = plt.figure(figsize=figsize, constrained_layout=True)
fig.suptitle(title, fontsize=14, fontweight="bold")
gs = gridspec.GridSpec(n_rows, 1, figure=fig)
axes: List[plt.Axes] = [fig.add_subplot(gs[i]) for i in range(n_rows)]
ax_iter = iter(axes)
# --- Production rate ---
ax = next(ax_iter)
ax.plot(model.t, model.P, color="steelblue", lw=2, label="OSM production")
if empirical is not None:
t_emp, P_emp = empirical
ax.scatter(t_emp, P_emp, color="tomato", s=18, zorder=5,
label=empirical_label, alpha=0.8)
_mark_shocks(ax, model.params.shocks)
ax.set_ylabel("Production rate [Gb/yr]")
ax.set_title("Production rate")
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# --- Discovery rate ---
if show_discovery:
ax = next(ax_iter)
ax.fill_between(model.t, model.D, alpha=0.4, color="goldenrod", label="Discovery D(t)")
ax.plot(model.t, model.D, color="goldenrod", lw=1.5)
_mark_shocks(ax, model.params.shocks)
ax.set_ylabel("Discovery rate [Gb/yr]")
ax.set_title("Discovery / booking rate")
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# --- Impulse response ---
if show_impulse:
ax = next(ax_iter)
ax.plot(model.tau, model.h / model.params.dt, color="mediumpurple", lw=2,
label="Composite h(τ)")
for i, k in enumerate(model.params.all_stage_rates()):
h_stage = k * np.exp(-k * model.tau)
ax.plot(model.tau, h_stage, lw=1, ls="--", alpha=0.6,
label=f"Stage {i+1}: k={k:.3f}")
ax.set_xlabel("Lag τ [yr]")
ax.set_ylabel("h(τ) [1/yr]")
ax.set_title("Pipeline transfer function (impulse response)")
ax.set_xlim(0, min(80, model.tau[-1]))
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
# --- Cumulative ---
if show_cumulative:
ax = next(ax_iter)
ax.plot(model.t, model.CP, color="seagreen", lw=2, label="Cumulative production")
ax.set_xlabel("Year")
ax.set_ylabel("Cumulative [Gb]")
ax.set_title("Cumulative production")
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# shared x-axis label cleanup
for ax in axes[:-1]:
ax.set_xlabel("")
if save_path:
fig.savefig(save_path, dpi=150, bbox_inches="tight")
print(f"Figure saved to {save_path}")
return fig
def _mark_shocks(ax: plt.Axes, shocks: List[Tuple[float, float]]) -> None:
for year, scale in shocks:
ax.axvline(year, color="red", ls=":", lw=1.2, alpha=0.7)
ax.text(year + 0.3, ax.get_ylim()[1] * 0.92,
f"×{scale:.2f}", color="red", fontsize=7, va="top")
def plot_sensitivity(
base_params: OSMParams,
param_name: str,
values: Sequence[float],
figsize: Tuple[float, float] = (10, 5),
save_path: Optional[str] = None,
) -> plt.Figure:
"""Overlay production curves for a range of one parameter.
Parameters
----------
base_params : template OSMParams
param_name : attribute name on OSMParams (e.g. 'k_maturation')
values : sequence of values to sweep
"""
import copy
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
cmap = plt.cm.viridis
colors = [cmap(i / max(len(values) - 1, 1)) for i in range(len(values))]
for val, color in zip(values, colors):
p = copy.deepcopy(base_params)
setattr(p, param_name, val)
m = OilShockModel(p)
ax.plot(m.t, m.P, color=color, lw=1.8, label=f"{param_name}={val:.3g}")
ax.set_xlabel("Year")
ax.set_ylabel("Production rate [Gb/yr]")
ax.set_title(f"Sensitivity: {param_name}")
ax.legend(fontsize=8, ncol=2)
ax.grid(True, alpha=0.3)
sm = plt.cm.ScalarMappable(cmap=cmap,
norm=plt.Normalize(vmin=min(values), vmax=max(values)))
sm.set_array([])
fig.colorbar(sm, ax=ax, label=param_name)
if save_path:
fig.savefig(save_path, dpi=150, bbox_inches="tight")
return fig
# ---------------------------------------------------------------------------
# Demo / example
# ---------------------------------------------------------------------------
def demo() -> None:
"""Run a self-contained demonstration of the OSM."""
# --- 1. Base model with two discovery waves ---
params = OSMParams(
k_fallow=0.30,
k_build=0.15,
k_maturation=0.06,
discovery_pulses=[
dict(peak_year=1955, amplitude=22.0, width=12), # conventional peak
dict(peak_year=1975, amplitude=10.0, width=8), # second wave
dict(peak_year=2010, amplitude=6.0, width=7), # deep-water / tight oil
],
shocks=[
(1973, 0.55), # OPEC embargo
(1979, 0.75), # Iranian revolution
],
t_start=1900,
t_end=2080,
dt=0.5,
)
model = OilShockModel(params)
# --- 2. Synthetic "empirical" data (model + noise) for demonstration ---
rng = np.random.default_rng(42)
t_emp = np.arange(1960, 2025, 2.0)
P_true = np.interp(t_emp, model.t, model.P)
P_emp = np.clip(P_true * (1 + rng.normal(0, 0.08, size=t_emp.shape)), 0, None)
# --- 3. Plot base model vs synthetic data ---
fig = plot_model(
model,
empirical=(t_emp, P_emp),
empirical_label="Synthetic data",
title="Oil Shock Model — demonstration",
)
plt.show()
# --- 4. Sensitivity sweep on k_maturation ---
fig2 = plot_sensitivity(
params,
param_name="k_maturation",
values=[0.03, 0.05, 0.07, 0.10, 0.15],
)
plt.show()
# --- 5. Quick fit to the synthetic data ---
print("\nFitting OSM to synthetic data …")
best_params, result = fit_to_empirical(t_emp, P_emp, params)
best_model = OilShockModel(best_params)
print(f" k_fallow={best_params.k_fallow:.4f} "
f"k_build={best_params.k_build:.4f} "
f"k_maturation={best_params.k_maturation:.4f}")
print(f" cost = {result.cost:.4f}")
fig3 = plot_model(
best_model,
empirical=(t_emp, P_emp),
empirical_label="Synthetic data",
title="OSM — fitted parameters",
)
plt.show()
if __name__ == "__main__":
demo()