Skip to content

Commit

Permalink
Add an event detection method based on Regina and Ogden (2021), #130
Browse files Browse the repository at this point in the history
  • Loading branch information
james-d-brown committed Dec 23, 2024
1 parent 891faf8 commit 677f22c
Show file tree
Hide file tree
Showing 20 changed files with 1,266 additions and 53 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ project(':wres-eventdetection') {
implementation project(':wres-datamodel')
api project(':wres-statistics')
implementation 'org.slf4j:slf4j-api:2.0.13'
implementation group: 'org.apache.commons', name: 'commons-math3', version: '3.6.1'

runtimeOnly('ch.qos.logback:logback-classic:1.5.11') {
// Not used at runtime, bloat
Expand Down
15 changes: 8 additions & 7 deletions src/wres/pipeline/pooling/EventsGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import wres.config.yaml.components.EvaluationDeclaration;
import wres.config.yaml.components.EventDetection;
import wres.config.yaml.components.EventDetectionDataset;
import wres.config.yaml.components.EventDetectionMethod;
import wres.datamodel.scale.TimeScaleOuter;
import wres.datamodel.space.Feature;
import wres.datamodel.space.FeatureGroup;
Expand All @@ -27,7 +26,6 @@
import wres.datamodel.time.TimeWindowOuter;
import wres.datamodel.time.TimeSeries;
import wres.eventdetection.EventDetector;
import wres.eventdetection.EventDetectorFactory;
import wres.io.project.Project;
import wres.io.retrieving.RetrieverFactory;
import wres.statistics.generated.TimeScale;
Expand All @@ -40,14 +38,17 @@
* @param baselineUpscaler the upscaler for single-valued time-series with a baseline orientation
* @param covariateUpscaler the upscaler for single-valued time-series with a covariate orientation
* @param measurementUnit the measurement unit
* @param eventDetector the event detector
* @author James Brown
*/
record EventsGenerator( TimeSeriesUpscaler<Double> leftUpscaler,
TimeSeriesUpscaler<Double> rightUpscaler,
TimeSeriesUpscaler<Double> baselineUpscaler,
TimeSeriesUpscaler<Double> covariateUpscaler,
String measurementUnit )
String measurementUnit,
EventDetector eventDetector )
{

/**
* Construct and validate.
*
Expand All @@ -56,6 +57,7 @@ record EventsGenerator( TimeSeriesUpscaler<Double> leftUpscaler,
* @param baselineUpscaler the upscaler for single-valued time-series with a baseline orientation
* @param covariateUpscaler the upscaler for single-valued time-series with a covariate orientation
* @param measurementUnit the measurement unit
* @param eventDetector the event detector
*/
EventsGenerator
{
Expand All @@ -64,6 +66,7 @@ record EventsGenerator( TimeSeriesUpscaler<Double> leftUpscaler,
Objects.requireNonNull( baselineUpscaler );
Objects.requireNonNull( covariateUpscaler );
Objects.requireNonNull( measurementUnit );
Objects.requireNonNull( eventDetector );
}

/** Logger. */
Expand Down Expand Up @@ -356,11 +359,9 @@ private Set<TimeWindowOuter> doEventDetection( TimeSeries<Double> timeSeries,
+ "metadata: {}.", timeSeries.getEvents()
.size(), timeSeries.getMetadata() );

// Get an event detector
EventDetector eventDetector = EventDetectorFactory.getEventDetector( EventDetectionMethod.DEFAULT );

// Unbounded time window, placeholder
return eventDetector.detect( timeSeries, details.detection() );
return this.eventDetector()
.detect( timeSeries );
}

/**
Expand Down
33 changes: 27 additions & 6 deletions src/wres/pipeline/pooling/PoolFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@
import wres.datamodel.time.TimeSeriesUpscaler;
import wres.datamodel.time.TimeWindowOuter;
import wres.datamodel.baselines.PersistenceGenerator;
import wres.eventdetection.EventDetector;
import wres.eventdetection.EventDetectorFactory;
import wres.io.project.Project;
import wres.io.retrieving.CachingSupplier;
import wres.io.retrieving.RetrieverFactory;
Expand Down Expand Up @@ -2938,11 +2940,30 @@ private PoolFactory( Project project )
this.baselineEnsembleUpscaler = TimeSeriesOfEnsembleUpscaler.of( baselineLenient,
this.getUnitMapper()
.getUnitAliases() );
this.eventsGenerator = new EventsGenerator( this.getLeftSingleValuedUpscaler(),
this.getRightSingleValuedUpscaler(),
this.getBaselineSingleValuedUpscaler(),
this.getCovariateSingleValuedUpscaler(),
this.getUnitMapper()
.getDesiredMeasurementUnitName() );

if ( Objects.nonNull( this.project.getDeclaration()
.eventDetection() ) )
{
EventDetector eventDetector = EventDetectorFactory.getEventDetector( this.getProject()
.getDeclaration()
.eventDetection()
.method(),
this.getProject()
.getDeclaration()
.eventDetection()
.parameters() );

this.eventsGenerator = new EventsGenerator( this.getLeftSingleValuedUpscaler(),
this.getRightSingleValuedUpscaler(),
this.getBaselineSingleValuedUpscaler(),
this.getCovariateSingleValuedUpscaler(),
this.getUnitMapper()
.getDesiredMeasurementUnitName(),
eventDetector );
}
else
{
this.eventsGenerator = null;
}
}
}
27 changes: 27 additions & 0 deletions wres-config/nonsrc/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,36 @@ definitions:
minItems: 1
method:
- "$ref": "#/definitions/EventDetectionMethodEnum"
parameters:
anyOf:
- "$ref": "#/definitions/EventDetectionParametersReginaOgden"
required:
- dataset

EventDetectionParametersReginaOgden:
title: Time-series event detection parameters for the Regina-Ogden method
description: "Event detection parameters for the Regina-Ogden event
detection method."
type: object
additionalProperties: false
properties:
window_size:
type: integer
minimum: 1
minimum_event_duration:
type: integer
minimum: 0
half_life:
type: integer
minimum: 1
start_radius:
type: integer
minimum: 0
duration_unit:
"$ref": "#/definitions/DurationEnum"
required:
- duration_unit

CrossPair:
title: Cross-pairing of time-series for consistency
description: "Applies cross-pairing to the time-series within an evaluation
Expand Down
54 changes: 54 additions & 0 deletions wres-config/src/wres/config/yaml/DeclarationValidator.java
Original file line number Diff line number Diff line change
Expand Up @@ -2399,6 +2399,60 @@ private static List<EvaluationStatusEvent> eventDetectionIsValid( EvaluationDecl
events.add( warn );
}

// Check parameters
List<EvaluationStatusEvent> parameters = DeclarationValidator.eventDetectionParametersAreValid( declaration );
events.addAll( parameters );

return Collections.unmodifiableList( events );
}

/**
* Checks that any event detection parameters are valid.
* @param declaration the evaluation declaration
* @return the validation events encountered
*/
private static List<EvaluationStatusEvent> eventDetectionParametersAreValid( EvaluationDeclaration declaration )
{
List<EvaluationStatusEvent> events = new ArrayList<>();

// Parameters undefined for which estimates/defaults are speculative: warn
if ( Objects.isNull( declaration.eventDetection()
.parameters() )
|| Objects.isNull( declaration.eventDetection()
.parameters()
.windowSize() ) )
{
EvaluationStatusEvent warn
= EvaluationStatusEvent.newBuilder()
.setStatusLevel( StatusLevel.WARN )
.setEventMessage( "Event detection was declared, but the window size "
+ "parameter was undefined. An attempt will be made to "
+ "choose a reasonable default by inspecting the "
+ "time-series data, but it is strongly recommended that "
+ "you instead declare the 'window_size' explicitly as "
+ "the default value may not be appropriate." )
.build();
events.add( warn );
}
if ( Objects.isNull( declaration.eventDetection()
.parameters() )
|| Objects.isNull( declaration.eventDetection()
.parameters()
.halfLife() ) )
{
EvaluationStatusEvent warn
= EvaluationStatusEvent.newBuilder()
.setStatusLevel( StatusLevel.WARN )
.setEventMessage( "Event detection was declared, but the half-life "
+ "parameter was undefined. An attempt will be made to "
+ "choose a reasonable default by inspecting the "
+ "time-series data, but it is strongly recommended that "
+ "you instead declare the 'half_life' explicitly as the "
+ "default value may not be appropriate." )
.build();
events.add( warn );
}

return Collections.unmodifiableList( events );
}

Expand Down
22 changes: 18 additions & 4 deletions wres-config/src/wres/config/yaml/components/EventDetection.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@

/**
* Used for the detection of discrete events within time-series.
* @param datasets the datasets to use for event detection
*
* @param datasets the datasets to use for event detection, required
* @param method the event detection method, optional
* @param parameters the event detection parameters, optional unless required by a particular method
*/
@RecordBuilder
@JsonDeserialize( using = EventDetectionDeserializer.class )
public record EventDetection( @JsonProperty( "dataset" ) Set<EventDetectionDataset> datasets,
@JsonProperty( "method ") EventDetectionMethod method )
@JsonProperty( "method " ) EventDetectionMethod method,
EventDetectionParameters parameters )
{
/**
* Creates an instance.
* @param datasets the datasets to use for event detection
*
* @param datasets the datasets to use for event detection, required
* @param method the event detection method, optional
* @param parameters the event detection parameters, optional unless required by a particular method
*/
public EventDetection
{
Expand All @@ -31,9 +38,16 @@ public record EventDetection( @JsonProperty( "dataset" ) Set<EventDetectionDatas
throw new IllegalArgumentException( "Declare at least one dataset for event detection." );
}

if( Objects.isNull( method ) )
if ( Objects.isNull( method ) )
{
method = EventDetectionMethod.DEFAULT;
}

// Default parameters
if ( Objects.isNull( parameters ) )
{
parameters = EventDetectionParametersBuilder.builder()
.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package wres.config.yaml.components;


import java.time.Duration;

import io.soabase.recordbuilder.core.RecordBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Event detection parameters.
*
* @param halfLife the half-life or decay term for exponential weighted averaging, which is used to smooth noise
* @param windowSize the duration over which a moving window is applied for trend detection
* @param minimumEventDuration the minimum event duration
* @param startRadius the radius to use when phase shifting events to a local minimum
*/
@RecordBuilder
public record EventDetectionParameters( Duration halfLife,
Duration windowSize,
Duration minimumEventDuration,
Duration startRadius )
{
/** Logger. */
private static final Logger LOGGER = LoggerFactory.getLogger( EventDetectionParameters.class );

/**
* Sets the defaults.
*
* @param halfLife the half-life or decay term for exponential weighted averaging, which is used to smooth noise
* @param windowSize the duration over which a moving window is applied for trend detection
* @param minimumEventDuration the minimum event duration, which defaults to the half-life, else zero
* @param startRadius the radius to use when phase shifting events to a local minimum, which defaults to zero
*/
public EventDetectionParameters
{
LOGGER.debug( "The event detection parameters were set as follows. The half life: {}. The window size: {}. "
+ "The minimum event duration: {}. The start radius: {}.",
halfLife,
windowSize,
minimumEventDuration,
startRadius );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public record FeatureGroups( Set<GeometryGroup> geometryGroups, Map<GeometryTupl
/**
* Sets the default values.
* @param geometryGroups the geometry groups
* @param offsets the offset values associated with features in the group, such as a datum offset, if any
*/
public FeatureGroups
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,33 @@ public Duration deserialize( JsonParser jp, DeserializationContext context )
ObjectReader mapper = ( ObjectReader ) jp.getCodec();
JsonNode node = mapper.readTree( jp );

return DurationDeserializer.getDuration( mapper, node, "period" );
return DurationDeserializer.getDuration( mapper, node, "period", "unit" );
}

/**
* Reads a {@link Duration} from a {@link JsonNode}.
* @param mapper the object mapper
* @param node the node to read
* @param durationNodeName the name of the node that contains the duration quantity
* @param durationUnitName the duration unit name
* @return a duration
* @throws IOException if the node could not be read
*/

static Duration getDuration( ObjectReader mapper, JsonNode node, String durationNodeName ) throws IOException
static Duration getDuration( ObjectReader mapper,
JsonNode node,
String durationNodeName,
String durationUnitName ) throws IOException
{
Objects.requireNonNull( mapper );
Objects.requireNonNull( node );

Duration duration = null;
if ( node.has( durationNodeName ) && node.has( "unit" ) )
if ( node.has( durationNodeName )
&& node.has( durationUnitName ) )
{
JsonNode periodNode = node.get( durationNodeName );
JsonNode unitNode = node.get( "unit" );
JsonNode unitNode = node.get( durationUnitName );
long durationUnit = periodNode.asLong();
String unitString = unitNode.asText();
ChronoUnit chronoUnit = mapper.readValue( unitString, ChronoUnit.class );
Expand All @@ -57,5 +62,4 @@ static Duration getDuration( ObjectReader mapper, JsonNode node, String duration

return duration;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*
* @author James Brown
*/
public class DurationIntervalDeserializer extends JsonDeserializer<Pair<Duration,Duration>>
public class DurationIntervalDeserializer extends JsonDeserializer<Pair<Duration, Duration>>
{
/** The name of the first duration within the interval. */
private final String firstName;
Expand All @@ -25,15 +25,15 @@ public class DurationIntervalDeserializer extends JsonDeserializer<Pair<Duration
private final String secondName;

@Override
public Pair<Duration,Duration> deserialize( JsonParser jp, DeserializationContext context )
public Pair<Duration, Duration> deserialize( JsonParser jp, DeserializationContext context )
throws IOException
{
Objects.requireNonNull( jp );

ObjectReader mapper = ( ObjectReader ) jp.getCodec();
JsonNode node = mapper.readTree( jp );
Duration first = DurationDeserializer.getDuration( mapper, node, this.firstName );
Duration second = DurationDeserializer.getDuration( mapper, node, this.secondName );
Duration first = DurationDeserializer.getDuration( mapper, node, this.firstName, "unit" );
Duration second = DurationDeserializer.getDuration( mapper, node, this.secondName, "unit" );

return Pair.of( first, second );
}
Expand Down
Loading

0 comments on commit 677f22c

Please sign in to comment.