diff --git a/src/main/java/org/entur/lamassu/delta/BaseGBFSFileDeltaCalculator.java b/src/main/java/org/entur/lamassu/delta/BaseGBFSFileDeltaCalculator.java
new file mode 100644
index 00000000..b7a56e8d
--- /dev/null
+++ b/src/main/java/org/entur/lamassu/delta/BaseGBFSFileDeltaCalculator.java
@@ -0,0 +1,184 @@
+/*
+ *
+ *
+ * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
+ * * the European Commission - subsequent versions of the EUPL (the "Licence");
+ * * You may not use this work except in compliance with the Licence.
+ * * You may obtain a copy of the Licence at:
+ * *
+ * * https://joinup.ec.europa.eu/software/page/eupl
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the Licence is distributed on an "AS IS" basis,
+ * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * See the Licence for the specific language governing permissions and
+ * * limitations under the Licence.
+ *
+ */
+
+package org.entur.lamassu.delta;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public abstract class BaseGBFSFileDeltaCalculator implements GBFSFileDeltaCalculator {
+ private static final List EXCLUDE_METHODS = List.of(
+ "toString",
+ "hashCode",
+ "equals"
+ );
+
+ @Override
+ public final GBFSFileDelta calculateDelta(S base, @NotNull S compare) {
+ List> entityDeltas = getEntityDeltas(base, compare);
+ return getGBFSFileDelta(base, compare, entityDeltas);
+ }
+
+ private @NotNull GBFSFileDelta getGBFSFileDelta(S base, @NotNull S compare, List> entityDeltas) {
+ return new GBFSFileDelta<>(
+ getLastUpdated(base),
+ getLastUpdated(compare),
+ getFileName(),
+ entityDeltas
+ );
+ }
+
+ private @NotNull List> getEntityDeltas(S base, @NotNull S compare) {
+ List baseEntities = getBaseEntities(base);
+ Map baseEntityMap = getBaseEntityMap(baseEntities);
+ List baseEntityIds = getEntityIds(baseEntityMap);
+ List compareEntities = getEntities(compare);
+ List compareEntityIds = getEntityIds(compareEntities);
+
+ return Stream.of(
+ getDeletedEntityDeltas(baseEntities, compareEntityIds),
+ getKeptEntityDeltas(compareEntities, baseEntityMap, baseEntityIds)
+ ).flatMap(Collection::stream).toList();
+ }
+
+ private @NotNull List getEntityIds(Map entityMap) {
+ return entityMap.keySet().stream().toList();
+ }
+
+ private @NotNull List getEntityIds(List entities) {
+ return entities.stream().map(this::getEntityId).toList();
+ }
+
+ private @NotNull List getBaseEntities(S base) {
+ return base != null ? getEntities(base) : List.of();
+ }
+
+ private @NotNull Map getBaseEntityMap(List baseEntities) {
+ return baseEntities.stream().collect(Collectors.toMap(this::getEntityId, v -> v));
+ }
+
+ private @NotNull List> getDeletedEntityDeltas(List baseEntities, List compareEntityIds) {
+ return baseEntities.stream()
+ .map(this::getEntityId)
+ .filter(id -> !compareEntityIds.contains(id))
+ .map(id ->
+ new GBFSEntityDelta(
+ id,
+ DeltaType.DELETE,
+ null
+ )).toList();
+ }
+
+ private @NotNull List> getKeptEntityDeltas(List compareEntities, Map baseEntityMap, List baseEntityIds) {
+ return compareEntities.stream()
+
+ // We do not need to return a delta for entities that haven't changed. We trust the implementation
+ // of equals from the gbfs model here.
+ .filter(entity -> !entity.equals(baseEntityMap.get(getEntityId(entity))))
+
+ .map(entity -> {
+ var entityId = getEntityId(entity);
+ // If the entity exists in the base, then this delta is an update, and we can compute
+ // the entity delta
+ if (baseEntityIds.contains(entityId)) {
+ return new GBFSEntityDelta<>(
+ entityId,
+ DeltaType.UPDATE,
+ getEntityDelta(baseEntityMap.get(entityId), entity)
+ );
+
+ // Otherwise, this is a new entity, and the "delta" contains the entire entity
+ } else {
+ return new GBFSEntityDelta<>(
+ entityId,
+ DeltaType.CREATE,
+ entity
+ );
+ }
+ }).toList();
+ }
+
+ private T getEntityDelta(T a, T b) {
+ T delta = createEntity();
+ Method[] methods = a.getClass().getDeclaredMethods();
+ for (Method method : methods) {
+ try {
+ if (!EXCLUDE_METHODS.contains(method.getName()) && method.getParameterCount() == 0 && (method.invoke(a) == null || !method.invoke(a).equals(method.invoke(b)))) {
+ getSetter(methods, method.getName()).ifPresent(setter -> {
+ try {
+ setter.invoke(delta, method.invoke(b));
+ } catch (IllegalAccessException | InvocationTargetException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ }
+ } catch (InvocationTargetException | IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return delta;
+ }
+
+ private @NotNull Optional getSetter(Method[] methods, String getterName) {
+ String setterName = getterName.replace("get", "set");
+ return Arrays.stream(methods).filter(method1 -> method1.getName().equals(setterName)).findFirst();
+ }
+
+ /**
+ * Get a list of enumerable entities from the GBFS file instance
+ * @param instance The GBFS file instance
+ * @return List of enumerable entities of type T
+ */
+ protected abstract List getEntities(S instance);
+
+ /**
+ * Get the id of the entity
+ * @param entity The entity
+ * @return The entity's id
+ */
+ protected abstract String getEntityId(T entity);
+
+ /**
+ * Create a new instance of the entity of type T
+ * @return An instance of T
+ */
+ protected abstract T createEntity();
+
+ /**
+ * Get the last updated time of the GBFS file instance
+ * @param instance The GBFS file instance
+ * @return The last updated time
+ */
+ protected abstract long getLastUpdated(S instance);
+
+ /**
+ * Get the file name of the GBFS file
+ * @return The file name
+ */
+ protected abstract String getFileName();
+}
diff --git a/src/main/java/org/entur/lamassu/delta/DeltaType.java b/src/main/java/org/entur/lamassu/delta/DeltaType.java
new file mode 100644
index 00000000..8831bbde
--- /dev/null
+++ b/src/main/java/org/entur/lamassu/delta/DeltaType.java
@@ -0,0 +1,40 @@
+/*
+ *
+ *
+ * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
+ * * the European Commission - subsequent versions of the EUPL (the "Licence");
+ * * You may not use this work except in compliance with the Licence.
+ * * You may obtain a copy of the Licence at:
+ * *
+ * * https://joinup.ec.europa.eu/software/page/eupl
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the Licence is distributed on an "AS IS" basis,
+ * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * See the Licence for the specific language governing permissions and
+ * * limitations under the Licence.
+ *
+ */
+
+package org.entur.lamassu.delta;
+
+/**
+ * Enum representing the type of delta.
+ */
+public enum DeltaType {
+
+ /**
+ * A new entity was created
+ */
+ CREATE,
+
+ /**
+ * En existing entity was updated
+ */
+ UPDATE,
+
+ /**
+ * An existing entity was deleted
+ */
+ DELETE
+}
diff --git a/src/main/java/org/entur/lamassu/delta/GBFSEntityDelta.java b/src/main/java/org/entur/lamassu/delta/GBFSEntityDelta.java
new file mode 100644
index 00000000..8ef3022a
--- /dev/null
+++ b/src/main/java/org/entur/lamassu/delta/GBFSEntityDelta.java
@@ -0,0 +1,33 @@
+/*
+ *
+ *
+ * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
+ * * the European Commission - subsequent versions of the EUPL (the "Licence");
+ * * You may not use this work except in compliance with the Licence.
+ * * You may obtain a copy of the Licence at:
+ * *
+ * * https://joinup.ec.europa.eu/software/page/eupl
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the Licence is distributed on an "AS IS" basis,
+ * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * See the Licence for the specific language governing permissions and
+ * * limitations under the Licence.
+ *
+ */
+
+package org.entur.lamassu.delta;
+
+/**
+ * A record representing the difference (delta) between two enumerable entities of a GBFS file
+ * @param entityId The unique ID of the entity
+ * @param type The type of the delta (create, update or delete)
+ * @param entity An instance of the entity itself, containing the changed fields.
+ * Note: this field is non-null only if the delta type is "UPDATE"
+ * @param The type of the enumerable entity that was compared
+ */
+public record GBFSEntityDelta(
+ String entityId,
+ DeltaType type,
+ E entity
+) {}
diff --git a/src/main/java/org/entur/lamassu/delta/GBFSFileDelta.java b/src/main/java/org/entur/lamassu/delta/GBFSFileDelta.java
new file mode 100644
index 00000000..8651a28a
--- /dev/null
+++ b/src/main/java/org/entur/lamassu/delta/GBFSFileDelta.java
@@ -0,0 +1,37 @@
+/*
+ *
+ *
+ * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
+ * * the European Commission - subsequent versions of the EUPL (the "Licence");
+ * * You may not use this work except in compliance with the Licence.
+ * * You may obtain a copy of the Licence at:
+ * *
+ * * https://joinup.ec.europa.eu/software/page/eupl
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the Licence is distributed on an "AS IS" basis,
+ * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * See the Licence for the specific language governing permissions and
+ * * limitations under the Licence.
+ *
+ */
+
+package org.entur.lamassu.delta;
+
+import java.util.List;
+
+/**
+ * Record representing the difference between two instances of a GBFS file
+ *
+ * @param base Numerical identifier of the base of the comparison
+ * @param compare Numerical identifier of the compare side
+ * @param fileName The file name of the GBFS files being compared
+ * @param entityDelta A list of entity deltas for the enumerable entity of type E in the GBFS file
+ * @param The type of the enumerable entity in the GBFS file
+ */
+public record GBFSFileDelta(
+ Long base,
+ Long compare,
+ String fileName,
+ List> entityDelta
+) {}
diff --git a/src/main/java/org/entur/lamassu/delta/GBFSFileDeltaCalculator.java b/src/main/java/org/entur/lamassu/delta/GBFSFileDeltaCalculator.java
new file mode 100644
index 00000000..990fdf95
--- /dev/null
+++ b/src/main/java/org/entur/lamassu/delta/GBFSFileDeltaCalculator.java
@@ -0,0 +1,43 @@
+/*
+ *
+ *
+ * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
+ * * the European Commission - subsequent versions of the EUPL (the "Licence");
+ * * You may not use this work except in compliance with the Licence.
+ * * You may obtain a copy of the Licence at:
+ * *
+ * * https://joinup.ec.europa.eu/software/page/eupl
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the Licence is distributed on an "AS IS" basis,
+ * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * See the Licence for the specific language governing permissions and
+ * * limitations under the Licence.
+ *
+ */
+
+package org.entur.lamassu.delta;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Represents a calculator that can compute the delta (difference) between to instances
+ * of a GBFS file.
+ *
+ * @param The type of the GBFS file instances to compare
+ * @param The type of the enumerable entity inside the GBFS file being compared
+ */
+public interface GBFSFileDeltaCalculator {
+
+ /**
+ * Calculate the delta (difference) between to instances of a GBFS file of type S
+ *
+ * @param base The base of the comparison
+ * Note: This parameter can be null, in it is then interpreted as a full update
+ *
+ * @param compare The instance to compare with the base
+ * Note: This parameter can't be null
+ * @return An instance of GBFSFileDelta containing deltas of the enumerable entity of type T
+ */
+ GBFSFileDelta calculateDelta(S base, @NotNull S compare);
+}
diff --git a/src/main/java/org/entur/lamassu/delta/GBFSStationStatusDeltaCalculator.java b/src/main/java/org/entur/lamassu/delta/GBFSStationStatusDeltaCalculator.java
new file mode 100644
index 00000000..a1ad278e
--- /dev/null
+++ b/src/main/java/org/entur/lamassu/delta/GBFSStationStatusDeltaCalculator.java
@@ -0,0 +1,54 @@
+/*
+ *
+ *
+ * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
+ * * the European Commission - subsequent versions of the EUPL (the "Licence");
+ * * You may not use this work except in compliance with the Licence.
+ * * You may obtain a copy of the Licence at:
+ * *
+ * * https://joinup.ec.europa.eu/software/page/eupl
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the Licence is distributed on an "AS IS" basis,
+ * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * See the Licence for the specific language governing permissions and
+ * * limitations under the Licence.
+ *
+ */
+
+package org.entur.lamassu.delta;
+
+import org.mobilitydata.gbfs.v3_0.station_status.GBFSStation;
+import org.mobilitydata.gbfs.v3_0.station_status.GBFSStationStatus;
+
+import java.util.List;
+
+public class GBFSStationStatusDeltaCalculator extends BaseGBFSFileDeltaCalculator {
+
+ public static final String FILE_NAME = "station_status";
+
+ @Override
+ protected List getEntities(GBFSStationStatus instance) {
+ return instance.getData().getStations();
+ }
+
+ @Override
+ protected String getEntityId(GBFSStation entity) {
+ return entity.getStationId();
+ }
+
+ @Override
+ protected GBFSStation createEntity() {
+ return new GBFSStation();
+ }
+
+ @Override
+ protected long getLastUpdated(GBFSStationStatus instance) {
+ return instance.getLastUpdated().getTime();
+ }
+
+ @Override
+ protected String getFileName() {
+ return FILE_NAME;
+ }
+}
diff --git a/src/main/java/org/entur/lamassu/delta/GBFSVehicleStatusDeltaCalculator.java b/src/main/java/org/entur/lamassu/delta/GBFSVehicleStatusDeltaCalculator.java
new file mode 100644
index 00000000..80711781
--- /dev/null
+++ b/src/main/java/org/entur/lamassu/delta/GBFSVehicleStatusDeltaCalculator.java
@@ -0,0 +1,54 @@
+/*
+ *
+ *
+ * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
+ * * the European Commission - subsequent versions of the EUPL (the "Licence");
+ * * You may not use this work except in compliance with the Licence.
+ * * You may obtain a copy of the Licence at:
+ * *
+ * * https://joinup.ec.europa.eu/software/page/eupl
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the Licence is distributed on an "AS IS" basis,
+ * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * See the Licence for the specific language governing permissions and
+ * * limitations under the Licence.
+ *
+ */
+
+package org.entur.lamassu.delta;
+
+import org.mobilitydata.gbfs.v3_0.vehicle_status.GBFSVehicle;
+import org.mobilitydata.gbfs.v3_0.vehicle_status.GBFSVehicleStatus;
+
+import java.util.List;
+
+public class GBFSVehicleStatusDeltaCalculator extends BaseGBFSFileDeltaCalculator {
+
+ public static final String FILE_NAME = "vehicle_status";
+
+ @Override
+ protected List getEntities(GBFSVehicleStatus instance) {
+ return instance.getData().getVehicles();
+ }
+
+ @Override
+ protected String getEntityId(GBFSVehicle entity) {
+ return entity.getVehicleId();
+ }
+
+ @Override
+ protected GBFSVehicle createEntity() {
+ return new GBFSVehicle();
+ }
+
+ @Override
+ protected long getLastUpdated(GBFSVehicleStatus instance) {
+ return instance.getLastUpdated().getTime();
+ }
+
+ @Override
+ protected String getFileName() {
+ return FILE_NAME;
+ }
+}
diff --git a/src/main/java/org/entur/lamassu/delta/package-info.java b/src/main/java/org/entur/lamassu/delta/package-info.java
new file mode 100644
index 00000000..416987d8
--- /dev/null
+++ b/src/main/java/org/entur/lamassu/delta/package-info.java
@@ -0,0 +1,37 @@
+/*
+ *
+ *
+ * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
+ * * the European Commission - subsequent versions of the EUPL (the "Licence");
+ * * You may not use this work except in compliance with the Licence.
+ * * You may obtain a copy of the Licence at:
+ * *
+ * * https://joinup.ec.europa.eu/software/page/eupl
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the Licence is distributed on an "AS IS" basis,
+ * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * See the Licence for the specific language governing permissions and
+ * * limitations under the Licence.
+ *
+ */
+
+/**
+ * Warning: This feature is experimental and API is subject to change.
+ *
+ *
+ * This package offers functionality to compare GBFS files of the same type
+ * and create a "delta" representation of the comparison.
+ *
+ *
+ *
+ *
+ * Use cases:
+ * * Simplify the entity cache updater logic for stations and vehicles which
+ * encodes much of the same logic but in a much more complicated way.
+ * * Offer the possibility to subscribe to deltas via a public API, which has
+ * the potential to reduce bandwidth and compute resource usage between
+ * servers and clients
+ *
+ */
+package org.entur.lamassu.delta;