Skip to content

Commit e278ea0

Browse files
authored
Merge pull request #23 from onaio/jan26-cleanup
Add sampled population tracking to campaign areas and rounds
2 parents ab6e1de + 05bbc7e commit e278ea0

7 files changed

Lines changed: 144 additions & 22 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ geoparquet/
1111
.playwright-mcp/
1212

1313
pixeldata/
14+
15+
# Database dumps
16+
*.dump
17+
*.sql.gz

truecover-app/src/components/CampaignAreasManager.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface CampaignArea {
2828
pixel_count: number;
2929
sampled_count: number;
3030
total_population: number;
31+
sampled_population: number;
3132
building_count: number;
3233
division_name: string | null;
3334
district_name: string | null;
@@ -344,6 +345,7 @@ const CampaignAreasManager: React.FC<CampaignAreasManagerProps> = ({
344345
const totalPixels = areas.reduce((sum, area) => sum + (area.pixel_count || 0), 0);
345346
const totalSampled = areas.reduce((sum, area) => sum + (area.sampled_count || 0), 0);
346347
const totalPopulation = areas.reduce((sum, area) => sum + (area.total_population || 0), 0);
348+
const totalSampledPopulation = areas.reduce((sum, area) => sum + (area.sampled_population || 0), 0);
347349
const totalBuildings = areas.reduce((sum, area) => sum + (area.building_count || 0), 0);
348350

349351
return (
@@ -472,7 +474,22 @@ const CampaignAreasManager: React.FC<CampaignAreasManagerProps> = ({
472474
)}
473475
</td>
474476
<td className="text-right font-mono">
475-
{area.total_population.toLocaleString()}
477+
{area.total_population === 0 ? (
478+
<span className="text-tactical-text-muted"></span>
479+
) : (
480+
<span>
481+
<span className="text-tactical-accent-green">
482+
{area.sampled_population.toLocaleString()}
483+
</span>
484+
<span className="text-tactical-text-muted"> / </span>
485+
<span className="text-tactical-text-primary">
486+
{area.total_population.toLocaleString()}
487+
</span>
488+
<span className="text-tactical-accent-green ml-1">
489+
({Math.round(area.sampled_population / area.total_population * 100)}%)
490+
</span>
491+
</span>
492+
)}
476493
</td>
477494
<td className="text-right font-mono">
478495
{buildingExtractionWorkflows.has(area.id) ? (
@@ -537,8 +554,19 @@ const CampaignAreasManager: React.FC<CampaignAreasManagerProps> = ({
537554
{totalPixels.toLocaleString()}
538555
</span>
539556
</td>
540-
<td className="text-right font-mono font-bold text-tactical-text-primary">
541-
{totalPopulation.toLocaleString()}
557+
<td className="text-right font-mono font-bold">
558+
<span className="text-tactical-accent-green">
559+
{totalSampledPopulation.toLocaleString()}
560+
</span>
561+
<span className="text-tactical-text-muted"> / </span>
562+
<span className="text-tactical-text-primary">
563+
{totalPopulation.toLocaleString()}
564+
</span>
565+
{totalPopulation > 0 && (
566+
<span className="text-tactical-accent-green ml-1">
567+
({Math.round(totalSampledPopulation / totalPopulation * 100)}%)
568+
</span>
569+
)}
542570
</td>
543571
<td className="text-right font-mono font-bold text-tactical-text-primary">
544572
{totalBuildings.toLocaleString()}

truecover-app/src/components/RoundsManager.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface Round {
2121
updated_at: string;
2222
location_count: number;
2323
pixel_count: number;
24+
sampled_population: number;
2425
}
2526

2627
interface RoundsManagerProps {
@@ -39,6 +40,7 @@ const RoundsManager: React.FC<RoundsManagerProps> = ({ campaignId, areaName, pro
3940
const { getToken } = useAuth();
4041
const { data: indicators } = useIndicators(projectId);
4142
const [rounds, setRounds] = useState<Round[]>([]);
43+
const [totalPopulation, setTotalPopulation] = useState<number>(0);
4244
const [isLoading, setIsLoading] = useState(true);
4345
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
4446
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
@@ -67,6 +69,7 @@ const RoundsManager: React.FC<RoundsManagerProps> = ({ campaignId, areaName, pro
6769
);
6870

6971
setRounds(response.data.rounds || []);
72+
setTotalPopulation(response.data.total_population || 0);
7073
} catch (err: any) {
7174
console.error('Error loading rounds:', err);
7275
// Don't set error if it's just an empty result or table doesn't exist
@@ -193,6 +196,9 @@ const RoundsManager: React.FC<RoundsManagerProps> = ({ campaignId, areaName, pro
193196
<th className="px-4 py-3 text-center text-xs font-mono font-bold text-tactical-text-primary uppercase tracking-wider">
194197
Pixels
195198
</th>
199+
<th className="px-4 py-3 text-right text-xs font-mono font-bold text-tactical-text-primary uppercase tracking-wider">
200+
Sampled Population
201+
</th>
196202
</tr>
197203
</thead>
198204
<tbody>
@@ -246,6 +252,27 @@ const RoundsManager: React.FC<RoundsManagerProps> = ({ campaignId, areaName, pro
246252
>
247253
{round.pixel_count}
248254
</td>
255+
<td
256+
className="px-4 py-3 text-right text-sm font-mono cursor-pointer"
257+
onClick={() => handleRoundClick(round.round_number)}
258+
>
259+
{totalPopulation === 0 ? (
260+
<span className="text-tactical-text-muted"></span>
261+
) : (
262+
<span>
263+
<span className="text-tactical-accent-green">
264+
{round.sampled_population.toLocaleString()}
265+
</span>
266+
<span className="text-tactical-text-muted"> / </span>
267+
<span className="text-tactical-text-primary">
268+
{totalPopulation.toLocaleString()}
269+
</span>
270+
<span className="text-tactical-accent-green ml-1">
271+
({Math.round(round.sampled_population / totalPopulation * 100)}%)
272+
</span>
273+
</span>
274+
)}
275+
</td>
249276
</tr>
250277
))}
251278
</tbody>

truecover-backend/db/migrations/add_cached_stats_to_campaign_areas.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
ALTER TABLE campaign_areas
55
ADD COLUMN IF NOT EXISTS cached_pixel_count INTEGER DEFAULT 0,
66
ADD COLUMN IF NOT EXISTS cached_population NUMERIC DEFAULT 0,
7-
ADD COLUMN IF NOT EXISTS cached_building_count INTEGER DEFAULT 0;
7+
ADD COLUMN IF NOT EXISTS cached_building_count INTEGER DEFAULT 0,
8+
ADD COLUMN IF NOT EXISTS cached_sampled_population NUMERIC DEFAULT 0;
89

910
COMMENT ON COLUMN campaign_areas.cached_pixel_count IS 'Cached count of pixels in this area, updated when pixels are computed';
1011
COMMENT ON COLUMN campaign_areas.cached_population IS 'Cached sum of population from all pixels in this area';
1112
COMMENT ON COLUMN campaign_areas.cached_building_count IS 'Cached count of buildings/locations in this area';
13+
COMMENT ON COLUMN campaign_areas.cached_sampled_population IS 'Cached sum of population from sampled pixels in this area';

truecover-backend/routes/campaigns.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,8 @@ def list_campaign_areas(user, campaign_id):
290290
dist.name as district_name,
291291
upz.name as upazila_name,
292292
uni.name as union_name,
293-
COALESCE(ca.cached_sampled_count, 0) as sampled_count
293+
COALESCE(ca.cached_sampled_count, 0) as sampled_count,
294+
COALESCE(ca.cached_sampled_population, 0) as sampled_population
294295
FROM campaign_areas ca
295296
LEFT JOIN admin_boundaries ab ON ca.admin_boundary_id = ab.id
296297
-- Get parent boundary names using the pcode hierarchy
@@ -327,7 +328,8 @@ def list_campaign_areas(user, campaign_id):
327328
'district_name': row[17],
328329
'upazila_name': row[18],
329330
'union_name': row[19],
330-
'sampled_count': row[20] or 0
331+
'sampled_count': row[20] or 0,
332+
'sampled_population': int(row[21]) if row[21] else 0
331333
}
332334
areas.append(area)
333335

truecover-backend/routes/rounds.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,15 @@ def list_rounds(user, campaign_id):
869869
""", (campaign_id, row[1]))
870870
pixel_count = cursor.fetchone()[0]
871871

872+
# Sum population from pixels in this round
873+
cursor.execute("""
874+
SELECT COALESCE(SUM(p.population), 0)
875+
FROM coverage_pixel cp
876+
JOIN pixels p ON cp.quadkey = p.quadkey
877+
WHERE cp.campaign_id = %s AND %s = ANY(cp.rounds)
878+
""", (campaign_id, row[1]))
879+
sampled_population = cursor.fetchone()[0] or 0
880+
872881
rounds.append({
873882
'id': str(row[0]),
874883
'round_number': row[1],
@@ -880,11 +889,23 @@ def list_rounds(user, campaign_id):
880889
'updated_at': row[7].isoformat() if row[7] else None,
881890
'sampling_target': row[8] if len(row) > 8 else 'locations',
882891
'location_count': location_count,
883-
'pixel_count': pixel_count
892+
'pixel_count': pixel_count,
893+
'sampled_population': int(sampled_population)
884894
})
885895

896+
# Get total population from all campaign areas
897+
cursor.execute("""
898+
SELECT COALESCE(SUM(cached_population), 0)
899+
FROM campaign_areas
900+
WHERE campaign_id = %s
901+
""", (campaign_id,))
902+
total_population = cursor.fetchone()[0] or 0
903+
886904
cursor.close()
887-
return jsonify({'rounds': rounds}), 200
905+
return jsonify({
906+
'rounds': rounds,
907+
'total_population': int(total_population)
908+
}), 200
888909

889910
except Exception as e:
890911
print(f"Error listing rounds: {e}")

truecover-backend/temporal/activities/cluster_sampling.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -348,12 +348,32 @@ async def compute_pixels_for_campaign_areas(
348348
area_pixel_count = cursor.rowcount
349349
total_pixels += area_pixel_count
350350

351-
# Update cached_pixel_count on the campaign_area
351+
# Update cached_pixel_count, cached_population, and cached_building_count
352352
cursor.execute("""
353+
WITH pixel_stats AS (
354+
SELECT
355+
COUNT(*) as pixel_count,
356+
COALESCE(SUM(p.population), 0) as total_population
357+
FROM pixel_area pa
358+
JOIN pixels p ON pa.quadkey = p.quadkey
359+
WHERE pa.campaign_area_id = %s
360+
),
361+
location_counts AS (
362+
SELECT COUNT(l.id) as building_count
363+
FROM campaign_areas ca
364+
LEFT JOIN locations l ON l.campaign_id = ca.campaign_id
365+
AND l.latitude BETWEEN ca.bbox_min_lat AND ca.bbox_max_lat
366+
AND l.longitude BETWEEN ca.bbox_min_lng AND ca.bbox_max_lng
367+
AND ST_Intersects(l.geometry, ca.geometry)
368+
WHERE ca.id = %s
369+
)
353370
UPDATE campaign_areas
354-
SET cached_pixel_count = %s, updated_at = NOW()
371+
SET cached_pixel_count = (SELECT pixel_count FROM pixel_stats),
372+
cached_population = (SELECT total_population FROM pixel_stats),
373+
cached_building_count = (SELECT building_count FROM location_counts),
374+
updated_at = NOW()
355375
WHERE id = %s
356-
""", (area_pixel_count, area_id))
376+
""", (area_id, area_id, area_id))
357377

358378
conn.commit()
359379
activity.logger.info(f"Computed {total_pixels} pixel associations for {len(campaign_area_ids)} areas")
@@ -688,18 +708,22 @@ async def assign_pixels_to_round(
688708
WHERE id = %s AND NOT (%s = ANY(COALESCE(rounds, '{}')))
689709
""", (round_number, coverage_id, round_number))
690710

691-
# Update cached_sampled_count on campaign_area (count pixels with any round)
711+
# Update cached_sampled_count and cached_sampled_population on campaign_area
692712
cursor.execute("""
693713
WITH sampled AS (
694-
SELECT COUNT(DISTINCT cp.quadkey) as cnt
714+
SELECT
715+
COUNT(DISTINCT cp.quadkey) as cnt,
716+
COALESCE(SUM(p.population), 0) as sampled_pop
695717
FROM coverage_pixel cp
696718
JOIN pixel_area pa ON cp.quadkey = pa.quadkey
719+
JOIN pixels p ON cp.quadkey = p.quadkey
697720
WHERE pa.campaign_area_id = %s
698721
AND cp.rounds IS NOT NULL
699722
AND array_length(cp.rounds, 1) > 0
700723
)
701724
UPDATE campaign_areas
702725
SET cached_sampled_count = (SELECT cnt FROM sampled),
726+
cached_sampled_population = (SELECT sampled_pop FROM sampled),
703727
updated_at = NOW()
704728
WHERE id = %s
705729
""", (campaign_area_id, campaign_area_id))
@@ -756,12 +780,15 @@ async def update_campaign_area_sampled_count_for_union(
756780

757781
area_id = str(row[0])
758782

759-
# Count pixels with rounds assigned
783+
# Count pixels and population with rounds assigned
760784
cursor.execute("""
761785
WITH sampled AS (
762-
SELECT COUNT(DISTINCT cp.quadkey) as cnt
786+
SELECT
787+
COUNT(DISTINCT cp.quadkey) as cnt,
788+
COALESCE(SUM(p.population), 0) as sampled_pop
763789
FROM coverage_pixel cp
764790
JOIN pixel_area pa ON cp.quadkey = pa.quadkey
791+
JOIN pixels p ON cp.quadkey = p.quadkey
765792
WHERE pa.campaign_area_id = %s
766793
AND cp.campaign_id = %s
767794
AND cp.indicator_id = %s
@@ -770,6 +797,7 @@ async def update_campaign_area_sampled_count_for_union(
770797
)
771798
UPDATE campaign_areas
772799
SET cached_sampled_count = (SELECT cnt FROM sampled),
800+
cached_sampled_population = (SELECT sampled_pop FROM sampled),
773801
updated_at = NOW()
774802
WHERE id = %s
775803
RETURNING cached_sampled_count
@@ -814,28 +842,34 @@ async def update_campaign_area_sampled_counts(
814842
results = {}
815843

816844
for area_id in campaign_area_ids:
817-
# Count pixels that have been sampled (have any round assigned)
845+
# Count pixels and population that have been sampled (have any round assigned)
818846
cursor.execute("""
819-
SELECT COUNT(DISTINCT cp.quadkey)
847+
SELECT
848+
COUNT(DISTINCT cp.quadkey),
849+
COALESCE(SUM(p.population), 0)
820850
FROM coverage_pixel cp
821851
JOIN pixel_area pa ON cp.quadkey = pa.quadkey
852+
JOIN pixels p ON cp.quadkey = p.quadkey
822853
WHERE pa.campaign_area_id = %s
823854
AND cp.campaign_id = %s
824855
AND cp.indicator_id = %s
825856
AND cp.rounds IS NOT NULL
826857
AND array_length(cp.rounds, 1) > 0
827858
""", (area_id, campaign_id, indicator_id))
828859

829-
sampled_count = cursor.fetchone()[0] or 0
860+
row = cursor.fetchone()
861+
sampled_count = row[0] or 0
862+
sampled_population = row[1] or 0
830863
results[area_id] = sampled_count
831864

832-
# Update cached count
865+
# Update cached counts
833866
cursor.execute("""
834867
UPDATE campaign_areas
835868
SET cached_sampled_count = %s,
869+
cached_sampled_population = %s,
836870
updated_at = NOW()
837871
WHERE id = %s
838-
""", (sampled_count, area_id))
872+
""", (sampled_count, sampled_population, area_id))
839873

840874
conn.commit()
841875

@@ -1192,18 +1226,22 @@ async def clear_round_from_pixels(
11921226

11931227
cleared_count = cursor.rowcount
11941228

1195-
# Update cached_sampled_count (count pixels still having rounds)
1229+
# Update cached_sampled_count and cached_sampled_population (count pixels still having rounds)
11961230
cursor.execute("""
11971231
WITH sampled AS (
1198-
SELECT COUNT(DISTINCT cp.quadkey) as cnt
1232+
SELECT
1233+
COUNT(DISTINCT cp.quadkey) as cnt,
1234+
COALESCE(SUM(p.population), 0) as sampled_pop
11991235
FROM coverage_pixel cp
12001236
JOIN pixel_area pa ON cp.quadkey = pa.quadkey
1237+
JOIN pixels p ON cp.quadkey = p.quadkey
12011238
WHERE pa.campaign_area_id = %s
12021239
AND cp.rounds IS NOT NULL
12031240
AND array_length(cp.rounds, 1) > 0
12041241
)
12051242
UPDATE campaign_areas
12061243
SET cached_sampled_count = (SELECT cnt FROM sampled),
1244+
cached_sampled_population = (SELECT sampled_pop FROM sampled),
12071245
updated_at = NOW()
12081246
WHERE id = %s
12091247
""", (campaign_area_id, campaign_area_id))

0 commit comments

Comments
 (0)