Skip to content

Commit 69431fd

Browse files
Merge pull request #662 from LinkedEarth/annualize
Annualize
2 parents e7b20b1 + af4f253 commit 69431fd

7 files changed

Lines changed: 904 additions & 12 deletions

File tree

condaenv.6d4f03jk.requirements.txt

Lines changed: 0 additions & 2 deletions
This file was deleted.

condaenv.k07sdt7n.requirements.txt

Lines changed: 0 additions & 2 deletions
This file was deleted.

oryx-build-commands.txt

Lines changed: 0 additions & 2 deletions
This file was deleted.

pyleoclim/core/series.py

Lines changed: 245 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ class Series:
126126
set to True to remove the NaNs and make time axis strictly prograde with duplicated timestamps reduced by averaging the values
127127
Default is None (marked for deprecation)
128128
129-
auto_time_params : bool,
129+
auto_time_params : bool
130130
If True, uses tsbase.disambiguate_time_metadata to ensure that time_name and time_unit are usable by Pyleoclim. This may override the provided metadata.
131131
If False, the provided time_name and time_unit are used. This may break some functionalities (e.g. common_time and convert_time_unit), so use at your own risk.
132132
If not provided, code will set to True for internal consistency.
@@ -2399,6 +2399,250 @@ def sort(self, verbose=False, ascending = True, keep_log = False):
23992399
new.log=()
24002400
new.log += ({len(new.log):'sort', 'ascending': ascending},)
24012401
return new
2402+
2403+
def annualize(self, months=list(range(1, 13)), min_res=0.25, frac_req_months=2/3):
2404+
'''
2405+
Annualize subannual data by averaging values within specified months for each year.
2406+
2407+
This method converts subannual time series data to annual resolution by computing
2408+
weighted averages of values from specified months using the custom_year_averages
2409+
utility function.
2410+
2411+
Parameters
2412+
----------
2413+
months : list of int, optional
2414+
List of months to include in the annual aggregation. Must be consecutive
2415+
months either within a calendar year or spanning across years (e.g.,
2416+
[10, 11, 12, 1, 2, 3] for Oct-Mar). Default is a calendar average, [1 ... 12].
2417+
min_res : float, optional
2418+
Minimum resolution threshold (in years) to determine if data is subannual.
2419+
Default is 0.25 (quarterly or finer resolution required).
2420+
frac_req_months : float, optional
2421+
Minimum fraction of requested months that must be present for a year to be included.
2422+
Default is 2/3 (67%). For example, if requesting 12 months, at least 8 months must be
2423+
present. If requesting 3 months (e.g., DJF), at least 2 months must be present.
2424+
Years that do not meet this threshold are dropped from the resulting series.
2425+
Set to 1.0 to require all months, or lower values (e.g., 1/3) for more lenient inclusion.
2426+
2427+
Returns
2428+
-------
2429+
Series
2430+
A new Series object with annualized data.
2431+
2432+
Examples
2433+
--------
2434+
1) Annual average
2435+
.. jupyter-execute::
2436+
2437+
soi = pyleo.utils.load_dataset('SOI')
2438+
dt = soi.resolution().describe()['median']
2439+
print(f"The series' resolution is {dt*12:.0f} month")
2440+
2441+
soi_a = soi.annualize()
2442+
dta = soi_a.resolution().describe()['median']
2443+
print(f"The series' resolution is now {dta:.0f} year")
2444+
fig, ax = soi.plot(title='Jan-Dec averaging')
2445+
soi_a.plot(marker='o',ax=ax)
2446+
2447+
2) JJA average : straightforward
2448+
.. jupyter-execute::
2449+
2450+
soi_jja = soi.annualize(months=[6, 7 , 8])
2451+
fig, ax = soi.plot(title='JJA averaging')
2452+
soi_jja.plot(marker='o',ax=ax, label='JJA average')
2453+
2454+
3) DJF average : straddles a year; handles it gracefully
2455+
.. jupyter-execute::
2456+
2457+
soi_djf = soi.annualize(months=[12, 1 , 2])
2458+
fig, ax = soi.plot(title='DJF averaging')
2459+
soi_djf.plot(marker='o',ax=ax, label='DJF average')
2460+
2461+
4) Varying the fraction of required months
2462+
.. jupyter-execute::
2463+
2464+
AprMar = [4,5,6,7,8,9,10,11,12,1,2,3]
2465+
soi_am_default = soi.annualize(months=AprMar)
2466+
soi_am_stringent = soi.annualize(months=AprMar,frac_req_months=0.9)
2467+
2468+
fig, ax = soi.plot(title='Apr-Mar averaging')
2469+
soi_am_default.plot(marker='o',ax=ax, label='Apr-Mar, $f=2/3$')
2470+
soi_am_stringent.plot(marker='o',ax=ax, label='Apr-Mar, $f=0.9$')
2471+
2472+
We see that insisting on a very high fraction of available months will result in dropped years
2473+
2474+
Notes
2475+
-----
2476+
- This method requires subannual data (median resolution <= min_res)
2477+
- Months must be consecutive to define a clear averaging period
2478+
- Uses weighted averaging to account for potentially uneven temporal spacing
2479+
'''
2480+
2481+
# Check if data is subannual using Resolution class
2482+
if not hasattr(self, 'time_unit') or 'year' not in self.time_unit.lower():
2483+
raise ValueError("time_unit must contain 'Year' to attempt annualization")
2484+
2485+
if not 0 <= min_res <= 0.5:
2486+
raise ValueError(f"min_res must be in (0, 0.5); got {min_res}")
2487+
2488+
# Check for suitable time axis properties
2489+
dt = self.resolution().describe()['median']
2490+
if dt >= min_res:
2491+
raise ValueError(f"Data appears to be too coarse (median resolution: {dt:.1f} years >= {min_res}) for meaningful averages.")
2492+
if dt < 0:
2493+
warnings.warn("Series was retrograde; it has been sorted in ascending order so annualization runs properly", RuntimeWarning)
2494+
self = self.sort()
2495+
2496+
# Set default months if not provided
2497+
if months is None:
2498+
months = list(range(1, 13))
2499+
2500+
# Validate months parameter
2501+
if not isinstance(months, (list, tuple)):
2502+
raise ValueError("months must be a list or tuple of integers")
2503+
2504+
if not all(isinstance(m, int) and 1 <= m <= 12 for m in months):
2505+
raise ValueError("months must contain integers between 1 and 12")
2506+
2507+
if len(months) == 0:
2508+
raise ValueError("months cannot be empty")
2509+
2510+
# Check if months are consecutive (allowing for year-end wrapping)
2511+
def is_consecutive_with_wrap(months_list):
2512+
"""Check if months are consecutive, allowing wrapping around year boundary"""
2513+
if len(months_list) <= 1:
2514+
return True
2515+
2516+
# Check normal consecutive case
2517+
is_normal_consecutive = True
2518+
for i in range(1, len(months_list)):
2519+
if months_list[i] != months_list[i-1] + 1:
2520+
is_normal_consecutive = False
2521+
break
2522+
2523+
if is_normal_consecutive:
2524+
return True
2525+
2526+
# Check wrap-around case (e.g., [11, 12, 1, 2] or [12, 1, 2])
2527+
# Find where the wrap occurs (where month decreases)
2528+
wrap_point = None
2529+
for i in range(1, len(months_list)):
2530+
if months_list[i] < months_list[i-1]:
2531+
if wrap_point is None:
2532+
wrap_point = i
2533+
else:
2534+
return False # Multiple wraps not allowed
2535+
2536+
if wrap_point is None:
2537+
return False # No wrap found but not consecutive
2538+
2539+
# Check consecutive before wrap
2540+
for i in range(1, wrap_point):
2541+
if months_list[i] != months_list[i-1] + 1:
2542+
return False
2543+
2544+
# Check consecutive after wrap
2545+
for i in range(wrap_point + 1, len(months_list)):
2546+
if months_list[i] != months_list[i-1] + 1:
2547+
return False
2548+
2549+
# Check that the wrap makes sense (December -> January)
2550+
if months_list[wrap_point-1] == 12 and months_list[wrap_point] == 1:
2551+
return True
2552+
2553+
return False
2554+
2555+
if not is_consecutive_with_wrap(months):
2556+
raise ValueError("months must be consecutive (e.g., [1,2,3] or [11,12,1,2])")
2557+
2558+
# Determine start and end months (use original order, not sorted)
2559+
start_month = months[0]
2560+
end_month = months[-1]
2561+
2562+
# Convert to pandas Series using existing method
2563+
try:
2564+
series = self.to_pandas()
2565+
except Exception as e:
2566+
raise ValueError(f"Could not convert Series to pandas: {e}")
2567+
2568+
# Ensure the index is datetime
2569+
if not hasattr(series.index, 'month'):
2570+
try:
2571+
series.index = pd.to_datetime(series.index)
2572+
except Exception as e:
2573+
raise ValueError(f"Could not convert time axis to datetime: {e}")
2574+
2575+
# Use the new custom_year_averages function
2576+
try:
2577+
annual_data = tsutils.custom_year_averages(
2578+
data=series,
2579+
start_month=start_month,
2580+
end_month=end_month,
2581+
years=None # Use all available years
2582+
)
2583+
except Exception as e:
2584+
raise ValueError(f"Failed to compute annual averages: {e}")
2585+
2586+
# Apply frac_req_months filtering
2587+
# Filter out years that don't meet the minimum fraction requirement
2588+
sufficient_years = []
2589+
2590+
for year in annual_data.index:
2591+
# Determine the date range for this year's period
2592+
if start_month <= end_month:
2593+
# Same calendar year
2594+
period_start = pd.Timestamp(year=year, month=start_month, day=1)
2595+
period_end = pd.Timestamp(year=year, month=end_month, day=28) # Use 28 to be safe
2596+
else:
2597+
# Spans calendar years
2598+
period_start = pd.Timestamp(year=year-1, month=start_month, day=1)
2599+
period_end = pd.Timestamp(year=year, month=end_month, day=28) # Use 28 to be safe
2600+
2601+
# Get data for this period
2602+
period_mask = (series.index >= period_start) & (series.index <= period_end)
2603+
period_data = series[period_mask]
2604+
2605+
if len(period_data) == 0:
2606+
continue
2607+
2608+
# Count available months (works for any temporal resolution, not just monthly)
2609+
available_months = set(period_data.index.month)
2610+
required_months = set(months)
2611+
available_required_months = available_months.intersection(required_months)
2612+
2613+
# Calculate fraction of required months that are present
2614+
fraction_present = len(available_required_months) / len(required_months)
2615+
2616+
if fraction_present >= frac_req_months:
2617+
sufficient_years.append(year)
2618+
2619+
# Filter annual_data to only include years with sufficient data
2620+
if sufficient_years:
2621+
annual_data = annual_data[annual_data.index.isin(sufficient_years)]
2622+
else:
2623+
annual_data = pd.Series([], dtype=float, name=annual_data.name)
2624+
2625+
# Report dropped years
2626+
total_years_before = len(tsutils.custom_year_averages(series, start_month, end_month, years=None).index)
2627+
dropped_years = total_years_before - len(annual_data)
2628+
2629+
if dropped_years > 0:
2630+
warnings.warn(f"Dropped {dropped_years} of {total_years_before} years due to insufficient data coverage (< {frac_req_months:.1%} of requested months)", RuntimeWarning)
2631+
2632+
if annual_data.empty:
2633+
raise ValueError(f"No years found with sufficient data coverage (>= {frac_req_months:.1%} of requested months). Consider lowering frac_req_months.")
2634+
2635+
# Create new Series by copying self and replacing time/value
2636+
annual_series = self.copy()
2637+
annual_series.time = annual_data.index.values.astype(float)
2638+
annual_series.value = annual_data.values
2639+
2640+
# Update label to indicate annualization
2641+
if self.label:
2642+
annual_series.label = f"{self.label} (annualized)"
2643+
2644+
return annual_series
2645+
24022646

24032647
def gaussianize(self, keep_log = False):
24042648
''' Gaussianizes the timeseries (i.e. maps its values to a standard normal)

0 commit comments

Comments
 (0)