Skip to content

Commit b0d414e

Browse files
Add data source selection to population calculation
Calculating population in accessibility maps will now ask to enter a data source of the zones type. The population function also returns a second parameter which indicates how much of the polygon is covered by the zones in the data source, and will warn the user when it is below 99% (for example, if the accessibility map is calculated just outside of the data source's border).
1 parent 68882bf commit b0d414e

File tree

14 files changed

+374
-99
lines changed

14 files changed

+374
-99
lines changed

docs/APIv1/accessibilityMap.yml

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ accessibilityMapRequest:
8585
type: boolean
8686
description: Whether to calculate the population of the polygon and include the count in the result. Defaults to false.
8787
example: true
88+
dataSourceId:
89+
type: string
90+
description: UUID of the data source that contains the population data we want to use. Does nothing if calculatePopulation is set to false. Defaults to undefined.
91+
example: 042ab131-0c2a-4ab6-b4a6-5b05d216d7dc
8892

8993

9094
accessibilityMapResponseSuccess:
@@ -224,6 +228,7 @@ accessibilityMapResultResponsePolygons:
224228
required:
225229
- durationSeconds
226230
- areaSqM
231+
- populationData
227232
properties:
228233
durationSeconds:
229234
type: number
@@ -245,11 +250,21 @@ accessibilityMapResultResponsePolygons:
245250
type: integer
246251
description: The number of POIs within the accessibility polygon (if any), separated by detailed categories. The keys are the names of the detailed categories defined in the PlaceDetailedCategory type, and the values are integers.
247252
example: {'service_bank': 8, 'service_other': 2, 'restaurant_restaurant': 5}
248-
population:
249-
type: number
250-
nullable: true
251-
description: The population inside the result polygon. Is null if calculatePopulation was set to false or the polygon does not cover any of the zones in the census db table.
252-
example: 102457
253+
populationData:
254+
type: object
255+
required:
256+
- population
257+
- dataSourceAreaRatio
258+
properties:
259+
population:
260+
type: number
261+
nullable: true
262+
description: The population inside the result polygon. Is null if calculatePopulation was set to false or the polygon does not cover any of the zones in the census db table (distinct from 0, which is for a population that's actually calculated to be 0 based on the data available).
263+
example: 102457
264+
dataSourceAreaRatio:
265+
type: number
266+
description: The portion, from 0 to 1, of how much of the polygon is covered by the zones included in the population data source (for example, it might be less than 1 when the source contains only data for Canada and the polygon is on the US-Canada border).
267+
example: 0.67327
253268

254269
accessibilityMapResponseBadRequest:
255270
type: string

locales/en/transit.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,9 @@
546546
"SeeDetailedCategories": "See detailed categories",
547547
"ShowEmptyDetailedCategories": "Show empty detailed categories",
548548
"AccessiblePOIsPopup": "The data used to determine accessible Points of interest was downloaded from OpenStreetMap and imported into the database. The results may be inaccurate if the accessibility polygon is not contained entirely within the imported area.",
549+
"DataSourceWarningPopup": "Warning: The population data from this data source covers only {{percentage}}% of the accessibility polygon. This may be due to it intersecting a body of water or the border of the region imported by this source.",
550+
"DataSourceNullWarningPopup": "Warning: The population data from this data source does not cover any of the accessibility polygon. This may be due to it being contained in a body of water or outside the region imported by this source.",
551+
"DataSourceNullWarningComparisonPopup": "Warning: The population data from this data source does not cover any of the accessibility polygon for at least one of the scenarios. This may be due to it being contained in a body of water or outside the region imported by this source.",
549552
"Scenario": "Scenario",
550553
"DataSource": "Data source",
551554
"CompareWithScenario": "Compare with scenario",
@@ -581,6 +584,7 @@
581584
"WithAlternatives": "Calculate with alternatives",
582585
"CalculatePois": "Calculate with points of interest",
583586
"CalculatePopulation": "Calculate with the population",
587+
"PopulationDataSourceSelect": "Select a data source to use to calculate the population",
584588
"Detailed": "Detailed results with steps",
585589
"WithGeometries": "Include route geometries (geojson)",
586590
"ReverseOD": "Reverse",
@@ -688,7 +692,8 @@
688692
"InvalidOdTripsDataSource": "Invalid OD calculations data source.",
689693
"ErrorCalculatingLocation": "Error calculating location {{id}}",
690694
"UserDiskQuotaReached": "Maximum allowed disk space has been reached. Please delete old tasks to delete their files.",
691-
"RoutingParametersInvalidForBatch": "Routing parameters are invalid. Make sure the scenario is set and times have a valid value."
695+
"RoutingParametersInvalidForBatch": "Routing parameters are invalid. Make sure the scenario is set and times have a valid value.",
696+
"DataSourceIsMissing": "The data source is required."
692697
},
693698
"actions": {
694699
"walking": "Walk",

locales/fr/transit.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,9 @@
546546
"SeeDetailedCategories": "Voir les catégories détaillées",
547547
"ShowEmptyDetailedCategories": "Montrer les catégories détaillées vides",
548548
"AccessiblePOIsPopup": "Les données utilisées pour calculer les lieux d'activité accessibles ont été téléchargées de OpenStreetMap et importées dans la base de données. Les résultats peuvent être inexacts si le polygone d'accessibilité n'est pas contenu entièrement dans la surface importée.",
549+
"DataSourceWarningPopup": "Attention: Les données de population de cette source de données ne couvrent que {{percentage}}% du polygone d'accessibilité. Cela peut être parce qu'il chevauche une étendue d'eau ou la frontière de la région importée par cette source.",
550+
"DataSourceNullWarningPopup": "Attention: Les données de population de cette source de données ne couvrent aucune partie du polygone d'accessibilité. Cela peut être parce qu'il est contenu dans une étendue d'eau ou en dehors de la région importée par cette source.",
551+
"DataSourceNullWarningComparisonPopup": "Attention: Les données de population de cette source de données ne couvrent aucune partie du polygone d'accessibilité pour au moins un des scénarios. Cela peut être parce qu'il est contenu dans une étendue d'eau ou en dehors de la région importée par cette source.",
549552
"Scenario": "Scénario",
550553
"DataSource": "Source de données",
551554
"CompareWithScenario": "Comparer avec scénario",
@@ -581,6 +584,7 @@
581584
"WithAlternatives": "Calcul avec alternatives",
582585
"CalculatePois": "Calcul avec lieux d'activité",
583586
"CalculatePopulation": "Calcul avec la population",
587+
"PopulationDataSourceSelect": "Sélectionnez une source de données pour calculer la population",
584588
"Detailed": "Résultats détaillés avec étapes",
585589
"WithGeometries": "Inclure les géométries des parcours (geojson)",
586590
"ReverseOD": "Inverser",
@@ -688,7 +692,8 @@
688692
"InvalidOdTripsDataSource": "Source de données de calculs OD invalide.",
689693
"ErrorCalculatingLocation": "Erreur de calcul pour le lieu {{id}}",
690694
"UserDiskQuotaReached": "L'espace disque maximal permis a été atteint. Veuillez supprimer des tâches passées pour en supprimer les fichiers",
691-
"RoutingParametersInvalidForBatch": "Les paramètres pour le calcul de chemin sont invalides. Assurez-vous que le scénario est bien configuré et que les temps sont valides."
695+
"RoutingParametersInvalidForBatch": "Les paramètres pour le calcul de chemin sont invalides. Assurez-vous que le scénario est bien configuré et que les temps sont valides.",
696+
"DataSourceIsMissing": "La source de données est requise."
692697
},
693698
"actions": {
694699
"walking": "Marche",

packages/transition-backend/src/api/__tests__/public.routes.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ describe('Testing API endpoints', () => {
700700
otherProperty: 'foo',
701701
accessiblePlacesCountByCategory: calculatePoisAndPopulation ? { 'service': 10 } : undefined,
702702
accessiblePlacesCountByDetailedCategory: calculatePoisAndPopulation ? { 'service_other': 4, 'service_bank': 6 } : undefined,
703-
population: calculatePoisAndPopulation ? 500 : null
703+
populationData: calculatePoisAndPopulation ? { population: 500, dataSourceAreaRatio: 1 } : { population: null, dataSourceAreaRatio: 0 }
704704
}
705705
}]
706706
},
@@ -729,11 +729,11 @@ describe('Testing API endpoints', () => {
729729
areaSqM: 1000,
730730
accessiblePlacesCountByCategory: { 'service': 10 },
731731
accessiblePlacesCountByDetailedCategory: { 'service_other': 4, 'service_bank': 6 },
732-
population: 500
732+
populationData: { population: 500, dataSourceAreaRatio: 1 }
733733
} : {
734734
durationSeconds: 900,
735735
areaSqM: 1000,
736-
population: null
736+
populationData: { population: null, dataSourceAreaRatio: 0 }
737737
}
738738
}]
739739
}

packages/transition-backend/src/api/public/AccessibilityMapAPIResponse.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type AccessibilityMapAPIQueryResponse = {
3333
walkingSpeedMps: number;
3434
calculatePois: boolean;
3535
calculatePopulation: boolean;
36+
dataSourceId?: string;
3637
};
3738

3839
type AccessibilityMapAPIResultResponse = {
@@ -47,6 +48,7 @@ type AccessibilityMapAPIResultResponse = {
4748
areaSqM: number;
4849
accessiblePlacesCountByCategory?: { [key in PlaceCategory]: number };
4950
accessiblePlacesCountByDetailedCategory?: { [key in PlaceDetailedCategory]: number };
51+
populationData: { population: number | null; dataSourceAreaRatio: number };
5052
};
5153
}>;
5254
};
@@ -90,7 +92,8 @@ export default class AccessibilityMapAPIResponse extends APIResponseBase<
9092
maxTransferTravelTimeSeconds: queryParams.maxTransferTravelTimeSeconds!,
9193
walkingSpeedMps: queryParams.walkingSpeedMps!,
9294
calculatePois: queryParams.calculatePois!,
93-
calculatePopulation: queryParams.calculatePopulation!
95+
calculatePopulation: queryParams.calculatePopulation!,
96+
dataSourceId: queryParams.dataSourceId || undefined
9497
};
9598
}
9699

@@ -111,7 +114,7 @@ export default class AccessibilityMapAPIResponse extends APIResponseBase<
111114
accessiblePlacesCountByCategory: feature.properties!.accessiblePlacesCountByCategory,
112115
accessiblePlacesCountByDetailedCategory:
113116
feature.properties!.accessiblePlacesCountByDetailedCategory,
114-
population: feature.properties!.population
117+
populationData: feature.properties!.populationData
115118
}
116119
}))
117120
}

packages/transition-backend/src/models/db/__tests__/census.db.test.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,45 @@ import { v4 as uuidV4 } from 'uuid';
88
import knex from 'chaire-lib-backend/lib/config/shared/db.config';
99

1010
import dbQueries from '../census.db.queries';
11+
import dataSourceDbQueries from 'chaire-lib-backend/lib/models/db/dataSources.db.queries';
1112
import zonesDbQueries from 'chaire-lib-backend/lib/models/db/zones.db.queries';
1213
import { Zone as ObjectClass } from 'chaire-lib-common/lib/services/zones/Zone';
1314
import { Polygon } from 'geojson';
15+
import { DataSourceAttributes } from 'chaire-lib-common/lib/services/dataSource/DataSource';
16+
17+
const dataSourceId = uuidV4();
18+
const dataSourceAttributes = {
19+
id: dataSourceId,
20+
type: 'zones',
21+
shortname: 'TDS',
22+
name: 'Test datasource',
23+
data: {}
24+
};
1425

1526
// The two zones are squares that are side by side
1627
const id1 = uuidV4();
1728
const newObjectAttributes = {
1829
id: id1,
1930
internal_id: 'test',
31+
dataSourceId: dataSourceId,
2032
geography: { type: 'Polygon' as const, coordinates: [ [ [-73, 45], [-73, 46], [-72, 46], [-72, 45], [-73, 45] ] ] }
2133
};
2234

2335
const id2 = uuidV4();
2436
const newObjectAttributes2 = {
2537
id: id2,
2638
internal_id: 'test2',
39+
dataSourceId: dataSourceId,
2740
geography: { type: 'Polygon' as const, coordinates: [[[-72, 45], [-72, 46], [-71, 46], [-71, 45], [-72, 45]]]}
2841
};
2942

3043
beforeAll(async () => {
3144
jest.setTimeout(10000);
3245
await dbQueries.truncate();
3346
await zonesDbQueries.truncate();
47+
await dataSourceDbQueries.truncate();
48+
49+
await dataSourceDbQueries.create(dataSourceAttributes as DataSourceAttributes);
3450
const newObject = new ObjectClass(newObjectAttributes, true);
3551
await zonesDbQueries.create(newObject.attributes);
3652
const newObject2 = new ObjectClass(newObjectAttributes2, true);
@@ -40,6 +56,7 @@ beforeAll(async () => {
4056
afterAll(async() => {
4157
await dbQueries.truncate();
4258
await zonesDbQueries.truncate();
59+
await dataSourceDbQueries.truncate();
4360
await knex.destroy();
4461
});
4562

@@ -83,8 +100,24 @@ describe('census', () => {
83100
coordinates: [[[-73, 45], [-73, 46], [-71.5, 46], [-71.5, 45], [-73, 45]]]
84101
};
85102

86-
const population = await dbQueries.getPopulationInPolygon(polygon);
87-
expect(population).toEqual(123456 + 500 / 2);
103+
const population = await dbQueries.getPopulationInPolygon(polygon, dataSourceId);
104+
expect(population.population).toEqual(123456 + 500 / 2);
105+
expect(population.dataSourceAreaRatio).toEqual(1);
106+
107+
});
108+
109+
test('population partially outside data source', async () => {
110+
111+
// Half of the polygon covers half the first zone and half of it is outside.
112+
// Thus, the population should be half the first zone with a dataSourceAreaRatio of 0.5
113+
const polygon: Polygon = {
114+
type: 'Polygon',
115+
coordinates: [[[-73.5, 45], [-73.5, 46], [-72.5, 46], [-72.5, 45], [-73.5, 45]]]
116+
};
117+
118+
const population = await dbQueries.getPopulationInPolygon(polygon, dataSourceId);
119+
expect(population.population).toEqual(123456 / 2);
120+
expect(population.dataSourceAreaRatio).toEqual(0.5);
88121

89122
});
90123

@@ -95,8 +128,22 @@ describe('census', () => {
95128
coordinates: [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]
96129
};
97130

98-
const population = await dbQueries.getPopulationInPolygon(polygon);
99-
expect(population).toBeNull();
131+
const population = await dbQueries.getPopulationInPolygon(polygon, dataSourceId);
132+
expect(population.population).toBeNull();
133+
expect(population.dataSourceAreaRatio).toEqual(0);
134+
135+
});
136+
137+
test('try to get population with data source not in db', async () => {
138+
139+
const polygon: Polygon = {
140+
type: 'Polygon',
141+
coordinates: [[[-73, 45], [-73, 46], [-71.5, 46], [-71.5, 45], [-73, 45]]]
142+
};
143+
144+
const population = await dbQueries.getPopulationInPolygon(polygon, uuidV4());
145+
expect(population.population).toBeNull();
146+
expect(population.dataSourceAreaRatio).toEqual(0);
100147

101148
});
102149
});

packages/transition-backend/src/models/db/census.db.queries.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,28 +63,40 @@ const addPopulationBatch = async (inputArray: { internalId: string; population:
6363
};
6464

6565
const getPopulationInPolygon = async (
66-
accessibilityPolygon: GeoJSON.MultiPolygon | GeoJSON.Polygon
67-
): Promise<number | null> => {
66+
accessibilityPolygon: GeoJSON.MultiPolygon | GeoJSON.Polygon,
67+
dataSourceId: string
68+
): Promise<{ population: number | null; dataSourceAreaRatio: number }> => {
6869
try {
69-
// Find all the zones that intersect the input polygon, and fetch their population, area, and the area of the intersection.
70+
// Find all the zones that intersect the input polygon from a particular data source, and fetch their population, area, and the area of the intersection.
7071
// We multiply the population of each zone by the ratio between the area of its intersection with the input polygon and its total area, to estimate the true population of zones that aren't entirely contained within the input polygon.
71-
// TO avoid division by zero in the edge case of a degenerate zone with no area, we use the NULLIF function.
72+
// To avoid division by zero in the edge case of a degenerate zone with no area, we use the NULLIF function.
73+
// We also get the ratio of the area of the zones in the polygon that have data to the area of the polygon.
74+
// When we get a polygon for which we have complete data, we expect that number to be 1 or very close to it, however it will be lower if data is missing from that area (for example, if we input a polygon that's over the Canada-US border while using a data source that only has data for Canada).
7275
const populationResponse = await knex.raw(
7376
`
7477
SELECT COUNT(*) as row_count,
7578
ROUND(SUM(
7679
population *
7780
ST_AREA(ST_INTERSECTION(ST_GeomFromGeoJSON(:polygon), geography::geometry)) /
7881
NULLIF(ST_AREA(geography::geometry), 0)
79-
)) as weighted_population
82+
)) as weighted_population,
83+
(
84+
ST_AREA(ST_INTERSECTION(ST_UNION(geography::geometry), ST_GeomFromGeoJSON(:polygon))) /
85+
NULLIF(ST_AREA(ST_GeomFromGeoJSON(:polygon)), 0)
86+
) as data_source_area_ratio
8087
FROM ${tableName} c JOIN ${parentTable} z ON c.zone_id = z.id
81-
WHERE ST_INTERSECTS(geography::geometry, ST_GeomFromGeoJSON(:polygon));
88+
WHERE z.data_source_id = :dataSourceId
89+
AND ST_INTERSECTS(geography::geometry, ST_GeomFromGeoJSON(:polygon));
8290
`,
83-
{ polygon: JSON.stringify(accessibilityPolygon) }
91+
{ polygon: JSON.stringify(accessibilityPolygon), dataSourceId }
8492
);
8593

8694
const result = populationResponse.rows[0];
87-
return Number(result.row_count) === 0 ? null : Number(result.weighted_population);
95+
96+
return {
97+
population: Number(result.row_count) === 0 ? null : Number(result.weighted_population),
98+
dataSourceAreaRatio: result.data_source_area_ratio === null ? 0 : Number(result.data_source_area_ratio)
99+
};
88100
} catch (error) {
89101
throw new TrError(`Problem getting population (knex error: ${error})`, 'CSDB0003', 'ProblemGettingPopulation');
90102
}

0 commit comments

Comments
 (0)