[{"data":1,"prerenderedAt":1635},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fnetwork-analysis-and-routing\u002Fcalculate-service-areas-pyqgis":3},{"id":4,"title":5,"body":6,"description":1624,"extension":1625,"meta":1626,"navigation":224,"path":1631,"seo":1632,"stem":1633,"__hash__":1634},"docs\u002Fspatial-data-processing-automation\u002Fnetwork-analysis-and-routing\u002Fcalculate-service-areas-pyqgis\u002Findex.md","Calculate Service Areas in PyQGIS",{"type":7,"value":8,"toc":1611},"minimark",[9,13,17,26,147,152,178,182,406,448,452,455,860,873,877,1018,1224,1247,1251,1254,1271,1277,1287,1291,1294,1444,1453,1456,1460,1480,1484,1528,1532,1535,1539,1547,1557,1563,1574,1578,1607],[10,11,5],"h1",{"id":12},"calculate-service-areas-in-pyqgis",[14,15,16],"p",{},"\"Everywhere within ten minutes' drive of this depot\" is a network question, and a buffer is not an answer to it. A ten-kilometre circle drawn round a depot includes the far side of an estuary and excludes the town twelve kilometres along the motorway. A service area follows the roads, and in PyQGIS it costs one Dijkstra run — the same run that produces a shortest path, read against a budget instead of towards a destination.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002Fnetwork-analysis-and-routing\u002F","Network Analysis & Routing in PyQGIS",". It covers the Processing algorithm, the graph API version with several bands at once, and the step everyone underestimates: turning a set of reachable edges into a polygon somebody can put on a map.",[14,27,28],{},[29,30,35,39,43,50,59,69,75,83,90,94,99,103,107,112,115,118,123,126,129,133,141],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 330","img","A circular buffer compared with a network service area from the same point, showing the buffer including unreachable land across water and excluding a town served by a fast road","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"A buffer answers a different question",[40,41,42],"desc",{},"A ten kilometre circle around a depot includes an area across an estuary that no road reaches, and stops short of a town that a fast road serves comfortably within the time budget. The network service area follows the roads, so it excludes the first and includes the second.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","330","#f6f3ea",[51,52,58],"text",{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"380","26","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Same depot, same ten minutes, different answers",[44,60],{"x":61,"y":62,"width":63,"height":64,"rx":65,"fill":66,"stroke":67,"style":68},"24","46","344","222","10","#fffdf7","#b91c1c","stroke-width:2.5",[51,70,74],{"x":71,"y":72,"style":73,"fill":67,"textAnchor":57},"196","72","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","10 km buffer",[76,77],"circle",{"cx":71,"cy":78,"r":79,"fill":80,"stroke":81,"style":82},"170","76","#dbeafe","#2563eb","stroke-width:2",[84,85],"path",{"d":86,"fill":87,"stroke":88,"style":89},"M60 236 Q140 214 196 226 Q252 238 332 218","none","#1e40af","stroke-width:6",[76,91],{"cx":71,"cy":78,"r":92,"fill":93},"6","#b45309",[51,95,98],{"x":71,"y":96,"style":97,"fill":88,"textAnchor":57},"256","text-anchor:middle;font-size:10px;font-family:sans-serif","the estuary is inside the circle",[44,100],{"x":101,"y":62,"width":63,"height":64,"rx":65,"fill":66,"stroke":102,"style":68},"392","#15803d",[51,104,106],{"x":105,"y":72,"style":73,"fill":102,"textAnchor":57},"564","10 minute service area",[84,108],{"d":109,"fill":87,"stroke":110,"style":111},"M470 200 Q500 148 564 146 Q634 144 690 116","#0f766e","stroke-width:5",[84,113],{"d":114,"fill":87,"stroke":110,"style":111},"M564 146 L560 206 L500 232",[84,116],{"d":117,"fill":87,"stroke":110,"style":111},"M470 200 L432 232",[84,119],{"d":120,"fill":121,"stroke":102,"style":122},"M446 206 Q470 130 566 122 Q660 116 706 104 L714 132 Q640 152 578 162 L582 214 L508 246 L440 246 Z","#e8efe6","stroke-width:2;fill-opacity:0.55",[84,124],{"d":125,"fill":87,"stroke":88,"style":89},"M420 236 Q500 214 556 226 Q612 238 692 218",[76,127],{"cx":105,"cy":128,"r":92,"fill":93},"146",[51,130,132],{"x":105,"y":131,"style":97,"fill":102,"textAnchor":57},"262","reaches the far town, stops at the water",[44,134],{"x":135,"y":136,"width":137,"height":138,"rx":92,"fill":139,"stroke":93,"style":140},"120","288","520","30","#fdf2e2","stroke-width:1.8",[51,142,146],{"x":53,"y":143,"style":144,"fill":145,"textAnchor":57},"308","text-anchor:middle;font-size:10.5px;font-family:sans-serif","#2f3b35","the difference is largest exactly where the decision matters",[148,149,151],"h2",{"id":150},"prerequisites","Prerequisites",[153,154,155,163,171],"ul",{},[156,157,158,162],"li",{},[159,160,161],"strong",{},"QGIS 3.34 LTR"," or newer, network in a projected CRS.",[156,164,165,166,170],{},"A network split at junctions and checked for connectivity — see ",[21,167,169],{"href":168},"\u002Fspatial-data-processing-automation\u002Fnetwork-analysis-and-routing\u002Fbuild-network-graph-with-qgsgraphbuilder\u002F","building a network graph",".",[156,172,173,174,170],{},"For drive times, a speed attribute — see ",[21,175,177],{"href":176},"\u002Fspatial-data-processing-automation\u002Fnetwork-analysis-and-routing\u002Fadd-speed-and-travel-cost-to-network-pyqgis\u002F","adding speed and travel cost",[148,179,181],{"id":180},"the-processing-algorithm","The Processing algorithm",[183,184,189],"pre",{"className":185,"code":186,"language":187,"meta":188,"style":188},"language-python shiki shiki-themes github-dark","import processing\nfrom qgis.core import QgsProject\n\nroads = QgsProject.instance().mapLayersByName(\"roads\")[0]\n\nresult = processing.run(\"native:serviceareafrompoint\", {\n    \"INPUT\": roads,\n    \"STRATEGY\": 1,\n    \"START_POINT\": \"445120,187330 [EPSG:27700]\",\n    \"TRAVEL_COST2\": 600,\n    \"SPEED_FIELD\": \"speed_kph\",\n    \"DEFAULT_SPEED\": 48,\n    \"TOLERANCE\": 0.5,\n    \"INCLUDE_BOUNDS\": True,\n    \"OUTPUT_LINES\": \"TEMPORARY_OUTPUT\",\n    \"OUTPUT\": \"TEMPORARY_OUTPUT\",\n})\n","python","",[190,191,192,205,219,226,251,256,273,282,297,310,323,336,349,362,375,388,400],"code",{"__ignoreMap":188},[193,194,197,201],"span",{"class":195,"line":196},"line",1,[193,198,200],{"class":199},"snl16","import",[193,202,204],{"class":203},"s95oV"," processing\n",[193,206,208,211,214,216],{"class":195,"line":207},2,[193,209,210],{"class":199},"from",[193,212,213],{"class":203}," qgis.core ",[193,215,200],{"class":199},[193,217,218],{"class":203}," QgsProject\n",[193,220,222],{"class":195,"line":221},3,[193,223,225],{"emptyLinePlaceholder":224},true,"\n",[193,227,229,232,235,238,242,245,248],{"class":195,"line":228},4,[193,230,231],{"class":203},"roads ",[193,233,234],{"class":199},"=",[193,236,237],{"class":203}," QgsProject.instance().mapLayersByName(",[193,239,241],{"class":240},"sU2Wk","\"roads\"",[193,243,244],{"class":203},")[",[193,246,46],{"class":247},"sDLfK",[193,249,250],{"class":203},"]\n",[193,252,254],{"class":195,"line":253},5,[193,255,225],{"emptyLinePlaceholder":224},[193,257,259,262,264,267,270],{"class":195,"line":258},6,[193,260,261],{"class":203},"result ",[193,263,234],{"class":199},[193,265,266],{"class":203}," processing.run(",[193,268,269],{"class":240},"\"native:serviceareafrompoint\"",[193,271,272],{"class":203},", {\n",[193,274,276,279],{"class":195,"line":275},7,[193,277,278],{"class":240},"    \"INPUT\"",[193,280,281],{"class":203},": roads,\n",[193,283,285,288,291,294],{"class":195,"line":284},8,[193,286,287],{"class":240},"    \"STRATEGY\"",[193,289,290],{"class":203},": ",[193,292,293],{"class":247},"1",[193,295,296],{"class":203},",\n",[193,298,300,303,305,308],{"class":195,"line":299},9,[193,301,302],{"class":240},"    \"START_POINT\"",[193,304,290],{"class":203},[193,306,307],{"class":240},"\"445120,187330 [EPSG:27700]\"",[193,309,296],{"class":203},[193,311,313,316,318,321],{"class":195,"line":312},10,[193,314,315],{"class":240},"    \"TRAVEL_COST2\"",[193,317,290],{"class":203},[193,319,320],{"class":247},"600",[193,322,296],{"class":203},[193,324,326,329,331,334],{"class":195,"line":325},11,[193,327,328],{"class":240},"    \"SPEED_FIELD\"",[193,330,290],{"class":203},[193,332,333],{"class":240},"\"speed_kph\"",[193,335,296],{"class":203},[193,337,339,342,344,347],{"class":195,"line":338},12,[193,340,341],{"class":240},"    \"DEFAULT_SPEED\"",[193,343,290],{"class":203},[193,345,346],{"class":247},"48",[193,348,296],{"class":203},[193,350,352,355,357,360],{"class":195,"line":351},13,[193,353,354],{"class":240},"    \"TOLERANCE\"",[193,356,290],{"class":203},[193,358,359],{"class":247},"0.5",[193,361,296],{"class":203},[193,363,365,368,370,373],{"class":195,"line":364},14,[193,366,367],{"class":240},"    \"INCLUDE_BOUNDS\"",[193,369,290],{"class":203},[193,371,372],{"class":247},"True",[193,374,296],{"class":203},[193,376,378,381,383,386],{"class":195,"line":377},15,[193,379,380],{"class":240},"    \"OUTPUT_LINES\"",[193,382,290],{"class":203},[193,384,385],{"class":240},"\"TEMPORARY_OUTPUT\"",[193,387,296],{"class":203},[193,389,391,394,396,398],{"class":195,"line":390},16,[193,392,393],{"class":240},"    \"OUTPUT\"",[193,395,290],{"class":203},[193,397,385],{"class":240},[193,399,296],{"class":203},[193,401,403],{"class":195,"line":402},17,[193,404,405],{"class":203},"})\n",[14,407,408,411,412,415,416,419,420,423,424,426,427,430,431,434,435,438,439,443,444,447],{},[159,409,410],{},"Breakdown:"," ",[190,413,414],{},"STRATEGY"," 1 is time, so ",[190,417,418],{},"TRAVEL_COST2"," is in ",[159,421,422],{},"seconds"," — 600 for ten minutes. With strategy 0 the same parameter is a distance in layer units, and mixing them up gives you a catchment that is either the whole county or one junction. The parameter is named ",[190,425,418],{}," because the original ",[190,428,429],{},"TRAVEL_COST"," was replaced when the units were clarified; passing the old name still works on many builds and is deprecated. ",[190,432,433],{},"INCLUDE_BOUNDS"," adds the partially traversed edges at the frontier, which is what makes a subsequent polygon look sane rather than ragged. Two outputs come back: ",[190,436,437],{},"OUTPUT"," is the reachable ",[440,441,442],"em",{},"nodes"," as points, and ",[190,445,446],{},"OUTPUT_LINES"," is the reachable network as lines.",[148,449,451],{"id":450},"several-bands-from-one-run","Several bands from one run",[14,453,454],{},"The graph API's advantage here is not subtlety, it is that five bands cost the same as one.",[183,456,458],{"className":185,"code":457,"language":187,"meta":188,"style":188},"from qgis.analysis import (\n    QgsVectorLayerDirector, QgsNetworkSpeedStrategy,\n    QgsGraphBuilder, QgsGraphAnalyzer,\n)\nfrom qgis.core import QgsPointXY\n\nspeed_index = roads.fields().indexOf(\"speed_kph\")\n\ndirector = QgsVectorLayerDirector(\n    roads, -1, \"\", \"\", \"\", QgsVectorLayerDirector.DirectionBoth\n)\ndirector.addStrategy(QgsNetworkSpeedStrategy(speed_index, 48.0, 1000.0 \u002F 3600.0))\n\ndepot = QgsPointXY(445120, 187330)\nbuilder = QgsGraphBuilder(roads.sourceCrs(), True, 0.5)\ntied = director.makeGraph(builder, [depot])\ngraph = builder.graph()\n\norigin = graph.findVertex(tied[0])\ntree, costs = QgsGraphAnalyzer.dijkstra(graph, origin, 0)\n\nbands = {}\nfor minutes in (5, 10, 15, 20, 30):\n    budget = minutes * 60\n    bands[minutes] = [\n        vertex_id for vertex_id, cost in enumerate(costs)\n        if tree[vertex_id] != -1 and cost \u003C= budget\n    ]\n    print(f\"{minutes:2d} min: {len(bands[minutes])} vertices\")\n",[190,459,460,472,477,482,487,498,502,516,520,530,557,561,583,587,607,625,635,645,650,666,681,686,697,736,752,763,782,811,817],{"__ignoreMap":188},[193,461,462,464,467,469],{"class":195,"line":196},[193,463,210],{"class":199},[193,465,466],{"class":203}," qgis.analysis ",[193,468,200],{"class":199},[193,470,471],{"class":203}," (\n",[193,473,474],{"class":195,"line":207},[193,475,476],{"class":203},"    QgsVectorLayerDirector, QgsNetworkSpeedStrategy,\n",[193,478,479],{"class":195,"line":221},[193,480,481],{"class":203},"    QgsGraphBuilder, QgsGraphAnalyzer,\n",[193,483,484],{"class":195,"line":228},[193,485,486],{"class":203},")\n",[193,488,489,491,493,495],{"class":195,"line":253},[193,490,210],{"class":199},[193,492,213],{"class":203},[193,494,200],{"class":199},[193,496,497],{"class":203}," QgsPointXY\n",[193,499,500],{"class":195,"line":258},[193,501,225],{"emptyLinePlaceholder":224},[193,503,504,507,509,512,514],{"class":195,"line":275},[193,505,506],{"class":203},"speed_index ",[193,508,234],{"class":199},[193,510,511],{"class":203}," roads.fields().indexOf(",[193,513,333],{"class":240},[193,515,486],{"class":203},[193,517,518],{"class":195,"line":284},[193,519,225],{"emptyLinePlaceholder":224},[193,521,522,525,527],{"class":195,"line":299},[193,523,524],{"class":203},"director ",[193,526,234],{"class":199},[193,528,529],{"class":203}," QgsVectorLayerDirector(\n",[193,531,532,535,538,540,543,546,548,550,552,554],{"class":195,"line":312},[193,533,534],{"class":203},"    roads, ",[193,536,537],{"class":199},"-",[193,539,293],{"class":247},[193,541,542],{"class":203},", ",[193,544,545],{"class":240},"\"\"",[193,547,542],{"class":203},[193,549,545],{"class":240},[193,551,542],{"class":203},[193,553,545],{"class":240},[193,555,556],{"class":203},", QgsVectorLayerDirector.DirectionBoth\n",[193,558,559],{"class":195,"line":325},[193,560,486],{"class":203},[193,562,563,566,569,571,574,577,580],{"class":195,"line":338},[193,564,565],{"class":203},"director.addStrategy(QgsNetworkSpeedStrategy(speed_index, ",[193,567,568],{"class":247},"48.0",[193,570,542],{"class":203},[193,572,573],{"class":247},"1000.0",[193,575,576],{"class":199}," \u002F",[193,578,579],{"class":247}," 3600.0",[193,581,582],{"class":203},"))\n",[193,584,585],{"class":195,"line":351},[193,586,225],{"emptyLinePlaceholder":224},[193,588,589,592,594,597,600,602,605],{"class":195,"line":364},[193,590,591],{"class":203},"depot ",[193,593,234],{"class":199},[193,595,596],{"class":203}," QgsPointXY(",[193,598,599],{"class":247},"445120",[193,601,542],{"class":203},[193,603,604],{"class":247},"187330",[193,606,486],{"class":203},[193,608,609,612,614,617,619,621,623],{"class":195,"line":377},[193,610,611],{"class":203},"builder ",[193,613,234],{"class":199},[193,615,616],{"class":203}," QgsGraphBuilder(roads.sourceCrs(), ",[193,618,372],{"class":247},[193,620,542],{"class":203},[193,622,359],{"class":247},[193,624,486],{"class":203},[193,626,627,630,632],{"class":195,"line":390},[193,628,629],{"class":203},"tied ",[193,631,234],{"class":199},[193,633,634],{"class":203}," director.makeGraph(builder, [depot])\n",[193,636,637,640,642],{"class":195,"line":402},[193,638,639],{"class":203},"graph ",[193,641,234],{"class":199},[193,643,644],{"class":203}," builder.graph()\n",[193,646,648],{"class":195,"line":647},18,[193,649,225],{"emptyLinePlaceholder":224},[193,651,653,656,658,661,663],{"class":195,"line":652},19,[193,654,655],{"class":203},"origin ",[193,657,234],{"class":199},[193,659,660],{"class":203}," graph.findVertex(tied[",[193,662,46],{"class":247},[193,664,665],{"class":203},"])\n",[193,667,669,672,674,677,679],{"class":195,"line":668},20,[193,670,671],{"class":203},"tree, costs ",[193,673,234],{"class":199},[193,675,676],{"class":203}," QgsGraphAnalyzer.dijkstra(graph, origin, ",[193,678,46],{"class":247},[193,680,486],{"class":203},[193,682,684],{"class":195,"line":683},21,[193,685,225],{"emptyLinePlaceholder":224},[193,687,689,692,694],{"class":195,"line":688},22,[193,690,691],{"class":203},"bands ",[193,693,234],{"class":199},[193,695,696],{"class":203}," {}\n",[193,698,700,703,706,709,712,715,717,719,721,724,726,729,731,733],{"class":195,"line":699},23,[193,701,702],{"class":199},"for",[193,704,705],{"class":203}," minutes ",[193,707,708],{"class":199},"in",[193,710,711],{"class":203}," (",[193,713,714],{"class":247},"5",[193,716,542],{"class":203},[193,718,65],{"class":247},[193,720,542],{"class":203},[193,722,723],{"class":247},"15",[193,725,542],{"class":203},[193,727,728],{"class":247},"20",[193,730,542],{"class":203},[193,732,138],{"class":247},[193,734,735],{"class":203},"):\n",[193,737,739,742,744,746,749],{"class":195,"line":738},24,[193,740,741],{"class":203},"    budget ",[193,743,234],{"class":199},[193,745,705],{"class":203},[193,747,748],{"class":199},"*",[193,750,751],{"class":247}," 60\n",[193,753,755,758,760],{"class":195,"line":754},25,[193,756,757],{"class":203},"    bands[minutes] ",[193,759,234],{"class":199},[193,761,762],{"class":203}," [\n",[193,764,766,769,771,774,776,779],{"class":195,"line":765},26,[193,767,768],{"class":203},"        vertex_id ",[193,770,702],{"class":199},[193,772,773],{"class":203}," vertex_id, cost ",[193,775,708],{"class":199},[193,777,778],{"class":247}," enumerate",[193,780,781],{"class":203},"(costs)\n",[193,783,785,788,791,794,797,799,802,805,808],{"class":195,"line":784},27,[193,786,787],{"class":199},"        if",[193,789,790],{"class":203}," tree[vertex_id] ",[193,792,793],{"class":199},"!=",[193,795,796],{"class":199}," -",[193,798,293],{"class":247},[193,800,801],{"class":199}," and",[193,803,804],{"class":203}," cost ",[193,806,807],{"class":199},"\u003C=",[193,809,810],{"class":203}," budget\n",[193,812,814],{"class":195,"line":813},28,[193,815,816],{"class":203},"    ]\n",[193,818,820,823,826,829,832,835,838,841,844,847,850,853,855,858],{"class":195,"line":819},29,[193,821,822],{"class":247},"    print",[193,824,825],{"class":203},"(",[193,827,828],{"class":199},"f",[193,830,831],{"class":240},"\"",[193,833,834],{"class":247},"{",[193,836,837],{"class":203},"minutes",[193,839,840],{"class":199},":2d",[193,842,843],{"class":247},"}",[193,845,846],{"class":240}," min: ",[193,848,849],{"class":247},"{len",[193,851,852],{"class":203},"(bands[minutes])",[193,854,843],{"class":247},[193,856,857],{"class":240}," vertices\"",[193,859,486],{"class":203},[14,861,862,864,865,868,869,872],{},[159,863,410],{}," One Dijkstra run fills ",[190,866,867],{},"costs"," for every vertex in the network, so every band is a filter over the same array — the thirty-minute band costs no more to compute than the five-minute one. Testing ",[190,870,871],{},"tree[vertex_id] != -1"," before the cost comparison is what excludes unreachable vertices, whose sentinel cost is large but finite and would otherwise sail past a generous budget. The bands nest by construction, so the ten-minute list contains the five-minute one, which is exactly what you want when turning them into rings.",[148,874,876],{"id":875},"from-vertices-to-a-polygon","From vertices to a polygon",[14,878,879],{},[29,880,883,886,889,892,895,899,904,907,941,945,948,951,954,981,984,987,991,997,1000,1003,1005,1008,1014],{"viewBox":881,"role":32,"ariaLabel":882,"xmlns":34},"0 0 760 316","Three ways of turning reachable network vertices into a service area polygon: a convex hull that over-claims, a concave hull that follows the shape, and buffered reachable edges that follow the roads closely",[36,884,885],{},"The polygon is a presentation choice, not a result",[40,887,888],{},"A convex hull around the reachable vertices includes large areas no road reaches. A concave hull follows the outline of the reached network much more closely. Buffering the reachable edges themselves produces a shape that hugs the roads, which is honest but often reads as a spider rather than a catchment.",[44,890],{"x":46,"y":46,"width":47,"height":891,"fill":49},"316",[51,893,894],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"Three shapes, all from the same reachable set",[44,896],{"x":728,"y":62,"width":897,"height":898,"rx":65,"fill":66,"stroke":67,"style":68},"228","204",[51,900,903],{"x":901,"y":72,"style":902,"fill":67,"textAnchor":57},"134","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","convex hull",[84,905],{"d":906,"fill":80,"stroke":81,"style":82},"M52 190 L84 108 L166 92 L216 148 L184 210 L96 214 Z",[908,909,910,915,919,923,927,931,935,939],"g",{"fill":110},[76,911],{"cx":912,"cy":913,"r":914},"52","190","3",[76,916],{"cx":917,"cy":918,"r":914},"84","108",[76,920],{"cx":921,"cy":922,"r":914},"166","92",[76,924],{"cx":925,"cy":926,"r":914},"216","148",[76,928],{"cx":929,"cy":930,"r":914},"184","210",[76,932],{"cx":933,"cy":934,"r":914},"96","214",[76,936],{"cx":937,"cy":938,"r":914},"128","150",[76,940],{"cx":938,"cy":78,"r":914},[51,942,944],{"x":901,"y":943,"style":97,"fill":67,"textAnchor":57},"236","claims land no road reaches",[44,946],{"x":947,"y":62,"width":897,"height":898,"rx":65,"fill":66,"stroke":102,"style":68},"266",[51,949,950],{"x":53,"y":72,"style":902,"fill":102,"textAnchor":57},"concave hull",[84,952],{"d":953,"fill":121,"stroke":102,"style":82},"M298 190 L330 108 L374 138 L412 92 L462 148 L430 210 L376 178 L342 214 Z",[908,955,956,959,961,964,967,970,973,977],{"fill":110},[76,957],{"cx":958,"cy":913,"r":914},"298",[76,960],{"cx":48,"cy":918,"r":914},[76,962],{"cx":963,"cy":922,"r":914},"412",[76,965],{"cx":966,"cy":926,"r":914},"462",[76,968],{"cx":969,"cy":930,"r":914},"430",[76,971],{"cx":972,"cy":934,"r":914},"342",[76,974],{"cx":975,"cy":976,"r":914},"374","138",[76,978],{"cx":979,"cy":980,"r":914},"376","178",[51,982,983],{"x":53,"y":943,"style":97,"fill":102,"textAnchor":57},"usually the right compromise",[44,985],{"x":986,"y":62,"width":897,"height":898,"rx":65,"fill":66,"stroke":110,"style":68},"512",[51,988,990],{"x":989,"y":72,"style":902,"fill":110,"textAnchor":57},"626","buffered edges",[84,992],{"d":993,"fill":87,"stroke":110,"style":994,"strokeLineCap":995,"opacity":996},"M544 196 Q590 140 626 138 Q676 136 706 106","stroke-width:14","round","0.35",[84,998],{"d":999,"fill":87,"stroke":110,"style":994,"strokeLineCap":995,"opacity":996},"M626 138 L622 200 L578 216",[84,1001],{"d":993,"fill":87,"stroke":110,"style":1002},"stroke-width:2.6",[84,1004],{"d":999,"fill":87,"stroke":110,"style":1002},[51,1006,1007],{"x":989,"y":943,"style":97,"fill":145,"textAnchor":57},"honest, reads as a spider",[44,1009],{"x":1010,"y":1011,"width":320,"height":1012,"rx":1013,"fill":139,"stroke":93,"style":82},"80","272","34","8",[51,1015,1017],{"x":53,"y":1016,"style":144,"fill":145,"textAnchor":57},"294","say which one you used — the area figure differs by a factor of two between them",[183,1019,1021],{"className":185,"code":1020,"language":187,"meta":188,"style":188},"from qgis.core import QgsGeometry, QgsFeature, QgsVectorLayer, QgsField\nfrom qgis.PyQt.QtCore import QVariant\n\nareas = QgsVectorLayer(\n    \"Polygon?crs=\" + roads.sourceCrs().authid(), \"service_areas\", \"memory\"\n)\nareas.dataProvider().addAttributes([QgsField(\"minutes\", QVariant.Int)])\nareas.updateFields()\n\nfeatures = []\nfor minutes, vertex_ids in bands.items():\n    points = [graph.vertex(v).point() for v in vertex_ids]\n    multipoint = QgsGeometry.fromMultiPointXY(points)\n    hull = multipoint.concaveHull(0.3, False)\n    feature = QgsFeature(areas.fields())\n    feature.setGeometry(hull)\n    feature[\"minutes\"] = minutes\n    features.append(feature)\n\nareas.dataProvider().addFeatures(features)\nareas.updateExtents()\n",[190,1022,1023,1034,1046,1050,1060,1079,1083,1094,1099,1103,1113,1125,1145,1155,1175,1185,1190,1205,1210,1214,1219],{"__ignoreMap":188},[193,1024,1025,1027,1029,1031],{"class":195,"line":196},[193,1026,210],{"class":199},[193,1028,213],{"class":203},[193,1030,200],{"class":199},[193,1032,1033],{"class":203}," QgsGeometry, QgsFeature, QgsVectorLayer, QgsField\n",[193,1035,1036,1038,1041,1043],{"class":195,"line":207},[193,1037,210],{"class":199},[193,1039,1040],{"class":203}," qgis.PyQt.QtCore ",[193,1042,200],{"class":199},[193,1044,1045],{"class":203}," QVariant\n",[193,1047,1048],{"class":195,"line":221},[193,1049,225],{"emptyLinePlaceholder":224},[193,1051,1052,1055,1057],{"class":195,"line":228},[193,1053,1054],{"class":203},"areas ",[193,1056,234],{"class":199},[193,1058,1059],{"class":203}," QgsVectorLayer(\n",[193,1061,1062,1065,1068,1071,1074,1076],{"class":195,"line":253},[193,1063,1064],{"class":240},"    \"Polygon?crs=\"",[193,1066,1067],{"class":199}," +",[193,1069,1070],{"class":203}," roads.sourceCrs().authid(), ",[193,1072,1073],{"class":240},"\"service_areas\"",[193,1075,542],{"class":203},[193,1077,1078],{"class":240},"\"memory\"\n",[193,1080,1081],{"class":195,"line":258},[193,1082,486],{"class":203},[193,1084,1085,1088,1091],{"class":195,"line":275},[193,1086,1087],{"class":203},"areas.dataProvider().addAttributes([QgsField(",[193,1089,1090],{"class":240},"\"minutes\"",[193,1092,1093],{"class":203},", QVariant.Int)])\n",[193,1095,1096],{"class":195,"line":284},[193,1097,1098],{"class":203},"areas.updateFields()\n",[193,1100,1101],{"class":195,"line":299},[193,1102,225],{"emptyLinePlaceholder":224},[193,1104,1105,1108,1110],{"class":195,"line":312},[193,1106,1107],{"class":203},"features ",[193,1109,234],{"class":199},[193,1111,1112],{"class":203}," []\n",[193,1114,1115,1117,1120,1122],{"class":195,"line":325},[193,1116,702],{"class":199},[193,1118,1119],{"class":203}," minutes, vertex_ids ",[193,1121,708],{"class":199},[193,1123,1124],{"class":203}," bands.items():\n",[193,1126,1127,1130,1132,1135,1137,1140,1142],{"class":195,"line":338},[193,1128,1129],{"class":203},"    points ",[193,1131,234],{"class":199},[193,1133,1134],{"class":203}," [graph.vertex(v).point() ",[193,1136,702],{"class":199},[193,1138,1139],{"class":203}," v ",[193,1141,708],{"class":199},[193,1143,1144],{"class":203}," vertex_ids]\n",[193,1146,1147,1150,1152],{"class":195,"line":351},[193,1148,1149],{"class":203},"    multipoint ",[193,1151,234],{"class":199},[193,1153,1154],{"class":203}," QgsGeometry.fromMultiPointXY(points)\n",[193,1156,1157,1160,1162,1165,1168,1170,1173],{"class":195,"line":364},[193,1158,1159],{"class":203},"    hull ",[193,1161,234],{"class":199},[193,1163,1164],{"class":203}," multipoint.concaveHull(",[193,1166,1167],{"class":247},"0.3",[193,1169,542],{"class":203},[193,1171,1172],{"class":247},"False",[193,1174,486],{"class":203},[193,1176,1177,1180,1182],{"class":195,"line":377},[193,1178,1179],{"class":203},"    feature ",[193,1181,234],{"class":199},[193,1183,1184],{"class":203}," QgsFeature(areas.fields())\n",[193,1186,1187],{"class":195,"line":390},[193,1188,1189],{"class":203},"    feature.setGeometry(hull)\n",[193,1191,1192,1195,1197,1200,1202],{"class":195,"line":402},[193,1193,1194],{"class":203},"    feature[",[193,1196,1090],{"class":240},[193,1198,1199],{"class":203},"] ",[193,1201,234],{"class":199},[193,1203,1204],{"class":203}," minutes\n",[193,1206,1207],{"class":195,"line":647},[193,1208,1209],{"class":203},"    features.append(feature)\n",[193,1211,1212],{"class":195,"line":652},[193,1213,225],{"emptyLinePlaceholder":224},[193,1215,1216],{"class":195,"line":668},[193,1217,1218],{"class":203},"areas.dataProvider().addFeatures(features)\n",[193,1220,1221],{"class":195,"line":683},[193,1222,1223],{"class":203},"areas.updateExtents()\n",[14,1225,1226,411,1228,1231,1232,1235,1236,1238,1239,1242,1243,1246],{},[159,1227,410],{},[190,1229,1230],{},"concaveHull(alpha, allowHoles)"," takes a threshold between 0 and 1 where lower values hug the points more tightly; 0.3 is a reasonable starting point for a road catchment and worth tuning by eye. Setting ",[190,1233,1234],{},"allowHoles"," to ",[190,1237,1172],{}," keeps the result as a solid catchment rather than one perforated wherever a park has no roads — which is a presentational choice, and the opposite choice is equally defensible if the holes are meaningful. Because the bands nest, drawing the largest first and the smallest last gives a ring map for free without any clipping. ",[190,1240,1241],{},"concaveHull"," needs QGIS 3.16 or newer; on older builds ",[190,1244,1245],{},"convexHull()"," is the fallback and over-claims noticeably.",[148,1248,1250],{"id":1249},"choosing-what-reached-means","Choosing what \"reached\" means",[14,1252,1253],{},"The single biggest source of disagreement between two service areas of the same place is not the algorithm, it is the definition. Three choices each change the answer by more than any parameter:",[14,1255,1256,1259,1260,1263,1264,1267,1268,1270],{},[159,1257,1258],{},"Nodes or edges."," Taking the reachable ",[440,1261,1262],{},"vertices"," means a road half-traversed at the budget contributes nothing, so the catchment shrinks by up to one edge length in every direction. Taking the reachable ",[440,1265,1266],{},"edges"," with ",[190,1269,433],{}," extends to the frontier. On a rural network with kilometre-long edges this is a large difference.",[14,1272,1273,1276],{},[159,1274,1275],{},"Speed assumptions."," A uniform 48 km\u002Fh and a per-class speed table produce catchments that differ by a third in a mixed urban-rural area. Whichever you use, record it next to the output.",[14,1278,1279,1282,1283,1286],{},[159,1280,1281],{},"Direction."," A catchment computed outward from a depot answers \"where can I deliver to\". Computed ",[440,1284,1285],{},"inward"," — running Dijkstra on a graph whose direction values are swapped — it answers \"who can reach me\", and on a one-way network these are genuinely different shapes. The Processing algorithms have no inward mode, so this one needs the graph API.",[148,1288,1290],{"id":1289},"computing-the-inward-catchment","Computing the inward catchment",[14,1292,1293],{},"\"Who can reach me\" is the question a hospital, a school or a depot receiving deliveries actually asks, and on a network with one-way streets it is not the same set as \"where can I reach\". The graph API answers it by reversing the direction interpretation.",[183,1295,1297],{"className":185,"code":1296,"language":187,"meta":188,"style":188},"inward_director = QgsVectorLayerDirector(\n    roads,\n    roads.fields().indexOf(\"oneway\"),\n    \"T\", \"F\", \"B\",                      # forward and backward values swapped\n    QgsVectorLayerDirector.DirectionBoth,\n)\ninward_director.addStrategy(QgsNetworkSpeedStrategy(speed_index, 48.0, 1000.0 \u002F 3600.0))\n\ninward_builder = QgsGraphBuilder(roads.sourceCrs(), True, 0.5)\ninward_tied = inward_director.makeGraph(inward_builder, [depot])\ninward_graph = inward_builder.graph()\n\ninward_tree, inward_costs = QgsGraphAnalyzer.dijkstra(\n    inward_graph, inward_graph.findVertex(inward_tied[0]), 0\n)\n",[190,1298,1299,1308,1313,1324,1346,1351,1355,1372,1376,1393,1403,1413,1417,1427,1440],{"__ignoreMap":188},[193,1300,1301,1304,1306],{"class":195,"line":196},[193,1302,1303],{"class":203},"inward_director ",[193,1305,234],{"class":199},[193,1307,529],{"class":203},[193,1309,1310],{"class":195,"line":207},[193,1311,1312],{"class":203},"    roads,\n",[193,1314,1315,1318,1321],{"class":195,"line":221},[193,1316,1317],{"class":203},"    roads.fields().indexOf(",[193,1319,1320],{"class":240},"\"oneway\"",[193,1322,1323],{"class":203},"),\n",[193,1325,1326,1329,1331,1334,1336,1339,1342],{"class":195,"line":228},[193,1327,1328],{"class":240},"    \"T\"",[193,1330,542],{"class":203},[193,1332,1333],{"class":240},"\"F\"",[193,1335,542],{"class":203},[193,1337,1338],{"class":240},"\"B\"",[193,1340,1341],{"class":203},",                      ",[193,1343,1345],{"class":1344},"sjoCn","# forward and backward values swapped\n",[193,1347,1348],{"class":195,"line":253},[193,1349,1350],{"class":203},"    QgsVectorLayerDirector.DirectionBoth,\n",[193,1352,1353],{"class":195,"line":258},[193,1354,486],{"class":203},[193,1356,1357,1360,1362,1364,1366,1368,1370],{"class":195,"line":275},[193,1358,1359],{"class":203},"inward_director.addStrategy(QgsNetworkSpeedStrategy(speed_index, ",[193,1361,568],{"class":247},[193,1363,542],{"class":203},[193,1365,573],{"class":247},[193,1367,576],{"class":199},[193,1369,579],{"class":247},[193,1371,582],{"class":203},[193,1373,1374],{"class":195,"line":284},[193,1375,225],{"emptyLinePlaceholder":224},[193,1377,1378,1381,1383,1385,1387,1389,1391],{"class":195,"line":299},[193,1379,1380],{"class":203},"inward_builder ",[193,1382,234],{"class":199},[193,1384,616],{"class":203},[193,1386,372],{"class":247},[193,1388,542],{"class":203},[193,1390,359],{"class":247},[193,1392,486],{"class":203},[193,1394,1395,1398,1400],{"class":195,"line":312},[193,1396,1397],{"class":203},"inward_tied ",[193,1399,234],{"class":199},[193,1401,1402],{"class":203}," inward_director.makeGraph(inward_builder, [depot])\n",[193,1404,1405,1408,1410],{"class":195,"line":325},[193,1406,1407],{"class":203},"inward_graph ",[193,1409,234],{"class":199},[193,1411,1412],{"class":203}," inward_builder.graph()\n",[193,1414,1415],{"class":195,"line":338},[193,1416,225],{"emptyLinePlaceholder":224},[193,1418,1419,1422,1424],{"class":195,"line":351},[193,1420,1421],{"class":203},"inward_tree, inward_costs ",[193,1423,234],{"class":199},[193,1425,1426],{"class":203}," QgsGraphAnalyzer.dijkstra(\n",[193,1428,1429,1432,1434,1437],{"class":195,"line":364},[193,1430,1431],{"class":203},"    inward_graph, inward_graph.findVertex(inward_tied[",[193,1433,46],{"class":247},[193,1435,1436],{"class":203},"]), ",[193,1438,1439],{"class":247},"0\n",[193,1441,1442],{"class":195,"line":377},[193,1443,486],{"class":203},[14,1445,1446,1448,1449,1452],{},[159,1447,410],{}," Swapping the forward and backward strings reverses every one-way edge, so a Dijkstra run outward from the depot on the reversed graph enumerates exactly the vertices from which the depot is reachable in the original. It is a second graph and a second build, which is the price of the answer. On a network with no one-way streets the two catchments are identical and the whole exercise is unnecessary — which is worth checking first, since a ",[190,1450,1451],{},"oneway"," field where every value is null makes this a pure waste of a build.",[14,1454,1455],{},"The two catchments diverge most in exactly the places that matter: city centres with one-way systems, and anywhere a dual carriageway separates travel directions. Reporting a delivery catchment when the question was about patients arriving is a mistake nobody spots on the map, because both shapes look plausible.",[148,1457,1459],{"id":1458},"qgis-version-compatibility","QGIS version compatibility",[14,1461,1462,1465,1466,1469,1470,1472,1473,1475,1476,1479],{},[190,1463,1464],{},"native:serviceareafrompoint"," and ",[190,1467,1468],{},"native:serviceareafromlayer"," have been present since 3.0. The ",[190,1471,418],{}," parameter replaced ",[190,1474,429],{}," in 3.10 to make the time-versus-distance units explicit; the old name is still accepted and still ambiguous, so use the new one. ",[190,1477,1478],{},"QgsGeometry.concaveHull()"," requires 3.16.",[148,1481,1483],{"id":1482},"troubleshooting","Troubleshooting",[153,1485,1486,1492,1498,1504,1510,1516],{},[156,1487,1488,1491],{},[159,1489,1490],{},"The service area covers the entire network."," Strategy 0 with a budget you intended as seconds, or a speed multiplier out by a factor of 1000.",[156,1493,1494,1497],{},[159,1495,1496],{},"The service area is a single point."," Strategy 1 with a budget you intended as metres.",[156,1499,1500,1503],{},[159,1501,1502],{},"The polygon has spikes reaching far outside the roads."," Convex hull, or a concave hull threshold set too high.",[156,1505,1506,1509],{},[159,1507,1508],{},"A neighbourhood across a river is included."," The network has a spurious connection there, or the tolerance welded two roads that do not meet.",[156,1511,1512,1515],{},[159,1513,1514],{},"Nothing is reached beyond the immediate junctions."," The direction field's default is forward rather than both, so every unmarked street became one-way.",[156,1517,1518,1521,1522,1524,1525,1527],{},[159,1519,1520],{},"The area figure changed after a QGIS upgrade."," Check whether ",[190,1523,433],{}," defaults differently, and whether the deprecated ",[190,1526,429],{}," is being used.",[148,1529,1531],{"id":1530},"conclusion","Conclusion",[14,1533,1534],{},"Run Dijkstra once, filter the cost array for every band you need, and turn the reachable set into polygons with a concave hull. The computation is the easy half; the half that decides whether anyone believes the map is stating plainly what \"within ten minutes\" meant — which speeds, which direction, and whether the frontier edges counted.",[148,1536,1538],{"id":1537},"frequently-asked-questions","Frequently Asked Questions",[14,1540,1541,1544,1546],{},[159,1542,1543],{},"Can I compute service areas for many origins at once?",[190,1545,1468],{}," takes a point layer and runs one catchment per feature. With the graph API, build once and loop the Dijkstra call over origins, which is much faster than the algorithm's per-feature rebuild.",[14,1548,1549,1552,1553,1556],{},[159,1550,1551],{},"How do I make the bands into rings rather than nested polygons?","\nSubtract each band from the next with ",[190,1554,1555],{},"difference()",", working from the smallest outwards. Nested polygons drawn largest-first look identical and are simpler; rings are what you want if the areas will be measured.",[14,1558,1559,1562],{},[159,1560,1561],{},"Why is my ten-minute area smaller than a ten-kilometre buffer everywhere?","\nBecause roads are not straight. A detour ratio of 1.3 is typical, so a ten-minute drive at 48 km\u002Fh covers about eight kilometres of ground in a straight line, not eight kilometres of road in every direction.",[14,1564,1565,1568,1569,1573],{},[159,1566,1567],{},"Can I weight the catchment by population?","\nNot directly, but the polygon is an ordinary layer, so ",[21,1570,1572],{"href":1571},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002Fzonal-statistics-pyqgis\u002F","zonal statistics"," against a population raster, or a spatial join against census polygons, gives the served population immediately.",[148,1575,1577],{"id":1576},"related","Related",[153,1579,1580,1585,1591,1596,1602],{},[156,1581,1582,1584],{},[21,1583,24],{"href":23}," — the guide this recipe belongs to",[156,1586,1587],{},[21,1588,1590],{"href":1589},"\u002Fspatial-data-processing-automation\u002Fnetwork-analysis-and-routing\u002Fshortest-path-between-points-pyqgis\u002F","Find the Shortest Path Between Points in PyQGIS",[156,1592,1593],{},[21,1594,1595],{"href":176},"Add Speed and Travel Cost to a Network in PyQGIS",[156,1597,1598],{},[21,1599,1601],{"href":1600},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuffer-geometry-pyqgis\u002F","Buffer a Geometry in PyQGIS",[156,1603,1604],{},[21,1605,1606],{"href":1571},"Zonal Statistics in PyQGIS",[1608,1609,1610],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}",{"title":188,"searchDepth":207,"depth":207,"links":1612},[1613,1614,1615,1616,1617,1618,1619,1620,1621,1622,1623],{"id":150,"depth":207,"text":151},{"id":180,"depth":207,"text":181},{"id":450,"depth":207,"text":451},{"id":875,"depth":207,"text":876},{"id":1249,"depth":207,"text":1250},{"id":1289,"depth":207,"text":1290},{"id":1458,"depth":207,"text":1459},{"id":1482,"depth":207,"text":1483},{"id":1530,"depth":207,"text":1531},{"id":1537,"depth":207,"text":1538},{"id":1576,"depth":207,"text":1577},"Build drive-time and walk-distance catchments over a network in PyQGIS — with native:serviceareafrompoint, with Dijkstra and a cost budget, and turned into usable polygons.","md",{"slug":1627,"type":1628,"breadcrumb":1629,"datePublished":1630,"dateModified":1630},"calculate-service-areas-pyqgis","article","Calculate Service Areas","2026-09-05","\u002Fspatial-data-processing-automation\u002Fnetwork-analysis-and-routing\u002Fcalculate-service-areas-pyqgis",{"title":5,"description":1624},"spatial-data-processing-automation\u002Fnetwork-analysis-and-routing\u002Fcalculate-service-areas-pyqgis\u002Findex","bIU-gqsQ322N9tAxhQYnbyxdKQ4JpstUke-Mqpxi-7U",1788563855550]