Skip to content

Commit

Permalink
Release 0.2.0 (#15)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
apulverizer committed Sep 13, 2019
1 parent ba36ede commit a6800c8
Show file tree
Hide file tree
Showing 15 changed files with 526 additions and 8 deletions.
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
src-docs/* linguist-vendored
tests/* linguist-vendored
28 changes: 28 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: apulverizer

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
A code sample (attach or link to any necessary data)

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
- OS: [e.g. Windows]
- Version [e.g. 22]
- Python Version [e.g. 3.7]

**Additional context**
Add any other context about the problem here.
20 changes: 20 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ WORKDIR $HOME

# Configure conda env
RUN conda create -n allagash python=3.7 \
&& conda install --name allagash -y geopandas=0.4.1 jupyter=1.0.0 matplotlib=3.1.1 pytest=5.0.1 \
&& 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 \
&& /opt/conda/envs/allagash/bin/pip install pulp==1.6.10 nbval==0.9.2 \
&& /opt/conda/envs/allagash/bin/pip install allagash --no-deps \
&& conda clean -a -f -y
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Allagash [![build status](https://github.com/apulverizer/allagash/workflows/build/badge.svg)](https://github.com/apulverizer/allagash/actions)
A spatial optimization library for covering problems
A spatial optimization library for covering problems. Full documentation is available [here](https://apulverizer.github.io/allagash)

### Running Locally
1. Clone the repo `git clone [email protected]:apulverizer/allagash.git`
Expand Down
3 changes: 3 additions & 0 deletions environment.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
name: allagash
channels:
- defaults
- esri
dependencies:
- arcgis=1.6.2
- shapely=1.6.4
- geopandas=0.4.1
- jupyter=1.0.0
- pip>=19.1.1
Expand Down
3 changes: 2 additions & 1 deletion src-doc/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ Examples
:caption: Examples

examples/LSCP
examples/MCLP
examples/MCLP
examples/Using ArcGIS
224 changes: 224 additions & 0 deletions src-doc/examples/Using ArcGIS.ipynb

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src-doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Allagash |release|
=============================

Allagash can be used to generate and solve spatial optimization problems using `GeoPandas <http://geopandas.org>`_ and `PuLP <https://pythonhosted.org/PuLP/>`_.
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/>`_.

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.

Expand Down
2 changes: 1 addition & 1 deletion src-doc/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ You can launch a Jupyter notebook by running:

.. code-block:: console
docker pull allagash/apulverizer:latest
docker pull apulverizer/allagash:latest
docker run -i -t --user=allagash -p 8888:8888 apulverizer/allagash:latest /bin/bash -c "jupyter notebook --ip='*' --port=8888 --no-browser"
Installing locally
Expand Down
2 changes: 1 addition & 1 deletion src/allagash/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .problem import Problem, UnboundedException, UndefinedException, InfeasibleException, NotSolvedException
from .coverage import Coverage

__version__ = "0.1.0"
__version__ = "0.2.0"
92 changes: 90 additions & 2 deletions src/allagash/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ def __init__(self, dataframe, demand_col=None, demand_name=None, supply_name=Non
"""
An object that stores the relationship between a set of demand locations and a set of supply locations.
Use this initializer if the coverage matrix has already been created, otherwise this can be created from two
geodataframes using the :meth:`~allagash.coverage.Coverage.from_geodataframes` factory method.
geodataframes using the :meth:`~allagash.coverage.Coverage.from_geodataframes` or
:meth:`~allagash.coverage.Coverage.from_spatially_enabled_dataframes` factory methods.
.. code-block:: python
Expand Down Expand Up @@ -115,7 +116,9 @@ def from_geodataframes(cls, demand_df, supply_df, demand_id_col, supply_id_col,
locations. Required if generating partial coverage.
:param str coverage_type: (optional) The type of coverage this represents. If not supplied, the default is
"binary". Options are "binary" and "partial".
:return:
:return: The coverage
:rtype: ~allagash.coverage.Coverage
"""
cls._validate_from_geodataframes(coverage_type, demand_col, demand_df, demand_id_col, demand_name, supply_df,
supply_id_col)
Expand Down Expand Up @@ -147,6 +150,61 @@ def from_geodataframes(cls, demand_df, supply_df, demand_id_col, supply_id_col,
supply_name=supply_name,
coverage_type=coverage_type)

@classmethod
def from_spatially_enabled_dataframes(cls, demand_df, supply_df, demand_id_col, supply_id_col, demand_name=None,
supply_name=None, demand_col=None, coverage_type="binary",
demand_geometry_col='SHAPE', supply_geometry_col='SHAPE'):
"""
Creates a new Coverage from two spatially enabled (arcgis) dataframes representing the demand and supply locations.
The coverage is determined by intersecting the two dataframes.
:param ~pandas.DataFrame demand_df: The spatially enabled dataframe containing the demand locations
:param ~pandas.DataFrame supply_df: The spatially enavled dataframe containing the supply locations
:param str demand_id_col: The name of the column that has unique identifiers for the demand locations
:param str supply_id_col: The name of the column that has unique identifiers for the supply locations
:param str demand_name: (optional) The name of the demand to use. If not supplied, a random name is generated.
:param str supply_name: (optional) The name of the supply to use. If not supplied, a random name is generated.
:param str demand_col: (optional) The name of the column that stores the amount of demand for the demand
locations. Required if generating partial coverage.
:param str coverage_type: (optional) The type of coverage this represents. If not supplied, the default is
"binary". Options are "binary" and "partial".
:param str demand_geometry_col: (optional) The name of the field storing the geometry in the demand dataframe.
If not supplied, the default is "SHAPE".
:param str supply_geometry_col: (optional) The name of the field storing the geometry in the supply dataframe.
If not supplied, the default is "SHAPE".
:return: The coverage
:rtype: ~allagash.coverage.Coverage
"""

cls._validate_from_spatially_enabled_dataframes(coverage_type, demand_col, demand_df, demand_id_col, demand_name, supply_df,
supply_id_col, demand_geometry_col, supply_geometry_col)
data = []
if coverage_type.lower() == 'binary':
for index, row in demand_df.iterrows():
contains = supply_df[supply_geometry_col].geom.contains(row[demand_geometry_col]).tolist()
if demand_col:
contains.insert(0, row[demand_col])
data.append(contains)
elif coverage_type.lower() == 'partial':
for index, row in demand_df.iterrows():
demand_area = row[demand_geometry_col].area
intersection_area = supply_df[supply_geometry_col].geom.intersect(row[demand_geometry_col]).geom.area
partial_coverage = ((intersection_area / demand_area) * row[demand_col]).tolist()
if demand_col:
partial_coverage.insert(0, row[demand_col])
data.append(partial_coverage)
else:
raise ValueError(f"Invalid coverage type '{coverage_type}'")
columns = supply_df[supply_id_col].tolist()
if demand_col:
columns.insert(0, demand_col)
df = pd.DataFrame.from_records(data, index=demand_df[demand_id_col], columns=columns)
return Coverage(df,
demand_col=demand_col,
demand_name=demand_name,
supply_name=supply_name,
coverage_type=coverage_type)

@classmethod
def _validate_from_geodataframes(cls, coverage_type, demand_col, demand_df, demand_id_col, demand_name, supply_df,
supply_id_col):
Expand All @@ -172,3 +230,33 @@ def _validate_from_geodataframes(cls, coverage_type, demand_col, demand_df, dema
raise ValueError(f"Invalid coverage type '{coverage_type}'")
if coverage_type.lower() == "partial" and demand_col is None:
raise ValueError(f"demand_col is required when generating partial coverage")

@classmethod
def _validate_from_spatially_enabled_dataframes(cls, coverage_type, demand_col, demand_df, demand_id_col, demand_name, supply_df,
supply_id_col, demand_geometry_col, supply_geometry_col):
if not isinstance(demand_df, pd.DataFrame):
raise TypeError(f"Expected 'Dataframe' type for demand_df, got '{type(demand_df)}'")
if not isinstance(supply_df, pd.DataFrame):
raise TypeError(f"Expected 'Dataframe' type for supply_df, got '{type(supply_df)}'")
if not isinstance(demand_id_col, str):
raise TypeError(f"Expected 'str' type for demand_id_col, got '{type(demand_id_col)}'")
if not isinstance(supply_id_col, str):
raise TypeError(f"Expected 'str' type for demand_id_col, got '{type(supply_id_col)}'")
if not isinstance(demand_name, str) and demand_name is not None:
raise TypeError(f"Expected 'str' type for demand_name, got '{type(demand_name)}'")
if not isinstance(coverage_type, str):
raise TypeError(f"Expected 'str' type for coverage_type, got '{type(coverage_type)}'")
if demand_col and demand_col not in demand_df.columns:
raise ValueError(f"'{demand_col}' not in dataframe")
if demand_id_col and demand_id_col not in demand_df.columns:
raise ValueError(f"'{demand_id_col}' not in dataframe")
if supply_id_col and supply_id_col not in supply_df.columns:
raise ValueError(f"'{supply_id_col}' not in dataframe")
if demand_geometry_col and demand_geometry_col not in demand_df.columns:
raise ValueError(f"'{demand_geometry_col}' not in dataframe")
if supply_geometry_col and supply_geometry_col not in supply_df.columns:
raise ValueError(f"'{supply_geometry_col}' not in dataframe")
if coverage_type.lower() not in ("binary", "partial"):
raise ValueError(f"Invalid coverage type '{coverage_type}'")
if coverage_type.lower() == "partial" and demand_col is None:
raise ValueError(f"demand_col is required when generating partial coverage")
30 changes: 30 additions & 0 deletions tests/acceptance/test_lscp.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import math
import os
import arcgis
import geopandas
import pulp
import pytest
Expand All @@ -20,6 +21,16 @@ def test_single_supply(self):
with pytest.raises((InfeasibleException, UndefinedException)) as e:
problem.solve(pulp.GLPK())

def test_single_supply_arcgis(self):
demand_id_col = "GEOID10"
supply_id_col = "ORIG_ID"
d = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/demand_point.shp"))
s = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/facility_service_areas.shp"))
coverage = Coverage.from_spatially_enabled_dataframes(d, s, demand_id_col, supply_id_col)
problem = Problem.lscp(coverage)
with pytest.raises((InfeasibleException, UndefinedException)) as e:
problem.solve(pulp.GLPK())

def test_multiple_supply(self):
demand_col = "Population"
demand_id_col = "GEOID10"
Expand All @@ -38,3 +49,22 @@ def test_multiple_supply(self):
assert(len(selected_locations) >= 5)
assert(len(selected_locations2) >= 17)
assert(coverage == 100)

def test_multiple_supply_arcgis(self):
demand_col = "Population"
demand_id_col = "GEOID10"
supply_id_col = "ORIG_ID"
d = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/demand_point.shp"))
s = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/facility_service_areas.shp"))
s2 = arcgis.GeoAccessor.from_featureclass(os.path.join(self.dir_name, "../test_data/facility2_service_areas.shp"))
coverage = Coverage.from_spatially_enabled_dataframes(d, s, demand_id_col, supply_id_col, demand_col=demand_col)
coverage2 = Coverage.from_spatially_enabled_dataframes(d, s2, demand_id_col, supply_id_col, demand_name=coverage.demand_name, demand_col=demand_col)
problem = Problem.lscp([coverage, coverage2])
problem.solve(pulp.GLPK())
selected_locations = problem.selected_supply(coverage)
selected_locations2 = problem.selected_supply(coverage2)
covered_demand = d.query(f"{demand_id_col} in ({[f'{i}' for i in problem.selected_demand(coverage)]})")
coverage = math.ceil((covered_demand[demand_col].sum() / d[demand_col].sum()) * 100)
assert(len(selected_locations) >= 5)
assert(len(selected_locations2) >= 17)
assert(coverage == 100)
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import arcgis
import geopandas
from pulp.solvers import GLPK
import pytest
Expand All @@ -13,21 +14,41 @@ def demand_points_dataframe():
return geopandas.read_file(os.path.join(dir_name, "test_data/demand_point.shp"))


@pytest.fixture(scope='class')
def demand_points_sedf():
return arcgis.GeoAccessor.from_featureclass(os.path.join(dir_name, "test_data/demand_point.shp"))


@pytest.fixture(scope='class')
def demand_polygon_dataframe():
return geopandas.read_file(os.path.join(dir_name, "test_data/demand_polygon.shp"))


@pytest.fixture(scope='class')
def demand_polygon_sedf():
return arcgis.GeoAccessor.from_featureclass(os.path.join(dir_name, "test_data/demand_polygon.shp"))


@pytest.fixture(scope='class')
def facility_service_areas_dataframe():
return geopandas.read_file(os.path.join(dir_name, "test_data/facility_service_areas.shp"))


@pytest.fixture(scope='class')
def facility_service_areas_sedf():
return arcgis.GeoAccessor.from_featureclass(os.path.join(dir_name, "test_data/facility_service_areas.shp"))


@pytest.fixture(scope='class')
def facility2_service_areas_dataframe():
return geopandas.read_file(os.path.join(dir_name, "test_data/facility2_service_areas.shp"))


@pytest.fixture(scope='class')
def facility2_service_areas_sedf():
return arcgis.GeoAccessor.from_featureclass(os.path.join(dir_name, "test_data/facility2_service_areas.shp"))


@pytest.fixture(scope="class")
def binary_coverage(demand_points_dataframe, facility_service_areas_dataframe):
return Coverage.from_geodataframes(demand_points_dataframe, facility_service_areas_dataframe, "GEOID10", "ORIG_ID",
Expand Down
Loading

0 comments on commit a6800c8

Please sign in to comment.