Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions api/src/main/java/org/openmrs/module/queue/utils/QueueUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,68 @@ public static Double computeAverageWaitTimeInMinutes(List<QueueEntry> queueEntri
return null;
}

/**
* Measures those still waiting, unlike {@link #computeAverageWaitTimeInMinutes(List)}, which
* averages waits that have already finished
*
* @param queueEntries the QueueEntries to check
* @param asOf the point in time to measure the open durations against
* @return the average duration, in minutes, between startedAt and asOf for entries that have a
* startedAt and no endedAt, or null if there are no such entries
*/
public static Double computeAverageOpenWaitTimeInMinutes(List<QueueEntry> queueEntries, Date asOf) {
if (queueEntries != null) {
double totalWaitTime = 0.0;
int numEntries = 0;
for (QueueEntry e : queueEntries) {
Long waitTime = computeOpenWaitTimeInMinutes(e, asOf);
if (waitTime != null) {
totalWaitTime += waitTime;
numEntries++;
}
}
// Returning 0.0 here would be indistinguishable from a genuine zero-minute wait
if (numEntries > 0) {
return totalWaitTime / numEntries;
}
}
return null;
}

/**
* @param queueEntry the QueueEntry to measure
* @param asOf the point in time to measure the open duration against
* @return how long the entry has been waiting, in minutes, never negative, or null if it has no
* startedAt or has already ended
*/
public static Long computeOpenWaitTimeInMinutes(QueueEntry queueEntry, Date asOf) {
if (queueEntry == null || asOf == null || queueEntry.getStartedAt() == null || queueEntry.getEndedAt() != null) {
return null;
}
// Measured between instants rather than between local date times, so that a wait spanning a
// daylight saving change reports the time that actually elapsed. Floored at zero, so that an
// entry with a startedAt in the future reports as not yet waiting rather than as negative
return Math.max(0, Duration.between(queueEntry.getStartedAt().toInstant(), asOf.toInstant()).toMinutes());
}

/**
* @param queueEntries the QueueEntries to check
* @return the entry that has a startedAt, has no endedAt, and started earliest, or null if there is
* no such entry
*/
public static QueueEntry findLongestOpenWait(List<QueueEntry> queueEntries) {
QueueEntry longestWaiting = null;
if (queueEntries != null) {
for (QueueEntry e : queueEntries) {
if (e.getStartedAt() != null && e.getEndedAt() == null
&& (longestWaiting == null || e.getStartedAt().before(longestWaiting.getStartedAt()))) {
longestWaiting = e;
}
}
}
return longestWaiting;
}

/**
* @param startDate1, endDate1 - the start and end date of one timeframe
* @param startDate2, endDate2 - the start and end date of second timeframe
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;

import java.time.Instant;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.TimeZone;

import org.junit.Test;
import org.openmrs.module.queue.model.QueueEntry;
Expand Down Expand Up @@ -77,6 +79,74 @@ public void shouldComputeAverageWaitTimeInMinutes() {
assertThat(QueueUtils.computeAverageWaitTimeInMinutes(null), is(nullValue()));
}

@Test
public void shouldComputeAverageOpenWaitTimeInMinutes() {
// Measured against the given instant rather than the end of the entry, so entries that have
// not ended are exactly the ones that count. Waits of 48 and 24 hours average out to 36.
assertThat(QueueUtils.computeAverageOpenWaitTimeInMinutes(entries(entry(AUG_1, NULL), entry(AUG_2, NULL)), AUG_3),
is(2160.0));

// An entry that has already ended is not still waiting, whatever its duration was
assertThat(QueueUtils.computeAverageOpenWaitTimeInMinutes(entries(entry(AUG_1, NULL), entry(AUG_1, AUG_2)), AUG_2),
is(1440.0));

// Entries without a start cannot be measured
assertThat(QueueUtils.computeAverageOpenWaitTimeInMinutes(entries(entry(NULL, NULL), entry(AUG_1, NULL)), AUG_2),
is(1440.0));

// Test that there is no average to report rather than dividing by zero and returning NaN
assertThat(QueueUtils.computeAverageOpenWaitTimeInMinutes(entries(entry(AUG_1, AUG_2)), AUG_3), is(nullValue()));
assertThat(QueueUtils.computeAverageOpenWaitTimeInMinutes(entries(), AUG_3), is(nullValue()));
assertThat(QueueUtils.computeAverageOpenWaitTimeInMinutes(null, AUG_3), is(nullValue()));
assertThat(QueueUtils.computeAverageOpenWaitTimeInMinutes(entries(entry(AUG_1, NULL)), NULL), is(nullValue()));
}

@Test
public void shouldFindLongestOpenWait() {
QueueEntry waitingSinceAug1 = entry(AUG_1, NULL);
QueueEntry waitingSinceAug2 = entry(AUG_2, NULL);
QueueEntry endedAfterTwoDays = entry(AUG_1, AUG_3);

// The entry that has been waiting longest is the one that started earliest
assertThat(QueueUtils.findLongestOpenWait(entries(waitingSinceAug2, waitingSinceAug1)), is(waitingSinceAug1));

// An entry that has ended is not waiting at all, however long it ran for
assertThat(QueueUtils.findLongestOpenWait(entries(endedAfterTwoDays, waitingSinceAug2)), is(waitingSinceAug2));

assertThat(QueueUtils.findLongestOpenWait(entries(endedAfterTwoDays)), is(nullValue()));
assertThat(QueueUtils.findLongestOpenWait(entries(entry(NULL, NULL))), is(nullValue()));
assertThat(QueueUtils.findLongestOpenWait(entries()), is(nullValue()));
assertThat(QueueUtils.findLongestOpenWait(null), is(nullValue()));
}

@Test
public void shouldComputeOpenWaitTimeInMinutes() {
assertThat(QueueUtils.computeOpenWaitTimeInMinutes(entry(AUG_1, NULL), AUG_2), is(1440L));
assertThat(QueueUtils.computeOpenWaitTimeInMinutes(entry(AUG_1, AUG_2), AUG_3), is(nullValue()));
assertThat(QueueUtils.computeOpenWaitTimeInMinutes(entry(NULL, NULL), AUG_2), is(nullValue()));
assertThat(QueueUtils.computeOpenWaitTimeInMinutes(null, AUG_2), is(nullValue()));
assertThat(QueueUtils.computeOpenWaitTimeInMinutes(entry(AUG_1, NULL), NULL), is(nullValue()));
// An entry whose startedAt is in the future has not started waiting yet
assertThat(QueueUtils.computeOpenWaitTimeInMinutes(entry(AUG_2, NULL), AUG_1), is(0L));
}

@Test
public void shouldMeasureAnOpenWaitAcrossADaylightSavingChange() {
TimeZone originalTimeZone = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/London"));
// British clocks go forward an hour at 01:00 UTC on this date, so an hour of waiting looks
// like two if it is measured between local date times rather than between instants
Date startedAt = Date.from(Instant.parse("2023-03-26T00:30:00Z"));
Date asOf = Date.from(Instant.parse("2023-03-26T01:30:00Z"));

assertThat(QueueUtils.computeOpenWaitTimeInMinutes(entry(startedAt, NULL), asOf), is(60L));
}
finally {
TimeZone.setDefault(originalTimeZone);
}
}

private List<QueueEntry> entries(QueueEntry... queueEntries) {
return Arrays.asList(queueEntries);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,24 @@

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.openmrs.Concept;
import org.openmrs.module.queue.api.QueueServicesWrapper;
import org.openmrs.module.queue.api.search.QueueEntrySearchCriteria;
import org.openmrs.module.queue.api.search.QueueSearchCriteria;
import org.openmrs.module.queue.model.Queue;
import org.openmrs.module.queue.model.QueueEntry;
import org.openmrs.module.queue.utils.QueueUtils;
import org.openmrs.module.queue.web.resources.parser.QueueEntrySearchCriteriaParser;
import org.openmrs.module.webservices.rest.SimpleObject;
import org.openmrs.module.webservices.rest.web.ConversionUtil;
import org.openmrs.module.webservices.rest.web.RestConstants;
import org.openmrs.module.webservices.rest.web.representation.CustomRepresentation;
import org.openmrs.module.webservices.rest.web.v1_0.controller.BaseRestController;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
Expand All @@ -43,6 +51,29 @@ public class QueueEntryMetricRestController extends BaseRestController {

public static final String AVERAGE_WAIT_TIME = "averageWaitTime";

public static final String AVERAGE_OPEN_WAIT_TIME = "averageOpenWaitTime";

public static final String LONGEST_OPEN_WAIT = "longestOpenWait";

public static final String COUNTS_BY_STATUS = "countsByStatus";

public static final String GROUP_BY = "groupBy";

public static final String WAIT_STATUS = "waitStatus";

public static final String QUEUE = "queue";

public static final String QUEUES = "queues";

// Narrower than REF, which for a queue entry also carries the queue, status, visit and priority,
// each a lazy load, for every queue reported on
private static final String LONGEST_OPEN_WAIT_REP = "uuid,display,startedAt,patient:(uuid,display)";

// Wider than REF, which carries neither the location and service that label a row nor the retired
// flag, but narrower than DEFAULT, which also carries the allowed priorities and statuses
private static final String QUEUE_REP = "uuid,display,name,description,retired,"
+ "location:(uuid,display),service:(uuid,display)";

private final QueueEntrySearchCriteriaParser searchCriteriaParser;

private final QueueServicesWrapper services;
Expand All @@ -66,22 +97,136 @@ public Object handleRequest(HttpServletRequest request) {

QueueEntrySearchCriteria criteria = searchCriteriaParser.constructFromRequest(parameters);

String[] groupByArray = parameters.get(GROUP_BY);
boolean groupByQueue = groupByArray != null && Arrays.asList(groupByArray).contains(QUEUE);
Comment thread
UjjawalPrabhat marked this conversation as resolved.

// If we only want count, then use the ore efficient service to get counts
if (metrics.size() == 1 && metrics.get(0).equals(COUNT)) {
if (!groupByQueue && metrics.size() == 1 && metrics.get(0).equals(COUNT)) {
ret.add(COUNT, services.getQueueEntryService().getCountOfQueueEntries(criteria).intValue());
} else {
List<QueueEntry> queueEntries = services.getQueueEntryService().getQueueEntries(criteria);
if (metrics.isEmpty() || metrics.contains(COUNT)) {
ret.add(COUNT, queueEntries.size());
// One instant for every duration, so the per-queue figures and the totals cannot disagree
Date asOf = new Date();
String[] waitStatusArray = parameters.get(WAIT_STATUS);
List<Concept> waitStatuses = null;
if (waitStatusArray != null) {
waitStatuses = new ArrayList<>(services.getConcepts(waitStatusArray));
// A blank status ref resolves to a null element
waitStatuses.removeIf(c -> c == null);
}
addMetrics(ret, queueEntries, metrics, asOf, waitStatuses);
if (groupByQueue) {
ret.add(QUEUES, getMetricsPerQueue(queueEntries, criteria, metrics, asOf, waitStatuses));
}
}

return ret;
}

// Adds the requested metrics, or all of them if none were requested
private void addMetrics(SimpleObject target, List<QueueEntry> queueEntries, List<String> metrics, Date asOf,
List<Concept> waitStatuses) {
if (metrics.isEmpty() || metrics.contains(COUNT)) {
target.add(COUNT, queueEntries.size());
}
if (metrics.isEmpty() || metrics.contains(AVERAGE_WAIT_TIME)) {
target.add(AVERAGE_WAIT_TIME, QueueUtils.computeAverageWaitTimeInMinutes(queueEntries));
}
// Unlike the two above, the remaining metrics are only reported when asked for, so that a caller
// that names no metric keeps receiving exactly what it received before they existed.
// An entry's startedAt is reset when it is called in to be seen, so the two open wait metrics are
// measured over only the statuses the caller counts as waiting, where it names any.
List<QueueEntry> waitingEntries = filterByStatus(queueEntries, waitStatuses);
if (metrics.contains(AVERAGE_OPEN_WAIT_TIME)) {
target.add(AVERAGE_OPEN_WAIT_TIME, QueueUtils.computeAverageOpenWaitTimeInMinutes(waitingEntries, asOf));
}
if (metrics.contains(LONGEST_OPEN_WAIT)) {
target.add(LONGEST_OPEN_WAIT, getLongestOpenWait(waitingEntries, asOf));
}
if (metrics.contains(COUNTS_BY_STATUS)) {
target.add(COUNTS_BY_STATUS, getCountsByStatus(queueEntries));
}
}

private List<QueueEntry> filterByStatus(List<QueueEntry> queueEntries, List<Concept> statuses) {
if (statuses == null || statuses.isEmpty()) {
return queueEntries;
}
List<QueueEntry> ret = new ArrayList<>();
for (QueueEntry queueEntry : queueEntries) {
if (statuses.contains(queueEntry.getStatus())) {
ret.add(queueEntry);
}
if (metrics.isEmpty() || metrics.contains(AVERAGE_WAIT_TIME)) {
ret.add(AVERAGE_WAIT_TIME, QueueUtils.computeAverageWaitTimeInMinutes(queueEntries));
}
return ret;
}

private SimpleObject getLongestOpenWait(List<QueueEntry> queueEntries, Date asOf) {
QueueEntry longestWaiting = QueueUtils.findLongestOpenWait(queueEntries);
if (longestWaiting == null) {
return null;
}
SimpleObject ret = new SimpleObject();
ret.add("minutes", QueueUtils.computeOpenWaitTimeInMinutes(longestWaiting, asOf));
ret.add("queueEntry",
ConversionUtil.convertToRepresentation(longestWaiting, new CustomRepresentation(LONGEST_OPEN_WAIT_REP)));
return ret;
}

// Keyed by status concept rather than by particular named statuses, as which statuses matter is a
// matter of configuration in the calling application rather than something this module fixes
private Map<String, Integer> getCountsByStatus(List<QueueEntry> queueEntries) {
Map<String, Integer> ret = new LinkedHashMap<>();
for (QueueEntry queueEntry : queueEntries) {
Concept status = queueEntry.getStatus();
if (status != null) {
ret.merge(status.getUuid(), 1, Integer::sum);
}
}
return ret;
}

// Seeded from the queues so that a queue nobody is in still reports a row of zeroes, then extended
// by the entries so that everything counted in the totals is also counted in a row: the queue
// search excludes retired queues, while the queue entry search has no such filter.
private List<SimpleObject> getMetricsPerQueue(List<QueueEntry> queueEntries, QueueEntrySearchCriteria criteria,
List<String> metrics, Date asOf, List<Concept> waitStatuses) {
Map<Queue, List<QueueEntry>> entriesByQueue = new LinkedHashMap<>();
for (Queue queue : getQueuesToReport(criteria)) {
entriesByQueue.put(queue, new ArrayList<>());
}
for (QueueEntry queueEntry : queueEntries) {
entriesByQueue.computeIfAbsent(queueEntry.getQueue(), q -> new ArrayList<>()).add(queueEntry);
}

List<SimpleObject> ret = new ArrayList<>();
for (Map.Entry<Queue, List<QueueEntry>> e : entriesByQueue.entrySet()) {
SimpleObject queueMetrics = new SimpleObject();
queueMetrics.add(QUEUE, ConversionUtil.convertToRepresentation(e.getKey(), new CustomRepresentation(QUEUE_REP)));
addMetrics(queueMetrics, e.getValue(), metrics, asOf, waitStatuses);
ret.add(queueMetrics);
}
return ret;
}

// Queues are limited by those criteria they share with the queue entry search, and are sorted by
// name so that repeating the same request returns the rows in the same order
private List<Queue> getQueuesToReport(QueueEntrySearchCriteria criteria) {
List<Queue> queues;
if (criteria.getQueues() != null) {
queues = new ArrayList<>(criteria.getQueues());
Comment thread
UjjawalPrabhat marked this conversation as resolved.
// A blank queue ref resolves to a null element
queues.removeIf(q -> q == null);
} else {
QueueSearchCriteria queueSearchCriteria = new QueueSearchCriteria();
queueSearchCriteria.setLocations(criteria.getLocations());
queueSearchCriteria.setServices(criteria.getServices());
queues = new ArrayList<>(services.getQueueService().getQueues(queueSearchCriteria));
}
queues.sort(Comparator.comparing(Queue::getName));
return queues;
}

@Override
public String getNamespace() {
return "v1/queue-entry-metric";
Expand Down
Loading