99from ..storage import ParquetStorage
1010from .base import awair
1111from .common_opts import device_id_opt
12- from .config import data_path_opt , err
12+ from .config import (
13+ data_path_opt ,
14+ err ,
15+ get_data_base_path ,
16+ list_monthly_files ,
17+ load_monthly_data ,
18+ resolve_device_by_name_or_id ,
19+ )
1320
1421
1522@awair .group
@@ -18,21 +25,82 @@ def data():
1825 pass
1926
2027
28+ def load_device_data (device_id : str | None , data_path : str ) -> tuple [pd .DataFrame , str , bool ]:
29+ """Load device data, trying monthly files first then falling back to single file.
30+
31+ Args:
32+ device_id: Device ID (string or numeric)
33+ data_path: Data path (may be single file or base directory)
34+
35+ Returns:
36+ Tuple of (DataFrame, source_description, is_monthly)
37+ """
38+ import re
39+
40+ # If device_id not provided, try to extract from data_path
41+ # Pattern: awair-{deviceId}.parquet or awair-{deviceId}/
42+ if device_id is None :
43+ match = re .search (r'awair-(\d+)(?:\.parquet|/|$)' , data_path )
44+ if match :
45+ device_id = match .group (1 )
46+
47+ # Try monthly files first
48+ if device_id is not None :
49+ if isinstance (device_id , str ):
50+ try :
51+ _ , device_id_int = resolve_device_by_name_or_id (device_id )
52+ except ValueError :
53+ device_id_int = int (device_id )
54+ else :
55+ device_id_int = device_id
56+
57+ base_path = get_data_base_path (device_id_int )
58+ monthly_files = list_monthly_files (base_path )
59+
60+ if monthly_files :
61+ df = load_monthly_data (base_path )
62+ source = f'{ base_path } / ({ len (monthly_files )} monthly files)'
63+ return df , source , True
64+
65+ # Fall back to single file
66+ storage = ParquetStorage (data_path )
67+ df = storage .read_data ()
68+ return df , data_path , False
69+
70+
2171@data .command
2272@device_id_opt
2373@data_path_opt
2474def info (device_id : str | None , data_path : str ):
25- """Show data file information."""
26- storage = ParquetStorage (data_path )
27- summary = storage .get_data_summary ()
75+ """Show data file information.
2876
29- echo (f'Data file: { data_path } ' )
30- echo (f'Total records: { summary ["count" ]} ' )
31- if summary ['earliest' ]:
32- echo (f'Date range: { summary ["earliest" ]} to { summary ["latest" ]} ' )
33- echo (f'File size: { summary ["file_size_mb" ]:.2f} MB' )
34- else :
35- echo ('No data in file' )
77+ Automatically detects and reads from monthly sharded files if available,
78+ falling back to single-file format.
79+ """
80+ df , source , is_monthly = load_device_data (device_id , data_path )
81+
82+ echo (f'Data source: { source } ' )
83+
84+ if df .empty :
85+ echo ('No data found' )
86+ return
87+
88+ echo (f'Total records: { len (df ):,} ' )
89+
90+ df ['timestamp' ] = pd .to_datetime (df ['timestamp' ])
91+ earliest = df ['timestamp' ].min ()
92+ latest = df ['timestamp' ].max ()
93+ echo (f'Date range: { earliest } to { latest } ' )
94+
95+ if is_monthly :
96+ # Show per-month breakdown
97+ base_path = source .split (' (' )[0 ]
98+ monthly_files = list_monthly_files (base_path )
99+ echo ('\n Monthly files:' )
100+ for f in monthly_files :
101+ month_name = f .split ('/' )[- 1 ].replace ('.parquet' , '' )
102+ month_df = pd .read_parquet (f )
103+ echo (f' { month_name } : { len (month_df ):,} records' )
36104
37105
38106@data .command
@@ -49,14 +117,14 @@ def gaps(
49117 count : int ,
50118 min_gap : int | None ,
51119):
52- """Find and report the largest timing gaps in the data."""
120+ """Find and report the largest timing gaps in the data.
53121
54- # Read data
55- storage = ParquetStorage ( data_path )
56- df = storage . read_data ( )
122+ Automatically detects and reads from monthly sharded files if available.
123+ """
124+ df , source , _ = load_device_data ( device_id , data_path )
57125
58126 if df .empty :
59- err ('No data in file ' )
127+ err ('No data found ' )
60128 return
61129
62130 # Filter by date range if specified (parsing already handled by option callbacks)
@@ -100,7 +168,7 @@ def gaps(
100168 # Show summary
101169 date_range = f'{ df ["timestamp" ].min ().date ()} to { df ["timestamp" ].max ().date ()} '
102170
103- echo (f'Gap analysis for { data_path } ' )
171+ echo (f'Gap analysis for { source } ' )
104172 echo (f'Date range: { date_range } ' )
105173 echo (f'Total records: { len (df )} ' )
106174
@@ -132,13 +200,14 @@ def hist(
132200 from_dt : str | None ,
133201 to_dt : str | None ,
134202):
135- """Generate histogram of record counts per day."""
203+ """Generate histogram of record counts per day.
136204
137- storage = ParquetStorage (data_path )
138- df = storage .read_data ()
205+ Automatically detects and reads from monthly sharded files if available.
206+ """
207+ df , _ , _ = load_device_data (device_id , data_path )
139208
140209 if df .empty :
141- err ('No data in file ' )
210+ err ('No data found ' )
142211 return
143212
144213 # Ensure timestamp is datetime
@@ -166,3 +235,81 @@ def hist(
166235
167236 for _ , row in daily_counts .iterrows ():
168237 echo (f'{ row ["count" ]:7d} { row ["date" ]} ' )
238+
239+
240+ # Default row group size for monthly shards
241+ # 5000 rows = ~3.5 days at 1-minute intervals = ~80KB per RG
242+ # Monthly files have ~40-44k rows = ~8-9 RGs, good granularity for caching
243+ DEFAULT_MONTHLY_ROW_GROUP_SIZE = 5000
244+
245+
246+ @data .command
247+ @device_id_opt
248+ @data_path_opt
249+ @option ('-n' , '--dry-run' , is_flag = True , help = 'Show what would be done without writing files' )
250+ @option ('-r' , '--row-group-size' , type = int , default = DEFAULT_MONTHLY_ROW_GROUP_SIZE ,
251+ help = f'Row group size for output files (default: { DEFAULT_MONTHLY_ROW_GROUP_SIZE } )' )
252+ def shard (device_id : str | None , data_path : str , dry_run : bool , row_group_size : int ):
253+ """Split single parquet file into monthly shards.
254+
255+ Reads the existing awair-{deviceId}.parquet file and splits it into
256+ monthly files: awair-{deviceId}/{YYYY-MM}.parquet
257+
258+ This reduces Lambda write amplification by allowing updates to only
259+ touch the current month's file.
260+
261+ Default row group size is 5000 rows (~3.5 days, ~80KB) for good cache
262+ granularity. Use --row-group-size to customize.
263+ """
264+ # Read existing data
265+ echo (f'Reading: { data_path } ' )
266+ storage = ParquetStorage (data_path )
267+ df = storage .read_data ()
268+
269+ if df .empty :
270+ err ('No data in file' )
271+ return
272+
273+ echo (f'Using row_group_size: { row_group_size } ' )
274+
275+ # Ensure timestamp is datetime and extract year-month
276+ df ['timestamp' ] = pd .to_datetime (df ['timestamp' ])
277+ df ['year_month' ] = df ['timestamp' ].dt .strftime ('%Y-%m' )
278+
279+ # Group by year-month
280+ groups = df .groupby ('year_month' )
281+ echo (f'Found { len (groups )} months of data:' )
282+
283+ # Determine output base path (directory)
284+ # e.g., s3://380nwk/awair-17617.parquet -> s3://380nwk/awair-17617/
285+ if data_path .endswith ('.parquet' ):
286+ output_base = data_path [:- 8 ] # Remove .parquet suffix
287+ else :
288+ output_base = data_path
289+
290+ # Process each month
291+ for year_month , group_df in sorted (groups ):
292+ count = len (group_df )
293+ output_path = f'{ output_base } /{ year_month } .parquet'
294+
295+ date_range = f'{ group_df ["timestamp" ].min ().date ()} to { group_df ["timestamp" ].max ().date ()} '
296+ echo (f' { year_month } : { count :,} records ({ date_range } )' )
297+
298+ if dry_run :
299+ echo (f' Would write: { output_path } ' )
300+ else :
301+ # Prepare DataFrame for writing (remove year_month helper column)
302+ write_df = group_df .drop (columns = ['year_month' ]).sort_values ('timestamp' ).reset_index (drop = True )
303+
304+ # Write to monthly file
305+ write_df .to_parquet (output_path , index = False , engine = 'pyarrow' , row_group_size = row_group_size )
306+ echo (f' Wrote: { output_path } ' )
307+
308+ total_records = len (df )
309+ if dry_run :
310+ echo (f'\n Dry run complete. Would shard { total_records :,} records into { len (groups )} monthly files.' )
311+ echo ('Run without --dry-run to execute.' )
312+ else :
313+ echo (f'\n Sharded { total_records :,} records into { len (groups )} monthly files.' )
314+ echo (f'Original file preserved: { data_path } ' )
315+ echo ('After verifying shards, you can delete the original file.' )
0 commit comments