Skip to content

Commit 715417f

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 df181dc commit 715417f

File tree

14 files changed

+507
-169
lines changed

14 files changed

+507
-169
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+
populationDataSourceName:
89+
type: string
90+
description: The name or shortname of the data source that contains the population data we want to use. Does nothing if calculatePopulation is set to false. Defaults to undefined. If left undefined or an empty string, the population will not be calculated.
91+
example: Dissemination block boundaries 2021
8892

8993

9094
accessibilityMapResponseSuccess:
@@ -245,11 +249,22 @@ accessibilityMapResultResponsePolygons:
245249
type: integer
246250
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.
247251
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
252+
populationData:
253+
type: object
254+
description: Object that contains the population in the polygon and how much of it is covered by the data source. Undefined if calculatePopulation is set to false.
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 & 4 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 } : undefined
704704
}
705705
}]
706706
},
@@ -729,11 +729,10 @@ 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,
735-
areaSqM: 1000,
736-
population: null
735+
areaSqM: 1000
737736
}
738737
}]
739738
}

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+
populationDataSourceName?: 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+
populationDataSourceName: queryParams.populationDataSourceName || 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, 'Test datasource');
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, 'Test datasource');
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, 'Test datasource');
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, 'other datasource');
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: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import knex from 'chaire-lib-backend/lib/config/shared/db.config';
88
import TrError from 'chaire-lib-common/lib/utils/TrError';
99

1010
import { truncate } from 'chaire-lib-backend/lib/models/db/default.db.queries';
11+
import dsQueries from 'chaire-lib-backend/lib/models/db/dataSources.db.queries';
1112

1213
interface CensusAttributes {
1314
id: number;
@@ -63,28 +64,46 @@ const addPopulationBatch = async (inputArray: { internalId: string; population:
6364
};
6465

6566
const getPopulationInPolygon = async (
66-
accessibilityPolygon: GeoJSON.MultiPolygon | GeoJSON.Polygon
67-
): Promise<number | null> => {
67+
accessibilityPolygon: GeoJSON.MultiPolygon | GeoJSON.Polygon,
68+
populationDataSourceName: string
69+
): Promise<{ population: number | null; dataSourceAreaRatio: number }> => {
6870
try {
69-
// Find all the zones that intersect the input polygon, and fetch their population, area, and the area of the intersection.
71+
const dataSourceId = (await dsQueries.findByName(populationDataSourceName))?.id;
72+
if (dataSourceId === undefined) {
73+
return { population: null, dataSourceAreaRatio: 0 };
74+
}
75+
// 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.
7076
// 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.
77+
// To avoid division by zero in the edge case of a degenerate zone with no area, we use the NULLIF function.
78+
// We also get the ratio of the area of the zones in the polygon that have data to the area of the polygon.
79+
// 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).
7280
const populationResponse = await knex.raw(
7381
`
7482
SELECT COUNT(*) as row_count,
7583
ROUND(SUM(
76-
population *
77-
ST_AREA(ST_INTERSECTION(ST_GeomFromGeoJSON(:polygon), geography::geometry)) /
84+
population *
85+
ST_AREA(ST_INTERSECTION(ST_GeomFromGeoJSON(:polygon), geography::geometry)) /
7886
NULLIF(ST_AREA(geography::geometry), 0)
79-
)) as weighted_population
87+
)) as weighted_population,
88+
(
89+
ST_AREA(ST_INTERSECTION(ST_UNION(geography::geometry), ST_GeomFromGeoJSON(:polygon))) /
90+
NULLIF(ST_AREA(ST_GeomFromGeoJSON(:polygon)), 0)
91+
) as data_source_area_ratio
8092
FROM ${tableName} c JOIN ${parentTable} z ON c.zone_id = z.id
81-
WHERE ST_INTERSECTS(geography::geometry, ST_GeomFromGeoJSON(:polygon));
93+
WHERE z.data_source_id = :dataSourceId
94+
AND ST_INTERSECTS(geography::geometry, ST_GeomFromGeoJSON(:polygon));
8295
`,
83-
{ polygon: JSON.stringify(accessibilityPolygon) }
96+
{ polygon: JSON.stringify(accessibilityPolygon), dataSourceId }
8497
);
8598

8699
const result = populationResponse.rows[0];
87-
return Number(result.row_count) === 0 ? null : Number(result.weighted_population);
100+
101+
// If there is no population data, we set 'population' to null, and 'dataSourceAreaRatio' is irrelevant.
102+
// This is distinct from a population of 0, where we calculate that there is no one living in the zone according to the data, and so 'dataSourceAreaRatio' is something we are interested in in this case.
103+
return {
104+
population: Number(result.row_count) === 0 ? null : Number(result.weighted_population),
105+
dataSourceAreaRatio: result.data_source_area_ratio === null ? 0 : Number(result.data_source_area_ratio)
106+
};
88107
} catch (error) {
89108
throw new TrError(`Problem getting population (knex error: ${error})`, 'CSDB0003', 'ProblemGettingPopulation');
90109
}

0 commit comments

Comments
 (0)