diff --git a/.gitignore b/.gitignore index 06e5806c..7a5dbd4a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ FluSight-forecast-hub/ rsv-forecast-hub/ covid19-forecast-hub/ +flu-metrocast/ app/public/processed_data processed_data/ .DS_Store diff --git a/app/package-lock.json b/app/package-lock.json index 77df6eb0..5c734a96 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -46,10 +46,6 @@ "vite": "^6.0.1" } }, - "node_modules/driver.js": { - "version": "1.3.0", - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -9171,4 +9167,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/src/App.jsx b/app/src/App.jsx index 87ef0ddb..4f8f3ddb 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -56,7 +56,6 @@ const App = () => { return ( - {/* The ViewProvider now wraps everything, making the context available to all components */} diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index 796b3bcd..cec4029f 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -11,7 +11,6 @@ import { IconShare, IconBrandGithub } from '@tabler/icons-react'; import { useClipboard } from '@mantine/hooks'; const DataVisualizationContainer = () => { - // Get EVERYTHING from the single context hook const { selectedLocation, data, metadata, loading, error, availableDates, models, @@ -207,6 +206,28 @@ const DataVisualizationContainer = () => { ) }, + 'metrocast_projs': { + title: ( + + Flu MetroCast + + ), + buttonLabel: "About MetroCast", + content: ( + <> +

+ Data for the RespiLens Flu Metrocast view is retrieved from the Flu MetroCast Hub, which is a collaborative modeling project that collects and shares weekly probabilistic forecasts of influenza activity at the metropolitan level in the United States. The hub is run by epiENGAGE – an Insight Net Center for Implementation within the U.S. Centers for Disease Control and Prevention (CDC)’s Center for Forecasting and Outbreak Analytics (CFA). +

+

For more info and attribution on the Flu MetroCast Hub, please visit their site, or visit their visualization dashboard to engage with their original visualization scheme.

+
+ Forecasts +

+ Forecasting teams submit a probabilistic forecasts of targets every week of the flu season. RespiLens displays the 50% and 95% confidence intervals for each model's forecast for a chosen date, shown on the plot with a shadow. +

+
+ + ) + }, 'nhsnall': { title: ( @@ -349,14 +370,13 @@ const DataVisualizationContainer = () => { activeDate={activeDate} setActiveDate={setActiveDate} loading={loading} - multi={viewType !== 'flu_peak'} //disable multi date select if flu peak + multi={viewType !== 'flu_peak'} //this disables multi date select if flu peak /> )}
{ COVID-19 Forecast Hub: official CDC pageHubverse dashboard – official GitHub repository + + Flu MetroCast Hub: official dashboardsite – official GitHub repository + diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx new file mode 100644 index 00000000..96f8527d --- /dev/null +++ b/app/src/components/MetroCastView.jsx @@ -0,0 +1,260 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useMantineColorScheme, Stack, Text, Center } from '@mantine/core'; +import Plot from 'react-plotly.js'; +import Plotly from 'plotly.js/dist/plotly'; +import ModelSelector from './ModelSelector'; +import LastFetched from './LastFetched'; +import { MODEL_COLORS } from '../config/datasets'; +import { CHART_CONSTANTS } from '../constants/chart'; +import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils'; + +const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => { + const [yAxisRange, setYAxisRange] = useState(null); + const [xAxisRange, setXAxisRange] = useState(null); + const plotRef = useRef(null); + const isResettingRef = useRef(false); + + const getDefaultRangeRef = useRef(getDefaultRange); + const projectionsDataRef = useRef([]); + + const { colorScheme } = useMantineColorScheme(); + const groundTruth = data?.ground_truth; + const forecasts = data?.forecasts; + + const calculateYRange = useCallback((plotData, xRange) => { + if (!plotData || !xRange || !Array.isArray(plotData) || plotData.length === 0 || !selectedTarget) return null; + let minY = Infinity; + let maxY = -Infinity; + const [startX, endX] = xRange; + const startDate = new Date(startX); + const endDate = new Date(endX); + + plotData.forEach(trace => { + if (!trace.x || !trace.y) return; + for (let i = 0; i < trace.x.length; i++) { + const pointDate = new Date(trace.x[i]); + if (pointDate >= startDate && pointDate <= endDate) { + const value = Number(trace.y[i]); + if (!isNaN(value)) { + minY = Math.min(minY, value); + maxY = Math.max(maxY, value); + } + } + } + }); + + if (minY !== Infinity && maxY !== -Infinity) { + const padding = maxY * (CHART_CONSTANTS.Y_AXIS_PADDING_PERCENT / 100); + return [Math.max(0, minY - padding), maxY + padding]; + } + return null; + }, [selectedTarget]); + + const projectionsData = useMemo(() => { + if (!groundTruth || !forecasts || selectedDates.length === 0 || !selectedTarget) return []; + + const groundTruthValues = groundTruth[selectedTarget]; + if (!groundTruthValues) return []; + + const groundTruthTrace = { + x: groundTruth.dates || [], + y: groundTruthValues, + name: 'Observed', + type: 'scatter', + mode: 'lines+markers', + line: { color: 'black', width: 2, dash: 'dash' }, + marker: { size: 4, color: 'black' } + }; + + const modelTraces = selectedModels.flatMap(model => + selectedDates.flatMap((date, dateIndex) => { + const forecastsForDate = forecasts[date] || {}; + const forecast = forecastsForDate[selectedTarget]?.[model]; + if (!forecast || forecast.type !== 'quantile') return []; + + const forecastDates = [], medianValues = [], ci95Upper = [], ci95Lower = [], ci50Upper = [], ci50Lower = []; + const sortedHorizons = Object.keys(forecast.predictions || {}).sort((a, b) => Number(a) - Number(b)); + + sortedHorizons.forEach((h) => { + const pred = forecast.predictions[h]; + forecastDates.push(pred.date); + const { quantiles = [], values = [] } = pred; + + const findValue = (q) => { + const idx = quantiles.indexOf(q); + return idx !== -1 ? values[idx] : null; + }; + + const v025 = findValue(0.025), v25 = findValue(0.25), v50 = findValue(0.5), v75 = findValue(0.75), v975 = findValue(0.975); + + if (v50 !== null) { + medianValues.push(v50); + ci95Lower.push(v025 ?? v50); + ci50Lower.push(v25 ?? v50); + ci50Upper.push(v75 ?? v50); + ci95Upper.push(v975 ?? v50); + } + }); + + if (forecastDates.length === 0) return []; + + const modelColor = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length]; + const isFirstDate = dateIndex === 0; + + return [ + { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci95Upper, ...ci95Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}10`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 95% CI`, hoverinfo: 'none', legendgroup: model }, + { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci50Upper, ...ci50Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 50% CI`, hoverinfo: 'none', legendgroup: model }, + { x: forecastDates, y: medianValues, name: model, type: 'scatter', mode: 'lines+markers', line: { color: modelColor, width: 2, dash: 'solid' }, marker: { size: 6, color: modelColor }, showlegend: isFirstDate, legendgroup: model } + ]; + }) + ); + + return [groundTruthTrace, ...modelTraces]; + }, [groundTruth, forecasts, selectedDates, selectedModels, selectedTarget]); + + useEffect(() => { + getDefaultRangeRef.current = getDefaultRange; + projectionsDataRef.current = projectionsData; + }, [getDefaultRange, projectionsData]); + + const activeModels = useMemo(() => { + const activeModelSet = new Set(); + if (!forecasts || !selectedTarget || !selectedDates.length) return activeModelSet; + selectedDates.forEach(date => { + const targetData = forecasts[date]?.[selectedTarget]; + if (targetData) Object.keys(targetData).forEach(m => activeModelSet.add(m)); + }); + return activeModelSet; + }, [forecasts, selectedDates, selectedTarget]); + + const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]); + + useEffect(() => { setXAxisRange(null); }, [selectedTarget]); + + useEffect(() => { + const currentXRange = xAxisRange || defaultRange; + if (projectionsData.length > 0 && currentXRange) { + setYAxisRange(calculateYRange(projectionsData, currentXRange)); + } else { + setYAxisRange(null); + } + }, [projectionsData, xAxisRange, defaultRange, calculateYRange]); + + const handlePlotUpdate = useCallback((figure) => { + if (isResettingRef.current) { isResettingRef.current = false; return; } + if (figure?.['xaxis.range']) { + const newXRange = figure['xaxis.range']; + if (JSON.stringify(newXRange) !== JSON.stringify(xAxisRange)) setXAxisRange(newXRange); + } + }, [xAxisRange]); + + const layout = useMemo(() => ({ + width: Math.min(CHART_CONSTANTS.MAX_WIDTH, windowSize.width * CHART_CONSTANTS.WIDTH_RATIO), + height: Math.min(CHART_CONSTANTS.MAX_HEIGHT, windowSize.height * CHART_CONSTANTS.HEIGHT_RATIO), + autosize: true, + template: colorScheme === 'dark' ? 'plotly_dark' : 'plotly_white', + paper_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', + plot_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', + font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, + showlegend: selectedModels.length < 15, + legend: { + x: 0, y: 1, xanchor: 'left', yanchor: 'top', + bgcolor: colorScheme === 'dark' ? 'rgba(26, 27, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)', + bordercolor: colorScheme === 'dark' ? '#444' : '#ccc', + borderwidth: 1, font: { size: 10 } + }, + hovermode: 'x unified', + dragmode: false, + margin: { l: 60, r: 30, t: 30, b: 30 }, + xaxis: { + domain: [0, 1], + rangeslider: { range: getDefaultRange(true) }, + rangeselector: { + buttons: [ + {count: 1, label: '1m', step: 'month', stepmode: 'backward'}, + {count: 6, label: '6m', step: 'month', stepmode: 'backward'}, + {step: 'all', label: 'all'} + ] + }, + range: xAxisRange || defaultRange, + showline: true, linewidth: 1, + linecolor: colorScheme === 'dark' ? '#aaa' : '#444' + }, + yaxis: { + title: (() => { + const longName = targetDisplayNameMap[selectedTarget]; + return targetYAxisLabelMap[longName] || longName || selectedTarget || 'Value'; + })(), + range: yAxisRange, + autorange: yAxisRange === null, + }, + shapes: selectedDates.map(date => ({ + type: 'line', x0: date, x1: date, y0: 0, y1: 1, yref: 'paper', + line: { color: 'red', width: 1, dash: 'dash' } + })) + }), [colorScheme, windowSize, defaultRange, selectedTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange]); + + const config = useMemo(() => ({ + responsive: true, + displayModeBar: true, + displaylogo: false, + modeBarButtonsToRemove: ['resetScale2d', 'select2d', 'lasso2d'], + modeBarButtonsToAdd: [{ + name: 'Reset view', + icon: Plotly.Icons.home, + click: function(gd) { + const range = getDefaultRangeRef.current(); + if (!range) return; + const newYRange = projectionsDataRef.current.length > 0 ? calculateYRange(projectionsDataRef.current, range) : null; + isResettingRef.current = true; + setXAxisRange(null); + setYAxisRange(newYRange); + Plotly.relayout(gd, { 'xaxis.range': range, 'yaxis.range': newYRange, 'yaxis.autorange': newYRange === null }); + } + }] + }), [calculateYRange]); + + if (!selectedTarget) { + return ( +
+ Please select a target to view MetroCast data. +
+ ); + } + + return ( + + + +
+ +
+ +
+

+ Note that forecasts should be interpreted with great caution and may not reliably predict rapid changes in disease trends. +

+
+ + { + const index = selectedModels.indexOf(model); + return MODEL_COLORS[index % MODEL_COLORS.length]; + }} + /> +
+ ); +}; + +export default MetroCastView; \ No newline at end of file diff --git a/app/src/components/StateSelector.jsx b/app/src/components/StateSelector.jsx index 3e1023fa..1c0f8ed1 100644 --- a/app/src/components/StateSelector.jsx +++ b/app/src/components/StateSelector.jsx @@ -6,48 +6,93 @@ import ViewSelector from './ViewSelector'; import TargetSelector from './TargetSelector'; import { getDataPath } from '../utils/paths'; +const METRO_STATE_MAP = { + 'Colorado': 'CO', 'Georgia': 'GA', 'Indiana': 'IN', 'Maine': 'ME', + 'Maryland': 'MD', 'Massachusetts': 'MA', 'Minnesota': 'MN', + 'South Carolina': 'SC', 'Texas': 'TX', 'Utah': 'UT', + 'Virginia': 'VA', 'North Carolina': 'NC', 'Oregon': 'OR' +}; + const StateSelector = () => { - const { selectedLocation, handleLocationSelect } = useView(); + const { selectedLocation, handleLocationSelect, viewType} = useView(); const [states, setStates] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(''); - const [highlightedIndex, setHighlightedIndex] = useState(-1); // default highlighted will be set to 0 later (US) + const [highlightedIndex, setHighlightedIndex] = useState(-1); useEffect(() => { - const fetchStates = async () => { + const controller = new AbortController(); // controller prevents issues if you click away while locs are loading + + setStates([]); + setLoading(true); + + const fetchStates = async () => { // different fetching/ordering if it is metrocast vs. other views try { - const manifestResponse = await fetch(getDataPath('flusight/metadata.json')); - if (!manifestResponse.ok) { - throw new Error(`Failed to fetch metadata: ${manifestResponse.statusText}`); - } + const isMetro = viewType === 'metrocast_projs'; + const directory = isMetro ? 'flumetrocast' : 'flusight'; + + const manifestResponse = await fetch( + getDataPath(`${directory}/metadata.json`), + { signal: controller.signal } + ); + + if (!manifestResponse.ok) throw new Error(`Failed: ${manifestResponse.statusText}`); + const metadata = await manifestResponse.json(); - if (!metadata.locations || !Array.isArray(metadata.locations)) { - throw new Error('Invalid metadata format'); + let finalOrderedList = []; + + if (isMetro) { + const locations = metadata.locations; + const statesOnly = locations.filter(l => !l.location_name.includes(',')); + const citiesOnly = locations.filter(l => l.location_name.includes(',')); + statesOnly.sort((a, b) => a.location_name.localeCompare(b.location_name)); + + statesOnly.forEach(stateObj => { + finalOrderedList.push(stateObj) + const code = METRO_STATE_MAP[stateObj.location_name]; + + const children = citiesOnly + .filter(city => city.location_name.endsWith(`, ${code}`)) + .sort((a, b) => a.location_name.localeCompare(b.location_name)); + + finalOrderedList.push(...children); + }); + + const handledIds = finalOrderedList.map(l => l.abbreviation); + const leftovers = locations.filter(l => !handledIds.includes(l.abbreviation)); + finalOrderedList.push(...leftovers); + + } else { + finalOrderedList = metadata.locations.sort((a, b) => { + const isA_Default = a.abbreviation === 'US'; + const isB_Default = b.abbreviation === 'US'; + if (isA_Default) return -1; + if (isB_Default) return 1; + return (a.location_name || '').localeCompare(b.location_name || ''); + }); } - const sortedLocations = metadata.locations.sort((a, b) => { - if (a.abbreviation === 'US') return -1; - if (b.abbreviation === 'US') return 1; - return (a.location_name || '').localeCompare(b.location_name || ''); - }); - setStates(sortedLocations); + + setStates(finalOrderedList); } catch (err) { - console.error('Error in data loading:', err); + if (err.name === 'AbortError') return; setError(err.message); } finally { - setLoading(false); + if (!controller.signal.aborted) setLoading(false); } }; + fetchStates(); - }, []); - // This ensures the keyboard focus starts on the dark blue selected item. + return () => controller.abort(); + }, [viewType]); + useEffect(() => { - if (states.length > 0 && selectedLocation) { - const index = states.findIndex(state => state.abbreviation === selectedLocation); - setHighlightedIndex(index); + if (states.length > 0) { + const index = states.findIndex(state => state.abbreviation === selectedLocation); + setHighlightedIndex(index >= 0 ? index : 0); } }, [states, selectedLocation]); @@ -61,13 +106,11 @@ const StateSelector = () => { const newSearchTerm = e.currentTarget.value; setSearchTerm(newSearchTerm); - // Reset highlight to the first filtered item only if we are typing. if (newSearchTerm.length > 0 && filteredStates.length > 0) { setHighlightedIndex(0); } else if (newSearchTerm.length === 0) { - // If search is cleared, reset highlight to the currently selected item (US on load) const index = states.findIndex(state => state.abbreviation === selectedLocation); - setHighlightedIndex(index); + setHighlightedIndex(index >= 0 ? index : 0); } }; @@ -78,14 +121,12 @@ const StateSelector = () => { if (event.key === 'ArrowDown') { event.preventDefault(); - // Use filteredStates length here for wrapping newIndex = (highlightedIndex + 1) % filteredStates.length; } else if (event.key === 'ArrowUp') { event.preventDefault(); newIndex = (highlightedIndex - 1 + filteredStates.length) % filteredStates.length; } else if (event.key === 'Enter') { event.preventDefault(); - // Use the filteredStates array to get the state abbreviation based on the current highlight index const selectedState = filteredStates[highlightedIndex]; if (selectedState) { @@ -99,10 +140,6 @@ const StateSelector = () => { setHighlightedIndex(newIndex); }; - - // NOTE: The previous useEffect to handle out-of-bounds index is no longer strictly needed - // because we calculate the index based on filteredStates length in handleKeyDown, - // and reset it when the search term changes. if (loading) { return
; @@ -130,23 +167,25 @@ const StateSelector = () => { Location } autoFocus - aria-label="Search states and territories" + aria-label="Search locations" /> - {/* Map over filteredStates but still need the index */} {filteredStates.map((state, index) => { const isSelected = selectedLocation === state.abbreviation; const isKeyboardHighlighted = (searchTerm.length > 0 || index === highlightedIndex) && index === highlightedIndex && !isSelected; + // Only apply nested styling in Metrocast view + const isCity = viewType === 'metrocast_projs' && state.location_name.includes(','); + let variant = 'subtle'; let color = 'blue'; @@ -154,33 +193,35 @@ const StateSelector = () => { variant = 'filled'; color = 'blue'; } else if (isKeyboardHighlighted) { - // Style for the keyboard-highlighted state (light blue) only during search/nav variant = 'light'; color = 'blue'; } return ( diff --git a/app/src/components/ViewSwitchboard.jsx b/app/src/components/ViewSwitchboard.jsx index e2c62d52..85a0c1a4 100644 --- a/app/src/components/ViewSwitchboard.jsx +++ b/app/src/components/ViewSwitchboard.jsx @@ -1,6 +1,7 @@ import { Center, Stack, Loader, Text, Alert, Button } from '@mantine/core'; import { IconAlertTriangle, IconRefresh } from '@tabler/icons-react'; import FluView from './FluView'; +import MetroCastView from './MetroCastView'; import RSVView from './RSVView'; import COVID19View from './COVID19View'; import NHSNView from './NHSNView'; @@ -162,6 +163,21 @@ const ViewSwitchboard = ({ /> ); + case 'metrocast_projs': + return ( + + ); + default: return (
diff --git a/app/src/config/app.js b/app/src/config/app.js index 363c2400..c0ab8abc 100644 --- a/app/src/config/app.js +++ b/app/src/config/app.js @@ -1,30 +1,8 @@ -/** - * Application-wide configuration - * - * This file contains global settings that control the default behavior - * of the RespiLens application, including default landing pages, - * UI preferences, and feature flags. - */ - export const APP_CONFIG = { - /** - * Default landing page settings - * These determine what users see when they first visit the site - */ defaultDataset: 'covid', defaultView: 'frontpage', defaultLocation: 'US', - - /** - * Dataset display order in the UI - * This controls the order of datasets in the ViewSelector menu - */ - datasetDisplayOrder: ['covid', 'flu', 'rsv', 'nhsn'], - - /** - * Feature flags - * Use these to enable/disable major sections of the application - */ + datasetDisplayOrder: ['covid', 'flu', 'metrocast', 'rsv', 'nhsn'], features: { enableForecastle: true, enableNarratives: true, diff --git a/app/src/config/datasets.js b/app/src/config/datasets.js index 4ffbf090..3e8e12d2 100644 --- a/app/src/config/datasets.js +++ b/app/src/config/datasets.js @@ -55,7 +55,22 @@ export const DATASETS = { hasModelSelector: false, prefix: 'nhsn', dataPath: 'nhsn' - } + }, + metrocast: { + shortName: 'metrocast', + fullName: 'Flu MetroCast Forecasts', + views: [ + { key: 'projections', label: 'Projections', value: 'metrocast_projs' } + ], + defaultView: 'metrocast_projs', + defaultModel: 'epiENGAGE-ensemble_mean', + defaultLocation: 'boulder', + hasDateSelector: true, + hasModelSelector: true, + prefix: 'metrocast', + dataPath: 'flumetrocast', + targetLineDayOfWeek: 3 +}, }; // Helper function to get all valid view values diff --git a/app/src/contexts/ViewContext.jsx b/app/src/contexts/ViewContext.jsx index 0546a514..d186ec56 100644 --- a/app/src/contexts/ViewContext.jsx +++ b/app/src/contexts/ViewContext.jsx @@ -1,5 +1,3 @@ -// src/contexts/ViewContext.jsx - import { useState, useCallback, useEffect, useMemo } from 'react'; import { useLocation, useSearchParams } from 'react-router-dom'; import { URLParameterManager } from '../utils/urlManager'; @@ -15,12 +13,21 @@ export const ViewProvider = ({ children }) => { const urlManager = useMemo(() => new URLParameterManager(searchParams, setSearchParams), [searchParams, setSearchParams]); const [viewType, setViewType] = useState(() => urlManager.getView()); - const [selectedLocation, setSelectedLocation] = useState(() => urlManager.getLocation()); + const [selectedLocation, setSelectedLocation] = useState(() => { + const urlLoc = urlManager.getLocation(); + const currentView = urlManager.getView(); + const dataset = urlManager.getDatasetFromView(currentView); + if (dataset?.defaultLocation && urlLoc === APP_CONFIG.defaultLocation) { + return dataset.defaultLocation; + } + + return urlLoc; + }); const [selectedModels, setSelectedModels] = useState([]); const [selectedDates, setSelectedDates] = useState([]); const [activeDate, setActiveDate] = useState(null); const [selectedTarget, setSelectedTarget] = useState(null); - const CURRENT_FLU_SEASON_START = '2025-11-01'; // !! CRITICAL !!: need to change this manually based on the season + const CURRENT_FLU_SEASON_START = '2025-11-01'; // !! CRITICAL !!: need to change this manually based on the season (for flu peak view) const { data, metadata, loading, error, availableDates, models, availableTargets, modelsByTarget, peaks, availablePeakDates, availablePeakModels } = useForecastData(selectedLocation, viewType); @@ -38,28 +45,22 @@ export const ViewProvider = ({ children }) => { }, [viewType, urlManager]); const modelsForView = useMemo(() => { - // Handle the special 'fludetailed' view, which has two hardcoded targets if (viewType === 'fludetailed') { const target1Models = new Set(modelsByTarget['wk inc flu hosp'] || []); const target2Models = new Set(modelsByTarget['wk flu hosp rate change'] || []); - // Combine models from both targets return Array.from(new Set([...target1Models, ...target2Models])).sort(); } if (viewType === 'flu_peak') { - // Use the list calculated in useForecastData.js from the peaks data return availablePeakModels || []; } - // For all other views, just use the selectedTarget if (selectedTarget && modelsByTarget[selectedTarget]) { return modelsByTarget[selectedTarget]; } - - // Default to an empty list (or the original location-based list) - // Using an empty list is safer to prevent showing models that have no data + return []; - }, [selectedTarget, modelsByTarget, viewType, availablePeakModels]); // Dependency added + }, [selectedTarget, modelsByTarget, viewType, availablePeakModels]); const availableTargetsToExpose = useMemo(() => { if (viewType === 'flu_peak') { @@ -124,7 +125,6 @@ export const ViewProvider = ({ children }) => { if (needsModelUrlUpdate) { updateDatasetParams({ models: [] }); } - // Add availableDatesToExpose to dependency array since we use it in the logic }, [isForecastPage, loading, viewType, models, availableTargets, urlManager, updateDatasetParams, selectedTarget, modelsForView, availableDatesToExpose]); useEffect(() => { @@ -150,13 +150,9 @@ export const ViewProvider = ({ children }) => { const handleLocationSelect = (newLocation) => { - if (newLocation !== APP_CONFIG.defaultLocation) { - urlManager.updateLocation(newLocation); - } else { - const newParams = new URLSearchParams(searchParams); - newParams.delete('location'); - setSearchParams(newParams, { replace: true }); - } + const currentDataset = urlManager.getDatasetFromView(viewType); + const effectiveDefault = currentDataset?.defaultLocation || APP_CONFIG.defaultLocation; + urlManager.updateLocation(newLocation, effectiveDefault); setSelectedLocation(newLocation); }; @@ -174,6 +170,23 @@ export const ViewProvider = ({ children }) => { const newDataset = urlManager.getDatasetFromView(newView); const newSearchParams = new URLSearchParams(searchParams); + + const isMovingToMetrocast = newView === 'metrocast_projs'; + + if (isMovingToMetrocast) { + const needsCityDefault = selectedLocation === APP_CONFIG.defaultLocation || selectedLocation.length === 2; + + if (needsCityDefault && newDataset?.defaultLocation) { + setSelectedLocation(newDataset.defaultLocation); + newSearchParams.delete('location'); + } + } else { + if (selectedLocation !== APP_CONFIG.defaultLocation && selectedLocation.length > 2) { + setSelectedLocation(APP_CONFIG.defaultLocation); + newSearchParams.delete('location'); + } + } + if (newView !== APP_CONFIG.defaultView || newSearchParams.toString().length > 0) { newSearchParams.set('view', newView); } else { @@ -200,7 +213,6 @@ export const ViewProvider = ({ children }) => { newSearchParams.delete('nhsn_cols'); } } else { - // Logic for staying within the same compatible dataset group if (newDataset) { newSearchParams.delete(`${newDataset.prefix}_target`); } @@ -209,7 +221,7 @@ export const ViewProvider = ({ children }) => { setViewType(newView); setSearchParams(newSearchParams, { replace: true }); - }, [viewType, searchParams, setSearchParams, urlManager]); + }, [viewType, searchParams, setSearchParams, urlManager, selectedLocation]); const contextValue = { selectedLocation, handleLocationSelect, diff --git a/app/src/hooks/useForecastData.js b/app/src/hooks/useForecastData.js index 904ae7fb..c53f4b7b 100644 --- a/app/src/hooks/useForecastData.js +++ b/app/src/hooks/useForecastData.js @@ -16,6 +16,12 @@ export const useForecastData = (location, viewType) => { const peaks = data?.peaks || null; useEffect(() => { + const isMetrocastView = viewType === 'metrocast_projs'; + const isDefaultUS = location === 'US'; + if ((isMetrocastView && isDefaultUS) || (!isMetrocastView && location === 'boulder')) { + setLoading(false); + return; + } if (!location || !viewType || viewType === 'frontpage') { setLoading(false); setError(null); @@ -36,20 +42,19 @@ export const useForecastData = (location, viewType) => { setAvailableTargets([]); try { - // Updated map to hold both the directory name and the file suffix const datasetMap = { 'fludetailed': { directory: 'flusight', suffix: 'flu' }, 'flu_projs': { directory: 'flusight', suffix: 'flu' }, 'flu_peak': { directory: 'flusight', suffix: 'flu' }, 'covid_projs': { directory: 'covid19forecasthub', suffix: 'covid19' }, 'rsv_projs': { directory: 'rsvforecasthub', suffix: 'rsv' }, - 'nhsnall': { directory: 'nhsn', suffix: 'nhsn' } + 'nhsnall': { directory: 'nhsn', suffix: 'nhsn' }, + 'metrocast_projs': {directory: 'flumetrocast', suffix: 'flu_metrocast'} }; const datasetConfig = datasetMap[viewType]; if (!datasetConfig) throw new Error(`Unknown view type: ${viewType}`); - // Build paths using the new directory and suffix properties const dataPath = getDataPath(`${datasetConfig.directory}/${location}_${datasetConfig.suffix}.json`); const metadataPath = getDataPath(`${datasetConfig.directory}/metadata.json`); @@ -88,25 +93,22 @@ export const useForecastData = (location, viewType) => { // Loop over [target, targetData] entries Object.entries(dateData).forEach(([target, targetData]) => { - // Get or create the Set for this specific target if (!modelsByTargetMap.has(target)) { modelsByTargetMap.set(target, new Set()); } const modelSetForTarget = modelsByTargetMap.get(target); - // Add all models found under this target Object.keys(targetData).forEach(model => { modelSetForTarget.add(model); }); }); }); - // Convert the Map to a plain object for React state const modelsByTargetState = {}; for (const [target, modelSet] of modelsByTargetMap.entries()) { modelsByTargetState[target] = Array.from(modelSet).sort(); } - setModelsByTarget(modelsByTargetState); // Set our new map state + setModelsByTarget(modelsByTargetState); } let targets = []; @@ -137,7 +139,7 @@ export const useForecastData = (location, viewType) => { setAvailablePeakDates(dates); const models = new Set(); - const targets = new Set(); // Also extract targets here for consistency + const targets = new Set(); Object.values(peaks).forEach(dateData => { Object.entries(dateData).forEach(([targetName, targetData]) => { targets.add(targetName); diff --git a/app/src/utils/mapUtils.js b/app/src/utils/mapUtils.js index c004dc77..424f2d56 100644 --- a/app/src/utils/mapUtils.js +++ b/app/src/utils/mapUtils.js @@ -4,7 +4,9 @@ export const targetDisplayNameMap = { 'wk inc flu hosp': 'Weekly Incident Flu Hospitalizations', 'wk inc flu prop ed visits': "Proportion of ED Visits due to Flu", 'wk inc rsv hosp': 'Weekly Incident RSV Hospitalizations', - 'wk inc rsv prop ed visits': 'Proportion of ED Visits due to RSV' + 'wk inc rsv prop ed visits': 'Proportion of ED Visits due to RSV', + 'Flu ED visits pct': 'Percent of ED Visits due to Flu', + 'ILI ED visits pct': 'Percent of ED Visits due to Influenza-like Illness' }; export const targetYAxisLabelMap = { @@ -13,7 +15,9 @@ export const targetYAxisLabelMap = { "Weekly Incident Flu Hospitalizations": 'Flu Hospitalizations', "Proportion of ED Visits due to Flu": "Proportion of ED Visits due to Flu", "Weekly Incident RSV Hospitalizations": "RSV Hospitalizations", - "Proportion of ED Visits due to RSV": 'Proportion of ED Visits due to RSV' + "Proportion of ED Visits due to RSV": 'Proportion of ED Visits due to RSV', + 'Percent of ED Visits due to Flu': '% of ED Visits', + "Percent ofED Visits due to Influenza-like Illness": '% of ED Visits' } export const nhsnTargetsToColumnsMap = { diff --git a/app/src/utils/urlManager.js b/app/src/utils/urlManager.js index 47c7b59b..b3a40c3d 100644 --- a/app/src/utils/urlManager.js +++ b/app/src/utils/urlManager.js @@ -1,5 +1,3 @@ -// src/utils/urlManager.js - import { DATASETS, APP_CONFIG } from '../config'; export class URLParameterManager { @@ -19,13 +17,12 @@ export class URLParameterManager { return null; } - // Get all parameters for a specific dataset getDatasetParams(dataset) { - if (!dataset) return {}; // Return empty object if dataset is null + if (!dataset) return {}; const prefix = dataset.prefix; const params = {}; - const currentView = this.getView(); // Get the current view type + const currentView = this.getView(); if (dataset.hasDateSelector) { const dates = this.searchParams.get(`${prefix}_dates`); @@ -37,17 +34,13 @@ export class URLParameterManager { params.models = models ? models.split(',') : []; } - // --- ADDED: Read prefixed target --- - // Check if the current view (derived from viewType) supports targets - // Assuming NHSN ('nhsnall') is the only view without targets for now if (currentView !== 'nhsnall') { const target = this.searchParams.get(`${prefix}_target`); - // Assign if found, otherwise it remains undefined in params object if (target) { params.target = target; } } - // --- END ADDED BLOCK --- + // Special case for NHSN if (dataset.shortName === 'nhsn') { @@ -71,11 +64,8 @@ export class URLParameterManager { if (dataset.hasModelSelector) { newParams.delete(`${prefix}_models`); } - - // --- ADDED: Clear prefixed target --- - // Always attempt to delete the target parameter for this dataset prefix + newParams.delete(`${prefix}_target`); - // --- END ADDED LINE --- if (dataset.shortName === 'nhsn') { newParams.delete('nhsn_columns'); @@ -84,56 +74,45 @@ export class URLParameterManager { this.setSearchParams(newParams, { replace: true }); } - // Update parameters for a dataset updateDatasetParams(dataset, newParams) { if (!dataset) { return; } const updatedParams = new URLSearchParams(this.searchParams); const prefix = dataset.prefix; - const currentView = this.getView(); // Get the current view type + const currentView = this.getView(); + - // Update dates if present and dataset supports it - // Check if 'dates' key exists in newParams before accessing it if (dataset.hasDateSelector && Object.prototype.hasOwnProperty.call(newParams, 'dates')) { if (newParams.dates && newParams.dates.length > 0) { updatedParams.set(`${prefix}_dates`, newParams.dates.join(',')); } else { - updatedParams.delete(`${prefix}_dates`); // Delete if empty array or null/undefined + updatedParams.delete(`${prefix}_dates`); } } - // Update models if present and dataset supports it - // Check if 'models' key exists in newParams before accessing it if (dataset.hasModelSelector && Object.prototype.hasOwnProperty.call(newParams, 'models')) { if (newParams.models && newParams.models.length > 0) { updatedParams.set(`${prefix}_models`, newParams.models.join(',')); } else { - updatedParams.delete(`${prefix}_models`); // Delete if empty array or null/undefined + updatedParams.delete(`${prefix}_models`); } } - // --- ADDED: Update prefixed target --- - // Update target if present (and not NHSN view) - // Check if 'target' key exists in newParams before accessing it + if (currentView !== 'nhsnall' && Object.prototype.hasOwnProperty.call(newParams, 'target')) { - if (newParams.target) { // Check if target is truthy (not null, '', etc.) + if (newParams.target) { updatedParams.set(`${prefix}_target`, newParams.target); } else { - // Delete the parameter if target is explicitly set to null/undefined/'' updatedParams.delete(`${prefix}_target`); } } - // --- END ADDED BLOCK --- - - // Special case for NHSN columns - // Check if 'columns' key exists in newParams before accessing it if (dataset.shortName === 'nhsn' && Object.prototype.hasOwnProperty.call(newParams, 'columns')) { if (newParams.columns && newParams.columns.length > 0) { updatedParams.set('nhsn_columns', newParams.columns.join(',')); } else { - updatedParams.delete('nhsn_columns'); // Delete if empty array or null/undefined + updatedParams.delete('nhsn_columns'); } } @@ -144,14 +123,16 @@ export class URLParameterManager { } - // Update location parameter while preserving all other params - updateLocation(location) { + updateLocation(location, effectiveDefault = APP_CONFIG.defaultLocation) { const newParams = new URLSearchParams(this.searchParams); - if (location && location !== APP_CONFIG.defaultLocation) { + + // If the location matches the specific default for this view, remove it from URL + if (location && location !== effectiveDefault) { newParams.set('location', location); } else { - newParams.delete('location'); // Remove if default location or falsy + newParams.delete('location'); } + if (newParams.toString() !== this.searchParams.toString()) { this.setSearchParams(newParams, { replace: true }); } @@ -164,7 +145,6 @@ export class URLParameterManager { // Get current view from URL getView() { - // Use DATASETS config to find the default view if not in URL const viewParam = this.searchParams.get('view'); const allViews = Object.values(DATASETS).flatMap(ds => ds.views.map(v => v.value)); if (viewParam) { @@ -181,10 +161,6 @@ export class URLParameterManager { const defaultDatasetKey = APP_CONFIG.defaultDataset; return DATASETS[defaultDatasetKey]?.defaultView; } - - // Initialize URL with defaults if missing (Less critical now with context handling) - // This method is kept for backward compatibility but may be redundant with ViewContext initializeDefaults() { - // Intentionally empty - URL initialization now handled by ViewContext } } diff --git a/scripts/helper.py b/scripts/helper.py index 26b2070c..46f8bf22 100644 --- a/scripts/helper.py +++ b/scripts/helper.py @@ -77,7 +77,7 @@ def hubverse_df_preprocessor(df: pd.DataFrame, filter_quantiles: bool = True, fi def get_location_info( location_data: pd.DataFrame, location: str, - value_needed: Literal['abbreviation', 'location_name', 'population'] + value_needed: Literal['abbreviation', 'location_name', 'population', 'original_location_code'], ) -> str: """ Get a variety of location metadata information given the FIPS code of a location. @@ -85,7 +85,7 @@ def get_location_info( Args: location_data: The df of location metadata location: FIPS code for location for which info will be retrieved ('US' for US) - value_needed: Which piece of info to retrieve (one of 'abbreviation', 'location_name', 'population') + value_needed: Which piece of info to retrieve (one of 'abbreviation', 'location_name', 'population', 'original_location_code') Returns: The value requested (as a str) @@ -94,17 +94,39 @@ def get_location_info( ValueError: If the location FIPS code provided via `location` param is not in the location metadata """ + # The FIPS codes are unnecessary for respi needs (metrocast non-state locs use HSA id) + # But Respi DOES require that they be unique, and the default code value for metrocast states is 'All' + # So here we choose to use state FIPS codes instead of 'All' to make them keys + metrocast_states_to_fips = { + "colorado": "08", + "georgia": "13", + "indiana": "18", + "maine": "23", + "maryland": "24", + "massachusetts": "25", + "minnesota": "27", + "north-carolina": "37", + "oregon": "41", + "south-carolina": "45", + "texas": "OVERLAP-WITH-frederick_md", # putting this b/c the Texas FIPS is the same as Frederick, MD HSAid (48) + "utah": "49", + "virginia": "51" + } current_df = location_data[location_data['location'] == location] if current_df.empty: raise ValueError(f"Could not find location {location} in location data.") if value_needed == 'population': return int(current_df[value_needed].iloc[0]) + if (value_needed == 'original_location_code') and (location in metrocast_states_to_fips.keys()): + try: return metrocast_states_to_fips[location] + except KeyError: + raise KeyError(f"Flu MetroCast has added a new state {location}. Update `metrocast_states_to_fips` to include this state.") else: return str(current_df[value_needed].iloc[0]) def save_json_file( - pathogen: Literal['flusight','rsv','covid','covid19','rsvforecasthub','covid19forecasthub','nhsn'], + pathogen: Literal['flusight', 'flu', 'flusightforecasthub', 'rsv','covid','covid19','rsvforecasthub','covid19forecasthub','nhsn', 'flumetrocast', 'flumetrocasthub'], output_path: str, output_filename: str, file_contents: dict, @@ -127,12 +149,15 @@ def save_json_file( output_dir_map = { 'flu': 'flusight', 'flusight': 'flusight', + 'flusightforecasthub': 'flusight', 'rsv': 'rsvforecasthub', 'rsvforecasthub': 'rsvforecasthub', 'covid': 'covid19forecasthub', 'covid19': 'covid19forecasthub', 'covid19forecasthub': 'covid19forecasthub', 'nhsn': 'nhsn', + 'flumetrocast': 'flumetrocast', + 'flumetrocashtub': 'flumetrocast', } if pathogen not in output_dir_map: diff --git a/scripts/hub_dataset_processor.py b/scripts/hub_dataset_processor.py index c7f6ddd4..a9ac4dfb 100644 --- a/scripts/hub_dataset_processor.py +++ b/scripts/hub_dataset_processor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Dict, Any, Optional, Tuple import logging +import datetime import pandas as pd @@ -40,12 +41,16 @@ def __init__( locations_data: pd.DataFrame, target_data: pd.DataFrame, config: HubDatasetConfig, + is_metro_cast: bool = False ) -> None: self.output_dict: Dict[str, Dict[str, Any]] = {} self.df_data = data self.locations_data = locations_data self.target_data = target_data self.config = config + self.is_metro_cast = is_metro_cast + if self.is_metro_cast: # necessary date filter for metrocast data + self.df_data = self.df_data[self.df_data['reference_date'] >= datetime.date(2025, 11, 19)] self.logger = logging.getLogger(self.__class__.__name__) self.location_dataframes: Dict[str, pd.DataFrame] = {} @@ -73,9 +78,12 @@ def _build_outputs(self) -> None: loc_df = loc_df.copy() self.location_dataframes[loc_str] = loc_df - location_abbreviation = get_location_info( - location_data=self.locations_data, location=loc_str, value_needed="abbreviation" - ) + if self.is_metro_cast: + location_abbreviation = loc_df['location'].iloc[0] + else: + location_abbreviation = get_location_info( + location_data=self.locations_data, location=loc_str, value_needed="abbreviation" + ) file_name = f"{location_abbreviation}_{self.config.file_suffix}.json" ground_truth_df = self._prepare_ground_truth_df(location=loc_str) @@ -102,28 +110,52 @@ def _build_outputs(self) -> None: def _build_metadata_key(self, df: pd.DataFrame) -> Dict[str, Any]: """Build metadata section of an individual JSON file.""" location = str(df["location"].iloc[0]) - metadata = { - "location": location, - "abbreviation": get_location_info( - self.locations_data, location=location, value_needed="abbreviation" - ), - "location_name": get_location_info( - self.locations_data, location=location, value_needed="location_name" - ), - "population": get_location_info( - self.locations_data, location=location, value_needed="population" - ), - "dataset": self.config.dataset_label, - "series_type": self.config.series_type, - "hubverse_keys": { - "models": self._build_available_models_list(df=df), - "targets": list(dict.fromkeys(df["target"])), - "horizons": [str(h) for h in pd.unique(df["horizon"])], - "output_types": [ - item for item in pd.unique(df["output_type"]) if item not in self.config.drop_output_types - ], - }, - } + if self.is_metro_cast: # location.csv slightly different for MetroCast, requires different metadata building + metadata = { + "location": get_location_info( + self.locations_data, location=location, value_needed="original_location_code" + ), + "abbreviation": location, + "location_name": get_location_info( + self.locations_data, location=location, value_needed="location_name" + ), + "population": get_location_info( + self.locations_data, location=location, value_needed="population" + ), + "dataset": self.config.dataset_label, + "series_type": self.config.series_type, + "hubverse_keys": { + "models": self._build_available_models_list(df=df), + "targets": list(dict.fromkeys(df["target"])), + "horizons": [str(h) for h in pd.unique(df["horizon"])], + "output_types": [ + item for item in pd.unique(df["output_type"]) if item not in self.config.drop_output_types + ], + }, + } + else: + metadata = { + "location": location, + "abbreviation": get_location_info( + self.locations_data, location=location, value_needed="abbreviation" + ), + "location_name": get_location_info( + self.locations_data, location=location, value_needed="location_name" + ), + "population": get_location_info( + self.locations_data, location=location, value_needed="population" + ), + "dataset": self.config.dataset_label, + "series_type": self.config.series_type, + "hubverse_keys": { + "models": self._build_available_models_list(df=df), + "targets": list(dict.fromkeys(df["target"])), + "horizons": [str(h) for h in pd.unique(df["horizon"])], + "output_types": [ + item for item in pd.unique(df["output_type"]) if item not in self.config.drop_output_types + ], + }, + } return metadata def _prepare_ground_truth_df(self, location: str) -> pd.DataFrame: @@ -305,13 +337,24 @@ def _build_metadata_file(self, all_models: list[str]) -> Dict[str, Any]: "models": sorted(all_models), "locations": [], } - for _, row in self.locations_data.iterrows(): - location_info = { - "location": str(row["location"]), - "abbreviation": str(row["abbreviation"]), - "location_name": str(row["location_name"]), - "population": None if row["population"] is None else float(row["population"]), - } - metadata_file_contents["locations"].append(location_info) + if self.is_metro_cast: # different building for metrocast (stems from locations.csv structure) + for _, row in self.locations_data.iterrows(): + file_name = str(row["location"]) + "_flu_metrocast.json" + location_info = { + "location": self.output_dict[file_name]["metadata"]["location"], + "abbreviation": str(row["location"]), + "location_name": str(row["location_name"]), + "population": None if row["population"] is None else float(row["population"]), + } + metadata_file_contents["locations"].append(location_info) + else: + for _, row in self.locations_data.iterrows(): + location_info = { + "location": str(row["location"]), + "abbreviation": str(row["abbreviation"]), + "location_name": str(row["location_name"]), + "population": None if row["population"] is None else float(row["population"]), + } + metadata_file_contents["locations"].append(location_info) return metadata_file_contents diff --git a/scripts/process_RespiLens_data.py b/scripts/process_RespiLens_data.py index 8e3c9cf0..de064faf 100644 --- a/scripts/process_RespiLens_data.py +++ b/scripts/process_RespiLens_data.py @@ -10,7 +10,7 @@ from hubdata.create_target_data_schema import TargetType -from processors import FlusightDataProcessor, RSVDataProcessor, COVIDDataProcessor +from processors import FlusightDataProcessor, RSVDataProcessor, COVIDDataProcessor, FluMetrocastDataProcessor from nhsn_data_processor import NHSNDataProcessor from helper import save_json_file, hubverse_df_preprocessor, clean_nan_values @@ -39,13 +39,17 @@ def main(): type=str, required=False, help="Absolute path to local clone of COVID19 forecast repo.") + parser.add_argument("--flu-metrocast-hub-path", + type=str, + required=False, + help="Absolute path to local clone of flu-metrocast repo.") parser.add_argument("--NHSN", action='store_true', required=False, help="If set, pull NHSN data.") args = parser.parse_args() - if not (args.flusight_hub_path or args.rsv_hub_path or args.covid_hub_path or args.NHSN): + if not (args.flusight_hub_path or args.rsv_hub_path or args.covid_hub_path or args.NHSN or args.flu_metrocast_hub_path): print("🛑 No hub paths or NHSN flag provided 🛑, so no data will be fetched.") print("Please re-run script with hub path(s) specified or NHSN flag set.") sys.exit(1) @@ -136,6 +140,34 @@ def main(): overwrite=True ) logger.info("Success ✅") + + if args.flu_metrocast_hub_path: + # Use HubdataPy to get all flu-metrocast data in one df + logger.info("Establishing conneciton to local flu metrocast repository...") + flu_metrocast_hub_conn = connect_hub(args.flu_metrocast_hub_path) + logger.info("Success ✅") + logger.info("Collecting data from flu metrocast repo...") + flu_metrocast_hubverse_df = clean_nan_values(hubverse_df_preprocessor(df=flu_metrocast_hub_conn.get_dataset().to_table().to_pandas(), filter_nowcasts=True)) + flu_metrocast_locations_data = clean_nan_values(pd.read_csv(Path(args.flu_metrocast_hub_path) / 'auxiliary-data/locations.csv')) + flu_metrocast_target_data = clean_nan_values(connect_target_data(hub_path=args.flu_metrocast_hub_path, target_type=TargetType.TIME_SERIES).to_table().to_pandas()) + logger.info("Success ✅") + # Initialize converter oject + flu_metrocast_processor_object = FluMetrocastDataProcessor( + data=flu_metrocast_hubverse_df, + locations_data=flu_metrocast_locations_data, + target_data=flu_metrocast_target_data + ) + # Iteratively save output files + logger.info("Saving flu metrocast JSON files...") + for filename, contents in flu_metrocast_processor_object.output_dict.items(): + save_json_file( + pathogen='flumetrocast', + output_path=args.output_path, + output_filename=filename, + file_contents=contents, + overwrite=True + ) + logger.info("Success ✅") if args.NHSN: NHSN_processor_object = NHSNDataProcessor(resource_id='ua7e-t2fy', replace_column_names=True) diff --git a/scripts/processors/__init__.py b/scripts/processors/__init__.py index 4185d883..1564eca9 100644 --- a/scripts/processors/__init__.py +++ b/scripts/processors/__init__.py @@ -3,5 +3,6 @@ from .flusight import FlusightDataProcessor from .rsv_forecast_hub import RSVDataProcessor from .covid19_forecast_hub import COVIDDataProcessor +from .flu_metrocast_hub import FluMetrocastDataProcessor -__all__ = ["FlusightDataProcessor", "RSVDataProcessor", "COVIDDataProcessor"] +__all__ = ["FlusightDataProcessor", "RSVDataProcessor", "COVIDDataProcessor", "FluMetrocastDataProcessor"] diff --git a/scripts/processors/flu_metrocast_hub.py b/scripts/processors/flu_metrocast_hub.py new file mode 100644 index 00000000..9b0100a5 --- /dev/null +++ b/scripts/processors/flu_metrocast_hub.py @@ -0,0 +1,22 @@ +"""RespiLens processor for flu Metrocast Hubverse exports.""" + +import pandas as pd + +from hub_dataset_processor import HubDataProcessorBase, HubDatasetConfig + + +class FluMetrocastDataProcessor(HubDataProcessorBase): + def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data: pd.DataFrame): + config = HubDatasetConfig( + file_suffix="flu_metrocast", + dataset_label="flu metrocast forecasts", + ground_truth_date_column="target_end_date", + ground_truth_min_date=pd.Timestamp("2025-11-19"), + ) + super().__init__( + data=data, + locations_data=locations_data, + target_data=target_data, + config=config, + is_metro_cast=True + ) \ No newline at end of file diff --git a/update_all_data_source.sh b/update_all_data_source.sh index c723f0fe..5e28a377 100755 --- a/update_all_data_source.sh +++ b/update_all_data_source.sh @@ -11,6 +11,7 @@ repos=( "FluSight-forecast-hub|https://github.com/cdcepi/FluSight-forecast-hub.git" "rsv-forecast-hub|https://github.com/CDCgov/rsv-forecast-hub.git" "covid19-forecast-hub|https://github.com/CDCgov/covid19-forecast-hub.git" + "flu-metrocast|https://github.com/reichlab/flu-metrocast.git" ) for entry in "${repos[@]}"; do @@ -34,4 +35,5 @@ python scripts/process_RespiLens_data.py \ --flusight-hub-path "${SCRIPT_DIR}/FluSight-forecast-hub" \ --rsv-hub-path "${SCRIPT_DIR}/rsv-forecast-hub" \ --covid-hub-path "${SCRIPT_DIR}/covid19-forecast-hub" \ + --flu-metrocast-hub-path "${SCRIPT_DIR}/flu-metrocast" \ --NHSN