{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "---------------\n", "\n", "**If any part of this notebook is used in your research, please cite with the reference found in** **[README.md](https://github.com/pysal/spaghetti#bibtex-citation).**\n", "\n", "----------------\n", "\n", "## The Transportation Problem\n", "### Integrating pysal/spaghetti and [python-mip](https://github.com/coin-or/python-mip) for optimal shipping\n", "\n", "**Author: James D. Gaboardi** ****\n", "\n", "**This notebook provides a use case for:**\n", "\n", "1. Introducing the Transportation Problem\n", "2. Declaration of a solution class and model parameters\n", "3. Solving the Transportation Problem for an optimal shipment plan" ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:18.847312Z", "start_time": "2021-06-28T23:35:18.837892Z" } }, "outputs": [], "source": [ "%config InlineBackend.figure_format = \"retina\"" ] }, { "cell_type": "code", "execution_count": 2, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:18.887277Z", "start_time": "2021-06-28T23:35:18.850530Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Last updated: 2021-06-28T19:35:18.868892-04:00\n", "\n", "Python implementation: CPython\n", "Python version : 3.9.4\n", "IPython version : 7.24.1\n", "\n", "Compiler : Clang 11.1.0 \n", "OS : Darwin\n", "Release : 20.5.0\n", "Machine : x86_64\n", "Processor : i386\n", "CPU cores : 8\n", "Architecture: 64bit\n", "\n" ] } ], "source": [ "%load_ext watermark\n", "%watermark" ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.492082Z", "start_time": "2021-06-28T23:35:18.890311Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Watermark: 2.2.0\n", "\n", "geopandas : 0.9.0\n", "matplotlib : 3.4.2\n", "numpy : 1.20.3\n", "matplotlib_scalebar: 0.7.2\n", "libpysal : 4.4.0\n", "mip : 1.13.0\n", "spaghetti : 1.6.2\n", "json : 2.0.9\n", "\n" ] } ], "source": [ "import geopandas\n", "from libpysal import examples\n", "import matplotlib\n", "import mip\n", "import numpy\n", "import os\n", "import spaghetti\n", "import matplotlib_scalebar\n", "from matplotlib_scalebar.scalebar import ScaleBar\n", "\n", "%matplotlib inline\n", "%watermark -w\n", "%watermark -iv" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "-----------------------------\n", "\n", "### 1 Introduction\n", "#### Scenario\n", "\n", "There are **8** schools in Neighborhood Y of City X and a total of **100** microscopes for the biology classes at the **8** schools, though the microscopes are not evenly distributed across the locations. Since last academic year there has been a significant enrollment shift in the neighborhood, and at **4** of the schools there is a surplus whereas the remaining **4** schools require additional microscopes. Dr. Rachel Carson, the head of the biology department at City X's School Board decides to utilize a mathematical programming model to solve the microscope discrepency. After consideration, she selects the Transportation Problem.\n", "\n", "\n", "The Transportation Problem seeks to allocate supply to demand while minimizing transportation costs and was formally described by Hitchcock (1941). Supply ($\\textit{n}$) and demand ($\\textit{m}$) are generally represented as unit weights of decision variables at facilities along a network with the time or distance between nodes representing the cost of transporting one unit from a supply node to a demand node. These costs are stored in an $\\textit{n x m}$ cost matrix.\n", "\n", "--------------------------------\n", "\n", "#### Integer Linear Programming Formulation based on Daskin (2013, Ch. 2).\n", "\n", "$\\begin{array}\n", "\\displaystyle \\normalsize \\textrm{Minimize} & \\displaystyle \\normalsize \\sum_{i \\in I} \\sum_{j \\in J} c_{ij}x_{ij} & & & & \\normalsize (1) \\\\\n", "\\normalsize \\textrm{Subject To} & \\displaystyle \\normalsize \\sum_{j \\in J} x_{ij} \\leq S_i & \\normalsize \\forall i \\in I; & & &\\normalsize (2)\\\\\n", " & \\displaystyle \\normalsize \\sum_{i \\in I} x_{ij} \\geq D_j & \\normalsize \\forall j \\in J; & & &\\normalsize (3)\\\\\n", "& \\displaystyle \\normalsize x_{ij} \\geq 0 & \\displaystyle \\normalsize \\forall i \\in I & \\displaystyle \\normalsize \\normalsize \\forall j \\in j. & &\\normalsize (4)\\\\\n", "\\end{array}$\n", "\n", "$\\begin{array}\n", "\\displaystyle \\normalsize \\textrm{Where} & \\small i & \\small = & \\small \\textrm{each potential origin node} &&&&\\\\\n", "& \\small I & \\small = & \\small \\textrm{the complete set of potential origin nodes} &&&&\\\\\n", "& \\small j & \\small = & \\small \\textrm{each potential destination node} &&&&\\\\\n", "& \\small J & \\small = & \\small \\textrm{the complete set of potential destination nodes} &&&&\\\\\n", "& \\small x_{ij} & \\small = & \\small \\textrm{amount to be shipped from } i \\in I \\textrm{ to } j \\in J &&&&\\\\\n", "& \\small c_{ij} & \\small = & \\small \\textrm{per unit shipping costs between all } i,j \\textrm{ pairs} &&&& \\\\\n", "& \\small S_i & \\small = & \\small \\textrm{node } i \\textrm{ supply for } i \\in I &&&&\\\\\n", "& \\small D_j & \\small = & \\small \\textrm{node } j \\textrm{ demand for } j \\in J &&&&\\\\\n", "\\end{array}$\n", "\n", "\n", "---------------------------------\n", "\n", "**References**\n", "\n", "* **Church, Richard L. and Murray, Alan T.** (2009) *Business Site Selection, Locational Analysis, and GIS*. Hoboken. John Wiley & Sons, Inc.\n", "\n", "* **Daskin, M.** (2013) *Network and Discrete Location: Models, Algorithms, and Applications*. New York: John Wiley & Sons, Inc.\n", "\n", "* **Gass, S. I. and Assad, A. A.** (2005) *An Annotated Timeline of Operations Research: An Informal History*. Springer US.\n", "\n", "* **Hitchcock, Frank L.** (1941) *The Distribution of a Product from Several Sources to Numerous Localities*. Journal of Mathematics and Physics. 20(1):224-230.\n", "\n", "* **Koopmans, Tjalling C.** (1949) *Optimum Utilization of the Transportation System*. Econometrica. 17:136-146.\n", "\n", "* **Miller, H. J. and Shaw, S.-L.** (2001) *Geographic Information Systems for Transportation: Principles and Applications*. New York. Oxford University Press.\n", "\n", "\n", "* **Phillips, Don T. and Garcia‐Diaz, Alberto.** (1981) *Fundamentals of Network Analysis*. Englewood Cliffs. Prentice Hall. \n", "\n", "-------------------------------------\n", "\n", "### 2. A model, data, and parameters\n", "#### Schools labeled as either 'supply' or 'demand' locations" ] }, { "cell_type": "code", "execution_count": 4, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.496890Z", "start_time": "2021-06-28T23:35:20.494305Z" } }, "outputs": [], "source": [ "supply_schools = [1, 6, 7, 8]\n", "demand_schools = [2, 3, 4, 5]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Amount of supply and demand at each location (indexed by supply_schools and demand_schools)" ] }, { "cell_type": "code", "execution_count": 5, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.501033Z", "start_time": "2021-06-28T23:35:20.498814Z" } }, "outputs": [], "source": [ "amount_supply = [20, 30, 15, 35]\n", "amount_demand = [5, 45, 10, 40]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Solution class" ] }, { "cell_type": "code", "execution_count": 6, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.562718Z", "start_time": "2021-06-28T23:35:20.504508Z" } }, "outputs": [], "source": [ "class TransportationProblem:\n", " def __init__(\n", " self,\n", " supply_nodes,\n", " demand_nodes,\n", " cij,\n", " si,\n", " dj,\n", " xij_tag=\"x_%s,%s\",\n", " supply_constr_tag=\"supply(%s)\",\n", " demand_constr_tag=\"demand(%s)\",\n", " solver=\"cbc\",\n", " display=True,\n", " ):\n", " \"\"\"Instantiate and solve the Primal Transportation Problem\n", " based the formulation from Daskin (2013, Ch. 2).\n", " \n", " Parameters\n", " ----------\n", " supply_nodes : geopandas.GeoSeries\n", " Supply node decision variables.\n", " demand_nodes : geopandas.GeoSeries\n", " Demand node decision variables.\n", " cij : numpy.array\n", " Supply-to-demand distance matrix for nodes.\n", " si : geopandas.GeoSeries\n", " Amount that can be supplied by each supply node.\n", " dj : geopandas.GeoSeries\n", " Amount that can be received by each demand node.\n", " xij_tag : str\n", " Shipping decision variable names within the model. Default is\n", " 'x_%s,%s' where %s indicates string formatting.\n", " supply_constr_tag : str\n", " Supply constraint labels. Default is 'supply(%s)'.\n", " demand_constr_tag : str\n", " Demand constraint labels. Default is 'demand(%s)'.\n", " solver : str\n", " Default is 'cbc' (coin-branch-cut). Can be set\n", " to 'gurobi' (if Gurobi is installed).\n", " display : bool\n", " Print out solution results.\n", " \n", " Attributes\n", " ----------\n", " supply_nodes : See description in above. \n", " demand_nodes : See description in above.\n", " cij : See description in above.\n", " si : See description in above.\n", " dj : See description in above.\n", " xij_tag : See description in above.\n", " supply_constr_tag : See description in above.\n", " demand_constr_tag : See description in above.\n", " rows : int\n", " The number of supply nodes.\n", " rrows : range\n", " The index of supply nodes.\n", " cols : int\n", " The number of demand nodes.\n", " rcols : range\n", " The index of demand nodes.\n", " model : mip.model.Model\n", " Integer Linear Programming problem instance.\n", " xij : numpy.array\n", " Shipping decision variables (mip.entities.Var).\n", " \"\"\"\n", "\n", " # all nodes to be visited\n", " self.supply_nodes, self.demand_nodes = supply_nodes, demand_nodes\n", " # shipping costs (distance matrix) and amounts\n", " self.cij, self.si, self.dj = cij, si.values, dj.values\n", " self.ensure_float()\n", " # alpha tag for decision variables\n", " self.xij_tag = xij_tag\n", " # alpha tag for supply and demand constraints\n", " self.supply_constr_tag = supply_constr_tag\n", " self.demand_constr_tag = demand_constr_tag\n", " \n", " # instantiate a model\n", " self.model = mip.Model(\" TransportationProblem\", solver_name=solver)\n", " # define row and column indices\n", " self.rows, self.cols = self.si.shape[0], self.dj.shape[0]\n", " self.rrows, self.rcols = range(self.rows), range(self.cols)\n", " # create and set the decision variables\n", " self.shipping_dvs()\n", " # set the objective function\n", " self.objective_func()\n", " # add supply constraints\n", " self.add_supply_constrs()\n", " # add demand constraints\n", " self.add_demand_constrs()\n", " # solve\n", " self.solve(display=display)\n", " # shipping decisions lookup\n", " self.get_decisions(display=display)\n", "\n", " def ensure_float(self):\n", " \"\"\"Convert integers to floats (rough edge in mip.LinExpr)\"\"\"\n", " self.cij = self.cij.astype(float)\n", " self.si = self.si.astype(float)\n", " self.dj = self.dj.astype(float)\n", "\n", " def shipping_dvs(self):\n", " \"\"\"Create the shipping decision variables - eq (4).\"\"\"\n", "\n", " def _s(_x):\n", " \"\"\"Helper for naming variables\"\"\"\n", " return self.supply_nodes[_x].split(\"_\")[-1]\n", "\n", " def _d(_x):\n", " \"\"\"Helper for naming variables\"\"\"\n", " return self.demand_nodes[_x].split(\"_\")[-1]\n", "\n", " xij = numpy.array(\n", " [\n", " [self.model.add_var(self.xij_tag % (_s(i), _d(j))) for j in self.rcols]\n", " for i in self.rrows\n", " ]\n", " )\n", " self.xij = xij\n", "\n", " def objective_func(self):\n", " \"\"\"Add the objective function - eq (1).\"\"\"\n", " self.model.objective = mip.minimize(\n", " mip.xsum(\n", " self.cij[i, j] * self.xij[i, j] for i in self.rrows for j in self.rcols\n", " )\n", " )\n", "\n", " def add_supply_constrs(self):\n", " \"\"\"Add supply contraints to the model - eq (2).\"\"\"\n", " for i in self.rrows:\n", " rhs, label = self.si[i], self.supply_constr_tag % i\n", " self.model += mip.xsum(self.xij[i, j] for j in self.rcols) <= rhs, label\n", "\n", " def add_demand_constrs(self):\n", " \"\"\"Add demand contraints to the model - eq (3).\"\"\"\n", " for j in self.rcols:\n", " rhs, label = self.dj[j], self.demand_constr_tag % j\n", " self.model += mip.xsum(self.xij[i, j] for i in self.rrows) >= rhs, label\n", "\n", " def solve(self, display=True):\n", " \"\"\"Solve the model\"\"\"\n", " self.model.optimize()\n", " if display:\n", " obj = round(self.model.objective_value, 4)\n", " print(\"Minimized shipping costs: %s\" % obj)\n", "\n", " def get_decisions(self, display=True):\n", " \"\"\"Fetch the selected decision variables.\"\"\"\n", " shipping_decisions = {}\n", " if display:\n", " print(\"\\nShipping decisions:\")\n", " for i in self.rrows:\n", " for j in self.rcols:\n", " v, vx = self.xij[i, j], self.xij[i, j].x\n", " if vx > 0:\n", " if display:\n", " print(\"\\t\", v, vx)\n", " shipping_decisions[v.name] = vx\n", " self.shipping_decisions = shipping_decisions\n", "\n", " def print_lp(self, name=None):\n", " \"\"\"Save LP file in order to read in and print.\"\"\"\n", " if not name:\n", " name = self.model.name\n", " lp_file_name = \"%s.lp\" % name\n", " self.model.write(lp_file_name)\n", " lp_file = open(lp_file_name, \"r\")\n", " lp = lp_file.read()\n", " print(\"\\n\", lp)\n", " lp_file.close()\n", " os.remove(lp_file_name)\n", "\n", " def extract_shipments(self, paths, id_col, ship=\"ship\"):\n", " \"\"\"Extract the supply to demand shipments as a \n", " geopandas.GeoDataFrame of shapely.geometry.LineString objects.\n", " \n", " Parameters\n", " ----------\n", " paths : geopandas.GeoDataFrame\n", " Shortest-path routes between all self.supply_nodes\n", " and self.demand_nodes.\n", " id_col : str\n", " ID column name.\n", " ship : str\n", " Column name for the amount of good shipped.\n", " Default is 'ship'.\n", " \n", " Returns\n", " -------\n", " shipments : geopandas.GeoDataFrame\n", " Optimal shipments from self.supply_nodes to\n", " self.demand_nodes.\n", " \"\"\"\n", "\n", " def _id(sp):\n", " \"\"\"ID label helper\"\"\"\n", " return tuple([int(i) for i in sp.split(\"_\")[-1].split(\",\")])\n", "\n", " paths[ship] = int\n", " # set label of the shipping path for each OD pair.\n", " for ship_path, shipment in self.shipping_decisions.items():\n", " paths.loc[(paths[id_col] == _id(ship_path)), ship] = shipment\n", " # extract only shiiping paths\n", " shipments = paths[paths[ship] != int].copy()\n", " shipments[ship] = shipments[ship].astype(int)\n", "\n", " return shipments" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Plotting helper functions and constants\n", "**Note: originating shipments**" ] }, { "cell_type": "code", "execution_count": 7, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.568270Z", "start_time": "2021-06-28T23:35:20.565444Z" } }, "outputs": [], "source": [ "shipping_colors = [\"maroon\", \"cyan\", \"magenta\", \"orange\"]" ] }, { "cell_type": "code", "execution_count": 8, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.574584Z", "start_time": "2021-06-28T23:35:20.570362Z" } }, "outputs": [], "source": [ "def obs_labels(o, b, s, col=\"id\", **kwargs):\n", " \"\"\"Label each point pattern observation.\"\"\"\n", "\n", " def _lab_loc(_x):\n", " \"\"\"Helper for labeling observations.\"\"\"\n", " return _x.geometry.coords[0]\n", "\n", " if o.index.name != \"schools\":\n", " X = o.index.name[0]\n", " else:\n", " X = \"\"\n", " kws = {\"size\": s, \"ha\": \"left\", \"va\": \"bottom\", \"style\": \"oblique\"}\n", " kws.update(kwargs)\n", " o.apply(lambda x: b.annotate(text=X+str(x[col]), xy=_lab_loc(x), **kws), axis=1)" ] }, { "cell_type": "code", "execution_count": 9, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.583863Z", "start_time": "2021-06-28T23:35:20.576988Z" } }, "outputs": [], "source": [ "def make_patches(objects):\n", " \"\"\"Create patches for legend\"\"\"\n", " patches = []\n", " for _object in objects:\n", " try:\n", " oname = _object.index.name\n", " except AttributeError:\n", " oname = \"shipping\"\n", " if oname.split(\" \")[0] in [\"schools\", \"supply\", \"demand\"]:\n", " ovalue = _object.shape[0]\n", " if oname == \"schools\":\n", " ms, m, c, a = 3, \"o\", \"k\", 1\n", " elif oname.startswith(\"supply\"):\n", " ms, m, c, a = 10, \"o\", \"b\", 0.25\n", " elif oname.startswith(\"demand\"):\n", " ms, m, c, a = 10, \"o\", \"g\", 0.25\n", " if oname.endswith(\"snapped\"):\n", " ms, m, a = float(ms) / 2.0, \"x\", 1\n", " _kws = {\"lw\": 0, \"c\": c, \"marker\": m, \"ms\": ms, \"alpha\": a}\n", " label = \"%s — %s\" % (oname.capitalize(), int(ovalue))\n", " p = matplotlib.lines.Line2D([], [], label=label, **_kws)\n", " patches.append(p)\n", " else:\n", " patch_info = plot_shipments(_object, \"\", for_legend=True)\n", " for c, lw, lwsc, (i, j) in patch_info:\n", " label = \"s%s$\\\\rightarrow$d%s — %s microscopes\" % (i, j, lw)\n", " _kws = {\"alpha\": 0.75, \"c\": c, \"lw\": lwsc, \"label\": label}\n", " p = matplotlib.lines.Line2D([], [], solid_capstyle=\"round\", **_kws)\n", " patches.append(p)\n", " return patches" ] }, { "cell_type": "code", "execution_count": 10, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.589602Z", "start_time": "2021-06-28T23:35:20.586095Z" } }, "outputs": [], "source": [ "def legend(objects, anchor=(1.005, 1.016)):\n", " \"\"\"Add a legend to a plot\"\"\"\n", " patches = make_patches(objects)\n", " kws = {\"fancybox\": True, \"framealpha\": 0.85, \"fontsize\": \"x-large\"}\n", " kws.update({\"bbox_to_anchor\":anchor, \"labelspacing\":2., \"borderpad\":2.})\n", " legend = matplotlib.pyplot.legend(handles=patches, **kws)\n", " legend.get_frame().set_facecolor(\"white\")" ] }, { "cell_type": "code", "execution_count": 11, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.597104Z", "start_time": "2021-06-28T23:35:20.592297Z" } }, "outputs": [], "source": [ "def plot_shipments(sd, b, scaled=0.75, for_legend=False):\n", " \"\"\"Helper for plotting shipments based on OD and magnitude\"\"\"\n", " _patches = []\n", " _plot_kws = {\"alpha\":0.75, \"zorder\":0, \"capstyle\":\"round\"}\n", " for c, (g, gdf) in zip(shipping_colors, sd):\n", " lw, lw_scaled, ids = gdf[\"ship\"], gdf[\"ship\"] * scaled, gdf[\"id\"]\n", " if for_legend:\n", " for _lw, _lwsc, _id in zip(lw, lw_scaled, ids):\n", " _patches.append([c, _lw, _lwsc, _id])\n", " else:\n", " gdf.plot(ax=b, color=c, lw=lw_scaled, **_plot_kws)\n", " if for_legend:\n", " return _patches" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "--------------------------------------------------------\n", "#### Streets" ] }, { "cell_type": "code", "execution_count": 12, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:20.936363Z", "start_time": "2021-06-28T23:35:20.598932Z" } }, "outputs": [], "source": [ "streets = geopandas.read_file(examples.get_path(\"streets.shp\"))\n", "streets.crs = \"esri:102649\"\n", "streets = streets.to_crs(\"epsg:2762\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Schools" ] }, { "cell_type": "code", "execution_count": 13, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:21.254761Z", "start_time": "2021-06-28T23:35:20.938144Z" } }, "outputs": [], "source": [ "schools = geopandas.read_file(examples.get_path(\"schools.shp\"))\n", "schools.index.name = \"schools\"\n", "schools.crs = \"esri:102649\"\n", "schools = schools.to_crs(\"epsg:2762\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Schools - supply nodes" ] }, { "cell_type": "code", "execution_count": 14, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:21.284017Z", "start_time": "2021-06-28T23:35:21.256589Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
POLYIDgeometry
supply
01POINT (221615.157 268183.063)
56POINT (221542.706 268185.028)
67POINT (221847.882 267983.231)
78POINT (221406.839 267990.801)
\n", "
" ], "text/plain": [ " POLYID geometry\n", "supply \n", "0 1 POINT (221615.157 268183.063)\n", "5 6 POINT (221542.706 268185.028)\n", "6 7 POINT (221847.882 267983.231)\n", "7 8 POINT (221406.839 267990.801)" ] }, "execution_count": 14, "metadata": {}, "output_type": "execute_result" } ], "source": [ "schools_supply = schools[schools[\"POLYID\"].isin(supply_schools)]\n", "schools_supply.index.name = \"supply\"\n", "schools_supply" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Schools - demand nodes" ] }, { "cell_type": "code", "execution_count": 15, "metadata": { "ExecuteTime": { "end_time": "2021-06-28T23:35:21.307254Z", "start_time": "2021-06-28T23:35:21.286566Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
POLYIDgeometry
demand
12POINT (221122.271 268131.466)
23POINT (221474.669 267188.462)
34POINT (220453.142 268087.516)
45POINT (221235.835 267685.028)
\n", "