Search code examples
javalistjava-streamreduceminimum

Find the closest value by property with Stream API


I am currently working on some Java code that has a goal:

  • Receive list of Collection<ForecastPerDate> (see below);
  • Find items that have date >= today;
  • Get the value of the item with date closest to today (minimum diff);
  • Floor it and round it;
  • If no data has been found, it should fallback to 0 with a log message.
public record ForecastPerDate(String date, Double value) {}

My implementation so far seems pretty efficient and sane to me, but I don't like mutating variables or state (I am becoming more of a Haskell dev lately haha) and always quite liked using the Streams API of Java.

Just FYI the project uses Java 17 so that helps. I assume this probably can be solved with a reduce() function and some accumulator but I am unclear on how to, at least without causing more than one iteration.

Here is the code:

 @Override
    public Long getAvailabilityFromForecastData(final String fuCode,
                                                final String articleCode,
                                                final Collection<ForecastPerDate> forecasts) {
        if (forecasts == null || forecasts.isEmpty()) {
            log.info(
                    "No forecasts received for FU {} articleCode {}, assuming 0!",
                    fuCode,
                    articleCode
            );
            return 0L;
        }

        final long todayEpochDay = LocalDate.now().toEpochDay();
        final Map<String, Double> forecastMap = new HashMap<>();
        long smallestDiff = Integer.MAX_VALUE;
        String smallestDiffDate = null;

        for (final ForecastPerDate forecast : forecasts) {
            final long forecastEpochDay = LocalDate.parse(forecast.date()).toEpochDay();
            final long diff = forecastEpochDay - todayEpochDay;

            if (diff >= 0 && diff < smallestDiff) {
                // we look for values in present or future (>=0)
                smallestDiff = diff;
                smallestDiffDate = forecast.date();
                forecastMap.put(forecast.date(), forecast.value());
            }
        }

        if (smallestDiffDate != null) {
            final Double wantedForecastValue = forecastMap.get(smallestDiffDate);
            if (wantedForecastValue != null) {
                return availabilityAmountFormatter(wantedForecastValue);
            }
        }

        log.info(
                "Resorting to fallback for FU {} articleCode {}, 0 availability for article!  Forecasts: {}",
                fuCode,
                articleCode,
                forecasts
        );
        return 0L;
    }

    private Long availabilityAmountFormatter(final Double raw) {
        return Math.round(Math.floor(raw));
    }

EDIT: In the end after all suggestions here, a nice little algorithm came out:

    private static Long toEpochDay(final String date) {
        return LocalDate.parse(date).toEpochDay();
    }

    @Override
    public Long getAvailabilityFromForecastData(final String fuCode,
                                                final String articleCode,
                                                final Collection<ForecastPerDate> forecasts) {
        final long today = LocalDate.now().toEpochDay();
        final String fallbackMessage = "Resorting to fallback for FU {} articleCode {},"
                + " 0 availability for article! Forecasts: {}";

        if (forecasts == null) {
            log.info(fallbackMessage, fuCode, articleCode, null);
            return 0L;
        }

        final Optional<ForecastPerDate> result = forecasts.stream()
                .filter(fpd -> toEpochDay(fpd.date()) > today)
                .min(Comparator.comparing(fpd -> toEpochDay(fpd.date()) - today));

        if (result.isPresent()) {
            return availabilityAmountFormatter(result.get().value());
        } else {
            log.info(fallbackMessage, fuCode, articleCode, forecasts);
            return 0L;
        }
    }

    private Long availabilityAmountFormatter(final Double raw) {
        return Math.round(Math.floor(raw));
    }

Solution

  • Here is one approach.

    • stream the collection of objects
    • filter out any dates older than and including today.
    • then collect using Collectors.minBy and a special comparator.
    • then use the result with the rest of your code to either return the value or log the result.
    public Long getAvailabilityFromForecastData(final String fuCode,
            final String articleCode,
            final Collection<ForecastPerDate> forecasts) {
        
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("M/d/yyyy");
        
        Comparator<ForecastPerDate> comp =
                Comparator.comparing(f -> LocalDate.parse(f.date(), dtf),
                        (date1, date2) -> date1.compareTo(date2));
        
        Optional<ForecastPerDate> result = forecasts.stream()
                .filter(fpd -> LocalDate.parse(fpd.date(),dtf)
                        .isAfter(LocalDate.now()))
                .collect(Collectors.minBy(comp));
        
        if (result.isPresent()) {
            return availabilityAmountFormatter(result.get().value());
        }
        log.info(
                "Resorting to fallback for FU {} articleCode {}, 0 availability for article!  Forecasts: {}",
                fuCode, articleCode, forecasts);
        return 0L;
    }
    

    Demo

    Note: for this answer and demo I included in the above method a DateTimeFormatter since I don't know the format of your dates. You will probably need to alter it for your application.

            List<ForecastPerDate> list = List.of(
                    new ForecastPerDate("6/14/2022", 112.33),
                    new ForecastPerDate("6/19/2022", 122.33),
                    new ForecastPerDate("6/16/2022", 132.33),
                    new ForecastPerDate("6/20/2022", 142.33));
            long v = getAvailabilityFromForecastData("Foo","Bar", list);
            System.out.println(v);
    

    prints

    132 (based on current day of 6/15/2022)
    

    If no dates are present after the current day, 0 will be returned and the issue logged.

    Updated

    I believe this will be somewhat more efficient since the dates only need to be parsed a single time.

    You can put date and the actual ForecastPerDate object in an AbstractMap.SimpleEntry and at the same time parse the LocalDate.

    • Stream as before.
    • create the entry (i.e. map(fpd -> new SimpleEntry<>( LocalDate.parse(fpd.date(), dtf), fpd))
      • this parses the date and stores as the key, the record is the value.
    • Now that the date is parsed you can filter as before by getting the key from the entry.
    • The minBy Comparator is also simpler since there are pre-existing Entry comparators for key and value. So use .collect(Collectors.minBy(Entry.comparingByKey()));

    Putting it all together.

    Optional<Entry<LocalDate, ForecastPerDate>> result = forecasts
            .stream()
            .map(fpd -> new SimpleEntry<>(
                    LocalDate.parse(fpd.date(), dtf), fpd))
            .filter(e -> e.getKey().isAfter(LocalDate.now()))
            .collect(Collectors.minBy(Entry.comparingByKey()));
    

    To get the result there is one extra level of indirection. Get the Optional contents followed by the Entry.value() and then the value from ForecastPerDate object.

    if (result.isPresent()) {
        return availabilityAmountFormatter(result.get().getValue().value());
    }