Josep Bigorra
Josep Bigorra

Reputation: 833

Find the closest value by property with Stream API

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

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

Upvotes: 3

Views: 664

Answers (4)

Alexander Ivanchenko
Alexander Ivanchenko

Reputation: 29028

I assume this probably can be solved with a reduce()

It's a perfectly valid use case for reduce().

I have one proposal regarding the structure of ForecastPerDate. It would be far more convenient to store the date as LocalDate and not as String. That is the correct way of dealing with dates, because there's a little you can do with a string representing the date without parsing.

And even if you don't consider the idea of changing the ForecastPerDate as a possible option, it would not be difficult to adopt the following approach.

I'll proceed with ForecastPerDate record defined as follows:

public record ForecastPerDate(LocalDate date, Double value) {}

Find items that have date >= today

When we need to compare the two LocalDate objects there's no need to extract day from epoch and manipulate with them manually, basically it's a violation of the Information expert principle because LocalDate instances are capable to compare their data. Just let them do that by using ifAfter() and equals().

And there's no need for the intermediate Map. We can perform the reduction on the ForecastPerDate objects by always picking the one with the closest upcoming date.

In the code below, reduce() produces the result as an Optional<ForecastPerDate> (because identity is not provided) which gets transformed into Optional<Long>. If optional is empty, then a default value of 0L will be provided.

That's how it might look like:

public static Long getAvailabilityFromForecastData(final String fuCode,
                                                   final String articleCode,
                                                   final Collection<ForecastPerDate> forecasts) {
    // writing a log message
    
    LocalDate now = LocalDate.now();
    
    Long mostClosestForecastValue = forecasts.stream()
        .filter(forecast -> forecast.date().isAfter(now) || forecast.date.equals(now)) // today in the future
        .filter(forecast -> forecast.value() != null)
        .reduce((result, next) -> result.date().isBefore(next.date()) ? result : next) // Optional<ForecastPerDate>
        .map(forecast -> availabilityAmountFormatter(forecast.value())) // Optional<Long>
        .orElse(0L); // extracting Long value from the optional
    
    // writing a log message
    
    return mostClosestForecastValue;
}

A link to Online Demo

Upvotes: 3

WJS
WJS

Reputation: 40062

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

Upvotes: 0

Zircon
Zircon

Reputation: 4707

If you want to keep a similar flow to what you have, try something like this:

final LocalDate now = LocalDate.now();
return forecasts.stream()
        .map(ForecastPerDate::date)
        .map(LocalDate::parse)
        .filter(fd -> fd.isEqual(now) || fd.isAfter(now))
        .min()
        .map(LocalDate::toString)
        .map(forecastMap::get)
        .map(this::availabilityAmountFormatter)
        .orElseGet(() -> {
            log.info("Resorting to fallback...");
            return 0L;
        });

Upvotes: -1

marstran
marstran

Reputation: 28056

Most of the operations you mention are available method-calls on streams.

  • Receive list of Collection -> forecasts.stream()
  • Find items that have date >= today -> .filter()
  • Get the "value" of the item with date closest to today (minimum diff) -> .min(), giving an Optional<ForecastPerDate>
  • Floor it and round it -> optional.map()
  • If no data has been found it should fallback to 0 with a log message -> optional.orElseGet()

Put together, it would be something like this (I haven't compiled it, so it probably won't work on the first try):

@Override
public Long getAvailabilityFromForecastData(final String fuCode,
                                            final String articleCode,
                                            final Collection<ForecastPerDate> forecasts) {

    var today = LocalDate.now();

    return forecasts.stream()
        .filter(forecast -> !today.isBefore(LocalDate.parse(forecast.date())))
        .min(Comparator.comparing(forecast -> 
             Duration.between(today, LocalDate.parse(forecast.date()))
        .map(forecast -> availabilityAmountFormatter(forecast.value()))
        .orElseGet(() -> {
            log.info("No forecasts found");
            return 0L;
        });
}

I would move some of the logic into ForecastPerDate to avoid having to parse forecast.date() multiple times.

Upvotes: 1

Related Questions