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();
    }

}