CompositeIndicator.java
/*
* This file is part of Indicators.
*
* Indicators is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Indicators is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Indicators. If not, see <https://www.gnu.org/licenses/>.
*/
package fr.inrae.agroclim.indicators.model.indicator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import fr.inrae.agroclim.indicators.exception.IndicatorsException;
import fr.inrae.agroclim.indicators.exception.type.ComputationErrorType;
import fr.inrae.agroclim.indicators.model.Evaluation;
import fr.inrae.agroclim.indicators.model.EvaluationType;
import fr.inrae.agroclim.indicators.model.Knowledge;
import fr.inrae.agroclim.indicators.model.LocalizedString;
import fr.inrae.agroclim.indicators.model.Parameter;
import fr.inrae.agroclim.indicators.model.data.DailyData;
import fr.inrae.agroclim.indicators.model.data.Data;
import fr.inrae.agroclim.indicators.model.data.DataLoadingListener;
import fr.inrae.agroclim.indicators.model.data.DataLoadingListenerHandler;
import fr.inrae.agroclim.indicators.model.data.HasDataLoadingListener;
import fr.inrae.agroclim.indicators.model.data.Resource;
import fr.inrae.agroclim.indicators.model.data.Variable;
import fr.inrae.agroclim.indicators.model.function.aggregation.AggregationFunction;
import fr.inrae.agroclim.indicators.model.function.aggregation.JEXLFunction;
import fr.inrae.agroclim.indicators.model.function.listener.AggregationFunctionListener;
import fr.inrae.agroclim.indicators.model.function.normalization.Exponential;
import fr.inrae.agroclim.indicators.model.indicator.listener.IndicatorEvent;
import fr.inrae.agroclim.indicators.model.indicator.listener.IndicatorListener;
import fr.inrae.agroclim.indicators.util.Doublet;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.xml.bind.annotation.XmlTransient;
import jakarta.xml.bind.annotation.XmlType;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.log4j.Log4j2;
/**
* Composite indicator has list of indicators.
*
* Last date $Date$
*
* @author $Author$
* @version $Revision$
*/
@XmlRootElement
@XmlType(propOrder = {"tag", "aggregationFunction", "indicators"})
@EqualsAndHashCode(
callSuper = true,
of = {"aggregationFunction", "indicators", "tag"}
)
@Log4j2
public class CompositeIndicator extends Indicator
implements DataLoadingListener, Detailable, HasDataLoadingListener, Comparable<Indicator> {
/**
* UUID for Serializable.
*/
private static final long serialVersionUID = 6030595237342422003L;
/**
* @param start id of start stage (eg.: s0)
* @param end id of end stage (eg.: s1)
* @return phase as a CompositeIndicator
*/
public static CompositeIndicator createPhase(final String start,
final String end) {
final String langCode = "en";
final Indicator startStage = new CompositeIndicator();
startStage.setId("pheno_" + start);
startStage.setName(langCode, start);
startStage.setIndicatorCategory(IndicatorCategory.PHENO_PHASES);
final CompositeIndicator phase = new CompositeIndicator();
phase.setId(start + end);
phase.setName(langCode, end);
phase.setIndicatorCategory(IndicatorCategory.PHENO_PHASES);
phase.add(startStage);
// by default, set fake aggregation
final JEXLFunction jexl = new JEXLFunction();
jexl.setExpression("0.0d");
phase.setAggregationFunction(jexl);
return phase;
}
/**
* Function to aggregate values from indicator list.
*/
@XmlElement
@Getter
@Setter
private AggregationFunction aggregationFunction;
/**
* Handler for data loading listeners.
*/
@XmlTransient
private final DataLoadingListenerHandler dataLoadingListenerHandler;
/**
* Indicator list componing the composite indicator.
*/
@XmlElement(name = "indicator")
@Getter
private List<Indicator> indicators;
/**
* Tag of phase.
*/
@XmlElement
@Getter
@Setter
private String tag;
/**
* Constructor.
*/
public CompositeIndicator() {
super();
indicators = new ArrayList<>();
dataLoadingListenerHandler = new DataLoadingListenerHandler(
getListeners());
}
/**
* Constructor.
*
* @param c indicator to clone
*/
public CompositeIndicator(final CompositeIndicator c) {
this();
setId(c.getId());
try {
if (c.getNames() != null) {
setNames(new ArrayList<>());
for (final LocalizedString name : c.getNames()) {
getNames().add(name.clone());
}
}
if (c.getNormalizationFunction() != null) {
setNormalizationFunction(c.getNormalizationFunction().clone());
} else {
setNormalizationFunction(new Exponential());
}
} catch (final CloneNotSupportedException ex) {
LOGGER.catching(ex);
}
this.setCategory(c.getCategory());
this.setParent(c.getParent());
final ArrayList<Indicator> newIndicatorlist = new ArrayList<>();
c.getIndicators().forEach(i -> {
try {
newIndicatorlist.add(i.clone());
} catch (final CloneNotSupportedException ex) {
LOGGER.fatal("should never occurs as "
+ "indicator must implement clone()", ex);
}
});
this.setIndicators(newIndicatorlist);
this.setAggregationFunction(c.getAggregationFunction());
this.setNormalizationFunction(c.getNormalizationFunction());
this.setValue(c.getValue());
this.tag = c.tag;
this.setColor(c.getColor());
this.setNotNormalizedValue(c.getNotNormalizedValue());
}
/**
* @param i
* phase to add.
*/
public final void add(final Indicator i) {
i.setParent(this);
indicators.add(i);
if (getAggregationFunction() != null) {
return;
}
/* Si il ne s'agit pas de l'évaluation */
if (isAggregationNeeded()) {
setAggregationFunction(new JEXLFunction());
fireAggregationFunctionUpdated();
}
}
@Override
public final void addDataLoadingListener(final DataLoadingListener l) {
dataLoadingListenerHandler.addDataLoadingListener(l);
}
@Override
public final void addDataLoadingListeners(final DataLoadingListener[] ls) {
dataLoadingListenerHandler.addDataLoadingListeners(ls);
}
/**
* @param listener
* listener for aggregation function changes
*/
public final void addFunctionListener(final AggregationFunctionListener listener) {
getListeners().add(AggregationFunctionListener.class, listener);
}
/**
* Remove all aggregation function listeners.
*/
public final void clearFunctionListener() {
for (final AggregationFunctionListener listener
: getAggregationFunctionListeners()) {
getListeners().remove(AggregationFunctionListener.class, listener);
}
}
/**
* Clear phase list.
*/
public final void clearIndicators() {
indicators.clear();
}
@Override
@SuppressWarnings("checkstyle:DesignForExtension")
public CompositeIndicator clone() {
return new CompositeIndicator(this);
}
@Override
public final int compareTo(final Indicator o) {
final Comparator<Indicator> comparator
= Comparator.comparing(Indicator::getName,
Comparator.nullsFirst(Comparator.naturalOrder()));
return comparator.compare(this, o);
}
@Override
public final Double compute(final Resource<? extends DailyData> climResource) throws IndicatorsException {
if (climResource.getYears().isEmpty()) {
throw new RuntimeException(
String.format(
"No years in ClimaticResource (%d dailyData)!",
climResource.getData().size()));
}
final Map<String, Double> results = new HashMap<>();
double valueAfterAggregation = 0;
Double valueAfterNormalization;
for (final Indicator indicator : indicators) {
// isPhase() ?
if (IndicatorCategory.PHENO_PHASES.getTag().equals(
indicator.getCategory())) {
// On ignore l'indicateur s'il s'agit du stade final de la phase
indicator.setValue(null);
indicator.setNotNormalizedValue(null);
continue;
}
try {
final Double value = indicator.compute(climResource);
results.put(indicator.getId(), value);
} catch (final IndicatorsException e) {
throw new IndicatorsException(ComputationErrorType.COMPOSITE_COMPUTATION, e, indicator.getId());
}
}
if (isAggregationNeeded()) {
if (aggregationFunction != null) {
valueAfterAggregation = aggregationFunction.aggregate(results);
} else {
LOGGER.error("No aggregation function defined for {} in evaluation of type {}", getId(), getType());
}
} else if (EvaluationType.WITHOUT_AGGREGATION != getType()) {
if (results.keySet().isEmpty()) {
LOGGER.trace("No result for indicator {}", getId());
} else {
valueAfterAggregation = results.values().iterator().next();
}
}
// no normalization for simple evaluation
if (EvaluationType.WITHOUT_AGGREGATION != getType() && getNormalizationFunction() != null) {
if (getCategory() == null) {
if (!getCategory().equals(
IndicatorCategory.CLIMATIC_EFFECTS.getTag())) {
valueAfterNormalization = getNormalizationFunction()
.normalize(valueAfterAggregation);
} else {
valueAfterNormalization = valueAfterAggregation;
}
} else {
valueAfterNormalization = getNormalizationFunction().normalize(valueAfterAggregation);
}
} else {
valueAfterNormalization = valueAfterAggregation;
}
setNotNormalizedValue(valueAfterAggregation);
setValue(valueAfterNormalization);
return valueAfterNormalization;
}
/**
* @return true if at least one of the composed indicators is climatic
*/
@SuppressWarnings("checkstyle:DesignForExtension")
public boolean containsClimaticIndicator() {
boolean contains = false;
for (final Indicator indicator : getIndicators()) {
final String cat = indicator.getCategory();
if (indicator instanceof final CompositeIndicator compositeIndicator
&& !IndicatorCategory.PHENO_PHASES.getTag().equals(cat)) {
contains = compositeIndicator.containsClimaticIndicator();
if (!contains) {
break;
}
} else if (IndicatorCategory.INDICATORS.getTag().equals(cat)) {
contains = true;
break;
}
}
return contains;
}
/**
* This implementation raises functionAdded event to the
* AggregationFunctionListener of the composite indicator.
*/
public final void fireAggregationFunctionUpdated() {
for (final AggregationFunctionListener a
: getAggregationFunctionListeners()) {
a.onFunctionAdded(this);
}
}
@Override
public final void fireDataLoadingAddEvent(final Data data) {
dataLoadingListenerHandler.fireDataLoadingAddEvent(data);
}
@Override
public final void fireDataLoadingEndEvent(final String text) {
dataLoadingListenerHandler.fireDataLoadingEndEvent(text);
}
@Override
public final void fireDataLoadingStartEvent(final String text) {
dataLoadingListenerHandler.fireDataLoadingStartEvent(text);
}
@Override
public void fireDataSetEvent(final DataFile dataFile) {
// do nothing
}
@Override
public final void fireValueUpdated() {
getIndicators().forEach(Indicator::fireValueUpdated);
for (final IndicatorListener l
: getListeners().getListeners(IndicatorListener.class)) {
l.onIndicatorEvent(
IndicatorEvent.Type.UPDATED_VALUE.event(this));
}
}
/**
* @return listeners for aggregation function
*/
private AggregationFunctionListener[] getAggregationFunctionListeners() {
return getListeners().getListeners(AggregationFunctionListener.class);
}
@Override
public final DataLoadingListener[] getDataLoadingListeners() {
return dataLoadingListenerHandler.getDataLoadingListeners();
}
/**
* @return The first indicator of the list.
*/
public final Indicator getFirstIndicator() {
if (getIndicators() == null || getIndicators().isEmpty()) {
return null;
}
return getIndicators().iterator().next();
}
@Override
public final List<Doublet<Parameter, Number>> getParameterDefaults() {
final List<Doublet<Parameter, Number>> val = new ArrayList<>();
indicators.forEach(i -> val.addAll(i.getParameterDefaults()));
return val;
}
@Override
public final List<Parameter> getParameters() {
return indicators.stream()
.flatMap(i -> i.getParameters().stream())
.distinct()
.collect(Collectors.toList());
}
@Override
public final Map<String, Double> getParametersValues() {
final Map<String, Double> val = new HashMap<>();
indicators.forEach(indicator ->
indicator.getParametersValues().forEach((id, value) -> {
if (!val.containsKey(id)) {
val.put(id, value);
}
})
);
return val;
}
/**
* @return Evaluation type.
*/
@Override
public final EvaluationType getType() {
Evaluation evaluation = null;
if (this instanceof final Evaluation eval) {
evaluation = eval;
} else {
if (getParent() == null) {
return null;
}
CompositeIndicator p = (CompositeIndicator) getParent();
while (p != null) {
if (p instanceof final Evaluation eval) {
evaluation = eval;
break;
}
p = (CompositeIndicator) p.getParent();
}
}
if (evaluation == null) {
return null;
}
if (evaluation.getSettings() == null) {
return null;
}
return evaluation.getSettings().getType();
}
@Override
@SuppressWarnings("checkstyle:DesignForExtension")
public Set<Variable> getVariables() {
final Set<Variable> variables = new HashSet<>();
getIndicators().forEach(indicator -> variables.addAll(indicator.getVariables()));
return variables;
}
/**
* Set parent of indicators.
*/
public void initializeParent() {
getIndicators().stream().map(ind -> {
ind.setParent(this);
return ind;
})
.filter(CompositeIndicator.class::isInstance)
.forEach(ind -> ((CompositeIndicator) ind).initializeParent());
}
/**
* Detect if aggregation function is needed but missing.
*
* @param fire fire events while checking
* @return true if aggregation function is needed but missing
*/
public final boolean isAggregationMissing(final boolean fire) {
if (getType() == EvaluationType.WITHOUT_AGGREGATION) {
return false;
}
boolean isMissing = false;
final AggregationFunction aggregation = getAggregationFunction();
if (isAggregationNeeded()
&& (aggregation == null || !aggregation.isValid())) {
if (fire) {
fireIndicatorEvent(IndicatorEvent.Type.AGGREGATION_MISSING
.event(this));
}
isMissing = true;
}
for (final Indicator indicator : getIndicators()) {
if (indicator instanceof final CompositeIndicator compositeIndicator
&& compositeIndicator.isAggregationMissing(fire)) {
isMissing = true;
}
}
return isMissing;
}
/**
* @return if aggregation is needed, according to category and number of
* composed indicators
*/
private boolean isAggregationNeeded() {
if (getType() == EvaluationType.WITHOUT_AGGREGATION) {
return false;
}
final int minimum;
// evaluation
if (getCategory() == null || !isPhase()) {
minimum = 1;
} else {
/*
* Cas d'une phase phénologique : le 1er indicateur correspond au
* stade phénologique de fin
*/
minimum = 2;
}
return indicators.size() > minimum;
}
@Override
public final boolean isComputable() {
boolean isComputable = true;
for (final Indicator indicator : getIndicators()) {
if (!indicator.isComputable()) {
isComputable = false;
fireIndicatorEvent(
IndicatorEvent.Type.NOT_COMPUTABLE.event(indicator));
}
}
return isComputable;
}
@Override
public final boolean isComputable(
final Resource<? extends DailyData> data) {
return true;
}
/**
* Phases are categorized as CULTURAL_PRACTICES or
* ECOPHYSIOLOGICAL_PROCESSES in GETARI after edition.
*
* @return if it is a phase.
*/
public final boolean isPhase() {
return IndicatorCategory.PHENO_PHASES.equals(getIndicatorCategory())
|| getTag() != null && getTag().startsWith("pheno-");
}
/**
* @param id
* id of indicator to check
* @return indicator of id is present
*/
public final boolean isPresent(final String id) {
boolean result = false;
for (final Indicator i : indicators) {
if (i.getId() == null) {
throw new RuntimeException("Indicator id is null for " + i);
}
if (i.getId().equals(id)) {
result = true;
break;
}
}
return result;
}
@Override
public final void onDataLoadingAdd(final Data data) {
fireDataLoadingAddEvent(data);
}
@Override
public final void onDataLoadingEnd(final String text) {
fireDataLoadingEndEvent(text);
}
@Override
public final void onDataLoadingStart(final String text) {
fireDataLoadingStartEvent(text);
}
@Override
public void onFileSet(final DataFile dataFile) {
// do nothing
}
/**
* @param i
* indicator to remove
* @return if this list contained the specified indicator
*/
public final boolean remove(final Indicator i) {
boolean result;
result = indicators.remove(i);
// Pour une phase, si il s'agit du second appel (result = false), on ne fait rien
if (!result && i.getIndicatorCategory() == IndicatorCategory.PHENO_PHASES) {
return false;
}
if (!isAggregationNeeded() && aggregationFunction != null) {
setAggregationFunction(null);
fireAggregationFunctionUpdated();
}
fireIndicatorEvent(IndicatorEvent.Type.REMOVE.event(i));
isAggregationMissing(true);
if (!containsClimaticIndicator()) {
/* Ne contient pas d'indicateur climatique */
fireIndicatorEvent(
IndicatorEvent.Type.CLIMATIC_MISSING.event(this));
}
return result;
}
@Override
public final void removeParameter(final Parameter param) {
if (getIndicators() != null) {
getIndicators().forEach(i -> i.removeParameter(param));
}
}
/**
* @param children children indicators
*/
public final void setIndicators(final List<Indicator> children) {
this.indicators = new ArrayList<>(children);
}
@Override
public final void setParametersFromKnowledge(final Knowledge knowledge) {
getIndicators().forEach(i -> i.setParametersFromKnowledge(knowledge));
}
@Override
public final void setParametersValues(final Map<String, Double> values) {
getIndicators().forEach(i -> i.setParametersValues(values));
}
@Override
public final String toStringTree(final String indent) {
final StringBuilder sb = new StringBuilder();
sb.append(toStringTreeBase(indent));
if (aggregationFunction != null) {
sb.append(indent).append(" aggregation: ")
.append(aggregationFunction.toString()).append("\n");
}
getIndicators().forEach(indicator -> {
sb.append(indent).append(" indicator:\n");
sb.append(indicator.toStringTree(indent + " "));
});
return sb.toString();
}
}