Skip to content

Commit fda47d0

Browse files
authored
Add data validation and transformation utilities for credits and projects (#107)
* Add data validation and transformation utilities for credits and projects * Add unit tests for pipeline utilities including validation, summarization, and data transformation * [skip-ci] Retrigger CI
1 parent 33720f5 commit fda47d0

2 files changed

Lines changed: 634 additions & 0 deletions

File tree

offsets_db_data/pipeline_utils.py

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
import datetime
2+
import io
3+
import tempfile
4+
import zipfile
5+
from collections.abc import Callable
6+
7+
import fsspec
8+
import pandas as pd
9+
10+
from offsets_db_data.data import catalog
11+
from offsets_db_data.registry import get_registry_from_project_id
12+
13+
14+
def validate_data(
15+
*,
16+
new_data: pd.DataFrame,
17+
as_of: datetime.datetime,
18+
data_type: str,
19+
quantity_column: str,
20+
aggregation_func,
21+
) -> None:
22+
success = False
23+
for delta_days in [1, 2, 3, 4]:
24+
try:
25+
previous_date = (as_of - datetime.timedelta(days=delta_days)).strftime('%Y-%m-%d')
26+
print(
27+
f'Validating {data_type} for {as_of.strftime("%Y-%m-%d")} against {previous_date}'
28+
)
29+
old_data = catalog[data_type](date=previous_date).read()
30+
31+
new_quantity = aggregation_func(new_data[quantity_column])
32+
old_quantity = aggregation_func(old_data[quantity_column])
33+
34+
print(f'New {data_type}: {new_data.shape} | New {quantity_column}: {new_quantity}')
35+
print(f'Old {data_type}: {old_data.shape} | Old {quantity_column}: {old_quantity}')
36+
37+
if new_quantity < old_quantity * 0.99:
38+
raise ValueError(
39+
f'New {data_type}: {new_quantity} (from {as_of.strftime("%Y-%m-%d")}) are less than 99% of old {data_type}: {old_quantity} (from {previous_date})'
40+
)
41+
else:
42+
print(f'New {data_type} are at least 99% of old {data_type}')
43+
success = True
44+
break
45+
except Exception as e:
46+
print(f'Validation failed for {delta_days} day(s) back: {e}')
47+
continue
48+
49+
if not success:
50+
raise ValueError(
51+
'Validation failed for either 1, 2, 3, or 4 days back. Please make sure the data is available for either 1, 2, 3 or 4 days back.'
52+
)
53+
54+
55+
def validate_credits(*, new_credits: pd.DataFrame, as_of: datetime.datetime) -> None:
56+
validate_data(
57+
new_data=new_credits,
58+
as_of=as_of,
59+
data_type='credits',
60+
quantity_column='quantity',
61+
aggregation_func=sum,
62+
)
63+
64+
65+
def validate_projects(*, new_projects: pd.DataFrame, as_of: datetime.datetime) -> None:
66+
validate_data(
67+
new_data=new_projects,
68+
as_of=as_of,
69+
data_type='projects',
70+
quantity_column='project_id',
71+
aggregation_func=pd.Series.nunique,
72+
)
73+
74+
75+
def validate(
76+
*, new_credits: pd.DataFrame, new_projects: pd.DataFrame, as_of: datetime.datetime
77+
) -> None:
78+
validate_credits(new_credits=new_credits, as_of=as_of)
79+
validate_projects(new_projects=new_projects, as_of=as_of)
80+
81+
82+
def summarize(
83+
*,
84+
credits: pd.DataFrame,
85+
projects: pd.DataFrame,
86+
project_types: pd.DataFrame | None = None,
87+
registry_name: str | None = None,
88+
) -> None:
89+
"""
90+
Summarizes the credits, projects, and project types data.
91+
92+
Parameters
93+
----------
94+
credits : DataFrame
95+
The credits data.
96+
projects : DataFrame
97+
The projects data.
98+
project_types : DataFrame, optional
99+
The project types data.
100+
registry_name : str, optional
101+
Name of the specific registry to summarize. If None, summarizes across all registries.
102+
103+
Returns
104+
-------
105+
None
106+
"""
107+
# Create defensive copies to avoid modifying the original dataframes
108+
credits = credits if credits.empty else credits.copy()
109+
projects = projects if projects.empty else projects.copy()
110+
111+
# Single registry mode
112+
if registry_name:
113+
if not projects.empty:
114+
print(
115+
f'\n\nRetired and Issued (in Millions) summary for {registry_name}:\n\n'
116+
f'{projects[["retired", "issued"]].sum() / 1_000_000}\n\n'
117+
f'{projects.project_id.nunique()} unique projects.\n\n'
118+
)
119+
else:
120+
print(f'No projects found for {registry_name}...')
121+
122+
if not credits.empty:
123+
print(
124+
f'\n\nCredits summary (in Millions) for {registry_name}:\n\n'
125+
f'{credits.groupby(["transaction_type"])[["quantity"]].sum() / 1_000_000}\n\n'
126+
f'{credits.shape[0]} total transactions.\n\n'
127+
)
128+
else:
129+
print(f'No credits found for {registry_name}...')
130+
131+
# Multi-registry mode
132+
else:
133+
if not projects.empty:
134+
print(
135+
f'Summary Statistics for projects (in Millions):\n'
136+
f'{projects.groupby(["registry", "is_compliance"])[["retired", "issued"]].sum() / 1_000_000}\n'
137+
)
138+
else:
139+
print('No projects found')
140+
141+
if not credits.empty:
142+
credits['registry'] = credits['project_id'].map(get_registry_from_project_id)
143+
144+
print(
145+
f'Summary Statistics for credits (in Millions):\n'
146+
f'{credits.groupby(["registry", "transaction_type"])[["quantity"]].sum() / 1_000_000}\n'
147+
)
148+
else:
149+
print('No credits found')
150+
151+
# Always handle project types if provided
152+
if project_types is not None and not project_types.empty:
153+
print(
154+
f'Summary Statistics for project types:\n'
155+
f'{project_types.groupby(["project_type", "source"]).count()}\n'
156+
)
157+
elif project_types is not None:
158+
print('No project types found')
159+
160+
161+
def to_parquet(
162+
*,
163+
credits: pd.DataFrame,
164+
projects: pd.DataFrame,
165+
output_paths: dict,
166+
project_types: pd.DataFrame | None = None,
167+
registry_name: str | None = None,
168+
):
169+
"""
170+
Write the given DataFrames to Parquet files.
171+
172+
Parameters
173+
-----------
174+
credits : pd.DataFrame
175+
The DataFrame containing credits data.
176+
projects : pd.DataFrame
177+
The DataFrame containing projects data.
178+
output_paths : dict
179+
Dictionary containing output file paths.
180+
project_types : pd.DataFrame, optional
181+
The DataFrame containing project types data.
182+
registry_name : str, optional
183+
The name of the registry for logging purposes.
184+
"""
185+
credits.to_parquet(
186+
output_paths['credits'], index=False, compression='gzip', engine='fastparquet'
187+
)
188+
189+
prefix = f'{registry_name} ' if registry_name else ''
190+
print(f'Wrote {prefix}credits to {output_paths["credits"]}...')
191+
192+
projects.to_parquet(
193+
output_paths['projects'], index=False, compression='gzip', engine='fastparquet'
194+
)
195+
print(f'Wrote {prefix}projects to {output_paths["projects"]}...')
196+
197+
if project_types is not None and 'project-types' in output_paths:
198+
project_types.to_parquet(
199+
output_paths['project-types'],
200+
index=False,
201+
compression='gzip',
202+
engine='fastparquet',
203+
)
204+
print(f'Wrote project types to {output_paths["project-types"]}...')
205+
206+
207+
def _create_data_zip_buffer(
208+
*,
209+
credits: pd.DataFrame,
210+
projects: pd.DataFrame,
211+
project_types: pd.DataFrame,
212+
format_type: str,
213+
terms_content: str,
214+
) -> io.BytesIO:
215+
"""
216+
Create a zip buffer containing data files in the specified format with terms of access.
217+
218+
Parameters
219+
----------
220+
credits : pd.DataFrame
221+
DataFrame containing credit data.
222+
projects : pd.DataFrame
223+
DataFrame containing project data.
224+
project_types : pd.DataFrame
225+
DataFrame containing project type data.
226+
format_type : str
227+
Format type, either 'csv' or 'parquet'.
228+
terms_content : str
229+
Content of the terms of access file.
230+
231+
Returns
232+
-------
233+
io.BytesIO
234+
Buffer containing the zip file.
235+
"""
236+
zip_buffer = io.BytesIO()
237+
238+
with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED, False) as zf:
239+
zf.writestr('TERMS_OF_DATA_ACCESS.txt', terms_content)
240+
241+
if format_type == 'csv':
242+
with zf.open('credits.csv', 'w') as buffer:
243+
credits.to_csv(buffer, index=False)
244+
with zf.open('projects.csv', 'w') as buffer:
245+
projects.to_csv(buffer, index=False)
246+
with zf.open('project-types.csv', 'w') as buffer:
247+
project_types.to_csv(buffer, index=False)
248+
249+
elif format_type == 'parquet':
250+
# Write Parquet files to temporary files
251+
with tempfile.NamedTemporaryFile(suffix='.parquet') as temp_credits:
252+
credits.to_parquet(temp_credits.name, index=False, engine='fastparquet')
253+
temp_credits.seek(0)
254+
zf.writestr('credits.parquet', temp_credits.read())
255+
256+
with tempfile.NamedTemporaryFile(suffix='.parquet') as temp_projects:
257+
projects.to_parquet(temp_projects.name, index=False, engine='fastparquet')
258+
temp_projects.seek(0)
259+
zf.writestr('projects.parquet', temp_projects.read())
260+
261+
with tempfile.NamedTemporaryFile(suffix='.parquet') as temp_project_types:
262+
project_types.to_parquet(temp_project_types.name, index=False, engine='fastparquet')
263+
temp_project_types.seek(0)
264+
zf.writestr('project-types.parquet', temp_project_types.read())
265+
266+
# Move to the beginning of the BytesIO buffer
267+
zip_buffer.seek(0)
268+
return zip_buffer
269+
270+
271+
def write_latest_production(
272+
*,
273+
credits: pd.DataFrame,
274+
projects: pd.DataFrame,
275+
project_types: pd.DataFrame,
276+
bucket: str,
277+
terms_url: str = 's3://carbonplan-offsets-db/TERMS_OF_DATA_ACCESS.txt',
278+
):
279+
"""
280+
Write the latest production data to S3 as zip archives containing CSV and Parquet files.
281+
282+
Parameters
283+
----------
284+
credits : pd.DataFrame
285+
DataFrame containing credit data.
286+
projects : pd.DataFrame
287+
DataFrame containing project data.
288+
project_types : pd.DataFrame
289+
DataFrame containing project type data.
290+
bucket : str
291+
S3 bucket path to write the data to.
292+
terms_url : str, optional
293+
URL of the terms of access file.
294+
"""
295+
paths = {
296+
'csv': f'{bucket}/production/latest/offsets-db.csv.zip',
297+
'parquet': f'{bucket}/production/latest/offsets-db.parquet.zip',
298+
}
299+
300+
# Get terms content once
301+
fs = fsspec.filesystem('s3', anon=False)
302+
terms_content = fs.read_text(terms_url)
303+
304+
for format_type, path in paths.items():
305+
# Create zip buffer with data in the appropriate format
306+
zip_buffer = _create_data_zip_buffer(
307+
credits=credits,
308+
projects=projects,
309+
project_types=project_types,
310+
format_type=format_type,
311+
terms_content=terms_content,
312+
)
313+
314+
# Write buffer to S3
315+
with fsspec.open(path, 'wb') as f:
316+
f.write(zip_buffer.getvalue())
317+
318+
print(f'Wrote {format_type} to {path}...')
319+
zip_buffer.close()
320+
321+
322+
def transform_registry_data(
323+
*,
324+
process_credits_fn: Callable[[], pd.DataFrame],
325+
process_projects_fn: Callable[[pd.DataFrame], pd.DataFrame],
326+
output_paths: dict,
327+
registry_name: str | None = None,
328+
):
329+
"""
330+
Transform registry data by processing credits and projects, then writing to parquet files.
331+
332+
Parameters
333+
----------
334+
process_credits_fn : callable
335+
Function that returns processed credits DataFrame
336+
process_projects_fn : callable
337+
Function that takes a credits DataFrame and returns processed projects DataFrame
338+
output_paths : dict
339+
Dictionary containing output file paths for 'credits' and 'projects'
340+
registry_name : str, optional
341+
Name of the registry for logging purposes
342+
"""
343+
# Process credits
344+
credits = process_credits_fn()
345+
if registry_name:
346+
print(f'credits for {registry_name}: {credits.head()}')
347+
else:
348+
print(f'processed credits: {credits.head()}')
349+
350+
# Process projects
351+
projects = process_projects_fn(credits=credits)
352+
if registry_name:
353+
print(f'projects for {registry_name}: {projects.head()}')
354+
else:
355+
print(f'processed projects: {projects.head()}')
356+
357+
# Summarize data
358+
summarize(credits=credits, projects=projects, registry_name=registry_name)
359+
360+
# Write to parquet files
361+
to_parquet(
362+
credits=credits,
363+
projects=projects,
364+
output_paths=output_paths,
365+
registry_name=registry_name,
366+
)
367+
368+
return credits, projects

0 commit comments

Comments
 (0)