nmtc-mapper 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nmtc_mapper-0.1.0/PKG-INFO +130 -0
- nmtc_mapper-0.1.0/README.md +117 -0
- nmtc_mapper-0.1.0/nmtc_mapper.egg-info/PKG-INFO +130 -0
- nmtc_mapper-0.1.0/nmtc_mapper.egg-info/SOURCES.txt +22 -0
- nmtc_mapper-0.1.0/nmtc_mapper.egg-info/dependency_links.txt +1 -0
- nmtc_mapper-0.1.0/nmtc_mapper.egg-info/requires.txt +4 -0
- nmtc_mapper-0.1.0/nmtc_mapper.egg-info/top_level.txt +2 -0
- nmtc_mapper-0.1.0/nmtcmapper/__init__.py +10 -0
- nmtc_mapper-0.1.0/nmtcmapper/data/__init__.py +0 -0
- nmtc_mapper-0.1.0/nmtcmapper/data/loader.py +156 -0
- nmtc_mapper-0.1.0/nmtcmapper/data/schema.py +68 -0
- nmtc_mapper-0.1.0/nmtcmapper/eligibility/__init__.py +0 -0
- nmtc_mapper-0.1.0/nmtcmapper/eligibility/checker.py +128 -0
- nmtc_mapper-0.1.0/nmtcmapper/geocoder/__init__.py +0 -0
- nmtc_mapper-0.1.0/nmtcmapper/geocoder/census.py +196 -0
- nmtc_mapper-0.1.0/nmtcmapper/mapper.py +182 -0
- nmtc_mapper-0.1.0/pyproject.toml +20 -0
- nmtc_mapper-0.1.0/setup.cfg +4 -0
- nmtc_mapper-0.1.0/setup.py +13 -0
- nmtc_mapper-0.1.0/tests/__init__.py +0 -0
- nmtc_mapper-0.1.0/tests/conftest.py +37 -0
- nmtc_mapper-0.1.0/tests/test_checker.py +36 -0
- nmtc_mapper-0.1.0/tests/test_loader.py +84 -0
- nmtc_mapper-0.1.0/tests/test_mapper.py +54 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nmtc-mapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automated NMTC eligibility checker β geocode addresses and check Low-Income Community status using CDFI Fund and Census data
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Jaypatel1511/nmtc-mapper
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: pandas>=1.4.0
|
|
10
|
+
Requires-Dist: numpy>=1.21.0
|
|
11
|
+
Requires-Dist: requests>=2.27.0
|
|
12
|
+
Requires-Dist: openpyxl>=3.0.0
|
|
13
|
+
|
|
14
|
+
# nmtc-mapper πΊοΈ
|
|
15
|
+
|
|
16
|
+
**Automated NMTC eligibility checker for addresses and census tracts.**
|
|
17
|
+
|
|
18
|
+
Pass a DataFrame of addresses and get back a boolean column for NMTC eligibility,
|
|
19
|
+
distress level, poverty rate, AMI ratio, and more β using official CDFI Fund and
|
|
20
|
+
Census Bureau data. No manual lookups required.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Why nmtc-mapper?
|
|
25
|
+
|
|
26
|
+
The CDFI Fund provides a manual web tool (CIMS) for checking NMTC eligibility
|
|
27
|
+
one address at a time. nmtc-mapper automates this β pass 10,000 addresses and
|
|
28
|
+
get results in seconds, using the same official data source.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
pip install nmtc-mapper
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
from nmtcmapper import NMTCMapper
|
|
41
|
+
|
|
42
|
+
mapper = NMTCMapper()
|
|
43
|
+
|
|
44
|
+
# Single address (geocodes automatically)
|
|
45
|
+
result = mapper.check_address("1234 S Michigan Ave, Chicago, IL 60605")
|
|
46
|
+
result.summary()
|
|
47
|
+
print(result.nmtc_eligible) # True
|
|
48
|
+
print(result.distress_level) # "severe"
|
|
49
|
+
print(result.poverty_rate) # 0.38
|
|
50
|
+
|
|
51
|
+
# Known census tract (no geocoding needed)
|
|
52
|
+
result = mapper.check_tract("17031840100")
|
|
53
|
+
print(result.nmtc_eligible) # True
|
|
54
|
+
|
|
55
|
+
# Batch β enrich a DataFrame of addresses
|
|
56
|
+
import pandas as pd
|
|
57
|
+
df = pd.read_csv("projects.csv") # must have 'address' column
|
|
58
|
+
df = mapper.enrich(df, address_col="address")
|
|
59
|
+
print(df["nmtc_eligible"].value_counts())
|
|
60
|
+
print(df["distress_level"].value_counts())
|
|
61
|
+
|
|
62
|
+
# If you already have census tract IDs
|
|
63
|
+
df = mapper.enrich(df, tract_col="tract_id")
|
|
64
|
+
|
|
65
|
+
# Summary stats
|
|
66
|
+
mapper.eligible_count(df)
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Eligibility Rules (2016-2020 ACS β mandatory since Sept 1, 2024)
|
|
71
|
+
|
|
72
|
+
A census tract qualifies as a Low-Income Community (LIC) if it meets ANY of:
|
|
73
|
+
|
|
74
|
+
- Poverty rate >= 20%
|
|
75
|
+
- Median Family Income <= 80% of metro/state AMI
|
|
76
|
+
- Median Family Income <= 85% of state AMI (high migration rural counties)
|
|
77
|
+
|
|
78
|
+
Distress levels:
|
|
79
|
+
|
|
80
|
+
- deep β Poverty >= 40% OR AMI <= 50% OR unemployment >= 2x national rate
|
|
81
|
+
- severe β Poverty >= 30% OR AMI <= 60% OR unemployment >= 1.5x national rate
|
|
82
|
+
- lic β NMTC eligible (meets LIC criteria)
|
|
83
|
+
- ineligible β Does not qualify
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Data Sources
|
|
88
|
+
|
|
89
|
+
- CDFI Fund 2016-2020 ACS Low-Income Community Eligibility File
|
|
90
|
+
https://www.cdfifund.gov/research-data
|
|
91
|
+
- US Census Bureau Geocoding API (free, no API key required)
|
|
92
|
+
https://geocoding.geo.census.gov
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Output Columns
|
|
97
|
+
|
|
98
|
+
After running .enrich(), your DataFrame will have:
|
|
99
|
+
|
|
100
|
+
- nmtc_eligible (bool)
|
|
101
|
+
- distress_level (str: deep / severe / lic / ineligible)
|
|
102
|
+
- poverty_rate (float)
|
|
103
|
+
- ami_ratio (float)
|
|
104
|
+
- unemployment_rate (float)
|
|
105
|
+
- is_non_metro (bool)
|
|
106
|
+
- severe_distress (bool)
|
|
107
|
+
- deep_distress (bool)
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Running Tests
|
|
112
|
+
|
|
113
|
+
PYTHONPATH=. pytest tests/ -v
|
|
114
|
+
|
|
115
|
+
24 tests across all modules.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Who This Is For
|
|
120
|
+
|
|
121
|
+
- CDEs screening project locations for NMTC eligibility
|
|
122
|
+
- CDFI analysts qualifying borrower locations at scale
|
|
123
|
+
- Researchers analyzing geographic distribution of LIC tracts
|
|
124
|
+
- Anyone replacing manual CIMS lookups with automated Python
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT 2026 Jaypatel1511
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# nmtc-mapper πΊοΈ
|
|
2
|
+
|
|
3
|
+
**Automated NMTC eligibility checker for addresses and census tracts.**
|
|
4
|
+
|
|
5
|
+
Pass a DataFrame of addresses and get back a boolean column for NMTC eligibility,
|
|
6
|
+
distress level, poverty rate, AMI ratio, and more β using official CDFI Fund and
|
|
7
|
+
Census Bureau data. No manual lookups required.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why nmtc-mapper?
|
|
12
|
+
|
|
13
|
+
The CDFI Fund provides a manual web tool (CIMS) for checking NMTC eligibility
|
|
14
|
+
one address at a time. nmtc-mapper automates this β pass 10,000 addresses and
|
|
15
|
+
get results in seconds, using the same official data source.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
pip install nmtc-mapper
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
from nmtcmapper import NMTCMapper
|
|
28
|
+
|
|
29
|
+
mapper = NMTCMapper()
|
|
30
|
+
|
|
31
|
+
# Single address (geocodes automatically)
|
|
32
|
+
result = mapper.check_address("1234 S Michigan Ave, Chicago, IL 60605")
|
|
33
|
+
result.summary()
|
|
34
|
+
print(result.nmtc_eligible) # True
|
|
35
|
+
print(result.distress_level) # "severe"
|
|
36
|
+
print(result.poverty_rate) # 0.38
|
|
37
|
+
|
|
38
|
+
# Known census tract (no geocoding needed)
|
|
39
|
+
result = mapper.check_tract("17031840100")
|
|
40
|
+
print(result.nmtc_eligible) # True
|
|
41
|
+
|
|
42
|
+
# Batch β enrich a DataFrame of addresses
|
|
43
|
+
import pandas as pd
|
|
44
|
+
df = pd.read_csv("projects.csv") # must have 'address' column
|
|
45
|
+
df = mapper.enrich(df, address_col="address")
|
|
46
|
+
print(df["nmtc_eligible"].value_counts())
|
|
47
|
+
print(df["distress_level"].value_counts())
|
|
48
|
+
|
|
49
|
+
# If you already have census tract IDs
|
|
50
|
+
df = mapper.enrich(df, tract_col="tract_id")
|
|
51
|
+
|
|
52
|
+
# Summary stats
|
|
53
|
+
mapper.eligible_count(df)
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Eligibility Rules (2016-2020 ACS β mandatory since Sept 1, 2024)
|
|
58
|
+
|
|
59
|
+
A census tract qualifies as a Low-Income Community (LIC) if it meets ANY of:
|
|
60
|
+
|
|
61
|
+
- Poverty rate >= 20%
|
|
62
|
+
- Median Family Income <= 80% of metro/state AMI
|
|
63
|
+
- Median Family Income <= 85% of state AMI (high migration rural counties)
|
|
64
|
+
|
|
65
|
+
Distress levels:
|
|
66
|
+
|
|
67
|
+
- deep β Poverty >= 40% OR AMI <= 50% OR unemployment >= 2x national rate
|
|
68
|
+
- severe β Poverty >= 30% OR AMI <= 60% OR unemployment >= 1.5x national rate
|
|
69
|
+
- lic β NMTC eligible (meets LIC criteria)
|
|
70
|
+
- ineligible β Does not qualify
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Data Sources
|
|
75
|
+
|
|
76
|
+
- CDFI Fund 2016-2020 ACS Low-Income Community Eligibility File
|
|
77
|
+
https://www.cdfifund.gov/research-data
|
|
78
|
+
- US Census Bureau Geocoding API (free, no API key required)
|
|
79
|
+
https://geocoding.geo.census.gov
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Output Columns
|
|
84
|
+
|
|
85
|
+
After running .enrich(), your DataFrame will have:
|
|
86
|
+
|
|
87
|
+
- nmtc_eligible (bool)
|
|
88
|
+
- distress_level (str: deep / severe / lic / ineligible)
|
|
89
|
+
- poverty_rate (float)
|
|
90
|
+
- ami_ratio (float)
|
|
91
|
+
- unemployment_rate (float)
|
|
92
|
+
- is_non_metro (bool)
|
|
93
|
+
- severe_distress (bool)
|
|
94
|
+
- deep_distress (bool)
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Running Tests
|
|
99
|
+
|
|
100
|
+
PYTHONPATH=. pytest tests/ -v
|
|
101
|
+
|
|
102
|
+
24 tests across all modules.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Who This Is For
|
|
107
|
+
|
|
108
|
+
- CDEs screening project locations for NMTC eligibility
|
|
109
|
+
- CDFI analysts qualifying borrower locations at scale
|
|
110
|
+
- Researchers analyzing geographic distribution of LIC tracts
|
|
111
|
+
- Anyone replacing manual CIMS lookups with automated Python
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT 2026 Jaypatel1511
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nmtc-mapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automated NMTC eligibility checker β geocode addresses and check Low-Income Community status using CDFI Fund and Census data
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Jaypatel1511/nmtc-mapper
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: pandas>=1.4.0
|
|
10
|
+
Requires-Dist: numpy>=1.21.0
|
|
11
|
+
Requires-Dist: requests>=2.27.0
|
|
12
|
+
Requires-Dist: openpyxl>=3.0.0
|
|
13
|
+
|
|
14
|
+
# nmtc-mapper πΊοΈ
|
|
15
|
+
|
|
16
|
+
**Automated NMTC eligibility checker for addresses and census tracts.**
|
|
17
|
+
|
|
18
|
+
Pass a DataFrame of addresses and get back a boolean column for NMTC eligibility,
|
|
19
|
+
distress level, poverty rate, AMI ratio, and more β using official CDFI Fund and
|
|
20
|
+
Census Bureau data. No manual lookups required.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Why nmtc-mapper?
|
|
25
|
+
|
|
26
|
+
The CDFI Fund provides a manual web tool (CIMS) for checking NMTC eligibility
|
|
27
|
+
one address at a time. nmtc-mapper automates this β pass 10,000 addresses and
|
|
28
|
+
get results in seconds, using the same official data source.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
pip install nmtc-mapper
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
from nmtcmapper import NMTCMapper
|
|
41
|
+
|
|
42
|
+
mapper = NMTCMapper()
|
|
43
|
+
|
|
44
|
+
# Single address (geocodes automatically)
|
|
45
|
+
result = mapper.check_address("1234 S Michigan Ave, Chicago, IL 60605")
|
|
46
|
+
result.summary()
|
|
47
|
+
print(result.nmtc_eligible) # True
|
|
48
|
+
print(result.distress_level) # "severe"
|
|
49
|
+
print(result.poverty_rate) # 0.38
|
|
50
|
+
|
|
51
|
+
# Known census tract (no geocoding needed)
|
|
52
|
+
result = mapper.check_tract("17031840100")
|
|
53
|
+
print(result.nmtc_eligible) # True
|
|
54
|
+
|
|
55
|
+
# Batch β enrich a DataFrame of addresses
|
|
56
|
+
import pandas as pd
|
|
57
|
+
df = pd.read_csv("projects.csv") # must have 'address' column
|
|
58
|
+
df = mapper.enrich(df, address_col="address")
|
|
59
|
+
print(df["nmtc_eligible"].value_counts())
|
|
60
|
+
print(df["distress_level"].value_counts())
|
|
61
|
+
|
|
62
|
+
# If you already have census tract IDs
|
|
63
|
+
df = mapper.enrich(df, tract_col="tract_id")
|
|
64
|
+
|
|
65
|
+
# Summary stats
|
|
66
|
+
mapper.eligible_count(df)
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Eligibility Rules (2016-2020 ACS β mandatory since Sept 1, 2024)
|
|
71
|
+
|
|
72
|
+
A census tract qualifies as a Low-Income Community (LIC) if it meets ANY of:
|
|
73
|
+
|
|
74
|
+
- Poverty rate >= 20%
|
|
75
|
+
- Median Family Income <= 80% of metro/state AMI
|
|
76
|
+
- Median Family Income <= 85% of state AMI (high migration rural counties)
|
|
77
|
+
|
|
78
|
+
Distress levels:
|
|
79
|
+
|
|
80
|
+
- deep β Poverty >= 40% OR AMI <= 50% OR unemployment >= 2x national rate
|
|
81
|
+
- severe β Poverty >= 30% OR AMI <= 60% OR unemployment >= 1.5x national rate
|
|
82
|
+
- lic β NMTC eligible (meets LIC criteria)
|
|
83
|
+
- ineligible β Does not qualify
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Data Sources
|
|
88
|
+
|
|
89
|
+
- CDFI Fund 2016-2020 ACS Low-Income Community Eligibility File
|
|
90
|
+
https://www.cdfifund.gov/research-data
|
|
91
|
+
- US Census Bureau Geocoding API (free, no API key required)
|
|
92
|
+
https://geocoding.geo.census.gov
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Output Columns
|
|
97
|
+
|
|
98
|
+
After running .enrich(), your DataFrame will have:
|
|
99
|
+
|
|
100
|
+
- nmtc_eligible (bool)
|
|
101
|
+
- distress_level (str: deep / severe / lic / ineligible)
|
|
102
|
+
- poverty_rate (float)
|
|
103
|
+
- ami_ratio (float)
|
|
104
|
+
- unemployment_rate (float)
|
|
105
|
+
- is_non_metro (bool)
|
|
106
|
+
- severe_distress (bool)
|
|
107
|
+
- deep_distress (bool)
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Running Tests
|
|
112
|
+
|
|
113
|
+
PYTHONPATH=. pytest tests/ -v
|
|
114
|
+
|
|
115
|
+
24 tests across all modules.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Who This Is For
|
|
120
|
+
|
|
121
|
+
- CDEs screening project locations for NMTC eligibility
|
|
122
|
+
- CDFI analysts qualifying borrower locations at scale
|
|
123
|
+
- Researchers analyzing geographic distribution of LIC tracts
|
|
124
|
+
- Anyone replacing manual CIMS lookups with automated Python
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT 2026 Jaypatel1511
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
nmtc_mapper.egg-info/PKG-INFO
|
|
5
|
+
nmtc_mapper.egg-info/SOURCES.txt
|
|
6
|
+
nmtc_mapper.egg-info/dependency_links.txt
|
|
7
|
+
nmtc_mapper.egg-info/requires.txt
|
|
8
|
+
nmtc_mapper.egg-info/top_level.txt
|
|
9
|
+
nmtcmapper/__init__.py
|
|
10
|
+
nmtcmapper/mapper.py
|
|
11
|
+
nmtcmapper/data/__init__.py
|
|
12
|
+
nmtcmapper/data/loader.py
|
|
13
|
+
nmtcmapper/data/schema.py
|
|
14
|
+
nmtcmapper/eligibility/__init__.py
|
|
15
|
+
nmtcmapper/eligibility/checker.py
|
|
16
|
+
nmtcmapper/geocoder/__init__.py
|
|
17
|
+
nmtcmapper/geocoder/census.py
|
|
18
|
+
tests/__init__.py
|
|
19
|
+
tests/conftest.py
|
|
20
|
+
tests/test_checker.py
|
|
21
|
+
tests/test_loader.py
|
|
22
|
+
tests/test_mapper.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from nmtcmapper.mapper import NMTCMapper
|
|
2
|
+
from nmtcmapper.eligibility.checker import EligibilityResult
|
|
3
|
+
from nmtcmapper.data.loader import load_eligibility_table
|
|
4
|
+
from nmtcmapper.geocoder.census import geocode_address
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"NMTCMapper", "EligibilityResult",
|
|
9
|
+
"load_eligibility_table", "geocode_address",
|
|
10
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Download and cache the CDFI Fund NMTC eligibility file.
|
|
3
|
+
Builds a lookup table of all eligible census tracts.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import requests
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from nmtcmapper.data.schema import (
|
|
11
|
+
CACHE_DIR, CDFI_FUND_LIC_URL_2020,
|
|
12
|
+
ELIGIBILITY_FILE_COLUMNS,
|
|
13
|
+
LIC_POVERTY_RATE_THRESHOLD,
|
|
14
|
+
LIC_AMI_RATIO_METRO_THRESHOLD,
|
|
15
|
+
LIC_AMI_RATIO_RURAL_THRESHOLD,
|
|
16
|
+
SEVERE_POVERTY_THRESHOLD, SEVERE_AMI_THRESHOLD,
|
|
17
|
+
SEVERE_UNEMPLOYMENT_MULTIPLIER, NATIONAL_UNEMPLOYMENT_RATE,
|
|
18
|
+
DEEP_POVERTY_THRESHOLD, DEEP_AMI_THRESHOLD,
|
|
19
|
+
DEEP_UNEMPLOYMENT_MULTIPLIER,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_cache_dir() -> Path:
|
|
24
|
+
path = Path(CACHE_DIR)
|
|
25
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cache_path(filename: str) -> Path:
|
|
30
|
+
return get_cache_dir() / filename
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def download_eligibility_file(force: bool = False) -> Path:
|
|
34
|
+
filename = "NMTC_LIC_Eligibility_2016_2020.xlsx"
|
|
35
|
+
path = _cache_path(filename)
|
|
36
|
+
if path.exists() and not force:
|
|
37
|
+
print(f"Using cached eligibility file: {path}")
|
|
38
|
+
return path
|
|
39
|
+
print("Downloading NMTC eligibility file from CDFI Fund...")
|
|
40
|
+
try:
|
|
41
|
+
response = requests.get(CDFI_FUND_LIC_URL_2020, stream=True, timeout=120)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
with open(path, "wb") as f:
|
|
44
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
45
|
+
f.write(chunk)
|
|
46
|
+
print(f"Saved to {path}")
|
|
47
|
+
return path
|
|
48
|
+
except Exception as e:
|
|
49
|
+
print(f"Download failed: {e}")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_eligibility_table(force: bool = False) -> pd.DataFrame:
|
|
54
|
+
path = download_eligibility_file(force=force)
|
|
55
|
+
if path is None or not path.exists():
|
|
56
|
+
print("Using built-in sample eligibility data.")
|
|
57
|
+
return _build_sample_table()
|
|
58
|
+
print(f"Loading eligibility table from {path}...")
|
|
59
|
+
try:
|
|
60
|
+
df = pd.read_excel(path, dtype=str)
|
|
61
|
+
return _process_eligibility_table(df)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print(f"Error loading file: {e}. Using sample data.")
|
|
64
|
+
return _build_sample_table()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _process_eligibility_table(df: pd.DataFrame) -> pd.DataFrame:
|
|
68
|
+
df.columns = df.columns.str.strip().str.upper()
|
|
69
|
+
col_map = {k: v for k, v in ELIGIBILITY_FILE_COLUMNS.items() if k in df.columns}
|
|
70
|
+
df = df.rename(columns=col_map)
|
|
71
|
+
if "tract_id" not in df.columns:
|
|
72
|
+
if all(c in df.columns for c in ["state", "county", "tract"]):
|
|
73
|
+
df["tract_id"] = (
|
|
74
|
+
df["state"].str.zfill(2) +
|
|
75
|
+
df["county"].str.zfill(3) +
|
|
76
|
+
df["tract"].str.zfill(6)
|
|
77
|
+
)
|
|
78
|
+
for col in ["poverty_rate", "ami_ratio", "unemployment_rate"]:
|
|
79
|
+
if col in df.columns:
|
|
80
|
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
81
|
+
for col in ["is_non_metro", "is_high_migration_rural"]:
|
|
82
|
+
if col in df.columns:
|
|
83
|
+
df[col] = df[col].isin({"Y", "YES", "1", "True", "TRUE", "X"})
|
|
84
|
+
df = _compute_eligibility(df)
|
|
85
|
+
if "tract_id" in df.columns:
|
|
86
|
+
df = df.set_index("tract_id")
|
|
87
|
+
print(f"Eligibility table loaded: {len(df):,} census tracts")
|
|
88
|
+
return df
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _compute_eligibility(df: pd.DataFrame) -> pd.DataFrame:
|
|
92
|
+
pr = df.get("poverty_rate", pd.Series(dtype=float))
|
|
93
|
+
ami = df.get("ami_ratio", pd.Series(dtype=float))
|
|
94
|
+
unemp = df.get("unemployment_rate", pd.Series(dtype=float))
|
|
95
|
+
non_metro = df.get("is_non_metro", pd.Series(False, index=df.index))
|
|
96
|
+
|
|
97
|
+
poverty_lic = pr >= LIC_POVERTY_RATE_THRESHOLD
|
|
98
|
+
ami_lic = (
|
|
99
|
+
(non_metro & (ami <= LIC_AMI_RATIO_RURAL_THRESHOLD)) |
|
|
100
|
+
(~non_metro & (ami <= LIC_AMI_RATIO_METRO_THRESHOLD))
|
|
101
|
+
)
|
|
102
|
+
df["nmtc_eligible"] = poverty_lic | ami_lic
|
|
103
|
+
|
|
104
|
+
sev_poverty = pr >= SEVERE_POVERTY_THRESHOLD
|
|
105
|
+
sev_ami = ami <= SEVERE_AMI_THRESHOLD
|
|
106
|
+
sev_unemp = unemp >= (NATIONAL_UNEMPLOYMENT_RATE * SEVERE_UNEMPLOYMENT_MULTIPLIER)
|
|
107
|
+
df["severe_distress"] = sev_poverty | sev_ami | sev_unemp
|
|
108
|
+
|
|
109
|
+
deep_poverty = pr >= DEEP_POVERTY_THRESHOLD
|
|
110
|
+
deep_ami = ami <= DEEP_AMI_THRESHOLD
|
|
111
|
+
deep_unemp = unemp >= (NATIONAL_UNEMPLOYMENT_RATE * DEEP_UNEMPLOYMENT_MULTIPLIER)
|
|
112
|
+
df["deep_distress"] = deep_poverty | deep_ami | deep_unemp
|
|
113
|
+
|
|
114
|
+
def distress_label(row):
|
|
115
|
+
if row.get("deep_distress"):
|
|
116
|
+
return "deep"
|
|
117
|
+
elif row.get("severe_distress"):
|
|
118
|
+
return "severe"
|
|
119
|
+
elif row.get("nmtc_eligible"):
|
|
120
|
+
return "lic"
|
|
121
|
+
return "ineligible"
|
|
122
|
+
|
|
123
|
+
df["distress_level"] = df.apply(distress_label, axis=1)
|
|
124
|
+
return df
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _build_sample_table() -> pd.DataFrame:
|
|
128
|
+
sample_tracts = [
|
|
129
|
+
("17031840100", 0.38, 0.55, 0.12, False, False),
|
|
130
|
+
("17031839100", 0.42, 0.48, 0.15, False, False),
|
|
131
|
+
("17031010100", 0.18, 0.92, 0.04, False, False),
|
|
132
|
+
("36061015900", 0.35, 0.60, 0.11, False, False),
|
|
133
|
+
("36061019100", 0.28, 0.72, 0.09, False, False),
|
|
134
|
+
("36047052200", 0.14, 0.88, 0.05, False, False),
|
|
135
|
+
("26163518300", 0.45, 0.45, 0.18, False, False),
|
|
136
|
+
("26163520100", 0.32, 0.62, 0.13, False, False),
|
|
137
|
+
("13121010400", 0.29, 0.68, 0.10, False, False),
|
|
138
|
+
("48113010900", 0.22, 0.78, 0.07, False, False),
|
|
139
|
+
("17019000100", 0.15, 0.95, 0.03, True, True),
|
|
140
|
+
("26001010100", 0.18, 0.88, 0.06, True, False),
|
|
141
|
+
]
|
|
142
|
+
rows = []
|
|
143
|
+
for tid, pr, ami, unemp, non_metro, high_migration in sample_tracts:
|
|
144
|
+
rows.append({
|
|
145
|
+
"tract_id": tid,
|
|
146
|
+
"state": tid[:2],
|
|
147
|
+
"poverty_rate": pr,
|
|
148
|
+
"ami_ratio": ami,
|
|
149
|
+
"unemployment_rate": unemp,
|
|
150
|
+
"is_non_metro": non_metro,
|
|
151
|
+
"is_high_migration_rural": high_migration,
|
|
152
|
+
})
|
|
153
|
+
df = pd.DataFrame(rows)
|
|
154
|
+
df = _compute_eligibility(df)
|
|
155
|
+
df = df.set_index("tract_id")
|
|
156
|
+
return df
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Column mappings, eligibility thresholds, and constants for NMTC eligibility.
|
|
3
|
+
Based on 2016-2020 ACS data β mandatory for QLICIs closed on or after Sept 1, 2024.
|
|
4
|
+
Source: https://www.cdfifund.gov/research-data
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# ββ Eligibility Thresholds ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
8
|
+
|
|
9
|
+
# Low-Income Community (LIC) criteria β Section 45D
|
|
10
|
+
LIC_POVERTY_RATE_THRESHOLD = 0.20 # >= 20% poverty rate
|
|
11
|
+
LIC_AMI_RATIO_METRO_THRESHOLD = 0.80 # <= 80% of metro/state AMI
|
|
12
|
+
LIC_AMI_RATIO_RURAL_THRESHOLD = 0.85 # <= 85% of state AMI (high migration rural)
|
|
13
|
+
|
|
14
|
+
# Severe Distress thresholds
|
|
15
|
+
SEVERE_POVERTY_THRESHOLD = 0.30 # >= 30% poverty rate
|
|
16
|
+
SEVERE_AMI_THRESHOLD = 0.60 # <= 60% of AMI
|
|
17
|
+
SEVERE_UNEMPLOYMENT_MULTIPLIER = 1.5 # >= 1.5x national unemployment rate
|
|
18
|
+
|
|
19
|
+
# Deep Distress thresholds
|
|
20
|
+
DEEP_POVERTY_THRESHOLD = 0.40 # >= 40% poverty rate
|
|
21
|
+
DEEP_AMI_THRESHOLD = 0.50 # <= 50% of AMI
|
|
22
|
+
DEEP_UNEMPLOYMENT_MULTIPLIER = 2.0 # >= 2x national unemployment rate
|
|
23
|
+
|
|
24
|
+
# National unemployment rate benchmark (2016-2020 ACS)
|
|
25
|
+
NATIONAL_UNEMPLOYMENT_RATE = 0.057 # 5.7%
|
|
26
|
+
|
|
27
|
+
# ββ CDFI Fund Eligibility File Column Mappings ββββββββββββββββββββββββββββββββ
|
|
28
|
+
# Source: 2016-2020 ACS Low-Income Community Eligibility file from cdfifund.gov
|
|
29
|
+
|
|
30
|
+
ELIGIBILITY_FILE_COLUMNS = {
|
|
31
|
+
"GEOID": "tract_id",
|
|
32
|
+
"STATE": "state",
|
|
33
|
+
"COUNTY": "county",
|
|
34
|
+
"TRACT": "tract",
|
|
35
|
+
"POVERTY_RATE": "poverty_rate",
|
|
36
|
+
"MFI_RATIO": "ami_ratio",
|
|
37
|
+
"UNEMPLOYMENT_RATE": "unemployment_rate",
|
|
38
|
+
"NON_METRO": "is_non_metro",
|
|
39
|
+
"HIGH_MIGRATION_RURAL": "is_high_migration_rural",
|
|
40
|
+
"LIC_ELIGIBLE": "lic_eligible_raw",
|
|
41
|
+
"SEVERE_DISTRESS": "severe_distress_raw",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# ββ Download URLs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
45
|
+
CDFI_FUND_LIC_URL_2020 = (
|
|
46
|
+
"https://www.cdfifund.gov/sites/cdfi/files/2024-08/"
|
|
47
|
+
"NMTC_LIC_Eligibility_2016_2020_ACS.xlsx"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# ββ Cache βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
51
|
+
import os
|
|
52
|
+
CACHE_DIR = os.path.join(os.path.expanduser("~"), ".nmtcmapper", "cache")
|
|
53
|
+
|
|
54
|
+
# ββ Census Geocoder API βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
55
|
+
CENSUS_GEOCODER_URL = (
|
|
56
|
+
"https://geocoding.geo.census.gov/geocoder/geographies/address"
|
|
57
|
+
)
|
|
58
|
+
CENSUS_GEOCODER_BATCH_URL = (
|
|
59
|
+
"https://geocoding.geo.census.gov/geocoder/geographies/addressbatch"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# ββ Distress Levels βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
63
|
+
DISTRESS_LEVELS = {
|
|
64
|
+
"deep": "Deep Distress β highest need, strongest NMTC application score",
|
|
65
|
+
"severe": "Severe Distress β qualifies for 85% investment commitment",
|
|
66
|
+
"lic": "Low-Income Community β NMTC eligible",
|
|
67
|
+
"ineligible": "Not NMTC eligible",
|
|
68
|
+
}
|
|
File without changes
|