Skip to content

Commit a6800c8

Browse files
authored
Release 0.2.0 (#15)
* doc fix * Update issue templates * link to reference doc * add .gitattributes files * add support for using spatially enabled dataframes (arcgis) * add notebook * update notebook * updates * bump version
1 parent ba36ede commit a6800c8

15 files changed

+526
-8
lines changed

.gitattributes

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
src-docs/* linguist-vendored
2+
tests/* linguist-vendored

.github/ISSUE_TEMPLATE/bug_report.md

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
name: Bug report
3+
about: Create a report to help us improve
4+
title: ''
5+
labels: bug
6+
assignees: apulverizer
7+
8+
---
9+
10+
**Describe the bug**
11+
A clear and concise description of what the bug is.
12+
13+
**To Reproduce**
14+
A code sample (attach or link to any necessary data)
15+
16+
**Expected behavior**
17+
A clear and concise description of what you expected to happen.
18+
19+
**Screenshots**
20+
If applicable, add screenshots to help explain your problem.
21+
22+
**Desktop (please complete the following information):**
23+
- OS: [e.g. Windows]
24+
- Version [e.g. 22]
25+
- Python Version [e.g. 3.7]
26+
27+
**Additional context**
28+
Add any other context about the problem here.
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
name: Feature request
3+
about: Suggest an idea for this project
4+
title: ''
5+
labels: enhancement
6+
assignees: ''
7+
8+
---
9+
10+
**Is your feature request related to a problem? Please describe.**
11+
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12+
13+
**Describe the solution you'd like**
14+
A clear and concise description of what you want to happen.
15+
16+
**Describe alternatives you've considered**
17+
A clear and concise description of any alternative solutions or features you've considered.
18+
19+
**Additional context**
20+
Add any other context or screenshots about the feature request here.

Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ WORKDIR $HOME
2626

2727
# Configure conda env
2828
RUN conda create -n allagash python=3.7 \
29-
&& conda install --name allagash -y geopandas=0.4.1 jupyter=1.0.0 matplotlib=3.1.1 pytest=5.0.1 \
29+
&& conda install -c esri --name allagash -y geopandas=0.4.1 jupyter=1.0.0 matplotlib=3.1.1 pytest=5.0.1 arcgis=1.6.2 shapely=1.6.4 \
3030
&& /opt/conda/envs/allagash/bin/pip install pulp==1.6.10 nbval==0.9.2 \
3131
&& /opt/conda/envs/allagash/bin/pip install allagash --no-deps \
3232
&& conda clean -a -f -y

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Allagash [![build status](https://github.com/apulverizer/allagash/workflows/build/badge.svg)](https://github.com/apulverizer/allagash/actions)
2-
A spatial optimization library for covering problems
2+
A spatial optimization library for covering problems. Full documentation is available [here](https://apulverizer.github.io/allagash)
33

44
### Running Locally
55
1. Clone the repo `git clone [email protected]:apulverizer/allagash.git`

environment.yml

+3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
name: allagash
22
channels:
33
- defaults
4+
- esri
45
dependencies:
6+
- arcgis=1.6.2
7+
- shapely=1.6.4
58
- geopandas=0.4.1
69
- jupyter=1.0.0
710
- pip>=19.1.1

src-doc/examples.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ Examples
66
:caption: Examples
77

88
examples/LSCP
9-
examples/MCLP
9+
examples/MCLP
10+
examples/Using ArcGIS

src-doc/examples/Using ArcGIS.ipynb

+224
Large diffs are not rendered by default.

src-doc/index.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
Allagash |release|
77
=============================
88

9-
Allagash can be used to generate and solve spatial optimization problems using `GeoPandas <http://geopandas.org>`_ and `PuLP <https://pythonhosted.org/PuLP/>`_.
9+
Allagash can be used to generate and solve spatial optimization problems using `GeoPandas <http://geopandas.org>`_ or the `ArcGIS API for Python <https://developers.arcgis.com/python/>`_ (if installed) in conjunction with `PuLP <https://pythonhosted.org/PuLP/>`_.
1010

1111
The focus is on coverage problems though other optimization models may be added over time. Coverage modeling is generally used to find the best spatial configuration of a set of facilities that provide some level of service to units of demand. It is often necessary to “cover” demand within a prescribed time or distance.
1212

src-doc/installation.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ You can launch a Jupyter notebook by running:
1818

1919
.. code-block:: console
2020
21-
docker pull allagash/apulverizer:latest
21+
docker pull apulverizer/allagash:latest
2222
docker run -i -t --user=allagash -p 8888:8888 apulverizer/allagash:latest /bin/bash -c "jupyter notebook --ip='*' --port=8888 --no-browser"
2323
2424
Installing locally

src/allagash/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .problem import Problem, UnboundedException, UndefinedException, InfeasibleException, NotSolvedException
22
from .coverage import Coverage
33

4-
__version__ = "0.1.0"
4+
__version__ = "0.2.0"

src/allagash/coverage.py

+90-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ def __init__(self, dataframe, demand_col=None, demand_name=None, supply_name=Non
88
"""
99
An object that stores the relationship between a set of demand locations and a set of supply locations.
1010
Use this initializer if the coverage matrix has already been created, otherwise this can be created from two
11-
geodataframes using the :meth:`~allagash.coverage.Coverage.from_geodataframes` factory method.
11+
geodataframes using the :meth:`~allagash.coverage.Coverage.from_geodataframes` or
12+
:meth:`~allagash.coverage.Coverage.from_spatially_enabled_dataframes` factory methods.
1213
1314
.. code-block:: python
1415
@@ -115,7 +116,9 @@ def from_geodataframes(cls, demand_df, supply_df, demand_id_col, supply_id_col,
115116
locations. Required if generating partial coverage.
116117
:param str coverage_type: (optional) The type of coverage this represents. If not supplied, the default is
117118
"binary". Options are "binary" and "partial".
118-
:return:
119+
120+
:return: The coverage
121+
:rtype: ~allagash.coverage.Coverage
119122
"""
120123
cls._validate_from_geodataframes(coverage_type, demand_col, demand_df, demand_id_col, demand_name, supply_df,
121124
supply_id_col)
@@ -147,6 +150,61 @@ def from_geodataframes(cls, demand_df, supply_df, demand_id_col, supply_id_col,
147150
supply_name=supply_name,
148151
coverage_type=coverage_type)
149152

153+
@classmethod
154+
def from_spatially_enabled_dataframes(cls, demand_df, supply_df, demand_id_col, supply_id_col, demand_name=None,
155+
supply_name=None, demand_col=None, coverage_type="binary",
156+
demand_geometry_col='SHAPE', supply_geometry_col='SHAPE'):
157+
"""
158+
Creates a new Coverage from two spatially enabled (arcgis) dataframes representing the demand and supply locations.
159+
The coverage is determined by intersecting the two dataframes.
160+
161+
:param ~pandas.DataFrame demand_df: The spatially enabled dataframe containing the demand locations
162+
:param ~pandas.DataFrame supply_df: The spatially enavled dataframe containing the supply locations
163+
:param str demand_id_col: The name of the column that has unique identifiers for the demand locations
164+
:param str supply_id_col: The name of the column that has unique identifiers for the supply locations
165+
:param str demand_name: (optional) The name of the demand to use. If not supplied, a random name is generated.
166+
:param str supply_name: (optional) The name of the supply to use. If not supplied, a random name is generated.
167+
:param str demand_col: (optional) The name of the column that stores the amount of demand for the demand
168+
locations. Required if generating partial coverage.
169+
:param str coverage_type: (optional) The type of coverage this represents. If not supplied, the default is
170+
"binary". Options are "binary" and "partial".
171+
:param str demand_geometry_col: (optional) The name of the field storing the geometry in the demand dataframe.
172+
If not supplied, the default is "SHAPE".
173+
:param str supply_geometry_col: (optional) The name of the field storing the geometry in the supply dataframe.
174+
If not supplied, the default is "SHAPE".
175+
:return: The coverage
176+
:rtype: ~allagash.coverage.Coverage
177+
"""
178+
179+
cls._validate_from_spatially_enabled_dataframes(coverage_type, demand_col, demand_df, demand_id_col, demand_name, supply_df,
180+
supply_id_col, demand_geometry_col, supply_geometry_col)
181+
data = []
182+
if coverage_type.lower() == 'binary':
183+
for index, row in demand_df.iterrows():
184+
contains = supply_df[supply_geometry_col].geom.contains(row[demand_geometry_col]).tolist()
185+
if demand_col:
186+
contains.insert(0, row[demand_col])
187+
data.append(contains)
188+
elif coverage_type.lower() == 'partial':
189+
for index, row in demand_df.iterrows():
190+
demand_area = row[demand_geometry_col].area
191+
intersection_area = supply_df[supply_geometry_col].geom.intersect(row[demand_geometry_col]).geom.area
192+
partial_coverage = ((intersection_area / demand_area) * row[demand_col]).tolist()
193+
if demand_col:
194+
partial_coverage.insert(0, row[demand_col])
195+
data.append(partial_coverage)
196+
else:
197+
raise ValueError(f"Invalid coverage type '{coverage_type}'")
198+
columns = supply_df[supply_id_col].tolist()
199+
if demand_col:
200+
columns.insert(0, demand_col)
201+
df = pd.DataFrame.from_records(data, index=demand_df[demand_id_col], columns=columns)
202+
return Coverage(df,
203+
demand_col=demand_col,
204+
demand_name=demand_name,
205+
supply_name=supply_name,
206+
coverage_type=coverage_type)
207+
150208
@classmethod
151209
def _validate_from_geodataframes(cls, coverage_type, demand_col, demand_df, demand_id_col, demand_name, supply_df,
152210
supply_id_col):
@@ -172,3 +230,33 @@ def _validate_from_geodataframes(cls, coverage_type, demand_col, demand_df, dema
172230
raise ValueError(f"Invalid coverage type '{coverage_type}'")
173231
if coverage_type.lower() == "partial" and demand_col is None:
174232
raise ValueError(f"demand_col is required when generating partial coverage")
233+
234+
@classmethod
235+
def _validate_from_spatially_enabled_dataframes(cls, coverage_type, demand_col, demand_df, demand_id_col, demand_name, supply_df,
236+
supply_id_col, demand_geometry_col, supply_geometry_col):
237+
if not isinstance(demand_df, pd.DataFrame):
238+
raise TypeError(f"Expected 'Dataframe' type for demand_df, got '{type(demand_df)}'")
239+
if not isinstance(supply_df, pd.DataFrame):
240+
raise TypeError(f"Expected 'Dataframe' type for supply_df, got '{type(supply_df)}'")
241+
if not isinstance(demand_id_col, str):
242+
raise TypeError(f"Expected 'str' type for demand_id_col, got '{type(demand_id_col)}'")
243+
if not isinstance(supply_id_col, str):
244+
raise TypeError(f"Expected 'str' type for demand_id_col, got '{type(supply_id_col)}'")
245+
if not isinstance(demand_name, str) and demand_name is not None:
246+
raise TypeError(f"Expected 'str' type for demand_name, got '{type(demand_name)}'")
247+
if not isinstance(coverage_type, str):
248+
raise TypeError(f"Expected 'str' type for coverage_type, got '{type(coverage_type)}'")
249+
if demand_col and demand_col not in demand_df.columns:
250+
raise ValueError(f"'{demand_col}' not in dataframe")
251+
if demand_id_col and demand_id_col not in demand_df.columns:
252+
raise ValueError(f"'{demand_id_col}' not in dataframe")
253+
if supply_id_col and supply_id_col not in supply_df.columns:
254+
raise ValueError(f"'{supply_id_col}' not in dataframe")
255+
if demand_geometry_col and demand_geometry_col not in demand_df.columns:
256+
raise ValueError(f"'{demand_geometry_col}' not in dataframe")
257+
if supply_geometry_col and supply_geometry_col not in supply_df.columns:
258+
raise ValueError(f"'{supply_geometry_col}' not in dataframe")
259+
if coverage_type.lower() not in ("binary", "partial"):
260+
raise ValueError(f"Invalid coverage type '{coverage_type}'")
261+
if coverage_type.lower() == "partial" and demand_col is None:
262+
raise ValueError(f"demand_col is required when generating partial coverage")

tests/acceptance/test_lscp.py

+30
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import math
22
import os
3+
import arcgis
34
import geopandas
45
import pulp
56
import pytest
@@ -20,6 +21,16 @@ def test_single_supply(self):
2021
with pytest.raises((InfeasibleException, UndefinedException)) as e:
2122
problem.solve(pulp.GLPK())
2223

24+
def test_single_supply_arcgis(self):
25+
demand_id_col = "GEOID10"
26+
supply_id_col = "ORIG_ID"
27+
d = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/demand_point.shp"))
28+
s = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/facility_service_areas.shp"))
29+
coverage = Coverage.from_spatially_enabled_dataframes(d, s, demand_id_col, supply_id_col)
30+
problem = Problem.lscp(coverage)
31+
with pytest.raises((InfeasibleException, UndefinedException)) as e:
32+
problem.solve(pulp.GLPK())
33+
2334
def test_multiple_supply(self):
2435
demand_col = "Population"
2536
demand_id_col = "GEOID10"
@@ -38,3 +49,22 @@ def test_multiple_supply(self):
3849
assert(len(selected_locations) >= 5)
3950
assert(len(selected_locations2) >= 17)
4051
assert(coverage == 100)
52+
53+
def test_multiple_supply_arcgis(self):
54+
demand_col = "Population"
55+
demand_id_col = "GEOID10"
56+
supply_id_col = "ORIG_ID"
57+
d = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/demand_point.shp"))
58+
s = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/facility_service_areas.shp"))
59+
s2 = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/facility2_service_areas.shp"))
60+
coverage = Coverage.from_spatially_enabled_dataframes(d, s, demand_id_col, supply_id_col, demand_col=demand_col)
61+
coverage2 = Coverage.from_spatially_enabled_dataframes(d, s2, demand_id_col, supply_id_col, demand_name=coverage.demand_name, demand_col=demand_col)
62+
problem = Problem.lscp([coverage, coverage2])
63+
problem.solve(pulp.GLPK())
64+
selected_locations = problem.selected_supply(coverage)
65+
selected_locations2 = problem.selected_supply(coverage2)
66+
covered_demand = d.query(f"{demand_id_col} in ({[f'{i}' for i in problem.selected_demand(coverage)]})")
67+
coverage = math.ceil((covered_demand[demand_col].sum() / d[demand_col].sum()) * 100)
68+
assert(len(selected_locations) >= 5)
69+
assert(len(selected_locations2) >= 17)
70+
assert(coverage == 100)

tests/conftest.py

+21
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import arcgis
23
import geopandas
34
from pulp.solvers import GLPK
45
import pytest
@@ -13,21 +14,41 @@ def demand_points_dataframe():
1314
return geopandas.read_file(os.path.join(dir_name, "test_data/demand_point.shp"))
1415

1516

17+
@pytest.fixture(scope='class')
18+
def demand_points_sedf():
19+
return arcgis.GeoAccessor.from_featureclass(os.path.join(dir_name, "test_data/demand_point.shp"))
20+
21+
1622
@pytest.fixture(scope='class')
1723
def demand_polygon_dataframe():
1824
return geopandas.read_file(os.path.join(dir_name, "test_data/demand_polygon.shp"))
1925

2026

27+
@pytest.fixture(scope='class')
28+
def demand_polygon_sedf():
29+
return arcgis.GeoAccessor.from_featureclass(os.path.join(dir_name, "test_data/demand_polygon.shp"))
30+
31+
2132
@pytest.fixture(scope='class')
2233
def facility_service_areas_dataframe():
2334
return geopandas.read_file(os.path.join(dir_name, "test_data/facility_service_areas.shp"))
2435

2536

37+
@pytest.fixture(scope='class')
38+
def facility_service_areas_sedf():
39+
return arcgis.GeoAccessor.from_featureclass(os.path.join(dir_name, "test_data/facility_service_areas.shp"))
40+
41+
2642
@pytest.fixture(scope='class')
2743
def facility2_service_areas_dataframe():
2844
return geopandas.read_file(os.path.join(dir_name, "test_data/facility2_service_areas.shp"))
2945

3046

47+
@pytest.fixture(scope='class')
48+
def facility2_service_areas_sedf():
49+
return arcgis.GeoAccessor.from_featureclass(os.path.join(dir_name, "test_data/facility2_service_areas.shp"))
50+
51+
3152
@pytest.fixture(scope="class")
3253
def binary_coverage(demand_points_dataframe, facility_service_areas_dataframe):
3354
return Coverage.from_geodataframes(demand_points_dataframe, facility_service_areas_dataframe, "GEOID10", "ORIG_ID",

0 commit comments

Comments
 (0)