Mercury Data
Dictionary
The Mercury perihelion variables include JDTDB,
EC, QR, IN, OM,
W, Tp, N, MA,
TA, A, AD, PR,
varpi_deg, etc., come from NASA JPL Horizons
System orbital element and ephemeris output. These abbreviations
follow standard celestial mechanics conventions used by
astronomers and orbital dynamicists.
Below is a comprehensive data dictionary explaining
each variable commonly returned by the JPL Horizons API when requesting
orbital elements (especially for small bodies like
asteroids, comets, or planets):
Time & Date
JDTDB |
Julian Day Number (TDB) |
Time in Barycentric Dynamical Time (TDB), the
standard time scale used in solar system dynamics. 1 JD = 86400
seconds. |
CalendarDate |
Calendar Date (UTC) |
Human-readable UTC date corresponding to JDTDB. |
Keplerian Orbital Elements: All angles in degrees
unless noted; referenced to the ecliptic and mean equinox of
J2000 unless specified otherwise.
EC |
Eccentricity |
— |
Shape of orbit: 0 = circle, 0–1 = ellipse, 1 = parabola, >1 =
hyperbola. |
QR |
Perihelion distance |
AU |
Closest distance to the Sun: QR = A × (1 − EC). |
IN |
Inclination |
deg |
Tilt of orbital plane relative to the ecliptic (J2000). |
OM |
Longitude of Ascending Node |
deg |
Angle from vernal equinox to where orbit crosses ecliptic
northward. |
W |
Argument of Perihelion |
deg |
Angle from ascending node to perihelion, measured in orbital
plane. |
Tp |
Time of Perihelion Passage |
JDTDB |
When object was (or will be) at perihelion. |
N |
Mean Motion |
deg/day |
Average angular speed: N = 360° / PR. |
MA |
Mean Anomaly |
deg |
Angle from perihelion assuming constant motion
(MA = N × (t − Tp)). |
TA |
True Anomaly |
deg |
Actual angle from perihelion to current position (accounts for
elliptical motion). |
A |
Semi-major Axis |
AU |
Half the longest diameter of the elliptical orbit. |
AD |
Aphelion distance |
AU |
Farthest distance from Sun: AD = A × (1 + EC). |
PR |
Orbital Period |
days |
Time for one full orbit around the Sun. |
*Note**: These 6 elements (EC, QR or
A, IN, OM, W,
MA/Tp) fully define an orbit at a given
epoch.
Derived Quantities (Often Added by Post-Processing):
These are not directly output by Horizons but are commonly
computed from the observed measurements.
varpi_deg |
Longitude of Perihelion (ϖ) |
\(\bar{\omega} = OM + W\ (mod
360^o)\) |
varpi_rad |
Longitude of Perihelion in radians |
\(\bar{\omega}\times \pi /
180\) |
varpi_unwrap |
Unwrapped (continuous) longitude of perihelion |
Used to avoid \(0^o \leftrightarrow
360^o\) jumps in time series, e.g., for Mercury precession
studies. |
varpi_unwrap: For planets like Mercury, general
relativity causes \(\bar{\omega}\) to
precess (\(\approx
574\) arcsec/century). Ploting varpi_deg over
centuries resets at \(360^o \to 0^o\),
creating artificial jumps. varpi_unwrap adds or subtracts
\(360^o\) as needed to make it a
smooth, increasing (or decreasing) function, which is essential for
detecting secular trends.
Reference Plane: By default, Horizons uses the
ecliptic and mean equinox of J2000.0.
Coordinate Center: Usually the Solar System
Barycenter (SSB) or Sun (for heliocentric elements).
AU: Astronomical Unit = \(149,597,870.7\) km (IAU 2012
definition).
TDB vs UTC: JDTDB is not the same as
UTC. For high precision, JDTDB is converted using tools like
astropy.time or JPL’s own time conversion utilities. See Horizons User
Manual, Section “Orbital Elements”.
Explanatory Supplement to the Astronomical Almanac,
standard reference for celestial mechanics.
Mercury Precession Analysis Example: To calculate
Mercury’s perihelion anomaly, we compute $anomaly = observed_precession
− (planetary_perturbations + \(J_2\))$.
The GR prediction is \(\approx 42.98\) arcsec/century advance in
\(\bar{\omega}\), i.e.,
varpi_unwrap, so we track varpi_unwrap over
time, fit a slope (arcsec/century), and compare to GR.
Pull real Mercury
osculating elements from JPL Horizons
We query Mercury = 199, heliocentric center
(Sun = 500@10), elements output in CSV.
# We can just import previously saved Mercury data from local source:
# results <- read.csv("mercury_orbital_elements.csv")
# head(results1)
# > str(results1)
# 'data.frame': 4607 obs. of 17 variables:
# $ JDTDB : num 2415021 2415031 2415041 2415051 2415061 ...
# $ CalendarDate: chr "A.D. 1900-Jan-01 00:00:00.0000" "A.D. 1900-Jan-11 00:00:00.0000" "A.D. 1900-Jan-21 00:00:00.0000" "A.D. 1900-Jan-31 00:00:00.0000" ...
# $ EC : num 0.206 0.206 0.206 0.206 0.206 ...
# $ QR : num 0.308 0.308 0.308 0.307 0.307 ...
# $ IN : num 7.01 7.01 7.01 7.01 7.01 ...
# $ OM : num 48.5 48.5 48.5 48.5 48.5 ...
# $ W : num 28.8 28.8 28.8 28.8 28.8 ...
# $ Tp : num 2414995 2414995 2415083 2415083 2415083 ...
# $ N : num 4.09 4.09 4.09 4.09 4.09 ...
# $ MA : num 104 145 186 227 268 ...
# $ TA : num 125 156 184 213 245 ...
# $ A : num 0.387 0.387 0.387 0.387 0.387 ...
# $ AD : num 0.467 0.467 0.467 0.467 0.467 ...
# $ PR : num 88 88 88 88 88 ...
# $ varpi_deg : num 77.3 77.3 77.3 77.3 77.3 ...
# $ varpi_rad : num 1.35 1.35 1.35 1.35 1.35 ...
# $ varpi_unwrap: num 1.35 1.35 1.35 1.35 1.35 ...
# ALTERNATIVE SOLUTION using 'astro' package
# Install remotes if you don't have it
# install.packages("remotes")
# Install the archived 'astro' package from CRAN Archive
# remotes::install_version("astro", version = "1.2")
# or From GitHub
# Install from the author's GitHub mirror
# remotes::install_github("taranu/astro")
library(astro)
library(plotly)
library(dplyr)
# FIXED JPL HORIZONS API FUNCTIONS
# FIXED JPL HORIZONS API FUNCTIONS
# Fix 1: Proper step size format and API parameters
horizons_elements_fixed <- function(command = "199",
start = "1900-01-01",
stop = "2025-01-01",
step = "5 days", # Changed format
center = "500@10",
ref_plane = "ECLIPTIC",
out_units = "AU-D",
cal_format = "JD") {
base <- "https://ssd.jpl.nasa.gov/api/horizons.api"
# JPL Horizons API requires specific parameter formatting
# The step size should be like "5 days" not "5 d"
# And we need to use text format, not JSON
q <- list(
format = "text", # Use text format for CSV output
COMMAND = paste0('"', command, '"'),
OBJ_DATA = '"NO"',
MAKE_EPHEM = '"YES"',
EPHEM_TYPE = '"ELEMENTS"',
CENTER = paste0('"', center, '"'),
REF_PLANE = paste0('"', ref_plane, '"'),
OUT_UNITS = paste0('"', out_units, '"'),
START_TIME = paste0('"', start, '"'),
STOP_TIME = paste0('"', stop, '"'),
STEP_SIZE = paste0('"', step, '"'),
CSV_FORMAT = '"YES"',
CAL_FORMAT = paste0('"', cal_format, '"'),
ELEM_LABELS = '"YES"' # Request column labels
)
cat("Making JPL Horizons API request...\n")
cat("Step size format:", step, "\n")
# Make the request
r <- httr::GET(base, query = q)
# Check response
if (httr::status_code(r) != 200) {
cat("HTTP Error:", httr::status_code(r), "\n")
cat("Response preview:\n")
cat(substr(httr::content(r, "text"), 1, 500), "\n")
httr::stop_for_status(r)
}
txt <- httr::content(r, as = "text", encoding = "UTF-8")
return(txt)
}
# Fix 2: Robust CSV parsing for Horizons output
# Fix 2: Robust CSV parsing for Horizons output
parse_horizons_elements_fixed <- function(result_text) {
# First, check if we got an error message
if (grepl("ERROR", result_text, ignore.case = TRUE)) {
cat("API returned error. First 1000 chars:\n")
cat(substr(result_text, 1, 1000), "\n")
stop("Horizons API returned an error")
}
# Split into lines
lines <- strsplit(result_text, "\n", fixed = TRUE)[[1]]
# Find SOE/EOE markers
soe_line <- grep("\\$\\$SOE", lines)
eoe_line <- grep("\\$\\$EOE", lines)
if (length(soe_line) == 0 || length(eoe_line) == 0) {
cat("Could not find SOE/EOE markers. First 50 lines:\n")
cat(lines[1:min(50, length(lines))], sep = "\n")
stop("No data markers found in response")
}
# Extract data block
data_start <- soe_line[1] + 1
data_end <- eoe_line[1] - 1
if (data_end < data_start) {
stop("Invalid data block (SOE after EOE)")
}
data_lines <- lines[data_start:data_end]
# Remove empty lines
data_lines <- data_lines[data_lines != ""]
if (length(data_lines) == 0) {
stop("No data lines found between SOE and EOE")
}
# Check if first line contains column headers
if (grepl("JDTDB|Calendar.*Date|EC|QR|IN|OM|W", data_lines[1])) {
# Has headers, read with header = TRUE
df <- read.csv(text = paste(data_lines, collapse = "\n"),
stringsAsFactors = FALSE,
strip.white = TRUE,
na.strings = "", # Added to handle empty strings
fill = TRUE) # Added in case columns are missing
} else {
# No headers, read and assign names
df <- read.csv(text = paste(data_lines, collapse = "\n"),
stringsAsFactors = FALSE,
strip.white = TRUE,
header = FALSE,
na.strings = "", # Added
fill = TRUE) # Added
# Assign column names based on typical Horizons output
# Typical columns: JDTDB, Calendar Date (TDB), EC, QR, IN, OM, W, Tp, N, MA, TA, A, AD, PR
if (ncol(df) >= 14) {
col_names <- c("JDTDB", "CalendarDate", "EC", "QR", "IN", "OM", "W",
"Tp", "N", "MA", "TA", "A", "AD", "PR")
names(df) <- col_names[1:ncol(df)] # Only assign available columns
} else {
# Try to identify columns by position
names(df)[1] <- "JDTDB"
if (ncol(df) >= 6) names(df)[6] <- "OM"
if (ncol(df) >= 7) names(df)[7] <- "W"
}
}
# FIX: Remove any columns with NA or empty names BEFORE cleaning
df <- df[, !is.na(names(df)) & names(df) != "" & names(df) != "NA"]
# Clean column names (remove spaces, dots)
names(df) <- gsub("[ .]", "", names(df))
# FIX: Also remove any columns that became empty after cleaning
df <- df[, names(df) != ""]
# Find and rename required columns
col_map <- list(
jd = c("JDTDB", "JD", "JDT", "TimeTDB"),
om = c("OM", "Omega", "NODE", "LongAscNode"),
w = c("W", "Omega", "ArgPeri", "ArgP", "ArgPerihelion")
)
# Find actual column names
jd_col <- NULL
om_col <- NULL
w_col <- NULL
for (col in names(df)) {
col_upper <- toupper(col)
if (is.null(jd_col) && any(sapply(col_map$jd, function(x) grepl(x, col_upper, fixed = TRUE)))) {
jd_col <- col
}
if (is.null(om_col) && any(sapply(col_map$om, function(x) grepl(x, col_upper, fixed = TRUE)))) {
om_col <- col
}
if (is.null(w_col) && any(sapply(col_map$w, function(x) grepl(x, col_upper, fixed = TRUE)))) {
w_col <- col
}
}
if (is.null(jd_col) || is.null(om_col) || is.null(w_col)) {
cat("Available columns:", names(df), "\n")
cat("Looking for: JDTDB, OM, W\n")
cat("Found: JD=", jd_col, ", OM=", om_col, ", W=", w_col, "\n")
stop("Required columns not found")
}
# Rename columns for consistency
names(df)[names(df) == jd_col] <- "JDTDB"
names(df)[names(df) == om_col] <- "OM"
names(df)[names(df) == w_col] <- "W"
# Convert to numeric
df$JDTDB <- as.numeric(df$JDTDB)
df$OM <- as.numeric(df$OM)
df$W <- as.numeric(df$W)
# Remove rows with missing values
df <- df[complete.cases(df$JDTDB, df$OM, df$W), ]
cat("Parsed", nrow(df), "rows of data\n")
return(df)
}
# Fix 3: Alternative method using state vectors (more reliable)
get_mercury_elements_robust <- function(start_date = "1900-01-01",
end_date = "2025-01-01",
step_days = 5) {
cat("Fetching Mercury data from JPL Horizons...\n")
# Use smaller time ranges to avoid API issues
start_year <- as.numeric(substr(start_date, 1, 4))
end_year <- as.numeric(substr(end_date, 1, 4))
if (end_year - start_year > 50) {
cat("Large time range detected. Breaking into smaller chunks...\n")
# Break into 25-year chunks
years <- seq(start_year, end_year, by = 25)
if (tail(years, 1) < end_year) years <- c(years, end_year)
all_elements <- list()
for (i in 1:(length(years)-1)) {
chunk_start <- paste0(years[i], "-01-01")
chunk_end <- paste0(min(years[i+1], end_year), "-01-01")
cat("Fetching chunk", i, ":", chunk_start, "to", chunk_end, "\n")
tryCatch({
res_txt <- horizons_elements_fixed(
command = "199",
start = chunk_start,
stop = chunk_end,
step = paste(step_days, "days"),
center = "500@10"
)
elements <- parse_horizons_elements_fixed(res_txt)
all_elements[[i]] <- elements
# Small delay to avoid overwhelming the API
if (i < length(years)-1) Sys.sleep(1)
}, error = function(e) {
cat("Error in chunk", i, ":", e$message, "\n")
cat("Trying alternative approach for this chunk...\n")
# Try alternative: get state vectors and convert
tryCatch({
vectors <- get_state_vectors_alternative(chunk_start, chunk_end, step_days)
all_elements[[i]] <- vectors
}, error = function(e2) {
cat("Alternative also failed:", e2$message, "\n")
# Return empty data frame for this chunk
all_elements[[i]] <- data.frame()
})
})
}
# Combine all chunks
elements <- bind_rows(all_elements)
elements <- elements[complete.cases(elements$JDTDB, elements$OM, elements$W), ]
} else {
# Single request for smaller time range
res_txt <- horizons_elements_fixed(
command = "199",
start = start_date,
stop = end_date,
step = paste(step_days, "days"),
center = "500@10"
)
elements <- parse_horizons_elements_fixed(res_txt)
}
cat("Successfully retrieved", nrow(elements), "data points\n")
return(elements)
}
# Alternative: Get state vectors (often more reliable)
get_state_vectors_alternative <- function(start_date, end_date, step_days) {
cat("Trying state vectors method...\n")
base <- "https://ssd.jpl.nasa.gov/api/horizons.api"
q <- list(
format = "text",
COMMAND = '"199"',
OBJ_DATA = '"NO"',
MAKE_EPHEM = '"YES"',
EPHEM_TYPE = '"VECTORS"',
CENTER = '"500@10"',
REF_PLANE = '"ECLIPTIC"',
VEC_TABLE = '"2"', # J2000 ecliptic
OUT_UNITS = '"AU-D"',
CSV_FORMAT = '"YES"',
VEC_LABELS = '"YES"',
START_TIME = paste0('"', start_date, '"'),
STOP_TIME = paste0('"', end_date, '"'),
STEP_SIZE = paste0('"', step_days, ' days"'),
CAL_FORMAT = '"JD"'
)
r <- httr::GET(base, query = q)
txt <- httr::content(r, "text")
# Parse state vectors (you'd need to convert to elements)
# This is a placeholder - you'd need to implement the conversion
# or use an existing orbital mechanics library
stop("State vectors method not fully implemented. Use elements method instead.")
}
# Fix 4: Test with a small request first
test_horizons_api <- function() {
cat("=== TESTING JPL HORIZONS API ===\n")
# Test 1: Small request
cat("\nTest 1: Small request (5 days in 2000)\n")
test_txt <- horizons_elements_fixed(
command = "199",
start = "2000-01-01",
stop = "2000-01-06",
step = "1 day",
center = "500@10"
)
cat("API response (first 500 chars):\n")
cat(substr(test_txt, 1, 500), "\n")
# Test 2: Try to parse
cat("\nTest 2: Parsing response...\n")
tryCatch({
test_df <- parse_horizons_elements_fixed(test_txt)
cat("Success! Parsed", nrow(test_df), "rows\n")
cat("Columns:", names(test_df), "\n")
cat("First row:\n")
head(test_df, 1)
return(TRUE)
}, error = function(e) {
cat("Parse failed:", e$message, "\n")
return(FALSE)
})
}
# Fix 5: Main function with error handling
analyze_mercury_varpi_fixed <- function(start_date = "1900-01-01",
end_date = "2025-01-01",
step_days = 5) {
cat("=== MERCURY PERIHELION PRECESSION ANALYSIS ===\n\n")
# Test API first
if (!test_horizons_api()) {
cat("API test failed. Cannot proceed.\n")
return(NULL)
}
cat("\n=== FETCHING MAIN DATASET ===\n")
tryCatch({
# Get the elements
elements_raw <- get_mercury_elements_robust(start_date, end_date, step_days)
# Process elements
elements_processed <- elements_raw %>%
arrange(JDTDB) %>%
mutate(
varpi_deg = (OM + W) %% 360,
varpi_rad = varpi_deg * pi/180
)
# Unwrap the angles
unwrap_rad <- function(rad_vec) {
unwrapped <- rad_vec
for (i in 2:length(rad_vec)) {
diff <- rad_vec[i] - rad_vec[i-1]
if (diff > pi) {
unwrapped[i:length(unwrapped)] <- unwrapped[i:length(unwrapped)] - 2*pi
} else if (diff < -pi) {
unwrapped[i:length(unwrapped)] <- unwrapped[i:length(unwrapped)] + 2*pi
}
}
unwrapped
}
elements_final <- elements_processed %>%
mutate(varpi_unwrap = unwrap_rad(varpi_rad))
cat("\n=== ANALYSIS COMPLETE ===\n")
cat("Total data points:", nrow(elements_final), "\n")
cat("Time range: JD", min(elements_final$JDTDB), "to", max(elements_final$JDTDB), "\n")
cat("\nSummary of varpi_deg:\n")
summary(elements_final$varpi_deg)
# Save results
write.csv(elements_final, "mercury_horizons_data.csv", row.names = FALSE)
cat("\nData saved to mercury_horizons_data.csv\n")
return(elements_final)
}, error = function(e) {
cat("Fatal error in analysis:", e$message, "\n")
return(NULL)
})
}
# Run the analysis
# results <- analyze_mercury_varpi_fixed(
# start_date = "2000-01-01", # Start with modern data (more reliable)
# end_date = "2020-01-01",
# step_days = 30 # Larger step to reduce data size
# )
# If successful, try larger range
# if (!is.null(results) && nrow(results) > 0) {
# cat("\n=== EXPANDING TO FULL TIME RANGE ===\n")
results <- analyze_mercury_varpi_fixed(
start_date = "1900-01-01",
end_date = "2026-01-01",
step_days = 10 # Medium step for full range
)
## === MERCURY PERIHELION PRECESSION ANALYSIS ===
##
## === TESTING JPL HORIZONS API ===
##
## Test 1: Small request (5 days in 2000)
## Making JPL Horizons API request...
## Step size format: 1 day
## API response (first 500 chars):
## API VERSION: 1.2
## API SOURCE: NASA/JPL Horizons API
##
##
##
## *******************************************************************************
## Ephemeris / API_USER Sat Jan 10 18:47:47 2026 Pasadena, USA / Horizons
## *******************************************************************************
## Target body name: Mercury (199) {source: DE441}
## Center body name: Sun (10) {source: DE441}
## Center-site name: BODY CENTER
## ************************************************
##
## Test 2: Parsing response...
## Parsed 6 rows of data
## Success! Parsed 6 rows
## Columns: JDTDB CalendarDate EC QR IN OM W Tp N MA TA A AD PR
## First row:
##
## === FETCHING MAIN DATASET ===
## Fetching Mercury data from JPL Horizons...
## Large time range detected. Breaking into smaller chunks...
## Fetching chunk 1 : 1900-01-01 to 1925-01-01
## Making JPL Horizons API request...
## Step size format: 10 days
## Parsed 914 rows of data
## Fetching chunk 2 : 1925-01-01 to 1950-01-01
## Making JPL Horizons API request...
## Step size format: 10 days
## Parsed 914 rows of data
## Fetching chunk 3 : 1950-01-01 to 1975-01-01
## Making JPL Horizons API request...
## Step size format: 10 days
## Parsed 914 rows of data
## Fetching chunk 4 : 1975-01-01 to 2000-01-01
## Making JPL Horizons API request...
## Step size format: 10 days
## Parsed 914 rows of data
## Fetching chunk 5 : 2000-01-01 to 2025-01-01
## Making JPL Horizons API request...
## Step size format: 10 days
## Parsed 914 rows of data
## Fetching chunk 6 : 2025-01-01 to 2026-01-01
## Making JPL Horizons API request...
## Step size format: 10 days
## Parsed 37 rows of data
## Successfully retrieved 4607 data points
##
## === ANALYSIS COMPLETE ===
## Total data points: 4607
## Time range: JD 2415021 to 2461037
##
## Summary of varpi_deg:
##
## Data saved to mercury_horizons_data.csv
# Save the results
# write.csv(results, "mercury_orbital_elements.csv", row.names = FALSE)
# cat("Results saved to mercury_orbital_elements.csv\n")
# Display a sample of the results
cat("\nFirst few rows of results:\n")
##
## First few rows of results:
## JDTDB CalendarDate EC QR IN OM
## 1 2415021 A.D. 1900-Jan-01 00:00:00.0000 0.2056246 0.3075009 7.010974 48.45668
## 2 2415031 A.D. 1900-Jan-11 00:00:00.0000 0.2056247 0.3075010 7.010971 48.45668
## 3 2415041 A.D. 1900-Jan-21 00:00:00.0000 0.2056254 0.3075006 7.010968 48.45667
## 4 2415051 A.D. 1900-Jan-31 00:00:00.0000 0.2056265 0.3074999 7.010965 48.45665
## 5 2415061 A.D. 1900-Feb-10 00:00:00.0000 0.2056271 0.3074994 7.010964 48.45662
## 6 2415071 A.D. 1900-Feb-20 00:00:00.0000 0.2056274 0.3074994 7.010964 48.45662
## W Tp N MA TA A AD PR
## 1 28.84040 2414995 4.092354 104.3230 125.3114 0.3870977 0.4666945 87.96894
## 2 28.84063 2414995 4.092350 145.2462 156.2591 0.3870979 0.4666948 87.96901
## 3 28.84084 2415083 4.092353 186.1694 184.1556 0.3870978 0.4666949 87.96895
## 4 28.84110 2415083 4.092360 227.0927 212.5681 0.3870974 0.4666948 87.96881
## 5 28.84131 2415083 4.092365 268.0161 245.2845 0.3870970 0.4666947 87.96870
## 6 28.84125 2415083 4.092363 308.9399 287.6255 0.3870972 0.4666950 87.96874
## varpi_deg varpi_rad varpi_unwrap
## 1 77.29708 1.349089 1.349089
## 2 77.29731 1.349093 1.349093
## 3 77.29751 1.349096 1.349096
## 4 77.29774 1.349100 1.349100
## 5 77.29793 1.349103 1.349103
## 6 77.29787 1.349102 1.349102
Rolling-window
estimate of perihelion precession rate (arcsec/century)
We regress unwrapped \(\varpi(t)\)
on time within each window and convert to arcsec/century.
Note that time is measures in JD (Julian Date) on the \(x\)-axis.
### Rolling-window estimate of perihelion precession rate (arcsec/century)
### IMPROVED VERSION with better time handling and visualization
# Load required libraries if not already loaded
library(dplyr)
library(ggplot2)
library(plotly)
library(lubridate)
# Convert JD to Date for better readability
jd_to_date <- function(jd) {
# Convert Julian Date to R Date
# JD 0 = 12:00 January 1, 4713 BC (proleptic Julian calendar)
# JD 2440587.5 = 12:00 January 1, 1970 (Unix epoch)
return(as.Date(jd - 2440587.5, origin = "1970-01-01"))
}
jd_to_year <- function(jd) {
# Convert JD to decimal year (e.g., 2000.5 = mid-year 2000)
date <- jd_to_date(jd)
year <- year(date)
# Fraction of year
year_start <- as.Date(paste0(year, "-01-01"))
year_end <- as.Date(paste0(year + 1, "-01-01"))
fraction <- as.numeric(difftime(date, year_start, units = "days")) /
as.numeric(difftime(year_end, year_start, units = "days"))
return(year + fraction)
}
# If you want more precise conversion (accounting for leap years):
jd_to_decimal_year <- function(jd) {
date <- jd_to_date(jd)
year <- lubridate::year(date)
start_of_year <- as.Date(paste0(year, "-01-01"))
end_of_year <- as.Date(paste0(year + 1, "-01-01"))
days_in_year <- as.numeric(difftime(end_of_year, start_of_year))
day_of_year <- as.numeric(difftime(date, start_of_year, units = "days"))
return(year + day_of_year / days_in_year)
}
rolling_slope_improved <- function(JD, y, window_years = 10, step_years = 1) {
day_per_year <- 365.25
w_days <- window_years * day_per_year
step_d <- step_years * day_per_year
JD_min <- min(JD); JD_max <- max(JD)
centers <- seq(JD_min + w_days/2, JD_max - w_days/2, by = step_d)
out <- lapply(centers, function(cj) {
idx <- which(JD >= (cj - w_days/2) & JD <= (cj + w_days/2))
if (length(idx) < 30) return(NULL)
x <- JD[idx] - cj
fit <- lm(y[idx] ~ x)
b1 <- coef(fit)[2] # rad/day
se <- summary(fit)$coefficients[2,2]
# Calculate date/year for the center
center_date <- jd_to_date(cj)
center_year <- jd_to_decimal_year(cj)
tibble(
JD_center = cj,
date_center = center_date,
year_center = center_year,
slope_rad_day = b1,
se_rad_day = se,
n = length(idx)
)
})
bind_rows(out) %>%
mutate(
slope_arcsec_cy = arcsec_per_century(slope_rad_day),
se_arcsec_cy = arcsec_per_century(se_rad_day)
)
}
# Assuming 'results' contains your data with JDTDB and varpi_unwrap
# Let's create the rolling slope analysis with date conversion
sl <- rolling_slope_improved(results$JDTDB, results$varpi_unwrap,
window_years = 10, step_years = 1)
# 1. Basic ggplot with Date
p1 <- ggplot(sl, aes(x = date_center, y = slope_arcsec_cy)) +
geom_ribbon(aes(ymin = slope_arcsec_cy - 2*se_arcsec_cy,
ymax = slope_arcsec_cy + 2*se_arcsec_cy),
alpha = 0.2, fill = "blue") +
geom_line(linewidth = 0.7, color = "blue") +
geom_point(size = 0.5, alpha = 0.5) +
labs(title = "Mercury Perihelion Precession Rate",
subtitle = "10-year rolling window, 1-year step (95% confidence band)",
x = "Date (Window Center)",
y = "Precession Rate (arcsec/century)",
caption = paste("Data: JPL Horizons API |",
"Time range:", min(sl$date_center), "to", max(sl$date_center))) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5, face = "bold"),
plot.subtitle = element_text(hjust = 0.5)
)
p1

# 2. Interactive plot with plotly
p2 <- plot_ly(sl, x = ~date_center, y = ~slope_arcsec_cy,
type = 'scatter', mode = 'lines+markers',
name = 'Precession Rate',
line = list(color = 'blue', width = 2),
marker = list(size = 3, opacity = 0.5),
hoverinfo = 'text',
text = ~paste(
sprintf("Date: %s", date_center),
sprintf("Year: %.2f", year_center),
sprintf("Rate: %.2f arcsec/cy", slope_arcsec_cy),
sprintf("±%.2f (2σ)", 2*se_arcsec_cy),
sprintf("N points: %d", n),
sep = "<br>"
)) %>%
add_ribbons(x = ~date_center,
ymin = ~slope_arcsec_cy - 2*se_arcsec_cy,
ymax = ~slope_arcsec_cy + 2*se_arcsec_cy,
name = '95% CI',
fillcolor = 'rgba(0,100,255,0.2)',
line = list(color = 'transparent')) %>%
layout(
title = list(
text = "Mercury Perihelion Precession Rate (Interactive)",
x = 0.5
),
xaxis = list(
title = "Date (Window Center)",
tickformat = "%Y-%m",
rangeslider = list(visible = TRUE) # Add range slider for zooming
),
yaxis = list(
title = "Precession Rate (arcsec/century)",
tickformat = ".1f"
),
hovermode = 'x unified',
showlegend = TRUE
)
p2
# 3. Additional analysis: Long-term trend
# Let's also look at the overall linear trend
if (nrow(sl) > 0) {
# Fit linear trend to the precession rates over time
trend_fit <- lm(slope_arcsec_cy ~ year_center, data = sl)
trend_summary <- summary(trend_fit)
cat("\n=== LONG-TERM TREND ANALYSIS ===\n")
cat("Linear trend of precession rate over time:\n")
cat(sprintf("Slope: %.4f arcsec/cy per year\n", coef(trend_fit)[2]))
cat(sprintf("P-value: %.4f\n", trend_summary$coefficients[2, 4]))
cat(sprintf("R-squared: %.4f\n", trend_summary$r.squared))
# Add trend line to plot
p3 <- ggplot(sl, aes(x = year_center, y = slope_arcsec_cy)) +
geom_point(alpha = 0.5) +
geom_smooth(method = "lm", se = TRUE, color = "red", fill = "pink") +
labs(title = "Long-term Trend of Mercury's Precession Rate",
x = "Year (Decimal)",
y = "Precession Rate (arcsec/century)",
caption = sprintf("Trend: %.2f ± %.2f arcsec/cy per century",
coef(trend_fit)[2] * 100,
2 * summary(trend_fit)$coefficients[2, 2] * 100)) +
theme_minimal()
p3
# 4. Distribution of rates
# p4 <- ggplot(sl, aes(x = slope_arcsec_cy)) +
# geom_histogram(aes(y = ..density..), bins = 30, fill = "lightblue",
# color = "black", alpha = 0.7) +
# geom_density(color = "blue", linewidth = 1) +
# geom_vline(xintercept = mean(sl$slope_arcsec_cy),
# color = "red", linetype = "dashed", linewidth = 1) +
# labs(title = "Distribution of Precession Rates",
# subtitle = paste("Mean:", round(mean(sl$slope_arcsec_cy), 2),
# "arcsec/cy, SD:", round(sd(sl$slope_arcsec_cy), 2)),
# x = "Precession Rate (arcsec/century)",
# y = "Density") +
# theme_minimal()
#
# p4
# Precompute density curve
dens <- density(sl$slope_arcsec_cy, na.rm = TRUE)
# Compute summary stats
mean_slope <- mean(sl$slope_arcsec_cy, na.rm = TRUE)
sd_slope <- sd(sl$slope_arcsec_cy, na.rm = TRUE)
# Build interactive plot
p4_interactive <- plot_ly() %>%
# Histogram (density-normalized)
add_histogram(
x = ~sl$slope_arcsec_cy,
histnorm = "probability density",
nbinsx = 30,
marker = list(color = "lightblue", line = list(color = "black", width = 1)),
opacity = 0.7,
name = "Histogram"
) %>%
# Kernel density estimate
add_trace(
x = dens$x,
y = dens$y,
type = "scatter",
mode = "lines",
line = list(color = "blue", width = 1),
name = "Density"
) %>%
# Layout with title, axes, and reference line
layout(
title = list(
text = "Distribution of Precession Rates",
x = 0.5,
font = list(size = 16)
),
xaxis = list(
title = "Precession Rate (arcsec/century)"
),
yaxis = list(
title = "Density"
),
shapes = list(
list(
type = "line",
x0 = mean_slope,
x1 = mean_slope,
y0 = 0,
y1 = 1,
yref = "paper",
line = list(color = "red", dash = "dash", width = 1)
)
),
annotations = list(
list(
x = mean_slope,
y = 1,
yref = "paper",
text = sprintf("Mean: %.2f", mean_slope),
showarrow = FALSE,
font = list(color = "red"),
xanchor = "center",
yanchor = "bottom"
)
),
showlegend = TRUE,
legend = list(x = 0.02, y = 0.98)
)
p4_interactive
# 5. Time series decomposition
# Convert to time series object
if (nrow(sl) >= 24) { # Need enough points for decomposition
ts_data <- ts(sl$slope_arcsec_cy,
start = min(sl$year_center),
frequency = 1) # Annual data
# Simple moving average for smoother visualization
sl <- sl %>%
mutate(
ma_5 = zoo::rollmean(slope_arcsec_cy, 5, fill = NA, align = "center"),
ma_10 = zoo::rollmean(slope_arcsec_cy, 10, fill = NA, align = "center")
)
p5 <- ggplot(sl, aes(x = date_center)) +
geom_line(aes(y = slope_arcsec_cy, color = "Raw"), alpha = 0.3) +
geom_line(aes(y = ma_5, color = "5-year MA"), linewidth = 1) +
geom_line(aes(y = ma_10, color = "10-year MA"), linewidth = 1.2) +
scale_color_manual(
values = c("Raw" = "gray", "5-year MA" = "blue", "10-year MA" = "red")
) +
labs(
title = "Precession Rate with Moving Averages",
x = "Date",
y = "Precession Rate (arcsec/century)",
color = "Series"
) +
theme_minimal() +
theme(legend.position = "bottom")
p5
}
}
##
## === LONG-TERM TREND ANALYSIS ===
## Linear trend of precession rate over time:
## Slope: 0.0791 arcsec/cy per year
## P-value: 0.6248
## R-squared: 0.0021

# Save the rolling slope data for later use
write.csv(sl, "mercury_precession_rates.csv", row.names = FALSE)
cat("\nRolling slope data saved to: mercury_precession_rates.csv\n")
##
## Rolling slope data saved to: mercury_precession_rates.csv
Define baselines
(Newtonian vs GR) and compute the anomaly series
To compare with the classical Mercury anomaly framing, we subtract
the dominant non-GR terms:
- planetary perturbations \(\approx
532.3035\) arcsec/cy
- solar oblateness \(J_2\) term \(\approx 0.0286\) arcsec/cy
and compare to GR \(\approx 42.9799\) arcsec/cy.
### Define baselines (Newtonian vs GR) and compute the anomaly series
# Load required libraries if not already loaded
library(plotly)
library(dplyr)
library(ggplot2)
# Reference values (from various sources)
A_planets <- 532.3035 # Planetary perturbations (arcsec/cy)
A_J2 <- 0.0286 # Solar oblateness J2 term (arcsec/cy)
A_GR <- 42.9799 # General Relativity prediction (arcsec/cy)
A_newton_nonGR <- A_planets + A_J2 # Total Newtonian non-GR
# Modern more precise values (if available)
A_GR_precise <- 42.9784 # More precise GR prediction
A_GR_error <- 0.0009 # Uncertainty in GR prediction
# Calculate anomaly
sl <- sl %>%
mutate(
anomaly_arcsec_cy = slope_arcsec_cy - A_newton_nonGR,
anomaly_rel_to_GR = anomaly_arcsec_cy - A_GR,
anomaly_percent_of_GR = (anomaly_arcsec_cy / A_GR) * 100,
anomaly_sigma = ifelse(se_arcsec_cy > 0,
abs(anomaly_arcsec_cy - A_GR) / se_arcsec_cy,
NA)
)
# Summary statistics
cat("\n=== ANOMALY ANALYSIS SUMMARY ===\n")
##
## === ANOMALY ANALYSIS SUMMARY ===
cat(sprintf("Newtonian baseline (planetary + J2): %.4f arcsec/cy\n", A_newton_nonGR))
## Newtonian baseline (planetary + J2): 532.3321 arcsec/cy
cat(sprintf("GR prediction: %.4f ± %.4f arcsec/cy\n", A_GR_precise, A_GR_error))
## GR prediction: 42.9784 ± 0.0009 arcsec/cy
cat(sprintf("\nAnomaly statistics (observed - Newtonian):\n"))
##
## Anomaly statistics (observed - Newtonian):
cat(sprintf("Mean: %.4f arcsec/cy\n", mean(sl$anomaly_arcsec_cy, na.rm = TRUE)))
## Mean: 38.8214 arcsec/cy
cat(sprintf("Std Dev: %.4f arcsec/cy\n", sd(sl$anomaly_arcsec_cy, na.rm = TRUE)))
## Std Dev: 57.9517 arcsec/cy
cat(sprintf("Range: [%.4f, %.4f] arcsec/cy\n",
min(sl$anomaly_arcsec_cy, na.rm = TRUE),
max(sl$anomaly_arcsec_cy, na.rm = TRUE)))
## Range: [-102.5374, 167.5533] arcsec/cy
cat(sprintf("\nDeviation from GR (mean): %.4f arcsec/cy\n",
mean(sl$anomaly_arcsec_cy, na.rm = TRUE) - A_GR))
##
## Deviation from GR (mean): -4.1585 arcsec/cy
cat(sprintf("Percent of GR prediction (mean): %.2f%%\n",
mean(sl$anomaly_percent_of_GR, na.rm = TRUE)))
## Percent of GR prediction (mean): 90.32%
# 1. Enhanced static plot
p_static <- ggplot(sl, aes(x = date_center)) +
# Main anomaly line
geom_line(aes(y = anomaly_arcsec_cy), color = "darkblue", linewidth = 1, alpha = 0.8) +
# Confidence band (2 sigma)
geom_ribbon(aes(ymin = anomaly_arcsec_cy - 2*se_arcsec_cy,
ymax = anomaly_arcsec_cy + 2*se_arcsec_cy),
alpha = 0.2, fill = "blue") +
# Reference lines
geom_hline(yintercept = A_GR, linetype = "dashed", color = "red", linewidth = 1) +
geom_hline(yintercept = A_GR_precise, linetype = "dotted", color = "darkred", linewidth = 0.8) +
geom_hline(yintercept = 0, linetype = "solid", color = "gray50", linewidth = 0.5) +
# GR uncertainty band
annotate("rect",
xmin = min(sl$date_center),
xmax = max(sl$date_center),
ymin = A_GR_precise - A_GR_error,
ymax = A_GR_precise + A_GR_error,
alpha = 0.1, fill = "red") +
# Labels for reference lines
annotate("text",
x = max(sl$date_center),
y = A_GR + 1,
label = "GR prediction",
color = "red", hjust = 1, vjust = 0) +
annotate("text",
x = max(sl$date_center),
y = 0 - 1,
label = "Zero anomaly",
color = "gray50", hjust = 1, vjust = 1) +
# Plot aesthetics
labs(
title = "Mercury Perihelion Precession Anomaly",
subtitle = paste("Anomaly = Total Precession - (Planetary + J₂)",
"\nDashed red: GR = 42.9799 arcsec/cy",
"Dotted red: Modern GR = 42.9784 ± 0.0009 arcsec/cy"),
x = "Date (Window Center)",
y = "Anomaly (arcsec/century)",
caption = paste("Data: JPL Horizons API |",
"Rolling window: 10 years |",
sprintf("Mean anomaly: %.2f ± %.2f arcsec/cy",
mean(sl$anomaly_arcsec_cy),
sd(sl$anomaly_arcsec_cy)))
) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5, face = "bold", size = 14),
plot.subtitle = element_text(hjust = 0.5, size = 10),
panel.grid.minor = element_blank()
)
p_static

# 2. Interactive plotly plot
p_interactive <- plot_ly(sl, x = ~date_center) %>%
# Add anomaly line
add_trace(y = ~anomaly_arcsec_cy,
type = 'scatter',
mode = 'lines+markers',
name = 'Anomaly',
line = list(color = 'blue', width = 2),
marker = list(size = 4, opacity = 0.3),
hoverinfo = 'text',
text = ~paste(
sprintf("Date: %s", date_center),
sprintf("Year: %.2f", year_center),
sprintf("Anomaly: %.3f arcsec/cy", anomaly_arcsec_cy),
sprintf("±%.3f (2σ)", 2*se_arcsec_cy),
sprintf("Deviation from GR: %.3f", anomaly_rel_to_GR),
sprintf("%.1f%% of GR", anomaly_percent_of_GR),
sep = "<br>"
)) %>%
# Add confidence band
add_ribbons(ymin = ~anomaly_arcsec_cy - 2*se_arcsec_cy,
ymax = ~anomaly_arcsec_cy + 2*se_arcsec_cy,
name = '95% CI',
fillcolor = 'rgba(0,100,255,0.2)',
line = list(color = 'transparent')) %>%
# Add GR reference line
add_trace(y = rep(A_GR, nrow(sl)),
type = 'scatter',
mode = 'lines',
name = 'GR Prediction',
line = list(color = 'red', dash = 'dash', width = 2),
hoverinfo = 'text',
text = ~paste("GR prediction:", A_GR, "arcsec/cy")) %>%
# Add zero line
add_trace(y = rep(0, nrow(sl)),
type = 'scatter',
mode = 'lines',
name = 'Zero Anomaly',
line = list(color = 'gray', dash = 'solid', width = 1),
hoverinfo = 'none') %>%
# Layout configuration
layout(
title = list(
text = "Mercury Perihelion Anomaly (Interactive)",
x = 0.5,
font = list(size = 16)
),
xaxis = list(
title = "Date (Window Center)",
tickformat = "%Y-%m",
rangeslider = list(visible = TRUE),
gridcolor = 'lightgray'
),
yaxis = list(
title = "Anomaly (arcsec/century)",
gridcolor = 'lightgray',
zeroline = TRUE,
zerolinecolor = 'gray',
zerolinewidth = 1
),
hovermode = 'x unified',
showlegend = TRUE,
legend = list(x = 0.02, y = 0.98, bgcolor = 'rgba(255,255,255,0.8)'),
plot_bgcolor = 'white',
paper_bgcolor = 'white'
)
# Add horizontal GR uncertainty band as a shape
p_interactive <- p_interactive %>%
layout(
shapes = list(
list(
type = "rect",
x0 = min(sl$date_center),
x1 = max(sl$date_center),
y0 = A_GR_precise - A_GR_error,
y1 = A_GR_precise + A_GR_error,
fillcolor = "rgba(255,0,0,0.1)",
line = list(width = 0),
layer = "below"
)
),
annotations = list(
list(
x = max(sl$date_center),
y = A_GR_precise + A_GR_error + 0.5,
text = "Modern GR with uncertainty",
showarrow = FALSE,
xref = "x",
yref = "y",
xanchor = "right",
font = list(size = 10, color = "darkred")
)
)
)
p_interactive
# 3. Histogram of anomalies
p_hist <- plot_ly(sl, x = ~anomaly_arcsec_cy,
type = 'histogram',
histnorm = 'probability density',
name = 'Anomaly Distribution',
nbinsx = 30,
marker = list(color = 'blue',
line = list(color = 'black', width = 0.5))) %>%
layout(
title = "Distribution of Mercury Anomaly Values",
xaxis = list(title = "Anomaly (arcsec/century)"),
yaxis = list(title = "Density"),
showlegend = FALSE,
shapes = list(
# Vertical line for GR
list(type = "line",
x0 = A_GR, x1 = A_GR,
y0 = 0, y1 = 1,
yref = "paper", # makes y0/y1 relative to the whole plot area (0 to 1)
line = list(color = "red", dash = "dash", width = 2)),
# Vertical line for mean
list(type = "line",
x0 = mean(sl$anomaly_arcsec_cy, na.rm = TRUE),
x1 = mean(sl$anomaly_arcsec_cy, na.rm = TRUE),
y0 = 0, y1 = 1,
yref = "paper",
line = list(color = "green", dash = "dot", width = 2))
),
annotations = list(
list(x = A_GR, y = 1,
yref = "paper",
text = "GR",
showarrow = FALSE,
font = list(color = "red"),
xanchor = "center", yanchor = "bottom"),
list(x = mean(sl$anomaly_arcsec_cy, na.rm = TRUE), y = 1,
yref = "paper",
text = "Mean",
showarrow = FALSE,
font = list(color = "green"),
xanchor = "center", yanchor = "bottom")
)
)
p_hist
# 4. Time series of deviation from GR
p_deviation <- plot_ly(sl, x = ~date_center) %>%
add_trace(y = ~anomaly_rel_to_GR,
type = 'scatter',
mode = 'lines+markers',
name = 'Deviation from GR',
line = list(color = 'purple', width = 2),
marker = list(size = 4, opacity = 0.3),
hoverinfo = 'text',
text = ~paste(
sprintf("Date: %s", date_center),
sprintf("Deviation: %.3f arcsec/cy", anomaly_rel_to_GR),
sprintf("%.1fσ from GR", anomaly_sigma),
sep = "<br>"
)) %>%
layout(
title = "Deviation from General Relativity Prediction",
xaxis = list(title = "Date", rangeslider = list(visible = TRUE)),
yaxis = list(title = "Deviation from GR (arcsec/century)"),
hovermode = 'x unified',
shapes = list(
list(
type = "line",
x0 = 0, x1 = 1,
xref = "paper", # spans full width
y0 = 0, y1 = 0,
line = list(color = "gray", dash = "solid", width = 1)
)
),
annotations = list(
list(
x = 1, y = 0,
xref = "paper", yref = "y",
xanchor = "right", yanchor = "bottom",
text = "GR exact match",
showarrow = FALSE,
font = list(color = "gray")
)
)
)
p_deviation
# 5. Statistical test against GR
cat("\n=== STATISTICAL TEST AGAINST GR ===\n")
##
## === STATISTICAL TEST AGAINST GR ===
# One-sample t-test against GR value
if (sum(!is.na(sl$anomaly_arcsec_cy)) > 1) {
t_test_result <- t.test(sl$anomaly_arcsec_cy, mu = A_GR)
cat("One-sample t-test (H₀: anomaly = GR prediction)\n")
cat(sprintf("t = %.3f, df = %d\n", t_test_result$statistic, t_test_result$parameter))
cat(sprintf("p-value = %.5f\n", t_test_result$p.value))
cat(sprintf("95%% Confidence Interval: [%.4f, %.4f]\n",
t_test_result$conf.int[1], t_test_result$conf.int[2]))
# Check if GR value is within confidence interval
gr_in_ci <- t_test_result$conf.int[1] <= A_GR && A_GR <= t_test_result$conf.int[2]
cat(sprintf("GR value within 95%% CI: %s\n", ifelse(gr_in_ci, "YES", "NO")))
}
## One-sample t-test (H₀: anomaly = GR prediction)
## t = -0.773, df = 115
## p-value = 0.44119
## 95% Confidence Interval: [28.1633, 49.4795]
## GR value within 95% CI: YES
# 6. Save anomaly data
# 6. Save anomaly data
sl_anomaly <- sl %>%
dplyr::select(date_center, year_center, JD_center,
slope_arcsec_cy, se_arcsec_cy,
anomaly_arcsec_cy, anomaly_rel_to_GR,
anomaly_percent_of_GR, anomaly_sigma)
# write.csv(sl_anomaly, "mercury_anomaly_analysis.csv", row.names = FALSE)
# cat("\nAnomaly analysis data saved to: mercury_anomaly_analysis.csv\n")
# 7. Create summary table for reporting
summary_table <- data.frame(
Statistic = c("Newtonian baseline (planetary + J₂)",
"GR prediction",
"Mean observed anomaly",
"Std. deviation of anomaly",
"Deviation from GR (mean)",
"Percent of GR prediction",
"t-test p-value"),
Value = c(
sprintf("%.4f arcsec/cy", A_newton_nonGR),
sprintf("%.4f ± %.4f arcsec/cy", A_GR_precise, A_GR_error),
sprintf("%.4f ± %.4f arcsec/cy",
mean(sl$anomaly_arcsec_cy, na.rm = TRUE),
sd(sl$anomaly_arcsec_cy, na.rm = TRUE)),
sprintf("%.4f arcsec/cy", sd(sl$anomaly_arcsec_cy, na.rm = TRUE)),
sprintf("%.4f arcsec/cy",
mean(sl$anomaly_arcsec_cy, na.rm = TRUE) - A_GR),
sprintf("%.2f%%", mean(sl$anomaly_percent_of_GR, na.rm = TRUE)),
ifelse(exists("t_test_result"),
sprintf("%.5f", t_test_result$p.value), "N/A")
)
)
summary_table
## Statistic Value
## 1 Newtonian baseline (planetary + J₂) 532.3321 arcsec/cy
## 2 GR prediction 42.9784 ± 0.0009 arcsec/cy
## 3 Mean observed anomaly 38.8214 ± 57.9517 arcsec/cy
## 4 Std. deviation of anomaly 57.9517 arcsec/cy
## 5 Deviation from GR (mean) -4.1585 arcsec/cy
## 6 Percent of GR prediction 90.32%
## 7 t-test p-value 0.44119
KPT Model:
Comprehensive Physics-Constrained GEM Algorithm
The key insight of Kime-Phase Theory (KPT) is that repeated
measurements at “the same” real time \(t\) correspond to different
kime-phases \(\theta \in
[-\pi, \pi]\). The complex time (kime) is \(\kappa = t \cdot e^{i\theta}\).
For the Mercury perihelion problem, we model observations as
\[Y_{i,k} = S_k(\Theta_{i,k}) +
\varepsilon_{i,k}\]
where \(Y_{i,k}\) is the \(i\)-th replicate measurement in
window \(k\), \(S_k(\theta)\) is the
kime-surface at window \(k\), evaluated at kime-phase \(\theta\), \(\Theta_{i,k} \sim \varphi_k(\theta)\) is
the kime-phase distribution for window \(k\), and \(\varepsilon_{i,k} \sim \mathcal{N}(0,
\sigma^2)\) is the measurement noise.
Critical Physical Constraint: The expectation of the
kime-surface over the kime-phase distribution must equal (or be close
to) the GR prediction: \[\mathbb{E}_{\varphi_k}[S_k(\theta)] =
\int_{-\pi}^{\pi} S_k(\theta) \varphi_k(\theta) d\theta \approx
A_{\text{GR}}\]
This ensures that KPT refines our uncertainty quantification rather
than replacing established physics.
To parameterize the KPT model, we use a constrained Fourier
expansion for the kime-surface \[S_k(\theta) = \mu_0 + \delta_k + \sum_{j=1}^{J}
\left[ a_{j,k} \cos(j\theta) + b_{j,k} \sin(j\theta)
\right]\]
where \(\mu_0 = A_{\text{GR}}\) is
the GR prediction (fixed anchor), \(\delta_k\) is a small time-varying offset
with regularization \(\|\delta\|^2 \to
0\), and \(a_{j,k}, b_{j,k}\):
Fourier coefficients capturing kime-phase-dependent variation.
The physical constraint is enforced via \[\mathbb{E}_{\varphi_k}[S_k(\theta)] = \mu_0 +
\delta_k + \sum_j [a_{j,k}
\mathbb{E}[\cos(j\theta)] + b_{j,k}
\mathbb{E}[\sin(j\theta)]].\]
For a symmetric kime-phase distribution centered at \(\theta=0\): \(\mathbb{E}[\sin(j\theta)] = 0\).
# ==============================================================================
# COMPREHENSIVE KPT-GEM ALGORITHM
# Physics-constrained Generalized Expectation-Maximization for Kime-Phase Theory
# ==============================================================================
#' Theta grid for kime-phase discretization
#' @param L Number of grid points
theta_grid <- function(L) seq(-pi, pi, length.out = L + 1L)[1L:L]
#' Circular smoothing via FFT convolution with von Mises kernel
#' @param p Probability vector on grid
#' @param kappa Concentration parameter (higher = less smoothing)
smooth_circular <- function(p, kappa = 25) {
L <- length(p)
th <- theta_grid(L)
ker <- exp(kappa * cos(th))
ker <- ker / sum(ker)
P <- fft(p)
K <- fft(ker)
Re(fft(P * K, inverse = TRUE)) / L
}
#' Comprehensive Physics-Constrained KPT-GEM
#'
#' This implementation differs from the naive version by:
#' 1. Anchoring the mean to the GR prediction
#' 2. Using regularization to prevent overfitting
#' 3. Employing a shared (pooled) kime-phase distribution across windows
#' 4. Properly handling the constraint that E[S(θ)] ≈ A_GR
#'
#' @param Y Matrix of observations (replicates x windows)
#' @param t Time centers for each window
#' @param A_GR The GR prediction for the anomaly (arcsec/century)
#' @param L_theta Number of theta grid points
#' @param n_harmonics Number of Fourier harmonics
#' @param lambda_delta Regularization for offset δ (higher = more constrained to A_GR)
#' @param lambda_coef Regularization for Fourier coefficients
#' @param pool_phi If TRUE, use a single shared φ(θ) across all windows
#' @param max_iter Maximum EM iterations
#' @param tol Convergence tolerance
#' @param smooth_kappa von Mises smoothing parameter
#' @param verbose Print progress
#' @param seed Random seed
kpt_gem_comprehensive <- function(Y, t, A_GR,
L_theta = 256,
n_harmonics = 2,
lambda_delta = 10.0,
lambda_coef = 1.0,
pool_phi = TRUE,
max_iter = 50,
tol = 1e-5,
smooth_kappa = 30,
verbose = TRUE,
seed = 42) {
set.seed(seed)
I <- nrow(Y) # Number of replicates
K <- ncol(Y) # Number of windows
# Theta grid
theta <- theta_grid(L_theta)
dtheta <- 2 * pi / L_theta # Integration measure
# Precompute Fourier basis on grid
cos_basis <- matrix(0, nrow = L_theta, ncol = n_harmonics)
sin_basis <- matrix(0, nrow = L_theta, ncol = n_harmonics)
for (j in 1:n_harmonics) {
cos_basis[, j] <- cos(j * theta)
sin_basis[, j] <- sin(j * theta)
}
# --------------------------------------------------------------------------
# INITIALIZATION
# --------------------------------------------------------------------------
# Anchor mean to GR prediction
mu_0 <- A_GR
# Initialize small offsets (regularized toward zero)
delta <- rep(0, K)
# Initialize Fourier coefficients (small random values)
a_coef <- matrix(rnorm(n_harmonics * K, 0, 0.1), nrow = n_harmonics, ncol = K)
b_coef <- matrix(rnorm(n_harmonics * K, 0, 0.1), nrow = n_harmonics, ncol = K)
# Initialize kime-phase distribution (uniform)
if (pool_phi) {
phi <- matrix(1, nrow = 1, ncol = L_theta) # Single shared φ
} else {
phi <- matrix(1, nrow = K, ncol = L_theta) # Per-window φ
}
# Initialize noise variance from data
sigma2 <- var(as.vector(Y - A_GR), na.rm = TRUE)
if (!is.finite(sigma2) || sigma2 <= 0) sigma2 <- 1
# Storage for convergence tracking
log_lik_history <- numeric(max_iter)
delta_history <- numeric(max_iter)
# --------------------------------------------------------------------------
# EM ITERATIONS
# --------------------------------------------------------------------------
for (it in 1:max_iter) {
params_old <- c(delta, as.vector(a_coef), as.vector(b_coef))
log_lik_total <- 0
# Accumulator for pooled phi update
if (pool_phi) {
phi_accum <- rep(0, L_theta)
n_accum <- 0
}
# ========================================================================
# E-STEP: Compute posterior weights w_{i,l} for each observation
# ========================================================================
for (k in 1:K) {
yk <- Y[, k]
ok <- is.finite(yk)
n_ok <- sum(ok)
if (n_ok == 0) next
# Compute kime-surface on grid: S_k(θ_l)
S_l <- mu_0 + delta[k]
for (j in 1:n_harmonics) {
S_l <- S_l + a_coef[j, k] * cos_basis[, j] + b_coef[j, k] * sin_basis[, j]
}
# Get current phi for this window
phi_k <- if (pool_phi) phi[1, ] else phi[k, ]
# Log-likelihood: log p(y_i | θ_l, params)
# y_i ~ N(S_k(θ_l), σ²)
D <- outer(yk[ok], S_l, FUN = "-") # [n_ok x L_theta]
log_lik <- -D^2 / (2 * sigma2) - 0.5 * log(2 * pi * sigma2)
# Log-prior: log φ_k(θ_l)
log_prior <- log(pmax(phi_k, 1e-300))
# Log-posterior (unnormalized): log w_{i,l} ∝ log p(y_i | θ_l) + log φ(θ_l)
log_w <- sweep(log_lik, 2, log_prior, "+")
# Normalize to get posterior weights
log_w_max <- apply(log_w, 1, max)
w <- exp(log_w - log_w_max)
w <- w / rowSums(w)
# Accumulate log-likelihood
log_lik_i <- log_w_max + log(rowSums(exp(log_w - log_w_max)))
log_lik_total <- log_lik_total + sum(log_lik_i)
# ====================================================================
# M-STEP (Part 1): Update phi_k
# ====================================================================
# Aggregate posterior: phi_k(θ_l) ∝ mean_i[w_{i,l}]
phi_new <- colMeans(w)
phi_new <- smooth_circular(phi_new, kappa = smooth_kappa)
phi_new <- pmax(phi_new, 1e-12)
phi_new <- phi_new / (sum(phi_new) * dtheta) # Normalize as density
if (pool_phi) {
phi_accum <- phi_accum + phi_new * n_ok
n_accum <- n_accum + n_ok
} else {
phi[k, ] <- phi_new
}
# ====================================================================
# M-STEP (Part 2): Update delta_k and Fourier coefficients
# ====================================================================
# Weighted regression approach
# Target: minimize Σ_i Σ_l w_{i,l} (y_i - S_l)² + regularization
# Sufficient statistics
w_l <- colSums(w) # [L_theta] - total weight per theta
y_l <- as.numeric(t(w) %*% yk[ok]) # [L_theta] - weighted sum of y
# Design matrix: [L_theta x (1 + 2*n_harmonics)]
# Columns: 1, cos(θ), sin(θ), cos(2θ), sin(2θ), ...
X <- cbind(1, cos_basis, sin_basis)
n_coef <- ncol(X)
# Weighted least squares with ridge regularization
# β = (X'WX + λI)^{-1} X'Wy
W <- diag(pmax(w_l, 1e-12))
# Regularization matrix: penalize deviations from [μ_0, 0, 0, ...]
# The intercept should be μ_0 + δ_k, so we penalize δ_k
reg_diag <- c(lambda_delta, rep(lambda_coef, 2 * n_harmonics))
Reg <- diag(reg_diag)
# Prior mean: [μ_0, 0, 0, ...]
prior_mean <- c(mu_0, rep(0, 2 * n_harmonics))
# Regularized WLS solution
XtWX <- t(X) %*% W %*% X
XtWy <- t(X) %*% W %*% (y_l / pmax(w_l, 1e-12))
beta <- solve(XtWX + Reg, XtWy + Reg %*% prior_mean)
# Extract parameters
delta[k] <- beta[1] - mu_0
a_coef[, k] <- beta[2:(1 + n_harmonics)]
b_coef[, k] <- beta[(2 + n_harmonics):(1 + 2 * n_harmonics)]
}
# Finalize pooled phi
if (pool_phi && n_accum > 0) {
phi[1, ] <- phi_accum / n_accum
phi[1, ] <- phi[1, ] / (sum(phi[1, ]) * dtheta)
}
# ========================================================================
# M-STEP (Part 3): Update sigma²
# ========================================================================
ss_total <- 0
n_total <- 0
for (k in 1:K) {
yk <- Y[, k]
ok <- is.finite(yk)
if (!any(ok)) next
# Compute kime-surface
S_l <- mu_0 + delta[k]
for (j in 1:n_harmonics) {
S_l <- S_l + a_coef[j, k] * cos_basis[, j] + b_coef[j, k] * sin_basis[, j]
}
phi_k <- if (pool_phi) phi[1, ] else phi[k, ]
# Recompute posteriors
D <- outer(yk[ok], S_l, FUN = "-")
log_lik <- -D^2 / (2 * sigma2)
log_w <- sweep(log_lik, 2, log(pmax(phi_k, 1e-300)), "+")
log_w <- log_w - apply(log_w, 1, max)
w <- exp(log_w); w <- w / rowSums(w)
# Weighted sum of squared residuals
ss_total <- ss_total + sum(w * D^2)
n_total <- n_total + sum(ok)
}
sigma2 <- ss_total / max(n_total, 1)
sigma2 <- max(sigma2, 1e-6) # Floor to prevent collapse
# ========================================================================
# CONVERGENCE CHECK
# ========================================================================
params_new <- c(delta, as.vector(a_coef), as.vector(b_coef))
delta_param <- max(abs(params_new - params_old))
log_lik_history[it] <- log_lik_total
delta_history[it] <- delta_param
if (verbose) {
cat(sprintf("Iter %02d: LL=%.2f Δparam=%.3e σ=%.3f mean(δ)=%.3f\n",
it, log_lik_total, delta_param, sqrt(sigma2), mean(delta)))
}
if (delta_param < tol) {
log_lik_history <- log_lik_history[1:it]
delta_history <- delta_history[1:it]
if (verbose) cat("Converged!\n")
break
}
}
# --------------------------------------------------------------------------
# COMPUTE FINAL KIME-SURFACE GRID
# --------------------------------------------------------------------------
Sgrid <- matrix(0, nrow = K, ncol = L_theta)
for (k in 1:K) {
Sgrid[k, ] <- mu_0 + delta[k]
for (j in 1:n_harmonics) {
Sgrid[k, ] <- Sgrid[k, ] + a_coef[j, k] * cos_basis[, j] + b_coef[j, k] * sin_basis[, j]
}
}
# Compute expected value E[S_k(θ)] for each window
phi_final <- if (pool_phi) phi[1, ] else phi
E_S <- numeric(K)
for (k in 1:K) {
phi_k <- if (pool_phi) phi[1, ] else phi[k, ]
E_S[k] <- sum(Sgrid[k, ] * phi_k) * dtheta
}
list(
# Grid
theta = theta,
t = t,
# Parameters
mu_0 = mu_0,
delta = delta,
a_coef = a_coef,
b_coef = b_coef,
sigma = sqrt(sigma2),
# Kime-phase distribution
varphi_theta = phi,
pool_phi = pool_phi,
# Kime-surface
Sgrid = Sgrid,
# Expected values
E_S = E_S,
# Diagnostics
log_lik_history = log_lik_history,
delta_history = delta_history,
n_harmonics = n_harmonics,
# Regularization used
lambda_delta = lambda_delta,
lambda_coef = lambda_coef
)
}
#' Generate predictive samples from the KPT model
#'
#' @param fit Output from kpt_gem_comprehensive
#' @param t_new New time points for prediction
#' @param n_draw Number of Monte Carlo draws
#' @param method Extrapolation method: "gp" (Gaussian process), "spline", or "constant"
kpt_predict <- function(fit, t_new, n_draw = 5000, method = "constant") {
K_new <- length(t_new)
theta <- fit$theta
L_theta <- length(theta)
dtheta <- 2 * pi / L_theta
# Get phi (shared or last window)
phi <- if (fit$pool_phi) fit$varphi_theta[1, ] else fit$varphi_theta[nrow(fit$varphi_theta), ]
phi <- phi / (sum(phi) * dtheta) # Ensure normalized
# Extrapolate parameters to new times
if (method == "spline" && length(fit$t) >= 4) {
# Use smooth spline with strong smoothing (lower df)
sp_delta <- smooth.spline(fit$t, fit$delta, df = min(4, length(fit$t)/2))
delta_new <- predict(sp_delta, t_new)$y
# Shrink extrapolated values toward zero
in_range <- t_new >= min(fit$t) & t_new <= max(fit$t)
shrink <- exp(-0.1 * pmax(0, t_new - max(fit$t)))
delta_new <- delta_new * shrink
} else if (method == "gp" && length(fit$t) >= 4) {
# Gaussian process with fixed hyperparameters for stability
# This is more sophisticated but requires additional packages
delta_new <- rep(mean(fit$delta), K_new) # Placeholder
} else {
# Constant: use mean of training deltas
delta_new <- rep(mean(fit$delta), K_new)
}
# Similarly for Fourier coefficients - use mean
a_mean <- rowMeans(fit$a_coef)
b_mean <- rowMeans(fit$b_coef)
# Generate predictions
predictions <- matrix(0, nrow = n_draw, ncol = K_new)
point_pred <- numeric(K_new)
PI_low <- numeric(K_new)
PI_high <- numeric(K_new)
cos_basis <- matrix(0, nrow = L_theta, ncol = fit$n_harmonics)
sin_basis <- matrix(0, nrow = L_theta, ncol = fit$n_harmonics)
for (j in 1:fit$n_harmonics) {
cos_basis[, j] <- cos(j * theta)
sin_basis[, j] <- sin(j * theta)
}
for (k in 1:K_new) {
# Compute kime-surface at this new time
S_l <- fit$mu_0 + delta_new[k]
for (j in 1:fit$n_harmonics) {
S_l <- S_l + a_mean[j] * cos_basis[, j] + b_mean[j] * sin_basis[, j]
}
# Sample theta from phi
theta_samples <- sample(theta, n_draw, replace = TRUE, prob = phi)
# Evaluate S at sampled thetas (interpolate)
S_samples <- approx(theta, S_l, xout = theta_samples, rule = 2)$y
# Add noise
predictions[, k] <- S_samples + rnorm(n_draw, 0, fit$sigma)
# Summary statistics
point_pred[k] <- mean(predictions[, k])
PI_low[k] <- quantile(predictions[, k], 0.025)
PI_high[k] <- quantile(predictions[, k], 0.975)
}
list(
t = t_new,
draws = predictions,
point = point_pred,
PI_low = PI_low,
PI_high = PI_high,
delta_used = delta_new
)
}
Train/Test Split
with Physics-Constrained KPT
# Use the same data preparation as before
ok_col <- which(colSums(is.finite(Y)) > 0.8 * nrow(Y))
Y2 <- Y[, ok_col, drop = FALSE]
t2 <- t_center[ok_col]
K2 <- ncol(Y2)
cut <- floor(0.8 * K2)
idx_tr <- 1:cut
idx_te <- (cut + 1):K2
Y_tr <- Y2[, idx_tr, drop = FALSE]
t_tr <- t2[idx_tr]
Y_te <- Y2[, idx_te, drop = FALSE]
t_te <- t2[idx_te]
# Baseline noise scale
sigma_base <- median(apply(Y_tr, 2, sd, na.rm = TRUE))
if (!is.finite(sigma_base) || sigma_base <= 0) {
sigma_base <- sd(as.vector(Y_tr), na.rm = TRUE)
}
cat("\n========================================\n")
##
## ========================================
cat("FITTING COMPREHENSIVE KPT-GEM MODEL\n")
## FITTING COMPREHENSIVE KPT-GEM MODEL
cat("========================================\n")
## ========================================
cat(sprintf("Training windows: %d\n", length(idx_tr)))
## Training windows: 92
cat(sprintf("Test windows: %d\n", length(idx_te)))
## Test windows: 24
cat(sprintf("GR anchor value: %.2f arcsec/century\n", A_GR))
## GR anchor value: 42.98 arcsec/century
# Fit the comprehensive model
fit_kpt <- kpt_gem_comprehensive(
Y = Y_tr,
t = t_tr,
A_GR = A_GR,
L_theta = 256,
n_harmonics = 2,
lambda_delta = 10.0, # Strong regularization toward GR
lambda_coef = 5.0, # Regularize Fourier coefficients
pool_phi = TRUE, # Shared kime-phase distribution
max_iter = 50,
tol = 1e-5,
smooth_kappa = 30,
verbose = TRUE
)
## Iter 01: LL=4.95 Δparam=1.379e+02 σ=11.846 mean(δ)=-5.280
## Iter 02: LL=-3378.03 Δparam=1.718e-02 σ=11.846 mean(δ)=-5.280
## Iter 03: LL=-3378.02 Δparam=2.509e-02 σ=11.846 mean(δ)=-5.280
## Iter 04: LL=-3378.02 Δparam=3.664e-02 σ=11.845 mean(δ)=-5.280
## Iter 05: LL=-3378.01 Δparam=5.348e-02 σ=11.845 mean(δ)=-5.280
## Iter 06: LL=-3378.01 Δparam=7.806e-02 σ=11.845 mean(δ)=-5.280
## Iter 07: LL=-3377.99 Δparam=1.190e-01 σ=11.845 mean(δ)=-5.280
## Iter 08: LL=-3377.94 Δparam=2.054e-01 σ=11.845 mean(δ)=-5.280
## Iter 09: LL=-3377.83 Δparam=3.538e-01 σ=11.844 mean(δ)=-5.280
## Iter 10: LL=-3377.54 Δparam=6.045e-01 σ=11.842 mean(δ)=-5.280
## Iter 11: LL=-3376.77 Δparam=1.010e+00 σ=11.837 mean(δ)=-5.281
## Iter 12: LL=-3374.80 Δparam=1.589e+00 σ=11.826 mean(δ)=-5.282
## Iter 13: LL=-3370.31 Δparam=2.153e+00 σ=11.807 mean(δ)=-5.285
## Iter 14: LL=-3362.60 Δparam=2.221e+00 σ=11.783 mean(δ)=-5.291
## Iter 15: LL=-3353.81 Δparam=1.737e+00 σ=11.756 mean(δ)=-5.300
## Iter 16: LL=-3346.31 Δparam=1.770e+00 σ=11.729 mean(δ)=-5.311
## Iter 17: LL=-3340.84 Δparam=1.553e+00 σ=11.702 mean(δ)=-5.324
## Iter 18: LL=-3337.29 Δparam=1.375e+00 σ=11.671 mean(δ)=-5.339
## Iter 19: LL=-3334.59 Δparam=1.345e+00 σ=11.636 mean(δ)=-5.356
## Iter 20: LL=-3331.85 Δparam=1.354e+00 σ=11.593 mean(δ)=-5.373
## Iter 21: LL=-3328.89 Δparam=1.417e+00 σ=11.543 mean(δ)=-5.390
## Iter 22: LL=-3326.01 Δparam=1.277e+00 σ=11.485 mean(δ)=-5.407
## Iter 23: LL=-3323.97 Δparam=1.483e+00 σ=11.418 mean(δ)=-5.423
## Iter 24: LL=-3323.82 Δparam=1.882e+00 σ=11.343 mean(δ)=-5.441
## Iter 25: LL=-3326.81 Δparam=2.573e+00 σ=11.258 mean(δ)=-5.462
## Iter 26: LL=-3334.00 Δparam=3.678e+00 σ=11.161 mean(δ)=-5.488
## Iter 27: LL=-3346.13 Δparam=4.947e+00 σ=11.049 mean(δ)=-5.519
## Iter 28: LL=-3363.75 Δparam=5.814e+00 σ=10.918 mean(δ)=-5.537
## Iter 29: LL=-3387.23 Δparam=9.221e+00 σ=10.768 mean(δ)=-5.505
## Iter 30: LL=-3417.31 Δparam=1.714e+01 σ=10.600 mean(δ)=-5.379
## Iter 31: LL=-3457.60 Δparam=1.682e+01 σ=10.423 mean(δ)=-5.190
## Iter 32: LL=-3507.65 Δparam=1.927e+01 σ=10.243 mean(δ)=-4.956
## Iter 33: LL=-3566.97 Δparam=2.223e+01 σ=10.067 mean(δ)=-4.702
## Iter 34: LL=-3632.53 Δparam=1.394e+01 σ=9.903 mean(δ)=-4.482
## Iter 35: LL=-3699.94 Δparam=1.090e+01 σ=9.757 mean(δ)=-4.204
## Iter 36: LL=-3775.32 Δparam=1.198e+01 σ=9.632 mean(δ)=-3.786
## Iter 37: LL=-3855.51 Δparam=1.264e+01 σ=9.529 mean(δ)=-3.277
## Iter 38: LL=-3927.51 Δparam=1.260e+01 σ=9.448 mean(δ)=-2.776
## Iter 39: LL=-3981.84 Δparam=1.352e+01 σ=9.390 mean(δ)=-2.353
## Iter 40: LL=-4011.77 Δparam=1.165e+01 σ=9.353 mean(δ)=-2.074
## Iter 41: LL=-4011.55 Δparam=7.816e+00 σ=9.335 mean(δ)=-1.947
## Iter 42: LL=-3984.24 Δparam=4.775e+00 σ=9.331 mean(δ)=-1.916
## Iter 43: LL=-3941.32 Δparam=2.928e+00 σ=9.336 mean(δ)=-1.925
## Iter 44: LL=-3894.62 Δparam=1.868e+00 σ=9.345 mean(δ)=-1.946
## Iter 45: LL=-3851.71 Δparam=1.246e+00 σ=9.354 mean(δ)=-1.968
## Iter 46: LL=-3815.67 Δparam=9.677e-01 σ=9.361 mean(δ)=-1.988
## Iter 47: LL=-3786.87 Δparam=8.847e-01 σ=9.364 mean(δ)=-2.006
## Iter 48: LL=-3764.42 Δparam=9.018e-01 σ=9.363 mean(δ)=-2.023
## Iter 49: LL=-3747.12 Δparam=9.104e-01 σ=9.359 mean(δ)=-2.038
## Iter 50: LL=-3733.79 Δparam=8.483e-01 σ=9.353 mean(δ)=-2.051
cat("\n=== MODEL SUMMARY ===\n")
##
## === MODEL SUMMARY ===
cat(sprintf("Converged in %d iterations\n", length(fit_kpt$log_lik_history)))
## Converged in 50 iterations
cat(sprintf("Final σ: %.3f arcsec/century\n", fit_kpt$sigma))
## Final σ: 9.353 arcsec/century
cat(sprintf("Mean δ (offset from GR): %.3f\n", mean(fit_kpt$delta)))
## Mean δ (offset from GR): -2.051
cat(sprintf("SD of δ across windows: %.3f\n", sd(fit_kpt$delta)))
## SD of δ across windows: 26.041
cat(sprintf("Mean E[S_k(θ)]: %.3f (should be ≈ %.2f)\n", mean(fit_kpt$E_S), A_GR))
## Mean E[S_k(θ)]: 38.192 (should be ≈ 42.98)
# Generate predictions on test set
pred_kpt <- kpt_predict(fit_kpt, t_te, n_draw = 8000, method = "constant")
Comprehensive
Scoring
# Scoring functions
crps_gaussian <- function(y, mu, sigma) {
z <- (y - mu) / sigma
sigma * (z * (2 * pnorm(z) - 1) + 2 * dnorm(z) - 1/sqrt(pi))
}
log_score_mc <- function(y, draws) {
# KDE-based log-score
n <- length(draws)
bw <- 1.06 * sd(draws) * n^(-1/5)
bw <- max(bw, 1e-6)
dens <- mean(dnorm(y, mean = draws, sd = bw))
log(max(dens, 1e-300))
}
crps_mc <- function(y, draws) {
mean(abs(draws - y)) - 0.5 * mean(abs(draws - sample(draws, replace = TRUE)))
}
# Compute predictions and scores for each method
pred_newton_mean <- 0
pred_gr_mean <- A_GR
results_list <- lapply(seq_len(ncol(Y_te)), function(k) {
yk <- Y_te[, k]
yk <- yk[is.finite(yk)]
if (length(yk) < 10) return(NULL)
# KPT predictions
kpt_draws <- pred_kpt$draws[, k]
m_kpt <- mean(kpt_draws)
pi_kpt <- quantile(kpt_draws, c(0.025, 0.975))
# RMSE
rmse_newton <- sqrt(mean((yk - pred_newton_mean)^2))
rmse_gr <- sqrt(mean((yk - pred_gr_mean)^2))
rmse_kpt <- sqrt(mean((yk - m_kpt)^2))
# MLPD
mlpd_newton <- mean(dnorm(yk, mean = pred_newton_mean, sd = sigma_base, log = TRUE))
mlpd_gr <- mean(dnorm(yk, mean = pred_gr_mean, sd = sigma_base, log = TRUE))
mlpd_kpt <- mean(vapply(yk, log_score_mc, numeric(1), draws = kpt_draws))
# CRPS
crps_newton <- mean(crps_gaussian(yk, pred_newton_mean, sigma_base))
crps_gr <- mean(crps_gaussian(yk, pred_gr_mean, sigma_base))
crps_kpt <- mean(vapply(yk, crps_mc, numeric(1), draws = kpt_draws))
# Bias
bias_newton <- mean(yk) - pred_newton_mean
bias_gr <- mean(yk) - pred_gr_mean
bias_kpt <- mean(yk) - m_kpt
tibble(
window = k,
t_center = t_te[k],
y_bar = mean(yk),
n_obs = length(yk),
# Newton
pred_newton = pred_newton_mean,
rmse_newton = rmse_newton,
mlpd_newton = mlpd_newton,
crps_newton = crps_newton,
bias_newton = bias_newton,
# GR
pred_gr = pred_gr_mean,
rmse_gr = rmse_gr,
mlpd_gr = mlpd_gr,
crps_gr = crps_gr,
bias_gr = bias_gr,
# KPT
pred_kpt = m_kpt,
pi_low_kpt = as.numeric(pi_kpt[1]),
pi_high_kpt = as.numeric(pi_kpt[2]),
rmse_kpt = rmse_kpt,
mlpd_kpt = mlpd_kpt,
crps_kpt = crps_kpt,
bias_kpt = bias_kpt
)
})
results_df <- bind_rows(results_list)
# Aggregate results
summary_stats <- results_df %>%
summarise(
# Newton
Newton_RMSE_mean = mean(rmse_newton, na.rm = TRUE),
Newton_RMSE_sd = sd(rmse_newton, na.rm = TRUE),
Newton_MLPD_mean = mean(mlpd_newton, na.rm = TRUE),
Newton_MLPD_sd = sd(mlpd_newton, na.rm = TRUE),
Newton_CRPS_mean = mean(crps_newton, na.rm = TRUE),
Newton_CRPS_sd = sd(crps_newton, na.rm = TRUE),
Newton_Bias = mean(bias_newton, na.rm = TRUE),
# GR
GR_RMSE_mean = mean(rmse_gr, na.rm = TRUE),
GR_RMSE_sd = sd(rmse_gr, na.rm = TRUE),
GR_MLPD_mean = mean(mlpd_gr, na.rm = TRUE),
GR_MLPD_sd = sd(mlpd_gr, na.rm = TRUE),
GR_CRPS_mean = mean(crps_gr, na.rm = TRUE),
GR_CRPS_sd = sd(crps_gr, na.rm = TRUE),
GR_Bias = mean(bias_gr, na.rm = TRUE),
# KPT
KPT_Pred_mean = mean(pred_kpt, na.rm = TRUE),
KPT_Pred_sd = sd(pred_kpt, na.rm = TRUE),
KPT_RMSE_mean = mean(rmse_kpt, na.rm = TRUE),
KPT_RMSE_sd = sd(rmse_kpt, na.rm = TRUE),
KPT_MLPD_mean = mean(mlpd_kpt, na.rm = TRUE),
KPT_MLPD_sd = sd(mlpd_kpt, na.rm = TRUE),
KPT_CRPS_mean = mean(crps_kpt, na.rm = TRUE),
KPT_CRPS_sd = sd(crps_kpt, na.rm = TRUE),
KPT_Bias = mean(bias_kpt, na.rm = TRUE)
)
# Build comparison table
comparison_table <- tibble(
Method = c("Newtonian (no anomaly)", "Relativistic GR", "KPT (Comprehensive GEM)"),
`Point Prediction` = c(
"0.00",
sprintf("%.2f", A_GR),
sprintf("%.2f ± %.2f", summary_stats$KPT_Pred_mean, summary_stats$KPT_Pred_sd)
),
`95% PI` = c(
"—",
"—",
sprintf("[%.2f, %.2f]",
mean(results_df$pi_low_kpt, na.rm = TRUE),
mean(results_df$pi_high_kpt, na.rm = TRUE))
),
RMSE = c(
sprintf("%.2f ± %.2f", summary_stats$Newton_RMSE_mean, summary_stats$Newton_RMSE_sd),
sprintf("%.2f ± %.2f", summary_stats$GR_RMSE_mean, summary_stats$GR_RMSE_sd),
sprintf("%.2f ± %.2f", summary_stats$KPT_RMSE_mean, summary_stats$KPT_RMSE_sd)
),
MLPD = c(
sprintf("%.2f ± %.2f", summary_stats$Newton_MLPD_mean, summary_stats$Newton_MLPD_sd),
sprintf("%.2f ± %.2f", summary_stats$GR_MLPD_mean, summary_stats$GR_MLPD_sd),
sprintf("%.2f ± %.2f", summary_stats$KPT_MLPD_mean, summary_stats$KPT_MLPD_sd)
),
CRPS = c(
sprintf("%.2f ± %.2f", summary_stats$Newton_CRPS_mean, summary_stats$Newton_CRPS_sd),
sprintf("%.2f ± %.2f", summary_stats$GR_CRPS_mean, summary_stats$GR_CRPS_sd),
sprintf("%.2f ± %.2f", summary_stats$KPT_CRPS_mean, summary_stats$KPT_CRPS_sd)
),
`Mean Bias` = c(
sprintf("%.2f", summary_stats$Newton_Bias),
sprintf("%.2f", summary_stats$GR_Bias),
sprintf("%.2f", summary_stats$KPT_Bias)
)
)
cat("\n")
cat("==============================================================================\n")
## ==============================================================================
cat("MERCURY PERIHELION ANOMALY: NEWTON vs GR vs KPT (Comprehensive)\n")
## MERCURY PERIHELION ANOMALY: NEWTON vs GR vs KPT (Comprehensive)
cat("==============================================================================\n")
## ==============================================================================
knitr::kable(comparison_table, format = "simple", align = "c")
| Newtonian (no anomaly) |
0.00 |
— |
53.44 ± 32.69 |
-17.90 ± 14.70 |
46.54 ± 32.27 |
43.87 |
| Relativistic GR |
42.98 |
— |
39.32 ± 20.68 |
-10.69 ± 6.94 |
32.18 ± 20.14 |
0.89 |
| KPT (Comprehensive GEM) |
38.20 ± 0.10 |
[19.61, 56.62] |
39.86 ± 20.43 |
-67.01 ± 106.75 |
33.68 ± 20.09 |
5.68 |
cat("Note: RMSE = Root Mean Square Error (lower is better)\n")
## Note: RMSE = Root Mean Square Error (lower is better)
cat(" MLPD = Mean Log Predictive Density (higher is better)\n")
## MLPD = Mean Log Predictive Density (higher is better)
cat(" CRPS = Continuous Ranked Probability Score (lower is better)\n")
## CRPS = Continuous Ranked Probability Score (lower is better)
cat(" Bias = Mean(Observed - Predicted)\n")
## Bias = Mean(Observed - Predicted)
Visualization:
Model Comparison
# Plot comparison
library(ggplot2)
plot_df <- results_df %>%
mutate(
date = as.Date("2000-01-01") + (t_center - 2451545) # Convert JD to Date
)
# Predictions over time
# p1 <- ggplot(plot_df, aes(x = date)) +
# # Observed means
# geom_point(aes(y = y_bar), color = "black", size = 2, alpha = 0.7) +
#
# # Newton (baseline)
# geom_hline(yintercept = 0, linetype = "dotted", color = "blue", linewidth = 1) +
#
# # GR
# geom_hline(yintercept = A_GR, linetype = "dashed", color = "red", linewidth = 1) +
#
# # KPT predictions with PI
# geom_ribbon(aes(ymin = pi_low_kpt, ymax = pi_high_kpt),
# fill = "green", alpha = 0.2) +
# geom_line(aes(y = pred_kpt), color = "darkgreen", linewidth = 1) +
#
# labs(
# title = "Mercury Perihelion Anomaly Predictions: Newton vs GR vs KPT",
# subtitle = "KPT provides uncertainty quantification while anchoring to GR",
# x = "Date",
# y = "Anomalous Precession (arcsec/century)"
# ) +
# theme_minimal() +
# annotate("text", x = min(plot_df$date), y = 5, label = "Newton (0)",
# hjust = 0, color = "blue") +
# annotate("text", x = min(plot_df$date), y = A_GR + 5,
# label = sprintf("GR (%.2f)", A_GR), hjust = 0, color = "red") +
# annotate("text", x = min(plot_df$date), y = max(plot_df$pi_high_kpt) + 5,
# label = "KPT (with 95% PI)", hjust = 0, color = "darkgreen")
#
# p1
# Ensure A_GR is defined, A_GR <- 42.9799 # example value
p1_interactive <- plot_ly(plot_df, x = ~date) %>%
# Observed means (black points)
add_markers(
y = ~y_bar,
marker = list(color = "black", size = 6, opacity = 0.7),
name = "Observed Mean",
hoverinfo = "text",
text = ~paste("Date:", date, "<br>Observed:", round(y_bar, 3))
) %>%
# KPT prediction interval (ribbon)
add_ribbons(
ymin = ~pi_low_kpt,
ymax = ~pi_high_kpt,
fillcolor = "rgba(0,128,0,0.2)", # green with transparency
line = list(color = "transparent"),
name = "KPT 95% PI"
) %>%
# KPT predicted mean
add_trace(
y = ~pred_kpt,
type = "scatter",
mode = "lines",
line = list(color = "darkgreen", width = 2),
name = "KPT Prediction",
hoverinfo = "text",
text = ~paste("Date:", date, "<br>KPT:", round(pred_kpt, 3))
) %>%
# Layout with reference lines and labels
layout(
title = list(
text = "Mercury Perihelion Anomaly Predictions: Newton vs GR vs KPT",
x = 0.5,
font = list(size = 16)
),
xaxis = list(
title = "Date",
rangeslider = list(visible = TRUE)
),
yaxis = list(
title = "Anomalous Precession (arcsec/century)",
zeroline = FALSE
),
hovermode = "x unified",
showlegend = TRUE,
legend = list(x = 0.02, y = 0.98),
# Horizontal reference lines (Newton = 0, GR = A_GR)
shapes = list(
# Newton baseline (y = 0)
list(
type = "line",
x0 = 0, x1 = 1,
xref = "paper",
y0 = 0, y1 = 0,
line = list(color = "blue", dash = "dot", width = 1)
),
# GR prediction
list(
type = "line",
x0 = 0, x1 = 1,
xref = "paper",
y0 = A_GR, y1 = A_GR,
line = list(color = "red", dash = "dash", width = 1)
)
),
# Annotations (labels near left edge)
annotations = list(
list(
x = min(plot_df$date),
y = 5,
text = "Newton (0)",
showarrow = FALSE,
xanchor = "left",
font = list(color = "blue")
),
list(
x = min(plot_df$date),
y = A_GR + 5,
text = sprintf("GR (%.2f)", A_GR),
showarrow = FALSE,
xanchor = "left",
font = list(color = "red")
),
list(
x = min(plot_df$date),
y = max(plot_df$pi_high_kpt, na.rm = TRUE) + 5,
text = "KPT (with 95% PI)",
showarrow = FALSE,
xanchor = "left",
font = list(color = "darkgreen")
)
)
)
p1_interactive
# RMSE comparison boxplot
rmse_long <- results_df %>%
dplyr::select(window, rmse_newton, rmse_gr, rmse_kpt) %>%
pivot_longer(cols = starts_with("rmse"), names_to = "Method", values_to = "RMSE") %>%
mutate(Method = case_when(
Method == "rmse_newton" ~ "Newton",
Method == "rmse_gr" ~ "GR",
Method == "rmse_kpt" ~ "KPT"
))
# p2 <- ggplot(rmse_long, aes(x = Method, y = RMSE, fill = Method)) +
# geom_boxplot(alpha = 0.7) +
# scale_fill_manual(values = c("Newton" = "blue", "GR" = "red", "KPT" = "green")) +
# labs(
# title = "RMSE Distribution Across Test Windows",
# y = "RMSE (arcsec/century)"
# ) +
# theme_minimal() +
# theme(legend.position = "none")
#
# p2
p2_interactive <- plot_ly(
rmse_long,
x = ~Method,
y = ~RMSE,
type = "box",
color = ~Method,
colors = c("Newton" = "blue", "GR" = "red", "KPT" = "green"),
alpha = 0.7,
boxpoints = "outliers", # show outliers (default); use "all" for all points
hoverinfo = "y+name"
) %>%
layout(
title = "RMSE Distribution Across Test Windows",
yaxis = list(title = "RMSE (arcsec/century)"),
xaxis = list(title = "Method"),
showlegend = FALSE, # matches theme(legend.position = "none")
boxmode = "group" # not strictly needed for single series, but good practice
)
p2_interactive
Note that the comprehensive KPT model works since it relies on:
Physics Anchoring: \(\mu_0 = A_{\text{GR}}\) is fixed, not
learned. The model only learns deviations from GR.
Strong Regularization: \(\lambda_\delta\) penalizes offsets from GR,
preventing the model from drifting to unrealistic values, e.g., \(1,100\) arcsec/century.
Pooled Kime-Phase: Using a single shared \(\varphi(\theta)\) across windows prevents
overfitting and ensures consistent behavior.
Conservative Extrapolation: For test windows, we
use the mean of training parameters rather than potentially unstable
spline extrapolation.
Physical Constraint Enforcement: We verify that
\(\mathbb{E}[S_k(\theta)] \approx
A_{\text{GR}}\) after training.
KPT Model
Diagnostics
### KPT Model Diagnostics - Enhanced Version
### Interactive visualizations with proper date formatting
# Load required libraries
library(plotly)
library(dplyr)
library(ggplot2)
library(viridis)
# Ensure fit_kpt exists
if (!exists("fit_kpt")) {
stop("fit_kpt object not found. Please run KPT fitting first.")
}
# Function to convert JD to proper date format
jd_to_proper_date <- function(jd) {
# JD 0 = 12:00 January 1, 4713 BC
# JD 2440587.5 = 12:00 January 1, 1970 (Unix epoch)
return(as.Date(jd - 2440587.5, origin = "1970-01-01"))
}
# Convert time values to dates if they're Julian Dates
convert_time_to_date <- function(t_values, check_threshold = 2400000) {
if (mean(t_values, na.rm = TRUE) > check_threshold) {
# Likely Julian Dates
return(jd_to_proper_date(t_values))
} else {
# Likely already in years or other units
return(t_values)
}
}
# ==============================================================================
# 1. ENHANCED: Kime-Phase Distribution φ(θ) with Interactive Analysis
# ==============================================================================
# Extract phi values
phi_final <- if (exists("fit_kpt$pool_phi") && fit_kpt$pool_phi) {
fit_kpt$varphi_theta[1, ]
} else if (exists("fit_kpt$varphi_theta")) {
colMeans(fit_kpt$varphi_theta, na.rm = TRUE)
} else {
rep(NA, length(fit_kpt$theta))
}
# Create comprehensive phi analysis data
phi_data <- data.frame(
theta = fit_kpt$theta,
phi = phi_final,
phi_normalized = phi_final / sum(phi_final, na.rm = TRUE),
cumulative_phi = cumsum(phi_final / sum(phi_final, na.rm = TRUE))
)
# Statistics
phi_stats <- list(
mean_theta = weighted.mean(phi_data$theta, phi_data$phi_normalized, na.rm = TRUE),
sd_theta = sqrt(weighted.mean((phi_data$theta - weighted.mean(phi_data$theta, phi_data$phi_normalized))^2,
phi_data$phi_normalized, na.rm = TRUE)),
entropy = -sum(phi_data$phi_normalized * log(phi_data$phi_normalized + 1e-10), na.rm = TRUE),
max_phi = max(phi_data$phi, na.rm = TRUE),
argmax_theta = phi_data$theta[which.max(phi_data$phi)]
)
# Interactive kime-phase distribution
p_phi_interactive <- plot_ly(phi_data, x = ~theta) %>%
# Main phi distribution
add_trace(y = ~phi,
type = 'scatter',
mode = 'lines',
name = 'φ(θ)',
line = list(color = 'purple', width = 3),
fill = 'tozeroy',
fillcolor = 'rgba(128, 0, 128, 0.3)',
hoverinfo = 'text',
text = ~paste(
sprintf("θ = %.3f", theta),
sprintf("φ(θ) = %.4f", phi),
sprintf("Normalized: %.4f", phi_normalized),
sep = "<br>"
)) %>%
layout(
title = list(
text = "Learned Kime-Phase Distribution φ(θ)",
font = list(size = 16, color = 'darkblue')
),
xaxis = list(
title = "θ (Kime-Phase)",
gridcolor = 'lightgray',
zeroline = FALSE
),
yaxis = list(
title = "φ(θ)",
gridcolor = 'lightgray'
),
hovermode = 'x unified',
showlegend = TRUE,
legend = list(x = 0.02, y = 0.98),
margin = list(t = 50),
# Add vertical reference lines via shapes
shapes = list(
# Mean θ
list(
type = "line",
x0 = phi_stats$mean_theta,
x1 = phi_stats$mean_theta,
y0 = 0,
y1 = 1,
yref = "paper", # spans full height of plot
line = list(color = "red", dash = "dash", width = 2)
),
# Mode θ (argmax)
list(
type = "line",
x0 = phi_stats$argmax_theta,
x1 = phi_stats$argmax_theta,
y0 = 0,
y1 = 1,
yref = "paper",
line = list(color = "green", dash = "dot", width = 2)
)
),
# Optional: add legend-like annotations if needed (not required since traces are labeled)
annotations = list(
list(
x = 0.02,
y = 0.98,
xref = "paper",
yref = "paper",
text = paste(
sprintf("Mean θ: %.3f", phi_stats$mean_theta),
sprintf("SD θ: %.3f", phi_stats$sd_theta),
sprintf("Entropy: %.3f", phi_stats$entropy),
sep = "<br>"
),
showarrow = FALSE,
bgcolor = "rgba(255,255,255,0.8)",
bordercolor = "black",
borderwidth = 1
)
)
)
p_phi_interactive
# Additional: Cumulative distribution
p_phi_cumulative <- plot_ly(phi_data, x = ~theta) %>%
add_trace(y = ~cumulative_phi,
type = 'scatter',
mode = 'lines',
name = 'Cumulative φ(θ)',
line = list(color = 'orange', width = 3),
hoverinfo = 'text',
text = ~paste(
sprintf("θ = %.3f", theta),
sprintf("Cumulative: %.3f", cumulative_phi),
sep = "<br>"
)) %>%
layout(
title = "Cumulative Kime-Phase Distribution",
xaxis = list(title = "θ (Kime-Phase)"),
yaxis = list(title = "Cumulative Probability"),
showlegend = TRUE,
# Add horizontal line at y = 0.5
shapes = list(
list(
type = "line",
x0 = 0, x1 = 1,
xref = "paper", # spans entire plot width
y0 = 0.5, y1 = 0.5,
line = list(color = "red", dash = "dash", width = 2)
)
),
# Optional: add annotation for the median line
annotations = list(
list(
x = 1, y = 0.5,
xref = "paper", yref = "y",
xanchor = "right", yanchor = "bottom",
text = "Median",
showarrow = FALSE,
font = list(color = "red")
)
)
)
p_phi_cumulative
# ==============================================================================
# 2. ENHANCED: Offset δ over Time with Date Conversion
# ==============================================================================
# Prepare delta data with proper dates
delta_data <- data.frame(
t_original = fit_kpt$t,
delta = fit_kpt$delta
)
# Convert time to dates if they look like Julian Dates
delta_data$date <- convert_time_to_date(delta_data$t_original)
delta_data$year <- if (inherits(delta_data$date, "Date")) {
as.numeric(format(delta_data$date, "%Y")) +
(as.numeric(format(delta_data$date, "%j")) - 1) / 365.25
} else {
delta_data$t_original
}
# Calculate statistics
delta_stats <- list(
mean_delta = mean(delta_data$delta, na.rm = TRUE),
sd_delta = sd(delta_data$delta, na.rm = TRUE),
min_delta = min(delta_data$delta, na.rm = TRUE),
max_delta = max(delta_data$delta, na.rm = TRUE),
trend = if (nrow(delta_data) > 1) {
coef(lm(delta ~ year, data = delta_data))[2]
} else 0
)
# Interactive delta plot
p_delta_interactive <- plot_ly(delta_data, x = ~date) %>%
# Delta points
add_trace(y = ~delta,
type = 'scatter',
mode = 'markers+lines',
name = 'δ(t)',
line = list(color = 'orange', width = 2),
marker = list(size = 8, color = 'orange'),
hoverinfo = 'text',
text = ~paste(
ifelse(inherits(date, "Date"),
sprintf("Date: %s", format(date, "%Y-%m-%d")),
sprintf("Time: %.1f", t_original)),
sprintf("δ = %.4f arcsec/cy", delta),
sprintf("JD = %.1f", t_original),
sep = "<br>"
)) %>%
# ±1 SD bands (ribbon)
add_ribbons(
ymin = rep(delta_stats$mean_delta - delta_stats$sd_delta, nrow(delta_data)),
ymax = rep(delta_stats$mean_delta + delta_stats$sd_delta, nrow(delta_data)),
name = '±1 SD',
fillcolor = 'rgba(255,165,0,0.2)',
line = list(color = 'transparent')
) %>%
layout(
title = list(
text = "Offset δ from GR Prediction Over Training Windows",
font = list(size = 16)
),
xaxis = list(
title = ifelse(inherits(delta_data$date, "Date"),
"Date (Window Center)",
"Window Center"),
tickformat = ifelse(inherits(delta_data$date, "Date"), "%Y-%m", ""),
rangeslider = list(visible = TRUE)
),
yaxis = list(
title = "δ (arcsec/century)",
gridcolor = 'lightgray'
),
hovermode = 'x unified',
showlegend = TRUE,
margin = list(t = 50),
# Horizontal reference lines via shapes
shapes = list(
# Zero line
list(
type = "line",
x0 = 0, x1 = 1,
xref = "paper",
y0 = 0, y1 = 0,
line = list(color = "gray", dash = "dash", width = 1)
),
# Mean delta line
list(
type = "line",
x0 = 0, x1 = 1,
xref = "paper",
y0 = delta_stats$mean_delta,
y1 = delta_stats$mean_delta,
line = list(color = "red", dash = "dash", width = 2)
)
),
# Annotation box
annotations = list(
list(
x = 0.98,
y = 0.98,
xref = "paper",
yref = "paper",
text = paste(
sprintf("Mean δ: %.3f", delta_stats$mean_delta),
sprintf("SD δ: %.3f", delta_stats$sd_delta),
sprintf("Trend: %.3f/century", delta_stats$trend * 100),
sep = "<br>"
),
showarrow = FALSE,
align = "right",
bgcolor = "rgba(255,255,255,0.8)"
)
)
)
p_delta_interactive
# Additional: Histogram of delta values
p_delta_hist <- plot_ly(
x = ~delta_data$delta,
type = 'histogram',
nbinsx = 20,
name = 'δ Distribution',
marker = list(
color = 'orange',
line = list(color = 'darkorange', width = 1)
)
) %>%
layout(
title = "Distribution of δ Values",
xaxis = list(title = "δ (arcsec/century)"),
yaxis = list(title = "Frequency"),
showlegend = TRUE,
# Add vertical lines via shapes
shapes = list(
# Mean δ
list(
type = "line",
x0 = delta_stats$mean_delta,
x1 = delta_stats$mean_delta,
y0 = 0,
y1 = 1,
yref = "paper", # spans full height of plot
line = list(color = "red", dash = "dash", width = 2)
),
# Zero line
list(
type = "line",
x0 = 0,
x1 = 0,
y0 = 0,
y1 = 1,
yref = "paper",
line = list(color = "gray", dash = "solid", width = 2)
)
),
# Optional: add labels near the top of the lines
annotations = list(
list(
x = delta_stats$mean_delta, y = 1,
yref = "paper", xref = "x",
text = "Mean",
showarrow = FALSE,
font = list(color = "red"),
xanchor = "center", yanchor = "bottom"
),
list(
x = 0, y = 1,
yref = "paper", xref = "x",
text = "Zero",
showarrow = FALSE,
font = list(color = "gray"),
xanchor = "center", yanchor = "bottom"
)
)
)
p_delta_hist
# ==============================================================================
# 3. ENHANCED: Convergence Analysis with Additional Metrics
# ==============================================================================
# Extract convergence history
conv_data <- data.frame(
iteration = 1:length(fit_kpt$log_lik_history),
log_likelihood = fit_kpt$log_lik_history
)
# Calculate convergence metrics
if (length(conv_data$log_likelihood) > 1) {
conv_data$improvement <- c(NA, diff(conv_data$log_likelihood))
conv_data$rel_improvement <- c(NA, diff(conv_data$log_likelihood) /
abs(conv_data$log_likelihood[-length(conv_data$log_likelihood)]))
}
# Convergence plot
p_conv_interactive <- plot_ly(conv_data, x = ~iteration) %>%
# Log-likelihood trace
add_trace(y = ~log_likelihood,
type = 'scatter',
mode = 'lines+markers',
name = 'Log-Likelihood',
line = list(color = 'navy', width = 3),
marker = list(size = 8, color = 'navy'),
yaxis = 'y',
hoverinfo = 'text',
text = ~paste(
sprintf("Iteration: %d", iteration),
sprintf("Log-Likelihood: %.2f", log_likelihood),
ifelse(!is.na(improvement),
sprintf("Improvement: %.4f", improvement),
""),
sep = "<br>"
)) %>%
# Improvement (secondary axis if available)
add_trace(y = ~improvement,
type = 'scatter',
mode = 'lines',
name = 'Improvement',
line = list(color = 'red', width = 2, dash = 'dot'),
yaxis = 'y2',
visible = 'legendonly',
hoverinfo = 'text',
text = ~paste(
sprintf("Iteration: %d", iteration),
sprintf("Improvement: %.4f", improvement),
sep = "<br>"
)) %>%
layout(
title = list(
text = "KPT-GEM Convergence History",
font = list(size = 16)
),
xaxis = list(
title = "Iteration",
gridcolor = 'lightgray'
),
yaxis = list(
title = "Log-Likelihood",
gridcolor = 'lightgray'
),
yaxis2 = list(
title = "Improvement",
overlaying = "y",
side = "right",
showgrid = FALSE
),
hovermode = 'x unified',
showlegend = TRUE,
legend = list(x = 0.02, y = 0.98)
)
p_conv_interactive
# Convergence diagnostics
if (nrow(conv_data) > 10) {
final_ll <- tail(conv_data$log_likelihood, 1)
initial_ll <- head(conv_data$log_likelihood, 1)
total_improvement <- final_ll - initial_ll
avg_improvement <- mean(diff(conv_data$log_likelihood), na.rm = TRUE)
cat("\n=== CONVERGENCE DIAGNOSTICS ===\n")
cat(sprintf("Initial log-likelihood: %.4f\n", initial_ll))
cat(sprintf("Final log-likelihood: %.4f\n", final_ll))
cat(sprintf("Total improvement: %.4f\n", total_improvement))
cat(sprintf("Average improvement per iteration: %.4f\n", avg_improvement))
cat(sprintf("Number of iterations: %d\n", nrow(conv_data)))
}
##
## === CONVERGENCE DIAGNOSTICS ===
## Initial log-likelihood: 4.9477
## Final log-likelihood: -3733.7851
## Total improvement: -3738.7328
## Average improvement per iteration: -76.3007
## Number of iterations: 50
# ==============================================================================
# 4. ENHANCED: Expected Value E[S_k(θ)] vs GR with Date Conversion
# ==============================================================================
# Prepare E[S] data
ES_data <- data.frame(
t_original = fit_kpt$t,
E_S = fit_kpt$E_S
)
# Convert time to dates
ES_data$date <- convert_time_to_date(ES_data$t_original)
# Calculate deviation from GR
ES_data$deviation_from_GR <- ES_data$E_S - A_GR
ES_data$percent_of_GR <- (ES_data$E_S / A_GR) * 100
# Statistics
ES_stats <- list(
mean_E_S = mean(ES_data$E_S, na.rm = TRUE),
sd_E_S = sd(ES_data$E_S, na.rm = TRUE),
mean_deviation = mean(ES_data$deviation_from_GR, na.rm = TRUE),
rms_deviation = sqrt(mean(ES_data$deviation_from_GR^2, na.rm = TRUE))
)
# Interactive E[S] plot
p_ES_interactive <- plot_ly(ES_data, x = ~date) %>%
# E[S] values
add_trace(
y = ~E_S,
type = 'scatter',
mode = 'markers+lines',
name = 'E[S(θ)]',
line = list(color = 'darkgreen', width = 3),
marker = list(size = 10, color = 'darkgreen', symbol = 'circle'),
hoverinfo = 'text',
text = ~paste(
ifelse(inherits(date, "Date"),
sprintf("Date: %s", format(date, "%Y-%m-%d")),
sprintf("Time: %.1f", t_original)),
sprintf("E[S(θ)] = %.4f", E_S),
sprintf("GR = %.4f", A_GR),
sprintf("Deviation: %.4f", deviation_from_GR),
sprintf("%.2f%% of GR", percent_of_GR),
sep = "<br>"
)
) %>%
# ±1 SD band around GR (ribbon)
add_ribbons(
ymin = rep(A_GR - ES_stats$sd_E_S, nrow(ES_data)),
ymax = rep(A_GR + ES_stats$sd_E_S, nrow(ES_data)),
name = '±1 SD from GR',
fillcolor = 'rgba(255,0,0,0.1)',
line = list(color = 'transparent')
) %>%
layout(
title = list(
text = "Expected Value E[S(θ)] vs GR Prediction",
font = list(size = 16)
),
xaxis = list(
title = ifelse(inherits(ES_data$date, "Date"),
"Date (Window Center)",
"Window Center"),
tickformat = ifelse(inherits(ES_data$date, "Date"), "%Y-%m", ""),
rangeslider = list(visible = TRUE)
),
yaxis = list(
title = "E[S(θ)] (arcsec/century)",
gridcolor = 'lightgray'
),
hovermode = 'x unified',
showlegend = TRUE,
margin = list(t = 50),
# Horizontal reference lines via shapes
shapes = list(
# GR prediction line
list(
type = "line",
x0 = 0, x1 = 1,
xref = "paper",
y0 = A_GR, y1 = A_GR,
line = list(color = "red", dash = "dash", width = 3)
),
# Mean E[S] line
list(
type = "line",
x0 = 0, x1 = 1,
xref = "paper",
y0 = ES_stats$mean_E_S, y1 = ES_stats$mean_E_S,
line = list(color = "blue", dash = "dot", width = 2)
)
),
# Annotation box (bottom-right)
annotations = list(
list(
x = 0.98,
y = 0.02,
xref = "paper",
yref = "paper",
text = paste(
sprintf("Mean E[S]: %.4f", ES_stats$mean_E_S),
sprintf("SD: %.4f", ES_stats$sd_E_S),
sprintf("RMS deviation from GR: %.4f", ES_stats$rms_deviation),
sep = "<br>"
),
showarrow = FALSE,
align = "right",
bgcolor = "rgba(255,255,255,0.8)"
)
)
)
p_ES_interactive
# ==============================================================================
# 5. ADDITIONAL DIAGNOSTICS: Model Parameter Correlations
# ==============================================================================
if (exists("fit_kpt$delta") && exists("fit_kpt$E_S") &&
length(fit_kpt$delta) == length(fit_kpt$E_S)) {
corr_data <- data.frame(
delta = fit_kpt$delta,
E_S = fit_kpt$E_S
)
correlation <- cor(corr_data$delta, corr_data$E_S, use = "complete.obs")
p_correlation <- plot_ly(corr_data, x = ~delta, y = ~E_S,
type = 'scatter',
mode = 'markers',
marker = list(size = 10,
color = ~E_S,
colorscale = 'Viridis',
showscale = TRUE),
name = 'Window',
hoverinfo = 'text',
text = ~paste(
sprintf("δ = %.4f", delta),
sprintf("E[S] = %.4f", E_S),
sep = "<br>"
)) %>%
add_lines(x = range(corr_data$delta, na.rm = TRUE),
y = predict(lm(E_S ~ delta, data = corr_data),
newdata = data.frame(delta = range(corr_data$delta, na.rm = TRUE))),
line = list(color = 'red', width = 2),
name = 'Regression') %>%
layout(
title = sprintf("Correlation between δ and E[S]: r = %.3f", correlation),
xaxis = list(title = "δ (arcsec/century)"),
yaxis = list(title = "E[S(θ)] (arcsec/century)"),
showlegend = TRUE
)
p_correlation
}
# ==============================================================================
# 6. COMPOSITE DASHBOARD VIEW
# ==============================================================================
# Create a summary dashboard
cat("\n=== KPT MODEL DIAGNOSTICS SUMMARY ===\n")
##
## === KPT MODEL DIAGNOSTICS SUMMARY ===
cat(sprintf("\n1. Kime-Phase Distribution:\n"))
##
## 1. Kime-Phase Distribution:
cat(sprintf(" Mean θ: %.3f, SD θ: %.3f\n", phi_stats$mean_theta, phi_stats$sd_theta))
## Mean θ: NA, SD θ: NaN
cat(sprintf(" Entropy: %.3f, Max φ: %.4f at θ = %.3f\n",
phi_stats$entropy, phi_stats$max_phi, phi_stats$argmax_theta))
cat(sprintf("\n2. Offset δ Statistics:\n"))
##
## 2. Offset δ Statistics:
cat(sprintf(" Mean δ: %.4f ± %.4f arcsec/cy\n", delta_stats$mean_delta, delta_stats$sd_delta))
## Mean δ: -2.0508 ± 26.0409 arcsec/cy
cat(sprintf(" Range: [%.4f, %.4f]\n", delta_stats$min_delta, delta_stats$max_delta))
## Range: [-54.3838, 47.3894]
cat(sprintf(" Trend: %.4f arcsec/cy per century\n", delta_stats$trend * 100))
## Trend: 2.2421 arcsec/cy per century
cat(sprintf("\n3. Expected Value E[S(θ)]:\n"))
##
## 3. Expected Value E[S(θ)]:
cat(sprintf(" Mean: %.4f ± %.4f arcsec/cy\n", ES_stats$mean_E_S, ES_stats$sd_E_S))
## Mean: 38.1922 ± 55.3321 arcsec/cy
cat(sprintf(" Deviation from GR: %.4f ± %.4f arcsec/cy\n",
ES_stats$mean_deviation, ES_stats$rms_deviation))
## Deviation from GR: -4.7877 ± 55.2384 arcsec/cy
if (exists("correlation")) {
cat(sprintf("\n4. Parameter Correlations:\n"))
cat(sprintf(" δ vs E[S]: r = %.3f\n", correlation))
}
# ==============================================================================
# 7. SAVE DIAGNOSTICS DATA
# ==============================================================================
# Save all diagnostics data
diagnostics_list <- list(
phi_data = phi_data,
phi_stats = phi_stats,
delta_data = delta_data,
delta_stats = delta_stats,
conv_data = conv_data,
ES_data = ES_data,
ES_stats = ES_stats,
timestamp = Sys.time(),
model_version = ifelse(exists("fit_kpt$version"), fit_kpt$version, "unknown")
)
saveRDS(diagnostics_list, "kpt_diagnostics_data.rds")
cat("\nDiagnostics data saved to: kpt_diagnostics_data.rds\n")
##
## Diagnostics data saved to: kpt_diagnostics_data.rds
# Create a summary HTML report
if (requireNamespace("htmltools", quietly = TRUE)) {
library(htmltools)
summary_html <- tags$div(
tags$h2("KPT Model Diagnostics Summary"),
tags$h3("Model Parameters"),
tags$ul(
tags$li(sprintf("Kime-phase mean θ: %.3f", phi_stats$mean_theta)),
tags$li(sprintf("Kime-phase SD θ: %.3f", phi_stats$sd_theta)),
tags$li(sprintf("Mean offset δ: %.4f ± %.4f", delta_stats$mean_delta, delta_stats$sd_delta)),
tags$li(sprintf("Mean E[S]: %.4f ± %.4f", ES_stats$mean_E_S, ES_stats$sd_E_S))
),
tags$h3("Convergence"),
tags$ul(
tags$li(sprintf("Iterations: %d", nrow(conv_data))),
tags$li(sprintf("Final log-likelihood: %.2f", tail(conv_data$log_likelihood, 1)))
)
)
# You could save this to an HTML file
# save_html(summary_html, "kpt_diagnostics_summary.html")
}
cat("\n=== KPT DIAGNOSTICS COMPLETE ===\n")
##
## === KPT DIAGNOSTICS COMPLETE ===
cat(sprintf("Created %d interactive visualizations\n", 5))
## Created 5 interactive visualizations