env-able 2.0.1__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {env_able-2.0.1 → env_able-2.1.0}/PKG-INFO +9 -1
  2. {env_able-2.0.1 → env_able-2.1.0}/README.md +8 -0
  3. {env_able-2.0.1 → env_able-2.1.0}/pyproject.toml +1 -1
  4. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/__init__.py +3 -2
  5. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/calc.py +102 -5
  6. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/docs/atlas-guide.md +88 -10
  7. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/docs/env-able-library.md +11 -1
  8. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/index.html +24879 -23961
  9. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/server.py +13975 -13411
  10. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/vector.py +137 -0
  11. {env_able-2.0.1 → env_able-2.1.0}/.gitignore +0 -0
  12. {env_able-2.0.1 → env_able-2.1.0}/CHANGELOG.md +0 -0
  13. {env_able-2.0.1 → env_able-2.1.0}/LICENSE +0 -0
  14. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/_gdal_env.py +0 -0
  15. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/_gdal_utils.py +0 -0
  16. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/_pipeline.py +0 -0
  17. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/_result.py +0 -0
  18. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/aggregate.py +0 -0
  19. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/__init__.py +0 -0
  20. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/aix.py +0 -0
  21. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/brandmark.py +0 -0
  22. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/claude_bridge.py +0 -0
  23. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/client.py +0 -0
  24. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/compliance.html +0 -0
  25. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/databricks.py +0 -0
  26. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/debug.py +0 -0
  27. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/desktop.py +0 -0
  28. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/docs/atlas-layout-view.md +0 -0
  29. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/docs/cartographic-judgment.md +0 -0
  30. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/edits.py +0 -0
  31. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/eirstyle.py +0 -0
  32. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/env_layers.py +0 -0
  33. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/landing.html +0 -0
  34. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/naming.py +0 -0
  35. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/palette.py +0 -0
  36. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/project.py +0 -0
  37. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/session.py +0 -0
  38. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/splash.html +0 -0
  39. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/templates/Summary A4 Landscape.pagx +0 -0
  40. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/templates/Summary A4 Portrait.pagx +0 -0
  41. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/templates/Title Bar A4 Landscape.pagx +0 -0
  42. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/templates/Title Bar A4 Portrait.pagx +0 -0
  43. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/HTML-TO-IMAGE-LICENSE.txt +0 -0
  44. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/MAPLIBRE-LICENSE.txt +0 -0
  45. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/PROTOMAPS-LICENSE.txt +0 -0
  46. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/ROBOTO-LICENSE.txt +0 -0
  47. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/html-to-image.js +0 -0
  48. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/maplibre-gl.css +0 -0
  49. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/maplibre-gl.js +0 -0
  50. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/pmtiles.js +0 -0
  51. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/protomaps-basemaps.js +0 -0
  52. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-400-italic.woff2 +0 -0
  53. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-400-normal.woff2 +0 -0
  54. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-500-normal.woff2 +0 -0
  55. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-700-normal.woff2 +0 -0
  56. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/classify.py +0 -0
  57. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/layout.py +0 -0
  58. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/morph.py +0 -0
  59. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/pull.py +0 -0
  60. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/remote.py +0 -0
  61. {env_able-2.0.1 → env_able-2.1.0}/src/env_able/stream.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: env-able
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: AI-ready GIS toolkit for energy and subsurface workflows.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -235,6 +235,14 @@ Lines and polygons with fewer vertices, no edge moved more than `tolerance`. Mea
235
235
  result = env.Simplify("counties.shp", 200, "meters", output="counties_light.gpkg")
236
236
  ```
237
237
 
238
+ ### `env.SummaryStatistics(input_table, statistics_fields, case_field=None, output=None, separator=", ")`
239
+ ArcGIS Pro's Summary Statistics: one row per unique set of `case_field` values, with each statistic asked for, as a **table** (no geometry). The input is a path, a DataFrame or a GeoDataFrame. The statistics are Dissolve's plus `MODE`; a text field takes only `MIN`, `MAX`, `COUNT`, `FIRST`, `LAST`, `UNIQUE`, `CONCATENATE` and `MODE`. Columns are named `SUM_EUR`, and blanks are left out of every statistic.
240
+
241
+ ```python
242
+ result = env.SummaryStatistics("wells.gpkg", [["EUR", "SUM"], ["API", "COUNT"]], case_field="OPERATOR")
243
+ result.gdf # a pandas DataFrame, one row per operator
244
+ ```
245
+
238
246
  ---
239
247
 
240
248
  ### `env.load(source)` — fluent spatial pipeline
@@ -209,6 +209,14 @@ Lines and polygons with fewer vertices, no edge moved more than `tolerance`. Mea
209
209
  result = env.Simplify("counties.shp", 200, "meters", output="counties_light.gpkg")
210
210
  ```
211
211
 
212
+ ### `env.SummaryStatistics(input_table, statistics_fields, case_field=None, output=None, separator=", ")`
213
+ ArcGIS Pro's Summary Statistics: one row per unique set of `case_field` values, with each statistic asked for, as a **table** (no geometry). The input is a path, a DataFrame or a GeoDataFrame. The statistics are Dissolve's plus `MODE`; a text field takes only `MIN`, `MAX`, `COUNT`, `FIRST`, `LAST`, `UNIQUE`, `CONCATENATE` and `MODE`. Columns are named `SUM_EUR`, and blanks are left out of every statistic.
214
+
215
+ ```python
216
+ result = env.SummaryStatistics("wells.gpkg", [["EUR", "SUM"], ["API", "COUNT"]], case_field="OPERATOR")
217
+ result.gdf # a pandas DataFrame, one row per operator
218
+ ```
219
+
212
220
  ---
213
221
 
214
222
  ### `env.load(source)` — fluent spatial pipeline
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "env-able"
7
- version = "2.0.1"
7
+ version = "2.1.0"
8
8
  description = "AI-ready GIS toolkit for energy and subsurface workflows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -1,4 +1,4 @@
1
- __version__ = "2.0.1"
1
+ __version__ = "2.1.0"
2
2
 
3
3
  # Resolve GDAL_DATA / PROJ_LIB before anything imports osgeo. IDEs and bare
4
4
  # `python script.py` runs skip conda's activation scripts, which is where those
@@ -9,8 +9,9 @@ _gdal_env.configure()
9
9
 
10
10
  from .vector import (
11
11
  Clip, Buffer, Intersect, Union, Dissolve, Near, SpatialJoin, Erase, Simplify,
12
+ SummaryStatistics,
12
13
  clip_async, buffer_async, intersect_async, union_async, dissolve_async,
13
- near_async, spatial_join_async, erase_async, simplify_async,
14
+ near_async, spatial_join_async, erase_async, simplify_async, summary_statistics_async,
14
15
  SJ_MATCH,
15
16
  load,
16
17
  )
@@ -5,8 +5,12 @@ Calculate Field: one expression, evaluated per row, with nothing else reachable.
5
5
  calc.compile_expr("!NAME!.upper()", fields)
6
6
 
7
7
  ArcGIS Pro's syntax, so an expression copied out of Pro works here: a field is written
8
- `!FIELD!`. A bare name works too when it is a field (`EUR * 1000`), because that is what people
9
- type first.
8
+ `!FIELD!`. ArcMap's `[FIELD]` works too, because people still type it, and so does a bare name
9
+ when it is a field (`EUR * 1000`), because that is what people type first.
10
+
11
+ A null propagates: `!EUR! * 1000` on a row with no EUR gives no value rather than failing the
12
+ whole calculation on that row, and a comparison against a null is false. That is what a database
13
+ does, and it is what someone calculating a 30,000-row column with a few blanks in it expects.
10
14
 
11
15
  It is Python's grammar, NOT Python. The expression is parsed with `ast` and walked by an
12
16
  interpreter that knows a short list of node types; anything else -- attribute access other than
@@ -18,15 +22,50 @@ model may have been handed by a document it was asked to read.
18
22
  from __future__ import annotations
19
23
 
20
24
  import ast
25
+ import keyword
21
26
  import math
22
27
  import operator
23
28
  import re
24
29
 
30
+ def _iif(test, a, b): # never called: ev() evaluates only the branch taken
31
+ return a if test else b
32
+
33
+
25
34
  FUNCS = {
26
35
  "abs": abs, "round": round, "min": min, "max": max, "len": len,
27
36
  "str": str, "int": int, "float": float, "bool": bool,
28
37
  "sqrt": math.sqrt, "floor": math.floor, "ceil": math.ceil, "log": math.log,
29
38
  "log10": math.log10, "exp": math.exp,
39
+ # The function forms people know from ArcMap and from spreadsheets. iif is lazy, so
40
+ # iif(!N! != 0, 1 / !N!, 0) does not divide by zero on the rows it is guarding.
41
+ "iif": _iif, "upper": str.upper, "lower": str.lower, "trim": str.strip,
42
+ }
43
+ #: Functions that pass a null through rather than tripping over it. bool and iif decide what a
44
+ #: null means themselves, so they are not in it.
45
+ _NULL_SAFE = set(FUNCS) - {"bool", "iif"}
46
+
47
+ #: One line each, for the expression box's suggestions. Every function and method is listed;
48
+ #: verify.py checks the two agree, so a function cannot be added without its help.
49
+ HELP = {
50
+ "abs": ("abs(x)", "the value without its sign"),
51
+ "round": ("round(x, n)", "x to n decimal places"),
52
+ "min": ("min(a, b)", "the smaller value"),
53
+ "max": ("max(a, b)", "the larger value"),
54
+ "len": ("len(text)", "how many characters"),
55
+ "str": ("str(x)", "x as text"),
56
+ "int": ("int(x)", "x as a whole number"),
57
+ "float": ("float(x)", "x as a number"),
58
+ "bool": ("bool(x)", "true or false"),
59
+ "sqrt": ("sqrt(x)", "square root"),
60
+ "floor": ("floor(x)", "round down"),
61
+ "ceil": ("ceil(x)", "round up"),
62
+ "log": ("log(x)", "natural logarithm"),
63
+ "log10": ("log10(x)", "base-10 logarithm"),
64
+ "exp": ("exp(x)", "e to the power x"),
65
+ "iif": ("iif(test, a, b)", "a when test is true, otherwise b"),
66
+ "upper": ("upper(text)", "capitals"),
67
+ "lower": ("lower(text)", "lower case"),
68
+ "trim": ("trim(text)", "without leading and trailing spaces"),
30
69
  }
31
70
  #: Methods a string may be asked for. Each returns a string, a bool or an int, so a chain of
32
71
  #: them cannot reach anything that is not data.
@@ -79,6 +118,26 @@ def compile_expr(expr: str, fields):
79
118
  return slots[real]
80
119
 
81
120
  src = re.sub(r"!([^!]+)!", sub, src)
121
+
122
+ def sub_bracket(m):
123
+ # [NAME] is a field only when NAME is one, and only where a value could start: after a
124
+ # name, a closing bracket or a quote it is a subscript (!NAME![0], !NAME![:3]), which
125
+ # is Python's and stays Python's.
126
+ before = src[:m.start()].rstrip()
127
+ word = re.search(r"[A-Za-z_]\w*$", before)
128
+ # ...except after a keyword: `not [ID] == 3` is a value, though `not` ends in a letter.
129
+ if before and (before[-1].isalnum() or before[-1] in "_)]'\"") and not (
130
+ word and keyword.iskeyword(word.group(0))):
131
+ return m.group(0)
132
+ name = m.group(1).strip()
133
+ real = name if name in fields else lookup.get(name.lower())
134
+ if real is None:
135
+ return m.group(0)
136
+ if real not in slots:
137
+ slots[real] = _field_token(len(slots))
138
+ return slots[real]
139
+
140
+ src = re.sub(r"\[([^\[\]]+)\]", sub_bracket, src)
82
141
  try:
83
142
  tree = ast.parse(src, mode="eval")
84
143
  except SyntaxError as e:
@@ -95,6 +154,12 @@ def compile_expr(expr: str, fields):
95
154
  if n.id in fields or n.id.lower() in lookup:
96
155
  continue
97
156
  raise CalcError("%r is not a field or a function. Write a field as !NAME!." % n.id)
157
+ elif isinstance(n, ast.List):
158
+ # What a [NAME] that matched no field parses as. Saying "List is not allowed"
159
+ # about a typo in a field name would send somebody looking in the wrong place.
160
+ if len(n.elts) == 1 and isinstance(n.elts[0], ast.Name):
161
+ raise CalcError("[%s] is not a field in this layer." % n.elts[0].id)
162
+ raise CalcError("A list is not allowed in a field calculation.")
98
163
  elif isinstance(n, ast.Attribute):
99
164
  if n.attr not in STR_METHODS:
100
165
  raise CalcError(".%s is not allowed; string methods are: %s"
@@ -133,6 +198,8 @@ def compile_expr(expr: str, fields):
133
198
  return row.get(n.id if n.id in fields else lookup[n.id.lower()])
134
199
  if t is ast.BinOp:
135
200
  a, b = ev(n.left, row), ev(n.right, row)
201
+ if a is None or b is None:
202
+ return None
136
203
  if isinstance(n.op, ast.Pow) and isinstance(b, (int, float)) and abs(b) > _MAX_POW:
137
204
  raise CalcError("An exponent over %d is refused." % _MAX_POW)
138
205
  if isinstance(n.op, ast.Mult) and (isinstance(a, str) or isinstance(b, str)):
@@ -141,7 +208,10 @@ def compile_expr(expr: str, fields):
141
208
  raise CalcError("That would make a string over %d characters." % _MAX_LEN)
142
209
  return _BIN[type(n.op)](a, b)
143
210
  if t is ast.UnaryOp:
144
- return _UN[type(n.op)](ev(n.operand, row))
211
+ v = ev(n.operand, row)
212
+ if v is None and not isinstance(n.op, ast.Not):
213
+ return None
214
+ return _UN[type(n.op)](v)
145
215
  if t is ast.BoolOp:
146
216
  if isinstance(n.op, ast.And):
147
217
  v = True
@@ -160,6 +230,10 @@ def compile_expr(expr: str, fields):
160
230
  left = ev(n.left, row)
161
231
  for op, c in zip(n.ops, n.comparators):
162
232
  right = ev(c, row)
233
+ # Only ==, != and `is` have an answer about a null; anything else is false.
234
+ if (left is None or right is None) and not isinstance(
235
+ op, (ast.Eq, ast.NotEq, ast.Is, ast.IsNot)):
236
+ return False
163
237
  if not _CMP[type(op)](left, right):
164
238
  return False
165
239
  left = right
@@ -169,6 +243,8 @@ def compile_expr(expr: str, fields):
169
243
  if t is ast.Call:
170
244
  if isinstance(n.func, ast.Attribute):
171
245
  obj = ev(n.func.value, row)
246
+ if obj is None:
247
+ return None
172
248
  if not isinstance(obj, str):
173
249
  raise CalcError(".%s() needs text, and this row has %r"
174
250
  % (n.func.attr, obj))
@@ -178,11 +254,25 @@ def compile_expr(expr: str, fields):
178
254
  isinstance(a, int) and a > _MAX_LEN for a in args):
179
255
  raise CalcError("A width over %d is refused." % _MAX_LEN)
180
256
  return getattr(obj, n.func.attr)(*args)
181
- return FUNCS[n.func.id](*[ev(a, row) for a in n.args])
257
+ fname = n.func.id
258
+ if fname == "iif":
259
+ if len(n.args) != 3:
260
+ raise CalcError("iif() takes three values: iif(test, a, b).")
261
+ return ev(n.args[1], row) if ev(n.args[0], row) else ev(n.args[2], row)
262
+ args = [ev(a, row) for a in n.args]
263
+ if fname in _NULL_SAFE and any(a is None for a in args):
264
+ return None
265
+ if fname in ("upper", "lower", "trim"):
266
+ if len(args) != 1:
267
+ raise CalcError("%s() takes one value." % fname)
268
+ return FUNCS[fname](str(args[0]))
269
+ return FUNCS[fname](*args)
182
270
  if t is ast.Attribute:
183
271
  raise CalcError(".%s must be called, as .%s()" % (n.attr, n.attr))
184
272
  if t is ast.Subscript:
185
273
  obj = ev(n.value, row)
274
+ if obj is None:
275
+ return None
186
276
  s = n.slice
187
277
  if type(s) is ast.Slice:
188
278
  return obj[slice(*(ev(x, row) if x is not None else None
@@ -191,9 +281,16 @@ def compile_expr(expr: str, fields):
191
281
  if t is ast.Tuple:
192
282
  return tuple(ev(x, row) for x in n.elts)
193
283
  if t is ast.JoinedStr:
194
- return "".join(str(ev(v, row)) for v in n.values)
284
+ # A null anywhere makes the whole string null, as text concatenation does in a
285
+ # database, rather than writing the word "None" into somebody's labels.
286
+ parts = [ev(v, row) for v in n.values]
287
+ if any(p is None for p in parts):
288
+ return None
289
+ return "".join(str(p) for p in parts)
195
290
  if t is ast.FormattedValue:
196
291
  v = ev(n.value, row)
292
+ if v is None:
293
+ return None
197
294
  spec = ev(n.format_spec, row) if n.format_spec is not None else ""
198
295
  if any(int(d) > _MAX_LEN for d in re.findall(r"\d+", spec)):
199
296
  raise CalcError("A width over %d is refused." % _MAX_LEN)
@@ -245,6 +245,11 @@ Draw order, top to bottom — drag to reorder. Each row has:
245
245
 
246
246
  **Right-clicking the row opens that same menu.** Either gesture, same menu.
247
247
 
248
+ **Duplicate**, in that menu, copies the layer as it is now, unsaved edits included, as
249
+ "*name* copy", then "*name* copy (2)". It keeps the look and filter and takes a colour of its own,
250
+ and an edit to one does not reach the other. Nothing is written to disk: a save keeps the copy in
251
+ the project, and Morph or Save As makes a file of it. **Undo** takes it away again.
252
+
248
253
  A **bar beside the *Layers* heading** runs while geometry is coming in — opening a project, a
249
254
  layer arriving, or the full-resolution swap a layout asks for. Hover it and it says what it is
250
255
  waiting for. Indeterminate, because none of that reports progress.
@@ -642,8 +647,8 @@ from 339.8 MB to 53.6 MB. A narrower layer is sent exactly as before.
642
647
  The colour chip on a layer row opens the picker. For polygons it has **Fill** and **Outline**
643
648
  tabs.
644
649
 
645
- **A layer's look travels.** Right-click ▸ **Save style** writes it as an `.atlasstyle` file —
646
- colour, outline, opacity, symbology, labels, without the data — and **Apply style…** puts one on
650
+ **A layer's look travels.** Right-click ▸ **Save Style** writes it as an `.atlasstyle` file —
651
+ colour, outline, opacity, symbology, labels, without the data — and **Apply Style…** puts one on
647
652
  another layer; the *Symbology* tab's **Styles** group does the same for the layer picked there.
648
653
  That is how a client's operator colours carry from one map to the next: the same
649
654
  operator takes the same colour. Field names are matched against the new layer's own columns, as
@@ -845,9 +850,9 @@ picked in that tab: a label field (or *No labels*), **View Unplaced**, and **Con
845
850
  drawn whatever collides. It is how one label is moved or reworded for print without touching the
846
851
  data. The source layer stops labelling, so nothing prints twice; `Ctrl+Z` puts it back.
847
852
 
848
- ### Hex bins
853
+ ### Hex Bins
849
854
 
850
- **Right-click a point layer ▸ Hex bins** counts its points in hexagons and colours each by how many
855
+ **Right-click a point layer ▸ Hex Bins** counts its points in hexagons and colours each by how many
851
856
  it holds — a new layer, in natural-breaks classes, drawn just above the points, which are hidden
852
857
  underneath. At basin scale 30,000 laterals print as solid colour and a heatmap has no legend a
853
858
  reader can look a number up in; bins have both. Cells are about 40 across the data by default,
@@ -871,14 +876,32 @@ Across the top, as in ArcGIS Pro:
871
876
  | **Add Field** | Opens Fields with the new-field row ready |
872
877
  | **Calculate** | Calculate Field (below) |
873
878
 
879
+ **Click a row to select its feature**, as in Pro: **Ctrl+click** adds or removes one row and
880
+ **Shift+click** selects the run from the last row clicked, in the order the table shows. The
881
+ map draws the selection at once.
882
+
874
883
  ### Calculate Field
875
884
 
876
885
  Pick a field and write an expression, ArcGIS Pro's way: a field is `!NAME!` (click a field
877
- under the box to insert it). Arithmetic, comparisons, `a if test else b`, f-strings, text
878
- methods (`.upper()`, `.lower()`, `.title()`, `.strip()`, `.replace()`, `.zfill()` and a few
879
- more) and `abs`, `round`, `min`, `max`, `len`, `str`, `int`, `float`, `sqrt`, `floor`, `ceil`,
880
- `log`, `log10`, `exp` are available. Nothing else: an expression cannot import, open files or
881
- reach anything that is not the row's values.
886
+ under the box to insert it), and ArcMap's `[NAME]` works too. Arithmetic, comparisons,
887
+ `a if test else b`, f-strings, text methods (`.upper()`, `.lower()`, `.title()`, `.strip()`,
888
+ `.replace()`, `.zfill()` and a few more) and `abs`, `round`, `min`, `max`, `len`, `str`, `int`,
889
+ `float`, `bool`, `sqrt`, `floor`, `ceil`, `log`, `log10`, `exp`, `iif`, `upper`, `lower`, `trim` are
890
+ available. `iif(test, a, b)` only works out the side it takes, so
891
+ `iif(!N! != 0, 100 / !N!, 0)` never divides by zero. Nothing else: an expression cannot import,
892
+ open files or reach anything that is not the row's values.
893
+
894
+ **Suggestions** appear as you type: a few letters offer the functions and fields that start
895
+ with them, `!` or `[` offers the fields, and a `.` after a value offers the text methods. **Tab**
896
+ or **Enter** takes the highlighted one, the arrow keys move, **Esc** closes the list.
897
+
898
+ **A blank value stays blank.** `!EUR! * 1000` on a row with no EUR gives no value rather than
899
+ stopping the calculation, a comparison with a blank is false, and an f-string with a blank in it
900
+ is blank.
901
+
902
+ **A new field**: choose **+ New field…** at the bottom of the field list, name it and pick its
903
+ type, or *Type from the result* to have Atlas work the type out from what the expression gives.
904
+ The field is created as part of the same edit, so one Undo takes the column away again.
882
905
 
883
906
  With a selection it calculates **only the selected records**, and says so; otherwise all of
884
907
  them. **Preview** shows the first five results without changing anything. Every row is checked
@@ -898,9 +921,45 @@ Edits are **staged**, not written. The count of unsaved changes is shown, and:
898
921
 
899
922
  - **Save** writes back to the source file (only offered when there is a file to write to)
900
923
  - **Save As** writes a new GeoPackage
901
- - **Undo** / **Discard** roll back
924
+ - **Undo** / **Redo** / **Discard** roll back, and forward again. Redo is offered until the
925
+ next new edit
902
926
  - **Restore** recovers from the backup Atlas takes before its first write
903
927
 
928
+ ### Edit Vertices
929
+
930
+ **Tools ▸ Edit ▸ Vertices** reshapes features. A panel opens at the top right of the map: pick the
931
+ layer, then click a feature. With one feature already selected, the tool opens on it, as Pro's
932
+ does. The feature's vertices show as white handles with a smaller handle at the middle of each
933
+ edge. Then:
934
+
935
+ | To | Do |
936
+ |---|---|
937
+ | move a vertex | drag it |
938
+ | add a vertex | drag an edge's middle handle |
939
+ | delete a vertex | click it, then **Delete Vertex** or the **Delete** key |
940
+ | undo, redo | the panel's arrows, or **Ctrl+Z** / **Ctrl+Y** |
941
+ | put it all back | **Discard**, after a confirm: the shape edits on this layer go, attribute edits stay |
942
+ | stop editing | **✕** at the top right, or **Esc**. Unsaved edits are kept, to save or undo later |
943
+
944
+ **Snapping** lands a dragged vertex on another feature's vertex, or failing that on its nearest
945
+ line, within 12 pixels. Both are on by default and toggle in the panel. It snaps to the real
946
+ vertex even where the map is drawing a simplified copy of a large layer.
947
+
948
+ Every change is a staged edit, like a cell edit, so the attribute table's Undo, Save and Discard
949
+ cover it too. A change that would make a polygon cross itself is refused with the reason, a ring
950
+ keeps three corners and a line two, and a point can only move. **Save** writes to the layer's
951
+ file after a backup; on a layer with no file (a drawn AOI, a query result) it keeps the edits in
952
+ the project instead. A format that stores no geometry (CSV, Excel) cannot be reshaped: Save As a
953
+ GeoPackage first.
954
+
955
+ ### Layer Properties
956
+
957
+ **Right-click a layer ▸ Properties** shows where it came from and what it is, as Pro's Layer
958
+ Properties ▸ Source does: the file, feature class, format, size and date; the coordinate system
959
+ the file is in, beside the one Atlas holds it in (WGS 84) and draws it in (Web Mercator); the
960
+ feature count, geometry types, vertices and extent; and every field with its type and how many
961
+ are blank. A layer with no file says so.
962
+
904
963
  ### Layers with no file behind them
905
964
 
906
965
  A layer pulled from Databricks is a **query result**, not a file — there is nothing to write
@@ -926,6 +985,7 @@ rather than failing silently.
926
985
  | **Near** | How far is every feature from the nearest feature of another layer: adds a `dist_<layer>_<unit>` column, empty past an optional limit |
927
986
  | **Erase** | This layer, with another layer's polygons cut out of it. Clip's opposite |
928
987
  | **Simplify** | Lines and polygons with fewer vertices, none moved more than the tolerance; points are refused |
988
+ | **Summary Statistics** | Totals, averages and counts for each group, as a **table** in the Tables panel, not a layer |
929
989
 
930
990
  **History** (beside the tools) lists this session's runs, newest first. Picking one opens its tool
931
991
  with the same settings filled in and stops, so you can check them before pressing Run.
@@ -983,6 +1043,20 @@ Matches `arcpy.management.Dissolve`.
983
1043
  end to end and gives each run its own feature, so two unconnected roads called "Main" stay
984
1044
  two features.
985
1045
 
1046
+ ### Summary Statistics
1047
+
1048
+ Matches `arcpy.analysis.Statistics`. The input is a layer or a table, and the output is a
1049
+ **table**: it lands in the Tables panel, since it has no shapes to draw.
1050
+
1051
+ - **Group By** — the fields that split the rows into groups, picked as Dissolve's fields are.
1052
+ Tick none, *(Nothing)*, for a single row covering everything. A blank in a Group By field is a
1053
+ group of its own, and the result says how many rows it holds.
1054
+ - **Statistics** — field + statistic pairs: Dissolve's thirteen plus `MODE`, the commonest value
1055
+ (the lowest, on a tie). A text field is offered only what Pro allows text: `MIN`, `MAX`,
1056
+ `COUNT`, `FIRST`, `LAST`, `UNIQUE`, `CONCATENATE`, `MODE`.
1057
+ - Columns are named `SUM_EUR`, `MODE_OPERATOR` and so on. Blanks are left out of every statistic,
1058
+ and there is no `FREQUENCY` column: `COUNT` on a field with no blanks counts each group's rows.
1059
+
986
1060
  ---
987
1061
 
988
1062
  ## The Catalog pane
@@ -1021,6 +1095,10 @@ A container offers what you can **create** in it. An item offers what you can **
1021
1095
  | a map | Open, Rename…, Delete Map |
1022
1096
  | a layout | Open, Rename…, Delete Layout |
1023
1097
  | a feature class or dataset | Add To Current Map, Copy Path |
1098
+
1099
+ **A click selects a dataset; a double-click adds it to the map**, as do dragging it onto the map
1100
+ and *Add To Current Map*. A single click used to add it, which put layers on the map that nobody
1101
+ meant to add.
1024
1102
  | a geodatabase or folder | Add All Layers To Map, Copy Path, Refresh, Remove From Project |
1025
1103
 
1026
1104
  An **empty geodatabase registers**. Registering is not loading, and a `.gdb` you point Atlas at
@@ -280,6 +280,16 @@ Fewer vertices on lines and polygons, no edge moved more than `tolerance`, measu
280
280
  so the distance is true on the ground. Topology is preserved. Points are refused, and the result's
281
281
  warnings say how many vertices went.
282
282
 
283
+ ### `env.SummaryStatistics(table, statistics_fields, case_field=None, output=None, separator=", ")`
284
+
285
+ ArcGIS Pro's Summary Statistics: one row per unique set of `case_field` values, with each
286
+ statistic asked for, as a **table** with no geometry. `table` is a path, a DataFrame or a
287
+ GeoDataFrame, whose geometry is ignored. `statistics_fields` is `[[field, statistic], ...]`, for
288
+ example `[["EUR", "SUM"], ["OPERATOR", "MODE"]]`; the statistics are `SUMMARY_STATISTICS`
289
+ (Dissolve's plus `MODE`), and a text field takes only `SUMMARY_TEXT_STATISTICS`. Leave
290
+ `case_field` out for one row. Columns are named `SUM_EUR`; blanks are left out of every statistic,
291
+ and a blank case value is a group of its own, counted in the warnings.
292
+
283
293
  ---
284
294
 
285
295
  ## Pipelines and async
@@ -563,7 +573,7 @@ draws no text at all with no error.
563
573
  `rest_external_links_frames`, `connector_arrow_frames`
564
574
 
565
575
  **Geoprocessing** — `Clip`, `Buffer`, `Intersect`, `Union`, `Dissolve`, `SpatialJoin`, `Near`,
566
- `Erase`, `Simplify`, plus `*_async` for each, `SJ_MATCH`
576
+ `Erase`, `Simplify`, `SummaryStatistics`, plus `*_async` for each, `SJ_MATCH`
567
577
 
568
578
  **Pipeline** — `load` → `SpatialPipeline` → `.run()` / `.run_async()`
569
579