From ea52ad71987b32f93a8d0a4c0299456d7171c54b Mon Sep 17 00:00:00 2001 From: Rostyslav Zatserkovnyi Date: Thu, 4 Jan 2024 17:12:09 +0200 Subject: [PATCH 01/48] Replace deprecated pkg_resources --- _delphi_utils_python/delphi_utils/geomap.py | 4 ++-- _delphi_utils_python/setup.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index f43b80504..56ec4eded 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -12,7 +12,7 @@ from collections import defaultdict import pandas as pd -import pkg_resources +import importlib_resources from pandas.api.types import is_string_dtype @@ -138,7 +138,7 @@ def __init__(self, census_year=2020): self._geo_sets[geo_type] = self._load_geo_values(geo_type) def _load_crosswalk_from_file(self, from_code, to_code, data_path): - stream = pkg_resources.resource_stream(__name__, data_path) + stream = importlib_resources.files(__name__).joinpath(data_path) dtype = { from_code: str, to_code: str, diff --git a/_delphi_utils_python/setup.py b/_delphi_utils_python/setup.py index b3aa86358..eebdd44c0 100644 --- a/_delphi_utils_python/setup.py +++ b/_delphi_utils_python/setup.py @@ -11,6 +11,7 @@ "epiweeks", "freezegun", "gitpython", + "importlib_resources==6.1.1", "mock", "moto", "numpy", From 3ae53ad82af14808c24bc7fb10db534b8b9a2153 Mon Sep 17 00:00:00 2001 From: Rostyslav Zatserkovnyi Date: Tue, 23 Jan 2024 20:05:10 +0200 Subject: [PATCH 02/48] Less strict version --- _delphi_utils_python/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_delphi_utils_python/setup.py b/_delphi_utils_python/setup.py index eebdd44c0..9560feb71 100644 --- a/_delphi_utils_python/setup.py +++ b/_delphi_utils_python/setup.py @@ -11,7 +11,7 @@ "epiweeks", "freezegun", "gitpython", - "importlib_resources==6.1.1", + "importlib_resources>=1.3", "mock", "moto", "numpy", From 20693a2f8122fe98ab53bf444a8648428e3b5311 Mon Sep 17 00:00:00 2001 From: dsweber2 Date: Wed, 24 Apr 2024 17:29:43 -0500 Subject: [PATCH 03/48] fix: errors in build-container-images.yml * pin to rocker/tidyverse:4.2 * update Dockerfile to use pak and rspm for package installation * add workflow_dispatch button --- .github/workflows/build-container-images.yml | 5 +++-- backfill_corrections/Dockerfile | 17 ++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-container-images.yml b/.github/workflows/build-container-images.yml index 87d9b5446..18eaeab35 100644 --- a/.github/workflows/build-container-images.yml +++ b/.github/workflows/build-container-images.yml @@ -2,14 +2,15 @@ name: Build indicator container images and upload to registry on: push: - branches: [ main, prod ] + branches: [main, prod] + workflow_dispatch: jobs: build: runs-on: ubuntu-latest strategy: matrix: - packages: [ backfill_corrections ] + packages: [backfill_corrections] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/backfill_corrections/Dockerfile b/backfill_corrections/Dockerfile index 8d2bc8ea2..6d862508b 100644 --- a/backfill_corrections/Dockerfile +++ b/backfill_corrections/Dockerfile @@ -1,7 +1,7 @@ FROM gurobi/optimizer:9.5.1 as gurobi ## Install R and tidyverse -FROM rocker/tidyverse:latest +FROM rocker/tidyverse:4.2 WORKDIR /opt/gurobi COPY --from=gurobi /opt/gurobi . @@ -15,22 +15,17 @@ ENV LD_LIBRARY_PATH $GUROBI_HOME/lib RUN ln -s -f /usr/share/zoneinfo/America/New_York /etc/localtime RUN apt-get update && apt-get install -qq -y \ - libglpk-dev\ + apt-file \ python3-venv \ python3-dev \ python3-pip -RUN install2.r --error \ - roxygen2 \ - Rglpk \ - argparser - +RUN R -e 'install.packages("pak", repos = sprintf("https://r-lib.github.io/p/pak/stable/%s/%s/%s", .Platform$pkgType, R.Version()$os, R.Version()$arch))' +RUN R -e 'install.packages(c("rspm"))' RUN --mount=type=secret,id=GITHUB_TOKEN \ export GITHUB_PAT="$(cat /run/secrets/GITHUB_TOKEN)" && \ - R -e 'devtools::install_version("bettermc", version = "1.1.2")' && \ - R -e 'devtools::install_github("cmu-delphi/covidcast", ref = "evalcast", subdir = "R-packages/evalcast")' && \ - R -e 'devtools::install_github(repo="ryantibs/quantgen", subdir="quantgen")' && \ - R -e 'install.packages(list.files(path="/opt/gurobi/linux64/R/", pattern="^gurobi_.*[.]tar[.]gz$", full.names = TRUE), repos=NULL)' + R -e 'rspm::enable(); pak::pkg_install(c("roxygen2", "Rglpk", "argparser", "gfkse/bettermc@v1.1.2", "cmu-delphi/covidcast/R-packages/evalcast@evalcast", "ryantibs/quantgen/quantgen"))' +RUN R -e 'install.packages(list.files(path="/opt/gurobi/linux64/R/", pattern="^gurobi_.*[.]tar[.]gz$", full.names = TRUE), repos=NULL)' WORKDIR /backfill_corrections/ ADD ./delphiBackfillCorrection ./delphiBackfillCorrection/ From 3022a9010df7b6e3878e9ff4c6ab9b84315b052d Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Wed, 24 Apr 2024 15:48:33 -0700 Subject: [PATCH 04/48] ci: update backfill-corr-ci.yml * simplify caching (r-lib/setup-r-dependencies caches by default) * switch to Ubuntu latest * keep R on 4.2 --- .github/workflows/backfill-corr-ci.yml | 42 +++++++------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/.github/workflows/backfill-corr-ci.yml b/.github/workflows/backfill-corr-ci.yml index 3143050eb..23eb8c0d1 100644 --- a/.github/workflows/backfill-corr-ci.yml +++ b/.github/workflows/backfill-corr-ci.yml @@ -10,49 +10,28 @@ name: R backfill corrections on: push: - branches: [ main, prod ] + branches: [main, prod] pull_request: - types: [ opened, synchronize, reopened, ready_for_review ] - branches: [ main, prod ] + types: [opened, synchronize, reopened, ready_for_review] + branches: [main, prod] jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest if: github.event.pull_request.draft == false - strategy: - matrix: - r-version: [4.2.1] defaults: run: working-directory: backfill_corrections/delphiBackfillCorrection steps: - - uses: actions/checkout@v2 - - name: Set up R ${{ matrix.r-version }} + - uses: actions/checkout@v4 + + - name: Set up R 4.2 uses: r-lib/actions/setup-r@v2 with: - r-version: ${{ matrix.r-version }} use-public-rspm: true - - name: Install linux dependencies - run: | - sudo apt-get install \ - libcurl4-openssl-dev \ - libgdal-dev \ - libudunits2-dev \ - libglpk-dev \ - libharfbuzz-dev \ - libfribidi-dev - - name: Get date - id: get-date - run: | - echo "::set-output name=date::$(/bin/date -u "+%Y%m%d")" - - name: Cache R packages - uses: actions/cache@v2 - with: - path: ${{ env.R_LIBS_USER }} - key: ${{ runner.os }}-r-backfillcorr-${{ steps.get-date.outputs.date }} - restore-keys: | - ${{ runner.os }}-r-backfillcorr- + r-version: 4.2 + - name: Install and cache dependencies env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -60,7 +39,8 @@ jobs: with: extra-packages: any::rcmdcheck working-directory: backfill_corrections/delphiBackfillCorrection - upgrade: 'TRUE' + upgrade: "TRUE" + - name: Check package uses: r-lib/actions/check-r-package@v2 with: From 8afaa63fc43164ecc94067be89f8f75fff40dde9 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Fri, 3 May 2024 11:44:50 -0700 Subject: [PATCH 05/48] refactor(geomap): add type hints, refactor test_archive --- .../data_proc/geomap/geo_data_proc.py | 7 +- _delphi_utils_python/delphi_utils/geomap.py | 102 +++++++++++------- _delphi_utils_python/tests/test_archive.py | 40 +++---- _delphi_utils_python/tests/testing.py | 23 ++++ 4 files changed, 102 insertions(+), 70 deletions(-) create mode 100644 _delphi_utils_python/tests/testing.py diff --git a/_delphi_utils_python/data_proc/geomap/geo_data_proc.py b/_delphi_utils_python/data_proc/geomap/geo_data_proc.py index 287667812..c2a07a78f 100755 --- a/_delphi_utils_python/data_proc/geomap/geo_data_proc.py +++ b/_delphi_utils_python/data_proc/geomap/geo_data_proc.py @@ -12,7 +12,6 @@ from os import remove, listdir from os.path import join, isfile from zipfile import ZipFile -from pandas.core.frame import DataFrame import requests import pandas as pd @@ -70,8 +69,8 @@ def create_fips_zip_crosswalk(): # Find the population fractions (the heaviest computation, takes about a minute) # Note that the denominator in the fractions is the source population pop_df.set_index(["fips", "zip"], inplace=True) - fips_zip: DataFrame = pop_df.groupby("fips", as_index=False).apply(lambda g: g["pop"] / g["pop"].sum()) - zip_fips: DataFrame = pop_df.groupby("zip", as_index=False).apply(lambda g: g["pop"] / g["pop"].sum()) + fips_zip: pd.DataFrame = pop_df.groupby("fips", as_index=False).apply(lambda g: g["pop"] / g["pop"].sum()) + zip_fips: pd.DataFrame = pop_df.groupby("zip", as_index=False).apply(lambda g: g["pop"] / g["pop"].sum()) # Rename and write to file fips_zip = fips_zip.reset_index(level=["fips", "zip"]).rename(columns={"pop": "weight"}).query("weight > 0.0") @@ -228,7 +227,7 @@ def create_state_population_table(): derive_fips_state_crosswalk() census_pop = pd.read_csv(join(OUTPUT_DIR, FIPS_POPULATION_OUT_FILENAME), dtype={"fips": str, "pop": int}) - state: DataFrame = pd.read_csv(join(OUTPUT_DIR, FIPS_STATE_OUT_FILENAME), dtype=str) + state: pd.DataFrame = pd.read_csv(join(OUTPUT_DIR, FIPS_STATE_OUT_FILENAME), dtype=str) state_pop = state.merge(census_pop, on="fips").groupby(["state_code", "state_id", "state_name"], as_index=False).sum() state_pop.sort_values("state_code").to_csv(join(OUTPUT_DIR, STATE_POPULATION_OUT_FILENAME), index=False) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index f43b80504..8fe7f521c 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -1,15 +1,12 @@ """Contains geographic mapping tools. Authors: Dmitry Shemetov @dshemetov, James Sharpnack @jsharpna, Maria Jahja -Created: 2020-06-01 - -TODO: -- use a caching utility to store the crossfiles - see: https://github.com/cmu-delphi/covidcast-indicators/issues/282 """ + # pylint: disable=too-many-lines from os.path import join from collections import defaultdict +from typing import Iterator, List, Literal, Optional, Set, Union import pandas as pd import pkg_resources @@ -106,7 +103,7 @@ class GeoMapper: # pylint: disable=too-many-public-methods "nation": {"pop": "nation_pop.csv"}, } - def __init__(self, census_year=2020): + def __init__(self, census_year: int = 2020): """Initialize geomapper. Parameters @@ -137,7 +134,9 @@ def __init__(self, census_year=2020): for geo_type in self._geos: self._geo_sets[geo_type] = self._load_geo_values(geo_type) - def _load_crosswalk_from_file(self, from_code, to_code, data_path): + def _load_crosswalk_from_file( + self, from_code: str, to_code: str, data_path: str + ) -> pd.DataFrame: stream = pkg_resources.resource_stream(__name__, data_path) dtype = { from_code: str, @@ -167,7 +166,9 @@ def _load_geo_values(self, geo_type): return set(crosswalk[geo_type]) @staticmethod - def convert_fips_to_mega(data, fips_col="fips", mega_col="megafips"): + def convert_fips_to_mega( + data: pd.DataFrame, fips_col: str = "fips", mega_col: str = "megafips" + ) -> pd.DataFrame: """Convert fips or chng-fips string to a megafips string.""" data = data.copy() data[mega_col] = data[fips_col].astype(str).str.zfill(5) @@ -176,14 +177,14 @@ def convert_fips_to_mega(data, fips_col="fips", mega_col="megafips"): @staticmethod def megacounty_creation( - data, - thr_count, - thr_win_len, - thr_col="visits", - fips_col="fips", - date_col="timestamp", - mega_col="megafips", - ): + data: pd.DataFrame, + thr_count: Union[float, int], + thr_win_len: int, + thr_col: str = "visits", + fips_col: str = "fips", + date_col: str = "timestamp", + mega_col: str = "megafips", + ) -> pd.DataFrame: """Create megacounty column. Parameters @@ -205,7 +206,7 @@ def megacounty_creation( if "_thr_col_roll" in data.columns: raise ValueError("Column name '_thr_col_roll' is reserved.") - def agg_sum_iter(data): + def agg_sum_iter(data: pd.DataFrame) -> Iterator[pd.DataFrame]: data_gby = ( data[[fips_col, date_col, thr_col]] .set_index(date_col) @@ -228,7 +229,13 @@ def agg_sum_iter(data): # Conversion functions def add_geocode( - self, df, from_code, new_code, from_col=None, new_col=None, dropna=True + self, + df: pd.DataFrame, + from_code: str, + new_code: str, + from_col: Optional[str] = None, + new_col: Optional[str] = None, + dropna: bool = True, ): """Add a new geocode column to a dataframe. @@ -316,7 +323,9 @@ def add_geocode( return df - def _add_nation_geocode(self, df, from_code, from_col, new_col): + def _add_nation_geocode( + self, df: pd.DataFrame, from_code: str, from_col: str, new_col: str + ) -> pd.DataFrame: """Add a nation geocode column to a dataframe. See `add_geocode()` documentation for argument description. @@ -334,15 +343,15 @@ def _add_nation_geocode(self, df, from_code, from_col, new_col): def replace_geocode( self, - df, - from_code, - new_code, - from_col=None, - new_col=None, - date_col="timestamp", - data_cols=None, - dropna=True, - ): + df: pd.DataFrame, + from_code: str, + new_code: str, + from_col: Optional[str] = None, + new_col: Optional[str] = None, + date_col: Optional[str] = "timestamp", + data_cols: Optional[List[str]] = None, + dropna: bool = True, + ) -> pd.DataFrame: """Replace a geocode column in a dataframe. Currently supported conversions: @@ -403,7 +412,13 @@ def replace_geocode( df = df.groupby([new_col]).sum(numeric_only=True).reset_index() return df - def add_population_column(self, data, geocode_type, geocode_col=None, dropna=True): + def add_population_column( + self, + data: pd.DataFrame, + geocode_type: Literal["fips", "zip"], + geocode_col: Optional[str] = None, + dropna: bool = True, + ) -> pd.DataFrame: """ Append a population column to a dataframe, based on the FIPS or ZIP code. @@ -451,15 +466,15 @@ def add_population_column(self, data, geocode_type, geocode_col=None, dropna=Tru @staticmethod def fips_to_megacounty( - data, - thr_count, - thr_win_len, - thr_col="visits", - fips_col="fips", - date_col="timestamp", - mega_col="megafips", + data: pd.DataFrame, + thr_count: Union[float, int], + thr_win_len: int, + thr_col: str = "visits", + fips_col: str = "fips", + date_col: str = "timestamp", + mega_col: str = "megafips", count_cols=None, - ): + ) -> pd.DataFrame: """Convert and aggregate from FIPS or chng-fips to megaFIPS. Parameters @@ -501,7 +516,7 @@ def fips_to_megacounty( data = data.reset_index().groupby([date_col, mega_col]).sum(numeric_only=True) return data.reset_index() - def as_mapper_name(self, geo_type, state="state_id"): + def as_mapper_name(self, geo_type: str, state: str = "state_id") -> str: """ Return the mapper equivalent of a region type. @@ -513,7 +528,7 @@ def as_mapper_name(self, geo_type, state="state_id"): return "fips" return geo_type - def get_crosswalk(self, from_code, to_code): + def get_crosswalk(self, from_code: str, to_code: str) -> pd.DataFrame: """Return a dataframe mapping the given geocodes. Parameters @@ -532,7 +547,7 @@ def get_crosswalk(self, from_code, to_code): except KeyError as e: raise ValueError(f'Mapping from "{from_code}" to "{to_code}" not found.') from e - def get_geo_values(self, geo_type): + def get_geo_values(self, geo_type: str) -> Set[str]: """ Return a set of all values for a given geography type. @@ -551,7 +566,12 @@ def get_geo_values(self, geo_type): except KeyError as e: raise ValueError(f'Given geo type "{geo_type}" not found') from e - def get_geos_within(self, container_geocode, contained_geocode_type, container_geocode_type): + def get_geos_within( + self, + container_geocode: str, + contained_geocode_type: str, + container_geocode_type: str, + ) -> Set[str]: """ Return all contained regions of the given type within the given container geocode. diff --git a/_delphi_utils_python/tests/test_archive.py b/_delphi_utils_python/tests/test_archive.py index e821e011b..589b55513 100644 --- a/_delphi_utils_python/tests/test_archive.py +++ b/_delphi_utils_python/tests/test_archive.py @@ -2,10 +2,11 @@ from io import StringIO, BytesIO from os import listdir, mkdir from os.path import join -from typing import Any, Dict, List +from typing import Dict, List from boto3 import Session -from git import Repo, exc +from git import Repo +from git.exc import InvalidGitRepositoryError import mock from moto import mock_s3 import numpy as np @@ -16,6 +17,7 @@ from delphi_utils.archive import ArchiveDiffer, GitArchiveDiffer, S3ArchiveDiffer,\ archiver_from_params from delphi_utils.nancodes import Nans +from testing import set_df_dtypes CSV_DTYPES = { "geo_id": str, "val": float, "se": float, "sample_size": float, @@ -26,20 +28,12 @@ class Example: def __init__(self, before, after, diff): def fix_df(df): if isinstance(df, pd.DataFrame): - return Example._set_df_datatypes(df, CSV_DTYPES) + return set_df_dtypes(df, CSV_DTYPES) return df self.before = fix_df(before) self.after = fix_df(after) self.diff = fix_df(diff) - @staticmethod - def _set_df_datatypes(df: pd.DataFrame, dtypes: Dict[str, Any]) -> pd.DataFrame: - df = df.copy() - for k, v in dtypes.items(): - if k in df.columns: - df[k] = df[k].astype(v) - return df - @dataclass class Expecteds: deleted: List[str] @@ -194,9 +188,6 @@ def __post_init__(self): assert set(EXPECTEDS.new) == set(f"{csv_name}.csv" for csv_name, dfs in CSVS.items() if dfs.before is None), \ "Bad programmer: added more new files to CSVS.after without updating EXPECTEDS.new" -def _assert_frames_equal_ignore_row_order(df1, df2, index_cols: List[str] = None): - return assert_frame_equal(df1.set_index(index_cols).sort_index(), df2.set_index(index_cols).sort_index()) - class ArchiveDifferTestlike: def set_up(self, tmp_path): cache_dir = join(str(tmp_path), "cache") @@ -209,10 +200,10 @@ def check_filtered_exports(self, export_dir): assert set(listdir(export_dir)) == set(EXPECTEDS.filtered_exports) for f in EXPECTEDS.filtered_exports: example = CSVS[f.replace(".csv", "")] - _assert_frames_equal_ignore_row_order( - pd.read_csv(join(export_dir, f), dtype=CSV_DTYPES), - example.after if example.diff is None else example.diff, - index_cols=["geo_id"] + example = example.after if example.diff is None else example.diff + assert_frame_equal( + pd.read_csv(join(export_dir, f), dtype=CSV_DTYPES).sort_values("geo_id", ignore_index=True), + example.sort_values("geo_id", ignore_index=True) ) class TestArchiveDiffer(ArchiveDifferTestlike): @@ -264,14 +255,13 @@ def test_diff_and_filter_exports(self, tmp_path): # Check that the diff files look as expected for key, diff_name in EXPECTEDS.common_diffs.items(): - if diff_name is None: continue - _assert_frames_equal_ignore_row_order( - pd.read_csv(join(export_dir, diff_name), dtype=CSV_DTYPES), - CSVS[key.replace(".csv", "")].diff, - index_cols=["geo_id"] + if diff_name is None: + continue + assert_frame_equal( + pd.read_csv(join(export_dir, diff_name), dtype=CSV_DTYPES).sort_values("geo_id", ignore_index=True), + CSVS[key.replace(".csv", "")].diff.sort_values("geo_id", ignore_index=True) ) - # Test filter_exports # =================== @@ -406,7 +396,7 @@ def test_init_args(self, tmp_path): GitArchiveDiffer(cache_dir, export_dir, override_dirty=False, commit_partial_success=True) - with pytest.raises(exc.InvalidGitRepositoryError): + with pytest.raises(InvalidGitRepositoryError): GitArchiveDiffer(cache_dir, export_dir) repo = Repo.init(cache_dir) diff --git a/_delphi_utils_python/tests/testing.py b/_delphi_utils_python/tests/testing.py new file mode 100644 index 000000000..7e8f55e90 --- /dev/null +++ b/_delphi_utils_python/tests/testing.py @@ -0,0 +1,23 @@ +"""Common utilities for testing functions.""" +from typing import Any, Dict +import pandas as pd + + +def check_valid_dtype(dtype): + """Check if a dtype is a valid Pandas type.""" + try: + pd.api.types.pandas_dtype(dtype) + except TypeError as e: + raise ValueError(f"Invalid dtype {dtype}") from e + + +def set_df_dtypes(df: pd.DataFrame, dtypes: Dict[str, Any]) -> pd.DataFrame: + """Set the dataframe column datatypes.""" + for d in dtypes.values(): + check_valid_dtype(d) + + df = df.copy() + for k, v in dtypes.items(): + if k in df.columns: + df[k] = df[k].astype(v) + return df From d4b056e7a4c11982324e9224c9f9f6fd5d5ec65c Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Fri, 3 May 2024 11:45:07 -0700 Subject: [PATCH 06/48] lint: format geomap.py with black --- _delphi_utils_python/delphi_utils/geomap.py | 97 +++++++++++---------- pyproject.toml | 4 + 2 files changed, 57 insertions(+), 44 deletions(-) create mode 100644 pyproject.toml diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index 8fe7f521c..29ae3667e 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -76,7 +76,7 @@ class GeoMapper: # pylint: disable=too-many-public-methods "msa": "zip_msa_table.csv", "pop": "zip_pop.csv", "state": "zip_state_code_table.csv", - "hhs": "zip_hhs_table.csv" + "hhs": "zip_hhs_table.csv", }, "fips": { "chng-fips": "fips_chng-fips_table.csv", @@ -87,19 +87,12 @@ class GeoMapper: # pylint: disable=too-many-public-methods "state": "fips_state_table.csv", "hhs": "fips_hhs_table.csv", }, + "hhs": {"pop": "hhs_pop.csv"}, "chng-fips": {"state": "chng-fips_state_table.csv"}, "state": {"state": "state_codes_table.csv"}, - "state_code": { - "hhs": "state_code_hhs_table.csv", - "pop": "state_pop.csv" - }, - "state_id": { - "pop": "state_pop.csv" - }, - "state_name": { - "pop": "state_pop.csv" - }, - "hhs": {"pop": "hhs_pop.csv"}, + "state_code": {"hhs": "state_code_hhs_table.csv", "pop": "state_pop.csv"}, + "state_id": {"pop": "state_pop.csv"}, + "state_name": {"pop": "state_pop.csv"}, "nation": {"pop": "nation_pop.csv"}, } @@ -117,19 +110,16 @@ def __init__(self, census_year: int = 2020): # Include all unique geos from first-level and second-level keys in # CROSSWALK_FILENAMES, with a few exceptions self._geos = { - subkey for mainkey in self.CROSSWALK_FILENAMES - for subkey in self.CROSSWALK_FILENAMES[mainkey] - }.union( - set(self.CROSSWALK_FILENAMES.keys()) - ) - set(["state", "pop"]) + subkey + for mainkey in self.CROSSWALK_FILENAMES + for subkey in self.CROSSWALK_FILENAMES[mainkey] + }.union(set(self.CROSSWALK_FILENAMES.keys())) - set(["state", "pop"]) for from_code, to_codes in self.CROSSWALK_FILENAMES.items(): for to_code, file_path in to_codes.items(): - self._crosswalks[from_code][to_code] = \ - self._load_crosswalk_from_file(from_code, - to_code, - join(f"data/{census_year}", file_path) - ) + self._crosswalks[from_code][to_code] = self._load_crosswalk_from_file( + from_code, to_code, join(f"data/{census_year}", file_path) + ) for geo_type in self._geos: self._geo_sets[geo_type] = self._load_geo_values(geo_type) @@ -143,13 +133,13 @@ def _load_crosswalk_from_file( to_code: str, "pop": int, "weight": float, - **{geo: str for geo in self._geos - set("nation")} + **{geo: str for geo in self._geos - set("nation")}, } usecols = [from_code, "pop"] if to_code == "pop" else None return pd.read_csv(stream, dtype=dtype, usecols=usecols) - def _load_geo_values(self, geo_type): + def _load_geo_values(self, geo_type: str) -> Set[str]: if geo_type == "nation": return {"us"} @@ -276,8 +266,9 @@ def add_geocode( df = df.copy() from_col = from_code if from_col is None else from_col new_col = new_code if new_col is None else new_col - assert from_col != new_col, \ - f"Can't use the same column '{from_col}' for both from_col and to_col" + assert ( + from_col != new_col + ), f"Can't use the same column '{from_col}' for both from_col and to_col" state_codes = ["state_code", "state_id", "state_name"] if not is_string_dtype(df[from_col]): @@ -337,7 +328,7 @@ def _add_nation_geocode( return df raise ValueError( - f"Conversion to the nation level is not supported " + "Conversion to the nation level is not supported " f"from {from_code}; try {valid_from_codes}" ) @@ -443,7 +434,15 @@ def add_population_column( """ geocode_col = geocode_type if geocode_col is None else geocode_col data = data.copy() - supported_geos = ["fips", "zip", "state_id", "state_name", "state_code", "hhs", "nation"] + supported_geos = [ + "fips", + "zip", + "state_id", + "state_name", + "state_code", + "hhs", + "nation", + ] if geocode_type not in supported_geos: raise ValueError( f"Only {supported_geos} geocodes supported. For other codes, aggregate those." @@ -457,11 +456,9 @@ def add_population_column( else: data[geocode_col] = data[geocode_col].astype(str) merge_type = "inner" if dropna else "left" - data_with_pop = ( - data - .merge(pop_df, left_on=geocode_col, right_on=geocode_type, how=merge_type) - .rename(columns={"pop": "population"}) - ) + data_with_pop = data.merge( + pop_df, left_on=geocode_col, right_on=geocode_type, how=merge_type + ).rename(columns={"pop": "population"}) return data_with_pop @staticmethod @@ -545,7 +542,9 @@ def get_crosswalk(self, from_code: str, to_code: str) -> pd.DataFrame: try: return self._crosswalks[from_code][to_code] except KeyError as e: - raise ValueError(f'Mapping from "{from_code}" to "{to_code}" not found.') from e + raise ValueError( + f'Mapping from "{from_code}" to "{to_code}" not found.' + ) from e def get_geo_values(self, geo_type: str) -> Set[str]: """ @@ -601,20 +600,30 @@ def get_geos_within( if contained_geocode_type == "state": if container_geocode_type == "nation" and container_geocode == "us": crosswalk = self._crosswalks["state"]["state"] - return set(crosswalk["state_id"]) # pylint: disable=unsubscriptable-object + return set(crosswalk["state_id"]) # pylint: disable=unsubscriptable-object if container_geocode_type == "hhs": crosswalk_hhs = self._crosswalks["fips"]["hhs"] crosswalk_state = self._crosswalks["fips"]["state"] - fips_hhs = crosswalk_hhs[crosswalk_hhs["hhs"] == container_geocode]["fips"] - return set(crosswalk_state[crosswalk_state["fips"].isin(fips_hhs)]["state_id"]) - elif (contained_geocode_type in ("county", "fips", "chng-fips") and - container_geocode_type == "state"): + fips_hhs = crosswalk_hhs[crosswalk_hhs["hhs"] == container_geocode][ + "fips" + ] + return set( + crosswalk_state[crosswalk_state["fips"].isin(fips_hhs)]["state_id"] + ) + elif ( + contained_geocode_type in ("county", "fips", "chng-fips") + and container_geocode_type == "state" + ): contained_geocode_type = self.as_mapper_name(contained_geocode_type) crosswalk = self._crosswalks[contained_geocode_type]["state"] return set( - crosswalk[crosswalk["state_id"] == container_geocode][contained_geocode_type] + crosswalk[crosswalk["state_id"] == container_geocode][ + contained_geocode_type + ] ) - raise ValueError("(contained_geocode_type, container_geocode_type) was " - f"({contained_geocode_type}, {container_geocode_type}), but " - "must be one of (state, nation), (state, hhs), (county, state)" - ", (fips, state), (chng-fips, state)") + raise ValueError( + "(contained_geocode_type, container_geocode_type) was " + f"({contained_geocode_type}, {container_geocode_type}), but " + "must be one of (state, nation), (state, hhs), (county, state)" + ", (fips, state), (chng-fips, state)" + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..9a31b63a0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.black] +line-length = 120 +target-version = ['py38'] +include = '_delphi_utils_python' From 677131e5d079c8fa8ed77e855c81265d23671e11 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Fri, 3 May 2024 11:46:29 -0700 Subject: [PATCH 07/48] repo: ignore format commit blame --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..f91c04645 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Format geomap.py with black +d4b056e7a4c11982324e9224c9f9f6fd5d5ec65c From ff2d3413ab202782ec1ec5dfff4a449af8f78a43 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Mon, 6 May 2024 15:32:59 -0700 Subject: [PATCH 08/48] lint+doc: update geomap docs and minor lint --- .../data_proc/geomap/README.md | 31 ++-- .../data_proc/geomap/geo_data_proc.py | 8 +- _delphi_utils_python/delphi_utils/geomap.py | 135 ++++++++++-------- 3 files changed, 90 insertions(+), 84 deletions(-) diff --git a/_delphi_utils_python/data_proc/geomap/README.md b/_delphi_utils_python/data_proc/geomap/README.md index 08075fff9..38297b691 100644 --- a/_delphi_utils_python/data_proc/geomap/README.md +++ b/_delphi_utils_python/data_proc/geomap/README.md @@ -1,4 +1,4 @@ -# Geocoding data processing pipeline +# Geocoding Data Processing Authors: Jingjing Tang, James Sharpnack, Dmitry Shemetov @@ -7,42 +7,37 @@ Authors: Jingjing Tang, James Sharpnack, Dmitry Shemetov Requires the following source files below. Run the following to build the crosswalk tables in `covidcast-indicators/_delph_utils_python/delph_utils/data` -``` + +```sh $ python geo_data_proc.py ``` -You can see consistency checks and diffs with old sources in ./consistency_checks.ipynb +Find data consistency checks in `./source-file-sanity-check.ipynb`. ## Geo Codes We support the following geocodes. -- The ZIP code and the FIPS code are the most granular geocodes we support. - - The [ZIP code](https://en.wikipedia.org/wiki/ZIP_Code) is a US postal code used by the USPS and the [FIPS code](https://en.wikipedia.org/wiki/FIPS_county_code) is an identifier for US counties and other associated territories. The ZIP code is five digit code (with leading zeros). - - The FIPS code is a five digit code (with leading zeros), where the first two digits are a two-digit state code and the last three are a three-digit county code (see this [US Census Bureau page](https://www.census.gov/library/reference/code-lists/ansi.html) for detailed information). -- The Metropolitan Statistical Area (MSA) code refers to regions around cities (these are sometimes referred to as CBSA codes). More information on these can be found at the [US Census Bureau](https://www.census.gov/programs-surveys/metro-micro/about.html). - - We are reserving 10001-10099 for states codes of the form 100XX where XX is the FIPS code for the state (the current smallest CBSA is 10100). In the case that the CBSA codes change then it should be verified that these are not used. +- The [ZIP code](https://en.wikipedia.org/wiki/ZIP_Code) is a US postal code used by the USPS and the [FIPS code](https://en.wikipedia.org/wiki/FIPS_county_code) is an identifier for US counties and other associated territories. The ZIP code is five digit code (with leading zeros). +- The FIPS code is a five digit code (with leading zeros), where the first two digits are a two-digit state code and the last three are a three-digit county code (see this [US Census Bureau page](https://www.census.gov/library/reference/code-lists/ansi.html) for detailed information). +- The Metropolitan Statistical Area (MSA) code refers to regions around cities (these are sometimes referred to as CBSA codes). More information on these can be found at the [US Census Bureau](https://www.census.gov/programs-surveys/metro-micro/about.html). We rserve 10001-10099 for states codes of the form 100XX where XX is the FIPS code for the state (the current smallest CBSA is 10100). In the case that the CBSA codes change then it should be verified that these are not used. - State codes are a series of equivalent identifiers for US state. They include the state name, the state number (state_id), and the state two-letter abbreviation (state_code). The state number is the state FIPS code. See [here](https://en.wikipedia.org/wiki/List_of_U.S._state_and_territory_abbreviations) for more. - The Hospital Referral Region (HRR) and the Hospital Service Area (HSA). More information [here](https://www.dartmouthatlas.org/covid-19/hrr-mapping/). -FIPS codes depart in some special cases, so we produce manual changes listed below. -## Source files +## Source Files The source files are requested from a government URL when `geo_data_proc.py` is run (see the top of said script for the URLs). Below we describe the locations to find updated versions of the source files, if they are ever needed. - ZIP -> FIPS (county) population tables available from [US Census](https://www.census.gov/geographies/reference-files/time-series/geo/relationship-files.html#par_textimage_674173622). This file contains the population of the intersections between ZIP and FIPS regions, allowing the creation of a population-weighted transform between the two. As of 4 February 2022, this source did not include population information for 24 ZIPs that appear in our indicators. We have added those values manually using information available from the [zipdatamaps website](www.zipdatamaps.com). - ZIP -> HRR -> HSA crosswalk file comes from the 2018 version at the [Dartmouth Atlas Project](https://atlasdata.dartmouth.edu/static/supp_research_data). - FIPS -> MSA crosswalk file comes from the September 2018 version of the delineation files at the [US Census Bureau](https://www.census.gov/geographies/reference-files/time-series/demo/metro-micro/delineation-files.html). -- State Code -> State ID -> State Name comes from the ANSI standard at the [US Census](https://www.census.gov/library/reference/code-lists/ansi.html#par_textimage_3). The first two digits of a FIPS codes should match the state code here. +- State Code -> State ID -> State Name comes from the ANSI standard at the [US Census](https://www.census.gov/library/reference/code-lists/ansi.html#par_textimage_3). - -## Derived files +## Derived Files The rest of the crosswalk tables are derived from the mappings above. We provide crosswalk functions from granular to coarser codes, but not the other way around. This is because there is no information gained when crosswalking from coarse to granular. - - -## Deprecated source files +## Deprecated Source Files - ZIP to FIPS to HRR to states: `02_20_uszips.csv` comes from a version of the table [here](https://simplemaps.com/data/us-zips) modified by Jingjing to include population weights. - The `02_20_uszips.csv` file is based on the newest consensus data including 5-digit zipcode, fips code, county name, state, population, HRR, HSA (I downloaded the original file from [here](https://simplemaps.com/data/us-zips). This file matches best to the most recent (2020) situation in terms of the population. But there still exist some matching problems. I manually checked and corrected those lines (~20) with [zip-codes](https://www.zip-codes.com/zip-code/58439/zip-code-58439.asp). The mapping from 5-digit zipcode to HRR is based on the file in 2017 version downloaded from [here](https://atlasdata.dartmouth.edu/static/supp_research_data). @@ -51,7 +46,3 @@ The rest of the crosswalk tables are derived from the mappings above. We provide - CBSA -> FIPS crosswalk from [here](https://data.nber.org/data/cbsa-fips-county-crosswalk.html) (the file is `cbsatocountycrosswalk.csv`). - MSA tables from March 2020 [here](https://www.census.gov/geographies/reference-files/time-series/demo/metro-micro/delineation-files.html). This file seems to differ in a few fips codes from the source for the 02_20_uszip file which Jingjing constructed. There are at least 10 additional fips in 03_20_msa that are not in the uszip file, and one of the msa codes seems to be incorrect: 49020 (a google search confirms that it is incorrect in uszip and correct in the census data). - MSA tables from 2019 [here](https://apps.bea.gov/regional/docs/msalist.cfm) - -## Notes - -- The NAs in the coding currently zero-fills. diff --git a/_delphi_utils_python/data_proc/geomap/geo_data_proc.py b/_delphi_utils_python/data_proc/geomap/geo_data_proc.py index c2a07a78f..5634d6f83 100755 --- a/_delphi_utils_python/data_proc/geomap/geo_data_proc.py +++ b/_delphi_utils_python/data_proc/geomap/geo_data_proc.py @@ -1,10 +1,7 @@ """ -Authors: Dmitry Shemetov @dshemetov, James Sharpnack @jsharpna - -Intended execution: +Authors: Dmitry Shemetov, James Sharpnack cd _delphi_utils/data_proc/geomap -chmod u+x geo_data_proc.py python geo_data_proc.py """ @@ -19,7 +16,7 @@ # Source files -YEAR = 2019 +YEAR = 2020 INPUT_DIR = "./old_source_files" OUTPUT_DIR = f"../../delphi_utils/data/{YEAR}" FIPS_BY_ZIP_POP_URL = "https://www2.census.gov/geo/docs/maps-data/data/rel/zcta_county_rel_10.txt?#" @@ -41,7 +38,6 @@ FIPS_HHS_FILENAME = "fips_hhs_table.csv" FIPS_CHNGFIPS_OUT_FILENAME = "fips_chng-fips_table.csv" FIPS_POPULATION_OUT_FILENAME = "fips_pop.csv" - CHNGFIPS_STATE_OUT_FILENAME = "chng-fips_state_table.csv" ZIP_HSA_OUT_FILENAME = "zip_hsa_table.csv" ZIP_HRR_OUT_FILENAME = "zip_hrr_table.csv" diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index 29ae3667e..c928a2bf5 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -18,54 +18,89 @@ class GeoMapper: # pylint: disable=too-many-public-methods The GeoMapper class provides utility functions for translating between different geocodes. Supported geocodes: - - zip: zip5, a length 5 str of 0-9 with leading 0's - - fips: state code and county code, a length 5 str of 0-9 with leading 0's - - msa: metropolitan statistical area, a length 5 str of 0-9 with leading 0's - - state_code: state code, a str of 0-9 - - state_id: state id, a str of A-Z - - hrr: hospital referral region, an int 1-500 - - Mappings: - - [x] zip -> fips : population weighted - - [x] zip -> hrr : unweighted - - [x] zip -> msa : unweighted - - [x] zip -> state - - [x] zip -> hhs - - [x] zip -> population - - [x] state code -> hhs - - [x] fips -> state : unweighted - - [x] fips -> msa : unweighted - - [x] fips -> megacounty - - [x] fips -> hrr - - [x] fips -> hhs - - [x] fips -> chng-fips - - [x] chng-fips -> state : unweighted - - [x] nation - - [ ] zip -> dma (postponed) - - The GeoMapper instance loads crosswalk tables from the package data_dir. The - crosswalk tables are assumed to have been built using the geo_data_proc.py script - in data_proc/geomap. If a mapping between codes is NOT one to many, then the table has - just two colums. If the mapping IS one to many, then a third column, the weight column, - exists (e.g. zip, fips, weight; satisfying (sum(weights) where zip==ZIP) == 1). + + - zip: five characters [0-9] with leading 0's, e.g. "33626" + also known as zip5 or zip code + - fips: five characters [0-9] with leading 0's, e.g. "12057" + the first two digits are the state FIPS code and the last + three are the county FIPS code + - msa: five characters [0-9] with leading 0's, e.g. "90001" + also known as metropolitan statistical area + - state_code: two characters [0-9], e.g "06" + - state_id: two characters [A-Z], e.g "CA" + - state_name: human-readable name, e.g "California" + - hrr: an integer from 1-500, also known as hospital + referral region + - hhs: an integer from 1-10, also known as health and human services region + https://www.hhs.gov/about/agencies/iea/regional-offices/index.html + + Valid mappings: + + From To Population Weighted + zip fips Yes + zip hrr No + zip msa Yes + zip state_* Yes + zip hhs Yes + zip population -- + zip nation No + state_* state_* No + state_* hhs No + state_* population -- + state_* nation No + fips state_* No + fips msa No + fips megacounty No + fips hrr Yes + fips hhs No + fips chng-fips No + fips nation No + chng-fips state_* No + + Crosswalk Tables + ================ + + The GeoMapper instance loads pre-generated crosswalk tables (built by the + script in `data_proc/geomap/geo_data_proc.py`). If a mapping between codes + is one to one or many to one, then the table has just two columns. If the + mapping is one to many, then a weight column is provided, which gives the + fractional population contribution of a source_geo to the target_geo. The + weights satisfy the condition that df.groupby(from_code).sum(weight) == 1.0 + for all values of from_code. + + Aggregation + =========== + + The GeoMapper class provides functions to aggregate data from one geocode + to another. The aggregation can be a simple one-to-one mapping or a + weighted aggregation. The weighted aggregation is useful when the data + being aggregated is a population-weighted quantity, such as visits or + cases. The aggregation is done by multiplying the data columns by the + weights and summing over the data columns. Note that the aggregation does + not adjust the aggregation for missing or NA values in the data columns, + which is equivalent to a zero-fill. Example Usage - ========== + ============= The main GeoMapper object loads and stores crosswalk dataframes on-demand. - When replacing geocodes with a new one an aggregation step is performed on the data columns - to merge entries (i.e. in the case of a many to one mapping or a weighted mapping). This - requires a specification of the data columns, which are assumed to be all the columns that - are not the geocodes or the date column specified in date_col. + When replacing geocodes with a new one an aggregation step is performed on + the data columns to merge entries (i.e. in the case of a many to one + mapping or a weighted mapping). This requires a specification of the data + columns, which are assumed to be all the columns that are not the geocodes + or the date column specified in date_col. Example 1: to add a new column with a new geocode, possibly with weights: > gmpr = GeoMapper() - > df = gmpr.add_geocode(df, "fips", "zip", from_col="fips", new_col="geo_id", + > df = gmpr.add_geocode(df, "fips", "zip", + from_col="fips", new_col="geo_id", date_col="timestamp", dropna=False) - Example 2: to replace a geocode column with a new one, aggregating the data with weights: + Example 2: to replace a geocode column with a new one, aggregating the data + with weights: > gmpr = GeoMapper() - > df = gmpr.replace_geocode(df, "fips", "zip", from_col="fips", new_col="geo_id", + > df = gmpr.replace_geocode(df, "fips", "zip", + from_col="fips", new_col="geo_id", date_col="timestamp", dropna=False) """ @@ -113,7 +148,7 @@ def __init__(self, census_year: int = 2020): subkey for mainkey in self.CROSSWALK_FILENAMES for subkey in self.CROSSWALK_FILENAMES[mainkey] - }.union(set(self.CROSSWALK_FILENAMES.keys())) - set(["state", "pop"]) + }.union(set(self.CROSSWALK_FILENAMES.keys())) - {"state", "pop"} for from_code, to_codes in self.CROSSWALK_FILENAMES.items(): for to_code, file_path in to_codes.items(): @@ -135,7 +170,6 @@ def _load_crosswalk_from_file( "weight": float, **{geo: str for geo in self._geos - set("nation")}, } - usecols = [from_code, "pop"] if to_code == "pop" else None return pd.read_csv(stream, dtype=dtype, usecols=usecols) @@ -229,13 +263,6 @@ def add_geocode( ): """Add a new geocode column to a dataframe. - Currently supported conversions: - - fips -> state_code, state_id, state_name, zip, msa, hrr, nation, hhs, chng-fips - - chng-fips -> state_code, state_id, state_name - - zip -> state_code, state_id, state_name, fips, msa, hrr, nation, hhs - - state_x -> state_y (where x and y are in {code, id, name}), nation - - state_code -> hhs, nation - Parameters --------- df: pd.DataFrame @@ -303,7 +330,7 @@ def add_geocode( df = df.merge(crosswalk, left_on=from_col, right_on=from_col, how="left") # Drop extra state columns - if new_code in state_codes and not from_code in state_codes: + if new_code in state_codes and from_code not in state_codes: state_codes.remove(new_code) df.drop(columns=state_codes, inplace=True) elif new_code in state_codes and from_code in state_codes: @@ -345,13 +372,6 @@ def replace_geocode( ) -> pd.DataFrame: """Replace a geocode column in a dataframe. - Currently supported conversions: - - fips -> chng-fips, state_code, state_id, state_name, zip, msa, hrr, nation - - chng-fips -> state_code, state_id, state_name - - zip -> state_code, state_id, state_name, fips, msa, hrr, nation - - state_x -> state_y (where x and y are in {code, id, name}), nation - - state_code -> hhs, nation - Parameters --------- df: pd.DataFrame @@ -397,7 +417,7 @@ def replace_geocode( df[data_cols] = df[data_cols].multiply(df["weight"], axis=0) df.drop("weight", axis=1, inplace=True) - if not date_col is None: + if date_col is not None: df = df.groupby([date_col, new_col]).sum(numeric_only=True).reset_index() else: df = df.groupby([new_col]).sum(numeric_only=True).reset_index() @@ -575,8 +595,7 @@ def get_geos_within( Return all contained regions of the given type within the given container geocode. Given container_geocode (e.g "ca" for California) of type container_geocode_type - (e.g "state"), return: - - all (contained_geocode_type)s within container_geocode + (e.g "state"), return all (contained_geocode_type)s within container_geocode. Supports these 4 combinations: - all states within a nation From a7fbb3e744a5f5850d993b5d69bb97e895603c4e Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Mon, 6 May 2024 15:33:15 -0700 Subject: [PATCH 09/48] feat(geomap): add aggregate_by_weighted_sum --- _delphi_utils_python/delphi_utils/geomap.py | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index c928a2bf5..1007d1ff6 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -646,3 +646,46 @@ def get_geos_within( "must be one of (state, nation), (state, hhs), (county, state)" ", (fips, state), (chng-fips, state)" ) + + def aggregate_by_weighted_sum( + self, df: pd.DataFrame, to_geo: str, sensor: str, population_column: str + ) -> pd.DataFrame: + """Aggregate sensor, weighted by time-dependent population. + + Note: This function generates its own population weights and adjusts the + weights based on which data is NA. This is in contrast to the + `replace_geocode` function, which assumes that the weights are already + present in the data and does not adjust for missing data (see the + docstring for the GeoMapper class). + + Parameters + --------- + df: pd.DataFrame + Input dataframe, assumed to have a sensor column (e.g. "visits"), a + to_geo column (e.g. "state"), and a population column (corresponding + to a from_geo, e.g. "wastewater collection site"). + to_geo: str + The column name of the geocode to aggregate to. + sensor: str + The column name of the sensor to aggregate. + population_column: str + The column name of the population to weight the sensor by. + + Returns + --------- + agg_df: pd.DataFrame + A dataframe with the aggregated sensor values, weighted by population. + """ + # Zero-out populations where the sensor is NA + df[f"relevant_pop_{sensor}"] = df[population_column] * df[sensor].abs().notna() + # Weight the sensor by the population + df[f"weighted_{sensor}"] = df[sensor] * df[f"relevant_pop_{sensor}"] + agg_df = df.groupby(["timestamp", to_geo]).agg( + { + f"relevant_pop_{sensor}": "sum", + f"weighted_{sensor}": lambda x: x.sum(min_count=1), + } + ) + agg_df["val"] = agg_df[f"weighted_{sensor}"] / agg_df[f"relevant_pop_{sensor}"] + agg_df = agg_df.reset_index() + return agg_df From 7359bf9281ba1479894ef89a42545c9a5b8dc2af Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Tue, 7 May 2024 10:17:39 -0700 Subject: [PATCH 10/48] Update _delphi_utils_python/delphi_utils/geomap.py --- _delphi_utils_python/delphi_utils/geomap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index 1007d1ff6..bb5f7da83 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -29,6 +29,7 @@ class GeoMapper: # pylint: disable=too-many-public-methods - state_code: two characters [0-9], e.g "06" - state_id: two characters [A-Z], e.g "CA" - state_name: human-readable name, e.g "California" + - state_*: we use this below to refer to the three above geocodes in aggregate - hrr: an integer from 1-500, also known as hospital referral region - hhs: an integer from 1-10, also known as health and human services region From c249a3ab8df436a0652225a9d11e3288207305a1 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Tue, 7 May 2024 10:19:02 -0700 Subject: [PATCH 11/48] Update _delphi_utils_python/delphi_utils/geomap.py --- _delphi_utils_python/delphi_utils/geomap.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index bb5f7da83..da8cbe772 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -263,6 +263,8 @@ def add_geocode( dropna: bool = True, ): """Add a new geocode column to a dataframe. + + See class docstring for supported geocode transformations. Parameters --------- From 577a41ef89997554db99f38e96bca168e625eec2 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Tue, 7 May 2024 10:19:39 -0700 Subject: [PATCH 12/48] Update _delphi_utils_python/delphi_utils/geomap.py --- _delphi_utils_python/delphi_utils/geomap.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index da8cbe772..8d746ce54 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -374,6 +374,8 @@ def replace_geocode( dropna: bool = True, ) -> pd.DataFrame: """Replace a geocode column in a dataframe. + + See class docstring for supported geocode transformations. Parameters --------- From 912f58dbf964686b62bfed65f0c28d3ab6650174 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Tue, 7 May 2024 10:23:22 -0700 Subject: [PATCH 13/48] Update _delphi_utils_python/delphi_utils/geomap.py --- _delphi_utils_python/delphi_utils/geomap.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index 8d746ce54..d3a150f37 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -657,11 +657,11 @@ def aggregate_by_weighted_sum( ) -> pd.DataFrame: """Aggregate sensor, weighted by time-dependent population. - Note: This function generates its own population weights and adjusts the - weights based on which data is NA. This is in contrast to the - `replace_geocode` function, which assumes that the weights are already - present in the data and does not adjust for missing data (see the - docstring for the GeoMapper class). + Note: This function generates its own population weights and excludes + locations where the data is NA, which is effectively an extrapolation assumption + to the rest of the geos. This is in contrast to the `replace_geocode` function, + which assumes that the weights are already present in the data and does + not adjust for missing data (see the docstring for the GeoMapper class). Parameters --------- From 511322b0358f4170733ba8bd6f9d1b5dfe069c1a Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Tue, 7 May 2024 15:47:15 -0700 Subject: [PATCH 14/48] feat(geomap): fix and test aggregate_by_weighted_sum --- _delphi_utils_python/delphi_utils/geomap.py | 37 +++++---- _delphi_utils_python/tests/test_geomap.py | 86 +++++++++++++++++++++ 2 files changed, 109 insertions(+), 14 deletions(-) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index d3a150f37..a313c754c 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -263,7 +263,7 @@ def add_geocode( dropna: bool = True, ): """Add a new geocode column to a dataframe. - + See class docstring for supported geocode transformations. Parameters @@ -374,7 +374,7 @@ def replace_geocode( dropna: bool = True, ) -> pd.DataFrame: """Replace a geocode column in a dataframe. - + See class docstring for supported geocode transformations. Parameters @@ -653,15 +653,16 @@ def get_geos_within( ) def aggregate_by_weighted_sum( - self, df: pd.DataFrame, to_geo: str, sensor: str, population_column: str + self, df: pd.DataFrame, to_geo: str, sensor_col: str, time_col: str, population_col: str ) -> pd.DataFrame: """Aggregate sensor, weighted by time-dependent population. Note: This function generates its own population weights and excludes - locations where the data is NA, which is effectively an extrapolation assumption - to the rest of the geos. This is in contrast to the `replace_geocode` function, - which assumes that the weights are already present in the data and does - not adjust for missing data (see the docstring for the GeoMapper class). + locations where the data is NA, which is effectively an extrapolation + assumption to the rest of the geos. This is in contrast to the + `replace_geocode` function, which assumes that the weights are already + present in the data and does not adjust for missing data (see the + docstring for the GeoMapper class). Parameters --------- @@ -681,16 +682,24 @@ def aggregate_by_weighted_sum( agg_df: pd.DataFrame A dataframe with the aggregated sensor values, weighted by population. """ + # Don't modify the input dataframe + df = df.copy() # Zero-out populations where the sensor is NA - df[f"relevant_pop_{sensor}"] = df[population_column] * df[sensor].abs().notna() + df["_zeroed_pop"] = df[population_col] * df[sensor_col].abs().notna() # Weight the sensor by the population - df[f"weighted_{sensor}"] = df[sensor] * df[f"relevant_pop_{sensor}"] - agg_df = df.groupby(["timestamp", to_geo]).agg( + df["_weighted_sensor"] = df[sensor_col] * df["_zeroed_pop"] + agg_df = ( + df.groupby([time_col, to_geo]) + .agg( { - f"relevant_pop_{sensor}": "sum", - f"weighted_{sensor}": lambda x: x.sum(min_count=1), + "_zeroed_pop": "sum", + "_weighted_sensor": lambda x: x.sum(min_count=1), } + ).assign( + _new_sensor = lambda x: x["_weighted_sensor"] / x["_zeroed_pop"] + ).reset_index() + .rename(columns={"_new_sensor": f"weighted_{sensor_col}"}) + .drop(columns=["_zeroed_pop", "_weighted_sensor"]) ) - agg_df["val"] = agg_df[f"weighted_{sensor}"] / agg_df[f"relevant_pop_{sensor}"] - agg_df = agg_df.reset_index() + return agg_df diff --git a/_delphi_utils_python/tests/test_geomap.py b/_delphi_utils_python/tests/test_geomap.py index ab86c143d..f29cb9b65 100644 --- a/_delphi_utils_python/tests/test_geomap.py +++ b/_delphi_utils_python/tests/test_geomap.py @@ -395,3 +395,89 @@ def test_census_year_pop(self, geomapper, geomapper_2019): df = pd.DataFrame({"fips": ["01001"]}) assert geomapper.add_population_column(df, "fips").population[0] == 56145 assert geomapper_2019.add_population_column(df, "fips").population[0] == 55869 + + def test_aggregate_by_weighted_sum(self, geomapper: GeoMapper): + df = pd.DataFrame( + { + "timestamp": [0] * 7, + "state": ["al", "al", "ca", "ca", "nd", "me", "me"], + "a": [1, 2, 3, 4, 12, -2, 2], + "b": [5, 6, 7, np.nan, np.nan, -1, -2], + "population_served": [10, 5, 8, 1, 3, 1, 2], + } + ) + agg_df = geomapper.aggregate_by_weighted_sum( + df, + to_geo="state", + sensor_col="a", + time_col = "timestamp", + population_col="population_served" + ) + agg_df_by_hand = pd.DataFrame( + { + "timestamp": [0] * 4, + "state": ["al", "ca", "me", "nd"], + "weighted_a": [ + (1 * 10 + 2 * 5) / 15, + (3 * 8 + 4 * 1) / 9, + (-2 * 1 + 2 * 2) / 3, + (12 * 3) / 3, + ] + } + ) + pd.testing.assert_frame_equal(agg_df, agg_df_by_hand) + agg_df = geomapper.aggregate_by_weighted_sum( + df, + to_geo="state", + sensor_col="b", + time_col = "timestamp", + population_col="population_served" + ) + agg_df_by_hand = pd.DataFrame( + { + "timestamp": [0] * 4, + "state": ["al", "ca", "me", "nd"], + "weighted_b": [ + (5 * 10 + 6 * 5) / 15, + (7 * 8 + 4 * 0) / 8, + (-1 * 1 + -2 * 2) / 3, + (np.nan) / 3, + ] + } + ) + pd.testing.assert_frame_equal(agg_df, agg_df_by_hand) + + df = pd.DataFrame( + { + "state": [ + "al", + "al", + "ca", + "ca", + "nd", + ], + "nation": ["us"] * 5, + "timestamp": [0] * 3 + [1] * 2, + "a": [1, 2, 3, 4, 12], + "b": [5, 6, 7, np.nan, np.nan], + "population_served": [10, 5, 8, 1, 3], + } + ) + agg_df = geomapper.aggregate_by_weighted_sum( + df, + to_geo="nation", + sensor_col="a", + time_col = "timestamp", + population_col="population_served" + ) + agg_df_by_hand = pd.DataFrame( + { + "timestamp": [0, 1], + "nation": ["us"] * 2, + "weighted_a": [ + (1 * 10 + 2 * 5 + 3 * 8) / 23, + (1 * 4 + 3 * 12) / 4 + ] + } + ) + pd.testing.assert_frame_equal(agg_df, agg_df_by_hand) From 79072dcdec3faca9aaeeea65de83f7fa5c00d53f Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Tue, 7 May 2024 15:55:07 -0700 Subject: [PATCH 15/48] lint: format test_geomap --- _delphi_utils_python/tests/test_geomap.py | 206 +++++++++++++++------- 1 file changed, 139 insertions(+), 67 deletions(-) diff --git a/_delphi_utils_python/tests/test_geomap.py b/_delphi_utils_python/tests/test_geomap.py index f29cb9b65..c968fd359 100644 --- a/_delphi_utils_python/tests/test_geomap.py +++ b/_delphi_utils_python/tests/test_geomap.py @@ -10,10 +10,12 @@ def geomapper(): return GeoMapper(census_year=2020) + @pytest.fixture(scope="class") def geomapper_2019(): return GeoMapper(census_year=2019) + class TestGeoMapper: fips_data = pd.DataFrame( { @@ -34,7 +36,8 @@ class TestGeoMapper: fips_data_3 = pd.DataFrame( { "fips": ["48059", "48253", "48441", "72003", "72005", "10999"], - "timestamp": [pd.Timestamp("2018-01-01")] * 3 + [pd.Timestamp("2018-01-03")] * 3, + "timestamp": [pd.Timestamp("2018-01-01")] * 3 + + [pd.Timestamp("2018-01-03")] * 3, "count": [1, 2, 3, 4, 8, 5], "total": [2, 4, 7, 11, 100, 10], } @@ -58,7 +61,8 @@ class TestGeoMapper: zip_data = pd.DataFrame( { "zip": ["45140", "95616", "95618"] * 2, - "timestamp": [pd.Timestamp("2018-01-01")] * 3 + [pd.Timestamp("2018-01-03")] * 3, + "timestamp": [pd.Timestamp("2018-01-01")] * 3 + + [pd.Timestamp("2018-01-03")] * 3, "count": [99, 345, 456, 100, 344, 442], } ) @@ -132,7 +136,7 @@ class TestGeoMapper: ) # Loading tests updated 8/26 - def test_crosswalks(self, geomapper): + def test_crosswalks(self, geomapper: GeoMapper): # These tests ensure that the one-to-many crosswalks have properly normalized weights # FIPS -> HRR is allowed to be an incomplete mapping, since only a fraction of a FIPS # code can not belong to an HRR @@ -152,33 +156,32 @@ def test_crosswalks(self, geomapper): cw = geomapper.get_crosswalk(from_code="zip", to_code="hhs") assert cw.groupby("zip")["weight"].sum().round(5).eq(1.0).all() - - def test_load_zip_fips_table(self, geomapper): + def test_load_zip_fips_table(self, geomapper: GeoMapper): fips_data = geomapper.get_crosswalk(from_code="zip", to_code="fips") assert set(fips_data.columns) == set(["zip", "fips", "weight"]) assert pd.api.types.is_string_dtype(fips_data.zip) assert pd.api.types.is_string_dtype(fips_data.fips) assert pd.api.types.is_float_dtype(fips_data.weight) - def test_load_state_table(self, geomapper): + def test_load_state_table(self, geomapper: GeoMapper): state_data = geomapper.get_crosswalk(from_code="state", to_code="state") assert tuple(state_data.columns) == ("state_code", "state_id", "state_name") assert state_data.shape[0] == 60 - def test_load_fips_msa_table(self, geomapper): + def test_load_fips_msa_table(self, geomapper: GeoMapper): msa_data = geomapper.get_crosswalk(from_code="fips", to_code="msa") assert tuple(msa_data.columns) == ("fips", "msa") - def test_load_fips_chngfips_table(self, geomapper): + def test_load_fips_chngfips_table(self, geomapper: GeoMapper): chngfips_data = geomapper.get_crosswalk(from_code="fips", to_code="chng-fips") assert tuple(chngfips_data.columns) == ("fips", "chng-fips") - def test_load_zip_hrr_table(self, geomapper): + def test_load_zip_hrr_table(self, geomapper: GeoMapper): zip_data = geomapper.get_crosswalk(from_code="zip", to_code="hrr") assert pd.api.types.is_string_dtype(zip_data["zip"]) assert pd.api.types.is_string_dtype(zip_data["hrr"]) - def test_megacounty(self, geomapper): + def test_megacounty(self, geomapper: GeoMapper): new_data = geomapper.fips_to_megacounty(self.mega_data, 6, 50) assert ( new_data[["count", "visits"]].sum() @@ -204,12 +207,18 @@ def test_megacounty(self, geomapper): "count": [8, 7, 3, 10021], } ) - pd.testing.assert_frame_equal(new_data.set_index("megafips").sort_index(axis=1), expected_df.set_index("megafips").sort_index(axis=1)) + pd.testing.assert_frame_equal( + new_data.set_index("megafips").sort_index(axis=1), + expected_df.set_index("megafips").sort_index(axis=1), + ) # chng-fips should have the same behavior when converting to megacounties. mega_county_groups = self.mega_data_3.copy() - mega_county_groups.fips.replace({1125:"01g01"}, inplace = True) + mega_county_groups.fips.replace({1125: "01g01"}, inplace=True) new_data = geomapper.fips_to_megacounty(self.mega_data_3, 4, 1) - pd.testing.assert_frame_equal(new_data.set_index("megafips").sort_index(axis=1), expected_df.set_index("megafips").sort_index(axis=1)) + pd.testing.assert_frame_equal( + new_data.set_index("megafips").sort_index(axis=1), + expected_df.set_index("megafips").sort_index(axis=1), + ) new_data = geomapper.fips_to_megacounty(self.mega_data_3, 4, 1, thr_col="count") expected_df = pd.DataFrame( @@ -220,14 +229,20 @@ def test_megacounty(self, geomapper): "count": [6, 5, 7, 10021], } ) - pd.testing.assert_frame_equal(new_data.set_index("megafips").sort_index(axis=1), expected_df.set_index("megafips").sort_index(axis=1)) + pd.testing.assert_frame_equal( + new_data.set_index("megafips").sort_index(axis=1), + expected_df.set_index("megafips").sort_index(axis=1), + ) # chng-fips should have the same behavior when converting to megacounties. mega_county_groups = self.mega_data_3.copy() - mega_county_groups.fips.replace({1123:"01g01"}, inplace = True) + mega_county_groups.fips.replace({1123: "01g01"}, inplace=True) new_data = geomapper.fips_to_megacounty(self.mega_data_3, 4, 1, thr_col="count") - pd.testing.assert_frame_equal(new_data.set_index("megafips").sort_index(axis=1), expected_df.set_index("megafips").sort_index(axis=1)) + pd.testing.assert_frame_equal( + new_data.set_index("megafips").sort_index(axis=1), + expected_df.set_index("megafips").sort_index(axis=1), + ) - def test_add_population_column(self, geomapper): + def test_add_population_column(self, geomapper: GeoMapper): new_data = geomapper.add_population_column(self.fips_data_3, "fips") assert new_data.shape == (5, 5) new_data = geomapper.add_population_column(self.zip_data, "zip") @@ -245,14 +260,18 @@ def test_add_population_column(self, geomapper): new_data = geomapper.add_population_column(self.nation_data, "nation") assert new_data.shape == (1, 3) - def test_add_geocode(self, geomapper): + def test_add_geocode(self, geomapper: GeoMapper): # state_code -> nation new_data = geomapper.add_geocode(self.zip_data, "zip", "state_code") new_data2 = geomapper.add_geocode(new_data, "state_code", "nation") assert new_data2["nation"].unique()[0] == "us" new_data = geomapper.replace_geocode(self.zip_data, "zip", "state_code") - new_data2 = geomapper.add_geocode(new_data, "state_code", "state_id", new_col="state") - new_data3 = geomapper.replace_geocode(new_data2, "state_code", "nation", new_col="geo_id") + new_data2 = geomapper.add_geocode( + new_data, "state_code", "state_id", new_col="state" + ) + new_data3 = geomapper.replace_geocode( + new_data2, "state_code", "nation", new_col="geo_id" + ) assert "state" not in new_data3.columns # state_code -> hhs @@ -264,11 +283,15 @@ def test_add_geocode(self, geomapper): new_data = geomapper.replace_geocode(self.zip_data, "zip", "state_name") new_data2 = geomapper.add_geocode(new_data, "state_name", "state_id") assert new_data2.shape == (4, 5) - new_data2 = geomapper.replace_geocode(new_data, "state_name", "state_id", new_col="abbr") + new_data2 = geomapper.replace_geocode( + new_data, "state_name", "state_id", new_col="abbr" + ) assert "abbr" in new_data2.columns # fips -> nation - new_data = geomapper.replace_geocode(self.fips_data_5, "fips", "nation", new_col="NATION") + new_data = geomapper.replace_geocode( + self.fips_data_5, "fips", "nation", new_col="NATION" + ) pd.testing.assert_frame_equal( new_data, pd.DataFrame().from_dict( @@ -278,15 +301,25 @@ def test_add_geocode(self, geomapper): "count": {0: 10024.0}, "total": {0: 100006.0}, } - ) + ), ) # fips -> chng-fips new_data = geomapper.add_geocode(self.fips_data_5, "fips", "chng-fips") - assert sorted(list(new_data["chng-fips"])) == ['01123', '18181', '48g19', '72003'] + assert sorted(list(new_data["chng-fips"])) == [ + "01123", + "18181", + "48g19", + "72003", + ] assert new_data["chng-fips"].size == self.fips_data_5.fips.size new_data = geomapper.replace_geocode(self.fips_data_5, "fips", "chng-fips") - assert sorted(list(new_data["chng-fips"])) == ['01123', '18181', '48g19', '72003'] + assert sorted(list(new_data["chng-fips"])) == [ + "01123", + "18181", + "48g19", + "72003", + ] assert new_data["chng-fips"].size == self.fips_data_5.fips.size # chng-fips -> state_id @@ -294,12 +327,12 @@ def test_add_geocode(self, geomapper): new_data2 = geomapper.add_geocode(new_data, "chng-fips", "state_id") assert new_data2["state_id"].unique().size == 4 assert new_data2["state_id"].size == self.fips_data_5.fips.size - assert sorted(list(new_data2["state_id"])) == ['al', 'in', 'pr', 'tx'] + assert sorted(list(new_data2["state_id"])) == ["al", "in", "pr", "tx"] new_data2 = geomapper.replace_geocode(new_data, "chng-fips", "state_id") assert new_data2["state_id"].unique().size == 4 assert new_data2["state_id"].size == 4 - assert sorted(list(new_data2["state_id"])) == ['al', 'in', 'pr', 'tx'] + assert sorted(list(new_data2["state_id"])) == ["al", "in", "pr", "tx"] # zip -> nation new_data = geomapper.replace_geocode(self.zip_data, "zip", "nation") @@ -315,7 +348,7 @@ def test_add_geocode(self, geomapper): "count": {0: 900, 1: 886}, "total": {0: 1800, 1: 1772}, } - ) + ), ) # hrr -> nation @@ -324,53 +357,84 @@ def test_add_geocode(self, geomapper): new_data2 = geomapper.replace_geocode(new_data, "hrr", "nation") # fips -> hrr (dropna=True/False check) - assert not geomapper.add_geocode(self.fips_data_3, "fips", "hrr").isna().any().any() - assert geomapper.add_geocode(self.fips_data_3, "fips", "hrr", dropna=False).isna().any().any() + assert ( + not geomapper.add_geocode(self.fips_data_3, "fips", "hrr") + .isna() + .any() + .any() + ) + assert ( + geomapper.add_geocode(self.fips_data_3, "fips", "hrr", dropna=False) + .isna() + .any() + .any() + ) # fips -> zip (date_col=None chech) - new_data = geomapper.replace_geocode(self.fips_data_5.drop(columns=["timestamp"]), "fips", "hrr", date_col=None) + new_data = geomapper.replace_geocode( + self.fips_data_5.drop(columns=["timestamp"]), "fips", "hrr", date_col=None + ) pd.testing.assert_frame_equal( new_data, pd.DataFrame().from_dict( { - 'hrr': {0: '1', 1: '183', 2: '184', 3: '382', 4: '7'}, - 'count': {0: 1.772347174163783, 1: 7157.392403522299, 2: 2863.607596477701, 3: 1.0, 4: 0.22765282583621685}, - 'total': {0: 3.544694348327566, 1: 71424.64801363471, 2: 28576.35198636529, 3: 1.0, 4: 0.4553056516724337} + "hrr": {0: "1", 1: "183", 2: "184", 3: "382", 4: "7"}, + "count": { + 0: 1.772347174163783, + 1: 7157.392403522299, + 2: 2863.607596477701, + 3: 1.0, + 4: 0.22765282583621685, + }, + "total": { + 0: 3.544694348327566, + 1: 71424.64801363471, + 2: 28576.35198636529, + 3: 1.0, + 4: 0.4553056516724337, + }, } - ) + ), ) # fips -> hhs - new_data = geomapper.replace_geocode(self.fips_data_3.drop(columns=["timestamp"]), - "fips", "hhs", date_col=None) + new_data = geomapper.replace_geocode( + self.fips_data_3.drop(columns=["timestamp"]), "fips", "hhs", date_col=None + ) pd.testing.assert_frame_equal( new_data, pd.DataFrame().from_dict( { "hhs": {0: "2", 1: "6"}, "count": {0: 12, 1: 6}, - "total": {0: 111, 1: 13} + "total": {0: 111, 1: 13}, } - ) + ), ) # zip -> hhs new_data = geomapper.replace_geocode(self.zip_data, "zip", "hhs") - new_data = new_data.round(10) # get rid of a floating point error with 99.00000000000001 + new_data = new_data.round( + 10 + ) # get rid of a floating point error with 99.00000000000001 pd.testing.assert_frame_equal( new_data, pd.DataFrame().from_dict( { - "timestamp": {0: pd.Timestamp("2018-01-01"), 1: pd.Timestamp("2018-01-01"), - 2: pd.Timestamp("2018-01-03"), 3: pd.Timestamp("2018-01-03")}, + "timestamp": { + 0: pd.Timestamp("2018-01-01"), + 1: pd.Timestamp("2018-01-01"), + 2: pd.Timestamp("2018-01-03"), + 3: pd.Timestamp("2018-01-03"), + }, "hhs": {0: "5", 1: "9", 2: "5", 3: "9"}, "count": {0: 99.0, 1: 801.0, 2: 100.0, 3: 786.0}, - "total": {0: 198.0, 1: 1602.0, 2: 200.0, 3: 1572.0} + "total": {0: 198.0, 1: 1602.0, 2: 200.0, 3: 1572.0}, } - ) + ), ) - def test_get_geos(self, geomapper): + def test_get_geos(self, geomapper: GeoMapper): assert geomapper.get_geo_values("nation") == {"us"} assert geomapper.get_geo_values("hhs") == set(str(i) for i in range(1, 11)) assert len(geomapper.get_geo_values("fips")) == 3293 @@ -378,20 +442,31 @@ def test_get_geos(self, geomapper): assert len(geomapper.get_geo_values("state_id")) == 60 assert len(geomapper.get_geo_values("zip")) == 32976 - def test_get_geos_2019(self, geomapper_2019): + def test_get_geos_2019(self, geomapper_2019: GeoMapper): assert len(geomapper_2019.get_geo_values("fips")) == 3292 assert len(geomapper_2019.get_geo_values("chng-fips")) == 2710 - def test_get_geos_within(self, geomapper): - assert len(geomapper.get_geos_within("us","state","nation")) == 60 - assert len(geomapper.get_geos_within("al","county","state")) == 68 - assert len(geomapper.get_geos_within("al","fips","state")) == 68 - assert geomapper.get_geos_within("al","fips","state") == geomapper.get_geos_within("al","county","state") - assert len(geomapper.get_geos_within("al","chng-fips","state")) == 66 - assert len(geomapper.get_geos_within("4","state","hhs")) == 8 - assert geomapper.get_geos_within("4","state","hhs") == {'al', 'fl', 'ga', 'ky', 'ms', 'nc', "tn", "sc"} + def test_get_geos_within(self, geomapper: GeoMapper): + assert len(geomapper.get_geos_within("us", "state", "nation")) == 60 + assert len(geomapper.get_geos_within("al", "county", "state")) == 68 + assert len(geomapper.get_geos_within("al", "fips", "state")) == 68 + assert geomapper.get_geos_within( + "al", "fips", "state" + ) == geomapper.get_geos_within("al", "county", "state") + assert len(geomapper.get_geos_within("al", "chng-fips", "state")) == 66 + assert len(geomapper.get_geos_within("4", "state", "hhs")) == 8 + assert geomapper.get_geos_within("4", "state", "hhs") == { + "al", + "fl", + "ga", + "ky", + "ms", + "nc", + "tn", + "sc", + } - def test_census_year_pop(self, geomapper, geomapper_2019): + def test_census_year_pop(self, geomapper: GeoMapper, geomapper_2019: GeoMapper): df = pd.DataFrame({"fips": ["01001"]}) assert geomapper.add_population_column(df, "fips").population[0] == 56145 assert geomapper_2019.add_population_column(df, "fips").population[0] == 55869 @@ -410,8 +485,8 @@ def test_aggregate_by_weighted_sum(self, geomapper: GeoMapper): df, to_geo="state", sensor_col="a", - time_col = "timestamp", - population_col="population_served" + time_col="timestamp", + population_col="population_served", ) agg_df_by_hand = pd.DataFrame( { @@ -422,7 +497,7 @@ def test_aggregate_by_weighted_sum(self, geomapper: GeoMapper): (3 * 8 + 4 * 1) / 9, (-2 * 1 + 2 * 2) / 3, (12 * 3) / 3, - ] + ], } ) pd.testing.assert_frame_equal(agg_df, agg_df_by_hand) @@ -430,8 +505,8 @@ def test_aggregate_by_weighted_sum(self, geomapper: GeoMapper): df, to_geo="state", sensor_col="b", - time_col = "timestamp", - population_col="population_served" + time_col="timestamp", + population_col="population_served", ) agg_df_by_hand = pd.DataFrame( { @@ -442,7 +517,7 @@ def test_aggregate_by_weighted_sum(self, geomapper: GeoMapper): (7 * 8 + 4 * 0) / 8, (-1 * 1 + -2 * 2) / 3, (np.nan) / 3, - ] + ], } ) pd.testing.assert_frame_equal(agg_df, agg_df_by_hand) @@ -467,17 +542,14 @@ def test_aggregate_by_weighted_sum(self, geomapper: GeoMapper): df, to_geo="nation", sensor_col="a", - time_col = "timestamp", - population_col="population_served" + time_col="timestamp", + population_col="population_served", ) agg_df_by_hand = pd.DataFrame( { "timestamp": [0, 1], "nation": ["us"] * 2, - "weighted_a": [ - (1 * 10 + 2 * 5 + 3 * 8) / 23, - (1 * 4 + 3 * 12) / 4 - ] + "weighted_a": [(1 * 10 + 2 * 5 + 3 * 8) / 23, (1 * 4 + 3 * 12) / 4], } ) pd.testing.assert_frame_equal(agg_df, agg_df_by_hand) From 48e247fbc8e80f9196b262dfae5743ea9ee1cbe7 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Tue, 7 May 2024 15:56:10 -0700 Subject: [PATCH 16/48] repo: update blame-ignore --- .git-blame-ignore-revs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index f91c04645..904a3bf69 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,4 @@ -# Format geomap.py with black +# Format geomap.py d4b056e7a4c11982324e9224c9f9f6fd5d5ec65c +# Format test_geomap.py +79072dcdec3faca9aaeeea65de83f7fa5c00d53f \ No newline at end of file From 6bccf68be5ff8f79e125fba201235552d2c97c04 Mon Sep 17 00:00:00 2001 From: minhkhul Date: Thu, 30 May 2024 14:38:53 -0400 Subject: [PATCH 17/48] specify solver --- _delphi_utils_python/delphi_utils/weekday.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_delphi_utils_python/delphi_utils/weekday.py b/_delphi_utils_python/delphi_utils/weekday.py index ba5d75815..055812818 100644 --- a/_delphi_utils_python/delphi_utils/weekday.py +++ b/_delphi_utils_python/delphi_utils/weekday.py @@ -93,7 +93,7 @@ def _fit(X, scales, npnums, npdenoms): for scale in scales: try: prob = cp.Problem(cp.Minimize((-ll + lmbda * penalty) / scale)) - _ = prob.solve() + _ = prob.solve(solver=cp.CLARABEL) return b.value except SolverError: # If the magnitude of the objective function is too large, an error is From 74726ed959f0e130d4ceeb70f3c3cabffefaf33f Mon Sep 17 00:00:00 2001 From: minhkhul Date: Fri, 31 May 2024 15:15:53 -0400 Subject: [PATCH 18/48] weekday test change --- _delphi_utils_python/tests/test_weekday.py | 23 +++++----------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/_delphi_utils_python/tests/test_weekday.py b/_delphi_utils_python/tests/test_weekday.py index 52e6f4f7e..9b187e114 100644 --- a/_delphi_utils_python/tests/test_weekday.py +++ b/_delphi_utils_python/tests/test_weekday.py @@ -18,24 +18,11 @@ def test_get_params(self): result = Weekday.get_params(self.TEST_DATA, "den", ["num"], "date", [1], TEST_LOGGER) print(result) - expected_result = [ - -0.05993665, - -0.0727396, - -0.05618517, - 0.0343405, - 0.12534997, - 0.04561813, - -2.27669028, - -1.89564374, - -1.5695407, - -1.29838116, - -1.08216513, - -0.92089259, - -0.81456355, - -0.76317802, - -0.76673598, - -0.82523745, - ] + expected_result = np.array([[-0.05990542, -0.07272124, -0.05618539, + 0.0343087, 0.1253007, 0.04562494, + -2.27662546, -1.8956484, -1.56959677, + -1.29847058, -1.08226981, -0.92099449, + -0.81464459, -0.76322013, -0.7667211,-0.8251475]]) assert np.allclose(result, expected_result) def test_calc_adjustment_with_zero_parameters(self): From 6912077acba97e835aff7d0cd3d64309a1a9241d Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Wed, 25 Oct 2023 15:53:54 -0700 Subject: [PATCH 19/48] feat+lint+ci: unify linters, add `make format` * all linters now use the same configuration file * add `make format` command to format all code * make sure all indicators pass with the new config * update the template files as well * add a new darker format job to CI * add python package caching * update README --- .github/workflows/python-ci.yml | 49 +++++++++++++------ README.md | 41 ++++++++++++---- _delphi_utils_python/.pylintrc | 22 --------- _delphi_utils_python/Makefile | 5 +- _delphi_utils_python/delphi_utils/geomap.py | 5 +- _delphi_utils_python/delphi_utils/logger.py | 11 +++-- _delphi_utils_python/delphi_utils/smooth.py | 20 +++----- .../delphi_utils/validator/dynamic.py | 2 - _delphi_utils_python/setup.py | 3 +- _template_python/.pylintrc | 22 --------- _template_python/Makefile | 5 +- _template_python/setup.py | 1 + changehc/.pylintrc | 24 --------- changehc/Makefile | 5 +- .../delphi_changehc/download_ftp_files.py | 1 - changehc/delphi_changehc/sensor.py | 3 -- changehc/delphi_changehc/update_sensor.py | 15 +++--- changehc/setup.py | 3 +- claims_hosp/.pylintrc | 23 --------- claims_hosp/Makefile | 5 +- claims_hosp/delphi_claims_hosp/smooth.py | 4 +- claims_hosp/setup.py | 3 +- doctor_visits/.pylintrc | 8 --- doctor_visits/Makefile | 5 +- doctor_visits/delphi_doctor_visits/run.py | 2 +- doctor_visits/delphi_doctor_visits/smooth.py | 4 +- doctor_visits/setup.py | 3 +- google_symptoms/.pylintrc | 8 --- google_symptoms/Makefile | 5 +- .../delphi_google_symptoms/pull.py | 2 +- google_symptoms/setup.py | 3 +- hhs_hosp/.pylintrc | 22 --------- hhs_hosp/Makefile | 5 +- hhs_hosp/setup.py | 3 +- nchs_mortality/.pylintrc | 24 --------- nchs_mortality/Makefile | 5 +- nchs_mortality/setup.py | 1 + nwss_wastewater/.pylintrc | 22 --------- nwss_wastewater/Makefile | 5 +- nwss_wastewater/setup.py | 1 + pyproject.toml | 35 ++++++++++++- quidel_covidtest/.pylintrc | 24 --------- quidel_covidtest/Makefile | 5 +- quidel_covidtest/setup.py | 3 +- sir_complainsalot/Makefile | 5 +- .../delphi_sir_complainsalot/run.py | 2 +- sir_complainsalot/setup.py | 3 +- 47 files changed, 189 insertions(+), 288 deletions(-) delete mode 100644 _delphi_utils_python/.pylintrc delete mode 100644 _template_python/.pylintrc delete mode 100644 changehc/.pylintrc delete mode 100644 claims_hosp/.pylintrc delete mode 100644 doctor_visits/.pylintrc delete mode 100644 google_symptoms/.pylintrc delete mode 100644 hhs_hosp/.pylintrc delete mode 100644 nchs_mortality/.pylintrc delete mode 100644 nwss_wastewater/.pylintrc delete mode 100644 quidel_covidtest/.pylintrc diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 0534cbce2..13af97d63 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -16,28 +16,40 @@ jobs: if: github.event.pull_request.draft == false strategy: matrix: - packages: - [ - _delphi_utils_python, - changehc, - claims_hosp, - doctor_visits, - google_symptoms, - hhs_hosp, - nchs_mortality, - nwss_wastewater, - quidel_covidtest, - sir_complainsalot, - ] + include: + - package: "_delphi_utils_python" + dir: "delphi_utils" + - package: "changehc" + dir: "delphi_changehc" + - package: "claims_hosp" + dir: "delphi_claims_hosp" + - package: "doctor_visits" + dir: "delphi_doctor_visits" + - package: "google_symptoms" + dir: "delphi_google_symptoms" + - package: "hhs_hosp" + dir: "delphi_hhs" + - package: "nchs_mortality" + dir: "delphi_nchs_mortality" + - package: "nwss_wastewater" + dir: "delphi_nwss" + - package: "quidel_covidtest" + dir: "delphi_quidel_covidtest" + - package: "sir_complainsalot" + dir: "delphi_sir_complainsalot" defaults: run: - working-directory: ${{ matrix.packages }} + working-directory: ${{ matrix.package }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.8 + cache: "pip" + cache-dependency-path: "setup.py" - name: Install testing dependencies run: | python -m pip install --upgrade pip @@ -51,3 +63,8 @@ jobs: - name: Test run: | make test + - uses: akaihola/darker@v2.1.1 + with: + options: "--check --diff --isort --color" + src: "${{ matrix.package }}/${{ matrix.dir }}" + version: "~=2.1.1" diff --git a/README.md b/README.md index 049b3ad49..3d4f8d161 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ In early April 2020, Delphi developed a uniform data schema for [a new Epidata endpoint focused on COVID-19](https://cmu-delphi.github.io/delphi-epidata/api/covidcast.html). Our intent was to provide signals that would track in real-time and in fine geographic granularity all facets of the COVID-19 pandemic, aiding both nowcasting and forecasting. Delphi's long history in tracking and forecasting influenza made us uniquely situated to provide access to data streams not available anywhere else, including medical claims data, electronic medical records, lab test records, massive public surveys, and internet search trends. We also process commonly-used publicly-available data sources, both for user convenience and to provide data versioning for sources that do not track revisions themselves. -Each data stream arrives in a different format using a different delivery technique, be it sftp, an access-controlled API, or an email attachment. The purpose of each pipeline in this repository is to fetch the raw source data, extract informative aggregate signals, and output those signals---which we call **COVID-19 indicators**---in a common format for upload to the [COVIDcast API](https://cmu-delphi.github.io/delphi-epidata/api/covidcast.html). +Each data stream arrives in a different format using a different delivery technique, be it sftp, an access-controlled API, or an email attachment. The purpose of each pipeline in this repository is to fetch the raw source data, extract informative aggregate signals, and output those signals---which we call **COVID-19 indicators**---in a common format for upload to the [COVIDcast API](https://cmu-delphi.github.io/delphi-epidata/api/covidcast.html). For client access to the API, along with a variety of other utilities, see our [R](https://cmu-delphi.github.io/covidcast/covidcastR/) and [Python](https://cmu-delphi.github.io/covidcast/covidcast-py/html/) packages. @@ -13,18 +13,19 @@ For interactive visualizations (of a subset of the available indicators), see ou ## Organization Utilities: -* `_delphi_utils_python` - common behaviors -* `_template_python` & `_template_r` - starting points for new data sources -* `ansible` & `jenkins` - automated testing and deployment -* `sir_complainsalot` - a Slack bot to check for missing data + +- `_delphi_utils_python` - common behaviors +- `_template_python` & `_template_r` - starting points for new data sources +- `ansible` & `jenkins` - automated testing and deployment +- `sir_complainsalot` - a Slack bot to check for missing data Indicator pipelines: all remaining directories. -Each indicator pipeline includes its own documentation. +Each indicator pipeline includes its own documentation. -* Consult README.md for directions to install, lint, test, and run the pipeline for that indicator. -* Consult REVIEW.md for the checklist to use for code reviews. -* Consult DETAILS.md (if present) for implementation details, including handling of corner cases. +- Consult README.md for directions to install, lint, test, and run the pipeline for that indicator. +- Consult REVIEW.md for the checklist to use for code reviews. +- Consult DETAILS.md (if present) for implementation details, including handling of corner cases. ## Development @@ -35,6 +36,28 @@ Each indicator pipeline includes its own documentation. 3. Add new commits to your branch in response to feedback. 4. When approved, tag an admin to merge the PR. Let them know if this change should be released immediately, at a set future date, or if it can just go along for the ride whenever the next release happens. +### Linting and Formatting + +Each indicator has a `make lint` command to check for linting errors and a `make +format` command to incrementally format your code (using +[darker](https://github.com/akaihola/darker)). These are both automated with a +[Github Action](.github/workflows/python-ci.yml). + +If you get the error `ERROR:darker.git:fatal: Not a valid commit name `, +then it's likely because your local main branch is not up to date; either you +need to rebase or merge. Note that `darker` reads from `pyproject.toml` for +default settings. + +If the lines you change are in a file that uses 2 space indentation, `darker` +will indent the lines around your changes and not the rest, which will likely +break the code; in that case, you should probably just pass the whole file +through black. You can do that with the following command (using the same +virtual environment as above): + +```sh +env/bin/black +``` + ## Release Process The release process consists of multiple steps which can all be done via the GitHub website: diff --git a/_delphi_utils_python/.pylintrc b/_delphi_utils_python/.pylintrc deleted file mode 100644 index ad0180ed7..000000000 --- a/_delphi_utils_python/.pylintrc +++ /dev/null @@ -1,22 +0,0 @@ - -[MESSAGES CONTROL] - -disable=logging-format-interpolation, - too-many-locals, - too-many-arguments, - # Allow pytest functions to be part of a class. - no-self-use, - # Allow pytest classes to have one test. - too-few-public-methods - -[BASIC] - -# Allow arbitrarily short-named variables. -variable-rgx=([a-z_][a-z0-9_]*|[a-zA-Z]) -argument-rgx=([a-z_][a-z0-9_]*|[a-zA-Z]) -attr-rgx=([a-z_][a-z0-9_]*|[a-zA-Z]) - -[DESIGN] - -# Don't complain about pytest "unused" arguments. -ignored-argument-names=(_.*|run_as_module) \ No newline at end of file diff --git a/_delphi_utils_python/Makefile b/_delphi_utils_python/Makefile index dd9c5f37f..79d7f7943 100644 --- a/_delphi_utils_python/Makefile +++ b/_delphi_utils_python/Makefile @@ -14,9 +14,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint delphi_utils + . env/bin/activate; pylint delphi_utils --rcfile=../pyproject.toml . env/bin/activate; pydocstyle delphi_utils +format: + . env/bin/activate; darker delphi_utils + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=delphi_utils --cov-report=term-missing) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index a313c754c..360673a37 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -3,7 +3,6 @@ Authors: Dmitry Shemetov @dshemetov, James Sharpnack @jsharpna, Maria Jahja """ -# pylint: disable=too-many-lines from os.path import join from collections import defaultdict from typing import Iterator, List, Literal, Optional, Set, Union @@ -13,7 +12,7 @@ from pandas.api.types import is_string_dtype -class GeoMapper: # pylint: disable=too-many-public-methods +class GeoMapper: """Geo mapping tools commonly used in Delphi. The GeoMapper class provides utility functions for translating between different @@ -624,7 +623,7 @@ def get_geos_within( if contained_geocode_type == "state": if container_geocode_type == "nation" and container_geocode == "us": crosswalk = self._crosswalks["state"]["state"] - return set(crosswalk["state_id"]) # pylint: disable=unsubscriptable-object + return set(crosswalk["state_id"]) if container_geocode_type == "hhs": crosswalk_hhs = self._crosswalks["fips"]["hhs"] crosswalk_state = self._crosswalks["fips"]["state"] diff --git a/_delphi_utils_python/delphi_utils/logger.py b/_delphi_utils_python/delphi_utils/logger.py index d04ff7673..d70ae4c8e 100644 --- a/_delphi_utils_python/delphi_utils/logger.py +++ b/_delphi_utils_python/delphi_utils/logger.py @@ -1,9 +1,10 @@ -"""Structured logger utility for creating JSON logs.""" +"""Structured logger utility for creating JSON logs. -# the Delphi group uses two ~identical versions of this file. -# try to keep them in sync with edits, for sanity. -# https://github.com/cmu-delphi/covidcast-indicators/blob/main/_delphi_utils_python/delphi_utils/logger.py # pylint: disable=line-too-long -# https://github.com/cmu-delphi/delphi-epidata/blob/dev/src/common/logger.py +The Delphi group uses two ~identical versions of this file. +Try to keep them in sync with edits, for sanity. + https://github.com/cmu-delphi/covidcast-indicators/blob/main/_delphi_utils_python/delphi_utils/logger.py + https://github.com/cmu-delphi/delphi-epidata/blob/dev/src/common/logger.py +""" import contextlib import logging diff --git a/_delphi_utils_python/delphi_utils/smooth.py b/_delphi_utils_python/delphi_utils/smooth.py index 503fcf1b2..d9c95b552 100644 --- a/_delphi_utils_python/delphi_utils/smooth.py +++ b/_delphi_utils_python/delphi_utils/smooth.py @@ -304,17 +304,11 @@ def left_gauss_linear_smoother(self, signal): n = len(signal) signal_smoothed = np.zeros_like(signal) # A is the regression design matrix - A = np.vstack([np.ones(n), np.arange(n)]).T # pylint: disable=invalid-name + A = np.vstack([np.ones(n), np.arange(n)]).T for idx in range(n): - weights = np.exp( - -((np.arange(idx + 1) - idx) ** 2) / self.gaussian_bandwidth - ) - AwA = np.dot( # pylint: disable=invalid-name - A[: (idx + 1), :].T * weights, A[: (idx + 1), :] - ) - Awy = np.dot( # pylint: disable=invalid-name - A[: (idx + 1), :].T * weights, signal[: (idx + 1)].reshape(-1, 1) - ) + weights = np.exp(-((np.arange(idx + 1) - idx) ** 2) / self.gaussian_bandwidth) + AwA = np.dot(A[: (idx + 1), :].T * weights, A[: (idx + 1), :]) + Awy = np.dot(A[: (idx + 1), :].T * weights, signal[: (idx + 1)].reshape(-1, 1)) try: beta = np.linalg.solve(AwA, Awy) signal_smoothed[idx] = np.dot(A[: (idx + 1), :], beta)[-1] @@ -389,9 +383,7 @@ def savgol_coeffs(self, nl, nr, poly_fit_degree): if nr > 0: warnings.warn("The filter is no longer causal.") - A = np.vstack( # pylint: disable=invalid-name - [np.arange(nl, nr + 1) ** j for j in range(poly_fit_degree + 1)] - ).T + A = np.vstack([np.arange(nl, nr + 1) ** j for j in range(poly_fit_degree + 1)]).T if self.gaussian_bandwidth is None: mat_inverse = np.linalg.inv(A.T @ A) @ A.T @@ -406,7 +398,7 @@ def savgol_coeffs(self, nl, nr, poly_fit_degree): coeffs[i] = (mat_inverse @ basis_vector)[0] return coeffs - def savgol_smoother(self, signal): # pylint: disable=inconsistent-return-statements + def savgol_smoother(self, signal): """Smooth signal with the savgol smoother. Returns a convolution of the 1D signal with the Savitzky-Golay coefficients, respecting diff --git a/_delphi_utils_python/delphi_utils/validator/dynamic.py b/_delphi_utils_python/delphi_utils/validator/dynamic.py index 6758086ab..9bc72ec1c 100644 --- a/_delphi_utils_python/delphi_utils/validator/dynamic.py +++ b/_delphi_utils_python/delphi_utils/validator/dynamic.py @@ -320,7 +320,6 @@ def create_dfs(self, geo_sig_df, api_df_or_error, checking_date, geo_type, signa # # These variables are interpolated into the call to `api_df_or_error.query()` # below but pylint doesn't recognize that. - # pylint: disable=unused-variable reference_start_date = recent_cutoff_date - self.params.max_check_lookbehind if signal_type in self.params.smoothed_signals: # Add an extra 7 days to the reference period. @@ -328,7 +327,6 @@ def create_dfs(self, geo_sig_df, api_df_or_error, checking_date, geo_type, signa timedelta(days=7) reference_end_date = recent_cutoff_date - timedelta(days=1) - # pylint: enable=unused-variable # Subset API data to relevant range of dates. reference_api_df = api_df_or_error.query( diff --git a/_delphi_utils_python/setup.py b/_delphi_utils_python/setup.py index 046dc5d3a..fc25adae3 100644 --- a/_delphi_utils_python/setup.py +++ b/_delphi_utils_python/setup.py @@ -2,12 +2,13 @@ from setuptools import find_packages with open("README.md", "r") as f: - long_description = f.read() + long_description = f.read() required = [ "boto3", "covidcast", "cvxpy", + "darker[isort]~=2.1.1", "epiweeks", "freezegun", "gitpython", diff --git a/_template_python/.pylintrc b/_template_python/.pylintrc deleted file mode 100644 index f30837c7e..000000000 --- a/_template_python/.pylintrc +++ /dev/null @@ -1,22 +0,0 @@ - -[MESSAGES CONTROL] - -disable=logging-format-interpolation, - too-many-locals, - too-many-arguments, - # Allow pytest functions to be part of a class. - no-self-use, - # Allow pytest classes to have one test. - too-few-public-methods - -[BASIC] - -# Allow arbitrarily short-named variables. -variable-rgx=[a-z_][a-z0-9_]* -argument-rgx=[a-z_][a-z0-9_]* -attr-rgx=[a-z_][a-z0-9_]* - -[DESIGN] - -# Don't complain about pytest "unused" arguments. -ignored-argument-names=(_.*|run_as_module) \ No newline at end of file diff --git a/_template_python/Makefile b/_template_python/Makefile index bc88f1fec..390113eef 100644 --- a/_template_python/Makefile +++ b/_template_python/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/_template_python/setup.py b/_template_python/setup.py index ba1325b3c..f56326844 100644 --- a/_template_python/setup.py +++ b/_template_python/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "numpy", "pandas", "pydocstyle", diff --git a/changehc/.pylintrc b/changehc/.pylintrc deleted file mode 100644 index c71c52434..000000000 --- a/changehc/.pylintrc +++ /dev/null @@ -1,24 +0,0 @@ - -[MESSAGES CONTROL] - -disable=logging-format-interpolation, - too-many-locals, - too-many-arguments, - # Allow pytest functions to be part of a class. - no-self-use, - # Allow pytest classes to have one test. - too-few-public-methods, - # Ignore - R0903, C0301, R0914, C0103, W1203, E0611, R0902, R0913, W0105, W0611, W1401 - -[BASIC] - -# Allow arbitrarily short-named variables. -variable-rgx=[a-z_][a-z0-9_]* -argument-rgx=[a-z_][a-z0-9_]* -attr-rgx=[a-z_][a-z0-9_]* - -[DESIGN] - -# Don't complain about pytest "unused" arguments. -ignored-argument-names=(_.*|run_as_module) \ No newline at end of file diff --git a/changehc/Makefile b/changehc/Makefile index bc88f1fec..390113eef 100644 --- a/changehc/Makefile +++ b/changehc/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/changehc/delphi_changehc/download_ftp_files.py b/changehc/delphi_changehc/download_ftp_files.py index f85ef9944..dad47bb3f 100644 --- a/changehc/delphi_changehc/download_ftp_files.py +++ b/changehc/delphi_changehc/download_ftp_files.py @@ -1,7 +1,6 @@ """Download files from the specified ftp server.""" # standard -import datetime import functools from os import path diff --git a/changehc/delphi_changehc/sensor.py b/changehc/delphi_changehc/sensor.py index d1422567b..0449f07df 100644 --- a/changehc/delphi_changehc/sensor.py +++ b/changehc/delphi_changehc/sensor.py @@ -6,9 +6,6 @@ """ -# standard packages -import logging - # third party import numpy as np import pandas as pd diff --git a/changehc/delphi_changehc/update_sensor.py b/changehc/delphi_changehc/update_sensor.py index cb5b42a4b..a91dec5ac 100644 --- a/changehc/delphi_changehc/update_sensor.py +++ b/changehc/delphi_changehc/update_sensor.py @@ -5,18 +5,16 @@ Created: 2020-10-14 """ # standard packages -import logging from multiprocessing import Pool, cpu_count # third party import numpy as np import pandas as pd -from delphi_utils import GeoMapper, add_prefix, create_export_csv, Weekday +from delphi_utils import GeoMapper, Weekday, add_prefix, create_export_csv # first party from .config import Config -from .constants import SMOOTHED, SMOOTHED_ADJ, SMOOTHED_CLI, SMOOTHED_ADJ_CLI,\ - SMOOTHED_FLU, SMOOTHED_ADJ_FLU, NA +from .constants import SMOOTHED, SMOOTHED_ADJ, SMOOTHED_ADJ_CLI, SMOOTHED_ADJ_FLU, SMOOTHED_CLI, SMOOTHED_FLU from .sensor import CHCSensor @@ -173,10 +171,11 @@ def geo_reindex(self, data): unique_geo_ids = pd.unique(data_frame[geo]) data_frame.set_index([geo, Config.DATE_COL],inplace=True) # for each location, fill in all missing dates with 0 values - multiindex = pd.MultiIndex.from_product((unique_geo_ids, self.fit_dates), - names=[geo, Config.DATE_COL]) - assert (len(multiindex) <= (len(gmpr.get_geo_values(gmpr.as_mapper_name(geo))) * len(self.fit_dates)) - ), f"more loc-date pairs than maximum number of geographies x number of dates, length of multiindex is {len(multiindex)}, geo level is {geo}" + multiindex = pd.MultiIndex.from_product((unique_geo_ids, self.fit_dates), names=[geo, Config.DATE_COL]) + assert len(multiindex) <= (len(gmpr.get_geo_values(gmpr.as_mapper_name(geo))) * len(self.fit_dates)), ( + "more loc-date pairs than maximum number of geographies x number of dates, " + f"length of multiindex is {len(multiindex)}, geo level is {geo}" + ) # fill dataframe with missing dates using 0 data_frame = data_frame.reindex(multiindex, fill_value=0) diff --git a/changehc/setup.py b/changehc/setup.py index d46649391..ae661fbcf 100644 --- a/changehc/setup.py +++ b/changehc/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "numpy", "pandas", "pyarrow", @@ -13,7 +14,7 @@ "covidcast", "boto3", "moto~=4.2.14", - "paramiko" + "paramiko", ] setup( diff --git a/claims_hosp/.pylintrc b/claims_hosp/.pylintrc deleted file mode 100644 index 7fc2f5c30..000000000 --- a/claims_hosp/.pylintrc +++ /dev/null @@ -1,23 +0,0 @@ - -[MESSAGES CONTROL] - -disable=logging-format-interpolation, - too-many-locals, - too-many-arguments, - # Allow pytest functions to be part of a class. - no-self-use, - # Allow pytest classes to have one test. - too-few-public-methods, - - -[BASIC] - -# Allow arbitrarily short-named variables. -variable-rgx=[a-z_][a-z0-9_]* -argument-rgx=[a-z_][a-z0-9_]* -attr-rgx=[a-z_][a-z0-9_]* - -[DESIGN] - -# Don't complain about pytest "unused" arguments. -ignored-argument-names=(_.*|run_as_module) \ No newline at end of file diff --git a/claims_hosp/Makefile b/claims_hosp/Makefile index bc88f1fec..390113eef 100644 --- a/claims_hosp/Makefile +++ b/claims_hosp/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/claims_hosp/delphi_claims_hosp/smooth.py b/claims_hosp/delphi_claims_hosp/smooth.py index a66bcc25c..56b132fa2 100644 --- a/claims_hosp/delphi_claims_hosp/smooth.py +++ b/claims_hosp/delphi_claims_hosp/smooth.py @@ -27,13 +27,11 @@ def left_gauss_linear(arr, bandwidth=Config.SMOOTHER_BANDWIDTH): """ n_rows = len(arr) out_arr = np.zeros_like(arr) - X = np.vstack([np.ones(n_rows), np.arange(n_rows)]).T # pylint: disable=invalid-name + X = np.vstack([np.ones(n_rows), np.arange(n_rows)]).T for idx in range(n_rows): weights = np.exp(-((np.arange(idx + 1) - idx) ** 2) / bandwidth) - # pylint: disable=invalid-name XwX = np.dot(X[: (idx + 1), :].T * weights, X[: (idx + 1), :]) Xwy = np.dot(X[: (idx + 1), :].T * weights, arr[: (idx + 1)].reshape(-1, 1)) - # pylint: enable=invalid-name try: beta = np.linalg.solve(XwX, Xwy) out_arr[idx] = np.dot(X[: (idx + 1), :], beta)[-1] diff --git a/claims_hosp/setup.py b/claims_hosp/setup.py index bc50a6414..f0005c170 100644 --- a/claims_hosp/setup.py +++ b/claims_hosp/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "numpy", "pandas", "pyarrow", @@ -11,7 +12,7 @@ "pytest-cov", "pylint==2.8.3", "delphi-utils", - "covidcast" + "covidcast", ] setup( diff --git a/doctor_visits/.pylintrc b/doctor_visits/.pylintrc deleted file mode 100644 index a14b269cc..000000000 --- a/doctor_visits/.pylintrc +++ /dev/null @@ -1,8 +0,0 @@ -[DESIGN] - -min-public-methods=0 - - -[MESSAGES CONTROL] - -disable=R0801, C0200, C0330, E1101, E0611, E1136, C0114, C0116, C0103, R0913, R0914, R0915, W1401, W1202, W1203, W0702 diff --git a/doctor_visits/Makefile b/doctor_visits/Makefile index bc88f1fec..390113eef 100644 --- a/doctor_visits/Makefile +++ b/doctor_visits/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/doctor_visits/delphi_doctor_visits/run.py b/doctor_visits/delphi_doctor_visits/run.py index 93c346ee7..fd09c56d6 100644 --- a/doctor_visits/delphi_doctor_visits/run.py +++ b/doctor_visits/delphi_doctor_visits/run.py @@ -20,7 +20,7 @@ from .get_latest_claims_name import get_latest_filename -def run_module(params): +def run_module(params): # pylint: disable=too-many-statements """ Run doctor visits indicator. diff --git a/doctor_visits/delphi_doctor_visits/smooth.py b/doctor_visits/delphi_doctor_visits/smooth.py index 72f691942..d24f1b85f 100644 --- a/doctor_visits/delphi_doctor_visits/smooth.py +++ b/doctor_visits/delphi_doctor_visits/smooth.py @@ -22,7 +22,7 @@ def moving_avg(x, y, k=7): """ n = len(y) sy = np.zeros((n - k + 1, 1)) - for i in range(len(sy)): + for i in range(len(sy)): # pylint: disable=consider-using-enumerate sy[i] = np.mean(y[i : (i + k)]) return x[(k - 1) :], sy @@ -39,7 +39,7 @@ def padded_moving_avg(y, k=7): """ n = len(y) sy = np.zeros((n - k + 1, 1)) - for i in range(len(sy)): + for i in range(len(sy)): # pylint: disable=consider-using-enumerate sy[i] = np.mean(y[i : (i + k)]) # pad first k obs with 0 diff --git a/doctor_visits/setup.py b/doctor_visits/setup.py index faba7c670..7a7451b96 100644 --- a/doctor_visits/setup.py +++ b/doctor_visits/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "numpy", "pandas", "paramiko", @@ -9,7 +10,7 @@ "pytest", "pytest-cov", "pylint==2.8.3", - "delphi-utils" + "delphi-utils", ] setup( diff --git a/google_symptoms/.pylintrc b/google_symptoms/.pylintrc deleted file mode 100644 index f337ecf9c..000000000 --- a/google_symptoms/.pylintrc +++ /dev/null @@ -1,8 +0,0 @@ -[DESIGN] - -min-public-methods=1 - - -[MESSAGES CONTROL] - -disable=R0801, E1101, E0611, C0114, C0116, C0103, R0913, R0914, W0702, W0707 diff --git a/google_symptoms/Makefile b/google_symptoms/Makefile index f6a5b7e63..6884278cf 100644 --- a/google_symptoms/Makefile +++ b/google_symptoms/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ; (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/google_symptoms/delphi_google_symptoms/pull.py b/google_symptoms/delphi_google_symptoms/pull.py index 29def6b4e..d5921a3e4 100644 --- a/google_symptoms/delphi_google_symptoms/pull.py +++ b/google_symptoms/delphi_google_symptoms/pull.py @@ -67,7 +67,7 @@ def preprocess(df, level): try: df = df[KEEP_COLUMNS] except KeyError: - raise ValueError( + raise ValueError( # pylint: disable=raise-missing-from "Some necessary columns are missing. The dataset " "schema may have changed. Please investigate." ) diff --git a/google_symptoms/setup.py b/google_symptoms/setup.py index 91af03e64..e9c3459e0 100644 --- a/google_symptoms/setup.py +++ b/google_symptoms/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "mock", "numpy", "pandas", @@ -12,7 +13,7 @@ "delphi-utils", "freezegun", "pandas-gbq", - "db-dtypes" + "db-dtypes", ] setup( diff --git a/hhs_hosp/.pylintrc b/hhs_hosp/.pylintrc deleted file mode 100644 index 58c6edbba..000000000 --- a/hhs_hosp/.pylintrc +++ /dev/null @@ -1,22 +0,0 @@ - -[MESSAGES CONTROL] - -disable=logging-format-interpolation, - too-many-locals, - too-many-arguments, - # Allow pytest functions to be part of a class. - no-self-use, - # Allow pytest classes to have one test. - too-few-public-methods - -[BASIC] - -# Allow arbitrarily short-named variables. -variable-rgx=[a-z_][a-z0-9_]* -argument-rgx=[a-z_][a-z0-9_]* -attr-rgx=[a-z_][a-z0-9_]* - -[DESIGN] - -# Don't complain about pytest "unused" arguments. -ignored-argument-names=(_.*|run_as_module) diff --git a/hhs_hosp/Makefile b/hhs_hosp/Makefile index ea591dcb5..69529feb7 100644 --- a/hhs_hosp/Makefile +++ b/hhs_hosp/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/hhs_hosp/setup.py b/hhs_hosp/setup.py index b19bcbb42..b575906cd 100644 --- a/hhs_hosp/setup.py +++ b/hhs_hosp/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "freezegun", "numpy", "pandas", @@ -11,7 +12,7 @@ "pylint==2.8.3", "delphi-utils", "covidcast", - "delphi-epidata" + "delphi-epidata", ] setup( diff --git a/nchs_mortality/.pylintrc b/nchs_mortality/.pylintrc deleted file mode 100644 index c72b4e124..000000000 --- a/nchs_mortality/.pylintrc +++ /dev/null @@ -1,24 +0,0 @@ - -[MESSAGES CONTROL] - -disable=logging-format-interpolation, - too-many-locals, - too-many-arguments, - too-many-branches, - too-many-statements, - # Allow pytest functions to be part of a class. - no-self-use, - # Allow pytest classes to have one test. - too-few-public-methods - -[BASIC] - -# Allow arbitrarily short-named variables. -variable-rgx=[a-z_][a-z0-9_]* -argument-rgx=[a-z_][a-z0-9_]* -attr-rgx=[a-z_][a-z0-9_]* - -[DESIGN] - -# Don't complain about pytest "unused" arguments. -ignored-argument-names=(_.*|run_as_module) \ No newline at end of file diff --git a/nchs_mortality/Makefile b/nchs_mortality/Makefile index bc88f1fec..390113eef 100644 --- a/nchs_mortality/Makefile +++ b/nchs_mortality/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/nchs_mortality/setup.py b/nchs_mortality/setup.py index 76915936b..7b830d4e4 100644 --- a/nchs_mortality/setup.py +++ b/nchs_mortality/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "numpy", "pandas", "pydocstyle", diff --git a/nwss_wastewater/.pylintrc b/nwss_wastewater/.pylintrc deleted file mode 100644 index f30837c7e..000000000 --- a/nwss_wastewater/.pylintrc +++ /dev/null @@ -1,22 +0,0 @@ - -[MESSAGES CONTROL] - -disable=logging-format-interpolation, - too-many-locals, - too-many-arguments, - # Allow pytest functions to be part of a class. - no-self-use, - # Allow pytest classes to have one test. - too-few-public-methods - -[BASIC] - -# Allow arbitrarily short-named variables. -variable-rgx=[a-z_][a-z0-9_]* -argument-rgx=[a-z_][a-z0-9_]* -attr-rgx=[a-z_][a-z0-9_]* - -[DESIGN] - -# Don't complain about pytest "unused" arguments. -ignored-argument-names=(_.*|run_as_module) \ No newline at end of file diff --git a/nwss_wastewater/Makefile b/nwss_wastewater/Makefile index bc88f1fec..390113eef 100644 --- a/nwss_wastewater/Makefile +++ b/nwss_wastewater/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/nwss_wastewater/setup.py b/nwss_wastewater/setup.py index f2cce8cb3..4d5ef172d 100644 --- a/nwss_wastewater/setup.py +++ b/nwss_wastewater/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "numpy", "pandas", "pydocstyle", diff --git a/pyproject.toml b/pyproject.toml index 9a31b63a0..2ca230476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,37 @@ [tool.black] line-length = 120 target-version = ['py38'] -include = '_delphi_utils_python' + +[tool.darker] +revision = 'origin/main...' +color = true +isort = true + +[tool.isort] +profile = "black" +known_third_party = ["pytest"] + +[tool.pylint] +[tool.pylint.main] +max-line-length = 120 +disable = [ + 'logging-format-interpolation', + # Allow pytest functions to be part of a class + 'no-self-use', + 'too-many-locals', + 'too-many-arguments', + 'too-many-branches', + 'too-many-statements', + # Allow pytest classes to have one test + 'too-few-public-methods', +] +enable = 'useless-suppression' + +[tool.pylint.basic] +# Allow arbitrarily short-named variables. +variable-rgx = '[A-Za-z_][a-z0-9_]*' +argument-rgx = '[A-Za-z_][a-z0-9_]*' +attr-rgx = '[A-Za-z_][a-z0-9_]*' + +[tool.pylint.design] +ignored-argument-names = ['(_.*|run_as_module)'] diff --git a/quidel_covidtest/.pylintrc b/quidel_covidtest/.pylintrc deleted file mode 100644 index 29bd9aac2..000000000 --- a/quidel_covidtest/.pylintrc +++ /dev/null @@ -1,24 +0,0 @@ - -[MESSAGES CONTROL] - -disable=logging-format-interpolation, - too-many-locals, - too-many-arguments, - too-many-branches, - # Allow pytest functions to be part of a class. - no-self-use, - # Allow pytest classes to have one test. - too-few-public-methods -enable=useless-suppression - -[BASIC] - -# Allow arbitrarily short-named variables. -variable-rgx=[a-z_][a-z0-9_]* -argument-rgx=[a-z_][a-z0-9_]* -attr-rgx=[a-z_][a-z0-9_]* - -[DESIGN] - -# Don't complain about pytest "unused" arguments. -ignored-argument-names=(_.*|run_as_module) diff --git a/quidel_covidtest/Makefile b/quidel_covidtest/Makefile index bc88f1fec..390113eef 100644 --- a/quidel_covidtest/Makefile +++ b/quidel_covidtest/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/quidel_covidtest/setup.py b/quidel_covidtest/setup.py index 369ac30c0..db7448275 100644 --- a/quidel_covidtest/setup.py +++ b/quidel_covidtest/setup.py @@ -2,6 +2,7 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "numpy", "pandas", "pyarrow", @@ -13,7 +14,7 @@ "imap-tools", "xlrd==1.2.0", "covidcast", - "openpyxl" + "openpyxl", ] setup( diff --git a/sir_complainsalot/Makefile b/sir_complainsalot/Makefile index bc88f1fec..390113eef 100644 --- a/sir_complainsalot/Makefile +++ b/sir_complainsalot/Makefile @@ -17,9 +17,12 @@ install-ci: venv pip install . lint: - . env/bin/activate; pylint $(dir) + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml . env/bin/activate; pydocstyle $(dir) +format: + . env/bin/activate; darker $(dir) + test: . env/bin/activate ;\ (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) diff --git a/sir_complainsalot/delphi_sir_complainsalot/run.py b/sir_complainsalot/delphi_sir_complainsalot/run.py index 962fa1bc3..a1555b9c2 100644 --- a/sir_complainsalot/delphi_sir_complainsalot/run.py +++ b/sir_complainsalot/delphi_sir_complainsalot/run.py @@ -48,7 +48,7 @@ def run_module(): elapsed_time_in_seconds = elapsed_time_in_seconds) -def split_complaints(complaints, n=49): # pylint: disable=invalid-name +def split_complaints(complaints, n=49): """Yield successive n-sized chunks from complaints list.""" for i in range(0, len(complaints), n): yield complaints[i:i + n] diff --git a/sir_complainsalot/setup.py b/sir_complainsalot/setup.py index c51253104..dc89e3107 100644 --- a/sir_complainsalot/setup.py +++ b/sir_complainsalot/setup.py @@ -2,13 +2,14 @@ from setuptools import find_packages required = [ + "darker[isort]~=2.1.1", "pandas", "pytest", "pytest-cov", "pylint==2.8.3", "delphi-utils", "slackclient", - "covidcast" + "covidcast", ] setup( From 6f46f2b4a0cf86137fda5bd58025997647c87b46 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Tue, 14 May 2024 17:20:51 -0700 Subject: [PATCH 20/48] lint: sort dependencies in all setup.py --- _delphi_utils_python/setup.py | 2 +- _template_python/setup.py | 8 ++++---- changehc/setup.py | 14 +++++++------- claims_hosp/setup.py | 10 +++++----- doctor_visits/setup.py | 8 ++++---- google_symptoms/setup.py | 12 ++++++------ hhs_hosp/setup.py | 10 +++++----- nchs_mortality/setup.py | 10 +++++----- nwss_wastewater/setup.py | 10 +++++----- quidel_covidtest/setup.py | 12 ++++++------ sir_complainsalot/setup.py | 8 ++++---- 11 files changed, 52 insertions(+), 52 deletions(-) diff --git a/_delphi_utils_python/setup.py b/_delphi_utils_python/setup.py index fc25adae3..d4c9d2a0e 100644 --- a/_delphi_utils_python/setup.py +++ b/_delphi_utils_python/setup.py @@ -18,8 +18,8 @@ "pandas>=1.1.0", "pydocstyle", "pylint==2.8.3", - "pytest", "pytest-cov", + "pytest", "requests-mock", "slackclient", "structlog", diff --git a/_template_python/setup.py b/_template_python/setup.py index f56326844..d7bc44078 100644 --- a/_template_python/setup.py +++ b/_template_python/setup.py @@ -2,15 +2,15 @@ from setuptools import find_packages required = [ + "covidcast", "darker[isort]~=2.1.1", + "delphi-utils", "numpy", "pandas", "pydocstyle", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", - "covidcast" + "pytest-cov", + "pytest", ] setup( diff --git a/changehc/setup.py b/changehc/setup.py index ae661fbcf..1faebb6a5 100644 --- a/changehc/setup.py +++ b/changehc/setup.py @@ -2,19 +2,19 @@ from setuptools import find_packages required = [ + "boto3", + "covidcast", "darker[isort]~=2.1.1", + "delphi-utils", + "moto~=4.2.14", "numpy", "pandas", + "paramiko", "pyarrow", "pydocstyle", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", - "covidcast", - "boto3", - "moto~=4.2.14", - "paramiko", + "pytest-cov", + "pytest", ] setup( diff --git a/claims_hosp/setup.py b/claims_hosp/setup.py index f0005c170..490b38b99 100644 --- a/claims_hosp/setup.py +++ b/claims_hosp/setup.py @@ -2,17 +2,17 @@ from setuptools import find_packages required = [ + "covidcast", "darker[isort]~=2.1.1", + "delphi-utils", "numpy", "pandas", - "pyarrow", "paramiko", + "pyarrow", "pydocstyle", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", - "covidcast", + "pytest-cov", + "pytest", ] setup( diff --git a/doctor_visits/setup.py b/doctor_visits/setup.py index 7a7451b96..fc291160d 100644 --- a/doctor_visits/setup.py +++ b/doctor_visits/setup.py @@ -3,14 +3,14 @@ required = [ "darker[isort]~=2.1.1", + "delphi-utils", "numpy", "pandas", "paramiko", - "scikit-learn", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", + "pytest-cov", + "pytest", + "scikit-learn", ] setup( diff --git a/google_symptoms/setup.py b/google_symptoms/setup.py index e9c3459e0..ccba3c47a 100644 --- a/google_symptoms/setup.py +++ b/google_symptoms/setup.py @@ -3,17 +3,17 @@ required = [ "darker[isort]~=2.1.1", + "db-dtypes", + "delphi-utils", + "freezegun", "mock", "numpy", + "pandas-gbq", "pandas", "pydocstyle", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", - "freezegun", - "pandas-gbq", - "db-dtypes", + "pytest-cov", + "pytest", ] setup( diff --git a/hhs_hosp/setup.py b/hhs_hosp/setup.py index b575906cd..90a685ac8 100644 --- a/hhs_hosp/setup.py +++ b/hhs_hosp/setup.py @@ -2,17 +2,17 @@ from setuptools import find_packages required = [ + "covidcast", "darker[isort]~=2.1.1", + "delphi-epidata", + "delphi-utils", "freezegun", "numpy", "pandas", "pydocstyle", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", - "covidcast", - "delphi-epidata", + "pytest-cov", + "pytest", ] setup( diff --git a/nchs_mortality/setup.py b/nchs_mortality/setup.py index 7b830d4e4..3fe354ba4 100644 --- a/nchs_mortality/setup.py +++ b/nchs_mortality/setup.py @@ -3,16 +3,16 @@ required = [ "darker[isort]~=2.1.1", + "delphi-utils", + "epiweeks", + "freezegun", "numpy", "pandas", "pydocstyle", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", + "pytest-cov", + "pytest", "sodapy", - "epiweeks", - "freezegun", ] setup( diff --git a/nwss_wastewater/setup.py b/nwss_wastewater/setup.py index 4d5ef172d..26f1b7324 100644 --- a/nwss_wastewater/setup.py +++ b/nwss_wastewater/setup.py @@ -3,16 +3,16 @@ required = [ "darker[isort]~=2.1.1", + "delphi-utils", + "epiweeks", + "freezegun", "numpy", "pandas", "pydocstyle", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", + "pytest-cov", + "pytest", "sodapy", - "epiweeks", - "freezegun", ] setup( diff --git a/quidel_covidtest/setup.py b/quidel_covidtest/setup.py index db7448275..c2791930f 100644 --- a/quidel_covidtest/setup.py +++ b/quidel_covidtest/setup.py @@ -2,19 +2,19 @@ from setuptools import find_packages required = [ + "covidcast", "darker[isort]~=2.1.1", + "delphi-utils", + "imap-tools", "numpy", + "openpyxl", "pandas", "pyarrow", "pydocstyle", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", - "imap-tools", + "pytest-cov", + "pytest", "xlrd==1.2.0", - "covidcast", - "openpyxl", ] setup( diff --git a/sir_complainsalot/setup.py b/sir_complainsalot/setup.py index dc89e3107..157c001b2 100644 --- a/sir_complainsalot/setup.py +++ b/sir_complainsalot/setup.py @@ -2,14 +2,14 @@ from setuptools import find_packages required = [ + "covidcast", "darker[isort]~=2.1.1", + "delphi-utils", "pandas", - "pytest", - "pytest-cov", "pylint==2.8.3", - "delphi-utils", + "pytest-cov", + "pytest", "slackclient", - "covidcast", ] setup( From 37945137ea1ef9efa044b996b5f8d5d28659b953 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Wed, 5 Jun 2024 11:59:16 -0700 Subject: [PATCH 21/48] chore: sorting dependencies ignore blame --- .git-blame-ignore-revs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 904a3bf69..9b48a931a 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,4 +1,6 @@ # Format geomap.py d4b056e7a4c11982324e9224c9f9f6fd5d5ec65c # Format test_geomap.py -79072dcdec3faca9aaeeea65de83f7fa5c00d53f \ No newline at end of file +79072dcdec3faca9aaeeea65de83f7fa5c00d53f +# Sort setup.py dependencies +6912077acba97e835aff7d0cd3d64309a1a9241d \ No newline at end of file From bc6962eba534d95e839fa0e7933c68e7dc146b73 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Fri, 7 Jun 2024 14:58:05 -0700 Subject: [PATCH 22/48] lint(geomap): minor tweak --- _delphi_utils_python/delphi_utils/geomap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index 56ec4eded..0b7e8a766 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -131,14 +131,14 @@ def __init__(self, census_year=2020): self._crosswalks[from_code][to_code] = \ self._load_crosswalk_from_file(from_code, to_code, - join(f"data/{census_year}", file_path) + join("data", f"{census_year}", file_path) ) for geo_type in self._geos: self._geo_sets[geo_type] = self._load_geo_values(geo_type) def _load_crosswalk_from_file(self, from_code, to_code, data_path): - stream = importlib_resources.files(__name__).joinpath(data_path) + stream = importlib_resources.files(__name__) / data_path dtype = { from_code: str, to_code: str, From 195d7b23b898f810655ac903eb62286e7c42c051 Mon Sep 17 00:00:00 2001 From: Dmitry Shemetov Date: Fri, 7 Jun 2024 15:02:53 -0700 Subject: [PATCH 23/48] lint: format --- _delphi_utils_python/delphi_utils/geomap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_delphi_utils_python/delphi_utils/geomap.py b/_delphi_utils_python/delphi_utils/geomap.py index 5b6786036..be6df2d24 100644 --- a/_delphi_utils_python/delphi_utils/geomap.py +++ b/_delphi_utils_python/delphi_utils/geomap.py @@ -3,12 +3,12 @@ Authors: Dmitry Shemetov @dshemetov, James Sharpnack @jsharpna, Maria Jahja """ -from os.path import join from collections import defaultdict +from os.path import join from typing import Iterator, List, Literal, Optional, Set, Union -import pandas as pd import importlib_resources +import pandas as pd from pandas.api.types import is_string_dtype From ae6f011ad588c17aa698ae5baef2133a16df7b28 Mon Sep 17 00:00:00 2001 From: minhkhul <118945681+minhkhul@users.noreply.github.com> Date: Mon, 10 Jun 2024 12:03:11 -0400 Subject: [PATCH 24/48] nssp pipeline code (#1952) * to make nssp run in staging * add nssp to Jenkinsfile * nssp_token name change * et code * Update nssp/delphi_nssp/run.py Co-authored-by: David Weber * Update nssp/README.md Co-authored-by: David Weber * Update nssp/DETAILS.md Co-authored-by: David Weber * Update nssp/delphi_nssp/__main__.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * Update nssp/delphi_nssp/pull.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * Update nssp/delphi_nssp/run.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * readme update * column names mapping + signals name standardization to fit with other available sources and signals" * improve readability * Add type_dict constant * more type_dict * add more unit test pull * data for unit test of pull * hrr + msa geos * use enumerate for clarity * set nssp sircal max_age to 13 days * set nssp sircal max_age to 15 days, to account for nighttime run * add validation to params * Update nssp/DETAILS.md Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * Update nssp/delphi_nssp/constants.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * et code * Update nssp/delphi_nssp/run.py Co-authored-by: David Weber * Update nssp/README.md Co-authored-by: David Weber * Update nssp/DETAILS.md Co-authored-by: David Weber * Update nssp/delphi_nssp/__main__.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * Update nssp/delphi_nssp/pull.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * Update nssp/delphi_nssp/run.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * readme update * column names mapping + signals name standardization to fit with other available sources and signals" * improve readability * Add type_dict constant * more type_dict * add more unit test pull * data for unit test of pull * hrr + msa geos * use enumerate for clarity * to make nssp run in staging * add nssp to Jenkinsfile * nssp_token name change * set nssp sircal max_age to 15 days, to account for nighttime run * set nssp sircal max_age to 13 days * add validation to params * Update nssp/DETAILS.md Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * Update nssp/delphi_nssp/constants.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * nssp correlation rmd and general notebook folder * making Black happy * update to new geomapper function * following 120 line convention everywhere * happy linter * happy black formatter in nssp * drop unneeded nssp tests * updates borked old tests, caught by @dshemetov * rebase woes and version consistency * Update nssp-params-prod.json.j2 min/max lag to 13 * Update params.json.template min/max lag to 7 and 13 * missed column renames for geo_mapper, unneeded index * lint+fix: update from linter changes * add make format to nssp Makefile * run make format on nssp * ci: update ci to lint nssp * lint: linter happy * lint: pydocstyle happy * lint: pydocstyle happy * pct_visits to pct_ed_visits --------- Co-authored-by: David Weber Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> Co-authored-by: Dmitry Shemetov --- .github/workflows/python-ci.yml | 2 + Jenkinsfile | 2 +- _delphi_utils_python/tests/test_weekday.py | 2 +- ansible/templates/nssp-params-prod.json.j2 | 29 + .../sir_complainsalot-params-prod.json.j2 | 4 + ansible/vars.yaml | 3 + notebooks/.Rprofile | 1 + notebooks/nssp/cor_dashboard.Rmd | 257 ++++ ...covid_ER_admissions_state_correlations.pdf | Bin 0 -> 87505 bytes .../covid_ER_admissions_state_lag_cor.pdf | Bin 0 -> 7178 bytes .../covid_ER_admissions_time_correlations.pdf | Bin 0 -> 5255 bytes .../flu_ER_admissions_state_correlations.pdf | Bin 0 -> 87492 bytes .../nssp/flu_ER_admissions_state_lag_cor.pdf | Bin 0 -> 7170 bytes .../flu_ER_admissions_time_correlations.pdf | Bin 0 -> 5276 bytes notebooks/renv.lock | 1367 +++++++++++++++++ notebooks/renv/.gitignore | 7 + notebooks/renv/activate.R | 1220 +++++++++++++++ notebooks/renv/settings.json | 19 + nssp/.pylintrc | 22 + nssp/DETAILS.md | 13 + nssp/Makefile | 32 + nssp/README.md | 75 + nssp/REVIEW.md | 38 + nssp/cache/.gitignore | 0 nssp/delphi_nssp/__init__.py | 13 + nssp/delphi_nssp/__main__.py | 13 + nssp/delphi_nssp/constants.py | 42 + nssp/delphi_nssp/pull.py | 71 + nssp/delphi_nssp/run.py | 133 ++ nssp/params.json.template | 30 + nssp/receiving/.gitignore | 1 + nssp/setup.py | 32 + nssp/tests/test_data/page.txt | 65 + nssp/tests/test_pull.py | 59 + nssp/tests/test_run.py | 31 + pyproject.toml | 4 + sir_complainsalot/params.json.template | 4 + 37 files changed, 3589 insertions(+), 2 deletions(-) create mode 100644 ansible/templates/nssp-params-prod.json.j2 create mode 100644 notebooks/.Rprofile create mode 100644 notebooks/nssp/cor_dashboard.Rmd create mode 100644 notebooks/nssp/covid_ER_admissions_state_correlations.pdf create mode 100644 notebooks/nssp/covid_ER_admissions_state_lag_cor.pdf create mode 100644 notebooks/nssp/covid_ER_admissions_time_correlations.pdf create mode 100644 notebooks/nssp/flu_ER_admissions_state_correlations.pdf create mode 100644 notebooks/nssp/flu_ER_admissions_state_lag_cor.pdf create mode 100644 notebooks/nssp/flu_ER_admissions_time_correlations.pdf create mode 100644 notebooks/renv.lock create mode 100644 notebooks/renv/.gitignore create mode 100644 notebooks/renv/activate.R create mode 100644 notebooks/renv/settings.json create mode 100644 nssp/.pylintrc create mode 100644 nssp/DETAILS.md create mode 100644 nssp/Makefile create mode 100644 nssp/README.md create mode 100644 nssp/REVIEW.md create mode 100644 nssp/cache/.gitignore create mode 100644 nssp/delphi_nssp/__init__.py create mode 100644 nssp/delphi_nssp/__main__.py create mode 100644 nssp/delphi_nssp/constants.py create mode 100644 nssp/delphi_nssp/pull.py create mode 100644 nssp/delphi_nssp/run.py create mode 100644 nssp/params.json.template create mode 100644 nssp/receiving/.gitignore create mode 100644 nssp/setup.py create mode 100644 nssp/tests/test_data/page.txt create mode 100644 nssp/tests/test_pull.py create mode 100644 nssp/tests/test_run.py diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 13af97d63..3e1ee9689 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -31,6 +31,8 @@ jobs: dir: "delphi_hhs" - package: "nchs_mortality" dir: "delphi_nchs_mortality" + - package: "nssp" + dir: "delphi_nssp" - package: "nwss_wastewater" dir: "delphi_nwss" - package: "quidel_covidtest" diff --git a/Jenkinsfile b/Jenkinsfile index 0052fd215..3011ebde7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -10,7 +10,7 @@ - TODO: #527 Get this list automatically from python-ci.yml at runtime. */ -def indicator_list = ["backfill_corrections", "changehc", "claims_hosp", "google_symptoms", "hhs_hosp", "nchs_mortality", "quidel_covidtest", "sir_complainsalot", "doctor_visits", "nwss_wastewater"] +def indicator_list = ["backfill_corrections", "changehc", "claims_hosp", "google_symptoms", "hhs_hosp", "nchs_mortality", "quidel_covidtest", "sir_complainsalot", "doctor_visits", "nwss_wastewater", "nssp"] def build_package_main = [:] def build_package_prod = [:] def deploy_staging = [:] diff --git a/_delphi_utils_python/tests/test_weekday.py b/_delphi_utils_python/tests/test_weekday.py index 9b187e114..53da7088c 100644 --- a/_delphi_utils_python/tests/test_weekday.py +++ b/_delphi_utils_python/tests/test_weekday.py @@ -58,4 +58,4 @@ def test_calc_adjustment(self): # The date and "den" column are unchanged by this function assert np.allclose(result["num"].values, expected_nums) assert np.allclose(result["den"].values, self.TEST_DATA["den"].values) - assert np.array_equal(result["date"].values, self.TEST_DATA["date"].values) \ No newline at end of file + assert np.array_equal(result["date"].values, self.TEST_DATA["date"].values) diff --git a/ansible/templates/nssp-params-prod.json.j2 b/ansible/templates/nssp-params-prod.json.j2 new file mode 100644 index 000000000..b131b6130 --- /dev/null +++ b/ansible/templates/nssp-params-prod.json.j2 @@ -0,0 +1,29 @@ +{ + "common": { + "export_dir": "/common/covidcast/receiving/nssp", + "log_filename": "/var/log/indicators/nssp.log", + "log_exceptions": false + }, + "indicator": { + "wip_signal": true, + "static_file_dir": "./static", + "socrata_token": "{{ nssp_token }}" + }, + "validation": { + "common": { + "data_source": "nssp", + "api_credentials": "{{ validation_api_key }}", + "span_length": 15, + "min_expected_lag": {"all": "7"}, + "max_expected_lag": {"all": "13"}, + "dry_run": true, + "suppressed_errors": [] + }, + "static": { + "minimum_sample_size": 0, + "missing_se_allowed": true, + "missing_sample_size_allowed": true + }, + "dynamic": {} + } +} diff --git a/ansible/templates/sir_complainsalot-params-prod.json.j2 b/ansible/templates/sir_complainsalot-params-prod.json.j2 index d6f5da081..a016fc470 100644 --- a/ansible/templates/sir_complainsalot-params-prod.json.j2 +++ b/ansible/templates/sir_complainsalot-params-prod.json.j2 @@ -50,6 +50,10 @@ "hhs": { "max_age":15, "maintainers": [] + }, + "nssp": { + "max_age":13, + "maintainers": [] } } } diff --git a/ansible/vars.yaml b/ansible/vars.yaml index 8e059c873..ff9ba135c 100644 --- a/ansible/vars.yaml +++ b/ansible/vars.yaml @@ -56,6 +56,9 @@ nchs_mortality_token: "{{ vault_cdc_socrata_token }}" # NWSS nwss_wastewater_token: "{{ vault_cdc_socrata_token }}" +# nssp +nssp_token: "{{ vault_cdc_socrata_token }}" + # SirCAL sir_complainsalot_api_key: "{{ vault_sir_complainsalot_api_key }}" sir_complainsalot_slack_token: "{{ vault_sir_complainsalot_slack_token }}" diff --git a/notebooks/.Rprofile b/notebooks/.Rprofile new file mode 100644 index 000000000..81b960f5c --- /dev/null +++ b/notebooks/.Rprofile @@ -0,0 +1 @@ +source("renv/activate.R") diff --git a/notebooks/nssp/cor_dashboard.Rmd b/notebooks/nssp/cor_dashboard.Rmd new file mode 100644 index 000000000..58d3ed6a0 --- /dev/null +++ b/notebooks/nssp/cor_dashboard.Rmd @@ -0,0 +1,257 @@ +--- +title: "Correlation Analyses for COVID-19 Indicators" +author: "Delphi Group" +date: "`r format(Sys.time(), '%B %d, %Y')`" +output: + html_document: + code_folding: hide +--- + +```{r, include = FALSE} +knitr::opts_chunk$set(message = FALSE, warning = FALSE, fig.width = 8, + fig.height = 7) +``` + +### Getting data +This requires that you've already run the nssp pipeline. See the `nssp` directory for instructions on doing that. +First loading some libraries and reading the results from the pipeline: +```{r} +library(covidcast) +library(epidatr) +library(dplyr) +library(ggplot2) + +library(purrr) +library(tidyverse) +library(dplyr) +library(readr) +files <- list.files(here::here("nssp/receiving"), pattern="\\.csv$", full.names = TRUE) +read_row <- function(filename) { + split_name <- filename %>% + tools::file_path_sans_ext() %>% + strsplit("/") %>% `[[`(1) %>% tail(n=1) %>% + strsplit("_") %>% `[[`(1) + week_number <- split_name[[2]] + geo_type <- split_name[[3]] + col_name <- split_name[-(1:3)] %>% paste(collapse = "_") + read_csv(filename, show_col_types = FALSE) %>% + as_tibble %>% + mutate(signal = col_name, + geo_type = geo_type, + week_number = week_number) %>% + mutate(across(geo_id, factor)) %>% + rename(geo_value = geo_id, time_value = week_number) %>% + select(-missing_se, -se, -sample_size, -missing_sample_size) %>% + return +} +res <- map(files, read_row) +nssp_data <- bind_rows(res) +nssp_state <- nssp_data %>% + filter(geo_type == "state") %>% + mutate(time_value = epidatr:::parse_api_week(time_value)) %>% + as_epi_df(time_type = "week", geo_type = "state") %>% + select(-missing_val, -geo_type) +unique(nssp_data$time_value) +``` +And epidatr versions of hhs for comparison +```{r} +library(epidatr) +eval_time <- epidatr::epirange(from = "2020-01-01", to = Sys.Date()) +fetch_args <- epidatr::fetch_args_list(return_empty = TRUE, timeout_seconds = 300) + +flu_hhs <- epidatr::pub_covidcast( + source = "hhs", + signals = "confirmed_admissions_influenza_1d_prop_7dav", + geo_type = "state", + time_type = "day", + geo_values = "*", + time_values = eval_time, + fetch_args = fetch_args + ) %>% + select(-signal, -source, - time_type) + +covid_hhs <- epidatr::pub_covidcast( + source = "hhs", + signals = "confirmed_admissions_covid_1d_prop_7dav", + geo_type = "state", + time_type = "day", + geo_values = "*", + time_values = eval_time, + fetch_args = fetch_args + ) %>% + select(-signal, -source, - time_type) + + +nchs <- epidatr::pub_covidcast( + source = "nchs-mortality", + signals = "deaths_allcause_incidence_num", + geo_type = "state", + time_type = "week", + geo_values = "*", + time_values = epidatr::epirange(from = "202001", to = "202418"), + fetch_args = epidatr::fetch_args_list(return_empty = TRUE, timeout_seconds = 300) + ) +``` +# Flu +```{r} +library(epiprocess) +nssp_flu_state <- nssp_state %>% filter(signal == "pct_ed_visits_influenza") %>% select(-signal) %>% drop_na %>% rename(pct_flu_visits = val) %>% as_epi_df(time_type = "week", geo_type = "state") +week_starts <- nssp_flu_state$time_value %>% unique() +flu_hhs_weekly <- flu_hhs %>% select(geo_value, time_value, value) %>% filter(time_value %in% week_starts) %>% rename(conf_admission = value) %>% drop_na %>% as_epi_df(time_type = "week", geo_type = "state") +joined <- nssp_flu_state %>% left_join(flu_hhs_weekly) +``` + +After the necessary joining, lets look at the average correlations +```{r} +cor(joined$pct_flu_visits, joined$conf_admission, method = "spearman") +``` +So the overall correlation is pretty high. + +## Correlations sliced by state +```{r} +correlations_space_flu <- epi_cor(joined, pct_flu_visits, conf_admission, cor_by = "geo_value", use = "complete.obs", method = "spearman") +library(maps) # For map data +states_map <- map_data("state") +mapped <- states_map %>% as_tibble %>% mutate(geo_value = setNames(tolower(state.abb), tolower(state.name))[region]) %>% right_join(correlations_space_flu) %>% arrange(group, order) +library(viridis) +ggplot(mapped, aes(x = long, y = lat, group = group, fill = cor)) + + geom_polygon(colour = "black") + + scale_fill_viridis(discrete=FALSE, option="viridis", limits = c(0,1)) + + coord_map("polyconic") + + labs(title = "Spearman Correlations between Flu ER visits and Flu hospital admissions") +ggsave("flu_ER_admissions_state_correlations.pdf") +``` +Over space, hospital admissions look like they're highly correlated with ER visits (which makes sense, frequently when one is admitted it is via the ER). +The lowest overall correlation is +```{r} +correlations_space_flu %>% summarize(across(where(is.numeric), .fns = list(min = min, median = median, mean = mean, std = sd, q25 = ~quantile(.,0.25), q75 = ~quantile(.,0.75), max = max))) +``` +### Lag evaluation +```{r} +library(purrr) +lags <- 0:35 + +lagged_flu_state <- map_dfr(lags, function(lag) { + epi_cor(joined, pct_flu_visits, conf_admission, cor_by = geo_value, dt1 = lag, use = "complete.obs", method = "spearman") %>% + mutate(lag = .env$lag) +}) + +lagged_flu_state %>% + group_by(lag) %>% + summarize(mean = mean(cor, na.rm = TRUE)) %>% + ggplot(aes(x = lag, y = mean)) + + geom_line() + + geom_point() + + labs(x = "Lag", y = "Mean correlation", title = "Lag comparison for state spearman correlations for flu ER and Hosp admissions") +ggsave("flu_ER_admissions_state_lag_cor.pdf") +``` +Somewhat unsurprisingly, the correlation is highest immediately afterward. +## Correlations sliced by time +```{r} +correlations_time_flu <- epi_cor(joined, pct_flu_visits, conf_admission, cor_by = "time_value", use = "complete.obs", method = "spearman") +correlations_time_flu +ggplot(correlations_time_flu, aes(x = time_value, y = cor)) + geom_line() + lims(y=c(0,1)) + labs(title = "Spearman Correlations between Flu ER visits and Flu hospital admissions") +ggsave("flu_ER_admissions_time_correlations.pdf") +``` +Strangely, sliced by time, we get significantly lower correlations +```{r} +correlations_time_flu %>% summarize(across(where(is.numeric), .fns = list(min = min, median = median, mean = mean, std = sd, q25 = ~quantile(.,0.25), q75 = ~quantile(.,0.75), max = max))) +``` +Seems like we have a Simpson's paradox adjacent result, since for any given location the signals are fairly well correlated when averaged over time, but at a given time, averaging over different locations suggests they're not very well correlated. +If the typical explanation applies, this means that there are large differences in the number of points. + +so, getting the counts: +```{r} +joined %>% group_by(geo_value) %>% count %>% arrange(n) %>% ungroup %>% summarise(across(where(is.numeric), .fns = list(min = min, max = max))) +``` +Each location has 82 + +```{r} +joined %>% group_by(time_value) %>% count %>% arrange(n) %>% ungroup %>% summarise(across(where(is.numeric), .fns = list(min = min, max = max))) +``` +# Covid +```{r} +library(epiprocess) +nssp_data %>% pull(signal) %>% unique +nssp_state <- nssp_data %>% + filter(geo_type == "state") %>% + mutate(time_value = epidatr:::parse_api_week(time_value)) %>% + as_epi_df(time_type = "week", geo_type = "state") %>% + select(-missing_val, -geo_type) +nssp_covid_state <- nssp_state %>% filter(signal == "pct_ed_visits_covid") %>% select(-signal) %>% drop_na %>% rename(pct_covid_visits = val) %>% as_epi_df(time_type = "week", geo_type = "state") +week_starts <- nssp_covid_state$time_value %>% unique() +covid_hhs_weekly <- covid_hhs %>% select(geo_value, time_value, value) %>% filter(time_value %in% week_starts) %>% rename(conf_admission = value) %>% drop_na %>% as_epi_df(time_type = "week", geo_type = "state") +joined_covid <- nssp_covid_state %>% left_join(covid_hhs_weekly) +``` + +After the necessary joining, lets look at the average correlations +```{r} +cor(joined_covid$pct_covid_visits, joined_covid$conf_admission, method = "spearman") +``` +So the overall correlation is pretty high, but lower than flu. + +## Correlations sliced by state +```{r} +correlations_space_covid <- epi_cor(joined_covid, pct_covid_visits, conf_admission, cor_by = "geo_value", use = "complete.obs", method = "spearman") +library(maps) # For map data +states_map <- map_data("state") +mapped <- states_map %>% as_tibble %>% mutate(geo_value = setNames(tolower(state.abb), tolower(state.name))[region]) %>% right_join(correlations_space_covid) %>% arrange(group, order) +library(viridis) +ggplot(mapped, aes(x = long, y = lat, group = group, fill = cor)) + + geom_polygon(colour = "black") + + scale_fill_viridis(discrete=FALSE, option="viridis", limits = c(0,1)) + + coord_map("polyconic") + + labs(title = "Spearman Correlations between covid ER visits and covid hospital admissions") +ggsave("covid_ER_admissions_state_correlations.pdf") +ggsave("covid_ER_admissions_state_correlations.png") +``` +Over space, hospital admissions look like they're highly correlated with ER visits (which makes sense, frequently when one is admitted it is via the ER). +The lowest overall correlation is +```{r} +correlations_space_covid %>% summarize(across(where(is.numeric), .fns = list(min = min, median = median, mean = mean, std = sd, q25 = ~quantile(.,0.25), q75 = ~quantile(.,0.75), max = max))) +``` +### Lag evaluation +```{r} +library(purrr) +lags <- 0:35 + +lagged_covid_state <- map_dfr(lags, function(lag) { + epi_cor(joined_covid, pct_covid_visits, conf_admission, cor_by = geo_value, dt1 = -lag, use = "complete.obs", method = "spearman") %>% + mutate(lag = .env$lag) +}) + +lagged_covid_state %>% + group_by(lag) %>% + summarize(mean = mean(cor, na.rm = TRUE)) %>% + ggplot(aes(x = lag, y = mean)) + + geom_line() + + geom_point() + + labs(x = "Lag", y = "Mean correlation", title = "Lag comparison for state spearman correlations for covid ER and Hosp admissions") +ggsave("covid_ER_admissions_state_lag_cor.pdf") +ggsave("covid_ER_admissions_state_lag_cor.png") +``` +Somewhat unsurprisingly, the correlation is highest immediately afterward, though its significantly lower than in the flu case. +## Correlations sliced by time +```{r} +correlations_time_covid <- epi_cor(joined_covid, pct_covid_visits, conf_admission, cor_by = "time_value", use = "complete.obs", method = "spearman") +correlations_time_covid +ggplot(correlations_time_covid, aes(x = time_value, y = cor)) + geom_line() + lims(y=c(0,1)) + labs(title = "Spearman Correlations between covid ER visits and covid hospital admissions") +ggsave("covid_ER_admissions_time_correlations.pdf") +ggsave("covid_ER_admissions_time_correlations.png") +``` +Strangely, sliced by time, we get significantly lower correlations, some of them are even negative +```{r} +correlations_time_covid %>% summarize(across(where(is.numeric), .fns = list(min = min, median = median, mean = mean, std = sd, q25 = ~quantile(.,0.25), q75 = ~quantile(.,0.75), max = max))) +``` +Seems like we have a Simpson's paradox adjacent result, since for any given location the signals are fairly well correlated when averaged over time, but at a given time, averaging over different locations suggests they're not very well correlated. +If the typical explanation applies, this means that there are large differences in the number of points. + +so, getting the counts: +```{r} +joined_covid %>% group_by(geo_value) %>% count %>% arrange(n) %>% ungroup %>% summarise(across(where(is.numeric), .fns = list(min = min, max = max))) +``` +Each location has 82 + +```{r} +joined_covid %>% group_by(time_value) %>% count %>% arrange(n) %>% ungroup %>% summarise(across(where(is.numeric), .fns = list(min = min, max = max))) +``` diff --git a/notebooks/nssp/covid_ER_admissions_state_correlations.pdf b/notebooks/nssp/covid_ER_admissions_state_correlations.pdf new file mode 100644 index 0000000000000000000000000000000000000000..35f272b039d02de08fd3941e895c00d158f4c726 GIT binary patch literal 87505 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 literal 0 HcmV?d00001 diff --git a/notebooks/nssp/covid_ER_admissions_state_lag_cor.pdf b/notebooks/nssp/covid_ER_admissions_state_lag_cor.pdf new file mode 100644 index 0000000000000000000000000000000000000000..86852e048901296dadf4d13c2f530cb02a681db6 GIT binary patch literal 7178 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 U8T+#`UrXtn<06+&SH2?qr literal 0 HcmV?d00001 diff --git a/notebooks/nssp/covid_ER_admissions_time_correlations.pdf b/notebooks/nssp/covid_ER_admissions_time_correlations.pdf new file mode 100644 index 0000000000000000000000000000000000000000..be131cd9226b2f5a5d16d029c5df003d8e70022b GIT binary patch literal 5255 zcmZ`-XH-+`(ngRL5TuDH!Um;SAO%Q30TDtMsnS7W2oMcPNTFBhRf-}=2c?J{6i}LA zp{evDf}&IbLFrBEmw1lebI-ZoUMnl>efGRFv**V=GmnH3QcDIZD-V(g8Vec^>I~|& zC4ry-1Rz|!22xfAfi*}N6a`DbBT*C#AdNf=gTUk=3J^J{yu3UNaS{YJAUOWN@650i z9PO3~piM#%U9e~}U`VA9sTA5ZBND-pipG%WZ{=m>Wg#@|XDk98Itfx$1!3@x^ekY% zvjM6uyCHR$rav{^l)Mo7Dx{r?C){LYk zAG+qzIA6lpZ2T?6`{p#C<@XB>yW@s;Cf(0_&Zf)ltna!b6vr=eaScrHmvm0LVd^=b zt1mBjjfc7WQkTIk2#e>h1ImBEeK$EZJc1GLH-@YW6$ij%CpR>$WYAXocFlP%0)li;@ae1#RcrN!0(GZ3p16su_Ui-EJ2A1k|=+^~-j zmKIEHd3sCNp)pDA;$V#;dyFTqPR_uHWDrlZnr#0u#-TWAZDf}+TWX16%QBBUBb4>% zn6X1hhML~HWMBTnC@Bbdc^aZO$lN!=Z^(u#igs?ad#hIlrJlU*$vcwbI+Lh6rVG?w zCEr=$DQ-~usM8XmHK2*qQ7_UCqPa`2`=U$p7HG<{I{Zp9Lc&?vD@{S^@--f%rvp6V zE_@twS6>`)Wl$*8sX>gSS4tdlFLG-MZrU$+YOar*g~MpD<-cJK`QClL>K#WuK#ht- z8W5jE63-8+-AGUyp6flVJoL8erq;YKDEV0q+uV%T_ytNw;%0Bz>%x1cJ`x|SM+x!Y znBD_!nknpKR|0dx12~%#$Ib-l5KcT6RE|cASmg{Aa`uj>F{GLZ$jU_)BQHH*hz-Fr zSLzAL+!KFzJmRu$A@O|Wiq8|vgWe|5#xmwoIm{Ffmnt0>vm3FAP@ac8BIOt*DZ`~; zP^;NKE}e`DYwSATU3uL~?H>q;#4P4W_KT?FgPccLTmgqQxEbF@_D#{y)q3$oV|DIq zM&jvW)n+SAW~1qcYoSVo(yVE#^(H07g;nLor_>Y%LqHp+P`EBBhZM6wT!LrrdhTKD z!xT2&x$6&^xpY4Oxm}l_g*;ZLJ6;7I4!)%t1{H;xw-!nto@yF??QppHc*NbyS3r@A z>|tdS>yXGU&9?&)yV94IOBWjxWyGFCdbclxn@u3hs-5K6-sSdj>_+(|Lu0*1ZD*TQ zqs5QoRt<&2(J@?Iz7R%jCYVdkx*QAp+4uza)hk*t6kzFVh+&(aa5wjZn_NZH&IjK| z@1j}zE{h*)XK>JTvI`=Z3 z(BG<-Sc@j_1w%?x6n@bCdGefq&!qLLsW1Bmt{EBvZG=%Rq*Ir ztqJ50xi&6k-O6Ptjdi4AdZ#_-Cyr5~st;u4f-iWdTF-E7Uu=SmEt_mt=<@s*UQ`&f z|NX4t;!fAn=!IgCxl#=_Tc*gCtNUEjFN*E*8Zo?A?1 zwU596Lu|C{LZzAz@3ZVbquMFAR05@drUTHFF*TSnziOsU7MD%{o~PI zjlVU%Z3$oddHzeLg_Wv*SOA0A{vYYI7XMeb{Hxj1d-14%!q$0c^lD-lIjp zcv9>@U@aOI0Lsc>Ef@f$JNZc_U`DM=2pfd=b9t4cUxMR^6x>N8a8cBiwq8gZj z(*LF!dQQKoCL`Iy%yjTjcxN4=&M5I<)UB@TE+&jR2kLYZWKTia59+;$XAp-Y&4@_j zgnTxb^hjWyM#5w9sL*}Jk7xG_A^Y5j{6~{@9ZQ6UpLxx-^+A@7G_$ z5WgMF{OY15dmv|yCI9GDQ$%dEvjgis&IJZ09K#5)yj=azh8ANgqwhcrE|qUXu&#y+ z__1}W_k7GthP|v?)mGwA2BVaisiloUTOq+WiYgZA5|xH;Ql82ibU^ev#I0_?%#+AV zXKZ}eQZd|0%dik-p;mIo7-nH&NNPM*iMKu`!9hrHifyABRxgPTrs#&LH4`Qt3;AAx zGe{>6$xf0@9dR<~L}}O!nQun^#H@8e9V)<&2U$&0E6A^KBL;5@OB3=Ae;?|fwA>y9zv56;P~ z@C{McU8=|-p6wI$5#U>z$kzOez{TVzHm~_2m{Zlm)Hbs2Zg4m3-W#k=j{LB})0J$= zcwg*@n!gBx^eG;I-N*5C*SsG`vo_O@_UP`^l#H#;8@qlMRTp<(A36~bht@{Is(6h% zzTMrnW|RjQG}V}9)Q(Ow@x9pp^aW291NR6M;|1x6{caA>ksJK+`;dSjf8dks!?$jf zI7k(;P_udDgW<%(lpyqoydtY?P^-iKp?xM0uFSxb3!pZ&gCU`>4+@xsQvi+E!eybB z@kb|^m^FBegm1A)XsGKj3aHIyn-mH^2%E`qnzosirW~nZIG|CPZS2CkDd5d|9KxRe z()tk(j=9{yP?0SzMDXRC1(>3!?2XD7w~0)?2j@Zz8}5woSc1+674QTeJw~kBhj@{DqSaA42?eih_*pbHp+*;Pal9232p!8!&N0t{=RGiH zHOeSd?YrD3eGRnAW1w|Cx4pY#nt^iWtWZX@td>P~TGz4Z@@bwG#Z3X0;2d-eH12S~ zMPW&Sa>jC@@}s?RN})o{F{u2 zc*R!vQpD-T%OloADWV0DuS)0{@8^T_G2Bhd3$2To4J}`8f41{+Sz^+BG3bz3d!{!Mhr9sgal)SQTcetBh1O6qcx$FxGK80Y4dZLd;d{yHtkwC-FKU z>5Y~nH%Zy*;%9ZdWR}>l)N|wPQP3AKtnE{xyuB5zk=WrTyat$Dk?*-LN8M<_N|-1$zwT3yP2lH zuD~`?o7By7mt`&+Uap^!dJL$9mp?9V;WF1N+?C$n)4$aFw&y|5*}PS= zv_HP*WIU+T*a}&@$91{IKs&o!W9nW9ItG4R7wAr+Xp z92cYmfmf)%t`2uYSUL4LklOjtZ(#;M(XuJXB;_Dz(8GXKE+4gcKAxKbX4E>1^L`FJ8)3`X1-Jrnh#I(D<>l zw$Z06e2njl>38z5MJHmg@}0-q&d-F<;gbOs0r7kAy`%fA_YDP31{SHcytp0Yci~<( z^M!X8xNo%I(0r*PM$~b5y%t})d|--W$|LkfS76uoob^{R4auQ?qH*lp9Hs05hf#dS z2fLpK);PSmk<45wUMJovK95XA>gIaq3FPtRrsZ8}@oc%(qJu8EYSC;T)f>UrNNqun z=Z&Y06$oyg8LB2Kzf{dp9NiQj*Yt{(J=1TQUca%y+a!@Lq0QSJ35}GAoL9<8XX#k! z3B3PAzn}^?Cp*WN$x2`)B+kDDPZ^%5mb@*cqUUb#Ia4`xEYl=m$?U};GmgxH&4T{> z^_MsdK4z_*RFSMxVY-u$;%279lh3{ddm^_C3omagk1D^{nttcIEnEV`tANoMj4rEf zk{|5s3v5J|*~9LRCz~cMOCrv$>tvELAq0o&!6k!MsU^SV=k`I?FYUT*vTYtcb)ejz zdb@eK>%skEZ;RPdS!jBagvS+}))v``@h6(1~cXJ=F`p?=#3q)x) zA>dWG!jBOjqz4qMuygqj3`F%kB?R?L^iS&z6DINX8@JmnZeQ{HkXV6v)Ru$mG;cE> z?VtTPRC!F=Lbeku`cb5;j?9YIeiL`++?{XFj8GQFmC=v%%k-^s4F&ZE>L2yx_8h^*X-PVZPn_`DO=8URac0il5S!2wV%X7SO&~ zQ>~j(XRay6SD}%-63Iz34i9@A>pd6>>cgC)l3kJ$S?e}$62>T&$1k2ITtP*UIRX7Hz$;d@EwcVy4`D0SDIOS zi1@wd^>)JOtnquoXPqjsx;M2yrp-4?rYgz!&LJzZpkL)gW=~zz*4_1^{_;DI?PdTK z)fttu3l1-X&!LANOP$B;t@uBi7W;u}3h6*DAOEP`BIzduK5xI_xvkrulv>bq{`8*h zR@=m&$8*nZ7)0Ql!mjPgc5i9gmBQx(dp&cQdCI`bj)hREll`4%#sTCJnOWDp+7u5y zx$61H^QX_J_)q^hys|h~5M!9-&$J%0y!Ee9Tj}>03i?Y|(Ztgv3@n}|tkB2S|C}ta z_zQS4_7?yGBe70S7@C+uS4(UFc?Ga57EdJuN^r0nl|aGZFisQ@O&|h05@`AlnT!R% z&LosOZN^2Rsq|47jU}O}u1+|NCjh2kagH>|6@?}d@BsKK2}3ue-9(|$G<^mHcEr*` zk+Ea|EQ=>l95GG+nDjGAG;Q+7;ZT3WU8s0x6p8AJLs2OJnBYvnW3JPpp?-QM(G;_@Bzs&rJYqO1(c1;9qqC z=oS6D5amh2IDsGl6bgd;egL?Ff}8^21pLNeG`Wrb0Qlb+6i(ZN{u_hB6=|!NR*$IkylbcfFvX|4Yff32RQ^7!~g&Q literal 0 HcmV?d00001 diff --git a/notebooks/nssp/flu_ER_admissions_state_correlations.pdf b/notebooks/nssp/flu_ER_admissions_state_correlations.pdf new file mode 100644 index 0000000000000000000000000000000000000000..56e04209dea5a383bcb981257d1f6c24158350fc GIT binary patch literal 87492 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 AA^-pY literal 0 HcmV?d00001 diff --git a/notebooks/nssp/flu_ER_admissions_state_lag_cor.pdf b/notebooks/nssp/flu_ER_admissions_state_lag_cor.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d08c84c32fd46d6cb6756bde92bdc2dc8ed0fa9d GIT binary patch literal 7170 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 YWnaA1KKWxBvhE literal 0 HcmV?d00001 diff --git a/notebooks/nssp/flu_ER_admissions_time_correlations.pdf b/notebooks/nssp/flu_ER_admissions_time_correlations.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c00c1479e13b71aa83c4913478ea569dd9df215a GIT binary patch literal 5276 zcmZ`-c|6qX_ZQhFWe+8W2o;~1F~-Pe_B~tCMp=@gawQ?VD3Tca z8d)oAR47^Uo$220_kQpF{XVbP=a1+8oaZ^`Ie(n@Id5qTU440|0vseAH4`-()g9IE zLIOd-5HQj6H;}qI2tbjrXbO%<&_z?QU|C&N7z74~C_xmViV!3WAqN6XiC+Kjo(+zI zr=PL{8<5ZezBmjSY)+*FP$~3d79^q<6@w))cHs(e1qdDc>5IsKP7_EAFd4QBLxtcY7P2rvZ zHI|ZpOO$NbHJd%XKSVD|vv0EBylz-atLS{49CB47b@FXpUHg?HJ`KpVP6dGwhyH{G z<>m3cTx34r?z-3UWn-{(Ygos60!AB#>`|rbah|Z`4TJ8 zBi7f_D(I7qHB*D5lM94E-gs!=rmfJP-(IY59!Im9kuk>)so3x!k8{#}=T1iDv}jD2 z3~!G$R67a9hyZNa$!qn(ok7r7WwrBkp!m##u2q)tuKN(ld9fBrg?BHuxY$_~di6Ag zI|CrEYHFMgu_=mjrFE#QrC1%>N3Cw*FQ|sMD9%b?mDIXWuT|Z+Vv{>ieLnTV0`9IN z8JZY!wLsefPcK(OB438ZShGibOw`pWFKFhp$5iT>S^Lrb<;xe**H5dM`ZY@wN8I9+ zh(>T7w;LJJYY{2&I2xc({aR|I1z%k=V0f}K+EcVY_HOWvTQiA|S&X_A1Y6~P%ZV+c zbs5v%>yAH3IwZ`=m5Q{r_jhgbl!!+Zm(0yuK_VW$oeXZGRXiT`Ntdqn+@5woB^~L6 zXZPIU&(E|xR(WZnjz^YcRV7dm8K=WqKB&nS2(>h66?DF2xpZUd+~B6?mz%9N$%RIN zy)oAJPZB&1V@*;FlehS#ae-FM>AFx+UPyYufu~yb?|ox$Nyx=pfx3+Epjn3BNyf&m zwREATI8NJDW0y*5?>d#fbr%fAl$#$h2l;9)y7yNw8C+(ai$dYQUWRjs2KZM{kw4dGl@f=r7&16?qKQK^ZYFcRyXm6R=clxx z^dRm%ZF1g0Wf>%QMUpo#g@ON&E;rzAe=~oXSVvrO<|xi%N82PGSUMrj2Gyo$bbl!k zs+@2tpO6uYe-nZlPWH7_D2Yl7QlE+KjxP$~j zC^n-td1lS$&%B43QRd*n^N zZ~9Rlmt=JHO+>$aeC}^$+a?hraZzrpSlZ3i6zz1`eG$Jmilj0LanAkd^V*Twza-?i z+YV;q5_z)p?RdJZ=y8AAN(+FBJZsCajn{Z^XZSVQQuW6Neml059s$IY2$)jf#CC1Pxm z1;HMUTJ?JdvrSLJgMM5pjKc1I+7ds=GWU2n!3v@kU$!t-t09Jr--me?4kvQ0wTAEd zjtj?ZT5kE3zf^K#=XtNQ4%JwjzEE?ur~eZ&dj*)RbMHaDX%81$(R#*`ZRTnq$1y#U zkhUSg`VAiv$xT!cBUUYGHM;Og);aiVmI9M~wBp_z#Ydtm!3dX2j@mw~6`OBZBF^sx znDy-0>+~7vaCK+&p0Nzkf237n|7kVL+pDg±kWVZ(bP=t4wQf!6RPcYsrIxHTlq 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 Q8Mulv5+p6HXRZ(W4>J)Xga7~l literal 0 HcmV?d00001 diff --git a/notebooks/renv.lock b/notebooks/renv.lock new file mode 100644 index 000000000..dd103cd87 --- /dev/null +++ b/notebooks/renv.lock @@ -0,0 +1,1367 @@ +{ + "R": { + "Version": "4.4.0", + "Repositories": [ + { + "Name": "RSPM", + "URL": "https://packagemanager.posit.co/all/latest" + } + ] + }, + "Packages": { + "DBI": { + "Package": "DBI", + "Version": "1.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "164809cd72e1d5160b4cb3aa57f510fe" + }, + "KernSmooth": { + "Package": "KernSmooth", + "Version": "2.23-22", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "stats" + ], + "Hash": "2fecebc3047322fa5930f74fae5de70f" + }, + "MASS": { + "Package": "MASS", + "Version": "7.3-60.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats", + "utils" + ], + "Hash": "2f342c46163b0b54d7b64d1f798e2c78" + }, + "MMWRweek": { + "Package": "MMWRweek", + "Version": "0.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "4329e57e2536e12afe479e8571416dbc" + }, + "Matrix": { + "Package": "Matrix", + "Version": "1.7-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "grid", + "lattice", + "methods", + "stats", + "utils" + ], + "Hash": "1920b2f11133b12350024297d8a4ff4a" + }, + "R6": { + "Package": "R6", + "Version": "2.5.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "470851b6d5d0ac559e9d01bb352b4021" + }, + "RColorBrewer": { + "Package": "RColorBrewer", + "Version": "1.1-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "45f0398006e83a5b10b72a90663d8d8c" + }, + "Rcpp": { + "Package": "Rcpp", + "Version": "1.0.12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods", + "utils" + ], + "Hash": "5ea2700d21e038ace58269ecdbeb9ec0" + }, + "askpass": { + "Package": "askpass", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "sys" + ], + "Hash": "cad6cf7f1d5f6e906700b9d3e718c796" + }, + "backports": { + "Package": "backports", + "Version": "1.4.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "c39fbec8a30d23e721980b8afb31984c" + }, + "base64enc": { + "Package": "base64enc", + "Version": "0.1-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "543776ae6848fde2f48ff3816d0628bc" + }, + "bit": { + "Package": "bit", + "Version": "4.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "d242abec29412ce988848d0294b208fd" + }, + "bit64": { + "Package": "bit64", + "Version": "4.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bit", + "methods", + "stats", + "utils" + ], + "Hash": "9fe98599ca456d6552421db0d6772d8f" + }, + "bslib": { + "Package": "bslib", + "Version": "0.7.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "base64enc", + "cachem", + "fastmap", + "grDevices", + "htmltools", + "jquerylib", + "jsonlite", + "lifecycle", + "memoise", + "mime", + "rlang", + "sass" + ], + "Hash": "8644cc53f43828f19133548195d7e59e" + }, + "cachem": { + "Package": "cachem", + "Version": "1.0.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "fastmap", + "rlang" + ], + "Hash": "c35768291560ce302c0a6589f92e837d" + }, + "checkmate": { + "Package": "checkmate", + "Version": "2.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "backports", + "utils" + ], + "Hash": "c01cab1cb0f9125211a6fc99d540e315" + }, + "class": { + "Package": "class", + "Version": "7.3-22", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "MASS", + "R", + "stats", + "utils" + ], + "Hash": "f91f6b29f38b8c280f2b9477787d4bb2" + }, + "classInt": { + "Package": "classInt", + "Version": "0.4-10", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "KernSmooth", + "R", + "class", + "e1071", + "grDevices", + "graphics", + "stats" + ], + "Hash": "f5a40793b1ae463a7ffb3902a95bf864" + }, + "cli": { + "Package": "cli", + "Version": "3.6.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "utils" + ], + "Hash": "1216ac65ac55ec0058a6f75d7ca0fd52" + }, + "clipr": { + "Package": "clipr", + "Version": "0.8.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" + }, + "colorspace": { + "Package": "colorspace", + "Version": "2.1-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats" + ], + "Hash": "f20c47fd52fae58b4e377c37bb8c335b" + }, + "covidcast": { + "Package": "covidcast", + "Version": "0.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MMWRweek", + "R", + "dplyr", + "ggplot2", + "grDevices", + "httr", + "purrr", + "rlang", + "sf", + "tidyr", + "xml2" + ], + "Hash": "ee88255e014ff787bd3db3f4735fb24a" + }, + "cpp11": { + "Package": "cpp11", + "Version": "0.4.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "5a295d7d963cc5035284dcdbaf334f4e" + }, + "crayon": { + "Package": "crayon", + "Version": "1.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grDevices", + "methods", + "utils" + ], + "Hash": "e8a1e41acf02548751f45c718d55aa6a" + }, + "credentials": { + "Package": "credentials", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass", + "curl", + "jsonlite", + "openssl", + "sys" + ], + "Hash": "c7844b32098dcbd1c59cbd8dddb4ecc6" + }, + "curl": { + "Package": "curl", + "Version": "5.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "411ca2c03b1ce5f548345d2fc2685f7a" + }, + "desc": { + "Package": "desc", + "Version": "1.4.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "utils" + ], + "Hash": "99b79fcbd6c4d1ce087f5c5c758b384f" + }, + "digest": { + "Package": "digest", + "Version": "0.6.35", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "utils" + ], + "Hash": "698ece7ba5a4fa4559e3d537e7ec3d31" + }, + "dplyr": { + "Package": "dplyr", + "Version": "1.1.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "generics", + "glue", + "lifecycle", + "magrittr", + "methods", + "pillar", + "rlang", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "fedd9d00c2944ff00a0e2696ccf048ec" + }, + "e1071": { + "Package": "e1071", + "Version": "1.7-14", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "class", + "grDevices", + "graphics", + "methods", + "proxy", + "stats", + "utils" + ], + "Hash": "4ef372b716824753719a8a38b258442d" + }, + "epidatr": { + "Package": "epidatr", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MMWRweek", + "R", + "cachem", + "checkmate", + "cli", + "glue", + "httr", + "jsonlite", + "magrittr", + "openssl", + "purrr", + "rappdirs", + "readr", + "tibble", + "usethis", + "xml2" + ], + "Hash": "cf6f60be321bfd49298e27717be8c2b2" + }, + "evaluate": { + "Package": "evaluate", + "Version": "0.23", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "daf4a1246be12c1fa8c7705a0935c1a0" + }, + "fansi": { + "Package": "fansi", + "Version": "1.0.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "utils" + ], + "Hash": "962174cf2aeb5b9eea581522286a911f" + }, + "farver": { + "Package": "farver", + "Version": "2.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "8106d78941f34855c440ddb946b8f7a5" + }, + "fastmap": { + "Package": "fastmap", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "f7736a18de97dea803bde0a2daaafb27" + }, + "fontawesome": { + "Package": "fontawesome", + "Version": "0.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "htmltools", + "rlang" + ], + "Hash": "c2efdd5f0bcd1ea861c2d4e2a883a67d" + }, + "fs": { + "Package": "fs", + "Version": "1.6.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "15aeb8c27f5ea5161f9f6a641fafd93a" + }, + "generics": { + "Package": "generics", + "Version": "0.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "15e9634c0fcd294799e9b2e929ed1b86" + }, + "gert": { + "Package": "gert", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass", + "credentials", + "openssl", + "rstudioapi", + "sys", + "zip" + ], + "Hash": "f70d3fe2d9e7654213a946963d1591eb" + }, + "ggplot2": { + "Package": "ggplot2", + "Version": "3.5.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "R", + "cli", + "glue", + "grDevices", + "grid", + "gtable", + "isoband", + "lifecycle", + "mgcv", + "rlang", + "scales", + "stats", + "tibble", + "vctrs", + "withr" + ], + "Hash": "44c6a2f8202d5b7e878ea274b1092426" + }, + "gh": { + "Package": "gh", + "Version": "1.4.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "gitcreds", + "glue", + "httr2", + "ini", + "jsonlite", + "lifecycle", + "rlang" + ], + "Hash": "fbbbc48eba7a6626a08bb365e44b563b" + }, + "gitcreds": { + "Package": "gitcreds", + "Version": "0.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "ab08ac61f3e1be454ae21911eb8bc2fe" + }, + "glue": { + "Package": "glue", + "Version": "1.7.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "e0b3a53876554bd45879e596cdb10a52" + }, + "gtable": { + "Package": "gtable", + "Version": "0.3.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "grid", + "lifecycle", + "rlang" + ], + "Hash": "e18861963cbc65a27736e02b3cd3c4a0" + }, + "highr": { + "Package": "highr", + "Version": "0.10", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "xfun" + ], + "Hash": "06230136b2d2b9ba5805e1963fa6e890" + }, + "hms": { + "Package": "hms", + "Version": "1.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "lifecycle", + "methods", + "pkgconfig", + "rlang", + "vctrs" + ], + "Hash": "b59377caa7ed00fa41808342002138f9" + }, + "htmltools": { + "Package": "htmltools", + "Version": "0.5.8.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "base64enc", + "digest", + "fastmap", + "grDevices", + "rlang", + "utils" + ], + "Hash": "81d371a9cc60640e74e4ab6ac46dcedc" + }, + "httr": { + "Package": "httr", + "Version": "1.4.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "curl", + "jsonlite", + "mime", + "openssl" + ], + "Hash": "ac107251d9d9fd72f0ca8049988f1d7f" + }, + "httr2": { + "Package": "httr2", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "curl", + "glue", + "lifecycle", + "magrittr", + "openssl", + "rappdirs", + "rlang", + "vctrs", + "withr" + ], + "Hash": "03d741c92fda96d98c3a3f22494e3b4a" + }, + "ini": { + "Package": "ini", + "Version": "0.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "6154ec2223172bce8162d4153cda21f7" + }, + "isoband": { + "Package": "isoband", + "Version": "0.2.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grid", + "utils" + ], + "Hash": "0080607b4a1a7b28979aecef976d8bc2" + }, + "jquerylib": { + "Package": "jquerylib", + "Version": "0.1.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "htmltools" + ], + "Hash": "5aab57a3bd297eee1c1d862735972182" + }, + "jsonlite": { + "Package": "jsonlite", + "Version": "1.8.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods" + ], + "Hash": "e1b9c55281c5adc4dd113652d9e26768" + }, + "knitr": { + "Package": "knitr", + "Version": "1.46", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "evaluate", + "highr", + "methods", + "tools", + "xfun", + "yaml" + ], + "Hash": "6e008ab1d696a5283c79765fa7b56b47" + }, + "labeling": { + "Package": "labeling", + "Version": "0.4.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "graphics", + "stats" + ], + "Hash": "b64ec208ac5bc1852b285f665d6368b3" + }, + "lattice": { + "Package": "lattice", + "Version": "0.22-6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "grid", + "stats", + "utils" + ], + "Hash": "cc5ac1ba4c238c7ca9fa6a87ca11a7e2" + }, + "lifecycle": { + "Package": "lifecycle", + "Version": "1.0.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "rlang" + ], + "Hash": "b8552d117e1b808b09a832f589b79035" + }, + "magrittr": { + "Package": "magrittr", + "Version": "2.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "7ce2733a9826b3aeb1775d56fd305472" + }, + "memoise": { + "Package": "memoise", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "cachem", + "rlang" + ], + "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c" + }, + "mgcv": { + "Package": "mgcv", + "Version": "1.9-1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "Matrix", + "R", + "graphics", + "methods", + "nlme", + "splines", + "stats", + "utils" + ], + "Hash": "110ee9d83b496279960e162ac97764ce" + }, + "mime": { + "Package": "mime", + "Version": "0.12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "tools" + ], + "Hash": "18e9c28c1d3ca1560ce30658b22ce104" + }, + "munsell": { + "Package": "munsell", + "Version": "0.5.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "colorspace", + "methods" + ], + "Hash": "4fd8900853b746af55b81fda99da7695" + }, + "nlme": { + "Package": "nlme", + "Version": "3.1-164", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "graphics", + "lattice", + "stats", + "utils" + ], + "Hash": "a623a2239e642806158bc4dc3f51565d" + }, + "openssl": { + "Package": "openssl", + "Version": "2.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass" + ], + "Hash": "ea2475b073243d9d338aa8f086ce973e" + }, + "pillar": { + "Package": "pillar", + "Version": "1.9.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "cli", + "fansi", + "glue", + "lifecycle", + "rlang", + "utf8", + "utils", + "vctrs" + ], + "Hash": "15da5a8412f317beeee6175fbc76f4bb" + }, + "pkgconfig": { + "Package": "pkgconfig", + "Version": "2.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "01f28d4278f15c76cddbea05899c5d6f" + }, + "prettyunits": { + "Package": "prettyunits", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "6b01fc98b1e86c4f705ce9dcfd2f57c7" + }, + "progress": { + "Package": "progress", + "Version": "1.2.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "crayon", + "hms", + "prettyunits" + ], + "Hash": "f4625e061cb2865f111b47ff163a5ca6" + }, + "proxy": { + "Package": "proxy", + "Version": "0.4-27", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils" + ], + "Hash": "e0ef355c12942cf7a6b91a6cfaea8b3e" + }, + "purrr": { + "Package": "purrr", + "Version": "1.0.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "lifecycle", + "magrittr", + "rlang", + "vctrs" + ], + "Hash": "1cba04a4e9414bdefc9dcaa99649a8dc" + }, + "rappdirs": { + "Package": "rappdirs", + "Version": "0.3.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "5e3c5dc0b071b21fa128676560dbe94d" + }, + "readr": { + "Package": "readr", + "Version": "2.1.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "clipr", + "cpp11", + "crayon", + "hms", + "lifecycle", + "methods", + "rlang", + "tibble", + "tzdb", + "utils", + "vroom" + ], + "Hash": "9de96463d2117f6ac49980577939dfb3" + }, + "renv": { + "Package": "renv", + "Version": "1.0.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "397b7b2a265bc5a7a06852524dabae20" + }, + "rlang": { + "Package": "rlang", + "Version": "1.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "utils" + ], + "Hash": "42548638fae05fd9a9b5f3f437fbbbe2" + }, + "rmarkdown": { + "Package": "rmarkdown", + "Version": "2.26", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bslib", + "evaluate", + "fontawesome", + "htmltools", + "jquerylib", + "jsonlite", + "knitr", + "methods", + "tinytex", + "tools", + "utils", + "xfun", + "yaml" + ], + "Hash": "9b148e7f95d33aac01f31282d49e4f44" + }, + "rprojroot": { + "Package": "rprojroot", + "Version": "2.0.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "4c8415e0ec1e29f3f4f6fc108bef0144" + }, + "rstudioapi": { + "Package": "rstudioapi", + "Version": "0.16.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "96710351d642b70e8f02ddeb237c46a7" + }, + "s2": { + "Package": "s2", + "Version": "1.1.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "wk" + ], + "Hash": "32f7b1a15bb01ae809022960abad5363" + }, + "sass": { + "Package": "sass", + "Version": "0.4.9", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R6", + "fs", + "htmltools", + "rappdirs", + "rlang" + ], + "Hash": "d53dbfddf695303ea4ad66f86e99b95d" + }, + "scales": { + "Package": "scales", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "RColorBrewer", + "cli", + "farver", + "glue", + "labeling", + "lifecycle", + "munsell", + "rlang", + "viridisLite" + ], + "Hash": "c19df082ba346b0ffa6f833e92de34d1" + }, + "sf": { + "Package": "sf", + "Version": "1.0-16", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "R", + "Rcpp", + "classInt", + "grDevices", + "graphics", + "grid", + "magrittr", + "methods", + "s2", + "stats", + "tools", + "units", + "utils" + ], + "Hash": "ad57b543f7c3fca05213ba78ff63df9b" + }, + "stringi": { + "Package": "stringi", + "Version": "1.8.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "tools", + "utils" + ], + "Hash": "058aebddea264f4c99401515182e656a" + }, + "stringr": { + "Package": "stringr", + "Version": "1.5.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "magrittr", + "rlang", + "stringi", + "vctrs" + ], + "Hash": "960e2ae9e09656611e0b8214ad543207" + }, + "sys": { + "Package": "sys", + "Version": "3.4.2", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "3a1be13d68d47a8cd0bfd74739ca1555" + }, + "tibble": { + "Package": "tibble", + "Version": "3.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "fansi", + "lifecycle", + "magrittr", + "methods", + "pillar", + "pkgconfig", + "rlang", + "utils", + "vctrs" + ], + "Hash": "a84e2cc86d07289b3b6f5069df7a004c" + }, + "tidyr": { + "Package": "tidyr", + "Version": "1.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "cpp11", + "dplyr", + "glue", + "lifecycle", + "magrittr", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "915fb7ce036c22a6a33b5a8adb712eb1" + }, + "tidyselect": { + "Package": "tidyselect", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "rlang", + "vctrs", + "withr" + ], + "Hash": "829f27b9c4919c16b593794a6344d6c0" + }, + "tinytex": { + "Package": "tinytex", + "Version": "0.50", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "xfun" + ], + "Hash": "be7a76845222ad20adb761f462eed3ea" + }, + "tzdb": { + "Package": "tzdb", + "Version": "0.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "f561504ec2897f4d46f0c7657e488ae1" + }, + "units": { + "Package": "units", + "Version": "0.8-5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp" + ], + "Hash": "119d19da480e873f72241ff6962ffd83" + }, + "usethis": { + "Package": "usethis", + "Version": "2.2.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "clipr", + "crayon", + "curl", + "desc", + "fs", + "gert", + "gh", + "glue", + "jsonlite", + "lifecycle", + "purrr", + "rappdirs", + "rlang", + "rprojroot", + "rstudioapi", + "stats", + "utils", + "whisker", + "withr", + "yaml" + ], + "Hash": "d524fd42c517035027f866064417d7e6" + }, + "utf8": { + "Package": "utf8", + "Version": "1.2.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "62b65c52671e6665f803ff02954446e9" + }, + "vctrs": { + "Package": "vctrs", + "Version": "0.6.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "rlang" + ], + "Hash": "c03fa420630029418f7e6da3667aac4a" + }, + "viridisLite": { + "Package": "viridisLite", + "Version": "0.4.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "c826c7c4241b6fc89ff55aaea3fa7491" + }, + "vroom": { + "Package": "vroom", + "Version": "1.6.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bit64", + "cli", + "cpp11", + "crayon", + "glue", + "hms", + "lifecycle", + "methods", + "progress", + "rlang", + "stats", + "tibble", + "tidyselect", + "tzdb", + "vctrs", + "withr" + ], + "Hash": "390f9315bc0025be03012054103d227c" + }, + "whisker": { + "Package": "whisker", + "Version": "0.4.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "c6abfa47a46d281a7d5159d0a8891e88" + }, + "withr": { + "Package": "withr", + "Version": "3.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics" + ], + "Hash": "d31b6c62c10dcf11ec530ca6b0dd5d35" + }, + "wk": { + "Package": "wk", + "Version": "0.9.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "5d4545e140e36476f35f20d0ca87963e" + }, + "xfun": { + "Package": "xfun", + "Version": "0.43", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grDevices", + "stats", + "tools" + ], + "Hash": "ab6371d8653ce5f2f9290f4ec7b42a8e" + }, + "xml2": { + "Package": "xml2", + "Version": "1.3.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "methods", + "rlang" + ], + "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" + }, + "yaml": { + "Package": "yaml", + "Version": "2.3.8", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "29240487a071f535f5e5d5a323b7afbd" + }, + "zip": { + "Package": "zip", + "Version": "2.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "fcc4bd8e6da2d2011eb64a5e5cc685ab" + } + } +} diff --git a/notebooks/renv/.gitignore b/notebooks/renv/.gitignore new file mode 100644 index 000000000..0ec0cbba2 --- /dev/null +++ b/notebooks/renv/.gitignore @@ -0,0 +1,7 @@ +library/ +local/ +cellar/ +lock/ +python/ +sandbox/ +staging/ diff --git a/notebooks/renv/activate.R b/notebooks/renv/activate.R new file mode 100644 index 000000000..d13f9932a --- /dev/null +++ b/notebooks/renv/activate.R @@ -0,0 +1,1220 @@ + +local({ + + # the requested version of renv + version <- "1.0.7" + attr(version, "sha") <- NULL + + # the project directory + project <- Sys.getenv("RENV_PROJECT") + if (!nzchar(project)) + project <- getwd() + + # use start-up diagnostics if enabled + diagnostics <- Sys.getenv("RENV_STARTUP_DIAGNOSTICS", unset = "FALSE") + if (diagnostics) { + start <- Sys.time() + profile <- tempfile("renv-startup-", fileext = ".Rprof") + utils::Rprof(profile) + on.exit({ + utils::Rprof(NULL) + elapsed <- signif(difftime(Sys.time(), start, units = "auto"), digits = 2L) + writeLines(sprintf("- renv took %s to run the autoloader.", format(elapsed))) + writeLines(sprintf("- Profile: %s", profile)) + print(utils::summaryRprof(profile)) + }, add = TRUE) + } + + # figure out whether the autoloader is enabled + enabled <- local({ + + # first, check config option + override <- getOption("renv.config.autoloader.enabled") + if (!is.null(override)) + return(override) + + # if we're being run in a context where R_LIBS is already set, + # don't load -- presumably we're being run as a sub-process and + # the parent process has already set up library paths for us + rcmd <- Sys.getenv("R_CMD", unset = NA) + rlibs <- Sys.getenv("R_LIBS", unset = NA) + if (!is.na(rlibs) && !is.na(rcmd)) + return(FALSE) + + # next, check environment variables + # TODO: prefer using the configuration one in the future + envvars <- c( + "RENV_CONFIG_AUTOLOADER_ENABLED", + "RENV_AUTOLOADER_ENABLED", + "RENV_ACTIVATE_PROJECT" + ) + + for (envvar in envvars) { + envval <- Sys.getenv(envvar, unset = NA) + if (!is.na(envval)) + return(tolower(envval) %in% c("true", "t", "1")) + } + + # enable by default + TRUE + + }) + + # bail if we're not enabled + if (!enabled) { + + # if we're not enabled, we might still need to manually load + # the user profile here + profile <- Sys.getenv("R_PROFILE_USER", unset = "~/.Rprofile") + if (file.exists(profile)) { + cfg <- Sys.getenv("RENV_CONFIG_USER_PROFILE", unset = "TRUE") + if (tolower(cfg) %in% c("true", "t", "1")) + sys.source(profile, envir = globalenv()) + } + + return(FALSE) + + } + + # avoid recursion + if (identical(getOption("renv.autoloader.running"), TRUE)) { + warning("ignoring recursive attempt to run renv autoloader") + return(invisible(TRUE)) + } + + # signal that we're loading renv during R startup + options(renv.autoloader.running = TRUE) + on.exit(options(renv.autoloader.running = NULL), add = TRUE) + + # signal that we've consented to use renv + options(renv.consent = TRUE) + + # load the 'utils' package eagerly -- this ensures that renv shims, which + # mask 'utils' packages, will come first on the search path + library(utils, lib.loc = .Library) + + # unload renv if it's already been loaded + if ("renv" %in% loadedNamespaces()) + unloadNamespace("renv") + + # load bootstrap tools + `%||%` <- function(x, y) { + if (is.null(x)) y else x + } + + catf <- function(fmt, ..., appendLF = TRUE) { + + quiet <- getOption("renv.bootstrap.quiet", default = FALSE) + if (quiet) + return(invisible()) + + msg <- sprintf(fmt, ...) + cat(msg, file = stdout(), sep = if (appendLF) "\n" else "") + + invisible(msg) + + } + + header <- function(label, + ..., + prefix = "#", + suffix = "-", + n = min(getOption("width"), 78)) + { + label <- sprintf(label, ...) + n <- max(n - nchar(label) - nchar(prefix) - 2L, 8L) + if (n <= 0) + return(paste(prefix, label)) + + tail <- paste(rep.int(suffix, n), collapse = "") + paste0(prefix, " ", label, " ", tail) + + } + + heredoc <- function(text, leave = 0) { + + # remove leading, trailing whitespace + trimmed <- gsub("^\\s*\\n|\\n\\s*$", "", text) + + # split into lines + lines <- strsplit(trimmed, "\n", fixed = TRUE)[[1L]] + + # compute common indent + indent <- regexpr("[^[:space:]]", lines) + common <- min(setdiff(indent, -1L)) - leave + paste(substring(lines, common), collapse = "\n") + + } + + startswith <- function(string, prefix) { + substring(string, 1, nchar(prefix)) == prefix + } + + bootstrap <- function(version, library) { + + friendly <- renv_bootstrap_version_friendly(version) + section <- header(sprintf("Bootstrapping renv %s", friendly)) + catf(section) + + # attempt to download renv + catf("- Downloading renv ... ", appendLF = FALSE) + withCallingHandlers( + tarball <- renv_bootstrap_download(version), + error = function(err) { + catf("FAILED") + stop("failed to download:\n", conditionMessage(err)) + } + ) + catf("OK") + on.exit(unlink(tarball), add = TRUE) + + # now attempt to install + catf("- Installing renv ... ", appendLF = FALSE) + withCallingHandlers( + status <- renv_bootstrap_install(version, tarball, library), + error = function(err) { + catf("FAILED") + stop("failed to install:\n", conditionMessage(err)) + } + ) + catf("OK") + + # add empty line to break up bootstrapping from normal output + catf("") + + return(invisible()) + } + + renv_bootstrap_tests_running <- function() { + getOption("renv.tests.running", default = FALSE) + } + + renv_bootstrap_repos <- function() { + + # get CRAN repository + cran <- getOption("renv.repos.cran", "https://cloud.r-project.org") + + # check for repos override + repos <- Sys.getenv("RENV_CONFIG_REPOS_OVERRIDE", unset = NA) + if (!is.na(repos)) { + + # check for RSPM; if set, use a fallback repository for renv + rspm <- Sys.getenv("RSPM", unset = NA) + if (identical(rspm, repos)) + repos <- c(RSPM = rspm, CRAN = cran) + + return(repos) + + } + + # check for lockfile repositories + repos <- tryCatch(renv_bootstrap_repos_lockfile(), error = identity) + if (!inherits(repos, "error") && length(repos)) + return(repos) + + # retrieve current repos + repos <- getOption("repos") + + # ensure @CRAN@ entries are resolved + repos[repos == "@CRAN@"] <- cran + + # add in renv.bootstrap.repos if set + default <- c(FALLBACK = "https://cloud.r-project.org") + extra <- getOption("renv.bootstrap.repos", default = default) + repos <- c(repos, extra) + + # remove duplicates that might've snuck in + dupes <- duplicated(repos) | duplicated(names(repos)) + repos[!dupes] + + } + + renv_bootstrap_repos_lockfile <- function() { + + lockpath <- Sys.getenv("RENV_PATHS_LOCKFILE", unset = "renv.lock") + if (!file.exists(lockpath)) + return(NULL) + + lockfile <- tryCatch(renv_json_read(lockpath), error = identity) + if (inherits(lockfile, "error")) { + warning(lockfile) + return(NULL) + } + + repos <- lockfile$R$Repositories + if (length(repos) == 0) + return(NULL) + + keys <- vapply(repos, `[[`, "Name", FUN.VALUE = character(1)) + vals <- vapply(repos, `[[`, "URL", FUN.VALUE = character(1)) + names(vals) <- keys + + return(vals) + + } + + renv_bootstrap_download <- function(version) { + + sha <- attr(version, "sha", exact = TRUE) + + methods <- if (!is.null(sha)) { + + # attempting to bootstrap a development version of renv + c( + function() renv_bootstrap_download_tarball(sha), + function() renv_bootstrap_download_github(sha) + ) + + } else { + + # attempting to bootstrap a release version of renv + c( + function() renv_bootstrap_download_tarball(version), + function() renv_bootstrap_download_cran_latest(version), + function() renv_bootstrap_download_cran_archive(version) + ) + + } + + for (method in methods) { + path <- tryCatch(method(), error = identity) + if (is.character(path) && file.exists(path)) + return(path) + } + + stop("All download methods failed") + + } + + renv_bootstrap_download_impl <- function(url, destfile) { + + mode <- "wb" + + # https://bugs.r-project.org/bugzilla/show_bug.cgi?id=17715 + fixup <- + Sys.info()[["sysname"]] == "Windows" && + substring(url, 1L, 5L) == "file:" + + if (fixup) + mode <- "w+b" + + args <- list( + url = url, + destfile = destfile, + mode = mode, + quiet = TRUE + ) + + if ("headers" %in% names(formals(utils::download.file))) + args$headers <- renv_bootstrap_download_custom_headers(url) + + do.call(utils::download.file, args) + + } + + renv_bootstrap_download_custom_headers <- function(url) { + + headers <- getOption("renv.download.headers") + if (is.null(headers)) + return(character()) + + if (!is.function(headers)) + stopf("'renv.download.headers' is not a function") + + headers <- headers(url) + if (length(headers) == 0L) + return(character()) + + if (is.list(headers)) + headers <- unlist(headers, recursive = FALSE, use.names = TRUE) + + ok <- + is.character(headers) && + is.character(names(headers)) && + all(nzchar(names(headers))) + + if (!ok) + stop("invocation of 'renv.download.headers' did not return a named character vector") + + headers + + } + + renv_bootstrap_download_cran_latest <- function(version) { + + spec <- renv_bootstrap_download_cran_latest_find(version) + type <- spec$type + repos <- spec$repos + + baseurl <- utils::contrib.url(repos = repos, type = type) + ext <- if (identical(type, "source")) + ".tar.gz" + else if (Sys.info()[["sysname"]] == "Windows") + ".zip" + else + ".tgz" + name <- sprintf("renv_%s%s", version, ext) + url <- paste(baseurl, name, sep = "/") + + destfile <- file.path(tempdir(), name) + status <- tryCatch( + renv_bootstrap_download_impl(url, destfile), + condition = identity + ) + + if (inherits(status, "condition")) + return(FALSE) + + # report success and return + destfile + + } + + renv_bootstrap_download_cran_latest_find <- function(version) { + + # check whether binaries are supported on this system + binary <- + getOption("renv.bootstrap.binary", default = TRUE) && + !identical(.Platform$pkgType, "source") && + !identical(getOption("pkgType"), "source") && + Sys.info()[["sysname"]] %in% c("Darwin", "Windows") + + types <- c(if (binary) "binary", "source") + + # iterate over types + repositories + for (type in types) { + for (repos in renv_bootstrap_repos()) { + + # retrieve package database + db <- tryCatch( + as.data.frame( + utils::available.packages(type = type, repos = repos), + stringsAsFactors = FALSE + ), + error = identity + ) + + if (inherits(db, "error")) + next + + # check for compatible entry + entry <- db[db$Package %in% "renv" & db$Version %in% version, ] + if (nrow(entry) == 0) + next + + # found it; return spec to caller + spec <- list(entry = entry, type = type, repos = repos) + return(spec) + + } + } + + # if we got here, we failed to find renv + fmt <- "renv %s is not available from your declared package repositories" + stop(sprintf(fmt, version)) + + } + + renv_bootstrap_download_cran_archive <- function(version) { + + name <- sprintf("renv_%s.tar.gz", version) + repos <- renv_bootstrap_repos() + urls <- file.path(repos, "src/contrib/Archive/renv", name) + destfile <- file.path(tempdir(), name) + + for (url in urls) { + + status <- tryCatch( + renv_bootstrap_download_impl(url, destfile), + condition = identity + ) + + if (identical(status, 0L)) + return(destfile) + + } + + return(FALSE) + + } + + renv_bootstrap_download_tarball <- function(version) { + + # if the user has provided the path to a tarball via + # an environment variable, then use it + tarball <- Sys.getenv("RENV_BOOTSTRAP_TARBALL", unset = NA) + if (is.na(tarball)) + return() + + # allow directories + if (dir.exists(tarball)) { + name <- sprintf("renv_%s.tar.gz", version) + tarball <- file.path(tarball, name) + } + + # bail if it doesn't exist + if (!file.exists(tarball)) { + + # let the user know we weren't able to honour their request + fmt <- "- RENV_BOOTSTRAP_TARBALL is set (%s) but does not exist." + msg <- sprintf(fmt, tarball) + warning(msg) + + # bail + return() + + } + + catf("- Using local tarball '%s'.", tarball) + tarball + + } + + renv_bootstrap_download_github <- function(version) { + + enabled <- Sys.getenv("RENV_BOOTSTRAP_FROM_GITHUB", unset = "TRUE") + if (!identical(enabled, "TRUE")) + return(FALSE) + + # prepare download options + pat <- Sys.getenv("GITHUB_PAT") + if (nzchar(Sys.which("curl")) && nzchar(pat)) { + fmt <- "--location --fail --header \"Authorization: token %s\"" + extra <- sprintf(fmt, pat) + saved <- options("download.file.method", "download.file.extra") + options(download.file.method = "curl", download.file.extra = extra) + on.exit(do.call(base::options, saved), add = TRUE) + } else if (nzchar(Sys.which("wget")) && nzchar(pat)) { + fmt <- "--header=\"Authorization: token %s\"" + extra <- sprintf(fmt, pat) + saved <- options("download.file.method", "download.file.extra") + options(download.file.method = "wget", download.file.extra = extra) + on.exit(do.call(base::options, saved), add = TRUE) + } + + url <- file.path("https://api.github.com/repos/rstudio/renv/tarball", version) + name <- sprintf("renv_%s.tar.gz", version) + destfile <- file.path(tempdir(), name) + + status <- tryCatch( + renv_bootstrap_download_impl(url, destfile), + condition = identity + ) + + if (!identical(status, 0L)) + return(FALSE) + + renv_bootstrap_download_augment(destfile) + + return(destfile) + + } + + # Add Sha to DESCRIPTION. This is stop gap until #890, after which we + # can use renv::install() to fully capture metadata. + renv_bootstrap_download_augment <- function(destfile) { + sha <- renv_bootstrap_git_extract_sha1_tar(destfile) + if (is.null(sha)) { + return() + } + + # Untar + tempdir <- tempfile("renv-github-") + on.exit(unlink(tempdir, recursive = TRUE), add = TRUE) + untar(destfile, exdir = tempdir) + pkgdir <- dir(tempdir, full.names = TRUE)[[1]] + + # Modify description + desc_path <- file.path(pkgdir, "DESCRIPTION") + desc_lines <- readLines(desc_path) + remotes_fields <- c( + "RemoteType: github", + "RemoteHost: api.github.com", + "RemoteRepo: renv", + "RemoteUsername: rstudio", + "RemotePkgRef: rstudio/renv", + paste("RemoteRef: ", sha), + paste("RemoteSha: ", sha) + ) + writeLines(c(desc_lines[desc_lines != ""], remotes_fields), con = desc_path) + + # Re-tar + local({ + old <- setwd(tempdir) + on.exit(setwd(old), add = TRUE) + + tar(destfile, compression = "gzip") + }) + invisible() + } + + # Extract the commit hash from a git archive. Git archives include the SHA1 + # hash as the comment field of the tarball pax extended header + # (see https://www.kernel.org/pub/software/scm/git/docs/git-archive.html) + # For GitHub archives this should be the first header after the default one + # (512 byte) header. + renv_bootstrap_git_extract_sha1_tar <- function(bundle) { + + # open the bundle for reading + # We use gzcon for everything because (from ?gzcon) + # > Reading from a connection which does not supply a 'gzip' magic + # > header is equivalent to reading from the original connection + conn <- gzcon(file(bundle, open = "rb", raw = TRUE)) + on.exit(close(conn)) + + # The default pax header is 512 bytes long and the first pax extended header + # with the comment should be 51 bytes long + # `52 comment=` (11 chars) + 40 byte SHA1 hash + len <- 0x200 + 0x33 + res <- rawToChar(readBin(conn, "raw", n = len)[0x201:len]) + + if (grepl("^52 comment=", res)) { + sub("52 comment=", "", res) + } else { + NULL + } + } + + renv_bootstrap_install <- function(version, tarball, library) { + + # attempt to install it into project library + dir.create(library, showWarnings = FALSE, recursive = TRUE) + output <- renv_bootstrap_install_impl(library, tarball) + + # check for successful install + status <- attr(output, "status") + if (is.null(status) || identical(status, 0L)) + return(status) + + # an error occurred; report it + header <- "installation of renv failed" + lines <- paste(rep.int("=", nchar(header)), collapse = "") + text <- paste(c(header, lines, output), collapse = "\n") + stop(text) + + } + + renv_bootstrap_install_impl <- function(library, tarball) { + + # invoke using system2 so we can capture and report output + bin <- R.home("bin") + exe <- if (Sys.info()[["sysname"]] == "Windows") "R.exe" else "R" + R <- file.path(bin, exe) + + args <- c( + "--vanilla", "CMD", "INSTALL", "--no-multiarch", + "-l", shQuote(path.expand(library)), + shQuote(path.expand(tarball)) + ) + + system2(R, args, stdout = TRUE, stderr = TRUE) + + } + + renv_bootstrap_platform_prefix <- function() { + + # construct version prefix + version <- paste(R.version$major, R.version$minor, sep = ".") + prefix <- paste("R", numeric_version(version)[1, 1:2], sep = "-") + + # include SVN revision for development versions of R + # (to avoid sharing platform-specific artefacts with released versions of R) + devel <- + identical(R.version[["status"]], "Under development (unstable)") || + identical(R.version[["nickname"]], "Unsuffered Consequences") + + if (devel) + prefix <- paste(prefix, R.version[["svn rev"]], sep = "-r") + + # build list of path components + components <- c(prefix, R.version$platform) + + # include prefix if provided by user + prefix <- renv_bootstrap_platform_prefix_impl() + if (!is.na(prefix) && nzchar(prefix)) + components <- c(prefix, components) + + # build prefix + paste(components, collapse = "/") + + } + + renv_bootstrap_platform_prefix_impl <- function() { + + # if an explicit prefix has been supplied, use it + prefix <- Sys.getenv("RENV_PATHS_PREFIX", unset = NA) + if (!is.na(prefix)) + return(prefix) + + # if the user has requested an automatic prefix, generate it + auto <- Sys.getenv("RENV_PATHS_PREFIX_AUTO", unset = NA) + if (is.na(auto) && getRversion() >= "4.4.0") + auto <- "TRUE" + + if (auto %in% c("TRUE", "True", "true", "1")) + return(renv_bootstrap_platform_prefix_auto()) + + # empty string on failure + "" + + } + + renv_bootstrap_platform_prefix_auto <- function() { + + prefix <- tryCatch(renv_bootstrap_platform_os(), error = identity) + if (inherits(prefix, "error") || prefix %in% "unknown") { + + msg <- paste( + "failed to infer current operating system", + "please file a bug report at https://github.com/rstudio/renv/issues", + sep = "; " + ) + + warning(msg) + + } + + prefix + + } + + renv_bootstrap_platform_os <- function() { + + sysinfo <- Sys.info() + sysname <- sysinfo[["sysname"]] + + # handle Windows + macOS up front + if (sysname == "Windows") + return("windows") + else if (sysname == "Darwin") + return("macos") + + # check for os-release files + for (file in c("/etc/os-release", "/usr/lib/os-release")) + if (file.exists(file)) + return(renv_bootstrap_platform_os_via_os_release(file, sysinfo)) + + # check for redhat-release files + if (file.exists("/etc/redhat-release")) + return(renv_bootstrap_platform_os_via_redhat_release()) + + "unknown" + + } + + renv_bootstrap_platform_os_via_os_release <- function(file, sysinfo) { + + # read /etc/os-release + release <- utils::read.table( + file = file, + sep = "=", + quote = c("\"", "'"), + col.names = c("Key", "Value"), + comment.char = "#", + stringsAsFactors = FALSE + ) + + vars <- as.list(release$Value) + names(vars) <- release$Key + + # get os name + os <- tolower(sysinfo[["sysname"]]) + + # read id + id <- "unknown" + for (field in c("ID", "ID_LIKE")) { + if (field %in% names(vars) && nzchar(vars[[field]])) { + id <- vars[[field]] + break + } + } + + # read version + version <- "unknown" + for (field in c("UBUNTU_CODENAME", "VERSION_CODENAME", "VERSION_ID", "BUILD_ID")) { + if (field %in% names(vars) && nzchar(vars[[field]])) { + version <- vars[[field]] + break + } + } + + # join together + paste(c(os, id, version), collapse = "-") + + } + + renv_bootstrap_platform_os_via_redhat_release <- function() { + + # read /etc/redhat-release + contents <- readLines("/etc/redhat-release", warn = FALSE) + + # infer id + id <- if (grepl("centos", contents, ignore.case = TRUE)) + "centos" + else if (grepl("redhat", contents, ignore.case = TRUE)) + "redhat" + else + "unknown" + + # try to find a version component (very hacky) + version <- "unknown" + + parts <- strsplit(contents, "[[:space:]]")[[1L]] + for (part in parts) { + + nv <- tryCatch(numeric_version(part), error = identity) + if (inherits(nv, "error")) + next + + version <- nv[1, 1] + break + + } + + paste(c("linux", id, version), collapse = "-") + + } + + renv_bootstrap_library_root_name <- function(project) { + + # use project name as-is if requested + asis <- Sys.getenv("RENV_PATHS_LIBRARY_ROOT_ASIS", unset = "FALSE") + if (asis) + return(basename(project)) + + # otherwise, disambiguate based on project's path + id <- substring(renv_bootstrap_hash_text(project), 1L, 8L) + paste(basename(project), id, sep = "-") + + } + + renv_bootstrap_library_root <- function(project) { + + prefix <- renv_bootstrap_profile_prefix() + + path <- Sys.getenv("RENV_PATHS_LIBRARY", unset = NA) + if (!is.na(path)) + return(paste(c(path, prefix), collapse = "/")) + + path <- renv_bootstrap_library_root_impl(project) + if (!is.null(path)) { + name <- renv_bootstrap_library_root_name(project) + return(paste(c(path, prefix, name), collapse = "/")) + } + + renv_bootstrap_paths_renv("library", project = project) + + } + + renv_bootstrap_library_root_impl <- function(project) { + + root <- Sys.getenv("RENV_PATHS_LIBRARY_ROOT", unset = NA) + if (!is.na(root)) + return(root) + + type <- renv_bootstrap_project_type(project) + if (identical(type, "package")) { + userdir <- renv_bootstrap_user_dir() + return(file.path(userdir, "library")) + } + + } + + renv_bootstrap_validate_version <- function(version, description = NULL) { + + # resolve description file + # + # avoid passing lib.loc to `packageDescription()` below, since R will + # use the loaded version of the package by default anyhow. note that + # this function should only be called after 'renv' is loaded + # https://github.com/rstudio/renv/issues/1625 + description <- description %||% packageDescription("renv") + + # check whether requested version 'version' matches loaded version of renv + sha <- attr(version, "sha", exact = TRUE) + valid <- if (!is.null(sha)) + renv_bootstrap_validate_version_dev(sha, description) + else + renv_bootstrap_validate_version_release(version, description) + + if (valid) + return(TRUE) + + # the loaded version of renv doesn't match the requested version; + # give the user instructions on how to proceed + dev <- identical(description[["RemoteType"]], "github") + remote <- if (dev) + paste("rstudio/renv", description[["RemoteSha"]], sep = "@") + else + paste("renv", description[["Version"]], sep = "@") + + # display both loaded version + sha if available + friendly <- renv_bootstrap_version_friendly( + version = description[["Version"]], + sha = if (dev) description[["RemoteSha"]] + ) + + fmt <- heredoc(" + renv %1$s was loaded from project library, but this project is configured to use renv %2$s. + - Use `renv::record(\"%3$s\")` to record renv %1$s in the lockfile. + - Use `renv::restore(packages = \"renv\")` to install renv %2$s into the project library. + ") + catf(fmt, friendly, renv_bootstrap_version_friendly(version), remote) + + FALSE + + } + + renv_bootstrap_validate_version_dev <- function(version, description) { + expected <- description[["RemoteSha"]] + is.character(expected) && startswith(expected, version) + } + + renv_bootstrap_validate_version_release <- function(version, description) { + expected <- description[["Version"]] + is.character(expected) && identical(expected, version) + } + + renv_bootstrap_hash_text <- function(text) { + + hashfile <- tempfile("renv-hash-") + on.exit(unlink(hashfile), add = TRUE) + + writeLines(text, con = hashfile) + tools::md5sum(hashfile) + + } + + renv_bootstrap_load <- function(project, libpath, version) { + + # try to load renv from the project library + if (!requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) + return(FALSE) + + # warn if the version of renv loaded does not match + renv_bootstrap_validate_version(version) + + # execute renv load hooks, if any + hooks <- getHook("renv::autoload") + for (hook in hooks) + if (is.function(hook)) + tryCatch(hook(), error = warnify) + + # load the project + renv::load(project) + + TRUE + + } + + renv_bootstrap_profile_load <- function(project) { + + # if RENV_PROFILE is already set, just use that + profile <- Sys.getenv("RENV_PROFILE", unset = NA) + if (!is.na(profile) && nzchar(profile)) + return(profile) + + # check for a profile file (nothing to do if it doesn't exist) + path <- renv_bootstrap_paths_renv("profile", profile = FALSE, project = project) + if (!file.exists(path)) + return(NULL) + + # read the profile, and set it if it exists + contents <- readLines(path, warn = FALSE) + if (length(contents) == 0L) + return(NULL) + + # set RENV_PROFILE + profile <- contents[[1L]] + if (!profile %in% c("", "default")) + Sys.setenv(RENV_PROFILE = profile) + + profile + + } + + renv_bootstrap_profile_prefix <- function() { + profile <- renv_bootstrap_profile_get() + if (!is.null(profile)) + return(file.path("profiles", profile, "renv")) + } + + renv_bootstrap_profile_get <- function() { + profile <- Sys.getenv("RENV_PROFILE", unset = "") + renv_bootstrap_profile_normalize(profile) + } + + renv_bootstrap_profile_set <- function(profile) { + profile <- renv_bootstrap_profile_normalize(profile) + if (is.null(profile)) + Sys.unsetenv("RENV_PROFILE") + else + Sys.setenv(RENV_PROFILE = profile) + } + + renv_bootstrap_profile_normalize <- function(profile) { + + if (is.null(profile) || profile %in% c("", "default")) + return(NULL) + + profile + + } + + renv_bootstrap_path_absolute <- function(path) { + + substr(path, 1L, 1L) %in% c("~", "/", "\\") || ( + substr(path, 1L, 1L) %in% c(letters, LETTERS) && + substr(path, 2L, 3L) %in% c(":/", ":\\") + ) + + } + + renv_bootstrap_paths_renv <- function(..., profile = TRUE, project = NULL) { + renv <- Sys.getenv("RENV_PATHS_RENV", unset = "renv") + root <- if (renv_bootstrap_path_absolute(renv)) NULL else project + prefix <- if (profile) renv_bootstrap_profile_prefix() + components <- c(root, renv, prefix, ...) + paste(components, collapse = "/") + } + + renv_bootstrap_project_type <- function(path) { + + descpath <- file.path(path, "DESCRIPTION") + if (!file.exists(descpath)) + return("unknown") + + desc <- tryCatch( + read.dcf(descpath, all = TRUE), + error = identity + ) + + if (inherits(desc, "error")) + return("unknown") + + type <- desc$Type + if (!is.null(type)) + return(tolower(type)) + + package <- desc$Package + if (!is.null(package)) + return("package") + + "unknown" + + } + + renv_bootstrap_user_dir <- function() { + dir <- renv_bootstrap_user_dir_impl() + path.expand(chartr("\\", "/", dir)) + } + + renv_bootstrap_user_dir_impl <- function() { + + # use local override if set + override <- getOption("renv.userdir.override") + if (!is.null(override)) + return(override) + + # use R_user_dir if available + tools <- asNamespace("tools") + if (is.function(tools$R_user_dir)) + return(tools$R_user_dir("renv", "cache")) + + # try using our own backfill for older versions of R + envvars <- c("R_USER_CACHE_DIR", "XDG_CACHE_HOME") + for (envvar in envvars) { + root <- Sys.getenv(envvar, unset = NA) + if (!is.na(root)) + return(file.path(root, "R/renv")) + } + + # use platform-specific default fallbacks + if (Sys.info()[["sysname"]] == "Windows") + file.path(Sys.getenv("LOCALAPPDATA"), "R/cache/R/renv") + else if (Sys.info()[["sysname"]] == "Darwin") + "~/Library/Caches/org.R-project.R/R/renv" + else + "~/.cache/R/renv" + + } + + renv_bootstrap_version_friendly <- function(version, shafmt = NULL, sha = NULL) { + sha <- sha %||% attr(version, "sha", exact = TRUE) + parts <- c(version, sprintf(shafmt %||% " [sha: %s]", substring(sha, 1L, 7L))) + paste(parts, collapse = "") + } + + renv_bootstrap_exec <- function(project, libpath, version) { + if (!renv_bootstrap_load(project, libpath, version)) + renv_bootstrap_run(version, libpath) + } + + renv_bootstrap_run <- function(version, libpath) { + + # perform bootstrap + bootstrap(version, libpath) + + # exit early if we're just testing bootstrap + if (!is.na(Sys.getenv("RENV_BOOTSTRAP_INSTALL_ONLY", unset = NA))) + return(TRUE) + + # try again to load + if (requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) { + return(renv::load(project = getwd())) + } + + # failed to download or load renv; warn the user + msg <- c( + "Failed to find an renv installation: the project will not be loaded.", + "Use `renv::activate()` to re-initialize the project." + ) + + warning(paste(msg, collapse = "\n"), call. = FALSE) + + } + + renv_json_read <- function(file = NULL, text = NULL) { + + jlerr <- NULL + + # if jsonlite is loaded, use that instead + if ("jsonlite" %in% loadedNamespaces()) { + + json <- tryCatch(renv_json_read_jsonlite(file, text), error = identity) + if (!inherits(json, "error")) + return(json) + + jlerr <- json + + } + + # otherwise, fall back to the default JSON reader + json <- tryCatch(renv_json_read_default(file, text), error = identity) + if (!inherits(json, "error")) + return(json) + + # report an error + if (!is.null(jlerr)) + stop(jlerr) + else + stop(json) + + } + + renv_json_read_jsonlite <- function(file = NULL, text = NULL) { + text <- paste(text %||% readLines(file, warn = FALSE), collapse = "\n") + jsonlite::fromJSON(txt = text, simplifyVector = FALSE) + } + + renv_json_read_default <- function(file = NULL, text = NULL) { + + # find strings in the JSON + text <- paste(text %||% readLines(file, warn = FALSE), collapse = "\n") + pattern <- '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]' + locs <- gregexpr(pattern, text, perl = TRUE)[[1]] + + # if any are found, replace them with placeholders + replaced <- text + strings <- character() + replacements <- character() + + if (!identical(c(locs), -1L)) { + + # get the string values + starts <- locs + ends <- locs + attr(locs, "match.length") - 1L + strings <- substring(text, starts, ends) + + # only keep those requiring escaping + strings <- grep("[[\\]{}:]", strings, perl = TRUE, value = TRUE) + + # compute replacements + replacements <- sprintf('"\032%i\032"', seq_along(strings)) + + # replace the strings + mapply(function(string, replacement) { + replaced <<- sub(string, replacement, replaced, fixed = TRUE) + }, strings, replacements) + + } + + # transform the JSON into something the R parser understands + transformed <- replaced + transformed <- gsub("{}", "`names<-`(list(), character())", transformed, fixed = TRUE) + transformed <- gsub("[[{]", "list(", transformed, perl = TRUE) + transformed <- gsub("[]}]", ")", transformed, perl = TRUE) + transformed <- gsub(":", "=", transformed, fixed = TRUE) + text <- paste(transformed, collapse = "\n") + + # parse it + json <- parse(text = text, keep.source = FALSE, srcfile = NULL)[[1L]] + + # construct map between source strings, replaced strings + map <- as.character(parse(text = strings)) + names(map) <- as.character(parse(text = replacements)) + + # convert to list + map <- as.list(map) + + # remap strings in object + remapped <- renv_json_read_remap(json, map) + + # evaluate + eval(remapped, envir = baseenv()) + + } + + renv_json_read_remap <- function(json, map) { + + # fix names + if (!is.null(names(json))) { + lhs <- match(names(json), names(map), nomatch = 0L) + rhs <- match(names(map), names(json), nomatch = 0L) + names(json)[rhs] <- map[lhs] + } + + # fix values + if (is.character(json)) + return(map[[json]] %||% json) + + # handle true, false, null + if (is.name(json)) { + text <- as.character(json) + if (text == "true") + return(TRUE) + else if (text == "false") + return(FALSE) + else if (text == "null") + return(NULL) + } + + # recurse + if (is.recursive(json)) { + for (i in seq_along(json)) { + json[i] <- list(renv_json_read_remap(json[[i]], map)) + } + } + + json + + } + + # load the renv profile, if any + renv_bootstrap_profile_load(project) + + # construct path to library root + root <- renv_bootstrap_library_root(project) + + # construct library prefix for platform + prefix <- renv_bootstrap_platform_prefix() + + # construct full libpath + libpath <- file.path(root, prefix) + + # run bootstrap code + renv_bootstrap_exec(project, libpath, version) + + invisible() + +}) diff --git a/notebooks/renv/settings.json b/notebooks/renv/settings.json new file mode 100644 index 000000000..ffdbb3200 --- /dev/null +++ b/notebooks/renv/settings.json @@ -0,0 +1,19 @@ +{ + "bioconductor.version": null, + "external.libraries": [], + "ignored.packages": [], + "package.dependency.fields": [ + "Imports", + "Depends", + "LinkingTo" + ], + "ppm.enabled": null, + "ppm.ignored.urls": [], + "r.version": null, + "snapshot.type": "implicit", + "use.cache": true, + "vcs.ignore.cellar": true, + "vcs.ignore.library": true, + "vcs.ignore.local": true, + "vcs.manage.ignores": true +} diff --git a/nssp/.pylintrc b/nssp/.pylintrc new file mode 100644 index 000000000..f30837c7e --- /dev/null +++ b/nssp/.pylintrc @@ -0,0 +1,22 @@ + +[MESSAGES CONTROL] + +disable=logging-format-interpolation, + too-many-locals, + too-many-arguments, + # Allow pytest functions to be part of a class. + no-self-use, + # Allow pytest classes to have one test. + too-few-public-methods + +[BASIC] + +# Allow arbitrarily short-named variables. +variable-rgx=[a-z_][a-z0-9_]* +argument-rgx=[a-z_][a-z0-9_]* +attr-rgx=[a-z_][a-z0-9_]* + +[DESIGN] + +# Don't complain about pytest "unused" arguments. +ignored-argument-names=(_.*|run_as_module) \ No newline at end of file diff --git a/nssp/DETAILS.md b/nssp/DETAILS.md new file mode 100644 index 000000000..539697baa --- /dev/null +++ b/nssp/DETAILS.md @@ -0,0 +1,13 @@ +# NSSP data + +We import the NSSP Emergency Department Visit data, including percentage and smoothed percentage of ER visits attributable to a given pathogen, from the CDC website. The data is provided at the county level, state level and national level; we do a population-weighted mean to aggregate from county data up to the HRR and MSA levels. + +## Geographical Levels +* `state`: reported using two-letter postal code +* `county`: reported using fips code +* `national`: just `us` for now +## Metrics +* `percent_visits_covid`, `percent_visits_rsv`, `percent_visits_influenza`: percentage of emergency department patient visits for specified pathogen. +* `percent_visits_combined`: sum of the three percentages of visits for flu, rsv and covid. +* `smoothed_percent_visits_covid`, `smoothed_percent_visits_rsv`, `smoothed_percent_visits_influenza`: 3 week moving average of the percentage of emergency department patient visits for specified pathogen. +* `smoothed_percent_visits_combined`: 3 week moving average of the sum of the three percentages of visits for flu, rsv and covid. \ No newline at end of file diff --git a/nssp/Makefile b/nssp/Makefile new file mode 100644 index 000000000..390113eef --- /dev/null +++ b/nssp/Makefile @@ -0,0 +1,32 @@ +.PHONY = venv, lint, test, clean + +dir = $(shell find ./delphi_* -name __init__.py | grep -o 'delphi_[_[:alnum:]]*' | head -1) +venv: + python3.8 -m venv env + +install: venv + . env/bin/activate; \ + pip install wheel ; \ + pip install -e ../_delphi_utils_python ;\ + pip install -e . + +install-ci: venv + . env/bin/activate; \ + pip install wheel ; \ + pip install ../_delphi_utils_python ;\ + pip install . + +lint: + . env/bin/activate; pylint $(dir) --rcfile=../pyproject.toml + . env/bin/activate; pydocstyle $(dir) + +format: + . env/bin/activate; darker $(dir) + +test: + . env/bin/activate ;\ + (cd tests && ../env/bin/pytest --cov=$(dir) --cov-report=term-missing) + +clean: + rm -rf env + rm -f params.json diff --git a/nssp/README.md b/nssp/README.md new file mode 100644 index 000000000..4bba6f626 --- /dev/null +++ b/nssp/README.md @@ -0,0 +1,75 @@ +# NSSP Emergency Department Visit data + +We import the NSSP Emergency Department Visit data, currently only the smoothed concentration, from the CDC website, aggregate to the state and national level from the wastewater sample site level, and export the aggregated data. +For details see the `DETAILS.md` file in this directory. + +## Create a MyAppToken +`MyAppToken` is required when fetching data from SODA Consumer API +(https://dev.socrata.com/foundry/data.cdc.gov/r8kw-7aab). Follow the +steps below to create a MyAppToken. +- Click the `Sign up for an app token` button in the linked website +- Sign In or Sign Up with Socrata ID +- Click the `Create New App Token` button +- Fill in `Application Name` and `Description` (You can just use delphi_wastewater + for both) and click `Save` +- Copy the `App Token` + + +## Running the Indicator + +The indicator is run by directly executing the Python module contained in this +directory. The safest way to do this is to create a virtual environment, +installed the common DELPHI tools, and then install the module and its +dependencies. To do this, run the following command from this directory: + +``` +make install +``` + +This command will install the package in editable mode, so you can make changes that +will automatically propagate to the installed package. + +All of the user-changable parameters are stored in `params.json`. To execute +the module and produce the output datasets (by default, in `receiving`), run +the following: + +``` +env/bin/python -m delphi_nssp +``` + +If you want to enter the virtual environment in your shell, +you can run `source env/bin/activate`. Run `deactivate` to leave the virtual environment. + +Once you are finished, you can remove the virtual environment and +params file with the following: + +``` +make clean +``` + +## Testing the code + +To run static tests of the code style, run the following command: + +``` +make lint +``` + +Unit tests are also included in the module. To execute these, run the following +command from this directory: + +``` +make test +``` + +To run individual tests, run the following: + +``` +(cd tests && ../env/bin/pytest .py --cov=delphi_NAME --cov-report=term-missing) +``` + +The output will show the number of unit tests that passed and failed, along +with the percentage of code covered by the tests. + +None of the linting or unit tests should fail, and the code lines that are not covered by unit tests should be small and +should not include critical sub-routines. diff --git a/nssp/REVIEW.md b/nssp/REVIEW.md new file mode 100644 index 000000000..03f87b17a --- /dev/null +++ b/nssp/REVIEW.md @@ -0,0 +1,38 @@ +## Code Review (Python) + +A code review of this module should include a careful look at the code and the +output. To assist in the process, but certainly not in replace of it, please +check the following items. + +**Documentation** + +- [ ] the README.md file template is filled out and currently accurate; it is +possible to load and test the code using only the instructions given +- [ ] minimal docstrings (one line describing what the function does) are +included for all functions; full docstrings describing the inputs and expected +outputs should be given for non-trivial functions + +**Structure** + +- [ ] code should pass lint checks (`make lint`) +- [ ] any required metadata files are checked into the repository and placed +within the directory `static` +- [ ] any intermediate files that are created and stored by the module should +be placed in the directory `cache` +- [ ] final expected output files to be uploaded to the API are placed in the +`receiving` directory; output files should not be committed to the respository +- [ ] all options and API keys are passed through the file `params.json` +- [ ] template parameter file (`params.json.template`) is checked into the +code; no personal (i.e., usernames) or private (i.e., API keys) information is +included in this template file + +**Testing** + +- [ ] module can be installed in a new virtual environment (`make install`) +- [ ] reasonably high level of unit test coverage covering all of the main logic +of the code (e.g., missing coverage for raised errors that do not currently seem +possible to reach are okay; missing coverage for options that will be needed are +not) +- [ ] all unit tests run without errors (`make test`) +- [ ] indicator directory has been added to GitHub CI +(`covidcast-indicators/.github/workflows/python-ci.yml`) diff --git a/nssp/cache/.gitignore b/nssp/cache/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/nssp/delphi_nssp/__init__.py b/nssp/delphi_nssp/__init__.py new file mode 100644 index 000000000..827935a53 --- /dev/null +++ b/nssp/delphi_nssp/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""Module to pull and clean indicators from the NSSP source. + +This file defines the functions that are made public by the module. As the +module is intended to be executed though the main method, these are primarily +for testing. +""" + +from __future__ import absolute_import + +from . import pull, run + +__version__ = "0.1.0" diff --git a/nssp/delphi_nssp/__main__.py b/nssp/delphi_nssp/__main__.py new file mode 100644 index 000000000..105f8e2d2 --- /dev/null +++ b/nssp/delphi_nssp/__main__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""Call the function run_module when executed. + +This file indicates that calling the module (`python -m delphi_nssp`) will +call the function `run_module` found within the run.py file. There should be +no need to change this template. +""" + +from delphi_utils import read_params + +from .run import run_module # pragma: no cover + +run_module(read_params()) # pragma: no cover diff --git a/nssp/delphi_nssp/constants.py b/nssp/delphi_nssp/constants.py new file mode 100644 index 000000000..0abb68c29 --- /dev/null +++ b/nssp/delphi_nssp/constants.py @@ -0,0 +1,42 @@ +"""Registry for variations.""" + +GEOS = [ + "hrr", + "msa", + "nation", + "state", + "county", +] + +SIGNALS_MAP = { + "percent_visits_covid": "pct_ed_visits_covid", + "percent_visits_influenza": "pct_ed_visits_influenza", + "percent_visits_rsv": "pct_ed_visits_rsv", + "percent_visits_combined": "pct_ed_visits_combined", + "percent_visits_smoothed_covid": "smoothed_pct_ed_visits_covid", + "percent_visits_smoothed_1": "smoothed_pct_ed_visits_influenza", + "percent_visits_smoothed_rsv": "smoothed_pct_ed_visits_rsv", + "percent_visits_smoothed": "smoothed_pct_ed_visits_combined", +} + +SIGNALS = [val for (key, val) in SIGNALS_MAP.items()] +NEWLINE = "\n" + +AUXILIARY_COLS = [ + "se", + "sample_size", + "missing_val", + "missing_se", + "missing_sample_size", +] +CSV_COLS = ["geo_id", "val"] + AUXILIARY_COLS + +TYPE_DICT = {key: float for key in SIGNALS} +TYPE_DICT.update( + { + "timestamp": "datetime64[ns]", + "geography": str, + "county": str, + "fips": int, + } +) diff --git a/nssp/delphi_nssp/pull.py b/nssp/delphi_nssp/pull.py new file mode 100644 index 000000000..5769cca82 --- /dev/null +++ b/nssp/delphi_nssp/pull.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""Functions for pulling NSSP ER data.""" + +import textwrap + +import pandas as pd +from sodapy import Socrata + +from .constants import NEWLINE, SIGNALS, SIGNALS_MAP, TYPE_DICT + + +def warn_string(df, type_dict): + """Format the warning string.""" + warn = textwrap.dedent( + f""" + Expected column(s) missed, The dataset schema may + have changed. Please investigate and amend the code. + + Columns needed: + {NEWLINE.join(sorted(type_dict.keys()))} + + Columns available: + {NEWLINE.join(sorted(df.columns))} + """ + ) + + return warn + + +def pull_nssp_data(socrata_token: str): + """Pull the latest NSSP ER visits data, and conforms it into a dataset. + + The output dataset has: + + - Each row corresponds to a single observation + - Each row additionally has columns for the signals in SIGNALS + + Parameters + ---------- + socrata_token: str + My App Token for pulling the NWSS data (could be the same as the nchs data) + test_file: Optional[str] + When not null, name of file from which to read test data + + Returns + ------- + pd.DataFrame + Dataframe as described above. + """ + # Pull data from Socrata API + client = Socrata("data.cdc.gov", socrata_token) + results = [] + offset = 0 + limit = 50000 # maximum limit allowed by SODA 2.0 + while True: + page = client.get("rdmq-nq56", limit=limit, offset=offset) + if not page: + break # exit the loop if no more results + results.extend(page) + offset += limit + df_ervisits = pd.DataFrame.from_records(results) + df_ervisits = df_ervisits.rename(columns={"week_end": "timestamp"}) + df_ervisits = df_ervisits.rename(columns=SIGNALS_MAP) + + try: + df_ervisits = df_ervisits.astype(TYPE_DICT) + except KeyError as exc: + raise ValueError(warn_string(df_ervisits, TYPE_DICT)) from exc + + keep_columns = ["timestamp", "geography", "county", "fips"] + return df_ervisits[SIGNALS + keep_columns] diff --git a/nssp/delphi_nssp/run.py b/nssp/delphi_nssp/run.py new file mode 100644 index 000000000..7c5a3ffac --- /dev/null +++ b/nssp/delphi_nssp/run.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""Functions to call when running the function. + +This module should contain a function called `run_module`, that is executed +when the module is run with `python -m delphi_nssp`. `run_module`'s lone argument should be a +nested dictionary of parameters loaded from the params.json file. We expect the `params` to have +the following structure: + - "common": + - "export_dir": str, directory to write daily output + - "log_filename": (optional) str, path to log file + - "log_exceptions" (optional): bool, whether to log exceptions to file + - "indicator": (optional) + - "wip_signal": (optional) Any[str, bool], list of signals that are + works in progress, or True if all signals in the registry are works + in progress, or False if only unpublished signals are. See + `delphi_utils.add_prefix()` + - "test_file" (optional): str, name of file from which to read test data + - "socrata_token": str, authentication for upstream data pull + - "archive" (optional): if provided, output will be archived with S3 + - "aws_credentials": Dict[str, str], AWS login credentials (see S3 documentation) + - "bucket_name: str, name of S3 bucket to read/write + - "cache_dir": str, directory of locally cached data +""" + +import time +from datetime import datetime + +import numpy as np +import us +from delphi_utils import create_export_csv, get_structured_logger +from delphi_utils.geomap import GeoMapper +from delphi_utils.nancodes import add_default_nancodes + +from .constants import AUXILIARY_COLS, CSV_COLS, GEOS, SIGNALS +from .pull import pull_nssp_data + + +def add_needed_columns(df, col_names=None): + """Short util to add expected columns not found in the dataset.""" + if col_names is None: + col_names = AUXILIARY_COLS + + for col_name in col_names: + df[col_name] = np.nan + df = add_default_nancodes(df) + return df + + +def logging(start_time, run_stats, logger): + """Boilerplate making logs.""" + elapsed_time_in_seconds = round(time.time() - start_time, 2) + min_max_date = run_stats and min(s[0] for s in run_stats) + csv_export_count = sum(s[-1] for s in run_stats) + max_lag_in_days = min_max_date and (datetime.now() - min_max_date).days + formatted_min_max_date = min_max_date and min_max_date.strftime("%Y-%m-%d") + logger.info( + "Completed indicator run", + elapsed_time_in_seconds=elapsed_time_in_seconds, + csv_export_count=csv_export_count, + max_lag_in_days=max_lag_in_days, + oldest_final_export_date=formatted_min_max_date, + ) + + +def run_module(params): + """ + Run the indicator. + + Arguments + -------- + params: Dict[str, Any] + Nested dictionary of parameters. + """ + start_time = time.time() + logger = get_structured_logger( + __name__, + filename=params["common"].get("log_filename"), + log_exceptions=params["common"].get("log_exceptions", True), + ) + export_dir = params["common"]["export_dir"] + socrata_token = params["indicator"]["socrata_token"] + + run_stats = [] + ## build the base version of the signal at the most detailed geo level you can get. + ## compute stuff here or farm out to another function or file + df_pull = pull_nssp_data(socrata_token) + ## aggregate + geo_mapper = GeoMapper() + for signal in SIGNALS: + for geo in GEOS: + df = df_pull.copy() + df["val"] = df[signal] + logger.info("Generating signal and exporting to CSV", metric=signal) + if geo == "nation": + df = df[df["geography"] == "United States"] + df["geo_id"] = "us" + elif geo == "state": + df = df[(df["county"] == "All") & (df["geography"] != "United States")] + df["geo_id"] = df["geography"].apply( + lambda x: us.states.lookup(x).abbr.lower() if us.states.lookup(x) else "dc" + ) + elif geo == "hrr": + df = df[["fips", "val", "timestamp"]] + # fips -> hrr has a weighted version + df = geo_mapper.replace_geocode(df, "fips", "hrr") + df = df.rename(columns={"hrr": "geo_id"}) + elif geo == "msa": + df = df[["fips", "val", "timestamp"]] + # fips -> msa doesn't have a weighted version, so we need to add columns and sum ourselves + df = geo_mapper.add_population_column(df, geocode_type="fips", geocode_col="fips") + df = geo_mapper.add_geocode(df, "fips", "msa", from_col="fips", new_col="geo_id") + df = geo_mapper.aggregate_by_weighted_sum(df, "geo_id", "val", "timestamp", "population") + df = df.rename(columns={"weighted_val": "val"}) + else: + df = df[df["county"] != "All"] + df["geo_id"] = df["fips"] + # add se, sample_size, and na codes + missing_cols = set(CSV_COLS) - set(df.columns) + df = add_needed_columns(df, col_names=list(missing_cols)) + df_csv = df[CSV_COLS + ["timestamp"]] + # actual export + dates = create_export_csv( + df_csv, + geo_res=geo, + export_dir=export_dir, + sensor=signal, + weekly_dates=True, + ) + if len(dates) > 0: + run_stats.append((max(dates), len(dates))) + + ## log this indicator run + logging(start_time, run_stats, logger) diff --git a/nssp/params.json.template b/nssp/params.json.template new file mode 100644 index 000000000..df989ede7 --- /dev/null +++ b/nssp/params.json.template @@ -0,0 +1,30 @@ +{ + "common": { + "export_dir": "./receiving", + "log_filename": "./nssp.log", + "log_exceptions": false + }, + "indicator": { + "wip_signal": true, + "static_file_dir": "./static", + "socrata_token": "" + }, + "validation": { + "common": { + "data_source": "nssp", + "api_credentials": "{{ validation_api_key }}", + "span_length": 15, + "min_expected_lag": {"all": "7"}, + "max_expected_lag": {"all": "13"}, + "dry_run": true, + "suppressed_errors": [] + }, + "static": { + "minimum_sample_size": 0, + "missing_se_allowed": true, + "missing_sample_size_allowed": true + }, + "dynamic": {} + } +} + diff --git a/nssp/receiving/.gitignore b/nssp/receiving/.gitignore new file mode 100644 index 000000000..afed0735d --- /dev/null +++ b/nssp/receiving/.gitignore @@ -0,0 +1 @@ +*.csv diff --git a/nssp/setup.py b/nssp/setup.py new file mode 100644 index 000000000..a6cbf640a --- /dev/null +++ b/nssp/setup.py @@ -0,0 +1,32 @@ +from setuptools import setup +from setuptools import find_packages + +required = [ + "numpy", + "pandas", + "pydocstyle", + "pytest", + "pytest-cov", + "pylint==2.8.3", + "delphi-utils", + "sodapy", + "epiweeks", + "freezegun", + "us", +] + +setup( + name="delphi_nssp", + version="0.1.0", + description="Indicators NSSP Emergency Department Visit", + author="Minh Le", + author_email="minhkhul@andrew.cmu.edu", + url="https://github.com/cmu-delphi/covidcast-indicators", + install_requires=required, + classifiers=[ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.8", + ], + packages=find_packages(), +) diff --git a/nssp/tests/test_data/page.txt b/nssp/tests/test_data/page.txt new file mode 100644 index 000000000..af5f25922 --- /dev/null +++ b/nssp/tests/test_data/page.txt @@ -0,0 +1,65 @@ +[ + { + "week_end": "2022-10-01T00:00:00.000", + "geography": "United States", + "county": "All", + "percent_visits_combined": "2.84", + "percent_visits_covid": "1.84", + "percent_visits_influenza": "0.48", + "percent_visits_rsv": "0.55", + "percent_visits_smoothed": "2.83", + "percent_visits_smoothed_covid": "2.07", + "percent_visits_smoothed_1": "0.34", + "percent_visits_smoothed_rsv": "0.44", + "ed_trends_covid": "Decreasing", + "ed_trends_influenza": "Increasing", + "ed_trends_rsv": "Increasing", + "hsa": "All", + "hsa_counties": "All", + "hsa_nci_id": "All", + "fips": "0", + "trend_source": "United States" + }, + { + "week_end": "2022-10-08T00:00:00.000", + "geography": "United States", + "county": "All", + "percent_visits_combined": "2.93", + "percent_visits_covid": "1.68", + "percent_visits_influenza": "0.68", + "percent_visits_rsv": "0.6", + "percent_visits_smoothed": "2.85", + "percent_visits_smoothed_covid": "1.85", + "percent_visits_smoothed_1": "0.49", + "percent_visits_smoothed_rsv": "0.53", + "ed_trends_covid": "Decreasing", + "ed_trends_influenza": "Increasing", + "ed_trends_rsv": "Increasing", + "hsa": "All", + "hsa_counties": "All", + "hsa_nci_id": "All", + "fips": "0", + "trend_source": "United States" + }, + { + "week_end": "2022-10-15T00:00:00.000", + "geography": "United States", + "county": "All", + "percent_visits_combined": "3.25", + "percent_visits_covid": "1.64", + "percent_visits_influenza": "0.9", + "percent_visits_rsv": "0.74", + "percent_visits_smoothed": "3.01", + "percent_visits_smoothed_covid": "1.72", + "percent_visits_smoothed_1": "0.69", + "percent_visits_smoothed_rsv": "0.63", + "ed_trends_covid": "Decreasing", + "ed_trends_influenza": "Increasing", + "ed_trends_rsv": "Increasing", + "hsa": "All", + "hsa_counties": "All", + "hsa_nci_id": "All", + "fips": "0", + "trend_source": "United States" + } +] diff --git a/nssp/tests/test_pull.py b/nssp/tests/test_pull.py new file mode 100644 index 000000000..cdc85a908 --- /dev/null +++ b/nssp/tests/test_pull.py @@ -0,0 +1,59 @@ +from datetime import datetime, date +import json +import unittest +from unittest.mock import patch, MagicMock +import tempfile +import os +import time +from datetime import datetime +import pdb +import pandas as pd +import pandas.api.types as ptypes + +from delphi_nssp.pull import ( + pull_nssp_data, +) +from delphi_nssp.constants import ( + SIGNALS, + NEWLINE, + SIGNALS_MAP, + TYPE_DICT, +) + + +class TestPullNSSPData(unittest.TestCase): + @patch("delphi_nssp.pull.Socrata") + def test_pull_nssp_data(self, mock_socrata): + # Load test data + with open("test_data/page.txt", "r") as f: + test_data = json.load(f) + + # Mock Socrata client and its get method + mock_client = MagicMock() + mock_client.get.side_effect = [test_data, []] # Return test data on first call, empty list on second call + mock_socrata.return_value = mock_client + + # Call function with test token + test_token = "test_token" + result = pull_nssp_data(test_token) + print(result) + + # Check that Socrata client was initialized with correct arguments + mock_socrata.assert_called_once_with("data.cdc.gov", test_token) + + # Check that get method was called with correct arguments + mock_client.get.assert_any_call("rdmq-nq56", limit=50000, offset=0) + + # Check result + assert result["timestamp"].notnull().all(), "timestamp has rogue NaN" + assert result["geography"].notnull().all(), "geography has rogue NaN" + assert result["county"].notnull().all(), "county has rogue NaN" + assert result["fips"].notnull().all(), "fips has rogue NaN" + + # Check for each signal in SIGNALS + for signal in SIGNALS: + assert result[signal].notnull().all(), f"{signal} has rogue NaN" + + +if __name__ == "__main__": + unittest.main() diff --git a/nssp/tests/test_run.py b/nssp/tests/test_run.py new file mode 100644 index 000000000..72346cff7 --- /dev/null +++ b/nssp/tests/test_run.py @@ -0,0 +1,31 @@ +from datetime import datetime, date +import json +from unittest.mock import patch +import tempfile +import os +import time +from datetime import datetime + +import numpy as np +import pandas as pd +from pandas.testing import assert_frame_equal +from delphi_nssp.constants import GEOS, SIGNALS, CSV_COLS +from delphi_nssp.run import ( + add_needed_columns +) + + +def test_add_needed_columns(): + df = pd.DataFrame({"geo_id": ["us"], "val": [1]}) + df = add_needed_columns(df, col_names=None) + assert df.columns.tolist() == [ + "geo_id", + "val", + "se", + "sample_size", + "missing_val", + "missing_se", + "missing_sample_size", + ] + assert df["se"].isnull().all() + assert df["sample_size"].isnull().all() diff --git a/pyproject.toml b/pyproject.toml index 2ca230476..e194a54b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,10 @@ line-length = 120 target-version = ['py38'] +[tool.ruff] +line-length = 120 +target-version = 'py38' + [tool.darker] revision = 'origin/main...' color = true diff --git a/sir_complainsalot/params.json.template b/sir_complainsalot/params.json.template index b07b197a4..e742c63bc 100644 --- a/sir_complainsalot/params.json.template +++ b/sir_complainsalot/params.json.template @@ -50,6 +50,10 @@ "hhs": { "max_age":15, "maintainers": [] + }, + "nssp": { + "max_age":13, + "maintainers": [] } } } From 445b5836ab6cb3487445d67ba2f7b939ff964ae2 Mon Sep 17 00:00:00 2001 From: minhkhul <118945681+minhkhul@users.noreply.github.com> Date: Tue, 18 Jun 2024 12:51:29 -0400 Subject: [PATCH 25/48] Reformat NSSP county zip code (#1976) * Format county fips to all be 5 digits with leading zeros + Add test * Update test_pull.py wordings * linter --- nssp/delphi_nssp/constants.py | 2 +- nssp/delphi_nssp/pull.py | 3 +++ nssp/tests/test_data/page.txt | 21 +++++++++++++++++++++ nssp/tests/test_pull.py | 1 + 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/nssp/delphi_nssp/constants.py b/nssp/delphi_nssp/constants.py index 0abb68c29..ddd2e74b8 100644 --- a/nssp/delphi_nssp/constants.py +++ b/nssp/delphi_nssp/constants.py @@ -37,6 +37,6 @@ "timestamp": "datetime64[ns]", "geography": str, "county": str, - "fips": int, + "fips": str, } ) diff --git a/nssp/delphi_nssp/pull.py b/nssp/delphi_nssp/pull.py index 5769cca82..ece94fab4 100644 --- a/nssp/delphi_nssp/pull.py +++ b/nssp/delphi_nssp/pull.py @@ -67,5 +67,8 @@ def pull_nssp_data(socrata_token: str): except KeyError as exc: raise ValueError(warn_string(df_ervisits, TYPE_DICT)) from exc + # Format county fips to all be 5 digits with leading zeros + df_ervisits["fips"] = df_ervisits["fips"].apply(lambda x: str(x).zfill(5) if str(x) != "0" else "0") + keep_columns = ["timestamp", "geography", "county", "fips"] return df_ervisits[SIGNALS + keep_columns] diff --git a/nssp/tests/test_data/page.txt b/nssp/tests/test_data/page.txt index af5f25922..34dfa71a8 100644 --- a/nssp/tests/test_data/page.txt +++ b/nssp/tests/test_data/page.txt @@ -61,5 +61,26 @@ "hsa_nci_id": "All", "fips": "0", "trend_source": "United States" + }, + { + "week_end": "2023-05-13T00:00:00.000", + "geography": "Colorado", + "county": "Jefferson", + "percent_visits_combined": "0.84", + "percent_visits_covid": "0.59", + "percent_visits_influenza": "0.23", + "percent_visits_rsv": "0.03", + "percent_visits_smoothed": "0.83", + "percent_visits_smoothed_covid": "0.62", + "percent_visits_smoothed_1": "0.18", + "percent_visits_smoothed_rsv": "0.02", + "ed_trends_covid": "Decreasing", + "ed_trends_influenza": "No Change", + "ed_trends_rsv": "Decreasing", + "hsa": "Denver (Denver), CO - Jefferson, CO", + "hsa_counties": "Adams, Arapahoe, Clear Creek, Denver, Douglas, Elbert, Gilpin, Grand, Jefferson, Park, Summit", + "hsa_nci_id": "688", + "fips": "8059", + "trend_source": "HSA" } ] diff --git a/nssp/tests/test_pull.py b/nssp/tests/test_pull.py index cdc85a908..b356341f6 100644 --- a/nssp/tests/test_pull.py +++ b/nssp/tests/test_pull.py @@ -49,6 +49,7 @@ def test_pull_nssp_data(self, mock_socrata): assert result["geography"].notnull().all(), "geography has rogue NaN" assert result["county"].notnull().all(), "county has rogue NaN" assert result["fips"].notnull().all(), "fips has rogue NaN" + assert result["fips"].apply(lambda x: isinstance(x, str) and len(x) != 4).all(), "fips formatting should always be 5 digits; include leading zeros if aplicable" # Check for each signal in SIGNALS for signal in SIGNALS: From e2033c3f9dbcb37292bb34551930fce64d9fcd22 Mon Sep 17 00:00:00 2001 From: minhkhul <118945681+minhkhul@users.noreply.github.com> Date: Tue, 25 Jun 2024 12:13:50 -0400 Subject: [PATCH 26/48] Add params to control solver, default Clarabel, pin cvxpy version (#1975) * remove clarabel spec in weekday, pin claims_hosp cvxpy to <=1.4 and doctor_visits cvxpy to >=1.5 * add solver as param spec * Update update_sensor.py * linter * linter * default to clarabel. ecos for hosp admissions * linter * Update claims_hosp/setup.py Co-authored-by: george * Apply suggestions from George's review Co-authored-by: george * linter * linter * linter * add test get params legacy * fix test get params legacy * linter --------- Co-authored-by: george --- _delphi_utils_python/delphi_utils/weekday.py | 28 ++++++++++++++++--- _delphi_utils_python/tests/test_weekday.py | 25 +++++++++++++++++ .../delphi_claims_hosp/update_indicator.py | 27 ++++++++++-------- claims_hosp/setup.py | 1 + .../delphi_doctor_visits/update_sensor.py | 23 +++++++++------ doctor_visits/setup.py | 1 + 6 files changed, 80 insertions(+), 25 deletions(-) diff --git a/_delphi_utils_python/delphi_utils/weekday.py b/_delphi_utils_python/delphi_utils/weekday.py index 055812818..6e8f23786 100644 --- a/_delphi_utils_python/delphi_utils/weekday.py +++ b/_delphi_utils_python/delphi_utils/weekday.py @@ -12,12 +12,19 @@ class Weekday: """Class to handle weekday effects.""" @staticmethod - def get_params(data, denominator_col, numerator_cols, date_col, scales, logger): + def get_params(data, denominator_col, numerator_cols, date_col, scales, logger, solver_override=None): r"""Fit weekday correction for each col in numerator_cols. Return a matrix of parameters: the entire vector of betas, for each time series column in the data. + + solver: Historically used "ECOS" but due to numerical stability issues, "CLARABEL" + (introduced in cvxpy 1.3)is now the default solver in cvxpy 1.5. """ + if solver_override is None: + solver = cp.CLARABEL + else: + solver = solver_override tmp = data.reset_index() denoms = tmp.groupby(date_col).sum()[denominator_col] nums = tmp.groupby(date_col).sum()[numerator_cols] @@ -35,7 +42,7 @@ def get_params(data, denominator_col, numerator_cols, date_col, scales, logger): # Loop over the available numerator columns and smooth each separately. for i in range(nums.shape[1]): - result = Weekday._fit(X, scales, npnums[:, i], npdenoms) + result = Weekday._fit(X, scales, npnums[:, i], npdenoms, solver) if result is None: logger.error("Unable to calculate weekday correction") else: @@ -44,7 +51,18 @@ def get_params(data, denominator_col, numerator_cols, date_col, scales, logger): return params @staticmethod - def _fit(X, scales, npnums, npdenoms): + def get_params_legacy(data, denominator_col, numerator_cols, date_col, scales, logger): + r""" + Preserves older default behavior of using the ECOS solver. + + NOTE: "ECOS" solver will not be installed by default as of cvxpy 1.6 + """ + return Weekday.get_params( + data, denominator_col, numerator_cols, date_col, scales, logger, solver_override=cp.ECOS + ) + + @staticmethod + def _fit(X, scales, npnums, npdenoms, solver): r"""Correct a signal estimated as numerator/denominator for weekday effects. The ordinary estimate would be numerator_t/denominator_t for each time point @@ -78,6 +96,8 @@ def _fit(X, scales, npnums, npdenoms): ll = (numerator * (X*b + log(denominator)) - sum(exp(X*b) + log(denominator))) / num_days + + solver: Historically use "ECOS" but due to numerical issues, "CLARABEL" is now default. """ b = cp.Variable((X.shape[1])) @@ -93,7 +113,7 @@ def _fit(X, scales, npnums, npdenoms): for scale in scales: try: prob = cp.Problem(cp.Minimize((-ll + lmbda * penalty) / scale)) - _ = prob.solve(solver=cp.CLARABEL) + _ = prob.solve(solver=solver) return b.value except SolverError: # If the magnitude of the objective function is too large, an error is diff --git a/_delphi_utils_python/tests/test_weekday.py b/_delphi_utils_python/tests/test_weekday.py index 53da7088c..adb3fbfae 100644 --- a/_delphi_utils_python/tests/test_weekday.py +++ b/_delphi_utils_python/tests/test_weekday.py @@ -25,6 +25,31 @@ def test_get_params(self): -0.81464459, -0.76322013, -0.7667211,-0.8251475]]) assert np.allclose(result, expected_result) + def test_get_params_legacy(self): + TEST_LOGGER = logging.getLogger() + + result = Weekday.get_params_legacy(self.TEST_DATA, "den", ["num"], "date", [1], TEST_LOGGER) + print(result) + expected_result = [ + -0.05993665, + -0.0727396, + -0.05618517, + 0.0343405, + 0.12534997, + 0.04561813, + -2.27669028, + -1.89564374, + -1.5695407, + -1.29838116, + -1.08216513, + -0.92089259, + -0.81456355, + -0.76317802, + -0.76673598, + -0.82523745, + ] + assert np.allclose(result, expected_result) + def test_calc_adjustment_with_zero_parameters(self): params = np.array([[0, 0, 0, 0, 0, 0, 0]]) diff --git a/claims_hosp/delphi_claims_hosp/update_indicator.py b/claims_hosp/delphi_claims_hosp/update_indicator.py index b4169370d..df3f3308f 100644 --- a/claims_hosp/delphi_claims_hosp/update_indicator.py +++ b/claims_hosp/delphi_claims_hosp/update_indicator.py @@ -13,13 +13,13 @@ # third party import numpy as np import pandas as pd -from delphi_utils import GeoMapper # first party -from delphi_utils import Weekday +from delphi_utils import GeoMapper, Weekday + from .config import Config, GeoConstants -from .load_data import load_data from .indicator import ClaimsHospIndicator +from .load_data import load_data class ClaimsHospIndicatorUpdater: @@ -152,15 +152,18 @@ def update_indicator(self, input_filepath, outpath, logger): data_frame = self.geo_reindex(data) # handle if we need to adjust by weekday - wd_params = Weekday.get_params( - data_frame, - "den", - ["num"], - Config.DATE_COL, - [1, 1e5], - logger, - ) if self.weekday else None - + wd_params = ( + Weekday.get_params_legacy( + data_frame, + "den", + ["num"], + Config.DATE_COL, + [1, 1e5], + logger, + ) + if self.weekday + else None + ) # run fitting code (maybe in parallel) rates = {} std_errs = {} diff --git a/claims_hosp/setup.py b/claims_hosp/setup.py index 490b38b99..76611e33d 100644 --- a/claims_hosp/setup.py +++ b/claims_hosp/setup.py @@ -13,6 +13,7 @@ "pylint==2.8.3", "pytest-cov", "pytest", + "cvxpy<1.6", ] setup( diff --git a/doctor_visits/delphi_doctor_visits/update_sensor.py b/doctor_visits/delphi_doctor_visits/update_sensor.py index 019c3f9d5..125c0df18 100644 --- a/doctor_visits/delphi_doctor_visits/update_sensor.py +++ b/doctor_visits/delphi_doctor_visits/update_sensor.py @@ -18,6 +18,7 @@ # first party from delphi_utils import Weekday + from .config import Config from .geo_maps import GeoMaps from .sensor import DoctorVisitsSensor @@ -125,15 +126,19 @@ def update_sensor( (burn_in_dates >= startdate) & (burn_in_dates <= enddate))[0][:len(sensor_dates)] # handle if we need to adjust by weekday - params = Weekday.get_params( - data, - "Denominator", - Config.CLI_COLS + Config.FLU1_COL, - Config.DATE_COL, - [1, 1e5, 1e10, 1e15], - logger, - ) if weekday else None - if weekday and np.any(np.all(params == 0,axis=1)): + params = ( + Weekday.get_params( + data, + "Denominator", + Config.CLI_COLS + Config.FLU1_COL, + Config.DATE_COL, + [1, 1e5, 1e10, 1e15], + logger, + ) + if weekday + else None + ) + if weekday and np.any(np.all(params == 0, axis=1)): # Weekday correction failed for at least one count type return None diff --git a/doctor_visits/setup.py b/doctor_visits/setup.py index fc291160d..53e5b722e 100644 --- a/doctor_visits/setup.py +++ b/doctor_visits/setup.py @@ -11,6 +11,7 @@ "pytest-cov", "pytest", "scikit-learn", + "cvxpy>=1.5", ] setup( From 84d059751b646c0075f1a384741f2c1d80981269 Mon Sep 17 00:00:00 2001 From: minhkhul <118945681+minhkhul@users.noreply.github.com> Date: Tue, 25 Jun 2024 17:35:56 -0400 Subject: [PATCH 27/48] remove hhs and chng from sircal (#1971) --- ansible/templates/sir_complainsalot-params-prod.json.j2 | 9 --------- sir_complainsalot/params.json.template | 9 --------- 2 files changed, 18 deletions(-) diff --git a/ansible/templates/sir_complainsalot-params-prod.json.j2 b/ansible/templates/sir_complainsalot-params-prod.json.j2 index a016fc470..e44f164b6 100644 --- a/ansible/templates/sir_complainsalot-params-prod.json.j2 +++ b/ansible/templates/sir_complainsalot-params-prod.json.j2 @@ -12,11 +12,6 @@ "maintainers": ["U01AP8GSWG3","U01069KCRS7"], "retired-signals": ["smoothed_covid19","smoothed_adj_covid19"] }, - "chng": { - "max_age": 6, - "maintainers": ["U01AP8GSWG3","U01069KCRS7"], - "retired-signals": ["7dav_outpatient_covid","7dav_inpatient_covid"] - }, "google-symptoms": { "max_age": 6, "maintainers": ["U01AP8GSWG3","U01069KCRS7"], @@ -47,10 +42,6 @@ "max_age":19, "maintainers": [] }, - "hhs": { - "max_age":15, - "maintainers": [] - }, "nssp": { "max_age":13, "maintainers": [] diff --git a/sir_complainsalot/params.json.template b/sir_complainsalot/params.json.template index e742c63bc..6b1bf870b 100644 --- a/sir_complainsalot/params.json.template +++ b/sir_complainsalot/params.json.template @@ -12,11 +12,6 @@ "maintainers": ["U01AP8GSWG3","U01069KCRS7"], "retired-signals": ["smoothed_covid19","smoothed_adj_covid19"] }, - "chng": { - "max_age": 6, - "maintainers": ["U01AP8GSWG3","U01069KCRS7"], - "retired-signals": ["7dav_outpatient_covid","7dav_inpatient_covid"] - }, "google-symptoms": { "max_age": 6, "maintainers": ["U01AP8GSWG3","U01069KCRS7"], @@ -47,10 +42,6 @@ "max_age":19, "maintainers": [] }, - "hhs": { - "max_age":15, - "maintainers": [] - }, "nssp": { "max_age":13, "maintainers": [] From d3bac9d330f1284df53da6dd548ad38e524bcf86 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 13:25:23 -0400 Subject: [PATCH 28/48] initial add pipeline manual --- _template_python/INDICATOR_DEV_GUIDE.md | 282 ++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 _template_python/INDICATOR_DEV_GUIDE.md diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md new file mode 100644 index 000000000..839f62301 --- /dev/null +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -0,0 +1,282 @@ +# Pipeline Development Manual + +## A step-by-step guide to writing a pipeline + +TODO: + +[] Geomapper guide +[] Setting up development environment +[] Deployment guide +[] Manual for R? + + +## Introduction + +This document provides a comprehensive guide on how to write a data pipeline in Python for the Delphi group. It focuses on various aspects of building a pipeline, including ingestion, transformation, and storage. This document assumes basic knowledge of Python and a familiarity with Delphi’s data processing practices. Throughout the manual, we will use various python libraries to demonstrate how to build a data pipeline that can handle large volumes of data efficiently. We will also discuss best practices for building reliable, scalable, and maintainable data pipelines. + +### Related documents: + +There is a guide to new endpoints (of which COVIDcast is a single example) here in delphi-epidata, and hosted on the actual website here. + +## Basic steps of an indicator + +This is the general extract-transform-load procedure used by all COVIDcast indicators: + +1. Download data from the source. + * This could be via an API query, scraping a website, an SFTP or S3 dropbox, an email attachment, etc. +2. Process the source data to extract one or more time-series signals. + * A signal includes a value, standard error (data-dependent), and sample size (data-dependent) for each region for each unit of time (a day or an epidemiological week "epi-week"). +3. Aggregate each signal to all possible standard higher geographic levels. + * For example, we generate data at the state level by combining data at the county level. +4. Output each signal into a set of CSV files with a fixed format. +5. Run a set of checks on the output. + * This ensures output will be accepted by the acquisition code and hunts for common signs of buggy code or bad source data. +6. (Data-dependent) Compare today's output with a cached version of what's currently in the API. + * This converts dense output to a diff and reduces the size of each update. +7. Deliver the CSV output files to the receiving/ directory on the API server. + +## Stage 0: Keep revision history (important!) + +If the data provider doesn’t provide or it is unclear if they provide historical versions of the data, immediately set up a script (bash, Python, etc) to automatically (e.g. cron) download the data every day and save locally with versioning. + +This step has a few goals: + +1. Determine if the data is revised over time +2. Understand the revision behavior in detail +3. If the data is revised, we want to save all possible versions, even before our pipeline is fully up + +The data should be saved in raw form – do not do any processing. Our own processing (cleaning, aggregation, normalization, etc) of the data may change as the pipeline code develops and doing any processing up front could make the historical data incompatible with the final procedure. +Check back in a couple weeks to compare data versions for revisions. + +## Stage 1: Exploratory Analysis + +The goal for exploratory analysis is to decide how the dataset does and does not fit our needs. This information will be used in the indicator documentation and will warn us about potential difficulties in the pipeline, so this should be done thoroughly! Your goal is to become an expert on the ins and outs of the data source. + +While some of this process might have been done already (i.e. it was already decided that the data is useful), it is still important to understand the properties of the dataset. The main objective during this stage is to understand what the dataset looks like in its raw format, establish what transformations need to be done, and create a basic roadmap to accomplish all later setup tasks. + +What you want to establish: + +* Data fields that correspond to signals we want to report +* Reporting lag and schedule +* Backfill behavior +* Sample size +* Georesolution +* Limitations + +### Fetching the data + +Download the data in whatever format suits you. Jupyter notebooks work particularly well for exploratory analysis but feel free to use whatever IDE/methodology works best for you. + +* As an example Luke manually downloaded a CSV from the DSEW-CPR county website and used that for initial exploration in a jupyter notebook. Don’t worry too much about productionizing the data-fetching step now. +* Check to see whether the data is coming from an existing source, e.g. the wastewater data and NCHS data are accessed the same way, so when adding wastewater data, we could reuse the API key and only needed to lightly modify the API calls for the new dataset. + +From a local file: + +```{python} +import pandas as pd +df = pd.read_csv('/Users/lukeneureiter/Downloads/luke_cpr_test.csv') +``` +From Socrata: + +```{python} +import os +from sodapy import Socrata +token = os.environ.get("SODAPY_APPTOKEN") +client = Socrata("data.cdc.gov", token) +results = client.get("rdmq-nq56", limit=10**10) +df = pd.DataFrame.from_records(results, coerce_float=True) +``` + +### Detailed questions to answer + +At this stage we want to answer the questions below (and any others that seem relevant) and consider how we might use the data before we determine that the source should become a pipeline. + +* What raw signals are available in the data? + * If the raw signals aren’t useful themselves, what useful signals could we create from these? + * Discuss with the data requestor or data request GitHub issue which signals they are interested in and, if there are multiple potential signals, the pros/cons of using each one. + * For each signal, we want to report a value, standard error (data-dependent), and sample size (data-dependent) for each region for each unit of time. Sample size is sometimes available as a separate “counts” signal. +* Are the signals available across different geographies? Can values be meaningfully compared between locations? + * Ideally, we want to report data at county, MSA, HRR, state, HHS, and nation levels (US) or subregion level 2 (county, parish, etc), subregion level 1 (state, province, territory), and nation levels for other countries. Some data sources report these levels themselves. For those that don’t, we use the geomapper to aggregate up from smaller to larger geo types. For that tool to work, signals must be aggregatable (i.e. values have to be comparable between geos) and the data must be reported at supported geo types or at geo types that are mappable to supported geo types. +* What geographies might be included that are not standard? + * For example, some data sources report NYC as separate from New York State. + * Others require special handling: D.C., Puerto Rico, Guam, U.S. Virgin Islands. + * ! Sampling site, facility, or other data-specific or proprietary geographic division + * The data may not be appropriate for inclusion in the main endpoint (as of 20240628 called COVIDcast). Talk to Dmitry Shemetov(geomapper), George Haff(epidata, DB), and Roni Rosenfeld(PI) for discussion. + * Should the data have its own endpoint? + * Consider creating a PRD (here or here) to present design options. +* What is the sample size? Is this a privacy concern for us or for the data provider? +* How often is data missing? + * E.g. for privacy, some sources only report when sample size is above a certain threshold + * Will we want to and is it feasible to interpolate missing values? +* Are there any aberrant values that don’t make sense? e.g. negative counts, out of range percentages, “manual” missingness codes (9999, -9999, etc) +* Does the data source revise their data? How often? + * See raw data saved in Stage 0 +* What is the reporting schedule of the data? +* What order of magnitude is the signal? (If it’s sufficiently small, this issue needs to be addressed first) +* How is the data processed by the data source? E.g. normalization, censoring values with small sample sizes, censoring values associated with low-population areas, smoothing, adding jitter, etc. +Keep any code and notes around! They will be helpful for later steps. +For any issues that come up, consider now if +* We’ve seen them before in another dataset and, if so, how we handled it. Is there code around that we can reuse? +* If it’s a small issue, how would you address it? Do you need an extra function to handle it? +* If it’s a big issue, talk to others and consider making a PRD to present potential solutions. + +## Stage 2: Pipeline Code + +Now that we know the substance and dimensions of our data, we can start planning the pipeline code. + +### Logic overview + +Broadly speaking, the objective here is to create a script that will download data, transform it (mainly by aggregating it to different geo levels), format it to match our standard format, and save the transformed data to the receiving directory as a CSV. The indicator, validation (a series of quality checks), and archive diffing (compressing the data by only outputting rows changed between data versions) are run via the runner. Acquisition (ingestion of files from the receiving directory and into the database) is run separately (see the delphi-epidata repo). + +params.json.template is copied to params.json during a run. params.json is used to set parameters that modify a run and that we expect we’ll want to change in the future (e.g. date range to generate) or need to be obfuscated (e.g. API key). + +Each indicator has a makefile (using GNU make), which provides predefined routines for local setup, testing, linting, and running the indicator. At the moment, the makefiles use python 3.8.15+. + +### Development + +To get started, Delphi has a basic code template that you should copy into a top-level directory in the covidcast-indicators repo. It can also be helpful to read through other indicators (e.g.), especially if they share a data source or format. + +Indicators should be written in python for speed and maintainability. If you think you need to use R, please reconsider! and talk to other engineering team members. + +Generally, indicators have: + +* run.py: Run through all the pipeline steps. Loops over all geo type-signal combinations we want to produce. Handles logging and saving to CSV using functions from delphi_utils. +* pull.py: Fetch the data from the data source and do basic processing (e.g. drop unnecessary columns). Advanced processing (e.g. sensorization) should go elsewhere. +* geo.py: Do geo-aggregation. This tends to be simple wrappers around delphi_utils.geomapper functions. Do other geo handling (e.g. finding and reporting DC as a state). +* constants.py: Lists of geos to produce, signals to produce, dataset ids, data source URL, etc. + +#### Function stubs + +If you have many functions you want to implement and/or anticipate a complex pipeline, consider starting with function stubs with comments or pseudo code. Bonus: consider writing unit tests upfront based on the expected behavior of each function. + +Some stubs to consider: + +* Retrieve a list of filenames +* Download one data file (API call, csv reader, etc.) +* Iterate through filenames to download all data files +* Construct an SQL query +* Run an SQL query +* Keep a list of columns +* Geographic transformations (will tend to be wrappers around delphi_utils.geomapper functions) + +Example stub: + +```{python} +def api_call(args) + #implement api call + return df +``` + +Next, populate the function stubs with the intention of using them for a single pre-defined run (ignoring params.json, other geo levels, etc). If you fetched data programmatically in Stage 0, you can reuse that in your data-fetching code. If you reformatted data in Stage 1, you can reuse that too. +Below is an example of the function stub that has been populated with code for a one-off run. + +```{python} +def api_call(token: str): + client = Socrata('healthdata.gov', token) + results = client.get("di4u-7yu6", limit=5000) + results_df = pd.DataFrame.from_records(results) + return results_df +``` + +After that, generalize your code to be able to be run on all geos of interest, take settings from params.json, use constants for easy maintenance, with extensive documentation, etc. + +#### Development environment + +Make sure you have a functional environment with python 3.8.15+. For local runs, the makefile’s make install target will set up a local virtual environment with necessary packages. + +(If working in R (not recommended), local runs can be run without a virtual environment or using the renv package, but production runs should be set up to user Docker.) + +#### Dealing with data-types + +* Often problem encountered prior to geomapper + * Problems that can arise and how to address them +* Basic conversion + +TODO: A list of assumptions that the server makes about various columns would be helpful. E.g. which geo values are allowed, should every valid date be present in some way, etc + +#### Dealing with geos + +In an ideal case, the data exists at one of our already covered geos: + +* State: state_code or state_id +* FIPS (state+county codes) +* ZIP +* MSA (metro statistical area, int) +* HRR (hospital referral region, int) + +If you want to map from one of these to another, we have a utility, geomapper.py, that covers most cases. A brief example of adding states with their population: + +```{python} +from delphi_utils.geomap import GeoMapper +geo_mapper = GeoMapper() +geo_mapper.add_geocode(df, "state_id", "state_code", from_col = "state") # add codes and ids from the full names +df = geo_mapper.add_population_column(df, "state_code") # add state populations +hhs_version = geo_mapper.replace_geocode(df, "state_code","hhs", new_col = "geo_id") # aggregate to hhs regions, renaming the geo column to geo_id +``` + +This example is taken from hhs_hosp; more documentation can be found in the geomapper class definition. + +#### Implement a Missing Value code system + +The column is described here + +#### Testing + +As a general rule, it helps to decompose your functions into operations for which you can write unit tests. To run the tests, use make test in the base directory. + +### Statistical Analysis + +### Documentation + + +## Stage 3: Deployment + +* This is after we have a working one-off script +* Using Delphi utils and functionality +* What happens to the data after it gets put in /receiving: + +Next, the acquisition:covidcast component of the delphi-epidata codebase does the following immediately after an indicator run (You do need to set acquisition job up): + +1. Look in the receiving/ folder to see if any new data files are available. If there are, then: + 1. Import the new data into the epimetric_full table of the epidata.covid database, filling in the columns as follows: + 1. source: parsed from the name of the subdirectory of receiving/ + 2. signal: parsed from the filename + 3. time_type: parsed from the filename + 4. time_value: parsed from the filename + 5. geo_type: parsed from the filename + 6. geo_value: parsed from each row of the csv file + 7. value: parsed from each row of the csv file + 8. se: parsed from each row of the csv file + 9. sample_size: parsed from each row of the csv file + 10. issue: whatever now is in time_type units + 11. lag: the difference in time_type units from now to time_value + 12. value_updated_timestamp: now + * Update the epimetric_latest table with any new keys or new versions of existing keys. + +### Staging + +After developing the pipeline code, but before deploying in development, the pipeline should be run on staging for at least a week. This involves setting up some cronicle jobs as follows: first the indicator run + +Then the acquisition run +https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5clgy6rs +https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5ctl7art + +Note the staging hostname and how the acquisition job is chained to run right after the indicator job. Do a few test runs. + +If everything goes well (check staging db if data is ingested properly), make a prod version of the indicator run job and use that to run indicator on a daily basis. + +Another thing to do is setting up the params.json template file in accordance with how you want to run the indicator and acquisition. Pay attention to the receiving directory, as well as how you can store credentials in vault. Refer to this guide for more vault info. + + +### Signal Documentation + +Apparently adding to a google spreadsheet, need to talk to someone (Carlyn) about the specifics + +Github page signal documentation talk to Nat and Tina + +## Appendix + +Use the appendix to keep track of discussion and decisions + +* New Archiver Procedure document about setting up an S3 ArchiveDiffer +* Indicator debugging document, somewhat out-of-date but might still be useful From 7e4a4fc3807ff54ce5d53160d43ed0f6ce69e784 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 15:08:06 -0400 Subject: [PATCH 29/48] links --- _template_python/INDICATOR_DEV_GUIDE.md | 121 ++++++++++++------------ 1 file changed, 60 insertions(+), 61 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index 839f62301..59a8c3188 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -16,14 +16,14 @@ This document provides a comprehensive guide on how to write a data pipeline in ### Related documents: -There is a guide to new endpoints (of which COVIDcast is a single example) here in delphi-epidata, and hosted on the actual website here. +We also have a guide on [adding new API endpoints](https://cmu-delphi.github.io/delphi-epidata/new_endpoint_tutorial.html) (of which COVIDcast is a single example). ## Basic steps of an indicator This is the general extract-transform-load procedure used by all COVIDcast indicators: 1. Download data from the source. - * This could be via an API query, scraping a website, an SFTP or S3 dropbox, an email attachment, etc. + * This could be via an API query, scraping the website, an SFTP or S3 dropbox, an email attachment, etc. 2. Process the source data to extract one or more time-series signals. * A signal includes a value, standard error (data-dependent), and sample size (data-dependent) for each region for each unit of time (a day or an epidemiological week "epi-week"). 3. Aggregate each signal to all possible standard higher geographic levels. @@ -33,9 +33,9 @@ This is the general extract-transform-load procedure used by all COVIDcast indic * This ensures output will be accepted by the acquisition code and hunts for common signs of buggy code or bad source data. 6. (Data-dependent) Compare today's output with a cached version of what's currently in the API. * This converts dense output to a diff and reduces the size of each update. -7. Deliver the CSV output files to the receiving/ directory on the API server. +7. Deliver the CSV output files to the `receiving/` directory on the API server. -## Stage 0: Keep revision history (important!) +## Step 0: Keep revision history (important!) If the data provider doesn’t provide or it is unclear if they provide historical versions of the data, immediately set up a script (bash, Python, etc) to automatically (e.g. cron) download the data every day and save locally with versioning. @@ -45,16 +45,17 @@ This step has a few goals: 2. Understand the revision behavior in detail 3. If the data is revised, we want to save all possible versions, even before our pipeline is fully up -The data should be saved in raw form – do not do any processing. Our own processing (cleaning, aggregation, normalization, etc) of the data may change as the pipeline code develops and doing any processing up front could make the historical data incompatible with the final procedure. +The data should be saved in _raw_ form – do not do any processing. Our own processing (cleaning, aggregation, normalization, etc) of the data may change as the pipeline code develops and doing any processing up front could make the historical data incompatible with the final procedure. + Check back in a couple weeks to compare data versions for revisions. -## Stage 1: Exploratory Analysis +## Step 1: Exploratory Analysis -The goal for exploratory analysis is to decide how the dataset does and does not fit our needs. This information will be used in the indicator documentation and will warn us about potential difficulties in the pipeline, so this should be done thoroughly! Your goal is to become an expert on the ins and outs of the data source. +The goal for exploratory analysis is to decide how the dataset does and does not fit our needs. This information will be used in the [indicator documentation](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_signals.html) and will warn us about potential difficulties in the pipeline, so this should be done thoroughly! Your goal is to become an expert on the ins and outs of the data source. While some of this process might have been done already (i.e. it was already decided that the data is useful), it is still important to understand the properties of the dataset. The main objective during this stage is to understand what the dataset looks like in its raw format, establish what transformations need to be done, and create a basic roadmap to accomplish all later setup tasks. -What you want to establish: +**What you want to establish:** * Data fields that correspond to signals we want to report * Reporting lag and schedule @@ -63,20 +64,21 @@ What you want to establish: * Georesolution * Limitations +Jupyter notebooks work particularly well for exploratory analysis but feel free to use whatever IDE/methodology works best for you. + ### Fetching the data -Download the data in whatever format suits you. Jupyter notebooks work particularly well for exploratory analysis but feel free to use whatever IDE/methodology works best for you. +Download the data in whatever format suits you. A one-off manual download is fine. Don’t worry too much about productionizing the data-fetching step at this point. (Although any code you write can be used later.) -* As an example Luke manually downloaded a CSV from the DSEW-CPR county website and used that for initial exploration in a jupyter notebook. Don’t worry too much about productionizing the data-fetching step now. -* Check to see whether the data is coming from an existing source, e.g. the wastewater data and NCHS data are accessed the same way, so when adding wastewater data, we could reuse the API key and only needed to lightly modify the API calls for the new dataset. +Check to see whether the data is coming from an existing source, e.g. the wastewater data and NCHS data are accessed the same way, so when adding wastewater data, we could reuse the API key and only needed to lightly modify the API calls for the new dataset. -From a local file: +Reading from a local file: ```{python} import pandas as pd df = pd.read_csv('/Users/lukeneureiter/Downloads/luke_cpr_test.csv') ``` -From Socrata: +Fetching from Socrata: ```{python} import os @@ -93,26 +95,26 @@ At this stage we want to answer the questions below (and any others that seem re * What raw signals are available in the data? * If the raw signals aren’t useful themselves, what useful signals could we create from these? - * Discuss with the data requestor or data request GitHub issue which signals they are interested in and, if there are multiple potential signals, the pros/cons of using each one. + * Discuss with the data requestor or consult the data request GitHub issue which signals they are interested in. If there are multiple potential signals, are there any known pros/cons of each one? * For each signal, we want to report a value, standard error (data-dependent), and sample size (data-dependent) for each region for each unit of time. Sample size is sometimes available as a separate “counts” signal. -* Are the signals available across different geographies? Can values be meaningfully compared between locations? - * Ideally, we want to report data at county, MSA, HRR, state, HHS, and nation levels (US) or subregion level 2 (county, parish, etc), subregion level 1 (state, province, territory), and nation levels for other countries. Some data sources report these levels themselves. For those that don’t, we use the geomapper to aggregate up from smaller to larger geo types. For that tool to work, signals must be aggregatable (i.e. values have to be comparable between geos) and the data must be reported at supported geo types or at geo types that are mappable to supported geo types. +* Are the signals available across different geographies? Can values be [meaningfully compared](https://cmu-delphi.github.io/delphi-epidata/api/covidcast-signals/google-symptoms.html#limitations) between locations? + * Ideally, we want to report data at [county, MSA, HRR, state, HHS, and nation levels](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_geography.html) (US) or subregion level 2 (county, parish, etc), subregion level 1 (state, province, territory), and nation levels for other countries. Some data sources report these levels themselves. For those that don’t, we use the [`geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/84d059751b646c0075f1a384741f2c1d80981269/_delphi_utils_python/delphi_utils/geomap.py) to aggregate up from smaller to larger geo types. For that tool to work, signals must be aggregatable (i.e. values have to be comparable between geos) and the data must be reported at supported geo types or at geo types that are mappable to supported geo types. * What geographies might be included that are not standard? * For example, some data sources report NYC as separate from New York State. - * Others require special handling: D.C., Puerto Rico, Guam, U.S. Virgin Islands. + * Others require special handling: D.C. and territories (Puerto Rico, Guam, U.S. Virgin Islands). * ! Sampling site, facility, or other data-specific or proprietary geographic division - * The data may not be appropriate for inclusion in the main endpoint (as of 20240628 called COVIDcast). Talk to Dmitry Shemetov(geomapper), George Haff(epidata, DB), and Roni Rosenfeld(PI) for discussion. + * The data may not be appropriate for inclusion in the main endpoint (as of 20240628 called COVIDcast). Talk to @dshemetov (geomapper), @melange396 (epidata, DB), and @RoniRos (PI) for discussion. * Should the data have its own endpoint? - * Consider creating a PRD (here or here) to present design options. + * Consider creating a PRD ([here](https://drive.google.com/drive/u/1/folders/155cGrc9Y7NWwygslCcU8gjL2AQbu5rFF) or [here](https://drive.google.com/drive/u/1/folders/13wUoIl-FjjCkbn2O8qH1iXOCBo2eF2-d)) to present design options. * What is the sample size? Is this a privacy concern for us or for the data provider? * How often is data missing? * E.g. for privacy, some sources only report when sample size is above a certain threshold - * Will we want to and is it feasible to interpolate missing values? + * Will we want to and is it feasible to [interpolate missing values](https://github.com/cmu-delphi/covidcast-indicators/issues/1539)? * Are there any aberrant values that don’t make sense? e.g. negative counts, out of range percentages, “manual” missingness codes (9999, -9999, etc) * Does the data source revise their data? How often? - * See raw data saved in Stage 0 + * See raw data saved in Step 0 * What is the reporting schedule of the data? -* What order of magnitude is the signal? (If it’s sufficiently small, this issue needs to be addressed first) +* What order of magnitude is the signal? (If it’s sufficiently small, [this issue on how rounding is done](https://github.com/cmu-delphi/covidcast-indicators/issues/1945) needs to be addressed first) * How is the data processed by the data source? E.g. normalization, censoring values with small sample sizes, censoring values associated with low-population areas, smoothing, adding jitter, etc. Keep any code and notes around! They will be helpful for later steps. For any issues that come up, consider now if @@ -120,34 +122,34 @@ For any issues that come up, consider now if * If it’s a small issue, how would you address it? Do you need an extra function to handle it? * If it’s a big issue, talk to others and consider making a PRD to present potential solutions. -## Stage 2: Pipeline Code +## Step 2: Pipeline Code Now that we know the substance and dimensions of our data, we can start planning the pipeline code. ### Logic overview -Broadly speaking, the objective here is to create a script that will download data, transform it (mainly by aggregating it to different geo levels), format it to match our standard format, and save the transformed data to the receiving directory as a CSV. The indicator, validation (a series of quality checks), and archive diffing (compressing the data by only outputting rows changed between data versions) are run via the runner. Acquisition (ingestion of files from the receiving directory and into the database) is run separately (see the delphi-epidata repo). +Broadly speaking, the objective here is to create a script that will download data, transform it (mainly by aggregating it to different geo levels), format it to match our standard format, and save the transformed data to the [receiving directory](https://github.com/cmu-delphi/covidcast-indicators/blob/d36352b/ansible/templates/changehc-params-prod.json.j2#L3) as a CSV. The indicator, [validation](https://github.com/cmu-delphi/covidcast-indicators/tree/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/validator) (a series of quality checks), and [archive diffing](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/archive.py) (compressing the data by only outputting rows changed between data versions) are run via the runner. Acquisition (ingestion of files from the receiving directory and into the database) is run separately (see the [`delphi-epidata repo`](https://github.com/cmu-delphi/delphi-epidata/tree/c65d8093d9e8fed97b3347e195cc9c40c1a5fcfa)). -params.json.template is copied to params.json during a run. params.json is used to set parameters that modify a run and that we expect we’ll want to change in the future (e.g. date range to generate) or need to be obfuscated (e.g. API key). +`params.json.template` is copied to `params.json` during a run. `params.json` is used to set parameters that modify a run and that we expect we’ll want to change in the future (e.g. date range to generate) or need to be obfuscated (e.g. API key). -Each indicator has a makefile (using GNU make), which provides predefined routines for local setup, testing, linting, and running the indicator. At the moment, the makefiles use python 3.8.15+. +Each indicator includes a makefile (using GNU make), which provides predefined routines for local setup, testing, linting, and running the indicator. At the moment, the makefiles use python 3.8.15+. ### Development -To get started, Delphi has a basic code template that you should copy into a top-level directory in the covidcast-indicators repo. It can also be helpful to read through other indicators (e.g.), especially if they share a data source or format. +To get started, Delphi has a [basic code template](https://github.com/cmu-delphi/covidcast-indicators/tree/6f46f2b4a0cf86137fda5bd58025997647c87b46/_template_python) that you should copy into a top-level directory in the [`covidcast-indicators` repo](https://github.com/cmu-delphi/covidcast-indicators/). It can also be helpful to read through other indicators, especially if they share a data source or format. Indicators should be written in python for speed and maintainability. If you think you need to use R, please reconsider! and talk to other engineering team members. Generally, indicators have: -* run.py: Run through all the pipeline steps. Loops over all geo type-signal combinations we want to produce. Handles logging and saving to CSV using functions from delphi_utils. -* pull.py: Fetch the data from the data source and do basic processing (e.g. drop unnecessary columns). Advanced processing (e.g. sensorization) should go elsewhere. -* geo.py: Do geo-aggregation. This tends to be simple wrappers around delphi_utils.geomapper functions. Do other geo handling (e.g. finding and reporting DC as a state). -* constants.py: Lists of geos to produce, signals to produce, dataset ids, data source URL, etc. +* `run.py`: Run through all the pipeline steps. Loops over all geo type-signal combinations we want to produce. Handles logging and saving to CSV using functions from [`delphi_utils`](https://github.com/cmu-delphi/covidcast-indicators/tree/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils). +* `pull.py`: Fetch the data from the data source and do basic processing (e.g. drop unnecessary columns). Advanced processing (e.g. sensorization) should go elsewhere. +* `geo.py`: Do geo-aggregation. This tends to be simple wrappers around [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) functions. Do other geo handling (e.g. finding and reporting DC as a state). +* `constants.py`: Lists of geos to produce, signals to produce, dataset ids, data source URL, etc. #### Function stubs -If you have many functions you want to implement and/or anticipate a complex pipeline, consider starting with function stubs with comments or pseudo code. Bonus: consider writing unit tests upfront based on the expected behavior of each function. +If you have many functions you want to implement and/or anticipate a complex pipeline, consider starting with [function stubs](https://en.wikipedia.org/wiki/Method_stub) with comments or pseudo code. Bonus: consider writing unit tests upfront based on the expected behavior of each function. Some stubs to consider: @@ -157,7 +159,7 @@ Some stubs to consider: * Construct an SQL query * Run an SQL query * Keep a list of columns -* Geographic transformations (will tend to be wrappers around delphi_utils.geomapper functions) +* Geographic transformations (will tend to be wrappers around [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) functions) Example stub: @@ -167,7 +169,7 @@ def api_call(args) return df ``` -Next, populate the function stubs with the intention of using them for a single pre-defined run (ignoring params.json, other geo levels, etc). If you fetched data programmatically in Stage 0, you can reuse that in your data-fetching code. If you reformatted data in Stage 1, you can reuse that too. +Next, populate the function stubs with the intention of using them for a single pre-defined run (ignoring params.json, other geo levels, etc). If you fetched data programmatically in Step 0, you can reuse that in your data-fetching code. If you reformatted data in Step 1, you can reuse that too. Below is an example of the function stub that has been populated with code for a one-off run. ```{python} @@ -184,7 +186,7 @@ After that, generalize your code to be able to be run on all geos of interest, t Make sure you have a functional environment with python 3.8.15+. For local runs, the makefile’s make install target will set up a local virtual environment with necessary packages. -(If working in R (not recommended), local runs can be run without a virtual environment or using the renv package, but production runs should be set up to user Docker.) +(If working in R (not recommended), local runs can be run without a virtual environment or using the [`renv` package](https://rstudio.github.io/renv/articles/renv.html), but production runs should be set up to user Docker.) #### Dealing with data-types @@ -204,7 +206,7 @@ In an ideal case, the data exists at one of our already covered geos: * MSA (metro statistical area, int) * HRR (hospital referral region, int) -If you want to map from one of these to another, we have a utility, geomapper.py, that covers most cases. A brief example of adding states with their population: +If you want to map from one of these to another, the [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) utility covers most cases. A brief example of adding states with their population: ```{python} from delphi_utils.geomap import GeoMapper @@ -214,22 +216,22 @@ df = geo_mapper.add_population_column(df, "state_code") # add state populations hhs_version = geo_mapper.replace_geocode(df, "state_code","hhs", new_col = "geo_id") # aggregate to hhs regions, renaming the geo column to geo_id ``` -This example is taken from hhs_hosp; more documentation can be found in the geomapper class definition. +This example is taken from [`hhs_hosp`](https://github.com/cmu-delphi/covidcast-indicators/blob/main/hhs_hosp/delphi_hhs/run.py); more documentation can be found in the `geomapper` class definition. #### Implement a Missing Value code system -The column is described here +The column is described [here](https://cmu-delphi.github.io/delphi-epidata/api/missing_codes.html). #### Testing -As a general rule, it helps to decompose your functions into operations for which you can write unit tests. To run the tests, use make test in the base directory. +As a general rule, it helps to decompose your functions into operations for which you can write unit tests. To run the tests, use `make test` in the top-level indicator directory. ### Statistical Analysis ### Documentation -## Stage 3: Deployment +## Step 3: Deployment * This is after we have a working one-off script * Using Delphi utils and functionality @@ -237,21 +239,21 @@ As a general rule, it helps to decompose your functions into operations for whic Next, the acquisition:covidcast component of the delphi-epidata codebase does the following immediately after an indicator run (You do need to set acquisition job up): -1. Look in the receiving/ folder to see if any new data files are available. If there are, then: - 1. Import the new data into the epimetric_full table of the epidata.covid database, filling in the columns as follows: - 1. source: parsed from the name of the subdirectory of receiving/ - 2. signal: parsed from the filename - 3. time_type: parsed from the filename - 4. time_value: parsed from the filename - 5. geo_type: parsed from the filename - 6. geo_value: parsed from each row of the csv file - 7. value: parsed from each row of the csv file - 8. se: parsed from each row of the csv file - 9. sample_size: parsed from each row of the csv file - 10. issue: whatever now is in time_type units - 11. lag: the difference in time_type units from now to time_value - 12. value_updated_timestamp: now - * Update the epimetric_latest table with any new keys or new versions of existing keys. +Look in the receiving/ folder to see if any new data files are available. If there are, then: +1. Import the new data into the epimetric_full table of the epidata.covid database, filling in the columns as follows: + 1. `source`: parsed from the name of the subdirectory of `receiving/` + 2. `signal`: parsed from the filename + 3. `time_type`: parsed from the filename + 4. `time_value`: parsed from the filename + 5. `geo_type`: parsed from the filename + 6. `geo_value`: parsed from each row of the csv file + 7. `value`: parsed from each row of the csv file + 8. `se`: parsed from each row of the csv file + 9. `sample_size`: parsed from each row of the csv file + 10. `issue`: whatever now is in time_type units + 11. `lag`: the difference in time_type units from now to time_value + 12. `value_updated_timestamp`: now +2. Update the `epimetric_latest` table with any new keys or new versions of existing keys. ### Staging @@ -265,8 +267,7 @@ Note the staging hostname and how the acquisition job is chained to run right af If everything goes well (check staging db if data is ingested properly), make a prod version of the indicator run job and use that to run indicator on a daily basis. -Another thing to do is setting up the params.json template file in accordance with how you want to run the indicator and acquisition. Pay attention to the receiving directory, as well as how you can store credentials in vault. Refer to this guide for more vault info. - +Another thing to do is setting up the params.json template file in accordance with how you want to run the indicator and acquisition. Pay attention to the receiving directory, as well as how you can store credentials in vault. Refer to [this guide](https://docs.google.com/document/d/1Bbuvtoxowt7x2_8USx_JY-yTo-Av3oAFlhyG-vXGG-c/edit#heading=h.8kkoy8sx3t7f) for more vault info. ### Signal Documentation @@ -276,7 +277,5 @@ Github page signal documentation talk to Nat and Tina ## Appendix -Use the appendix to keep track of discussion and decisions - -* New Archiver Procedure document about setting up an S3 ArchiveDiffer -* Indicator debugging document, somewhat out-of-date but might still be useful +* [Setting up an S3 ArchiveDiffer](https://docs.google.com/document/d/1VcnvfeiO-GUUf88RosmNUfiPMoby-SnwH9s12esi4sI/edit#heading=h.e4ul15t3xmfj) +* [Indicator debugging guide](https://docs.google.com/document/d/1vaNgQ2cDrMvAg0FbSurbCemF9WqZVrirPpWEK0RdATQ/edit), somewhat out-of-date but might still be useful From 79738f63b626386bf9666f902d20bbf5d5729ac3 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:03:49 -0400 Subject: [PATCH 30/48] formatting cleanup --- _template_python/INDICATOR_DEV_GUIDE.md | 42 +++++++++++++------------ 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index 59a8c3188..bd678c7aa 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -159,7 +159,7 @@ Some stubs to consider: * Construct an SQL query * Run an SQL query * Keep a list of columns -* Geographic transformations (will tend to be wrappers around [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) functions) +* Geographic transformations (tend to be wrappers around [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) functions) Example stub: @@ -237,30 +237,32 @@ As a general rule, it helps to decompose your functions into operations for whic * Using Delphi utils and functionality * What happens to the data after it gets put in /receiving: -Next, the acquisition:covidcast component of the delphi-epidata codebase does the following immediately after an indicator run (You do need to set acquisition job up): - -Look in the receiving/ folder to see if any new data files are available. If there are, then: -1. Import the new data into the epimetric_full table of the epidata.covid database, filling in the columns as follows: - 1. `source`: parsed from the name of the subdirectory of `receiving/` - 2. `signal`: parsed from the filename - 3. `time_type`: parsed from the filename - 4. `time_value`: parsed from the filename - 5. `geo_type`: parsed from the filename - 6. `geo_value`: parsed from each row of the csv file - 7. `value`: parsed from each row of the csv file - 8. `se`: parsed from each row of the csv file - 9. `sample_size`: parsed from each row of the csv file - 10. `issue`: whatever now is in time_type units - 11. `lag`: the difference in time_type units from now to time_value - 12. `value_updated_timestamp`: now -2. Update the `epimetric_latest` table with any new keys or new versions of existing keys. +Next, the `acquisition.covidcast` component of the `delphi-epidata` codebase does the following immediately after an indicator run (you need to set acquisition job up): + +1. Look in the receiving/ folder to see if any new data files are available. If there are, then: + 1. Import the new data into the epimetric_full table of the epidata.covid database, filling in the columns as follows: + 1. `source`: parsed from the name of the subdirectory of `receiving/` + 2. `signal`: parsed from the filename + 3. `time_type`: parsed from the filename + 4. `time_value`: parsed from the filename + 5. `geo_type`: parsed from the filename + 6. `geo_value`: parsed from each row of the csv file + 7. `value`: parsed from each row of the csv file + 8. `se`: parsed from each row of the csv file + 9. `sample_size`: parsed from each row of the csv file + 10. `issue`: whatever now is in time_type units + 11. `lag`: the difference in time_type units from now to time_value + 12. `value_updated_timestamp`: now + 2. Update the `epimetric_latest` table with any new keys or new versions of existing keys. ### Staging After developing the pipeline code, but before deploying in development, the pipeline should be run on staging for at least a week. This involves setting up some cronicle jobs as follows: first the indicator run Then the acquisition run + https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5clgy6rs + https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5ctl7art Note the staging hostname and how the acquisition job is chained to run right after the indicator job. Do a few test runs. @@ -273,9 +275,9 @@ Another thing to do is setting up the params.json template file in accordance wi Apparently adding to a google spreadsheet, need to talk to someone (Carlyn) about the specifics -Github page signal documentation talk to Nat and Tina +Github page signal documentation talk to @nmdefries and @tinatownes ## Appendix * [Setting up an S3 ArchiveDiffer](https://docs.google.com/document/d/1VcnvfeiO-GUUf88RosmNUfiPMoby-SnwH9s12esi4sI/edit#heading=h.e4ul15t3xmfj) -* [Indicator debugging guide](https://docs.google.com/document/d/1vaNgQ2cDrMvAg0FbSurbCemF9WqZVrirPpWEK0RdATQ/edit), somewhat out-of-date but might still be useful +* [Indicator debugging guide](https://docs.google.com/document/d/1vaNgQ2cDrMvAg0FbSurbCemF9WqZVrirPpWEK0RdATQ/edit): somewhat out-of-date but might still be useful From 8ebb47582f34f8ec1b0daaa0c377b37a8b5347fc Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:15:00 -0400 Subject: [PATCH 31/48] resource links --- _template_python/INDICATOR_DEV_GUIDE.md | 30 +++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index bd678c7aa..e76632d05 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -1,13 +1,14 @@ # Pipeline Development Manual + ## A step-by-step guide to writing a pipeline TODO: -[] Geomapper guide -[] Setting up development environment -[] Deployment guide -[] Manual for R? +* Geomapper guide +* Setting up development environment +* Deployment guide +* Manual for R? ## Introduction @@ -16,7 +17,14 @@ This document provides a comprehensive guide on how to write a data pipeline in ### Related documents: -We also have a guide on [adding new API endpoints](https://cmu-delphi.github.io/delphi-epidata/new_endpoint_tutorial.html) (of which COVIDcast is a single example). +[Adding new API endpoints](https://cmu-delphi.github.io/delphi-epidata/new_endpoint_tutorial.html) (of which COVIDcast is a single example). + +Most new data sources will be added as indicators within the main endpoint (called COVIDcast as of 20240628). In rare cases, it may be preferable to add a dedicated endpoint for a new indicator. This would mainly be done if the format of the new data weren't compatible with the format used by the main endpoint, for example, if an indicator reports the same signal for many demographic groups, or if the reported geographic levels are nonstandard in some way. + +[Setting up an S3 ArchiveDiffer](https://docs.google.com/document/d/1VcnvfeiO-GUUf88RosmNUfiPMoby-SnwH9s12esi4sI/edit#heading=h.e4ul15t3xmfj) + +[Indicator debugging guide](https://docs.google.com/document/d/1vaNgQ2cDrMvAg0FbSurbCemF9WqZVrirPpWEK0RdATQ/edit): somewhat out-of-date but might still be useful + ## Basic steps of an indicator @@ -25,7 +33,7 @@ This is the general extract-transform-load procedure used by all COVIDcast indic 1. Download data from the source. * This could be via an API query, scraping the website, an SFTP or S3 dropbox, an email attachment, etc. 2. Process the source data to extract one or more time-series signals. - * A signal includes a value, standard error (data-dependent), and sample size (data-dependent) for each region for each unit of time (a day or an epidemiological week "epi-week"). + * A signal includes a value, standard deviation (data-dependent), and sample size (data-dependent) for each region for each unit of time (a day or an epidemiological week "epi-week"). 3. Aggregate each signal to all possible standard higher geographic levels. * For example, we generate data at the state level by combining data at the county level. 4. Output each signal into a set of CSV files with a fixed format. @@ -35,6 +43,7 @@ This is the general extract-transform-load procedure used by all COVIDcast indic * This converts dense output to a diff and reduces the size of each update. 7. Deliver the CSV output files to the `receiving/` directory on the API server. + ## Step 0: Keep revision history (important!) If the data provider doesn’t provide or it is unclear if they provide historical versions of the data, immediately set up a script (bash, Python, etc) to automatically (e.g. cron) download the data every day and save locally with versioning. @@ -49,6 +58,7 @@ The data should be saved in _raw_ form – do not do any processing. Our own pro Check back in a couple weeks to compare data versions for revisions. + ## Step 1: Exploratory Analysis The goal for exploratory analysis is to decide how the dataset does and does not fit our needs. This information will be used in the [indicator documentation](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_signals.html) and will warn us about potential difficulties in the pipeline, so this should be done thoroughly! Your goal is to become an expert on the ins and outs of the data source. @@ -103,7 +113,7 @@ At this stage we want to answer the questions below (and any others that seem re * For example, some data sources report NYC as separate from New York State. * Others require special handling: D.C. and territories (Puerto Rico, Guam, U.S. Virgin Islands). * ! Sampling site, facility, or other data-specific or proprietary geographic division - * The data may not be appropriate for inclusion in the main endpoint (as of 20240628 called COVIDcast). Talk to @dshemetov (geomapper), @melange396 (epidata, DB), and @RoniRos (PI) for discussion. + * The data may not be appropriate for inclusion in the main endpoint (called COVIDcast as of 20240628). Talk to @dshemetov (geomapper), @melange396 (epidata, DB), and @RoniRos (PI) for discussion. * Should the data have its own endpoint? * Consider creating a PRD ([here](https://drive.google.com/drive/u/1/folders/155cGrc9Y7NWwygslCcU8gjL2AQbu5rFF) or [here](https://drive.google.com/drive/u/1/folders/13wUoIl-FjjCkbn2O8qH1iXOCBo2eF2-d)) to present design options. * What is the sample size? Is this a privacy concern for us or for the data provider? @@ -122,6 +132,7 @@ For any issues that come up, consider now if * If it’s a small issue, how would you address it? Do you need an extra function to handle it? * If it’s a big issue, talk to others and consider making a PRD to present potential solutions. + ## Step 2: Pipeline Code Now that we know the substance and dimensions of our data, we can start planning the pipeline code. @@ -276,8 +287,3 @@ Another thing to do is setting up the params.json template file in accordance wi Apparently adding to a google spreadsheet, need to talk to someone (Carlyn) about the specifics Github page signal documentation talk to @nmdefries and @tinatownes - -## Appendix - -* [Setting up an S3 ArchiveDiffer](https://docs.google.com/document/d/1VcnvfeiO-GUUf88RosmNUfiPMoby-SnwH9s12esi4sI/edit#heading=h.e4ul15t3xmfj) -* [Indicator debugging guide](https://docs.google.com/document/d/1vaNgQ2cDrMvAg0FbSurbCemF9WqZVrirPpWEK0RdATQ/edit): somewhat out-of-date but might still be useful From 027d269e1b14355f2bb15c91196d763fbec69502 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 19:30:01 -0400 Subject: [PATCH 32/48] statistical review --- _template_python/INDICATOR_DEV_GUIDE.md | 38 +++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index e76632d05..0ff02b980 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -74,13 +74,15 @@ While some of this process might have been done already (i.e. it was already dec * Georesolution * Limitations -Jupyter notebooks work particularly well for exploratory analysis but feel free to use whatever IDE/methodology works best for you. +Jupyter notebooks work particularly well for exploratory analysis but feel free to use whatever IDE/methodology works best for you. Some of this analysis may be useful during statistical review later, so save your code! + +If anything unusual comes up, discuss with the stakeholder (usually the original requestor of the data source, can also be @RoniRos). The goal is to figure out how to handle any issues before getting into the details of implementation. ### Fetching the data Download the data in whatever format suits you. A one-off manual download is fine. Don’t worry too much about productionizing the data-fetching step at this point. (Although any code you write can be used later.) -Check to see whether the data is coming from an existing source, e.g. the wastewater data and NCHS data are accessed the same way, so when adding wastewater data, we could reuse the API key and only needed to lightly modify the API calls for the new dataset. +Also check to see whether the data is coming from an existing source, e.g. the wastewater data and NCHS data are accessed the same way, so when adding wastewater data, we could reuse the API key and only needed to lightly modify the API calls for the new dataset. Reading from a local file: @@ -118,11 +120,12 @@ At this stage we want to answer the questions below (and any others that seem re * Consider creating a PRD ([here](https://drive.google.com/drive/u/1/folders/155cGrc9Y7NWwygslCcU8gjL2AQbu5rFF) or [here](https://drive.google.com/drive/u/1/folders/13wUoIl-FjjCkbn2O8qH1iXOCBo2eF2-d)) to present design options. * What is the sample size? Is this a privacy concern for us or for the data provider? * How often is data missing? - * E.g. for privacy, some sources only report when sample size is above a certain threshold + * For privacy, some sources only report when sample size is above a certain threshold + * Missingness due to reporting pattern (e.g. no weekend reports)? * Will we want to and is it feasible to [interpolate missing values](https://github.com/cmu-delphi/covidcast-indicators/issues/1539)? * Are there any aberrant values that don’t make sense? e.g. negative counts, out of range percentages, “manual” missingness codes (9999, -9999, etc) * Does the data source revise their data? How often? - * See raw data saved in Step 0 + * See raw data saved in [Step 0](#step-0-keep-revision-history-important) * What is the reporting schedule of the data? * What order of magnitude is the signal? (If it’s sufficiently small, [this issue on how rounding is done](https://github.com/cmu-delphi/covidcast-indicators/issues/1945) needs to be addressed first) * How is the data processed by the data source? E.g. normalization, censoring values with small sample sizes, censoring values associated with low-population areas, smoothing, adding jitter, etc. @@ -237,7 +240,32 @@ The column is described [here](https://cmu-delphi.github.io/delphi-epidata/api/m As a general rule, it helps to decompose your functions into operations for which you can write unit tests. To run the tests, use `make test` in the top-level indicator directory. -### Statistical Analysis +### Statistical review + +The data produced by the new indicator needs to be sanity-checked. Think of this as doing [exploratory data analysis](#step-1-exploratory-analysis) again, but on the pipeline output. Some of this does overlap with work done in Step 1, but should be revisited following our processing of the data. Aspects of this investigation will be useful to include in the signal documentation. + +The analysis doesn't need to be formatted as a report, but should be all in one place, viewable by all Delphi members, and in a format that makes it easy to comment on. Some good options are the GitHub issue originally requesting the data source and the GitHub pull request adding the indicator. + +There is not a formal process for this, and you're free to do whatever you think is reasonable and sufficient. A thorough analysis would cover the following topics: + +* Run the [correlations notebook](https://github.com/cmu-delphi/covidcast/blob/5f15f71/R-notebooks/cor_dashboard.Rmd) ([example output](https://cmu-delphi.github.io/covidcast/R-notebooks/signal_correlations.html#)). + * This helps evaluate the potential value of the signals for modeling. + * Choropleths give another way to plot the data to look for weird patterns. + * Good starting point for further analyses. +* Compare the new signals against pre-existing relevant signals + * For signals that are ostensibly measuring the same thing, this helps us see issues and benefits of one versus the other and how well they agree (e.g. [JHU cases vs USAFacts cases](https://github.com/cmu-delphi/covidcast-indicators/issues/991)). + * For signals that we expect to be related, we should see correlations of the right sign and magnitude. +* Plot all signals over time. + * (unlikely) Do we need to do any interpolation? + * (unlikely) Think about if we should do any filtering/cleaning, e.g. [low sample size](https://github.com/cmu-delphi/covidcast-indicators/issues/1513#issuecomment-1036326474) in covid tests causing high variability in test positivity rate. +* Plot all signals for all geos over time and space (via choropleth). + * Look for anomalies, missing geos, missing-not-at-random values, etc. + * Verify that DC and any territories are being handled as expected. +* Think about [limitations](https://cmu-delphi.github.io/delphi-epidata/api/covidcast-signals/jhu-csse.html#limitations), gotchas, and lag and backfill characteristics. + +[Example analysis 1](https://github.com/cmu-delphi/covidcast-indicators/pull/1495#issuecomment-1039477646), [example analysis 2](https://github.com/cmu-delphi/covidcast-indicators/issues/367#issuecomment-717415555). + +Once the analysis is complete, have the stakeholder (usually the original requestor of the data source, can also be @RoniRos) review it. ### Documentation From 100bc748c8280d2c82b30c0f420ae9dfb12f3bdc Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:09:33 -0400 Subject: [PATCH 33/48] naming standards --- _template_python/INDICATOR_DEV_GUIDE.md | 38 +++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index 0ff02b980..cc7f9bcb3 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -215,7 +215,7 @@ TODO: A list of assumptions that the server makes about various columns would be In an ideal case, the data exists at one of our already covered geos: * State: state_code or state_id -* FIPS (state+county codes) +* FIPS (state+county codes, 5-digit string) * ZIP * MSA (metro statistical area, int) * HRR (hospital referral region, int) @@ -240,9 +240,43 @@ The column is described [here](https://cmu-delphi.github.io/delphi-epidata/api/m As a general rule, it helps to decompose your functions into operations for which you can write unit tests. To run the tests, use `make test` in the top-level indicator directory. +#### Naming + +Indicator and signal names need to be approved by @RoniRos. + +The data source name as specified during an API call (e.g. in `epidatr::pub_covidcast(source = "jhu-csse", ...)`, "jhu-csse" is the data source name) should match the wildcard portion of the module name ("jhu" in `delphi_jhu`) _and_ the top-level directory name in `covidcast-indicators` ("jhu"). (Ideally, these would all also match how we casually refer to the indicator ("JHU"), but that's hard to foresee and enforce.) + +Ideally, the indicator name should: + +* Make it easy to tell where the data is coming from +* Make it easy to tell what type of data it is and/or what is unique about it +* Be uniquely identifying enough that if we added another indicator from the same organization, we could tell the two apart +* Be fairly short +* Be descriptive + +Based on these guidelines, the `jhu-csse` indicator would be better as `jhu-csse` everywhere (module name could be `delphi_jhu_csse`), rather than having a mix of `jhu-csse` and `jhu`. + +Signal names should not be too long, but the most important feature is that they are descriptive. + +Some standard tags used in signal names: + +* `raw`: unsmoothed, _no longer used; if no smoothing is specified the signal is assumed to be "raw"_ +* `7dav`: smoothed using a average over a rolling 7-day window; comes at the end of the name +* `smoothed`: smoothed using a more complex smoothing algorithm +* `prop`: counts per 100k population +* `pct`: percentage between 0 and 100 +* `num`: counts, _no longer used; if no value type is specified the signal is assumed to be a count_ +* `cli`: COVID-like illness +* `ili`: influenza-like illness + +Using this tag dictionary, we can interpret the following signals as + +* `confirmed_admissions_influenza_1d_prop` = raw (unsmoothed) daily ("1d") confirmed influenza hospital admissions ("confirmed_admissions_influenza") per 100,000 population ("prop"). +* `confirmed_admissions_influenza_1d_prop_7dav` = the same as above, but smoothed with a 7-day moving average ("7dav"). + ### Statistical review -The data produced by the new indicator needs to be sanity-checked. Think of this as doing [exploratory data analysis](#step-1-exploratory-analysis) again, but on the pipeline output. Some of this does overlap with work done in Step 1, but should be revisited following our processing of the data. Aspects of this investigation will be useful to include in the signal documentation. +The data produced by the new indicator needs to be sanity-checked. Think of this as doing [exploratory data analysis](#step-1-exploratory-analysis) again, but on the pipeline _output_. Some of this does overlap with work done in Step 1, but should be revisited following our processing of the data. Aspects of this investigation will be useful to include in the signal documentation. The analysis doesn't need to be formatted as a report, but should be all in one place, viewable by all Delphi members, and in a format that makes it easy to comment on. Some good options are the GitHub issue originally requesting the data source and the GitHub pull request adding the indicator. From f26beeafdedd6f848fe73e5722c70b01b694d627 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:37:55 -0400 Subject: [PATCH 34/48] documentation --- _template_python/INDICATOR_DEV_GUIDE.md | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index cc7f9bcb3..cc4740bc9 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -215,7 +215,7 @@ TODO: A list of assumptions that the server makes about various columns would be In an ideal case, the data exists at one of our already covered geos: * State: state_code or state_id -* FIPS (state+county codes, 5-digit string) +* FIPS (state+county codes, string leftpadded to 5 digits with zeroes) * ZIP * MSA (metro statistical area, int) * HRR (hospital referral region, int) @@ -240,6 +240,8 @@ The column is described [here](https://cmu-delphi.github.io/delphi-epidata/api/m As a general rule, it helps to decompose your functions into operations for which you can write unit tests. To run the tests, use `make test` in the top-level indicator directory. +Unit tests are required for all functions. Integration tests are highly desired, but may be difficult to set up depending on where the data is being fetched from. Mocking functions are useful in this case. + #### Naming Indicator and signal names need to be approved by @RoniRos. @@ -266,8 +268,8 @@ Some standard tags used in signal names: * `prop`: counts per 100k population * `pct`: percentage between 0 and 100 * `num`: counts, _no longer used; if no value type is specified the signal is assumed to be a count_ -* `cli`: COVID-like illness -* `ili`: influenza-like illness +* `cli`: COVID-like illness (fever, along with cough or shortness of breath or difficulty breathing) +* `ili`: influenza-like illness (fever, along with cough or sore throat) Using this tag dictionary, we can interpret the following signals as @@ -303,6 +305,27 @@ Once the analysis is complete, have the stakeholder (usually the original reques ### Documentation +The [documentation site](https://cmu-delphi.github.io/delphi-epidata/) ([code here](https://github.com/cmu-delphi/delphi-epidata/tree/628e9655144934f3903c133b6713df4d4fcc613e/docs)) stores long-term long-form documentation pages for each indicator, including those that are inactive. + +Active and new indicators go in the [COVIDcast Main Endpoint -> Data Sources and Signals](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_signals.html) section ([code here](https://github.com/cmu-delphi/delphi-epidata/tree/628e9655144934f3903c133b6713df4d4fcc613e/docs/api/covidcast-signals)). A [template doc page](https://github.com/cmu-delphi/delphi-epidata/blob/628e9655144934f3903c133b6713df4d4fcc613e/docs/api/covidcast-signals/_source-template.md) is available in the same directory. + +An indicator documentation page should contain as much detail (including technical detail) as possible. The following fields are required: + +* Description of the data source and data collection methods +* Links to the data source (organization and specific dataset(s) used) +* Links to any data source documentation you referenced +* List of signal names, descriptions, with start dates +* Prose description of how signals are calculated +* Specific math showing how signals are calculated, if unusual or complex +* How smoothing is done, if any +* Known limitations of the data source and the final signals +* Missingness characteristics, especially if the data is missing with a pattern (on weekends, etc) +* Lag and revision characteristics +* Licensing information + +and anything else that changes how users would use or interpret the data, impacts the usability of the signal, may be difficult to discover, recommended usecases, is unusual, any gotchas about the data or the data processing approach, etc. _More detail is better!_ + +At the time that you're writing the documentation, you are the expert on the data source and the indicator. Making the documentation thorough and clear will make the data maximally usable for future users, and will make maintenance for Delphi easier. ## Step 3: Deployment @@ -330,10 +353,14 @@ Next, the `acquisition.covidcast` component of the `delphi-epidata` codebase doe ### Staging -After developing the pipeline code, but before deploying in development, the pipeline should be run on staging for at least a week. This involves setting up some cronicle jobs as follows: first the indicator run +After developing the pipeline code, but before deploying in development, the pipeline should be run on staging for at least a week. This involves setting up some cronicle jobs as follows: + +first the indicator run Then the acquisition run +See @korlaxxalrok or @minhkhul for more information. + https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5clgy6rs https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5ctl7art From ae61a70092040730e6d28b90432be9deafee78a4 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:43:20 -0400 Subject: [PATCH 35/48] commenting and TODOs --- _template_python/INDICATOR_DEV_GUIDE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index cc4740bc9..c0874240b 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -161,6 +161,8 @@ Generally, indicators have: * `geo.py`: Do geo-aggregation. This tends to be simple wrappers around [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) functions. Do other geo handling (e.g. finding and reporting DC as a state). * `constants.py`: Lists of geos to produce, signals to produce, dataset ids, data source URL, etc. +Your code should be _extensively_ commented! Especially note sections where you took an unusual approach (make sure to say why and consider briefly discussing alternate approaches). + #### Function stubs If you have many functions you want to implement and/or anticipate a complex pipeline, consider starting with [function stubs](https://en.wikipedia.org/wiki/Method_stub) with comments or pseudo code. Bonus: consider writing unit tests upfront based on the expected behavior of each function. @@ -316,7 +318,7 @@ An indicator documentation page should contain as much detail (including technic * Links to any data source documentation you referenced * List of signal names, descriptions, with start dates * Prose description of how signals are calculated -* Specific math showing how signals are calculated, if unusual or complex +* Specific math showing how signals are calculated, if unusual or complex or you like equations * How smoothing is done, if any * Known limitations of the data source and the final signals * Missingness characteristics, especially if the data is missing with a pattern (on weekends, etc) @@ -327,6 +329,8 @@ and anything else that changes how users would use or interpret the data, impact At the time that you're writing the documentation, you are the expert on the data source and the indicator. Making the documentation thorough and clear will make the data maximally usable for future users, and will make maintenance for Delphi easier. +(For similar reasons, comment your code extensively!) + ## Step 3: Deployment * This is after we have a working one-off script @@ -373,6 +377,12 @@ Another thing to do is setting up the params.json template file in accordance wi ### Signal Documentation +TODO + Apparently adding to a google spreadsheet, need to talk to someone (Carlyn) about the specifics +How to add to signal discovery app + +How to add to www-main signal dashboard + Github page signal documentation talk to @nmdefries and @tinatownes From 6f24e88dbebdabde5d60648f887bf04b5a7c3b93 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:44:50 -0400 Subject: [PATCH 36/48] user links --- _template_python/INDICATOR_DEV_GUIDE.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index c0874240b..f6b83316c 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -76,7 +76,7 @@ While some of this process might have been done already (i.e. it was already dec Jupyter notebooks work particularly well for exploratory analysis but feel free to use whatever IDE/methodology works best for you. Some of this analysis may be useful during statistical review later, so save your code! -If anything unusual comes up, discuss with the stakeholder (usually the original requestor of the data source, can also be @RoniRos). The goal is to figure out how to handle any issues before getting into the details of implementation. +If anything unusual comes up, discuss with the stakeholder (usually the original requestor of the data source, can also be [@RoniRos](https://www.github.com/RoniRos)). The goal is to figure out how to handle any issues before getting into the details of implementation. ### Fetching the data @@ -115,7 +115,7 @@ At this stage we want to answer the questions below (and any others that seem re * For example, some data sources report NYC as separate from New York State. * Others require special handling: D.C. and territories (Puerto Rico, Guam, U.S. Virgin Islands). * ! Sampling site, facility, or other data-specific or proprietary geographic division - * The data may not be appropriate for inclusion in the main endpoint (called COVIDcast as of 20240628). Talk to @dshemetov (geomapper), @melange396 (epidata, DB), and @RoniRos (PI) for discussion. + * The data may not be appropriate for inclusion in the main endpoint (called COVIDcast as of 20240628). Talk to [@dshemetov](https://www.github.com/dshemetov) (geomapper), [@melange396](https://www.github.com/melange396) (epidata, DB), and [@RoniRos](https://www.github.com/RoniRos) (PI) for discussion. * Should the data have its own endpoint? * Consider creating a PRD ([here](https://drive.google.com/drive/u/1/folders/155cGrc9Y7NWwygslCcU8gjL2AQbu5rFF) or [here](https://drive.google.com/drive/u/1/folders/13wUoIl-FjjCkbn2O8qH1iXOCBo2eF2-d)) to present design options. * What is the sample size? Is this a privacy concern for us or for the data provider? @@ -246,7 +246,7 @@ Unit tests are required for all functions. Integration tests are highly desired, #### Naming -Indicator and signal names need to be approved by @RoniRos. +Indicator and signal names need to be approved by [@RoniRos](https://www.github.com/RoniRos). The data source name as specified during an API call (e.g. in `epidatr::pub_covidcast(source = "jhu-csse", ...)`, "jhu-csse" is the data source name) should match the wildcard portion of the module name ("jhu" in `delphi_jhu`) _and_ the top-level directory name in `covidcast-indicators` ("jhu"). (Ideally, these would all also match how we casually refer to the indicator ("JHU"), but that's hard to foresee and enforce.) @@ -303,7 +303,7 @@ There is not a formal process for this, and you're free to do whatever you think [Example analysis 1](https://github.com/cmu-delphi/covidcast-indicators/pull/1495#issuecomment-1039477646), [example analysis 2](https://github.com/cmu-delphi/covidcast-indicators/issues/367#issuecomment-717415555). -Once the analysis is complete, have the stakeholder (usually the original requestor of the data source, can also be @RoniRos) review it. +Once the analysis is complete, have the stakeholder (usually the original requestor of the data source, can also be [@RoniRos](https://www.github.com/RoniRos)) review it. ### Documentation @@ -363,7 +363,7 @@ first the indicator run Then the acquisition run -See @korlaxxalrok or @minhkhul for more information. +See [@korlaxxalrok](https://www.github.com/korlaxxalrok) or [@minhkhul](https://www.github.com/minhkhul) for more information. https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5clgy6rs @@ -385,4 +385,4 @@ How to add to signal discovery app How to add to www-main signal dashboard -Github page signal documentation talk to @nmdefries and @tinatownes +Github page signal documentation talk to [@nmdefries](https://www.github.com/nmdefries) and [@tinatownes](https://www.github.com/tinatownes) From fe39ebb1f8baa76670eb665d1dc99376ddfd3010 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:49:55 -0400 Subject: [PATCH 37/48] receiving backticks --- _template_python/INDICATOR_DEV_GUIDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index f6b83316c..a1c42b49f 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -335,11 +335,11 @@ At the time that you're writing the documentation, you are the expert on the dat * This is after we have a working one-off script * Using Delphi utils and functionality -* What happens to the data after it gets put in /receiving: +* What happens to the data after it gets put in `receiving/`: Next, the `acquisition.covidcast` component of the `delphi-epidata` codebase does the following immediately after an indicator run (you need to set acquisition job up): -1. Look in the receiving/ folder to see if any new data files are available. If there are, then: +1. Look in the `receiving/` folder to see if any new data files are available. If there are, then: 1. Import the new data into the epimetric_full table of the epidata.covid database, filling in the columns as follows: 1. `source`: parsed from the name of the subdirectory of `receiving/` 2. `signal`: parsed from the filename From 8ecea588b631e66c923d02a1f20da42b00ce1e40 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Mon, 8 Jul 2024 15:33:55 -0400 Subject: [PATCH 38/48] wrap lines --- _template_python/INDICATOR_DEV_GUIDE.md | 156 ++++++++++++++++-------- 1 file changed, 107 insertions(+), 49 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index a1c42b49f..15abf6153 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -13,13 +13,19 @@ TODO: ## Introduction -This document provides a comprehensive guide on how to write a data pipeline in Python for the Delphi group. It focuses on various aspects of building a pipeline, including ingestion, transformation, and storage. This document assumes basic knowledge of Python and a familiarity with Delphi’s data processing practices. Throughout the manual, we will use various python libraries to demonstrate how to build a data pipeline that can handle large volumes of data efficiently. We will also discuss best practices for building reliable, scalable, and maintainable data pipelines. +This document provides a comprehensive guide on how to write a data pipeline in Python for the Delphi group. +It focuses on various aspects of building a pipeline, including ingestion, transformation, and storage. +This document assumes basic knowledge of Python and a familiarity with Delphi’s data processing practices. +Throughout the manual, we will use various python libraries to demonstrate how to build a data pipeline that can handle large volumes of data efficiently. +We will also discuss best practices for building reliable, scalable, and maintainable data pipelines. ### Related documents: [Adding new API endpoints](https://cmu-delphi.github.io/delphi-epidata/new_endpoint_tutorial.html) (of which COVIDcast is a single example). -Most new data sources will be added as indicators within the main endpoint (called COVIDcast as of 20240628). In rare cases, it may be preferable to add a dedicated endpoint for a new indicator. This would mainly be done if the format of the new data weren't compatible with the format used by the main endpoint, for example, if an indicator reports the same signal for many demographic groups, or if the reported geographic levels are nonstandard in some way. +Most new data sources will be added as indicators within the main endpoint (called COVIDcast as of 20240628). +In rare cases, it may be preferable to add a dedicated endpoint for a new indicator. +This would mainly be done if the format of the new data weren't compatible with the format used by the main endpoint, for example, if an indicator reports the same signal for many demographic groups, or if the reported geographic levels are nonstandard in some way. [Setting up an S3 ArchiveDiffer](https://docs.google.com/document/d/1VcnvfeiO-GUUf88RosmNUfiPMoby-SnwH9s12esi4sI/edit#heading=h.e4ul15t3xmfj) @@ -30,11 +36,11 @@ Most new data sources will be added as indicators within the main endpoint (call This is the general extract-transform-load procedure used by all COVIDcast indicators: -1. Download data from the source. +1. Download data from the source. * This could be via an API query, scraping the website, an SFTP or S3 dropbox, an email attachment, etc. -2. Process the source data to extract one or more time-series signals. +2. Process the source data to extract one or more time-series signals. * A signal includes a value, standard deviation (data-dependent), and sample size (data-dependent) for each region for each unit of time (a day or an epidemiological week "epi-week"). -3. Aggregate each signal to all possible standard higher geographic levels. +3. Aggregate each signal to all possible standard higher geographic levels. * For example, we generate data at the state level by combining data at the county level. 4. Output each signal into a set of CSV files with a fixed format. 5. Run a set of checks on the output. @@ -54,16 +60,20 @@ This step has a few goals: 2. Understand the revision behavior in detail 3. If the data is revised, we want to save all possible versions, even before our pipeline is fully up -The data should be saved in _raw_ form – do not do any processing. Our own processing (cleaning, aggregation, normalization, etc) of the data may change as the pipeline code develops and doing any processing up front could make the historical data incompatible with the final procedure. +The data should be saved in _raw_ form – do not do any processing. +Our own processing (cleaning, aggregation, normalization, etc) of the data may change as the pipeline code develops and doing any processing up front could make the historical data incompatible with the final procedure. Check back in a couple weeks to compare data versions for revisions. ## Step 1: Exploratory Analysis -The goal for exploratory analysis is to decide how the dataset does and does not fit our needs. This information will be used in the [indicator documentation](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_signals.html) and will warn us about potential difficulties in the pipeline, so this should be done thoroughly! Your goal is to become an expert on the ins and outs of the data source. +The goal for exploratory analysis is to decide how the dataset does and does not fit our needs. +This information will be used in the [indicator documentation](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_signals.html) and will warn us about potential difficulties in the pipeline, so this should be done thoroughly! Your goal is to become an expert on the ins and outs of the data source. -While some of this process might have been done already (i.e. it was already decided that the data is useful), it is still important to understand the properties of the dataset. The main objective during this stage is to understand what the dataset looks like in its raw format, establish what transformations need to be done, and create a basic roadmap to accomplish all later setup tasks. +While some of this process might have been done already (i.e. +it was already decided that the data is useful), it is still important to understand the properties of the dataset. +The main objective during this stage is to understand what the dataset looks like in its raw format, establish what transformations need to be done, and create a basic roadmap to accomplish all later setup tasks. **What you want to establish:** @@ -74,13 +84,18 @@ While some of this process might have been done already (i.e. it was already dec * Georesolution * Limitations -Jupyter notebooks work particularly well for exploratory analysis but feel free to use whatever IDE/methodology works best for you. Some of this analysis may be useful during statistical review later, so save your code! +Jupyter notebooks work particularly well for exploratory analysis but feel free to use whatever IDE/methodology works best for you. +Some of this analysis may be useful during statistical review later, so save your code! -If anything unusual comes up, discuss with the stakeholder (usually the original requestor of the data source, can also be [@RoniRos](https://www.github.com/RoniRos)). The goal is to figure out how to handle any issues before getting into the details of implementation. +If anything unusual comes up, discuss with the stakeholder (usually the original requestor of the data source, can also be [@RoniRos](https://www.github.com/RoniRos)). +The goal is to figure out how to handle any issues before getting into the details of implementation. ### Fetching the data -Download the data in whatever format suits you. A one-off manual download is fine. Don’t worry too much about productionizing the data-fetching step at this point. (Although any code you write can be used later.) +Download the data in whatever format suits you. +A one-off manual download is fine. +Don’t worry too much about productionizing the data-fetching step at this point. +(Although any code you write can be used later.) Also check to see whether the data is coming from an existing source, e.g. the wastewater data and NCHS data are accessed the same way, so when adding wastewater data, we could reuse the API key and only needed to lightly modify the API calls for the new dataset. @@ -107,15 +122,22 @@ At this stage we want to answer the questions below (and any others that seem re * What raw signals are available in the data? * If the raw signals aren’t useful themselves, what useful signals could we create from these? - * Discuss with the data requestor or consult the data request GitHub issue which signals they are interested in. If there are multiple potential signals, are there any known pros/cons of each one? - * For each signal, we want to report a value, standard error (data-dependent), and sample size (data-dependent) for each region for each unit of time. Sample size is sometimes available as a separate “counts” signal. + * Discuss with the data requestor or consult the data request GitHub issue which signals they are interested in. + If there are multiple potential signals, are there any known pros/cons of each one? + * For each signal, we want to report a value, standard error (data-dependent), and sample size (data-dependent) for each region for each unit of time. + Sample size is sometimes available as a separate “counts” signal. * Are the signals available across different geographies? Can values be [meaningfully compared](https://cmu-delphi.github.io/delphi-epidata/api/covidcast-signals/google-symptoms.html#limitations) between locations? - * Ideally, we want to report data at [county, MSA, HRR, state, HHS, and nation levels](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_geography.html) (US) or subregion level 2 (county, parish, etc), subregion level 1 (state, province, territory), and nation levels for other countries. Some data sources report these levels themselves. For those that don’t, we use the [`geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/84d059751b646c0075f1a384741f2c1d80981269/_delphi_utils_python/delphi_utils/geomap.py) to aggregate up from smaller to larger geo types. For that tool to work, signals must be aggregatable (i.e. values have to be comparable between geos) and the data must be reported at supported geo types or at geo types that are mappable to supported geo types. + * Ideally, we want to report data at [county, MSA, HRR, state, HHS, and nation levels](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_geography.html) (US) or subregion level 2 (county, parish, etc), subregion level 1 (state, province, territory), and nation levels for other countries. + Some data sources report these levels themselves. + For those that don’t, we use the [`geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/84d059751b646c0075f1a384741f2c1d80981269/_delphi_utils_python/delphi_utils/geomap.py) to aggregate up from smaller to larger geo types. + For that tool to work, signals must be aggregatable (i.e. + values have to be comparable between geos) and the data must be reported at supported geo types or at geo types that are mappable to supported geo types. * What geographies might be included that are not standard? * For example, some data sources report NYC as separate from New York State. * Others require special handling: D.C. and territories (Puerto Rico, Guam, U.S. Virgin Islands). * ! Sampling site, facility, or other data-specific or proprietary geographic division - * The data may not be appropriate for inclusion in the main endpoint (called COVIDcast as of 20240628). Talk to [@dshemetov](https://www.github.com/dshemetov) (geomapper), [@melange396](https://www.github.com/melange396) (epidata, DB), and [@RoniRos](https://www.github.com/RoniRos) (PI) for discussion. + * The data may not be appropriate for inclusion in the main endpoint (called COVIDcast as of 20240628). + Talk to [@dshemetov](https://www.github.com/dshemetov) (geomapper), [@melange396](https://www.github.com/melange396) (epidata, DB), and [@RoniRos](https://www.github.com/RoniRos) (PI) for discussion. * Should the data have its own endpoint? * Consider creating a PRD ([here](https://drive.google.com/drive/u/1/folders/155cGrc9Y7NWwygslCcU8gjL2AQbu5rFF) or [here](https://drive.google.com/drive/u/1/folders/13wUoIl-FjjCkbn2O8qH1iXOCBo2eF2-d)) to present design options. * What is the sample size? Is this a privacy concern for us or for the data provider? @@ -129,9 +151,10 @@ At this stage we want to answer the questions below (and any others that seem re * What is the reporting schedule of the data? * What order of magnitude is the signal? (If it’s sufficiently small, [this issue on how rounding is done](https://github.com/cmu-delphi/covidcast-indicators/issues/1945) needs to be addressed first) * How is the data processed by the data source? E.g. normalization, censoring values with small sample sizes, censoring values associated with low-population areas, smoothing, adding jitter, etc. -Keep any code and notes around! They will be helpful for later steps. -For any issues that come up, consider now if -* We’ve seen them before in another dataset and, if so, how we handled it. Is there code around that we can reuse? + Keep any code and notes around! They will be helpful for later steps. + For any issues that come up, consider now if +* We’ve seen them before in another dataset and, if so, how we handled it. + Is there code around that we can reuse? * If it’s a small issue, how would you address it? Do you need an extra function to handle it? * If it’s a big issue, talk to others and consider making a PRD to present potential solutions. @@ -142,30 +165,42 @@ Now that we know the substance and dimensions of our data, we can start planning ### Logic overview -Broadly speaking, the objective here is to create a script that will download data, transform it (mainly by aggregating it to different geo levels), format it to match our standard format, and save the transformed data to the [receiving directory](https://github.com/cmu-delphi/covidcast-indicators/blob/d36352b/ansible/templates/changehc-params-prod.json.j2#L3) as a CSV. The indicator, [validation](https://github.com/cmu-delphi/covidcast-indicators/tree/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/validator) (a series of quality checks), and [archive diffing](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/archive.py) (compressing the data by only outputting rows changed between data versions) are run via the runner. Acquisition (ingestion of files from the receiving directory and into the database) is run separately (see the [`delphi-epidata repo`](https://github.com/cmu-delphi/delphi-epidata/tree/c65d8093d9e8fed97b3347e195cc9c40c1a5fcfa)). +Broadly speaking, the objective here is to create a script that will download data, transform it (mainly by aggregating it to different geo levels), format it to match our standard format, and save the transformed data to the [receiving directory](https://github.com/cmu-delphi/covidcast-indicators/blob/d36352b/ansible/templates/changehc-params-prod.json.j2#L3) as a CSV. +The indicator, [validation](https://github.com/cmu-delphi/covidcast-indicators/tree/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/validator) (a series of quality checks), and [archive diffing](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/archive.py) (compressing the data by only outputting rows changed between data versions) are run via the runner. +Acquisition (ingestion of files from the receiving directory and into the database) is run separately (see the [`delphi-epidata repo`](https://github.com/cmu-delphi/delphi-epidata/tree/c65d8093d9e8fed97b3347e195cc9c40c1a5fcfa)). -`params.json.template` is copied to `params.json` during a run. `params.json` is used to set parameters that modify a run and that we expect we’ll want to change in the future (e.g. date range to generate) or need to be obfuscated (e.g. API key). +`params.json.template` is copied to `params.json` during a run. +`params.json` is used to set parameters that modify a run and that we expect we’ll want to change in the future e.g. date range to generate) or need to be obfuscated (e.g. API key). -Each indicator includes a makefile (using GNU make), which provides predefined routines for local setup, testing, linting, and running the indicator. At the moment, the makefiles use python 3.8.15+. +Each indicator includes a makefile (using GNU make), which provides predefined routines for local setup, testing, linting, and running the indicator. +At the moment, the makefiles use python 3.8.15+. ### Development -To get started, Delphi has a [basic code template](https://github.com/cmu-delphi/covidcast-indicators/tree/6f46f2b4a0cf86137fda5bd58025997647c87b46/_template_python) that you should copy into a top-level directory in the [`covidcast-indicators` repo](https://github.com/cmu-delphi/covidcast-indicators/). It can also be helpful to read through other indicators, especially if they share a data source or format. +To get started, Delphi has a [basic code template](https://github.com/cmu-delphi/covidcast-indicators/tree/6f46f2b4a0cf86137fda5bd58025997647c87b46/_template_python) that you should copy into a top-level directory in the [`covidcast-indicators` repo](https://github.com/cmu-delphi/covidcast-indicators/). +It can also be helpful to read through other indicators, especially if they share a data source or format. -Indicators should be written in python for speed and maintainability. If you think you need to use R, please reconsider! and talk to other engineering team members. +Indicators should be written in python for speed and maintainability. +If you think you need to use R, please reconsider! and talk to other engineering team members. Generally, indicators have: -* `run.py`: Run through all the pipeline steps. Loops over all geo type-signal combinations we want to produce. Handles logging and saving to CSV using functions from [`delphi_utils`](https://github.com/cmu-delphi/covidcast-indicators/tree/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils). -* `pull.py`: Fetch the data from the data source and do basic processing (e.g. drop unnecessary columns). Advanced processing (e.g. sensorization) should go elsewhere. -* `geo.py`: Do geo-aggregation. This tends to be simple wrappers around [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) functions. Do other geo handling (e.g. finding and reporting DC as a state). +* `run.py`: Run through all the pipeline steps. + Loops over all geo type-signal combinations we want to produce. + Handles logging and saving to CSV using functions from [`delphi_utils`](https://github.com/cmu-delphi/covidcast-indicators/tree/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils). +* `pull.py`: Fetch the data from the data source and do basic processing (e.g. drop unnecessary columns). + Advanced processing (e.g. sensorization) should go elsewhere. +* `geo.py`: Do geo-aggregation. + This tends to be simple wrappers around [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) functions. + Do other geo handling (e.g. finding and reporting DC as a state). * `constants.py`: Lists of geos to produce, signals to produce, dataset ids, data source URL, etc. Your code should be _extensively_ commented! Especially note sections where you took an unusual approach (make sure to say why and consider briefly discussing alternate approaches). #### Function stubs -If you have many functions you want to implement and/or anticipate a complex pipeline, consider starting with [function stubs](https://en.wikipedia.org/wiki/Method_stub) with comments or pseudo code. Bonus: consider writing unit tests upfront based on the expected behavior of each function. +If you have many functions you want to implement and/or anticipate a complex pipeline, consider starting with [function stubs](https://en.wikipedia.org/wiki/Method_stub) with comments or pseudo code. +Bonus: consider writing unit tests upfront based on the expected behavior of each function. Some stubs to consider: @@ -181,26 +216,29 @@ Example stub: ```{python} def api_call(args) - #implement api call - return df + #implement api call + return df ``` -Next, populate the function stubs with the intention of using them for a single pre-defined run (ignoring params.json, other geo levels, etc). If you fetched data programmatically in Step 0, you can reuse that in your data-fetching code. If you reformatted data in Step 1, you can reuse that too. -Below is an example of the function stub that has been populated with code for a one-off run. +Next, populate the function stubs with the intention of using them for a single pre-defined run (ignoring params.json, other geo levels, etc). +If you fetched data programmatically in Step 0, you can reuse that in your data-fetching code. +If you reformatted data in Step 1, you can reuse that too. +Below is an example of the function stub that has been populated with code for a one-off run. ```{python} def api_call(token: str): client = Socrata('healthdata.gov', token) results = client.get("di4u-7yu6", limit=5000) results_df = pd.DataFrame.from_records(results) - return results_df + return results_df ``` After that, generalize your code to be able to be run on all geos of interest, take settings from params.json, use constants for easy maintenance, with extensive documentation, etc. #### Development environment -Make sure you have a functional environment with python 3.8.15+. For local runs, the makefile’s make install target will set up a local virtual environment with necessary packages. +Make sure you have a functional environment with python 3.8.15+. +For local runs, the makefile’s make install target will set up a local virtual environment with necessary packages. (If working in R (not recommended), local runs can be run without a virtual environment or using the [`renv` package](https://rstudio.github.io/renv/articles/renv.html), but production runs should be set up to user Docker.) @@ -210,7 +248,8 @@ Make sure you have a functional environment with python 3.8.15+. For local runs, * Problems that can arise and how to address them * Basic conversion -TODO: A list of assumptions that the server makes about various columns would be helpful. E.g. which geo values are allowed, should every valid date be present in some way, etc +TODO: A list of assumptions that the server makes about various columns would be helpful. +E.g. which geo values are allowed, should every valid date be present in some way, etc #### Dealing with geos @@ -222,7 +261,8 @@ In an ideal case, the data exists at one of our already covered geos: * MSA (metro statistical area, int) * HRR (hospital referral region, int) -If you want to map from one of these to another, the [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) utility covers most cases. A brief example of adding states with their population: +If you want to map from one of these to another, the [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) utility covers most cases. +A brief example of adding states with their population: ```{python} from delphi_utils.geomap import GeoMapper @@ -240,15 +280,19 @@ The column is described [here](https://cmu-delphi.github.io/delphi-epidata/api/m #### Testing -As a general rule, it helps to decompose your functions into operations for which you can write unit tests. To run the tests, use `make test` in the top-level indicator directory. +As a general rule, it helps to decompose your functions into operations for which you can write unit tests. +To run the tests, use `make test` in the top-level indicator directory. -Unit tests are required for all functions. Integration tests are highly desired, but may be difficult to set up depending on where the data is being fetched from. Mocking functions are useful in this case. +Unit tests are required for all functions. +Integration tests are highly desired, but may be difficult to set up depending on where the data is being fetched from. +Mocking functions are useful in this case. #### Naming Indicator and signal names need to be approved by [@RoniRos](https://www.github.com/RoniRos). -The data source name as specified during an API call (e.g. in `epidatr::pub_covidcast(source = "jhu-csse", ...)`, "jhu-csse" is the data source name) should match the wildcard portion of the module name ("jhu" in `delphi_jhu`) _and_ the top-level directory name in `covidcast-indicators` ("jhu"). (Ideally, these would all also match how we casually refer to the indicator ("JHU"), but that's hard to foresee and enforce.) +The data source name as specified during an API call (e.g. in `epidatr::pub_covidcast(source = "jhu-csse", ...)`, "jhu-csse" is the data source name) should match the wildcard portion of the module name ("jhu" in `delphi_jhu`) _and_ the top-level directory name in `covidcast-indicators` ("jhu"). +(Ideally, these would all also match how we casually refer to the indicator ("JHU"), but that's hard to foresee and enforce.) Ideally, the indicator name should: @@ -280,11 +324,16 @@ Using this tag dictionary, we can interpret the following signals as ### Statistical review -The data produced by the new indicator needs to be sanity-checked. Think of this as doing [exploratory data analysis](#step-1-exploratory-analysis) again, but on the pipeline _output_. Some of this does overlap with work done in Step 1, but should be revisited following our processing of the data. Aspects of this investigation will be useful to include in the signal documentation. +The data produced by the new indicator needs to be sanity-checked. +Think of this as doing [exploratory data analysis](#step-1-exploratory-analysis) again, but on the pipeline _output_. +Some of this does overlap with work done in Step 1, but should be revisited following our processing of the data. +Aspects of this investigation will be useful to include in the signal documentation. -The analysis doesn't need to be formatted as a report, but should be all in one place, viewable by all Delphi members, and in a format that makes it easy to comment on. Some good options are the GitHub issue originally requesting the data source and the GitHub pull request adding the indicator. +The analysis doesn't need to be formatted as a report, but should be all in one place, viewable by all Delphi members, and in a format that makes it easy to comment on. +Some good options are the GitHub issue originally requesting the data source and the GitHub pull request adding the indicator. -There is not a formal process for this, and you're free to do whatever you think is reasonable and sufficient. A thorough analysis would cover the following topics: +There is not a formal process for this, and you're free to do whatever you think is reasonable and sufficient. +A thorough analysis would cover the following topics: * Run the [correlations notebook](https://github.com/cmu-delphi/covidcast/blob/5f15f71/R-notebooks/cor_dashboard.Rmd) ([example output](https://cmu-delphi.github.io/covidcast/R-notebooks/signal_correlations.html#)). * This helps evaluate the potential value of the signals for modeling. @@ -309,9 +358,11 @@ Once the analysis is complete, have the stakeholder (usually the original reques The [documentation site](https://cmu-delphi.github.io/delphi-epidata/) ([code here](https://github.com/cmu-delphi/delphi-epidata/tree/628e9655144934f3903c133b6713df4d4fcc613e/docs)) stores long-term long-form documentation pages for each indicator, including those that are inactive. -Active and new indicators go in the [COVIDcast Main Endpoint -> Data Sources and Signals](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_signals.html) section ([code here](https://github.com/cmu-delphi/delphi-epidata/tree/628e9655144934f3903c133b6713df4d4fcc613e/docs/api/covidcast-signals)). A [template doc page](https://github.com/cmu-delphi/delphi-epidata/blob/628e9655144934f3903c133b6713df4d4fcc613e/docs/api/covidcast-signals/_source-template.md) is available in the same directory. +Active and new indicators go in the [COVIDcast Main Endpoint -> Data Sources and Signals](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_signals.html) section ([code here](https://github.com/cmu-delphi/delphi-epidata/tree/628e9655144934f3903c133b6713df4d4fcc613e/docs/api/covidcast-signals)). +A [template doc page](https://github.com/cmu-delphi/delphi-epidata/blob/628e9655144934f3903c133b6713df4d4fcc613e/docs/api/covidcast-signals/_source-template.md) is available in the same directory. -An indicator documentation page should contain as much detail (including technical detail) as possible. The following fields are required: +An indicator documentation page should contain as much detail (including technical detail) as possible. +The following fields are required: * Description of the data source and data collection methods * Links to the data source (organization and specific dataset(s) used) @@ -325,9 +376,11 @@ An indicator documentation page should contain as much detail (including technic * Lag and revision characteristics * Licensing information -and anything else that changes how users would use or interpret the data, impacts the usability of the signal, may be difficult to discover, recommended usecases, is unusual, any gotchas about the data or the data processing approach, etc. _More detail is better!_ +and anything else that changes how users would use or interpret the data, impacts the usability of the signal, may be difficult to discover, recommended usecases, is unusual, any gotchas about the data or the data processing approach, etc. +_More detail is better!_ -At the time that you're writing the documentation, you are the expert on the data source and the indicator. Making the documentation thorough and clear will make the data maximally usable for future users, and will make maintenance for Delphi easier. +At the time that you're writing the documentation, you are the expert on the data source and the indicator. +Making the documentation thorough and clear will make the data maximally usable for future users, and will make maintenance for Delphi easier. (For similar reasons, comment your code extensively!) @@ -339,7 +392,8 @@ At the time that you're writing the documentation, you are the expert on the dat Next, the `acquisition.covidcast` component of the `delphi-epidata` codebase does the following immediately after an indicator run (you need to set acquisition job up): -1. Look in the `receiving/` folder to see if any new data files are available. If there are, then: +1. Look in the `receiving/` folder to see if any new data files are available. + If there are, then: 1. Import the new data into the epimetric_full table of the epidata.covid database, filling in the columns as follows: 1. `source`: parsed from the name of the subdirectory of `receiving/` 2. `signal`: parsed from the filename @@ -357,7 +411,8 @@ Next, the `acquisition.covidcast` component of the `delphi-epidata` codebase doe ### Staging -After developing the pipeline code, but before deploying in development, the pipeline should be run on staging for at least a week. This involves setting up some cronicle jobs as follows: +After developing the pipeline code, but before deploying in development, the pipeline should be run on staging for at least a week. +This involves setting up some cronicle jobs as follows: first the indicator run @@ -369,11 +424,14 @@ https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5clgy6rs https://cronicle-prod-01.delphi.cmu.edu/#Schedule?sub=edit_event&id=elr5ctl7art -Note the staging hostname and how the acquisition job is chained to run right after the indicator job. Do a few test runs. +Note the staging hostname and how the acquisition job is chained to run right after the indicator job. +Do a few test runs. If everything goes well (check staging db if data is ingested properly), make a prod version of the indicator run job and use that to run indicator on a daily basis. -Another thing to do is setting up the params.json template file in accordance with how you want to run the indicator and acquisition. Pay attention to the receiving directory, as well as how you can store credentials in vault. Refer to [this guide](https://docs.google.com/document/d/1Bbuvtoxowt7x2_8USx_JY-yTo-Av3oAFlhyG-vXGG-c/edit#heading=h.8kkoy8sx3t7f) for more vault info. +Another thing to do is setting up the params.json template file in accordance with how you want to run the indicator and acquisition. +Pay attention to the receiving directory, as well as how you can store credentials in vault. +Refer to [this guide](https://docs.google.com/document/d/1Bbuvtoxowt7x2_8USx_JY-yTo-Av3oAFlhyG-vXGG-c/edit#heading=h.8kkoy8sx3t7f) for more vault info. ### Signal Documentation From 1526dc940f61e5a1ab8ff2ce795f35c965c2e48e Mon Sep 17 00:00:00 2001 From: minhkhul <118945681+minhkhul@users.noreply.github.com> Date: Mon, 8 Jul 2024 17:23:28 -0400 Subject: [PATCH 39/48] Doctor_visits patching code (#1977) * add patching code * add documentation * linter * fix dir name * Update get_latest_claims_name.py * remove patch var, use only issue" * issue -> issue_date for clarity * fix logger * unit test * fix unit test * lint * Update doctor_visits/delphi_doctor_visits/patch.py Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> * add download and get_latest_claims_name tests * Update test_get_latest_claims_name.py test file name --------- Co-authored-by: nmdefries <42820733+nmdefries@users.noreply.github.com> --- doctor_visits/README.md | 6 ++ .../download_claims_ftp_files.py | 8 ++- .../get_latest_claims_name.py | 9 ++- doctor_visits/delphi_doctor_visits/patch.py | 71 +++++++++++++++++++ doctor_visits/delphi_doctor_visits/run.py | 22 ++++-- doctor_visits/tests/test_download.py | 28 ++++++++ .../tests/test_get_latest_claims_name.py | 11 +-- doctor_visits/tests/test_patch.py | 37 ++++++++++ 8 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 doctor_visits/delphi_doctor_visits/patch.py create mode 100644 doctor_visits/tests/test_download.py create mode 100644 doctor_visits/tests/test_patch.py diff --git a/doctor_visits/README.md b/doctor_visits/README.md index ba2acf43e..9a9ac07b5 100644 --- a/doctor_visits/README.md +++ b/doctor_visits/README.md @@ -53,3 +53,9 @@ The output will show the number of unit tests that passed and failed, along with the percentage of code covered by the tests. None of the tests should fail and the code lines that are not covered by unit tests should be small and should not include critical sub-routines. + +## Running Patches: +To get data issued during specific date range, output in batch issue format, adjust `params.json` in accordance with `patch.py`, then run +``` +env/bin/python -m delphi_doctor_visits.patch +``` diff --git a/doctor_visits/delphi_doctor_visits/download_claims_ftp_files.py b/doctor_visits/delphi_doctor_visits/download_claims_ftp_files.py index 002d4a7c9..efd110d8b 100644 --- a/doctor_visits/delphi_doctor_visits/download_claims_ftp_files.py +++ b/doctor_visits/delphi_doctor_visits/download_claims_ftp_files.py @@ -51,9 +51,13 @@ def change_date_format(name): name = '_'.join(split_name) return name -def download(ftp_credentials, out_path, logger): +def download(ftp_credentials, out_path, logger, issue_date=None): """Pull the latest raw files.""" - current_time = datetime.datetime.now() + if not issue_date: + current_time = datetime.datetime.now() + else: + current_time = datetime.datetime.strptime(issue_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59) + logger.info("starting download", time=current_time) seconds_in_day = 24 * 60 * 60 diff --git a/doctor_visits/delphi_doctor_visits/get_latest_claims_name.py b/doctor_visits/delphi_doctor_visits/get_latest_claims_name.py index e417183c7..0a86d532f 100644 --- a/doctor_visits/delphi_doctor_visits/get_latest_claims_name.py +++ b/doctor_visits/delphi_doctor_visits/get_latest_claims_name.py @@ -5,9 +5,12 @@ import datetime from pathlib import Path -def get_latest_filename(dir_path, logger): +def get_latest_filename(dir_path, logger, issue_date=None): """Get the latest filename from the list of downloaded raw files.""" - current_date = datetime.datetime.now() + if issue_date: + current_date = datetime.datetime.strptime(issue_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59) + else: + current_date = datetime.datetime.now() files = list(Path(dir_path).glob("*")) latest_timestamp = datetime.datetime(1900, 1, 1) @@ -24,7 +27,7 @@ def get_latest_filename(dir_path, logger): latest_timestamp = timestamp latest_filename = file - assert current_date.date() == latest_timestamp.date(), "no drop for today" + assert current_date.date() == latest_timestamp.date(), f"no drop for {current_date}" logger.info("Latest claims file", filename=latest_filename) diff --git a/doctor_visits/delphi_doctor_visits/patch.py b/doctor_visits/delphi_doctor_visits/patch.py new file mode 100644 index 000000000..32b6d308f --- /dev/null +++ b/doctor_visits/delphi_doctor_visits/patch.py @@ -0,0 +1,71 @@ +""" +This module is used for patching data in the delphi_doctor_visits package. + +To use this module, you need to specify the range of issue dates in params.json, like so: + +{ + "common": { + ... + }, + "validation": { + ... + }, + "patch": { + "patch_dir": "/Users/minhkhuele/Desktop/delphi/covidcast-indicators/doctor_visits/AprilPatch", + "start_issue": "2024-04-20", + "end_issue": "2024-04-21" + } +} + +It will generate data for that range of issue dates, and store them in batch issue format: +[name-of-patch]/issue_[issue-date]/doctor-visits/actual_data_file.csv +""" + +from datetime import datetime, timedelta +from os import makedirs + +from delphi_utils import get_structured_logger, read_params + +from .run import run_module + + +def patch(): + """ + Run the doctor visits indicator for a range of issue dates. + + The range of issue dates is specified in params.json using the following keys: + - "patch": Only used for patching data + - "start_date": str, YYYY-MM-DD format, first issue date + - "end_date": str, YYYY-MM-DD format, last issue date + - "patch_dir": str, directory to write all issues output + """ + params = read_params() + logger = get_structured_logger("delphi_doctor_visits.patch", filename=params["common"]["log_filename"]) + + start_issue = datetime.strptime(params["patch"]["start_issue"], "%Y-%m-%d") + end_issue = datetime.strptime(params["patch"]["end_issue"], "%Y-%m-%d") + + logger.info(f"""Start patching {params["patch"]["patch_dir"]}""") + logger.info(f"""Start issue: {start_issue.strftime("%Y-%m-%d")}""") + logger.info(f"""End issue: {end_issue.strftime("%Y-%m-%d")}""") + + makedirs(params["patch"]["patch_dir"], exist_ok=True) + + current_issue = start_issue + + while current_issue <= end_issue: + logger.info(f"""Running issue {current_issue.strftime("%Y-%m-%d")}""") + + params["patch"]["current_issue"] = current_issue.strftime("%Y-%m-%d") + + current_issue_yyyymmdd = current_issue.strftime("%Y%m%d") + current_issue_dir = f"""{params["patch"]["patch_dir"]}/issue_{current_issue_yyyymmdd}/doctor-visits""" + makedirs(f"{current_issue_dir}", exist_ok=True) + params["common"]["export_dir"] = f"""{current_issue_dir}""" + + run_module(params, logger) + current_issue += timedelta(days=1) + + +if __name__ == "__main__": + patch() diff --git a/doctor_visits/delphi_doctor_visits/run.py b/doctor_visits/delphi_doctor_visits/run.py index fd09c56d6..3c941534a 100644 --- a/doctor_visits/delphi_doctor_visits/run.py +++ b/doctor_visits/delphi_doctor_visits/run.py @@ -20,7 +20,7 @@ from .get_latest_claims_name import get_latest_filename -def run_module(params): # pylint: disable=too-many-statements +def run_module(params, logger=None): # pylint: disable=too-many-statements """ Run doctor visits indicator. @@ -42,18 +42,26 @@ def run_module(params): # pylint: disable=too-many-statements - "se": bool, whether to write out standard errors - "obfuscated_prefix": str, prefix for signal name if write_se is True. - "parallel": bool, whether to update sensor in parallel. + - "patch": Only used for patching data, remove if not patching. + Check out patch.py and README for more details on how to run patches. + - "start_date": str, YYYY-MM-DD format, first issue date + - "end_date": str, YYYY-MM-DD format, last issue date + - "patch_dir": str, directory to write all issues output """ start_time = time.time() - logger = get_structured_logger( - __name__, filename=params["common"].get("log_filename"), - log_exceptions=params["common"].get("log_exceptions", True)) + issue_date = params.get("patch", {}).get("current_issue", None) + if not logger: + logger = get_structured_logger( + __name__, + filename=params["common"].get("log_filename"), + log_exceptions=params["common"].get("log_exceptions", True), + ) # pull latest data - download(params["indicator"]["ftp_credentials"], - params["indicator"]["input_dir"], logger) + download(params["indicator"]["ftp_credentials"], params["indicator"]["input_dir"], logger, issue_date=issue_date) # find the latest files (these have timestamps) - claims_file = get_latest_filename(params["indicator"]["input_dir"], logger) + claims_file = get_latest_filename(params["indicator"]["input_dir"], logger, issue_date=issue_date) # modify data modify_and_write(claims_file, logger) diff --git a/doctor_visits/tests/test_download.py b/doctor_visits/tests/test_download.py new file mode 100644 index 000000000..dc94e534c --- /dev/null +++ b/doctor_visits/tests/test_download.py @@ -0,0 +1,28 @@ +import unittest +from unittest.mock import patch, MagicMock +from delphi_doctor_visits.download_claims_ftp_files import download + +class TestDownload(unittest.TestCase): + @patch('delphi_doctor_visits.download_claims_ftp_files.paramiko.SSHClient') + @patch('delphi_doctor_visits.download_claims_ftp_files.path.exists', return_value=False) + def test_download(self, mock_exists, mock_sshclient): + mock_sshclient_instance = MagicMock() + mock_sshclient.return_value = mock_sshclient_instance + mock_sftp = MagicMock() + mock_sshclient_instance.open_sftp.return_value = mock_sftp + mock_sftp.listdir_attr.return_value = [MagicMock(filename="SYNEDI_AGG_OUTPATIENT_20200207_1455CDT.csv.gz")] + ftp_credentials = {"host": "test_host", "user": "test_user", "pass": "test_pass", "port": "test_port"} + out_path = "./test_data/" + logger = MagicMock() + + #case 1: download with issue_date that does not exist on ftp server + download(ftp_credentials, out_path, logger, issue_date="2020-02-08") + mock_sshclient_instance.connect.assert_called_once_with(ftp_credentials["host"], username=ftp_credentials["user"], password=ftp_credentials["pass"], port=ftp_credentials["port"]) + mock_sftp.get.assert_not_called() + + # case 2: download with issue_date that exists on ftp server + download(ftp_credentials, out_path, logger, issue_date="2020-02-07") + mock_sftp.get.assert_called() + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/doctor_visits/tests/test_get_latest_claims_name.py b/doctor_visits/tests/test_get_latest_claims_name.py index 98bd19e2d..d1003ad47 100644 --- a/doctor_visits/tests/test_get_latest_claims_name.py +++ b/doctor_visits/tests/test_get_latest_claims_name.py @@ -11,9 +11,12 @@ class TestGetLatestFileName: logger = Mock() - + dir_path = "test_data" + def test_get_latest_claims_name(self): - dir_path = "./test_data/" - with pytest.raises(AssertionError): - get_latest_filename(dir_path, self.logger) + get_latest_filename(self.dir_path, self.logger) + + def test_get_latest_claims_name_with_issue_date(self): + result = get_latest_filename(self.dir_path, self.logger, issue_date="2020-02-07") + assert str(result) == f"{self.dir_path}/SYNEDI_AGG_OUTPATIENT_07022020_1455CDT.csv.gz" diff --git a/doctor_visits/tests/test_patch.py b/doctor_visits/tests/test_patch.py new file mode 100644 index 000000000..5b4575a09 --- /dev/null +++ b/doctor_visits/tests/test_patch.py @@ -0,0 +1,37 @@ +import unittest +from unittest.mock import patch as mock_patch, call +from delphi_doctor_visits.patch import patch +import os +import shutil + +class TestPatchModule(unittest.TestCase): + def test_patch(self): + with mock_patch('delphi_doctor_visits.patch.run_module') as mock_run_module, \ + mock_patch('delphi_doctor_visits.patch.get_structured_logger') as mock_get_structured_logger, \ + mock_patch('delphi_doctor_visits.patch.read_params') as mock_read_params: + + mock_read_params.return_value = { + "common": { + "log_filename": "test.log" + }, + "patch": { + "start_issue": "2021-01-01", + "end_issue": "2021-01-02", + "patch_dir": "./patch_dir" + } + } + + patch() + + self.assertIn('current_issue', mock_read_params.return_value['patch']) + self.assertEqual(mock_read_params.return_value['patch']['current_issue'], '2021-01-02') + + self.assertTrue(os.path.isdir('./patch_dir')) + self.assertTrue(os.path.isdir('./patch_dir/issue_20210101/doctor-visits')) + self.assertTrue(os.path.isdir('./patch_dir/issue_20210102/doctor-visits')) + + # Clean up the created directories after the test + shutil.rmtree(mock_read_params.return_value["patch"]["patch_dir"]) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From c261a2064478c8b7a9ebc12cd62534d73a93b23d Mon Sep 17 00:00:00 2001 From: nmdefries <42820733+nmdefries@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:27:35 -0400 Subject: [PATCH 40/48] Format, wording recommendations Co-authored-by: David Weber --- _template_python/INDICATOR_DEV_GUIDE.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index 15abf6153..0933b0b7f 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -23,7 +23,7 @@ We will also discuss best practices for building reliable, scalable, and maintai [Adding new API endpoints](https://cmu-delphi.github.io/delphi-epidata/new_endpoint_tutorial.html) (of which COVIDcast is a single example). -Most new data sources will be added as indicators within the main endpoint (called COVIDcast as of 20240628). +Most new data sources will be added as indicators within the main endpoint (called COVIDcast as of 2024-06-28). In rare cases, it may be preferable to add a dedicated endpoint for a new indicator. This would mainly be done if the format of the new data weren't compatible with the format used by the main endpoint, for example, if an indicator reports the same signal for many demographic groups, or if the reported geographic levels are nonstandard in some way. @@ -49,7 +49,7 @@ This is the general extract-transform-load procedure used by all COVIDcast indic * This converts dense output to a diff and reduces the size of each update. 7. Deliver the CSV output files to the `receiving/` directory on the API server. - +Adding a new indicator typically means implementing steps 1-3. Step 4 is included via the function ` create_export_csv`. Steps 5 (the validator), 6 (the archive differ) and 7 (acquisition) are all handled by runners in production. ## Step 0: Keep revision history (important!) If the data provider doesn’t provide or it is unclear if they provide historical versions of the data, immediately set up a script (bash, Python, etc) to automatically (e.g. cron) download the data every day and save locally with versioning. @@ -146,17 +146,17 @@ At this stage we want to answer the questions below (and any others that seem re * Missingness due to reporting pattern (e.g. no weekend reports)? * Will we want to and is it feasible to [interpolate missing values](https://github.com/cmu-delphi/covidcast-indicators/issues/1539)? * Are there any aberrant values that don’t make sense? e.g. negative counts, out of range percentages, “manual” missingness codes (9999, -9999, etc) -* Does the data source revise their data? How often? +* Does the data source revise their data? How often? By how much? Is the revision meaningful, or an artifact of data processing methods? * See raw data saved in [Step 0](#step-0-keep-revision-history-important) * What is the reporting schedule of the data? -* What order of magnitude is the signal? (If it’s sufficiently small, [this issue on how rounding is done](https://github.com/cmu-delphi/covidcast-indicators/issues/1945) needs to be addressed first) +* What order of magnitude is the signal? (If it’s too small or too large, [this issue on how rounding is done](https://github.com/cmu-delphi/covidcast-indicators/issues/1945) needs to be addressed first) * How is the data processed by the data source? E.g. normalization, censoring values with small sample sizes, censoring values associated with low-population areas, smoothing, adding jitter, etc. Keep any code and notes around! They will be helpful for later steps. For any issues that come up, consider now if -* We’ve seen them before in another dataset and, if so, how we handled it. - Is there code around that we can reuse? -* If it’s a small issue, how would you address it? Do you need an extra function to handle it? -* If it’s a big issue, talk to others and consider making a PRD to present potential solutions. + * We’ve seen them before in another dataset and, if so, how we handled it. + Is there code around that we can reuse? + * If it’s a small issue, how would you address it? Do you need an extra function to handle it? + * If it’s a big issue, talk to others and consider making a PRD to present potential solutions. ## Step 2: Pipeline Code @@ -253,7 +253,7 @@ E.g. which geo values are allowed, should every valid date be present in some wa #### Dealing with geos -In an ideal case, the data exists at one of our already covered geos: +In an ideal case, the data exists at one of our [already covered geos](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_geography.html): * State: state_code or state_id * FIPS (state+county codes, string leftpadded to 5 digits with zeroes) @@ -262,7 +262,7 @@ In an ideal case, the data exists at one of our already covered geos: * HRR (hospital referral region, int) If you want to map from one of these to another, the [`delphi_utils.geomapper`](https://github.com/cmu-delphi/covidcast-indicators/blob/6912077acba97e835aff7d0cd3d64309a1a9241d/_delphi_utils_python/delphi_utils/geomap.py) utility covers most cases. -A brief example of adding states with their population: +A brief example of aggregating from states to hhs regions via their population: ```{python} from delphi_utils.geomap import GeoMapper @@ -290,6 +290,7 @@ Mocking functions are useful in this case. #### Naming Indicator and signal names need to be approved by [@RoniRos](https://www.github.com/RoniRos). +It is better to start that conversation sooner rather than later. The data source name as specified during an API call (e.g. in `epidatr::pub_covidcast(source = "jhu-csse", ...)`, "jhu-csse" is the data source name) should match the wildcard portion of the module name ("jhu" in `delphi_jhu`) _and_ the top-level directory name in `covidcast-indicators` ("jhu"). (Ideally, these would all also match how we casually refer to the indicator ("JHU"), but that's hard to foresee and enforce.) @@ -372,7 +373,7 @@ The following fields are required: * Specific math showing how signals are calculated, if unusual or complex or you like equations * How smoothing is done, if any * Known limitations of the data source and the final signals -* Missingness characteristics, especially if the data is missing with a pattern (on weekends, etc) +* Missingness characteristics, especially if the data is missing with a pattern (on weekends, specific states, etc) * Lag and revision characteristics * Licensing information From fc9b00f8d4a19c52926cbf68c25d05f03fe3e89d Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:52:46 -0400 Subject: [PATCH 41/48] archive differ explanation --- _template_python/INDICATOR_DEV_GUIDE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index 0933b0b7f..78733674c 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -27,7 +27,7 @@ Most new data sources will be added as indicators within the main endpoint (call In rare cases, it may be preferable to add a dedicated endpoint for a new indicator. This would mainly be done if the format of the new data weren't compatible with the format used by the main endpoint, for example, if an indicator reports the same signal for many demographic groups, or if the reported geographic levels are nonstandard in some way. -[Setting up an S3 ArchiveDiffer](https://docs.google.com/document/d/1VcnvfeiO-GUUf88RosmNUfiPMoby-SnwH9s12esi4sI/edit#heading=h.e4ul15t3xmfj) +[Setting up an S3 ArchiveDiffer](https://docs.google.com/document/d/1VcnvfeiO-GUUf88RosmNUfiPMoby-SnwH9s12esi4sI/edit#heading=h.e4ul15t3xmfj). Archive differs are used to compress data that has a long history that doesn't change that much. For example, the JHU CSSE indicator occasionally had revisions that could go back far in time, which meant that we needed to output all reference dates every day. Revisions didn't impact every location or reference date at a time, which meant that every issue would contain many values that were exactly the same as values issued the previous day. The archive differ removes those duplicates. [Indicator debugging guide](https://docs.google.com/document/d/1vaNgQ2cDrMvAg0FbSurbCemF9WqZVrirPpWEK0RdATQ/edit): somewhat out-of-date but might still be useful @@ -97,7 +97,7 @@ A one-off manual download is fine. Don’t worry too much about productionizing the data-fetching step at this point. (Although any code you write can be used later.) -Also check to see whether the data is coming from an existing source, e.g. the wastewater data and NCHS data are accessed the same way, so when adding wastewater data, we could reuse the API key and only needed to lightly modify the API calls for the new dataset. +Also check to see whether the data is coming from an existing source, e.g. NSSP and NCHS are accessed the same way, so when adding NSSP, we could reuse the API key and only needed to lightly modify the API calls for the new dataset. Reading from a local file: @@ -255,8 +255,8 @@ E.g. which geo values are allowed, should every valid date be present in some wa In an ideal case, the data exists at one of our [already covered geos](https://cmu-delphi.github.io/delphi-epidata/api/covidcast_geography.html): -* State: state_code or state_id -* FIPS (state+county codes, string leftpadded to 5 digits with zeroes) +* State: state_code (string, leftpadded to 2 digits with 0) or state_id (string) +* FIPS (state+county codes, string leftpadded to 5 digits with 0) * ZIP * MSA (metro statistical area, int) * HRR (hospital referral region, int) From 1916191f969cd049f9390d162325a22686e5a7f5 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Tue, 9 Jul 2024 16:21:05 -0400 Subject: [PATCH 42/48] drop location links --- _template_python/INDICATOR_DEV_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index 78733674c..a6bd6942d 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -37,7 +37,7 @@ This would mainly be done if the format of the new data weren't compatible with This is the general extract-transform-load procedure used by all COVIDcast indicators: 1. Download data from the source. - * This could be via an API query, scraping the website, an SFTP or S3 dropbox, an email attachment, etc. + * This could be via an [API query](https://github.com/cmu-delphi/covidcast-indicators/blob/fe39ebb1f8baa76670eb665d1dc99376ddfd3010/nssp/delphi_nssp/pull.py#L30), scraping a website, [an SFTP](https://github.com/cmu-delphi/covidcast-indicators/blob/fe39ebb1f8baa76670eb665d1dc99376ddfd3010/changehc/delphi_changehc/download_ftp_files.py#L19) or S3 dropbox, an email attachment, etc. 2. Process the source data to extract one or more time-series signals. * A signal includes a value, standard deviation (data-dependent), and sample size (data-dependent) for each region for each unit of time (a day or an epidemiological week "epi-week"). 3. Aggregate each signal to all possible standard higher geographic levels. From 8c81bae94b0b552d8704b4d5fcd90fcf4e2d8111 Mon Sep 17 00:00:00 2001 From: Nat DeFries <42820733+nmdefries@users.noreply.github.com> Date: Tue, 9 Jul 2024 17:31:10 -0400 Subject: [PATCH 43/48] don't mention R; naming conventions --- _template_python/INDICATOR_DEV_GUIDE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/_template_python/INDICATOR_DEV_GUIDE.md b/_template_python/INDICATOR_DEV_GUIDE.md index a6bd6942d..d003b6696 100644 --- a/_template_python/INDICATOR_DEV_GUIDE.md +++ b/_template_python/INDICATOR_DEV_GUIDE.md @@ -181,7 +181,7 @@ To get started, Delphi has a [basic code template](https://github.com/cmu-delphi It can also be helpful to read through other indicators, especially if they share a data source or format. Indicators should be written in python for speed and maintainability. -If you think you need to use R, please reconsider! and talk to other engineering team members. +Don't use R. Generally, indicators have: @@ -240,7 +240,7 @@ After that, generalize your code to be able to be run on all geos of interest, t Make sure you have a functional environment with python 3.8.15+. For local runs, the makefile’s make install target will set up a local virtual environment with necessary packages. -(If working in R (not recommended), local runs can be run without a virtual environment or using the [`renv` package](https://rstudio.github.io/renv/articles/renv.html), but production runs should be set up to user Docker.) +(If working in R (very much NOT recommended), local runs can be run without a virtual environment or using the [`renv` package](https://rstudio.github.io/renv/articles/renv.html), but production runs should be set up to use Docker.) #### Dealing with data-types @@ -306,12 +306,13 @@ Ideally, the indicator name should: Based on these guidelines, the `jhu-csse` indicator would be better as `jhu-csse` everywhere (module name could be `delphi_jhu_csse`), rather than having a mix of `jhu-csse` and `jhu`. Signal names should not be too long, but the most important feature is that they are descriptive. +If we're mirroring a processed dataset, consider keeping their signal names. -Some standard tags used in signal names: +Use the following standard tags when creating new signal names: * `raw`: unsmoothed, _no longer used; if no smoothing is specified the signal is assumed to be "raw"_ * `7dav`: smoothed using a average over a rolling 7-day window; comes at the end of the name -* `smoothed`: smoothed using a more complex smoothing algorithm +* `smoothed`: smoothed using a more complex smoothing algorithm; comes at the end of the name * `prop`: counts per 100k population * `pct`: percentage between 0 and 100 * `num`: counts, _no longer used; if no value type is specified the signal is assumed to be a count_ From 5d4fdbd3f7e6294dd72a7d26e189d5a4200c9586 Mon Sep 17 00:00:00 2001 From: george Date: Wed, 10 Jul 2024 09:45:34 -0400 Subject: [PATCH 44/48] attempt to fix jenkins builds (#1988) --- _delphi_utils_python/setup.py | 1 + claims_hosp/setup.py | 1 + doctor_visits/setup.py | 1 + 3 files changed, 3 insertions(+) diff --git a/_delphi_utils_python/setup.py b/_delphi_utils_python/setup.py index c33033164..fa3c54bf6 100644 --- a/_delphi_utils_python/setup.py +++ b/_delphi_utils_python/setup.py @@ -8,6 +8,7 @@ "boto3", "covidcast", "cvxpy", + "scs<3.2.6", # TODO: remove this ; it is a cvxpy dependency, and the excluded version appears to break our jenkins build. see: https://github.com/cvxgrp/scs/issues/283 "darker[isort]~=2.1.1", "epiweeks", "freezegun", diff --git a/claims_hosp/setup.py b/claims_hosp/setup.py index 76611e33d..3b859c294 100644 --- a/claims_hosp/setup.py +++ b/claims_hosp/setup.py @@ -14,6 +14,7 @@ "pytest-cov", "pytest", "cvxpy<1.6", + "scs<3.2.6", # TODO: remove this ; it is a cvxpy dependency, and the excluded version appears to break our jenkins build. see: https://github.com/cvxgrp/scs/issues/283 ] setup( diff --git a/doctor_visits/setup.py b/doctor_visits/setup.py index 53e5b722e..17d6fc9af 100644 --- a/doctor_visits/setup.py +++ b/doctor_visits/setup.py @@ -12,6 +12,7 @@ "pytest", "scikit-learn", "cvxpy>=1.5", + "scs<3.2.6", # TODO: remove this ; it is a cvxpy dependency, and the excluded version appears to break our jenkins build. see: https://github.com/cvxgrp/scs/issues/283 ] setup( From e36057d174b0428b6743f7dfae9446735c4ccaec Mon Sep 17 00:00:00 2001 From: george Date: Wed, 10 Jul 2024 10:59:33 -0400 Subject: [PATCH 45/48] fix doctor_visits log location & export_dir (#1980) * fix doctor_visits log location (updated to be in shared system indicators log directory) * Update doctor_visits export_dir Co-authored-by: minhkhul <118945681+minhkhul@users.noreply.github.com> --- ansible/templates/doctor_visits-params-prod.json.j2 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/templates/doctor_visits-params-prod.json.j2 b/ansible/templates/doctor_visits-params-prod.json.j2 index 49188d3bb..f6edab07f 100644 --- a/ansible/templates/doctor_visits-params-prod.json.j2 +++ b/ansible/templates/doctor_visits-params-prod.json.j2 @@ -1,7 +1,7 @@ { "common": { - "export_dir": "./receiving", - "log_filename": "./doctor-visits.log" + "export_dir": "/common/covidcast/receiving/doctor-visits", + "log_filename": "/var/log/indicators/doctor-visits.log" }, "indicator": { "input_file": "./input/SYNEDI_AGG_OUTPATIENT_18052020_1455CDT.csv.gz", @@ -43,4 +43,4 @@ ] } } -} \ No newline at end of file +} From 3304c5d7e1871d857bb1f18431228ff156b39eed Mon Sep 17 00:00:00 2001 From: Delphi Deploy Bot Date: Wed, 10 Jul 2024 15:03:01 +0000 Subject: [PATCH 46/48] chore: bump delphi_utils to 0.3.24 --- _delphi_utils_python/.bumpversion.cfg | 2 +- _delphi_utils_python/delphi_utils/__init__.py | 2 +- _delphi_utils_python/setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/_delphi_utils_python/.bumpversion.cfg b/_delphi_utils_python/.bumpversion.cfg index 3d4bc08a0..722a91e30 100644 --- a/_delphi_utils_python/.bumpversion.cfg +++ b/_delphi_utils_python/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.3.23 +current_version = 0.3.24 commit = True message = chore: bump delphi_utils to {new_version} tag = False diff --git a/_delphi_utils_python/delphi_utils/__init__.py b/_delphi_utils_python/delphi_utils/__init__.py index 2a9893a5e..7ff828440 100644 --- a/_delphi_utils_python/delphi_utils/__init__.py +++ b/_delphi_utils_python/delphi_utils/__init__.py @@ -15,4 +15,4 @@ from .nancodes import Nans from .weekday import Weekday -__version__ = "0.3.23" +__version__ = "0.3.24" diff --git a/_delphi_utils_python/setup.py b/_delphi_utils_python/setup.py index fa3c54bf6..3dee89b53 100644 --- a/_delphi_utils_python/setup.py +++ b/_delphi_utils_python/setup.py @@ -30,7 +30,7 @@ setup( name="delphi_utils", - version="0.3.23", + version="0.3.24", description="Shared Utility Functions for Indicators", long_description=long_description, long_description_content_type="text/markdown", From b569eb1303c39a1059ae524d57c7fd3a82d30c2e Mon Sep 17 00:00:00 2001 From: Delphi Deploy Bot Date: Wed, 10 Jul 2024 15:03:01 +0000 Subject: [PATCH 47/48] chore: bump covidcast-indicators to 0.3.55 --- .bumpversion.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index fe7064afc..de364861c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.3.54 +current_version = 0.3.55 commit = True message = chore: bump covidcast-indicators to {new_version} tag = False From 72ecb18a428f056eba55f8b2ab81d2519436eb20 Mon Sep 17 00:00:00 2001 From: melange396 Date: Wed, 10 Jul 2024 15:03:02 +0000 Subject: [PATCH 48/48] [create-pull-request] automated change --- changehc/version.cfg | 2 +- claims_hosp/version.cfg | 2 +- doctor_visits/version.cfg | 2 +- google_symptoms/version.cfg | 2 +- hhs_hosp/version.cfg | 2 +- nchs_mortality/version.cfg | 2 +- quidel_covidtest/version.cfg | 2 +- sir_complainsalot/version.cfg | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/changehc/version.cfg b/changehc/version.cfg index d3d61ed12..f5c28d2cd 100644 --- a/changehc/version.cfg +++ b/changehc/version.cfg @@ -1 +1 @@ -current_version = 0.3.54 +current_version = 0.3.55 diff --git a/claims_hosp/version.cfg b/claims_hosp/version.cfg index d3d61ed12..f5c28d2cd 100644 --- a/claims_hosp/version.cfg +++ b/claims_hosp/version.cfg @@ -1 +1 @@ -current_version = 0.3.54 +current_version = 0.3.55 diff --git a/doctor_visits/version.cfg b/doctor_visits/version.cfg index d3d61ed12..f5c28d2cd 100644 --- a/doctor_visits/version.cfg +++ b/doctor_visits/version.cfg @@ -1 +1 @@ -current_version = 0.3.54 +current_version = 0.3.55 diff --git a/google_symptoms/version.cfg b/google_symptoms/version.cfg index d3d61ed12..f5c28d2cd 100644 --- a/google_symptoms/version.cfg +++ b/google_symptoms/version.cfg @@ -1 +1 @@ -current_version = 0.3.54 +current_version = 0.3.55 diff --git a/hhs_hosp/version.cfg b/hhs_hosp/version.cfg index d3d61ed12..f5c28d2cd 100644 --- a/hhs_hosp/version.cfg +++ b/hhs_hosp/version.cfg @@ -1 +1 @@ -current_version = 0.3.54 +current_version = 0.3.55 diff --git a/nchs_mortality/version.cfg b/nchs_mortality/version.cfg index d3d61ed12..f5c28d2cd 100644 --- a/nchs_mortality/version.cfg +++ b/nchs_mortality/version.cfg @@ -1 +1 @@ -current_version = 0.3.54 +current_version = 0.3.55 diff --git a/quidel_covidtest/version.cfg b/quidel_covidtest/version.cfg index d3d61ed12..f5c28d2cd 100644 --- a/quidel_covidtest/version.cfg +++ b/quidel_covidtest/version.cfg @@ -1 +1 @@ -current_version = 0.3.54 +current_version = 0.3.55 diff --git a/sir_complainsalot/version.cfg b/sir_complainsalot/version.cfg index d3d61ed12..f5c28d2cd 100644 --- a/sir_complainsalot/version.cfg +++ b/sir_complainsalot/version.cfg @@ -1 +1 @@ -current_version = 0.3.54 +current_version = 0.3.55