param( [switch]$Refresh ) $ErrorActionPreference = 'Stop' $datasetRoot = Split-Path -Parent $PSScriptRoot $sourceDir = Join-Path $datasetRoot 'source' $dataDir = Join-Path $datasetRoot 'data' $sourcePath = Join-Path $sourceDir 'fbi-cde-wa-mvt-2018-2025.json' $monthlyPath = Join-Path $dataDir 'monthly.csv' $annualPath = Join-Path $dataDir 'annual-summary.csv' $periodDescriptivesPath = Join-Path $dataDir 'policy-period-descriptives.csv' $manifestPath = Join-Path $datasetRoot 'release-manifest.json' $endpoint = 'https://cde.ucr.cjis.gov/LATEST/summarized/state/WA/motor-vehicle-theft?from=01-2018&to=12-2025' New-Item -ItemType Directory -Force -Path $sourceDir, $dataDir | Out-Null if ($Refresh -or -not (Test-Path $sourcePath)) { $response = Invoke-WebRequest -UseBasicParsing -Uri $endpoint -TimeoutSec 60 if ($response.StatusCode -ne 200) { throw "FBI CDE returned HTTP $($response.StatusCode)." } [System.IO.File]::WriteAllText( $sourcePath, $response.Content, (New-Object System.Text.UTF8Encoding($false)) ) } $sourceText = [System.IO.File]::ReadAllText($sourcePath) $source = $sourceText | ConvertFrom-Json $waOffenses = $source.offenses.actuals.'Washington Offenses' $waClearances = $source.offenses.actuals.'Washington Clearances' $waOffenseRates = $source.offenses.rates.'Washington Offenses' $waClearanceRates = $source.offenses.rates.'Washington Clearances' $usOffenses = $source.offenses.actuals.'United States Offenses' $usClearances = $source.offenses.actuals.'United States Clearances' $usOffenseRates = $source.offenses.rates.'United States Offenses' $usClearanceRates = $source.offenses.rates.'United States Clearances' $waPopulation = $source.populations.population.Washington $usPopulation = $source.populations.population.'United States' $waCoverage = $source.tooltips.'Percent of Population Coverage'.Washington $usCoverage = $source.tooltips.'Percent of Population Coverage'.'United States' $rows = foreach ($property in $waOffenses.PSObject.Properties) { $sourceMonth = $property.Name $month = [DateTime]::ParseExact( "01-$sourceMonth", 'dd-MM-yyyy', [Globalization.CultureInfo]::InvariantCulture ) [pscustomobject][ordered]@{ month = $month.ToString('yyyy-MM-dd') wa_reported_offenses = [int]$property.Value wa_offense_rate_per_100k = [decimal]$waOffenseRates.$sourceMonth wa_clearances = [int]$waClearances.$sourceMonth wa_clearance_rate_per_100k = [decimal]$waClearanceRates.$sourceMonth wa_population = [long]$waPopulation.$sourceMonth wa_population_coverage_pct = [decimal]$waCoverage.$sourceMonth us_reported_offenses = [int]$usOffenses.$sourceMonth us_offense_rate_per_100k = [decimal]$usOffenseRates.$sourceMonth us_clearances = [int]$usClearances.$sourceMonth us_clearance_rate_per_100k = [decimal]$usClearanceRates.$sourceMonth us_population = [long]$usPopulation.$sourceMonth us_population_coverage_pct = [decimal]$usCoverage.$sourceMonth } } $rows = @($rows | Sort-Object month) if ($rows.Count -ne 96) { throw "Expected 96 monthly rows but found $($rows.Count)." } $expectedMonths = for ($i = 0; $i -lt 96; $i++) { ([DateTime]'2018-01-01').AddMonths($i).ToString('yyyy-MM-dd') } for ($i = 0; $i -lt $expectedMonths.Count; $i++) { if ($rows[$i].month -ne $expectedMonths[$i]) { throw "Month validation failed at row $($i + 1): expected $($expectedMonths[$i]), found $($rows[$i].month)." } } $requiredNumericFields = @( 'wa_reported_offenses', 'wa_offense_rate_per_100k', 'wa_clearances', 'wa_clearance_rate_per_100k', 'wa_population', 'wa_population_coverage_pct', 'us_reported_offenses', 'us_offense_rate_per_100k', 'us_clearances', 'us_clearance_rate_per_100k', 'us_population', 'us_population_coverage_pct' ) foreach ($row in $rows) { foreach ($field in $requiredNumericFields) { if ($null -eq $row.$field -or $row.$field -lt 0) { throw "Invalid $field for $($row.month)." } } if ($row.wa_population_coverage_pct -gt 100 -or $row.us_population_coverage_pct -gt 100) { throw "Population coverage exceeds 100% for $($row.month)." } } $rows | Export-Csv -NoTypeInformation -Encoding UTF8 -Path $monthlyPath $previousWaTotal = $null $annual = foreach ($group in ($rows | Group-Object { $_.month.Substring(0, 4) } | Sort-Object Name)) { $waTotal = [long]($group.Group | Measure-Object wa_reported_offenses -Sum).Sum $usTotal = [long]($group.Group | Measure-Object us_reported_offenses -Sum).Sum $change = if ($null -eq $previousWaTotal) { $null } else { [Math]::Round((($waTotal - $previousWaTotal) / $previousWaTotal) * 100, 2) } $annualRow = [pscustomobject][ordered]@{ year = [int]$group.Name months = $group.Count wa_reported_offenses = $waTotal wa_year_over_year_change_pct = $change wa_clearances = [long]($group.Group | Measure-Object wa_clearances -Sum).Sum wa_min_population_coverage_pct = [decimal]($group.Group | Measure-Object wa_population_coverage_pct -Minimum).Minimum wa_max_population_coverage_pct = [decimal]($group.Group | Measure-Object wa_population_coverage_pct -Maximum).Maximum us_reported_offenses = $usTotal us_min_population_coverage_pct = [decimal]($group.Group | Measure-Object us_population_coverage_pct -Minimum).Minimum us_max_population_coverage_pct = [decimal]($group.Group | Measure-Object us_population_coverage_pct -Maximum).Maximum } $previousWaTotal = $waTotal $annualRow } $annual | Export-Csv -NoTypeInformation -Encoding UTF8 -Path $annualPath $analysisPeriods = @( [ordered]@{ period = 'pre_hb1054' start = [DateTime]'2018-01-01' end = [DateTime]'2021-06-01' }, [ordered]@{ period = 'hb1054_full_months' start = [DateTime]'2021-08-01' end = [DateTime]'2023-04-01' }, [ordered]@{ period = 'sb5352_full_months' start = [DateTime]'2023-06-01' end = [DateTime]'2024-05-01' }, [ordered]@{ period = 'i2113_full_months' start = [DateTime]'2024-07-01' end = [DateTime]'2025-12-01' } ) $periodDescriptives = foreach ($period in $analysisPeriods) { $periodRows = @( $rows | Where-Object { ([DateTime]$_.month) -ge $period.start -and ([DateTime]$_.month) -le $period.end } ) $rateRatios = @( $periodRows | ForEach-Object { $_.wa_offense_rate_per_100k / $_.us_offense_rate_per_100k } ) [pscustomobject][ordered]@{ period = $period.period start_month = $period.start.ToString('yyyy-MM-dd') end_month = $period.end.ToString('yyyy-MM-dd') months = $periodRows.Count mean_monthly_wa_offenses = [Math]::Round( ($periodRows | Measure-Object wa_reported_offenses -Average).Average, 1 ) mean_wa_rate_per_100k = [Math]::Round( ($periodRows | Measure-Object wa_offense_rate_per_100k -Average).Average, 2 ) mean_us_rate_per_100k = [Math]::Round( ($periodRows | Measure-Object us_offense_rate_per_100k -Average).Average, 2 ) mean_wa_to_us_rate_ratio = [Math]::Round( ($rateRatios | Measure-Object -Average).Average, 2 ) minimum_wa_population_coverage_pct = [decimal]( $periodRows | Measure-Object wa_population_coverage_pct -Minimum ).Minimum causal_interpretation_permitted = $false } } $periodDescriptives | Export-Csv -NoTypeInformation -Encoding UTF8 -Path $periodDescriptivesPath $artifactPaths = @( $sourcePath, $monthlyPath, $annualPath, $periodDescriptivesPath, (Join-Path $dataDir 'policy-periods.csv'), (Join-Path $datasetRoot 'quality-report.md'), (Join-Path $datasetRoot 'source-audit.md'), (Join-Path $datasetRoot 'data-request-template.md') ) $artifacts = foreach ($path in $artifactPaths) { if (-not (Test-Path $path)) { throw "Required artifact is missing: $path" } $item = Get-Item $path [ordered]@{ path = $item.FullName.Substring($datasetRoot.Length + 1).Replace('\', '/') bytes = $item.Length sha256 = (Get-FileHash -Algorithm SHA256 -Path $path).Hash.ToLowerInvariant() } } $manifest = [ordered]@{ datasetKey = 'washington-monthly-motor-vehicle-theft-2018-2025' release = '0.1' status = 'research' generatedAtUtc = [DateTime]::UtcNow.ToString('o') coverage = [ordered]@{ start = $rows[0].month end = $rows[-1].month months = $rows.Count } principalSource = [ordered]@{ publisher = 'Federal Bureau of Investigation' system = 'Crime Data Explorer' endpoint = $endpoint sourceMaxDataDate = $source.cde_properties.max_data_date.UCR sourceLastRefreshDate = $source.cde_properties.last_refresh_date.UCR unit = 'Reported motor-vehicle-theft offenses' } artifacts = @($artifacts) } $manifestJson = $manifest | ConvertTo-Json -Depth 8 [System.IO.File]::WriteAllText( $manifestPath, $manifestJson + [Environment]::NewLine, (New-Object System.Text.UTF8Encoding($false)) ) Write-Output "Built $($rows.Count) monthly rows covering $($rows[0].month) through $($rows[-1].month)." Write-Output "Source last refreshed: $($source.cde_properties.last_refresh_date.UCR)" Write-Output "Manifest: $manifestPath"