Repository: bluesky/scanspec (language: python)

Issue:
Enable Strict Mode for Pyright
As discussed with @coretl we would like to use the scanspec repository to prototype a number of modern python correctness tooling including pyright strict mode (see [docs](https://github.com/microsoft/pyright/blob/main/docs/configuration.md)).

## Acceptance Criteria
- Strict mode enabled per https://diamondlightsource.github.io/python-copier-template/main/how-to/strict-mode.html
- `tox -e type-checking` gives no errors


Discussion / hints:

Fix patch:
diff --git a/docs/conf.py b/docs/conf.py
index acfda3be..4e376ee9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -72,6 +72,7 @@
 # ('envvar', 'LD_LIBRARY_PATH').
 nitpick_ignore = [
     ("py:class", "scanspec.core.C"),
+    ("py:class", "scanspec.core.T"),
     ("py:class", "pydantic.config.ConfigDict"),
 ]
 
diff --git a/pyproject.toml b/pyproject.toml
index b0bf50e6..820f6aaf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,8 +63,8 @@ name = "Tom Cobb"
 version_file = "src/scanspec/_version.py"
 
 [tool.pyright]
-typeCheckingMode = "standard"
-reportMissingImports = false  # Ignore missing stubs in imported modules
+typeCheckingMode = "strict"
+reportMissingImports = false # Ignore missing stubs in imported modules
 
 [tool.pytest.ini_options]
 # Run pytest with all our checkers, and don't spam us with massive tracebacks on error
@@ -135,9 +135,5 @@ convention = "google"
 [tool.ruff.lint.per-file-ignores]
 
 "tests/**/*" = [
-    # By default, private member access is allowed in tests
-    # See https://github.com/DiamondLightSource/python-copier-template/issues/154
-    # Remove this line to forbid private member access in tests
-    "SLF001",
-    "D",      # Don't check docstrings in tests
+    "D", # Don't check docstrings in tests
 ]
diff --git a/schema.json b/schema.json
index c58c4fef..459f28a5 100644
--- a/schema.json
+++ b/schema.json
@@ -1,1862 +1,1 @@
-{
-    "openapi": "3.1.0",
-    "info": {
-        "title": "FastAPI",
-        "version": "0.1.1"
-    },
-    "paths": {
-        "/valid": {
-            "post": {
-                "summary": "Valid",
-                "description": "Validate wether a ScanSpec can produce a viable scan.\n\nArgs:\n    spec: The scanspec to validate\n\nReturns:\n    ValidResponse: A canonical version of the spec if it is valid.\n        An error otherwise.",
-                "operationId": "valid_valid_post",
-                "requestBody": {
-                    "content": {
-                        "application/json": {
-                            "schema": {
-                                "$ref": "#/components/schemas/Spec-Input",
-                                "examples": [
-                                    {
-                                        "outer": {
-                                            "axis": "y",
-                                            "start": 0.0,
-                                            "stop": 10.0,
-                                            "num": 3,
-                                            "type": "Line"
-                                        },
-                                        "inner": {
-                                            "axis": "x",
-                                            "start": 0.0,
-                                            "stop": 10.0,
-                                            "num": 4,
-                                            "type": "Line"
-                                        },
-                                        "type": "Product"
-                                    }
-                                ]
-                            }
-                        }
-                    },
-                    "required": true
-                },
-                "responses": {
-                    "200": {
-                        "description": "Successful Response",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/ValidResponse"
-                                }
-                            }
-                        }
-                    },
-                    "422": {
-                        "description": "Validation Error",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/HTTPValidationError"
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        },
-        "/midpoints": {
-            "post": {
-                "summary": "Midpoints",
-                "description": "Generate midpoints from a scanspec.\n\nA scanspec can produce bounded points (i.e. a point is valid if an\naxis is between a minimum and and a maximum, see /bounds). The midpoints\nare the middle of each set of bounds.\n\nArgs:\n    request: Scanspec and formatting info.\n\nReturns:\n    MidpointsResponse: Midpoints of the scan",
-                "operationId": "midpoints_midpoints_post",
-                "requestBody": {
-                    "content": {
-                        "application/json": {
-                            "schema": {
-                                "$ref": "#/components/schemas/PointsRequest",
-                                "examples": [
-                                    {
-                                        "spec": {
-                                            "outer": {
-                                                "axis": "y",
-                                                "start": 0.0,
-                                                "stop": 10.0,
-                                                "num": 3,
-                                                "type": "Line"
-                                            },
-                                            "inner": {
-                                                "axis": "x",
-                                                "start": 0.0,
-                                                "stop": 10.0,
-                                                "num": 4,
-                                                "type": "Line"
-                                            },
-                                            "type": "Product"
-                                        },
-                                        "max_frames": 1024,
-                                        "format": "FLOAT_LIST"
-                                    }
-                                ]
-                            }
-                        }
-                    },
-                    "required": true
-                },
-                "responses": {
-                    "200": {
-                        "description": "Successful Response",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/MidpointsResponse"
-                                }
-                            }
-                        }
-                    },
-                    "422": {
-                        "description": "Validation Error",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/HTTPValidationError"
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        },
-        "/bounds": {
-            "post": {
-                "summary": "Bounds",
-                "description": "Generate bounds from a scanspec.\n\nA scanspec can produce points with lower and upper bounds.\n\nArgs:\n    request: Scanspec and formatting info.\n\nReturns:\n    BoundsResponse: Bounds of the scan",
-                "operationId": "bounds_bounds_post",
-                "requestBody": {
-                    "content": {
-                        "application/json": {
-                            "schema": {
-                                "$ref": "#/components/schemas/PointsRequest",
-                                "examples": [
-                                    {
-                                        "spec": {
-                                            "outer": {
-                                                "axis": "y",
-                                                "start": 0.0,
-                                                "stop": 10.0,
-                                                "num": 3,
-                                                "type": "Line"
-                                            },
-                                            "inner": {
-                                                "axis": "x",
-                                                "start": 0.0,
-                                                "stop": 10.0,
-                                                "num": 4,
-                                                "type": "Line"
-                                            },
-                                            "type": "Product"
-                                        },
-                                        "max_frames": 1024,
-                                        "format": "FLOAT_LIST"
-                                    }
-                                ]
-                            }
-                        }
-                    },
-                    "required": true
-                },
-                "responses": {
-                    "200": {
-                        "description": "Successful Response",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/BoundsResponse"
-                                }
-                            }
-                        }
-                    },
-                    "422": {
-                        "description": "Validation Error",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/HTTPValidationError"
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        },
-        "/gap": {
-            "post": {
-                "summary": "Gap",
-                "description": "Generate gaps from a scanspec.\n\nA scanspec may indicate if there is a gap between two frames.\nThe array returned corresponds to whether or not there is a gap\nafter each frame.\n\nArgs:\n    spec: Scanspec and formatting info.\n\nReturns:\n    GapResponse: Bounds of the scan",
-                "operationId": "gap_gap_post",
-                "requestBody": {
-                    "content": {
-                        "application/json": {
-                            "schema": {
-                                "$ref": "#/components/schemas/Spec-Input",
-                                "examples": [
-                                    {
-                                        "outer": {
-                                            "axis": "y",
-                                            "start": 0.0,
-                                            "stop": 10.0,
-                                            "num": 3,
-                                            "type": "Line"
-                                        },
-                                        "inner": {
-                                            "axis": "x",
-                                            "start": 0.0,
-                                            "stop": 10.0,
-                                            "num": 4,
-                                            "type": "Line"
-                                        },
-                                        "type": "Product"
-                                    }
-                                ]
-                            }
-                        }
-                    },
-                    "required": true
-                },
-                "responses": {
-                    "200": {
-                        "description": "Successful Response",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/GapResponse"
-                                }
-                            }
-                        }
-                    },
-                    "422": {
-                        "description": "Validation Error",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/HTTPValidationError"
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        },
-        "/smalleststep": {
-            "post": {
-                "summary": "Smallest Step",
-                "description": "Calculate the smallest step in a scan, both absolutely and per-axis.\n\nIgnore any steps of size 0.\n\nArgs:\n    spec: The spec of the scan\n\nReturns:\n    SmallestStepResponse: A description of the smallest steps in the spec",
-                "operationId": "smallest_step_smalleststep_post",
-                "requestBody": {
-                    "content": {
-                        "application/json": {
-                            "schema": {
-                                "$ref": "#/components/schemas/Spec-Input",
-                                "examples": [
-                                    {
-                                        "outer": {
-                                            "axis": "y",
-                                            "start": 0.0,
-                                            "stop": 10.0,
-                                            "num": 3,
-                                            "type": "Line"
-                                        },
-                                        "inner": {
-                                            "axis": "x",
-                                            "start": 0.0,
-                                            "stop": 10.0,
-                                            "num": 4,
-                                            "type": "Line"
-                                        },
-                                        "type": "Product"
-                                    }
-                                ]
-                            }
-                        }
-                    },
-                    "required": true
-                },
-                "responses": {
-                    "200": {
-                        "description": "Successful Response",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/SmallestStepResponse"
-                                }
-                            }
-                        }
-                    },
-                    "422": {
-                        "description": "Validation Error",
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/HTTPValidationError"
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    },
-    "components": {
-        "schemas": {
-            "BoundsResponse": {
-                "properties": {
-                    "total_frames": {
-                        "type": "integer",
-                        "title": "Total Frames",
-                        "description": "Total number of frames in spec"
-                    },
-                    "returned_frames": {
-                        "type": "integer",
-                        "title": "Returned Frames",
-                        "description": "Total of number of frames in this response, may be less than total_frames due to downsampling etc."
-                    },
-                    "format": {
-                        "$ref": "#/components/schemas/PointsFormat",
-                        "description": "Format of returned point data"
-                    },
-                    "lower": {
-                        "additionalProperties": {
-                            "anyOf": [
-                                {
-                                    "type": "string"
-                                },
-                                {
-                                    "items": {
-                                        "type": "number"
-                                    },
-                                    "type": "array"
-                                }
-                            ]
-                        },
-                        "type": "object",
-                        "title": "Lower",
-                        "description": "Lower bounds of scan frames if different from midpoints"
-                    },
-                    "upper": {
-                        "additionalProperties": {
-                            "anyOf": [
-                                {
-                                    "type": "string"
-                                },
-                                {
-                                    "items": {
-                                        "type": "number"
-                                    },
-                                    "type": "array"
-                                }
-                            ]
-                        },
-                        "type": "object",
-                        "title": "Upper",
-                        "description": "Upper bounds of scan frames if different from midpoints"
-                    }
-                },
-                "type": "object",
-                "required": [
-                    "total_frames",
-                    "returned_frames",
-                    "format",
-                    "lower",
-                    "upper"
-                ],
-                "title": "BoundsResponse",
-                "description": "Bounds of a generated scan."
-            },
-            "Circle": {
-                "properties": {
-                    "x_axis": {
-                        "title": "X Axis",
-                        "description": "The name matching the x axis of the spec"
-                    },
-                    "y_axis": {
-                        "title": "Y Axis",
-                        "description": "The name matching the y axis of the spec"
-                    },
-                    "x_middle": {
-                        "type": "number",
-                        "title": "X Middle",
-                        "description": "The central x point of the circle"
-                    },
-                    "y_middle": {
-                        "type": "number",
-                        "title": "Y Middle",
-                        "description": "The central y point of the circle"
-                    },
-                    "radius": {
-                        "type": "number",
-                        "exclusiveMinimum": 0.0,
-                        "title": "Radius",
-                        "description": "Radius of the circle"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Circle"
-                        ],
-                        "const": "Circle",
-                        "title": "Type",
-                        "default": "Circle"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "x_axis",
-                    "y_axis",
-                    "x_middle",
-                    "y_middle",
-                    "radius"
-                ],
-                "title": "Circle",
-                "description": "Mask contains points of axis within an xy circle of given radius.\n\n.. example_spec::\n\n    from scanspec.regions import Circle\n    from scanspec.specs import Line\n\n    grid = Line(\"y\", 1, 3, 10) * ~Line(\"x\", 0, 2, 10)\n    spec = grid & Circle(\"x\", \"y\", 1, 2, 0.9)"
-            },
-            "CombinationOf-Input": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "CombinationOf"
-                        ],
-                        "const": "CombinationOf",
-                        "title": "Type",
-                        "default": "CombinationOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "CombinationOf",
-                "description": "Abstract baseclass for a combination of two regions, left and right."
-            },
-            "CombinationOf-Output": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "CombinationOf"
-                        ],
-                        "const": "CombinationOf",
-                        "title": "Type",
-                        "default": "CombinationOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "CombinationOf",
-                "description": "Abstract baseclass for a combination of two regions, left and right."
-            },
-            "Concat-Input": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "The left-hand Spec to Concat, midpoints will appear earlier"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "The right-hand Spec to Concat, midpoints will appear later"
-                    },
-                    "gap": {
-                        "type": "boolean",
-                        "title": "Gap",
-                        "description": "If True, force a gap in the output at the join",
-                        "default": false
-                    },
-                    "check_path_changes": {
-                        "type": "boolean",
-                        "title": "Check Path Changes",
-                        "description": "If True path through scan will not be modified by squash",
-                        "default": true
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Concat"
-                        ],
-                        "const": "Concat",
-                        "title": "Type",
-                        "default": "Concat"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "Concat",
-                "description": "Concatenate two Specs together, running one after the other.\n\nEach Dimension of left and right must contain the same axes. Typically\nformed using `Spec.concat`.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"x\", 1, 3, 3).concat(Line(\"x\", 4, 5, 5))"
-            },
-            "Concat-Output": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The left-hand Spec to Concat, midpoints will appear earlier"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The right-hand Spec to Concat, midpoints will appear later"
-                    },
-                    "gap": {
-                        "type": "boolean",
-                        "title": "Gap",
-                        "description": "If True, force a gap in the output at the join",
-                        "default": false
-                    },
-                    "check_path_changes": {
-                        "type": "boolean",
-                        "title": "Check Path Changes",
-                        "description": "If True path through scan will not be modified by squash",
-                        "default": true
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Concat"
-                        ],
-                        "const": "Concat",
-                        "title": "Type",
-                        "default": "Concat"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "Concat",
-                "description": "Concatenate two Specs together, running one after the other.\n\nEach Dimension of left and right must contain the same axes. Typically\nformed using `Spec.concat`.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"x\", 1, 3, 3).concat(Line(\"x\", 4, 5, 5))"
-            },
-            "DifferenceOf-Input": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "DifferenceOf"
-                        ],
-                        "const": "DifferenceOf",
-                        "title": "Type",
-                        "default": "DifferenceOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "DifferenceOf",
-                "description": "A point is in DifferenceOf(a, b) if in a and not in b.\n\nTypically created with the ``-`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) - Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True, False, False, False])"
-            },
-            "DifferenceOf-Output": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "DifferenceOf"
-                        ],
-                        "const": "DifferenceOf",
-                        "title": "Type",
-                        "default": "DifferenceOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "DifferenceOf",
-                "description": "A point is in DifferenceOf(a, b) if in a and not in b.\n\nTypically created with the ``-`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) - Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True, False, False, False])"
-            },
-            "Ellipse": {
-                "properties": {
-                    "x_axis": {
-                        "title": "X Axis",
-                        "description": "The name matching the x axis of the spec"
-                    },
-                    "y_axis": {
-                        "title": "Y Axis",
-                        "description": "The name matching the y axis of the spec"
-                    },
-                    "x_middle": {
-                        "type": "number",
-                        "title": "X Middle",
-                        "description": "The central x point of the ellipse"
-                    },
-                    "y_middle": {
-                        "type": "number",
-                        "title": "Y Middle",
-                        "description": "The central y point of the ellipse"
-                    },
-                    "x_radius": {
-                        "type": "number",
-                        "exclusiveMinimum": 0.0,
-                        "title": "X Radius",
-                        "description": "The radius along the x axis of the ellipse"
-                    },
-                    "y_radius": {
-                        "type": "number",
-                        "exclusiveMinimum": 0.0,
-                        "title": "Y Radius",
-                        "description": "The radius along the y axis of the ellipse"
-                    },
-                    "angle": {
-                        "type": "number",
-                        "title": "Angle",
-                        "description": "The angle of the ellipse (degrees)",
-                        "default": 0.0
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Ellipse"
-                        ],
-                        "const": "Ellipse",
-                        "title": "Type",
-                        "default": "Ellipse"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "x_axis",
-                    "y_axis",
-                    "x_middle",
-                    "y_middle",
-                    "x_radius",
-                    "y_radius"
-                ],
-                "title": "Ellipse",
-                "description": "Mask contains points of axis within an xy ellipse of given radius.\n\n.. example_spec::\n\n    from scanspec.regions import Ellipse\n    from scanspec.specs import Line\n\n    grid = Line(\"y\", 3, 8, 10) * ~Line(\"x\", 1 ,8, 10)\n    spec = grid & Ellipse(\"x\", \"y\", 5, 5, 2, 3, 75)"
-            },
-            "GapResponse": {
-                "properties": {
-                    "gap": {
-                        "items": {
-                            "type": "boolean"
-                        },
-                        "type": "array",
-                        "title": "Gap",
-                        "description": "Boolean array indicating if there is a gap between each frame"
-                    }
-                },
-                "type": "object",
-                "required": [
-                    "gap"
-                ],
-                "title": "GapResponse",
-                "description": "Presence of gaps in a generated scan."
-            },
-            "HTTPValidationError": {
-                "properties": {
-                    "detail": {
-                        "items": {
-                            "$ref": "#/components/schemas/ValidationError"
-                        },
-                        "type": "array",
-                        "title": "Detail"
-                    }
-                },
-                "type": "object",
-                "title": "HTTPValidationError"
-            },
-            "IntersectionOf-Input": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "IntersectionOf"
-                        ],
-                        "const": "IntersectionOf",
-                        "title": "Type",
-                        "default": "IntersectionOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "IntersectionOf",
-                "description": "A point is in IntersectionOf(a, b) if in both a and b.\n\nTypically created with the ``&`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) & Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False, False,  True, False, False])"
-            },
-            "IntersectionOf-Output": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "IntersectionOf"
-                        ],
-                        "const": "IntersectionOf",
-                        "title": "Type",
-                        "default": "IntersectionOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "IntersectionOf",
-                "description": "A point is in IntersectionOf(a, b) if in both a and b.\n\nTypically created with the ``&`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) & Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False, False,  True, False, False])"
-            },
-            "Line": {
-                "properties": {
-                    "axis": {
-                        "title": "Axis",
-                        "description": "An identifier for what to move"
-                    },
-                    "start": {
-                        "type": "number",
-                        "title": "Start",
-                        "description": "Midpoint of the first point of the line"
-                    },
-                    "stop": {
-                        "type": "number",
-                        "title": "Stop",
-                        "description": "Midpoint of the last point of the line"
-                    },
-                    "num": {
-                        "type": "integer",
-                        "minimum": 1.0,
-                        "title": "Num",
-                        "description": "Number of frames to produce"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Line"
-                        ],
-                        "const": "Line",
-                        "title": "Type",
-                        "default": "Line"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "axis",
-                    "start",
-                    "stop",
-                    "num"
-                ],
-                "title": "Line",
-                "description": "Linearly spaced frames with start and stop as first and last midpoints.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"x\", 1, 2, 5)"
-            },
-            "Mask-Input": {
-                "properties": {
-                    "spec": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "The Spec containing the source midpoints"
-                    },
-                    "region": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The Region that midpoints will be inside"
-                    },
-                    "check_path_changes": {
-                        "type": "boolean",
-                        "title": "Check Path Changes",
-                        "description": "If True path through scan will not be modified by squash",
-                        "default": true
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Mask"
-                        ],
-                        "const": "Mask",
-                        "title": "Type",
-                        "default": "Mask"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "spec",
-                    "region"
-                ],
-                "title": "Mask",
-                "description": "Restrict Spec to only midpoints that fall inside the given Region.\n\nTypically created with the ``&`` operator. It also pushes down the\n``& | ^ -`` operators to its `Region` to avoid the need for brackets on\ncombinations of Regions.\n\nIf a Region spans multiple Frames objects, they will be squashed together.\n\n.. example_spec::\n\n    from scanspec.regions import Circle\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 3, 3) * Line(\"x\", 3, 5, 5) & Circle(\"x\", \"y\", 4, 2, 1.2)\n\nSee Also: `why-squash-can-change-path`"
-            },
-            "Mask-Output": {
-                "properties": {
-                    "spec": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The Spec containing the source midpoints"
-                    },
-                    "region": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The Region that midpoints will be inside"
-                    },
-                    "check_path_changes": {
-                        "type": "boolean",
-                        "title": "Check Path Changes",
-                        "description": "If True path through scan will not be modified by squash",
-                        "default": true
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Mask"
-                        ],
-                        "const": "Mask",
-                        "title": "Type",
-                        "default": "Mask"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "spec",
-                    "region"
-                ],
-                "title": "Mask",
-                "description": "Restrict Spec to only midpoints that fall inside the given Region.\n\nTypically created with the ``&`` operator. It also pushes down the\n``& | ^ -`` operators to its `Region` to avoid the need for brackets on\ncombinations of Regions.\n\nIf a Region spans multiple Frames objects, they will be squashed together.\n\n.. example_spec::\n\n    from scanspec.regions import Circle\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 3, 3) * Line(\"x\", 3, 5, 5) & Circle(\"x\", \"y\", 4, 2, 1.2)\n\nSee Also: `why-squash-can-change-path`"
-            },
-            "MidpointsResponse": {
-                "properties": {
-                    "total_frames": {
-                        "type": "integer",
-                        "title": "Total Frames",
-                        "description": "Total number of frames in spec"
-                    },
-                    "returned_frames": {
-                        "type": "integer",
-                        "title": "Returned Frames",
-                        "description": "Total of number of frames in this response, may be less than total_frames due to downsampling etc."
-                    },
-                    "format": {
-                        "$ref": "#/components/schemas/PointsFormat",
-                        "description": "Format of returned point data"
-                    },
-                    "midpoints": {
-                        "additionalProperties": {
-                            "anyOf": [
-                                {
-                                    "type": "string"
-                                },
-                                {
-                                    "items": {
-                                        "type": "number"
-                                    },
-                                    "type": "array"
-                                }
-                            ]
-                        },
-                        "type": "object",
-                        "title": "Midpoints",
-                        "description": "The midpoints of scan frames for each axis"
-                    }
-                },
-                "type": "object",
-                "required": [
-                    "total_frames",
-                    "returned_frames",
-                    "format",
-                    "midpoints"
-                ],
-                "title": "MidpointsResponse",
-                "description": "Midpoints of a generated scan."
-            },
-            "PointsFormat": {
-                "type": "string",
-                "enum": [
-                    "STRING",
-                    "FLOAT_LIST",
-                    "BASE64_ENCODED"
-                ],
-                "title": "PointsFormat",
-                "description": "Formats in which we can return points."
-            },
-            "PointsRequest": {
-                "properties": {
-                    "spec": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "The spec from which to generate points"
-                    },
-                    "max_frames": {
-                        "anyOf": [
-                            {
-                                "type": "integer"
-                            },
-                            {
-                                "type": "null"
-                            }
-                        ],
-                        "title": "Max Frames",
-                        "description": "The maximum number of points to return, if None will return as many as calculated",
-                        "default": 100000
-                    },
-                    "format": {
-                        "$ref": "#/components/schemas/PointsFormat",
-                        "description": "The format in which to output the points data",
-                        "default": "FLOAT_LIST"
-                    }
-                },
-                "type": "object",
-                "required": [
-                    "spec"
-                ],
-                "title": "PointsRequest",
-                "description": "A request for generated scan points."
-            },
-            "Polygon": {
-                "properties": {
-                    "x_axis": {
-                        "title": "X Axis",
-                        "description": "The name matching the x axis of the spec"
-                    },
-                    "y_axis": {
-                        "title": "Y Axis",
-                        "description": "The name matching the y axis of the spec"
-                    },
-                    "x_verts": {
-                        "items": {
-                            "type": "number"
-                        },
-                        "type": "array",
-                        "minItems": 3,
-                        "title": "X Verts",
-                        "description": "The Nx1 x coordinates of the polygons vertices"
-                    },
-                    "y_verts": {
-                        "items": {
-                            "type": "number"
-                        },
-                        "type": "array",
-                        "minItems": 3,
-                        "title": "Y Verts",
-                        "description": "The Nx1 y coordinates of the polygons vertices"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Polygon"
-                        ],
-                        "const": "Polygon",
-                        "title": "Type",
-                        "default": "Polygon"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "x_axis",
-                    "y_axis",
-                    "x_verts",
-                    "y_verts"
-                ],
-                "title": "Polygon",
-                "description": "Mask contains points of axis within a rotated xy polygon.\n\n.. example_spec::\n\n    from scanspec.regions import Polygon\n    from scanspec.specs import Line\n\n    grid = Line(\"y\", 3, 8, 10) * ~Line(\"x\", 1 ,8, 10)\n    spec = grid & Polygon(\"x\", \"y\", [1.0, 6.0, 8.0, 2.0], [4.0, 10.0, 6.0, 1.0])"
-            },
-            "Product-Input": {
-                "properties": {
-                    "outer": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "Will be executed once"
-                    },
-                    "inner": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "Will be executed len(outer) times"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Product"
-                        ],
-                        "const": "Product",
-                        "title": "Type",
-                        "default": "Product"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "outer",
-                    "inner"
-                ],
-                "title": "Product",
-                "description": "Outer product of two Specs, nesting inner within outer.\n\nThis means that inner will run in its entirety at each point in outer.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 2, 3) * Line(\"x\", 3, 4, 12)"
-            },
-            "Product-Output": {
-                "properties": {
-                    "outer": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "Will be executed once"
-                    },
-                    "inner": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "Will be executed len(outer) times"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Product"
-                        ],
-                        "const": "Product",
-                        "title": "Type",
-                        "default": "Product"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "outer",
-                    "inner"
-                ],
-                "title": "Product",
-                "description": "Outer product of two Specs, nesting inner within outer.\n\nThis means that inner will run in its entirety at each point in outer.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 2, 3) * Line(\"x\", 3, 4, 12)"
-            },
-            "Range": {
-                "properties": {
-                    "axis": {
-                        "title": "Axis",
-                        "description": "The name matching the axis to mask in spec"
-                    },
-                    "min": {
-                        "type": "number",
-                        "title": "Min",
-                        "description": "The minimum inclusive value in the region"
-                    },
-                    "max": {
-                        "type": "number",
-                        "title": "Max",
-                        "description": "The minimum inclusive value in the region"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Range"
-                        ],
-                        "const": "Range",
-                        "title": "Type",
-                        "default": "Range"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "axis",
-                    "min",
-                    "max"
-                ],
-                "title": "Range",
-                "description": "Mask contains points of axis >= min and <= max.\n\n>>> r = Range(\"x\", 1, 2)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True,  True, False, False])"
-            },
-            "Rectangle": {
-                "properties": {
-                    "x_axis": {
-                        "title": "X Axis",
-                        "description": "The name matching the x axis of the spec"
-                    },
-                    "y_axis": {
-                        "title": "Y Axis",
-                        "description": "The name matching the y axis of the spec"
-                    },
-                    "x_min": {
-                        "type": "number",
-                        "title": "X Min",
-                        "description": "Minimum inclusive x value in the region"
-                    },
-                    "y_min": {
-                        "type": "number",
-                        "title": "Y Min",
-                        "description": "Minimum inclusive y value in the region"
-                    },
-                    "x_max": {
-                        "type": "number",
-                        "title": "X Max",
-                        "description": "Maximum inclusive x value in the region"
-                    },
-                    "y_max": {
-                        "type": "number",
-                        "title": "Y Max",
-                        "description": "Maximum inclusive y value in the region"
-                    },
-                    "angle": {
-                        "type": "number",
-                        "title": "Angle",
-                        "description": "Clockwise rotation angle of the rectangle",
-                        "default": 0.0
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Rectangle"
-                        ],
-                        "const": "Rectangle",
-                        "title": "Type",
-                        "default": "Rectangle"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "x_axis",
-                    "y_axis",
-                    "x_min",
-                    "y_min",
-                    "x_max",
-                    "y_max"
-                ],
-                "title": "Rectangle",
-                "description": "Mask contains points of axis within a rotated xy rectangle.\n\n.. example_spec::\n\n    from scanspec.regions import Rectangle\n    from scanspec.specs import Line\n\n    grid = Line(\"y\", 1, 3, 10) * ~Line(\"x\", 0, 2, 10)\n    spec = grid & Rectangle(\"x\", \"y\", 0, 1.1, 1.5, 2.1, 30)"
-            },
-            "Region-Input": {
-                "oneOf": [
-                    {
-                        "$ref": "#/components/schemas/CombinationOf-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/UnionOf-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/IntersectionOf-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/DifferenceOf-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/SymmetricDifferenceOf-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Range"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Rectangle"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Polygon"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Circle"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Ellipse"
-                    }
-                ],
-                "discriminator": {
-                    "propertyName": "type",
-                    "mapping": {
-                        "Circle": "#/components/schemas/Circle",
-                        "CombinationOf": "#/components/schemas/CombinationOf-Input",
-                        "DifferenceOf": "#/components/schemas/DifferenceOf-Input",
-                        "Ellipse": "#/components/schemas/Ellipse",
-                        "IntersectionOf": "#/components/schemas/IntersectionOf-Input",
-                        "Polygon": "#/components/schemas/Polygon",
-                        "Range": "#/components/schemas/Range",
-                        "Rectangle": "#/components/schemas/Rectangle",
-                        "SymmetricDifferenceOf": "#/components/schemas/SymmetricDifferenceOf-Input",
-                        "UnionOf": "#/components/schemas/UnionOf-Input"
-                    }
-                }
-            },
-            "Region-Output": {
-                "oneOf": [
-                    {
-                        "$ref": "#/components/schemas/CombinationOf-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/UnionOf-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/IntersectionOf-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/DifferenceOf-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/SymmetricDifferenceOf-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Range"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Rectangle"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Polygon"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Circle"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Ellipse"
-                    }
-                ],
-                "discriminator": {
-                    "propertyName": "type",
-                    "mapping": {
-                        "Circle": "#/components/schemas/Circle",
-                        "CombinationOf": "#/components/schemas/CombinationOf-Output",
-                        "DifferenceOf": "#/components/schemas/DifferenceOf-Output",
-                        "Ellipse": "#/components/schemas/Ellipse",
-                        "IntersectionOf": "#/components/schemas/IntersectionOf-Output",
-                        "Polygon": "#/components/schemas/Polygon",
-                        "Range": "#/components/schemas/Range",
-                        "Rectangle": "#/components/schemas/Rectangle",
-                        "SymmetricDifferenceOf": "#/components/schemas/SymmetricDifferenceOf-Output",
-                        "UnionOf": "#/components/schemas/UnionOf-Output"
-                    }
-                }
-            },
-            "Repeat": {
-                "properties": {
-                    "num": {
-                        "type": "integer",
-                        "minimum": 1.0,
-                        "title": "Num",
-                        "description": "Number of frames to produce"
-                    },
-                    "gap": {
-                        "type": "boolean",
-                        "title": "Gap",
-                        "description": "If False and the slowest of the stack of Frames is snaked then the end and start of consecutive iterations of Spec will have no gap",
-                        "default": true
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Repeat"
-                        ],
-                        "const": "Repeat",
-                        "title": "Type",
-                        "default": "Repeat"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "num"
-                ],
-                "title": "Repeat",
-                "description": "Repeat an empty frame num times.\n\nCan be used on the outside of a scan to repeat the same scan many times.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = 2 * ~Line.bounded(\"x\", 3, 4, 1)\n\nIf you want snaked axes to have no gap between iterations you can do:\n\n.. example_spec::\n\n    from scanspec.specs import Line, Repeat\n\n    spec = Repeat(2, gap=False) * ~Line.bounded(\"x\", 3, 4, 1)\n\n.. note:: There is no turnaround arrow at x=4"
-            },
-            "SmallestStepResponse": {
-                "properties": {
-                    "absolute": {
-                        "type": "number",
-                        "title": "Absolute",
-                        "description": "Absolute smallest distance between two points on a single axis"
-                    },
-                    "per_axis": {
-                        "additionalProperties": {
-                            "type": "number"
-                        },
-                        "type": "object",
-                        "title": "Per Axis",
-                        "description": "Smallest distance between two points on each axis"
-                    }
-                },
-                "type": "object",
-                "required": [
-                    "absolute",
-                    "per_axis"
-                ],
-                "title": "SmallestStepResponse",
-                "description": "Information about the smallest steps between points in a spec."
-            },
-            "Snake-Input": {
-                "properties": {
-                    "spec": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "The Spec to run in reverse every other iteration"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Snake"
-                        ],
-                        "const": "Snake",
-                        "title": "Type",
-                        "default": "Snake"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "spec"
-                ],
-                "title": "Snake",
-                "description": "Run the Spec in reverse on every other iteration when nested.\n\nTypically created with the ``~`` operator.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 3, 3) * ~Line(\"x\", 3, 5, 5)"
-            },
-            "Snake-Output": {
-                "properties": {
-                    "spec": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The Spec to run in reverse every other iteration"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Snake"
-                        ],
-                        "const": "Snake",
-                        "title": "Type",
-                        "default": "Snake"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "spec"
-                ],
-                "title": "Snake",
-                "description": "Run the Spec in reverse on every other iteration when nested.\n\nTypically created with the ``~`` operator.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 3, 3) * ~Line(\"x\", 3, 5, 5)"
-            },
-            "Spec-Input": {
-                "oneOf": [
-                    {
-                        "$ref": "#/components/schemas/Product-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Repeat"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Zip-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Mask-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Snake-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Concat-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Squash-Input"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Line"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Static"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Spiral"
-                    }
-                ],
-                "discriminator": {
-                    "propertyName": "type",
-                    "mapping": {
-                        "Concat": "#/components/schemas/Concat-Input",
-                        "Line": "#/components/schemas/Line",
-                        "Mask": "#/components/schemas/Mask-Input",
-                        "Product": "#/components/schemas/Product-Input",
-                        "Repeat": "#/components/schemas/Repeat",
-                        "Snake": "#/components/schemas/Snake-Input",
-                        "Spiral": "#/components/schemas/Spiral",
-                        "Squash": "#/components/schemas/Squash-Input",
-                        "Static": "#/components/schemas/Static",
-                        "Zip": "#/components/schemas/Zip-Input"
-                    }
-                }
-            },
-            "Spec-Output": {
-                "oneOf": [
-                    {
-                        "$ref": "#/components/schemas/Product-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Repeat"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Zip-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Mask-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Snake-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Concat-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Squash-Output"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Line"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Static"
-                    },
-                    {
-                        "$ref": "#/components/schemas/Spiral"
-                    }
-                ],
-                "discriminator": {
-                    "propertyName": "type",
-                    "mapping": {
-                        "Concat": "#/components/schemas/Concat-Output",
-                        "Line": "#/components/schemas/Line",
-                        "Mask": "#/components/schemas/Mask-Output",
-                        "Product": "#/components/schemas/Product-Output",
-                        "Repeat": "#/components/schemas/Repeat",
-                        "Snake": "#/components/schemas/Snake-Output",
-                        "Spiral": "#/components/schemas/Spiral",
-                        "Squash": "#/components/schemas/Squash-Output",
-                        "Static": "#/components/schemas/Static",
-                        "Zip": "#/components/schemas/Zip-Output"
-                    }
-                }
-            },
-            "Spiral": {
-                "properties": {
-                    "x_axis": {
-                        "title": "X Axis",
-                        "description": "An identifier for what to move for x"
-                    },
-                    "y_axis": {
-                        "title": "Y Axis",
-                        "description": "An identifier for what to move for y"
-                    },
-                    "x_start": {
-                        "type": "number",
-                        "title": "X Start",
-                        "description": "x centre of the spiral"
-                    },
-                    "y_start": {
-                        "type": "number",
-                        "title": "Y Start",
-                        "description": "y centre of the spiral"
-                    },
-                    "x_range": {
-                        "type": "number",
-                        "title": "X Range",
-                        "description": "x width of the spiral"
-                    },
-                    "y_range": {
-                        "type": "number",
-                        "title": "Y Range",
-                        "description": "y width of the spiral"
-                    },
-                    "num": {
-                        "type": "integer",
-                        "minimum": 1.0,
-                        "title": "Num",
-                        "description": "Number of frames to produce"
-                    },
-                    "rotate": {
-                        "type": "number",
-                        "title": "Rotate",
-                        "description": "How much to rotate the angle of the spiral",
-                        "default": 0.0
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Spiral"
-                        ],
-                        "const": "Spiral",
-                        "title": "Type",
-                        "default": "Spiral"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "x_axis",
-                    "y_axis",
-                    "x_start",
-                    "y_start",
-                    "x_range",
-                    "y_range",
-                    "num"
-                ],
-                "title": "Spiral",
-                "description": "Archimedean spiral of \"x_axis\" and \"y_axis\".\n\nStarts at centre point (\"x_start\", \"y_start\") with angle \"rotate\". Produces\n\"num\" points in a spiral spanning width of \"x_range\" and height of \"y_range\"\n\n.. example_spec::\n\n    from scanspec.specs import Spiral\n\n    spec = Spiral(\"x\", \"y\", 1, 5, 10, 50, 30)"
-            },
-            "Squash-Input": {
-                "properties": {
-                    "spec": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "The Spec to squash the dimensions of"
-                    },
-                    "check_path_changes": {
-                        "type": "boolean",
-                        "title": "Check Path Changes",
-                        "description": "If True path through scan will not be modified by squash",
-                        "default": true
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Squash"
-                        ],
-                        "const": "Squash",
-                        "title": "Type",
-                        "default": "Squash"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "spec"
-                ],
-                "title": "Squash",
-                "description": "Squash a stack of Frames together into a single expanded Frames object.\n\nSee Also:\n    `why-squash-can-change-path`\n\n.. example_spec::\n\n    from scanspec.specs import Line, Squash\n\n    spec = Squash(Line(\"y\", 1, 2, 3) * Line(\"x\", 0, 1, 4))"
-            },
-            "Squash-Output": {
-                "properties": {
-                    "spec": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The Spec to squash the dimensions of"
-                    },
-                    "check_path_changes": {
-                        "type": "boolean",
-                        "title": "Check Path Changes",
-                        "description": "If True path through scan will not be modified by squash",
-                        "default": true
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Squash"
-                        ],
-                        "const": "Squash",
-                        "title": "Type",
-                        "default": "Squash"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "spec"
-                ],
-                "title": "Squash",
-                "description": "Squash a stack of Frames together into a single expanded Frames object.\n\nSee Also:\n    `why-squash-can-change-path`\n\n.. example_spec::\n\n    from scanspec.specs import Line, Squash\n\n    spec = Squash(Line(\"y\", 1, 2, 3) * Line(\"x\", 0, 1, 4))"
-            },
-            "Static": {
-                "properties": {
-                    "axis": {
-                        "title": "Axis",
-                        "description": "An identifier for what to move"
-                    },
-                    "value": {
-                        "type": "number",
-                        "title": "Value",
-                        "description": "The value at each point"
-                    },
-                    "num": {
-                        "type": "integer",
-                        "minimum": 1.0,
-                        "title": "Num",
-                        "description": "Number of frames to produce",
-                        "default": 1
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Static"
-                        ],
-                        "const": "Static",
-                        "title": "Type",
-                        "default": "Static"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "axis",
-                    "value"
-                ],
-                "title": "Static",
-                "description": "A static frame, repeated num times, with axis at value.\n\nCan be used to set axis=value at every point in a scan.\n\n.. example_spec::\n\n    from scanspec.specs import Line, Static\n\n    spec = Line(\"y\", 1, 2, 3).zip(Static(\"x\", 3))"
-            },
-            "SymmetricDifferenceOf-Input": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "SymmetricDifferenceOf"
-                        ],
-                        "const": "SymmetricDifferenceOf",
-                        "title": "Type",
-                        "default": "SymmetricDifferenceOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "SymmetricDifferenceOf",
-                "description": "A point is in SymmetricDifferenceOf(a, b) if in either a or b, but not both.\n\nTypically created with the ``^`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) ^ Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True, False,  True, False])"
-            },
-            "SymmetricDifferenceOf-Output": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "SymmetricDifferenceOf"
-                        ],
-                        "const": "SymmetricDifferenceOf",
-                        "title": "Type",
-                        "default": "SymmetricDifferenceOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "SymmetricDifferenceOf",
-                "description": "A point is in SymmetricDifferenceOf(a, b) if in either a or b, but not both.\n\nTypically created with the ``^`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) ^ Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True, False,  True, False])"
-            },
-            "UnionOf-Input": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Input",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "UnionOf"
-                        ],
-                        "const": "UnionOf",
-                        "title": "Type",
-                        "default": "UnionOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "UnionOf",
-                "description": "A point is in UnionOf(a, b) if in either a or b.\n\nTypically created with the ``|`` operator\n\n>>> r = Range(\"x\", 0.5, 2.5) | Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True,  True,  True, False])"
-            },
-            "UnionOf-Output": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The left-hand Region to combine"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Region-Output",
-                        "description": "The right-hand Region to combine"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "UnionOf"
-                        ],
-                        "const": "UnionOf",
-                        "title": "Type",
-                        "default": "UnionOf"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "UnionOf",
-                "description": "A point is in UnionOf(a, b) if in either a or b.\n\nTypically created with the ``|`` operator\n\n>>> r = Range(\"x\", 0.5, 2.5) | Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True,  True,  True, False])"
-            },
-            "ValidResponse": {
-                "properties": {
-                    "input_spec": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The input scanspec"
-                    },
-                    "valid_spec": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The validated version of the spec"
-                    }
-                },
-                "type": "object",
-                "required": [
-                    "input_spec",
-                    "valid_spec"
-                ],
-                "title": "ValidResponse",
-                "description": "Response model for spec validation."
-            },
-            "ValidationError": {
-                "properties": {
-                    "loc": {
-                        "items": {
-                            "anyOf": [
-                                {
-                                    "type": "string"
-                                },
-                                {
-                                    "type": "integer"
-                                }
-                            ]
-                        },
-                        "type": "array",
-                        "title": "Location"
-                    },
-                    "msg": {
-                        "type": "string",
-                        "title": "Message"
-                    },
-                    "type": {
-                        "type": "string",
-                        "title": "Error Type"
-                    }
-                },
-                "type": "object",
-                "required": [
-                    "loc",
-                    "msg",
-                    "type"
-                ],
-                "title": "ValidationError"
-            },
-            "Zip-Input": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "The left-hand Spec to Zip, will appear earlier in axes"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Spec-Input",
-                        "description": "The right-hand Spec to Zip, will appear later in axes"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Zip"
-                        ],
-                        "const": "Zip",
-                        "title": "Type",
-                        "default": "Zip"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "Zip",
-                "description": "Run two Specs in parallel, merging their midpoints together.\n\nTypically formed using `Spec.zip`.\n\nStacks of Frames are merged by:\n\n- If right creates a stack of a single Frames object of size 1, expand it to\n  the size of the fastest Frames object created by left\n- Merge individual Frames objects together from fastest to slowest\n\nThis means that Zipping a Spec producing stack [l2, l1] with a Spec\nproducing stack [r1] will assert len(l1)==len(r1), and produce\nstack [l2, l1.zip(r1)].\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"z\", 1, 2, 3) * Line(\"y\", 3, 4, 5).zip(Line(\"x\", 4, 5, 5))"
-            },
-            "Zip-Output": {
-                "properties": {
-                    "left": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The left-hand Spec to Zip, will appear earlier in axes"
-                    },
-                    "right": {
-                        "$ref": "#/components/schemas/Spec-Output",
-                        "description": "The right-hand Spec to Zip, will appear later in axes"
-                    },
-                    "type": {
-                        "type": "string",
-                        "enum": [
-                            "Zip"
-                        ],
-                        "const": "Zip",
-                        "title": "Type",
-                        "default": "Zip"
-                    }
-                },
-                "additionalProperties": false,
-                "type": "object",
-                "required": [
-                    "left",
-                    "right"
-                ],
-                "title": "Zip",
-                "description": "Run two Specs in parallel, merging their midpoints together.\n\nTypically formed using `Spec.zip`.\n\nStacks of Frames are merged by:\n\n- If right creates a stack of a single Frames object of size 1, expand it to\n  the size of the fastest Frames object created by left\n- Merge individual Frames objects together from fastest to slowest\n\nThis means that Zipping a Spec producing stack [l2, l1] with a Spec\nproducing stack [r1] will assert len(l1)==len(r1), and produce\nstack [l2, l1.zip(r1)].\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"z\", 1, 2, 3) * Line(\"y\", 3, 4, 5).zip(Line(\"x\", 4, 5, 5))"
-            }
-        }
-    }
-}
+{"openapi": "3.1.0", "info": {"title": "FastAPI", "version": "0.1.1"}, "paths": {"/valid": {"post": {"summary": "Valid", "description": "Validate wether a ScanSpec[str] can produce a viable scan.\n\nArgs:\n    spec: The scanspec to validate\n\nReturns:\n    ValidResponse: A canonical version of the spec if it is valid.\n        An error otherwise.", "operationId": "valid_valid_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Spec-Input", "examples": [{"outer": {"axis": "y", "start": 0.0, "stop": 10.0, "num": 3, "type": "Line"}, "inner": {"axis": "x", "start": 0.0, "stop": 10.0, "num": 4, "type": "Line"}, "type": "Product"}]}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ValidResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/midpoints": {"post": {"summary": "Midpoints", "description": "Generate midpoints from a scanspec.\n\nA scanspec can produce bounded points (i.e. a point is valid if an\naxis is between a minimum and and a maximum, see /bounds). The midpoints\nare the middle of each set of bounds.\n\nArgs:\n    request: Scanspec and formatting info.\n\nReturns:\n    MidpointsResponse: Midpoints of the scan", "operationId": "midpoints_midpoints_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PointsRequest", "examples": [{"spec": {"outer": {"axis": "y", "start": 0.0, "stop": 10.0, "num": 3, "type": "Line"}, "inner": {"axis": "x", "start": 0.0, "stop": 10.0, "num": 4, "type": "Line"}, "type": "Product"}, "max_frames": 1024, "format": "FLOAT_LIST"}]}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MidpointsResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/bounds": {"post": {"summary": "Bounds", "description": "Generate bounds from a scanspec.\n\nA scanspec can produce points with lower and upper bounds.\n\nArgs:\n    request: Scanspec and formatting info.\n\nReturns:\n    BoundsResponse: Bounds of the scan", "operationId": "bounds_bounds_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PointsRequest", "examples": [{"spec": {"outer": {"axis": "y", "start": 0.0, "stop": 10.0, "num": 3, "type": "Line"}, "inner": {"axis": "x", "start": 0.0, "stop": 10.0, "num": 4, "type": "Line"}, "type": "Product"}, "max_frames": 1024, "format": "FLOAT_LIST"}]}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BoundsResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/gap": {"post": {"summary": "Gap", "description": "Generate gaps from a scanspec.\n\nA scanspec may indicate if there is a gap between two frames.\nThe array returned corresponds to whether or not there is a gap\nafter each frame.\n\nArgs:\n    spec: Scanspec and formatting info.\n\nReturns:\n    GapResponse: Bounds of the scan", "operationId": "gap_gap_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Spec-Input", "examples": [{"outer": {"axis": "y", "start": 0.0, "stop": 10.0, "num": 3, "type": "Line"}, "inner": {"axis": "x", "start": 0.0, "stop": 10.0, "num": 4, "type": "Line"}, "type": "Product"}]}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GapResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/smalleststep": {"post": {"summary": "Smallest Step", "description": "Calculate the smallest step in a scan, both absolutely and per-axis.\n\nIgnore any steps of size 0.\n\nArgs:\n    spec: The spec of the scan\n\nReturns:\n    SmallestStepResponse: A description of the smallest steps in the spec", "operationId": "smallest_step_smalleststep_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Spec-Input", "examples": [{"outer": {"axis": "y", "start": 0.0, "stop": 10.0, "num": 3, "type": "Line"}, "inner": {"axis": "x", "start": 0.0, "stop": 10.0, "num": 4, "type": "Line"}, "type": "Product"}]}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SmallestStepResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}}, "components": {"schemas": {"BoundsResponse": {"properties": {"total_frames": {"type": "integer", "title": "Total Frames", "description": "Total number of frames in spec"}, "returned_frames": {"type": "integer", "title": "Returned Frames", "description": "Total of number of frames in this response, may be less than total_frames due to downsampling etc."}, "format": {"$ref": "#/components/schemas/PointsFormat", "description": "Format of returned point data"}, "lower": {"additionalProperties": {"anyOf": [{"type": "string"}, {"items": {"type": "number"}, "type": "array"}]}, "type": "object", "title": "Lower", "description": "Lower bounds of scan frames if different from midpoints"}, "upper": {"additionalProperties": {"anyOf": [{"type": "string"}, {"items": {"type": "number"}, "type": "array"}]}, "type": "object", "title": "Upper", "description": "Upper bounds of scan frames if different from midpoints"}}, "type": "object", "required": ["total_frames", "returned_frames", "format", "lower", "upper"], "title": "BoundsResponse", "description": "Bounds of a generated scan."}, "Circle": {"properties": {"x_axis": {"title": "X Axis", "description": "The name matching the x axis of the spec"}, "y_axis": {"title": "Y Axis", "description": "The name matching the y axis of the spec"}, "x_middle": {"type": "number", "title": "X Middle", "description": "The central x point of the circle"}, "y_middle": {"type": "number", "title": "Y Middle", "description": "The central y point of the circle"}, "radius": {"type": "number", "exclusiveMinimum": 0.0, "title": "Radius", "description": "Radius of the circle"}, "type": {"type": "string", "enum": ["Circle"], "const": "Circle", "title": "Type", "default": "Circle"}}, "additionalProperties": false, "type": "object", "required": ["x_axis", "y_axis", "x_middle", "y_middle", "radius"], "title": "Circle", "description": "Mask contains points of axis within an xy circle of given radius.\n\n.. example_spec::\n\n    from scanspec.regions import Circle\n    from scanspec.specs import Line\n\n    grid = Line(\"y\", 1, 3, 10) * ~Line(\"x\", 0, 2, 10)\n    spec = grid & Circle(\"x\", \"y\", 1, 2, 0.9)"}, "CombinationOf-Input": {"properties": {"left": {"$ref": "#/components/schemas/Region-Input", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Input", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["CombinationOf"], "const": "CombinationOf", "title": "Type", "default": "CombinationOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "CombinationOf", "description": "Abstract baseclass for a combination of two regions, left and right."}, "CombinationOf-Output": {"properties": {"left": {"$ref": "#/components/schemas/Region-Output", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Output", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["CombinationOf"], "const": "CombinationOf", "title": "Type", "default": "CombinationOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "CombinationOf", "description": "Abstract baseclass for a combination of two regions, left and right."}, "Concat-Input": {"properties": {"left": {"$ref": "#/components/schemas/Spec-Input", "description": "The left-hand Spec to Concat, midpoints will appear earlier"}, "right": {"$ref": "#/components/schemas/Spec-Input", "description": "The right-hand Spec to Concat, midpoints will appear later"}, "gap": {"type": "boolean", "title": "Gap", "description": "If True, force a gap in the output at the join", "default": false}, "check_path_changes": {"type": "boolean", "title": "Check Path Changes", "description": "If True path through scan will not be modified by squash", "default": true}, "type": {"type": "string", "enum": ["Concat"], "const": "Concat", "title": "Type", "default": "Concat"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "Concat", "description": "Concatenate two Specs together, running one after the other.\n\nEach Dimension of left and right must contain the same axes. Typically\nformed using `Spec.concat`.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"x\", 1, 3, 3).concat(Line(\"x\", 4, 5, 5))"}, "Concat-Output": {"properties": {"left": {"$ref": "#/components/schemas/Spec-Output", "description": "The left-hand Spec to Concat, midpoints will appear earlier"}, "right": {"$ref": "#/components/schemas/Spec-Output", "description": "The right-hand Spec to Concat, midpoints will appear later"}, "gap": {"type": "boolean", "title": "Gap", "description": "If True, force a gap in the output at the join", "default": false}, "check_path_changes": {"type": "boolean", "title": "Check Path Changes", "description": "If True path through scan will not be modified by squash", "default": true}, "type": {"type": "string", "enum": ["Concat"], "const": "Concat", "title": "Type", "default": "Concat"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "Concat", "description": "Concatenate two Specs together, running one after the other.\n\nEach Dimension of left and right must contain the same axes. Typically\nformed using `Spec.concat`.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"x\", 1, 3, 3).concat(Line(\"x\", 4, 5, 5))"}, "DifferenceOf-Input": {"properties": {"left": {"$ref": "#/components/schemas/Region-Input", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Input", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["DifferenceOf"], "const": "DifferenceOf", "title": "Type", "default": "DifferenceOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "DifferenceOf", "description": "A point is in DifferenceOf(a, b) if in a and not in b.\n\nTypically created with the ``-`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) - Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True, False, False, False])"}, "DifferenceOf-Output": {"properties": {"left": {"$ref": "#/components/schemas/Region-Output", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Output", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["DifferenceOf"], "const": "DifferenceOf", "title": "Type", "default": "DifferenceOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "DifferenceOf", "description": "A point is in DifferenceOf(a, b) if in a and not in b.\n\nTypically created with the ``-`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) - Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True, False, False, False])"}, "Ellipse": {"properties": {"x_axis": {"title": "X Axis", "description": "The name matching the x axis of the spec"}, "y_axis": {"title": "Y Axis", "description": "The name matching the y axis of the spec"}, "x_middle": {"type": "number", "title": "X Middle", "description": "The central x point of the ellipse"}, "y_middle": {"type": "number", "title": "Y Middle", "description": "The central y point of the ellipse"}, "x_radius": {"type": "number", "exclusiveMinimum": 0.0, "title": "X Radius", "description": "The radius along the x axis of the ellipse"}, "y_radius": {"type": "number", "exclusiveMinimum": 0.0, "title": "Y Radius", "description": "The radius along the y axis of the ellipse"}, "angle": {"type": "number", "title": "Angle", "description": "The angle of the ellipse (degrees)", "default": 0.0}, "type": {"type": "string", "enum": ["Ellipse"], "const": "Ellipse", "title": "Type", "default": "Ellipse"}}, "additionalProperties": false, "type": "object", "required": ["x_axis", "y_axis", "x_middle", "y_middle", "x_radius", "y_radius"], "title": "Ellipse", "description": "Mask contains points of axis within an xy ellipse of given radius.\n\n.. example_spec::\n\n    from scanspec.regions import Ellipse\n    from scanspec.specs import Line\n\n    grid = Line(\"y\", 3, 8, 10) * ~Line(\"x\", 1 ,8, 10)\n    spec = grid & Ellipse(\"x\", \"y\", 5, 5, 2, 3, 75)"}, "GapResponse": {"properties": {"gap": {"items": {"type": "boolean"}, "type": "array", "title": "Gap", "description": "Boolean array indicating if there is a gap between each frame"}}, "type": "object", "required": ["gap"], "title": "GapResponse", "description": "Presence of gaps in a generated scan."}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "IntersectionOf-Input": {"properties": {"left": {"$ref": "#/components/schemas/Region-Input", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Input", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["IntersectionOf"], "const": "IntersectionOf", "title": "Type", "default": "IntersectionOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "IntersectionOf", "description": "A point is in IntersectionOf(a, b) if in both a and b.\n\nTypically created with the ``&`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) & Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False, False,  True, False, False])"}, "IntersectionOf-Output": {"properties": {"left": {"$ref": "#/components/schemas/Region-Output", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Output", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["IntersectionOf"], "const": "IntersectionOf", "title": "Type", "default": "IntersectionOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "IntersectionOf", "description": "A point is in IntersectionOf(a, b) if in both a and b.\n\nTypically created with the ``&`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) & Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False, False,  True, False, False])"}, "Line": {"properties": {"axis": {"title": "Axis", "description": "An identifier for what to move"}, "start": {"type": "number", "title": "Start", "description": "Midpoint of the first point of the line"}, "stop": {"type": "number", "title": "Stop", "description": "Midpoint of the last point of the line"}, "num": {"type": "integer", "minimum": 1.0, "title": "Num", "description": "Number of frames to produce"}, "type": {"type": "string", "enum": ["Line"], "const": "Line", "title": "Type", "default": "Line"}}, "additionalProperties": false, "type": "object", "required": ["axis", "start", "stop", "num"], "title": "Line", "description": "Linearly spaced frames with start and stop as first and last midpoints.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"x\", 1, 2, 5)"}, "Mask-Input": {"properties": {"spec": {"$ref": "#/components/schemas/Spec-Input", "description": "The Spec containing the source midpoints"}, "region": {"$ref": "#/components/schemas/Region-Input", "description": "The Region that midpoints will be inside"}, "check_path_changes": {"type": "boolean", "title": "Check Path Changes", "description": "If True path through scan will not be modified by squash", "default": true}, "type": {"type": "string", "enum": ["Mask"], "const": "Mask", "title": "Type", "default": "Mask"}}, "additionalProperties": false, "type": "object", "required": ["spec", "region"], "title": "Mask", "description": "Restrict Spec to only midpoints that fall inside the given Region.\n\nTypically created with the ``&`` operator. It also pushes down the\n``& | ^ -`` operators to its `Region` to avoid the need for brackets on\ncombinations of Regions.\n\nIf a Region spans multiple Frames objects, they will be squashed together.\n\n.. example_spec::\n\n    from scanspec.regions import Circle\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 3, 3) * Line(\"x\", 3, 5, 5) & Circle(\"x\", \"y\", 4, 2, 1.2)\n\nSee Also: `why-squash-can-change-path`"}, "Mask-Output": {"properties": {"spec": {"$ref": "#/components/schemas/Spec-Output", "description": "The Spec containing the source midpoints"}, "region": {"$ref": "#/components/schemas/Region-Output", "description": "The Region that midpoints will be inside"}, "check_path_changes": {"type": "boolean", "title": "Check Path Changes", "description": "If True path through scan will not be modified by squash", "default": true}, "type": {"type": "string", "enum": ["Mask"], "const": "Mask", "title": "Type", "default": "Mask"}}, "additionalProperties": false, "type": "object", "required": ["spec", "region"], "title": "Mask", "description": "Restrict Spec to only midpoints that fall inside the given Region.\n\nTypically created with the ``&`` operator. It also pushes down the\n``& | ^ -`` operators to its `Region` to avoid the need for brackets on\ncombinations of Regions.\n\nIf a Region spans multiple Frames objects, they will be squashed together.\n\n.. example_spec::\n\n    from scanspec.regions import Circle\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 3, 3) * Line(\"x\", 3, 5, 5) & Circle(\"x\", \"y\", 4, 2, 1.2)\n\nSee Also: `why-squash-can-change-path`"}, "MidpointsResponse": {"properties": {"total_frames": {"type": "integer", "title": "Total Frames", "description": "Total number of frames in spec"}, "returned_frames": {"type": "integer", "title": "Returned Frames", "description": "Total of number of frames in this response, may be less than total_frames due to downsampling etc."}, "format": {"$ref": "#/components/schemas/PointsFormat", "description": "Format of returned point data"}, "midpoints": {"additionalProperties": {"anyOf": [{"type": "string"}, {"items": {"type": "number"}, "type": "array"}]}, "type": "object", "title": "Midpoints", "description": "The midpoints of scan frames for each axis"}}, "type": "object", "required": ["total_frames", "returned_frames", "format", "midpoints"], "title": "MidpointsResponse", "description": "Midpoints of a generated scan."}, "PointsFormat": {"type": "string", "enum": ["STRING", "FLOAT_LIST", "BASE64_ENCODED"], "title": "PointsFormat", "description": "Formats in which we can return points."}, "PointsRequest": {"properties": {"spec": {"$ref": "#/components/schemas/Spec-Input", "description": "The spec from which to generate points"}, "max_frames": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Max Frames", "description": "The maximum number of points to return, if None will return as many as calculated", "default": 100000}, "format": {"$ref": "#/components/schemas/PointsFormat", "description": "The format in which to output the points data", "default": "FLOAT_LIST"}}, "type": "object", "required": ["spec"], "title": "PointsRequest", "description": "A request for generated scan points."}, "Polygon": {"properties": {"x_axis": {"title": "X Axis", "description": "The name matching the x axis of the spec"}, "y_axis": {"title": "Y Axis", "description": "The name matching the y axis of the spec"}, "x_verts": {"items": {"type": "number"}, "type": "array", "minItems": 3, "title": "X Verts", "description": "The Nx1 x coordinates of the polygons vertices"}, "y_verts": {"items": {"type": "number"}, "type": "array", "minItems": 3, "title": "Y Verts", "description": "The Nx1 y coordinates of the polygons vertices"}, "type": {"type": "string", "enum": ["Polygon"], "const": "Polygon", "title": "Type", "default": "Polygon"}}, "additionalProperties": false, "type": "object", "required": ["x_axis", "y_axis", "x_verts", "y_verts"], "title": "Polygon", "description": "Mask contains points of axis within a rotated xy polygon.\n\n.. example_spec::\n\n    from scanspec.regions import Polygon\n    from scanspec.specs import Line\n\n    grid = Line(\"y\", 3, 8, 10) * ~Line(\"x\", 1 ,8, 10)\n    spec = grid & Polygon(\"x\", \"y\", [1.0, 6.0, 8.0, 2.0], [4.0, 10.0, 6.0, 1.0])"}, "Product-Input": {"properties": {"outer": {"$ref": "#/components/schemas/Spec-Input", "description": "Will be executed once"}, "inner": {"$ref": "#/components/schemas/Spec-Input", "description": "Will be executed len(outer) times"}, "type": {"type": "string", "enum": ["Product"], "const": "Product", "title": "Type", "default": "Product"}}, "additionalProperties": false, "type": "object", "required": ["outer", "inner"], "title": "Product", "description": "Outer product of two Specs, nesting inner within outer.\n\nThis means that inner will run in its entirety at each point in outer.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 2, 3) * Line(\"x\", 3, 4, 12)"}, "Product-Output": {"properties": {"outer": {"$ref": "#/components/schemas/Spec-Output", "description": "Will be executed once"}, "inner": {"$ref": "#/components/schemas/Spec-Output", "description": "Will be executed len(outer) times"}, "type": {"type": "string", "enum": ["Product"], "const": "Product", "title": "Type", "default": "Product"}}, "additionalProperties": false, "type": "object", "required": ["outer", "inner"], "title": "Product", "description": "Outer product of two Specs, nesting inner within outer.\n\nThis means that inner will run in its entirety at each point in outer.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 2, 3) * Line(\"x\", 3, 4, 12)"}, "Range": {"properties": {"axis": {"title": "Axis", "description": "The name matching the axis to mask in spec"}, "min": {"type": "number", "title": "Min", "description": "The minimum inclusive value in the region"}, "max": {"type": "number", "title": "Max", "description": "The minimum inclusive value in the region"}, "type": {"type": "string", "enum": ["Range"], "const": "Range", "title": "Type", "default": "Range"}}, "additionalProperties": false, "type": "object", "required": ["axis", "min", "max"], "title": "Range", "description": "Mask contains points of axis >= min and <= max.\n\n>>> r = Range(\"x\", 1, 2)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True,  True, False, False])"}, "Rectangle": {"properties": {"x_axis": {"title": "X Axis", "description": "The name matching the x axis of the spec"}, "y_axis": {"title": "Y Axis", "description": "The name matching the y axis of the spec"}, "x_min": {"type": "number", "title": "X Min", "description": "Minimum inclusive x value in the region"}, "y_min": {"type": "number", "title": "Y Min", "description": "Minimum inclusive y value in the region"}, "x_max": {"type": "number", "title": "X Max", "description": "Maximum inclusive x value in the region"}, "y_max": {"type": "number", "title": "Y Max", "description": "Maximum inclusive y value in the region"}, "angle": {"type": "number", "title": "Angle", "description": "Clockwise rotation angle of the rectangle", "default": 0.0}, "type": {"type": "string", "enum": ["Rectangle"], "const": "Rectangle", "title": "Type", "default": "Rectangle"}}, "additionalProperties": false, "type": "object", "required": ["x_axis", "y_axis", "x_min", "y_min", "x_max", "y_max"], "title": "Rectangle", "description": "Mask contains points of axis within a rotated xy rectangle.\n\n.. example_spec::\n\n    from scanspec.regions import Rectangle\n    from scanspec.specs import Line\n\n    grid = Line(\"y\", 1, 3, 10) * ~Line(\"x\", 0, 2, 10)\n    spec = grid & Rectangle(\"x\", \"y\", 0, 1.1, 1.5, 2.1, 30)"}, "Region-Input": {"oneOf": [{"$ref": "#/components/schemas/CombinationOf-Input"}, {"$ref": "#/components/schemas/UnionOf-Input"}, {"$ref": "#/components/schemas/IntersectionOf-Input"}, {"$ref": "#/components/schemas/DifferenceOf-Input"}, {"$ref": "#/components/schemas/SymmetricDifferenceOf-Input"}, {"$ref": "#/components/schemas/Range"}, {"$ref": "#/components/schemas/Rectangle"}, {"$ref": "#/components/schemas/Polygon"}, {"$ref": "#/components/schemas/Circle"}, {"$ref": "#/components/schemas/Ellipse"}], "discriminator": {"propertyName": "type", "mapping": {"Circle": "#/components/schemas/Circle", "CombinationOf": "#/components/schemas/CombinationOf-Input", "DifferenceOf": "#/components/schemas/DifferenceOf-Input", "Ellipse": "#/components/schemas/Ellipse", "IntersectionOf": "#/components/schemas/IntersectionOf-Input", "Polygon": "#/components/schemas/Polygon", "Range": "#/components/schemas/Range", "Rectangle": "#/components/schemas/Rectangle", "SymmetricDifferenceOf": "#/components/schemas/SymmetricDifferenceOf-Input", "UnionOf": "#/components/schemas/UnionOf-Input"}}}, "Region-Output": {"oneOf": [{"$ref": "#/components/schemas/CombinationOf-Output"}, {"$ref": "#/components/schemas/UnionOf-Output"}, {"$ref": "#/components/schemas/IntersectionOf-Output"}, {"$ref": "#/components/schemas/DifferenceOf-Output"}, {"$ref": "#/components/schemas/SymmetricDifferenceOf-Output"}, {"$ref": "#/components/schemas/Range"}, {"$ref": "#/components/schemas/Rectangle"}, {"$ref": "#/components/schemas/Polygon"}, {"$ref": "#/components/schemas/Circle"}, {"$ref": "#/components/schemas/Ellipse"}], "discriminator": {"propertyName": "type", "mapping": {"Circle": "#/components/schemas/Circle", "CombinationOf": "#/components/schemas/CombinationOf-Output", "DifferenceOf": "#/components/schemas/DifferenceOf-Output", "Ellipse": "#/components/schemas/Ellipse", "IntersectionOf": "#/components/schemas/IntersectionOf-Output", "Polygon": "#/components/schemas/Polygon", "Range": "#/components/schemas/Range", "Rectangle": "#/components/schemas/Rectangle", "SymmetricDifferenceOf": "#/components/schemas/SymmetricDifferenceOf-Output", "UnionOf": "#/components/schemas/UnionOf-Output"}}}, "Repeat": {"properties": {"num": {"type": "integer", "minimum": 1.0, "title": "Num", "description": "Number of frames to produce"}, "gap": {"type": "boolean", "title": "Gap", "description": "If False and the slowest of the stack of Frames is snaked then the end and start of consecutive iterations of Spec will have no gap", "default": true}, "type": {"type": "string", "enum": ["Repeat"], "const": "Repeat", "title": "Type", "default": "Repeat"}}, "additionalProperties": false, "type": "object", "required": ["num"], "title": "Repeat", "description": "Repeat an empty frame num times.\n\nCan be used on the outside of a scan to repeat the same scan many times.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = 2 * ~Line.bounded(\"x\", 3, 4, 1)\n\nIf you want snaked axes to have no gap between iterations you can do:\n\n.. example_spec::\n\n    from scanspec.specs import Line, Repeat\n\n    spec = Repeat(2, gap=False) * ~Line.bounded(\"x\", 3, 4, 1)\n\n.. note:: There is no turnaround arrow at x=4"}, "SmallestStepResponse": {"properties": {"absolute": {"type": "number", "title": "Absolute", "description": "Absolute smallest distance between two points on a single axis"}, "per_axis": {"additionalProperties": {"type": "number"}, "type": "object", "title": "Per Axis", "description": "Smallest distance between two points on each axis"}}, "type": "object", "required": ["absolute", "per_axis"], "title": "SmallestStepResponse", "description": "Information about the smallest steps between points in a spec."}, "Snake-Input": {"properties": {"spec": {"$ref": "#/components/schemas/Spec-Input", "description": "The Spec to run in reverse every other iteration"}, "type": {"type": "string", "enum": ["Snake"], "const": "Snake", "title": "Type", "default": "Snake"}}, "additionalProperties": false, "type": "object", "required": ["spec"], "title": "Snake", "description": "Run the Spec in reverse on every other iteration when nested.\n\nTypically created with the ``~`` operator.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 3, 3) * ~Line(\"x\", 3, 5, 5)"}, "Snake-Output": {"properties": {"spec": {"$ref": "#/components/schemas/Spec-Output", "description": "The Spec to run in reverse every other iteration"}, "type": {"type": "string", "enum": ["Snake"], "const": "Snake", "title": "Type", "default": "Snake"}}, "additionalProperties": false, "type": "object", "required": ["spec"], "title": "Snake", "description": "Run the Spec in reverse on every other iteration when nested.\n\nTypically created with the ``~`` operator.\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"y\", 1, 3, 3) * ~Line(\"x\", 3, 5, 5)"}, "Spec-Input": {"oneOf": [{"$ref": "#/components/schemas/Product-Input"}, {"$ref": "#/components/schemas/Repeat"}, {"$ref": "#/components/schemas/Zip-Input"}, {"$ref": "#/components/schemas/Mask-Input"}, {"$ref": "#/components/schemas/Snake-Input"}, {"$ref": "#/components/schemas/Concat-Input"}, {"$ref": "#/components/schemas/Squash-Input"}, {"$ref": "#/components/schemas/Line"}, {"$ref": "#/components/schemas/Static"}, {"$ref": "#/components/schemas/Spiral"}], "discriminator": {"propertyName": "type", "mapping": {"Concat": "#/components/schemas/Concat-Input", "Line": "#/components/schemas/Line", "Mask": "#/components/schemas/Mask-Input", "Product": "#/components/schemas/Product-Input", "Repeat": "#/components/schemas/Repeat", "Snake": "#/components/schemas/Snake-Input", "Spiral": "#/components/schemas/Spiral", "Squash": "#/components/schemas/Squash-Input", "Static": "#/components/schemas/Static", "Zip": "#/components/schemas/Zip-Input"}}}, "Spec-Output": {"oneOf": [{"$ref": "#/components/schemas/Product-Output"}, {"$ref": "#/components/schemas/Repeat"}, {"$ref": "#/components/schemas/Zip-Output"}, {"$ref": "#/components/schemas/Mask-Output"}, {"$ref": "#/components/schemas/Snake-Output"}, {"$ref": "#/components/schemas/Concat-Output"}, {"$ref": "#/components/schemas/Squash-Output"}, {"$ref": "#/components/schemas/Line"}, {"$ref": "#/components/schemas/Static"}, {"$ref": "#/components/schemas/Spiral"}], "discriminator": {"propertyName": "type", "mapping": {"Concat": "#/components/schemas/Concat-Output", "Line": "#/components/schemas/Line", "Mask": "#/components/schemas/Mask-Output", "Product": "#/components/schemas/Product-Output", "Repeat": "#/components/schemas/Repeat", "Snake": "#/components/schemas/Snake-Output", "Spiral": "#/components/schemas/Spiral", "Squash": "#/components/schemas/Squash-Output", "Static": "#/components/schemas/Static", "Zip": "#/components/schemas/Zip-Output"}}}, "Spiral": {"properties": {"x_axis": {"title": "X Axis", "description": "An identifier for what to move for x"}, "y_axis": {"title": "Y Axis", "description": "An identifier for what to move for y"}, "x_start": {"type": "number", "title": "X Start", "description": "x centre of the spiral"}, "y_start": {"type": "number", "title": "Y Start", "description": "y centre of the spiral"}, "x_range": {"type": "number", "title": "X Range", "description": "x width of the spiral"}, "y_range": {"type": "number", "title": "Y Range", "description": "y width of the spiral"}, "num": {"type": "integer", "minimum": 1.0, "title": "Num", "description": "Number of frames to produce"}, "rotate": {"type": "number", "title": "Rotate", "description": "How much to rotate the angle of the spiral", "default": 0.0}, "type": {"type": "string", "enum": ["Spiral"], "const": "Spiral", "title": "Type", "default": "Spiral"}}, "additionalProperties": false, "type": "object", "required": ["x_axis", "y_axis", "x_start", "y_start", "x_range", "y_range", "num"], "title": "Spiral", "description": "Archimedean spiral of \"x_axis\" and \"y_axis\".\n\nStarts at centre point (\"x_start\", \"y_start\") with angle \"rotate\". Produces\n\"num\" points in a spiral spanning width of \"x_range\" and height of \"y_range\"\n\n.. example_spec::\n\n    from scanspec.specs import Spiral\n\n    spec = Spiral(\"x\", \"y\", 1, 5, 10, 50, 30)"}, "Squash-Input": {"properties": {"spec": {"$ref": "#/components/schemas/Spec-Input", "description": "The Spec to squash the dimensions of"}, "check_path_changes": {"type": "boolean", "title": "Check Path Changes", "description": "If True path through scan will not be modified by squash", "default": true}, "type": {"type": "string", "enum": ["Squash"], "const": "Squash", "title": "Type", "default": "Squash"}}, "additionalProperties": false, "type": "object", "required": ["spec"], "title": "Squash", "description": "Squash a stack of Frames together into a single expanded Frames object.\n\nSee Also:\n    `why-squash-can-change-path`\n\n.. example_spec::\n\n    from scanspec.specs import Line, Squash\n\n    spec = Squash(Line(\"y\", 1, 2, 3) * Line(\"x\", 0, 1, 4))"}, "Squash-Output": {"properties": {"spec": {"$ref": "#/components/schemas/Spec-Output", "description": "The Spec to squash the dimensions of"}, "check_path_changes": {"type": "boolean", "title": "Check Path Changes", "description": "If True path through scan will not be modified by squash", "default": true}, "type": {"type": "string", "enum": ["Squash"], "const": "Squash", "title": "Type", "default": "Squash"}}, "additionalProperties": false, "type": "object", "required": ["spec"], "title": "Squash", "description": "Squash a stack of Frames together into a single expanded Frames object.\n\nSee Also:\n    `why-squash-can-change-path`\n\n.. example_spec::\n\n    from scanspec.specs import Line, Squash\n\n    spec = Squash(Line(\"y\", 1, 2, 3) * Line(\"x\", 0, 1, 4))"}, "Static": {"properties": {"axis": {"title": "Axis", "description": "An identifier for what to move"}, "value": {"type": "number", "title": "Value", "description": "The value at each point"}, "num": {"type": "integer", "minimum": 1.0, "title": "Num", "description": "Number of frames to produce", "default": 1}, "type": {"type": "string", "enum": ["Static"], "const": "Static", "title": "Type", "default": "Static"}}, "additionalProperties": false, "type": "object", "required": ["axis", "value"], "title": "Static", "description": "A static frame, repeated num times, with axis at value.\n\nCan be used to set axis=value at every point in a scan.\n\n.. example_spec::\n\n    from scanspec.specs import Line, Static\n\n    spec = Line(\"y\", 1, 2, 3).zip(Static(\"x\", 3))"}, "SymmetricDifferenceOf-Input": {"properties": {"left": {"$ref": "#/components/schemas/Region-Input", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Input", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["SymmetricDifferenceOf"], "const": "SymmetricDifferenceOf", "title": "Type", "default": "SymmetricDifferenceOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "SymmetricDifferenceOf", "description": "A point is in SymmetricDifferenceOf(a, b) if in either a or b, but not both.\n\nTypically created with the ``^`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) ^ Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True, False,  True, False])"}, "SymmetricDifferenceOf-Output": {"properties": {"left": {"$ref": "#/components/schemas/Region-Output", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Output", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["SymmetricDifferenceOf"], "const": "SymmetricDifferenceOf", "title": "Type", "default": "SymmetricDifferenceOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "SymmetricDifferenceOf", "description": "A point is in SymmetricDifferenceOf(a, b) if in either a or b, but not both.\n\nTypically created with the ``^`` operator.\n\n>>> r = Range(\"x\", 0.5, 2.5) ^ Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True, False,  True, False])"}, "UnionOf-Input": {"properties": {"left": {"$ref": "#/components/schemas/Region-Input", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Input", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["UnionOf"], "const": "UnionOf", "title": "Type", "default": "UnionOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "UnionOf", "description": "A point is in UnionOf(a, b) if in either a or b.\n\nTypically created with the ``|`` operator\n\n>>> r = Range(\"x\", 0.5, 2.5) | Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True,  True,  True, False])"}, "UnionOf-Output": {"properties": {"left": {"$ref": "#/components/schemas/Region-Output", "description": "The left-hand Region to combine"}, "right": {"$ref": "#/components/schemas/Region-Output", "description": "The right-hand Region to combine"}, "type": {"type": "string", "enum": ["UnionOf"], "const": "UnionOf", "title": "Type", "default": "UnionOf"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "UnionOf", "description": "A point is in UnionOf(a, b) if in either a or b.\n\nTypically created with the ``|`` operator\n\n>>> r = Range(\"x\", 0.5, 2.5) | Range(\"x\", 1.5, 3.5)\n>>> r.mask({\"x\": np.array([0, 1, 2, 3, 4])})\narray([False,  True,  True,  True, False])"}, "ValidResponse": {"properties": {"input_spec": {"$ref": "#/components/schemas/Spec-Output", "description": "The input scanspec"}, "valid_spec": {"$ref": "#/components/schemas/Spec-Output", "description": "The validated version of the spec"}}, "type": "object", "required": ["input_spec", "valid_spec"], "title": "ValidResponse", "description": "Response model for spec validation."}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}, "Zip-Input": {"properties": {"left": {"$ref": "#/components/schemas/Spec-Input", "description": "The left-hand Spec to Zip, will appear earlier in axes"}, "right": {"$ref": "#/components/schemas/Spec-Input", "description": "The right-hand Spec to Zip, will appear later in axes"}, "type": {"type": "string", "enum": ["Zip"], "const": "Zip", "title": "Type", "default": "Zip"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "Zip", "description": "Run two Specs in parallel, merging their midpoints together.\n\nTypically formed using `Spec.zip`.\n\nStacks of Frames are merged by:\n\n- If right creates a stack of a single Frames object of size 1, expand it to\n  the size of the fastest Frames object created by left\n- Merge individual Frames objects together from fastest to slowest\n\nThis means that Zipping a Spec producing stack [l2, l1] with a Spec\nproducing stack [r1] will assert len(l1)==len(r1), and produce\nstack [l2, l1.zip(r1)].\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"z\", 1, 2, 3) * Line(\"y\", 3, 4, 5).zip(Line(\"x\", 4, 5, 5))"}, "Zip-Output": {"properties": {"left": {"$ref": "#/components/schemas/Spec-Output", "description": "The left-hand Spec to Zip, will appear earlier in axes"}, "right": {"$ref": "#/components/schemas/Spec-Output", "description": "The right-hand Spec to Zip, will appear later in axes"}, "type": {"type": "string", "enum": ["Zip"], "const": "Zip", "title": "Type", "default": "Zip"}}, "additionalProperties": false, "type": "object", "required": ["left", "right"], "title": "Zip", "description": "Run two Specs in parallel, merging their midpoints together.\n\nTypically formed using `Spec.zip`.\n\nStacks of Frames are merged by:\n\n- If right creates a stack of a single Frames object of size 1, expand it to\n  the size of the fastest Frames object created by left\n- Merge individual Frames objects together from fastest to slowest\n\nThis means that Zipping a Spec producing stack [l2, l1] with a Spec\nproducing stack [r1] will assert len(l1)==len(r1), and produce\nstack [l2, l1.zip(r1)].\n\n.. example_spec::\n\n    from scanspec.specs import Line\n\n    spec = Line(\"z\", 1, 2, 3) * Line(\"y\", 3, 4, 5).zip(Line(\"x\", 4, 5, 5))"}}}}
diff --git a/src/scanspec/cli.py b/src/scanspec/cli.py
index 82a57cce..75e582d2 100644
--- a/src/scanspec/cli.py
+++ b/src/scanspec/cli.py
@@ -20,7 +20,7 @@
 )
 @click.version_option(prog_name="scanspec", message="%(version)s")
 @click.pass_context
-def cli(ctx, log_level: str):
+def cli(ctx: click.Context, log_level: str):
     """Top level scanspec command line interface."""
     level = getattr(logging, log_level.upper(), None)
     logging.basicConfig(format="%(levelname)s:%(message)s", level=level)
@@ -50,7 +50,7 @@ def plot(spec: str):
 @click.option(
     "--port", default=8080, help="The port that the scanspec service will be hosted on."
 )
-def service(cors, port):
+def service(cors: bool, port: int):
     """Run up a REST service."""
     from scanspec.service import run_app
 
diff --git a/src/scanspec/core.py b/src/scanspec/core.py
index a15b644c..4a4edb69 100644
--- a/src/scanspec/core.py
+++ b/src/scanspec/core.py
@@ -2,20 +2,28 @@
 
 from __future__ import annotations
 
+import itertools
 from collections.abc import Callable, Iterable, Iterator, Sequence
 from functools import lru_cache
 from inspect import isclass
-from typing import Any, Generic, Literal, TypeVar
+from typing import (
+    Any,
+    Generic,
+    Literal,
+    TypeVar,
+)
 
 import numpy as np
+import numpy.typing as npt
 from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler
 from pydantic.dataclasses import is_pydantic_dataclass, rebuild_dataclass
 from pydantic_core import CoreSchema
 from pydantic_core.core_schema import tagged_union_schema
 
 __all__ = [
-    "if_instance_do",
     "Axis",
+    "OtherAxis",
+    "if_instance_do",
     "AxesPoints",
     "Frames",
     "SnakedFrames",
@@ -31,6 +39,9 @@
 StrictConfig: ConfigDict = {"extra": "forbid"}
 
 C = TypeVar("C")
+T = TypeVar("T")
+
+GapArray = npt.NDArray[np.bool]
 
 
 def discriminated_union_of_subclasses(
@@ -111,7 +122,7 @@ def calculate(self) -> int:
     tagged_union = _TaggedUnion(super_cls, discriminator)
     _tagged_unions[super_cls] = tagged_union
 
-    def add_subclass_to_union(subclass):
+    def add_subclass_to_union(subclass: type[C]):
         # Add a discriminator field to a subclass so it can
         # be identified when deserializing
         subclass.__annotations__ = {
@@ -120,7 +131,9 @@ def add_subclass_to_union(subclass):
         }
         setattr(subclass, discriminator, Field(subclass.__name__, repr=False))  # type: ignore
 
-    def get_schema_of_union(cls, source_type: Any, handler: GetCoreSchemaHandler):
+    def get_schema_of_union(
+        cls: type[C], source_type: Any, handler: GetCoreSchemaHandler
+    ):
         if cls is not super_cls:
             tagged_union.add_member(cls)
             return handler(cls)
@@ -138,7 +151,7 @@ def get_schema_of_union(cls, source_type: Any, handler: GetCoreSchemaHandler):
 
 
 class _TaggedUnion:
-    def __init__(self, base_class: type, discriminator: str):
+    def __init__(self, base_class: type[Any], discriminator: str):
         self._base_class = base_class
         # Classes and their field names that refer to this tagged union
         self._discriminator = discriminator
@@ -154,7 +167,7 @@ def add_member(self, cls: type):
                 _TaggedUnion._rebuild(member)
 
     @staticmethod
-    def _rebuild(cls_or_func: type | Callable):
+    def _rebuild(cls_or_func: Callable[..., T]) -> None:
         if isclass(cls_or_func):
             if is_pydantic_dataclass(cls_or_func):
                 rebuild_dataclass(cls_or_func, force=True)
@@ -170,11 +183,13 @@ def schema(self, handler: GetCoreSchemaHandler) -> CoreSchema:
 
 
 @lru_cache(1)
-def _make_schema(members: tuple[type, ...], handler):
+def _make_schema(
+    members: tuple[type[Any], ...], handler: Callable[[Any], CoreSchema]
+) -> dict[str, CoreSchema]:
     return {member.__name__: handler(member) for member in members}
 
 
-def if_instance_do(x: Any, cls: type, func: Callable):
+def if_instance_do(x: C, cls: type[C], func: Callable[[C], T]) -> T:
     """If x is of type cls then return func(x), otherwise return NotImplemented.
 
     Used as a helper when implementing operator overloading.
@@ -186,11 +201,14 @@ def if_instance_do(x: Any, cls: type, func: Callable):
 
 
 #: A type variable for an `axis_` that can be specified for a scan
-Axis = TypeVar("Axis")
+Axis = TypeVar("Axis", covariant=True)
+
+#: Alternative axis variable to be used when two are required in the same type binding
+OtherAxis = TypeVar("OtherAxis")
 
 #: Map of axes to float ndarray of points
 #: E.g. {xmotor: array([0, 1, 2]), ymotor: array([2, 2, 2])}
-AxesPoints = dict[Axis, np.ndarray]
+AxesPoints = dict[Axis, npt.NDArray[np.floating[Any]]]
 
 
 class Frames(Generic[Axis]):
@@ -226,7 +244,7 @@ def __init__(
         midpoints: AxesPoints[Axis],
         lower: AxesPoints[Axis] | None = None,
         upper: AxesPoints[Axis] | None = None,
-        gap: np.ndarray | None = None,
+        gap: GapArray | None = None,
     ):
         #: The midpoints of scan frames for each axis
         self.midpoints = midpoints
@@ -274,7 +292,9 @@ def __len__(self) -> int:
         # All axespoints arrays are same length, pick the first one
         return len(self.gap)
 
-    def extract(self, indices: np.ndarray, calculate_gap=True) -> Frames[Axis]:
+    def extract(
+        self, indices: npt.NDArray[np.signedinteger[Any]], calculate_gap: bool = True
+    ) -> Frames[Axis]:
         """Return a new Frames object restricted to the indices provided.
 
         Args:
@@ -293,7 +313,7 @@ def extract_dict(ds: Iterable[AxesPoints[Axis]]) -> AxesPoints[Axis]:
                 return {k: v[dim_indices] for k, v in d.items()}
             return {}
 
-        def extract_gap(gaps: Iterable[np.ndarray]) -> np.ndarray | None:
+        def extract_gap(gaps: Iterable[GapArray]) -> GapArray | None:
             for gap in gaps:
                 if not calculate_gap:
                     return gap[dim_indices]
@@ -326,7 +346,7 @@ def concat_dict(ds: Sequence[AxesPoints[Axis]]) -> AxesPoints[Axis]:
             # lower[ax] = np.concatenate(self.lower[ax], other.lower[ax])
             return {a: np.concatenate([d[a] for d in ds]) for a in self.axes()}
 
-        def concat_gap(gaps: Sequence[np.ndarray]) -> np.ndarray:
+        def concat_gap(gaps: Sequence[GapArray]) -> GapArray:
             g = np.concatenate(gaps)
             # Calc the first frame
             g[0] = gap_between_frames(other, self)
@@ -354,7 +374,7 @@ def zip_dict(ds: Sequence[AxesPoints[Axis]]) -> AxesPoints[Axis]:
             # lower[ax] = {**self.lower[ax], **other.lower[ax]}
             return dict(kv for d in ds for kv in d.items())
 
-        def zip_gap(gaps: Sequence[np.ndarray]) -> np.ndarray:
+        def zip_gap(gaps: Sequence[GapArray]) -> GapArray:
             # Gap if either frames has a gap. E.g.
             # gap[i] = self.gap[i] | other.gap[i]
             return np.logical_or.reduce(gaps)
@@ -364,24 +384,24 @@ def zip_gap(gaps: Sequence[np.ndarray]) -> np.ndarray:
 
 def _merge_frames(
     *stack: Frames[Axis],
-    dict_merge=Callable[[Sequence[AxesPoints[Axis]]], AxesPoints[Axis]],  # type: ignore
-    gap_merge=Callable[[Sequence[np.ndarray]], np.ndarray | None],
+    dict_merge: Callable[[Sequence[AxesPoints[Axis]]], AxesPoints[Axis]],  # type: ignore
+    gap_merge: Callable[[Sequence[GapArray]], GapArray | None],
 ) -> Frames[Axis]:
     types = {type(fs) for fs in stack}
     assert len(types) == 1, f"Mismatching types for {stack}"
     cls = types.pop()
 
-    # If any lower or upper are different, apply to those
-    kwargs = {}
-    for a in ("lower", "upper"):
-        if any(fs.midpoints is not getattr(fs, a) for fs in stack):
-            kwargs[a] = dict_merge([getattr(fs, a) for fs in stack])
-
     # Apply to midpoints, force calculation of gap
     return cls(
         midpoints=dict_merge([fs.midpoints for fs in stack]),
         gap=gap_merge([fs.gap for fs in stack]),
-        **kwargs,
+        # If any lower or upper are different, apply to those
+        lower=dict_merge([fs.lower for fs in stack])
+        if any(fs.midpoints is not fs.lower for fs in stack)
+        else None,
+        upper=dict_merge([fs.upper for fs in stack])
+        if any(fs.midpoints is not fs.upper for fs in stack)
+        else None,
     )
 
 
@@ -393,7 +413,7 @@ def __init__(
         midpoints: AxesPoints[Axis],
         lower: AxesPoints[Axis] | None = None,
         upper: AxesPoints[Axis] | None = None,
-        gap: np.ndarray | None = None,
+        gap: GapArray | None = None,
     ):
         super().__init__(midpoints, lower=lower, upper=upper, gap=gap)
         # Override first element of gap to be True, as subsequent runs
@@ -401,11 +421,15 @@ def __init__(
         self.gap[0] = False
 
     @classmethod
-    def from_frames(cls, frames: Frames[Axis]) -> SnakedFrames[Axis]:
+    def from_frames(
+        cls: type[SnakedFrames[Any]], frames: Frames[OtherAxis]
+    ) -> SnakedFrames[OtherAxis]:
         """Create a snaked version of a `Frames` object."""
         return cls(frames.midpoints, frames.lower, frames.upper, frames.gap)
 
-    def extract(self, indices: np.ndarray, calculate_gap=True) -> Frames[Axis]:
+    def extract(
+        self, indices: npt.NDArray[np.signedinteger[Any]], calculate_gap: bool = True
+    ) -> Frames[Axis]:
         """Return a new Frames object restricted to the indices provided.
 
         Args:
@@ -434,23 +458,23 @@ def extract(self, indices: np.ndarray, calculate_gap=True) -> Frames[Axis]:
             cls = type(self)
             gap = None
 
-        # If lower or upper are different, apply to those
-        kwargs = {}
-        if self.midpoints is not self.lower:
-            # If going backwards select from the opposite bound
-            kwargs["lower"] = {
+        # Apply to midpoints
+        return cls(
+            {k: v[snake_indices] for k, v in self.midpoints.items()},
+            gap=gap,
+            # If lower or upper are different, apply to those
+            lower={
                 k: np.where(backwards, self.upper[k][snake_indices], v[snake_indices])
                 for k, v in self.lower.items()
             }
-        if self.midpoints is not self.upper:
-            kwargs["upper"] = {
+            if self.midpoints is not self.lower
+            else None,
+            upper={
                 k: np.where(backwards, self.lower[k][snake_indices], v[snake_indices])
                 for k, v in self.upper.items()
             }
-
-        # Apply to midpoints
-        return cls(
-            {k: v[snake_indices] for k, v in self.midpoints.items()}, gap=gap, **kwargs
+            if self.midpoints is not self.upper
+            else None,
         )
 
 
@@ -459,7 +483,9 @@ def gap_between_frames(frames1: Frames[Axis], frames2: Frames[Axis]) -> bool:
     return any(frames1.upper[a][-1] != frames2.lower[a][0] for a in frames1.axes())
 
 
-def squash_frames(stack: list[Frames[Axis]], check_path_changes=True) -> Frames[Axis]:
+def squash_frames(
+    stack: list[Frames[Axis]], check_path_changes: bool = True
+) -> Frames[Axis]:
     """Squash a stack of nested Frames into a single one.
 
     Args:
@@ -624,10 +650,7 @@ def __init__(self, stack: list[Frames[Axis]]):
     @property
     def axes(self) -> list[Axis]:
         """The axes that will be present in each points dictionary."""
-        axes = []
-        for frames in self.stack:
-            axes += frames.axes()
-        return axes
+        return list(itertools.chain(*(frames.axes() for frames in self.stack)))
 
     def __len__(self) -> int:
         """The number of dictionaries that will be produced if iterated over."""
diff --git a/src/scanspec/plot.py b/src/scanspec/plot.py
index f81c3447..44483655 100644
--- a/src/scanspec/plot.py
+++ b/src/scanspec/plot.py
@@ -1,14 +1,16 @@
 """`plot_spec` to visualize a scan."""
 
-from collections.abc import Iterator
+from collections.abc import Iterable, Iterator
 from itertools import cycle
 from typing import Any
 
 import numpy as np
+import numpy.typing as npt
 from matplotlib import colors, patches
 from matplotlib import pyplot as plt
-from mpl_toolkits.mplot3d import Axes3D, proj3d
-from scipy import interpolate
+from matplotlib.axes import Axes
+from mpl_toolkits.mplot3d import Axes3D, proj3d  # type: ignore
+from scipy import interpolate  # type: ignore
 
 from .core import Path
 from .regions import Circle, Ellipse, Polygon, Rectangle, Region, find_regions
@@ -17,51 +19,73 @@
 __all__ = ["plot_spec"]
 
 
-def _plot_arrays(axes, arrays: list[np.ndarray], **kwargs):
+def _plot_arrays(
+    axes: Axes, arrays: list[npt.NDArray[np.floating[Any]]], **kwargs: Any
+):
     if len(arrays) > 2:
-        axes.plot3D(arrays[2], arrays[1], arrays[0], **kwargs)
+        axes.plot3D(arrays[2], arrays[1], arrays[0], **kwargs)  # type: ignore
     elif len(arrays) == 2:
-        axes.plot(arrays[1], arrays[0], **kwargs)
+        axes.plot(arrays[1], arrays[0], **kwargs)  # type: ignore
     else:
-        axes.plot(arrays[0], np.zeros(len(arrays[0])), **kwargs)
+        axes.plot(arrays[0], np.zeros(len(arrays[0])), **kwargs)  # type: ignore
 
 
 # https://stackoverflow.com/a/11156353
-class _Arrow3D(patches.FancyArrowPatch):
-    def __init__(self, xs, ys, zs, *args, **kwargs):
-        super().__init__((0, 0), (0, 0), *args, **kwargs)
+class Arrow3D(patches.FancyArrowPatch):
+    def __init__(
+        self,
+        xs: npt.NDArray[np.floating[Any]],
+        ys: npt.NDArray[np.floating[Any]],
+        zs: npt.NDArray[np.floating[Any]],
+        *args: Any,
+        **kwargs: Any,
+    ):
+        super().__init__((0, 0), (0, 0), *args, **kwargs)  # type: ignore
         self._verts3d = xs, ys, zs
 
     # Added here because of https://github.com/matplotlib/matplotlib/issues/21688
-    def do_3d_projection(self, renderer=None):
+    def do_3d_projection(self, renderer: Any = None):
         xs3d, ys3d, zs3d = self._verts3d
         xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)  # type: ignore
         self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
 
         return np.min(zs)
 
+    @property
+    def verts3d(
+        self,
+    ) -> tuple[
+        npt.NDArray[np.floating[Any]],
+        npt.NDArray[np.floating[Any]],
+        npt.NDArray[np.floating[Any]],
+    ]:
+        return self._verts3d
 
-def _plot_arrow(axes, arrays: list[np.ndarray]):
+
+def _plot_arrow(axes: Axes, arrays: list[npt.NDArray[np.floating[Any]]]):
     if len(arrays) == 1:
         arrays = [np.array([0, 0])] + arrays
     if len(arrays) == 2:
         head = [a[-1] for a in reversed(arrays)]
         tail = [a[-1] - (a[-1] - a[-2]) * 0.1 for a in reversed(arrays)]
-        axes.annotate(
+        axes.annotate(  # type: ignore
             "",
-            head[:2],
-            tail[:2],
+            tuple(head[:2]),
+            tuple(tail[:2]),
             arrowprops={"color": "lightgrey", "arrowstyle": "-|>"},
         )
     elif len(arrays) == 3:
         arrows = [a[-2:] for a in reversed(arrays)]
-        a = _Arrow3D(
-            *arrows[:3], mutation_scale=10, arrowstyle="-|>", color="lightgrey"
-        )
+        a = Arrow3D(*arrows[:3], mutation_scale=10, arrowstyle="-|>", color="lightgrey")
         axes.add_artist(a)
 
 
-def _plot_spline(axes, ranges, arrays: list[np.ndarray], index_colours: dict[int, str]):
+def _plot_spline(
+    axes: Axes,
+    ranges: list[float],
+    arrays: list[npt.NDArray[np.floating[Any]]],
+    index_colours: dict[int, str],
+) -> Iterable[list[npt.NDArray[np.floating[Any]]]]:
     scaled_arrays = [a / r for a, r in zip(arrays, ranges, strict=False)]
     # Define curves parametrically
     t = np.zeros(len(arrays[0]))
@@ -76,12 +100,14 @@ def _plot_spline(axes, ranges, arrays: list[np.ndarray], index_colours: dict[int
         # There are no duplicated points, plot a spline
         t /= t[-1]
         # Scale the arrays so splines don't favour larger scaled axes
-        tck, _ = interpolate.splprep(scaled_arrays, k=2, s=0)
+        tck, _ = interpolate.splprep(scaled_arrays, k=2, s=0)  # type: ignore
         starts = sorted(index_colours)
         stops = starts[1:] + [len(arrays[0]) - 1]
         for start, stop in zip(starts, stops, strict=False):
-            tnew = np.linspace(t[start], t[stop], num=1001)
-            spline = interpolate.splev(tnew, tck)
+            start_value: float = t[start]
+            stop_value: float = t[stop]
+            tnew = np.linspace(start_value, stop_value, num=1001)
+            spline: npt.NDArray[np.floating[Any]] = interpolate.splev(tnew, tck)  # type: ignore
             # Scale the splines back to the original scaling
             unscaled_splines = [a * r for a, r in zip(spline, ranges, strict=False)]
             _plot_arrays(axes, unscaled_splines, color=index_colours[start])
@@ -110,31 +136,31 @@ def plot_spec(spec: Spec[Any], title: str | None = None):
 
     # Setup axes
     if ndims > 2:
-        plt.figure(figsize=(6, 6))
-        plt_axes = plt.axes(projection="3d")
-        plt_axes.grid(False)
+        plt.figure(figsize=(6, 6))  # type: ignore
+        plt_axes: Axes = plt.axes(projection="3d")  # type: ignore
+        plt_axes.grid(False)  # type: ignore
         if isinstance(plt_axes, Axes3D):
-            plt_axes.set_zlabel(axes[-3])
-            plt_axes.set_ylabel(axes[-2])
-            plt_axes.view_init(elev=15)
+            plt_axes.set_zlabel(axes[-3])  # type: ignore
+            plt_axes.set_ylabel(axes[-2])  # type: ignore
+            plt_axes.view_init(elev=15)  # type: ignore
         else:
             raise TypeError(
                 "Expected matplotlib to create an Axes3D object, "
                 f"instead got: {plt_axes}"
             )
     elif ndims == 2:
-        plt.figure(figsize=(6, 6))
-        plt_axes = plt.axes()
-        plt_axes.set_ylabel(axes[-2])
+        plt.figure(figsize=(6, 6))  # type: ignore
+        plt_axes = plt.axes()  # type: ignore
+        plt_axes.set_ylabel(axes[-2])  # type: ignore
     else:
-        plt.figure(figsize=(6, 2))
-        plt_axes = plt.axes()
+        plt.figure(figsize=(6, 2))  # type: ignore
+        plt_axes = plt.axes()  # type: ignore
         plt_axes.yaxis.set_visible(False)
-    plt_axes.set_xlabel(axes[-1])
+    plt_axes.set_xlabel(axes[-1])  # type: ignore
 
     # Title with dimension sizes
     title = title or ", ".join(f"Dim[{' '.join(d.axes())} len={len(d)}]" for d in dims)
-    plt.title(title)
+    plt.title(title)  # type: ignore
 
     # Plot any Regions
     if ndims <= 2:
@@ -164,8 +190,8 @@ def plot_spec(spec: Spec[Any], title: str | None = None):
                 plt_axes.add_patch(patches.Polygon(xy_verts, fill=False))
 
     # Plot the splines
-    tail: Any = {a: None for a in axes}
-    ranges = [max(np.max(v) - np.min(v), 0.0001) for k, v in dim.midpoints.items()]
+    tail: dict[str, npt.NDArray[np.floating[Any]] | None] = {a: None for a in axes}
+    ranges = [max(float(np.max(v) - np.min(v)), 0.0001) for v in dim.midpoints.values()]
     seg_col = cycle(colors.TABLEAU_COLORS)
     last_index = 0
     splines = None
@@ -174,8 +200,8 @@ def plot_spec(spec: Spec[Any], title: str | None = None):
     gap_indices = list(np.nonzero(dim.gap[1:])[0] + 1)
     for index in gap_indices + [len(dim)]:
         num_points = index - last_index
-        arrays = []
-        turnaround = []
+        arrays: list[npt.NDArray[np.floating[Any]]] = []
+        turnaround: list[npt.NDArray[np.floating[Any]]] = []
         for a in axes:
             # Add the midpoints and the lower and upper bounds
             arr = np.empty(num_points * 2 + 1)
@@ -184,13 +210,15 @@ def plot_spec(spec: Spec[Any], title: str | None = None):
             arr[-1] = dim.upper[a][index - 1]
             arrays.append(arr)
             # Add the turnaround
-            if tail[a] is not None:
+            axis_tail = tail[a]
+            if axis_tail is not None:
                 # Already had a tail, add lead in points
-                tail[a][2:] = np.linspace(-0.01, 0, 2) * (arr[1] - arr[0]) + arr[0]
-                turnaround.append(tail[a])
+                axis_tail[2:] = np.linspace(-0.01, 0, 2) * (arr[1] - arr[0]) + arr[0]
+                turnaround.append(axis_tail)
             # Add tail off points
-            tail[a] = np.empty(4)
-            tail[a][:2] = np.linspace(0, 0.01, 2) * (arr[-1] - arr[-2]) + arr[-1]
+            axis_tail = np.empty(4)
+            axis_tail[:2] = np.linspace(0, 0.01, 2) * (arr[-1] - arr[-2]) + arr[-1]
+            tail[a] = axis_tail
         last_index = index
 
         arrow_arr = None
@@ -240,4 +268,4 @@ def plot_spec(spec: Spec[Any], title: str | None = None):
         color="lightgrey",
     )
 
-    plt.show()
+    plt.show()  # type: ignore
diff --git a/src/scanspec/regions.py b/src/scanspec/regions.py
index 0f8e6872..b9f2ba42 100644
--- a/src/scanspec/regions.py
+++ b/src/scanspec/regions.py
@@ -9,9 +9,10 @@
 
 from collections.abc import Iterator, Mapping
 from dataclasses import is_dataclass
-from typing import Any, Generic
+from typing import Any, Generic, cast
 
 import numpy as np
+import numpy.typing as npt
 from pydantic import BaseModel, Field, TypeAdapter
 from pydantic.dataclasses import dataclass
 
@@ -39,6 +40,8 @@
     "find_regions",
 ]
 
+NpMask = npt.NDArray[np.bool]
+
 
 @discriminated_union_of_subclasses
 class Region(Generic[Axis]):
@@ -56,33 +59,33 @@ def axis_sets(self) -> list[set[Axis]]:  # noqa: D102
         """Produce the non-overlapping sets of axes this region spans."""
         raise NotImplementedError(self)
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         """Produce a mask of which points are in the region."""
         raise NotImplementedError(self)
 
-    def __or__(self, other) -> UnionOf[Axis]:
+    def __or__(self, other: Region[Axis]) -> UnionOf[Axis]:
         return if_instance_do(other, Region, lambda o: UnionOf(self, o))
 
-    def __and__(self, other) -> IntersectionOf[Axis]:
+    def __and__(self, other: Region[Axis]) -> IntersectionOf[Axis]:
         return if_instance_do(other, Region, lambda o: IntersectionOf(self, o))
 
-    def __sub__(self, other) -> DifferenceOf[Axis]:
+    def __sub__(self, other: Region[Axis]) -> DifferenceOf[Axis]:
         return if_instance_do(other, Region, lambda o: DifferenceOf(self, o))
 
-    def __xor__(self, other) -> SymmetricDifferenceOf[Axis]:
+    def __xor__(self, other: Region[Axis]) -> SymmetricDifferenceOf[Axis]:
         return if_instance_do(other, Region, lambda o: SymmetricDifferenceOf(self, o))
 
     def serialize(self) -> Mapping[str, Any]:
         """Serialize the Region to a dictionary."""
-        return TypeAdapter(Region).dump_python(self)
+        return TypeAdapter(Region[Any]).dump_python(self)
 
     @staticmethod
-    def deserialize(obj) -> Region:
+    def deserialize(obj: Any) -> Region[Any]:
         """Deserialize a Region from a dictionary."""
-        return TypeAdapter(Region).validate_python(obj)
+        return TypeAdapter(Region[Any]).validate_python(obj)
 
 
-def get_mask(region: Region[Axis], points: AxesPoints[Axis]) -> np.ndarray:
+def get_mask(region: Region[Axis], points: AxesPoints[Axis]) -> NpMask:
     """Return a mask of the points inside the region.
 
     If there is an overlap of axes of region and points return a
@@ -93,7 +96,7 @@ def get_mask(region: Region[Axis], points: AxesPoints[Axis]) -> np.ndarray:
     if needs_mask:
         return region.mask(points)
     else:
-        return np.ones(len(list(points.values())[0]))
+        return np.ones(len(list(points.values())[0]), dtype=np.bool)
 
 
 def _merge_axis_sets(axis_sets: list[set[Axis]]) -> Iterator[set[Axis]]:
@@ -137,7 +140,7 @@ class UnionOf(CombinationOf[Axis]):
     array([False,  True,  True,  True, False])
     """
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         mask = get_mask(self.left, points) | get_mask(self.right, points)
         return mask
 
@@ -153,7 +156,7 @@ class IntersectionOf(CombinationOf[Axis]):
     array([False, False,  True, False, False])
     """
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         mask = get_mask(self.left, points) & get_mask(self.right, points)
         return mask
 
@@ -169,7 +172,7 @@ class DifferenceOf(CombinationOf[Axis]):
     array([False,  True, False, False, False])
     """
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         left_mask = get_mask(self.left, points)
         # Return the xor restricted to the left region
         mask = left_mask ^ get_mask(self.right, points) & left_mask
@@ -187,7 +190,7 @@ class SymmetricDifferenceOf(CombinationOf[Axis]):
     array([False,  True, False,  True, False])
     """
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         mask = get_mask(self.left, points) ^ get_mask(self.right, points)
         return mask
 
@@ -208,7 +211,7 @@ class Range(Region[Axis]):
     def axis_sets(self) -> list[set[Axis]]:  # noqa: D102
         return [{self.axis}]
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         v = points[self.axis]
         mask = np.bitwise_and(v >= self.min, v <= self.max)
         return mask
@@ -240,7 +243,7 @@ class Rectangle(Region[Axis]):
     def axis_sets(self) -> list[set[Axis]]:  # noqa: D102
         return [{self.x_axis, self.y_axis}]
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         x = points[self.x_axis] - self.x_min
         y = points[self.y_axis] - self.y_min
         if self.angle != 0:
@@ -280,15 +283,15 @@ class Polygon(Region[Axis]):
     def axis_sets(self) -> list[set[Axis]]:  # noqa: D102
         return [{self.x_axis, self.y_axis}]
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         x = points[self.x_axis]
         y = points[self.y_axis]
         v1x, v1y = self.x_verts[-1], self.y_verts[-1]
-        mask = np.full(len(x), False, dtype=np.int8)
+        mask = np.full(len(x), False, dtype=np.bool)
         for v2x, v2y in zip(self.x_verts, self.y_verts, strict=False):
             # skip horizontal edges
             if v2y != v1y:
-                vmask = np.full(len(x), False, dtype=np.int8)
+                vmask = np.full(len(x), False, dtype=np.bool)
                 vmask |= (y < v2y) & (y >= v1y)
                 vmask |= (y < v1y) & (y >= v2y)
                 t = (y - v1y) / (v2y - v1y)
@@ -320,7 +323,7 @@ class Circle(Region[Axis]):
     def axis_sets(self) -> list[set[Axis]]:  # noqa: D102
         return [{self.x_axis, self.y_axis}]
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         x = points[self.x_axis] - self.x_middle
         y = points[self.y_axis] - self.y_middle
         mask = x * x + y * y <= (self.radius * self.radius)
@@ -355,7 +358,7 @@ class Ellipse(Region[Axis]):
     def axis_sets(self) -> list[set[Axis]]:  # noqa: D102
         return [{self.x_axis, self.y_axis}]
 
-    def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
+    def mask(self, points: AxesPoints[Axis]) -> NpMask:  # noqa: D102
         x = points[self.x_axis] - self.x_middle
         y = points[self.y_axis] - self.y_middle
         if self.angle != 0:
@@ -369,7 +372,7 @@ def mask(self, points: AxesPoints[Axis]) -> np.ndarray:  # noqa: D102
         return mask
 
 
-def find_regions(obj) -> Iterator[Region]:
+def find_regions(obj: Any) -> Iterator[Region[Any]]:  # noqa: D102
     """Recursively yield Regions from obj and its children."""
     if (
         hasattr(obj, "__pydantic_model__")
@@ -379,5 +382,5 @@ def find_regions(obj) -> Iterator[Region]:
         if isinstance(obj, Region):
             yield obj
         for name in obj.__dict__.keys():
-            regions: Iterator[Region] = find_regions(getattr(obj, name))
+            regions: Iterator[Region[Any]] = find_regions(getattr(cast(Any, obj), name))
             yield from regions
diff --git a/src/scanspec/service.py b/src/scanspec/service.py
index ce628de5..dcf152ed 100644
--- a/src/scanspec/service.py
+++ b/src/scanspec/service.py
@@ -4,8 +4,10 @@
 import json
 from collections.abc import Mapping
 from enum import Enum
+from typing import Any
 
 import numpy as np
+import numpy.typing as npt
 from fastapi import Body, FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.openapi.utils import get_openapi
@@ -31,8 +33,8 @@
 class ValidResponse:
     """Response model for spec validation."""
 
-    input_spec: Spec = Field(description="The input scanspec")
-    valid_spec: Spec = Field(description="The validated version of the spec")
+    input_spec: Spec[str] = Field(description="The input scanspec")
+    valid_spec: Spec[str] = Field(description="The validated version of the spec")
 
 
 class PointsFormat(str, Enum):
@@ -47,7 +49,7 @@ class PointsFormat(str, Enum):
 class PointsRequest:
     """A request for generated scan points."""
 
-    spec: Spec = Field(description="The spec from which to generate points")
+    spec: Spec[str] = Field(description="The spec from which to generate points")
     max_frames: int | None = Field(
         description="The maximum number of points to return, if None will return "
         "as many as calculated",
@@ -125,9 +127,9 @@ class SmallestStepResponse:
 
 @app.post("/valid", response_model=ValidResponse)
 def valid(
-    spec: Spec = Body(..., examples=[_EXAMPLE_SPEC]),
+    spec: Spec[str] = Body(..., examples=[_EXAMPLE_SPEC]),
 ) -> ValidResponse:
-    """Validate wether a ScanSpec can produce a viable scan.
+    """Validate wether a ScanSpec[str] can produce a viable scan.
 
     Args:
         spec: The scanspec to validate
@@ -200,7 +202,7 @@ def bounds(
 
 @app.post("/gap", response_model=GapResponse)
 def gap(
-    spec: Spec = Body(
+    spec: Spec[str] = Body(
         ...,
         examples=[_EXAMPLE_SPEC],
     ),
@@ -226,7 +228,7 @@ def gap(
 
 @app.post("/smalleststep", response_model=SmallestStepResponse)
 def smallest_step(
-    spec: Spec = Body(..., examples=[_EXAMPLE_SPEC]),
+    spec: Spec[str] = Body(..., examples=[_EXAMPLE_SPEC]),
 ) -> SmallestStepResponse:
     """Calculate the smallest step in a scan, both absolutely and per-axis.
 
@@ -256,7 +258,7 @@ def smallest_step(
 #
 
 
-def _to_chunk(request: PointsRequest) -> tuple[Frames, int]:
+def _to_chunk(request: PointsRequest) -> tuple[Frames[str], int]:
     spec = Spec.deserialize(request.spec)
     dims = spec.calculate()  # Grab dimensions from spec
     path = Path(dims)  # Convert to a path
@@ -303,7 +305,7 @@ def _format_axes_points(
         raise KeyError(f"Unknown format: {format}")
 
 
-def _reduce_frames(stack: list[Frames[str]], max_frames: int) -> Path:
+def _reduce_frames(stack: list[Frames[str]], max_frames: int) -> Path[str]:
     """Removes frames from a spec so len(path) < max_frames.
 
     Args:
@@ -323,7 +325,7 @@ def _reduce_frames(stack: list[Frames[str]], max_frames: int) -> Path:
     return Path(sub_frames)
 
 
-def _sub_sample(frames: Frames[str], ratio: float) -> Frames:
+def _sub_sample(frames: Frames[str], ratio: float) -> Frames[str]:
     """Provides a sub-sample Frames object whilst preserving its core structure.
 
     Args:
@@ -336,7 +338,7 @@ def _sub_sample(frames: Frames[str], ratio: float) -> Frames:
     return frames.extract(indexes, calculate_gap=False)
 
 
-def _calc_smallest_step(points: list[np.ndarray]) -> float:
+def _calc_smallest_step(points: list[npt.NDArray[np.float64]]) -> float:
     # Calc abs diffs of all axes, ignoring any zero values
     absolute_diffs = [_abs_diffs(axis_midpoints) for axis_midpoints in points]
     # Normalize and remove zeros
@@ -346,7 +348,7 @@ def _calc_smallest_step(points: list[np.ndarray]) -> float:
     return np.amin(norm_diffs)
 
 
-def _abs_diffs(array: np.ndarray) -> np.ndarray:
+def _abs_diffs(array: npt.NDArray[np.number[Any]]) -> npt.NDArray[np.number[Any]]:
     """Calculates the absolute differences between adjacent elements in the array.
 
     Args:
@@ -374,7 +376,7 @@ def run_app(cors: bool = False, port: int = 8080) -> None:
 
     import uvicorn
 
-    uvicorn.run(app, port=port)
+    uvicorn.run(app=app, port=port)
 
 
 def scanspec_schema_text() -> str:
diff --git a/src/scanspec/specs.py b/src/scanspec/specs.py
index adfec1a7..5a7807e9 100644
--- a/src/scanspec/specs.py
+++ b/src/scanspec/specs.py
@@ -8,12 +8,10 @@
 from __future__ import annotations
 
 from collections.abc import Callable, Mapping
-from typing import (
-    Any,
-    Generic,
-)
+from typing import Any, Generic, overload
 
 import numpy as np
+import numpy.typing as npt
 from pydantic import Field, TypeAdapter, validate_call
 from pydantic.dataclasses import dataclass
 
@@ -21,6 +19,7 @@
     Axis,
     Frames,
     Midpoints,
+    OtherAxis,
     Path,
     SnakedFrames,
     StrictConfig,
@@ -72,7 +71,9 @@ def axes(self) -> list[Axis]:  # noqa: D102
         """
         raise NotImplementedError(self)
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:  # noqa: D102
         """Produce a stack of nested `Frames` that form the scan.
 
         Ordered from slowest moving to fastest moving.
@@ -91,34 +92,42 @@ def shape(self) -> tuple[int, ...]:
         """Return the final, simplified shape of the scan."""
         return tuple(len(dim) for dim in self.calculate())
 
-    def __rmul__(self, other) -> Product[Axis]:
+    def __rmul__(self, other: int) -> Product[Axis]:
         return if_instance_do(other, int, lambda o: Product(Repeat(o), self))
 
-    def __mul__(self, other) -> Product[Axis]:
+    @overload
+    def __mul__(self, other: Spec[Axis]) -> Product[Axis]: ...
+
+    @overload
+    def __mul__(self, other: Spec[OtherAxis]) -> Product[Axis | OtherAxis]: ...
+
+    def __mul__(
+        self, other: Spec[Axis] | Spec[OtherAxis]
+    ) -> Product[Axis] | Product[Axis | OtherAxis]:
         return if_instance_do(other, Spec, lambda o: Product(self, o))
 
-    def __and__(self, other) -> Mask[Axis]:
+    def __and__(self, other: Region[Axis]) -> Mask[Axis]:
         return if_instance_do(other, Region, lambda o: Mask(self, o))
 
     def __invert__(self) -> Snake[Axis]:
         return Snake(self)
 
-    def zip(self, other: Spec) -> Zip[Axis]:
+    def zip(self, other: Spec[OtherAxis]) -> Zip[Axis | OtherAxis]:
         """`Zip` the Spec with another, iterating in tandem."""
-        return Zip(self, other)
+        return Zip(left=self, right=other)
 
-    def concat(self, other: Spec) -> Concat[Axis]:
+    def concat(self, other: Spec[Axis]) -> Concat[Axis]:
         """`Concat` the Spec with another, iterating one after the other."""
         return Concat(self, other)
 
     def serialize(self) -> Mapping[str, Any]:
         """Serialize the Spec to a dictionary."""
-        return TypeAdapter(Spec).dump_python(self)
+        return TypeAdapter(Spec[Any]).dump_python(self)
 
     @staticmethod
-    def deserialize(obj) -> Spec:
+    def deserialize(obj: Any) -> Spec[Any]:
         """Deserialize a Spec from a dictionary."""
-        return TypeAdapter(Spec).validate_python(obj)
+        return TypeAdapter(Spec[Any]).validate_python(obj)
 
 
 @dataclass(config=StrictConfig)
@@ -140,7 +149,9 @@ class Product(Spec[Axis]):
     def axes(self) -> list[Axis]:  # noqa: D102
         return self.outer.axes() + self.inner.axes()
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         frames_outer = self.outer.calculate(bounds=False, nested=nested)
         frames_inner = self.inner.calculate(bounds, nested=True)
         return frames_outer + frames_inner
@@ -179,7 +190,9 @@ class Repeat(Spec[Axis]):
     def axes(self) -> list[Axis]:  # noqa: D102
         return []
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         return [Frames({}, gap=np.full(self.num, self.gap))]
 
 
@@ -216,7 +229,9 @@ class Zip(Spec[Axis]):
     def axes(self) -> list[Axis]:  # noqa: D102
         return self.left.axes() + self.right.axes()
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         frames_left = self.left.calculate(bounds, nested)
         frames_right = self.right.calculate(bounds, nested)
         assert len(frames_left) >= len(
@@ -241,7 +256,7 @@ def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D
         padded_right += frames_right  # type: ignore
 
         # Work through, zipping them together one by one
-        frames = []
+        frames: list[Frames[Axis]] = []
         for left, right in zip(frames_left, padded_right, strict=False):
             if right is None:
                 combined = left
@@ -284,7 +299,9 @@ class Mask(Spec[Axis]):
     def axes(self) -> list[Axis]:  # noqa: D102
         return self.spec.axes()
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         frames = self.spec.calculate(bounds, nested)
         for axis_set in self.region.axis_sets():
             # Find the start and end index of any dimensions containing these axes
@@ -299,7 +316,7 @@ def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D
                 squashed = squash_frames(frames[si : ei + 1], check_path_changes)
                 frames = frames[:si] + [squashed] + frames[ei + 1 :]
         # Generate masks from the midpoints showing what's inside
-        masked_frames = []
+        masked_frames: list[Frames[Axis]] = []
         for f in frames:
             indices = get_mask(self.region, f.midpoints).nonzero()[0]
             masked_frames.append(f.extract(indices))
@@ -342,7 +359,9 @@ class Snake(Spec[Axis]):
     def axes(self) -> list[Axis]:  # noqa: D102
         return self.spec.axes()
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         return [
             SnakedFrames.from_frames(segment)
             for segment in self.spec.calculate(bounds, nested)
@@ -385,7 +404,9 @@ def axes(self) -> list[Axis]:  # noqa: D102
         assert set(left_axes) == set(right_axes), f"axes {left_axes} != {right_axes}"
         return left_axes
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         dim_left = squash_frames(
             self.left.calculate(bounds, nested), nested and self.check_path_changes
         )
@@ -420,15 +441,17 @@ class Squash(Spec[Axis]):
     def axes(self) -> list[Axis]:  # noqa: D102
         return self.spec.axes()
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         dims = self.spec.calculate(bounds, nested)
         dim = squash_frames(dims, nested and self.check_path_changes)
         return [dim]
 
 
 def _dimensions_from_indexes(
-    func: Callable[[np.ndarray], dict[Axis, np.ndarray]],
-    axes: list,
+    func: Callable[[npt.NDArray[np.float64]], dict[Axis, npt.NDArray[np.float64]]],
+    axes: list[Axis],
     num: int,
     bounds: bool,
 ) -> list[Frames[Axis]]:
@@ -472,7 +495,9 @@ class Line(Spec[Axis]):
     def axes(self) -> list[Axis]:  # noqa: D102
         return [self.axis]
 
-    def _line_from_indexes(self, indexes: np.ndarray) -> dict[Axis, np.ndarray]:
+    def _line_from_indexes(
+        self, indexes: npt.NDArray[np.float64]
+    ) -> dict[Axis, npt.NDArray[np.float64]]:
         if self.num == 1:
             # Only one point, stop-start gives length of one point
             step = self.stop - self.start
@@ -484,19 +509,21 @@ def _line_from_indexes(self, indexes: np.ndarray) -> dict[Axis, np.ndarray]:
         first = self.start - step / 2
         return {self.axis: indexes * step + first}
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         return _dimensions_from_indexes(
             self._line_from_indexes, self.axes(), self.num, bounds
         )
 
     @classmethod
     def bounded(
-        cls,
-        axis: Axis = Field(description="An identifier for what to move"),
+        cls: type[Line[Any]],
+        axis: OtherAxis = Field(description="An identifier for what to move"),
         lower: float = Field(description="Lower bound of the first point of the line"),
         upper: float = Field(description="Upper bound of the last point of the line"),
         num: int = Field(ge=1, description="Number of frames to produce"),
-    ) -> Line[Axis]:
+    ) -> Line[OtherAxis]:
         """Specify a Line by extreme bounds instead of midpoints.
 
         .. example_spec::
@@ -541,7 +568,7 @@ class Static(Spec[Axis]):
 
     @classmethod
     def duration(
-        cls: type[Static],
+        cls: type[Static[Any]],
         duration: float = Field(description="The duration of each static point"),
         num: int = Field(ge=1, description="Number of frames to produce", default=1),
     ) -> Static[str]:
@@ -553,15 +580,19 @@ def duration(
 
             spec = Line("y", 1, 2, 3).zip(Static.duration(0.1))
         """
-        return cls(DURATION, duration, num)
+        return Static(DURATION, duration, num)
 
     def axes(self) -> list[Axis]:  # noqa: D102
         return [self.axis]
 
-    def _repeats_from_indexes(self, indexes: np.ndarray) -> dict[Axis, np.ndarray]:
+    def _repeats_from_indexes(
+        self, indexes: npt.NDArray[np.float64]
+    ) -> dict[Axis, npt.NDArray[np.float64]]:
         return {self.axis: np.full(len(indexes), self.value)}
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         return _dimensions_from_indexes(
             self._repeats_from_indexes, self.axes(), self.num, bounds
         )
@@ -601,7 +632,9 @@ def axes(self) -> list[Axis]:  # noqa: D102
         # TODO: reversed from __init__ args, a good idea?
         return [self.y_axis, self.x_axis]
 
-    def _spiral_from_indexes(self, indexes: np.ndarray) -> dict[Axis, np.ndarray]:
+    def _spiral_from_indexes(
+        self, indexes: npt.NDArray[np.float64]
+    ) -> dict[Axis, npt.NDArray[np.float64]]:
         # simplest spiral equation: r = phi
         # we want point spacing across area to be the same as between rings
         # so: sqrt(area / num) = ring_spacing
@@ -618,16 +651,18 @@ def _spiral_from_indexes(self, indexes: np.ndarray) -> dict[Axis, np.ndarray]:
             self.x_axis: self.x_start + x_scale * phi * np.sin(phi + self.rotate),
         }
 
-    def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:  # noqa: D102
+    def calculate(  # noqa: D102
+        self, bounds: bool = True, nested: bool = False
+    ) -> list[Frames[Axis]]:
         return _dimensions_from_indexes(
             self._spiral_from_indexes, self.axes(), self.num, bounds
         )
 
     @classmethod
     def spaced(
-        cls,
-        x_axis: Axis = Field(description="An identifier for what to move for x"),
-        y_axis: Axis = Field(description="An identifier for what to move for y"),
+        cls: type[Spiral[Any]],
+        x_axis: OtherAxis = Field(description="An identifier for what to move for x"),
+        y_axis: OtherAxis = Field(description="An identifier for what to move for y"),
         x_start: float = Field(description="x centre of the spiral"),
         y_start: float = Field(description="y centre of the spiral"),
         radius: float = Field(description="radius of the spiral"),
@@ -635,7 +670,7 @@ def spaced(
         rotate: float = Field(
             description="How much to rotate the angle of the spiral", default=0.0
         ),
-    ) -> Spiral[Axis]:
+    ) -> Spiral[OtherAxis]:
         """Specify a Spiral equally spaced in "x_axis" and "y_axis".
 
         .. example_spec::
@@ -651,14 +686,21 @@ def spaced(
         n_rings = radius / dr
         num = int(n_rings**2 * np.pi)
         return cls(
-            x_axis, y_axis, x_start, y_start, radius * 2, radius * 2, num, rotate
+            x_axis,
+            y_axis,
+            x_start,
+            y_start,
+            radius * 2,
+            radius * 2,
+            num,
+            rotate,
         )
 
 
 Spiral.spaced = validate_call(Spiral.spaced)  # type:ignore
 
 
-def fly(spec: Spec[Axis], duration: float) -> Spec[Axis]:
+def fly(spec: Spec[Axis], duration: float) -> Spec[Axis | str]:
     """Flyscan, zipping with fixed duration for every frame.
 
     Args:
@@ -675,7 +717,7 @@ def fly(spec: Spec[Axis], duration: float) -> Spec[Axis]:
     return spec.zip(Static.duration(duration))
 
 
-def step(spec: Spec[Axis], duration: float, num: int = 1) -> Spec[Axis]:
+def step(spec: Spec[Axis], duration: float, num: int = 1) -> Spec[Axis | str]:
     """Step scan, with num frames of given duration at each frame in the spec.
 
     Args:
@@ -694,7 +736,7 @@ def step(spec: Spec[Axis], duration: float, num: int = 1) -> Spec[Axis]:
     return spec * Static.duration(duration, num)
 
 
-def get_constant_duration(frames: list[Frames]) -> float | None:
+def get_constant_duration(frames: list[Frames[Any]]) -> float | None:
     """Returns the duration of a number of ScanSpec frames, if known and consistent.
 
     Args:
diff --git a/src/scanspec/sphinxext.py b/src/scanspec/sphinxext.py
index 69500422..9362a983 100644
--- a/src/scanspec/sphinxext.py
+++ b/src/scanspec/sphinxext.py
@@ -1,9 +1,11 @@
 """An example_spec directive."""
 
 from contextlib import contextmanager
+from typing import Any, cast
 
 from docutils.statemachine import StringList
 from matplotlib.sphinxext import plot_directive
+from sphinx.application import Sphinx
 
 from . import __version__
 
@@ -15,9 +17,18 @@ def always_create_figures():
     This is needed even if source rst hasn't changed, as we often use
     example_spec from within docstrings
     """
-    orig_f = plot_directive.out_of_date
+
+    def always_true(
+        original: Any,
+        derived: Any,
+        includes: Any = None,
+    ) -> bool:
+        return True
+
+    # Type ignored because we never manipulate this object
+    orig_f = plot_directive.out_of_date  # type: ignore
     # Patch the plot directive so it thinks all sources are out of date
-    plot_directive.out_of_date = lambda *args, **kwargs: True
+    plot_directive.out_of_date = always_true
     try:
         yield
     finally:
@@ -27,7 +38,7 @@ def always_create_figures():
 class ExampleSpecDirective(plot_directive.PlotDirective):
     """Runs `plot_spec` on the ``spec`` definied in the content."""
 
-    def run(self):
+    def run(self) -> Any:
         """Run the directive."""
         self.content = StringList(
             ["# Example Spec", "", "from scanspec.plot import plot_spec"]
@@ -35,10 +46,10 @@ def run(self):
             + ["plot_spec(spec)"]
         )
         with always_create_figures():
-            return super().run()
+            return cast(Any, super().run())
 
 
-def setup(app):
+def setup(app: Sphinx):
     """Setup this extension in sphinx."""
     app.add_directive("example_spec", ExampleSpecDirective)
 


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29b..dceb31a2 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,27 @@
+from typing import Any
+
+import pytest
+
+
+def approx(
+    expected: Any,
+    rel: float | None = None,
+    abs: float | None = None,
+    nan_ok: bool = False,
+) -> Any:
+    """
+    Temporary loosely typed wrapper around approx.
+    To be removed pending:
+    https://github.com/pytest-dev/pytest/issues/7469
+
+    Args:
+        expected: Expected value
+        rel: Relative tolerance. Defaults to None.
+        abs: Absolute tolerance. Defaults to None.
+        nan_ok: Permit nan. Defaults to False.
+
+    Returns:
+        Any: Approximate comparator
+    """
+
+    return pytest.approx(expected, rel=rel, abs=abs, nan_ok=nan_ok)  # type: ignore
diff --git a/tests/test_basemodel.py b/tests/test_basemodel.py
index cb809747..a9897632 100644
--- a/tests/test_basemodel.py
+++ b/tests/test_basemodel.py
@@ -5,7 +5,7 @@
 
 
 class Foo(BaseModel):
-    spec: Spec
+    spec: Spec[str]
 
 
 simple_foo = Foo(spec=Line("x", 1, 2, 5))
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 37894ff0..421667b9 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -7,42 +7,70 @@
 
 import matplotlib.pyplot as plt
 import numpy as np
-import pytest
+import numpy.typing as npt
 from click.testing import CliRunner
+from matplotlib.lines import Line2D
 from matplotlib.patches import Rectangle
 from matplotlib.text import Annotation
+from mpl_toolkits.mplot3d.art3d import Line3D  # type: ignore
 
 from scanspec import __version__, cli
-from scanspec.plot import _Arrow3D
+from scanspec.plot import Arrow3D
 
+from . import approx
 
-def assert_min_max_2d(line, xmin, xmax, ymin, ymax, length=None):
+
+def assert_min_max_2d(
+    line: Line2D,
+    xmin: float,
+    xmax: float,
+    ymin: float,
+    ymax: float,
+    length: float | None = None,
+):
+    line_data = cast(npt.NDArray[np.float64], line.get_data())
     if length is not None:
-        assert len(line.get_data()[0]) == length
-    mins = np.min(line.get_data(), axis=1)
-    maxs = np.max(line.get_data(), axis=1)
+        assert len(line_data[0]) == length
+    mins = np.min(line_data, axis=1)
+    maxs = np.max(line_data, axis=1)
     assert list(zip(mins, maxs, strict=False)) == [
-        pytest.approx([xmin, xmax]),
-        pytest.approx([ymin, ymax]),
+        approx([xmin, xmax]),
+        approx([ymin, ymax]),
     ]
 
 
-def assert_min_max_3d(line, xmin, xmax, ymin, ymax, zmin, zmax, length=None):
+def assert_min_max_3d(
+    line: Line3D,
+    xmin: float,
+    xmax: float,
+    ymin: float,
+    ymax: float,
+    zmin: float,
+    zmax: float,
+    length: float | None = None,
+):
+    data_3d = cast(npt.NDArray[np.float64], line.get_data_3d())
     if length is not None:
-        assert len(line.get_data_3d()[0]) == length
-    mins = np.min(line.get_data_3d(), axis=1)
-    maxs = np.max(line.get_data_3d(), axis=1)
+        assert len(data_3d[0]) == length
+    mins = np.min(data_3d, axis=1)
+    maxs = np.max(data_3d, axis=1)
     assert list(zip(mins, maxs, strict=False)) == [
-        pytest.approx([xmin, xmax]),
-        pytest.approx([ymin, ymax]),
-        pytest.approx([zmin, zmax]),
+        approx([xmin, xmax]),
+        approx([ymin, ymax]),
+        approx([zmin, zmax]),
     ]
 
 
-def assert_3d_arrow(artist, x, y, z):
-    assert artist._verts3d[0][1] == pytest.approx(x)
-    assert artist._verts3d[1][1] == pytest.approx(y)
-    assert artist._verts3d[2][1] == pytest.approx(z)
+def assert_3d_arrow(
+    artist: Line3D,
+    x: float,
+    y: float,
+    z: float,
+):
+    assert isinstance(artist, Arrow3D)
+    assert artist.verts3d[0][1] == approx(x)
+    assert artist.verts3d[1][1] == approx(y)
+    assert artist.verts3d[2][1] == approx(z)
 
 
 def test_plot_1D_line() -> None:
@@ -90,7 +118,7 @@ def test_plot_1D_line_snake_repeat() -> None:
     texts = cast(list[Annotation], axes.texts)
     assert len(texts) == 2
     assert tuple(texts[0].xy) == (1, 0)
-    assert tuple(texts[1].xy) == pytest.approx([2, 0])
+    assert tuple(texts[1].xy) == approx([2, 0])
 
 
 def test_plot_1D_step() -> None:
@@ -141,7 +169,7 @@ def test_plot_2D_line() -> None:
     texts = cast(list[Annotation], axes.texts)
     assert len(texts) == 2
     assert tuple(texts[0].xy) == (0.5, 2)
-    assert tuple(texts[1].xy) == pytest.approx([2.5, 3])
+    assert tuple(texts[1].xy) == approx([2.5, 3])
 
 
 def test_plot_2D_line_rect_region() -> None:
@@ -187,7 +215,7 @@ def test_plot_3D_line() -> None:
         result = runner.invoke(cli.cli, ["plot", spec])
     assert result.exit_code == 0
     axes = plt.gcf().axes[0]
-    lines = axes.lines
+    lines = cast(list[Line3D], axes.lines)
     assert len(lines) == 13
     # First grid
     # First row
@@ -216,13 +244,16 @@ def test_plot_3D_line() -> None:
     # Arrows
     extra_artists = axes.get_children()
 
-    arrow_artists = list(
-        filter(
-            lambda artist: isinstance(artist, _Arrow3D)
-            and artist.get_visible()
-            and artist.get_in_layout(),
-            extra_artists,
-        )
+    arrow_artists = cast(
+        list[Line3D],
+        list(
+            filter(
+                lambda artist: isinstance(artist, Arrow3D)
+                and artist.get_visible()
+                and artist.get_in_layout(),
+                extra_artists,
+            )
+        ),
     )
     assert len(arrow_artists) == 4
     assert_3d_arrow(arrow_artists[0], 0.5, 2, 5)
diff --git a/tests/test_errors.py b/tests/test_errors.py
index 80144234..7dd23dda 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -1,3 +1,5 @@
+from typing import Any
+
 import pytest
 
 from scanspec.regions import Region
@@ -8,7 +10,8 @@ def test_not_implemented() -> None:
     with pytest.raises(NotImplementedError):
         Region().axis_sets()
     with pytest.raises(NotImplementedError):
-        Region().mask({})
+        region: Region[Any] = Region()
+        region.mask({})
     with pytest.raises(NotImplementedError):
         Spec().axes()
     with pytest.raises(NotImplementedError):
diff --git a/tests/test_iteration.py b/tests/test_iteration.py
index f3e2e75c..be31a26d 100644
--- a/tests/test_iteration.py
+++ b/tests/test_iteration.py
@@ -1,8 +1,8 @@
-import pytest
-
 from scanspec.core import Path
 from scanspec.specs import Line
 
+from . import approx
+
 
 def test_line_path() -> None:
     x = "x"
@@ -11,7 +11,7 @@ def test_line_path() -> None:
     path = Path(dims)
     assert len(path) == 5
     dim = path.consume()
-    assert dim.midpoints == {x: pytest.approx([0, 0.25, 0.5, 0.75, 1.0])}
+    assert dim.midpoints == {x: approx([0, 0.25, 0.5, 0.75, 1.0])}
     assert len(path) == 0
 
 
@@ -22,4 +22,4 @@ def test_line_midpoints() -> None:
     assert it.axes == [x]
     assert len(it) == 5
     midpoints = [d[x] for d in it]
-    assert midpoints == pytest.approx([0, 0.25, 0.5, 0.75, 1.0])
+    assert midpoints == approx([0, 0.25, 0.5, 0.75, 1.0])
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index d10dc3bf..f2f73b70 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -171,7 +171,7 @@ def test_vanilla_serialization():
         ),
     )
 
-    adapter = TypeAdapter(Spec)
+    adapter = TypeAdapter(Spec[str])
     serialized = adapter.dump_json(ob)
     deserialized = adapter.validate_json(serialized)
     assert deserialized == ob
diff --git a/tests/test_specs.py b/tests/test_specs.py
index dad0ffb6..6d44be06 100644
--- a/tests/test_specs.py
+++ b/tests/test_specs.py
@@ -21,19 +21,21 @@
     step,
 )
 
+from . import approx
+
 x, y, z = "x", "y", "z"
 
 
-def ints(s):
-    return pytest.approx([int(t) for t in s])
+def ints(s: str) -> Any:
+    return approx([int(t) for t in s])
 
 
 def test_one_point_duration() -> None:
     duration = Static.duration(1.0)
     (dim,) = duration.calculate()
-    assert dim.midpoints == {DURATION: pytest.approx([1.0])}
-    assert dim.lower == {DURATION: pytest.approx([1.0])}
-    assert dim.upper == {DURATION: pytest.approx([1.0])}
+    assert dim.midpoints == {DURATION: approx([1.0])}
+    assert dim.lower == {DURATION: approx([1.0])}
+    assert dim.upper == {DURATION: approx([1.0])}
     assert not isinstance(dim, SnakedFrames)
     assert dim.gap == ints("0")
 
@@ -41,9 +43,9 @@ def test_one_point_duration() -> None:
 def test_one_point_line() -> None:
     inst = Line(x, 0, 1, 1)
     (dim,) = inst.calculate()
-    assert dim.midpoints == {x: pytest.approx([0])}
-    assert dim.lower == {x: pytest.approx([-0.5])}
-    assert dim.upper == {x: pytest.approx([0.5])}
+    assert dim.midpoints == {x: approx([0])}
+    assert dim.lower == {x: approx([-0.5])}
+    assert dim.upper == {x: approx([0.5])}
     assert not isinstance(dim, SnakedFrames)
     assert dim.gap == ints("1")
 
@@ -51,19 +53,17 @@ def test_one_point_line() -> None:
 def test_two_point_line() -> None:
     inst = Line(x, 0, 1, 2)
     (dim,) = inst.calculate()
-    assert dim.midpoints == {x: pytest.approx([0, 1])}
-    assert dim.lower == {x: pytest.approx([-0.5, 0.5])}
-    assert dim.upper == {x: pytest.approx([0.5, 1.5])}
+    assert dim.midpoints == {x: approx([0, 1])}
+    assert dim.lower == {x: approx([-0.5, 0.5])}
+    assert dim.upper == {x: approx([0.5, 1.5])}
     assert dim.gap == ints("10")
 
 
 def test_two_point_stepped_line() -> None:
     inst = step(Line(x, 0, 1, 2), 0.1)
     dimx, dimt = inst.calculate()
-    assert dimx.midpoints == dimx.lower == dimx.upper == {x: pytest.approx([0, 1])}
-    assert (
-        dimt.midpoints == dimt.lower == dimt.upper == {DURATION: pytest.approx([0.1])}
-    )
+    assert dimx.midpoints == dimx.lower == dimx.upper == {x: approx([0, 1])}
+    assert dimt.midpoints == dimt.lower == dimt.upper == {DURATION: approx([0.1])}
     assert inst.frames().gap == ints("11")
 
 
@@ -71,16 +71,16 @@ def test_two_point_fly_line() -> None:
     inst = fly(Line(x, 0, 1, 2), 0.1)
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1]),
-        DURATION: pytest.approx([0.1, 0.1]),
+        x: approx([0, 1]),
+        DURATION: approx([0.1, 0.1]),
     }
     assert dim.lower == {
-        x: pytest.approx([-0.5, 0.5]),
-        DURATION: pytest.approx([0.1, 0.1]),
+        x: approx([-0.5, 0.5]),
+        DURATION: approx([0.1, 0.1]),
     }
     assert dim.upper == {
-        x: pytest.approx([0.5, 1.5]),
-        DURATION: pytest.approx([0.1, 0.1]),
+        x: approx([0.5, 1.5]),
+        DURATION: approx([0.1, 0.1]),
     }
     assert dim.gap == ints("10")
 
@@ -88,9 +88,9 @@ def test_two_point_fly_line() -> None:
 def test_many_point_line() -> None:
     inst = Line(x, 0, 1, 5)
     (dim,) = inst.calculate()
-    assert dim.midpoints == {x: pytest.approx([0, 0.25, 0.5, 0.75, 1])}
-    assert dim.lower == {x: pytest.approx([-0.125, 0.125, 0.375, 0.625, 0.875])}
-    assert dim.upper == {x: pytest.approx([0.125, 0.375, 0.625, 0.875, 1.125])}
+    assert dim.midpoints == {x: approx([0, 0.25, 0.5, 0.75, 1])}
+    assert dim.lower == {x: approx([-0.125, 0.125, 0.375, 0.625, 0.875])}
+    assert dim.upper == {x: approx([0.125, 0.375, 0.625, 0.875, 1.125])}
     assert dim.gap == ints("10000")
 
 
@@ -108,28 +108,16 @@ def test_spiral() -> None:
     inst = Spiral(x, y, 0, 10, 5, 50, 10)
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        y: pytest.approx(
-            [5.4, 6.4, 19.7, 23.8, 15.4, 1.7, -8.6, -10.7, -4.1, 8.3], abs=0.1
-        ),
-        x: pytest.approx(
-            [0.3, -0.9, -0.7, 0.5, 1.5, 1.6, 0.7, -0.6, -1.8, -2.4], abs=0.1
-        ),
+        y: approx([5.4, 6.4, 19.7, 23.8, 15.4, 1.7, -8.6, -10.7, -4.1, 8.3], abs=0.1),
+        x: approx([0.3, -0.9, -0.7, 0.5, 1.5, 1.6, 0.7, -0.6, -1.8, -2.4], abs=0.1),
     }
     assert dim.lower == {
-        y: pytest.approx(
-            [10.0, 2.7, 13.3, 23.5, 20.9, 8.7, -4.2, -10.8, -8.4, 1.6], abs=0.1
-        ),
-        x: pytest.approx(
-            [0.0, -0.3, -1.0, -0.1, 1.1, 1.7, 1.3, 0.0, -1.2, -2.2], abs=0.1
-        ),
+        y: approx([10.0, 2.7, 13.3, 23.5, 20.9, 8.7, -4.2, -10.8, -8.4, 1.6], abs=0.1),
+        x: approx([0.0, -0.3, -1.0, -0.1, 1.1, 1.7, 1.3, 0.0, -1.2, -2.2], abs=0.1),
     }
     assert dim.upper == {
-        y: pytest.approx(
-            [2.7, 13.3, 23.5, 20.9, 8.7, -4.2, -10.8, -8.4, 1.6, 15.3], abs=0.1
-        ),
-        x: pytest.approx(
-            [-0.3, -1.0, -0.1, 1.1, 1.7, 1.3, 0.0, -1.2, -2.2, -2.4], abs=0.1
-        ),
+        y: approx([2.7, 13.3, 23.5, 20.9, 8.7, -4.2, -10.8, -8.4, 1.6, 15.3], abs=0.1),
+        x: approx([-0.3, -1.0, -0.1, 1.1, 1.7, 1.3, 0.0, -1.2, -2.2, -2.4], abs=0.1),
     }
     assert not isinstance(dim, SnakedFrames)
     assert dim.gap == ints("1000000000")
@@ -145,8 +133,8 @@ def test_zipped_lines() -> None:
     assert inst.axes() == [x, y]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([0, 0.25, 0.5, 0.75, 1]),
-        y: pytest.approx([1, 1.25, 1.5, 1.75, 2]),
+        x: approx([0, 0.25, 0.5, 0.75, 1]),
+        y: approx([1, 1.25, 1.5, 1.75, 2]),
     }
     assert dim.gap == ints("10000")
 
@@ -158,16 +146,16 @@ def test_product_lines() -> None:
     assert len(dims) == 2
     dim = Path(dims).consume()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1, 0, 1, 0, 1]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0, 1, 0, 1, 0, 1]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.lower == {
-        x: pytest.approx([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.upper == {
-        x: pytest.approx([0.5, 1.5, 0.5, 1.5, 0.5, 1.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0.5, 1.5, 0.5, 1.5, 0.5, 1.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.gap == ints("101010")
 
@@ -177,11 +165,11 @@ def test_zipped_product_lines() -> None:
     assert inst.axes() == [y, x, z]
     dimy, dimxz = inst.calculate()
     assert dimxz.midpoints == {
-        x: pytest.approx([0, 0.25, 0.5, 0.75, 1]),
-        z: pytest.approx([2, 2.25, 2.5, 2.75, 3]),
+        x: approx([0, 0.25, 0.5, 0.75, 1]),
+        z: approx([2, 2.25, 2.5, 2.75, 3]),
     }
     assert dimy.midpoints == {
-        y: pytest.approx([1, 1.5, 2]),
+        y: approx([1, 1.5, 2]),
     }
     assert inst.frames().gap == ints("100001000010000")
 
@@ -191,16 +179,16 @@ def test_squashed_product() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1, 0, 1, 0, 1]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0, 1, 0, 1, 0, 1]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.lower == {
-        x: pytest.approx([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.upper == {
-        x: pytest.approx([0.5, 1.5, 0.5, 1.5, 0.5, 1.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0.5, 1.5, 0.5, 1.5, 0.5, 1.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.gap == ints("101010")
 
@@ -213,11 +201,11 @@ def test_squashed_multiplied_snake_scan() -> None:
     dimz, dimxyt = inst.calculate()
     for d in dimxyt.midpoints, dimxyt.lower, dimxyt.upper:
         assert d == {
-            x: pytest.approx([4, 4, 6, 6, 6, 6, 4, 4]),
-            y: pytest.approx([1, 1, 1, 1, 2, 2, 2, 2]),
-            DURATION: pytest.approx([9, 9, 9, 9, 9, 9, 9, 9]),
+            x: approx([4, 4, 6, 6, 6, 6, 4, 4]),
+            y: approx([1, 1, 1, 1, 2, 2, 2, 2]),
+            DURATION: approx([9, 9, 9, 9, 9, 9, 9, 9]),
         }
-    assert dimz.midpoints == dimz.lower == dimz.upper == {z: pytest.approx([1, 2])}
+    assert dimz.midpoints == dimz.lower == dimz.upper == {z: approx([1, 2])}
     assert inst.frames().gap == ints("1010101010101010")
 
 
@@ -228,16 +216,16 @@ def test_product_snaking_lines() -> None:
     assert len(dims) == 2
     dim = Path(dims).consume()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1, 1, 0, 0, 1]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0, 1, 1, 0, 0, 1]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.lower == {
-        x: pytest.approx([-0.5, 0.5, 1.5, 0.5, -0.5, 0.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([-0.5, 0.5, 1.5, 0.5, -0.5, 0.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.upper == {
-        x: pytest.approx([0.5, 1.5, 0.5, -0.5, 0.5, 1.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0.5, 1.5, 0.5, -0.5, 0.5, 1.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.gap == ints("101010")
 
@@ -246,9 +234,9 @@ def test_concat_lines() -> None:
     inst = Concat(Line(x, 0, 1, 2), Line(x, 1, 2, 3))
     assert inst.axes() == [x]
     (dim,) = inst.calculate()
-    assert dim.midpoints == {x: pytest.approx([0, 1, 1, 1.5, 2])}
-    assert dim.lower == {x: pytest.approx([-0.5, 0.5, 0.75, 1.25, 1.75])}
-    assert dim.upper == {x: pytest.approx([0.5, 1.5, 1.25, 1.75, 2.25])}
+    assert dim.midpoints == {x: approx([0, 1, 1, 1.5, 2])}
+    assert dim.lower == {x: approx([-0.5, 0.5, 0.75, 1.25, 1.75])}
+    assert dim.upper == {x: approx([0.5, 1.5, 1.25, 1.75, 2.25])}
     assert dim.gap == ints("10100")
 
 
@@ -257,16 +245,16 @@ def test_rect_region() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1, 0, 1, 0, 1]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0, 1, 0, 1, 0, 1]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.lower == {
-        x: pytest.approx([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.upper == {
-        x: pytest.approx([0.5, 1.5, 0.5, 1.5, 0.5, 1.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0.5, 1.5, 0.5, 1.5, 0.5, 1.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert dim.gap == ints("101010")
 
@@ -277,20 +265,20 @@ def test_rect_region_3D() -> None:
     )
     assert inst.axes() == [z, y, x]
     zdim, xydim = inst.calculate()
-    assert zdim.midpoints == {z: pytest.approx([3.2, 3.2])}
+    assert zdim.midpoints == {z: approx([3.2, 3.2])}
     assert zdim.midpoints is zdim.upper
     assert zdim.midpoints is zdim.lower
     assert xydim.midpoints == {
-        x: pytest.approx([0, 1, 0, 1, 0, 1]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0, 1, 0, 1, 0, 1]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert xydim.lower == {
-        x: pytest.approx([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert xydim.upper == {
-        x: pytest.approx([0.5, 1.5, 0.5, 1.5, 0.5, 1.5]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2]),
+        x: approx([0.5, 1.5, 0.5, 1.5, 0.5, 1.5]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2]),
     }
     assert inst.frames().gap == ints("101010101010")
 
@@ -302,8 +290,8 @@ def test_rect_region_union() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1, 0, 1, 2, 0, 1, 2, 1, 2]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 1.5, 2, 2, 2, 2.5, 2.5]),
+        x: approx([0, 1, 0, 1, 2, 0, 1, 2, 1, 2]),
+        y: approx([1, 1, 1.5, 1.5, 1.5, 2, 2, 2, 2.5, 2.5]),
     }
     assert dim.gap == ints("1010010010")
 
@@ -317,8 +305,8 @@ def test_rect_region_intersection() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([1, 1]),
-        y: pytest.approx([1.5, 2]),
+        x: approx([1, 1]),
+        y: approx([1.5, 2]),
     }
     assert dim.gap == ints("11")
 
@@ -332,9 +320,9 @@ def test_rect_region_difference() -> None:
     assert inst.axes() == [y, x, DURATION]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1, 0, 0]),
-        y: pytest.approx([1, 1, 1.5, 2]),
-        DURATION: pytest.approx([0.1, 0.1, 0.1, 0.1]),
+        x: approx([0, 1, 0, 0]),
+        y: approx([1, 1, 1.5, 2]),
+        DURATION: approx([0.1, 0.1, 0.1, 0.1]),
     }
     assert dim.gap == ints("1011")
 
@@ -346,8 +334,8 @@ def test_rect_region_symmetricdifference() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1, 0, 2, 0, 2, 1, 2]),
-        y: pytest.approx([1, 1, 1.5, 1.5, 2, 2, 2.5, 2.5]),
+        x: approx([0, 1, 0, 2, 0, 2, 1, 2]),
+        y: approx([1, 1, 1.5, 1.5, 2, 2, 2.5, 2.5]),
     }
     assert dim.gap == ints("10111110")
 
@@ -357,16 +345,16 @@ def test_circle_region() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([1, 0, 1, 2, 1]),
-        y: pytest.approx([1, 2, 2, 2, 3]),
+        x: approx([1, 0, 1, 2, 1]),
+        y: approx([1, 2, 2, 2, 3]),
     }
     assert dim.lower == {
-        x: pytest.approx([0.5, -0.5, 0.5, 1.5, 0.5]),
-        y: pytest.approx([1, 2, 2, 2, 3]),
+        x: approx([0.5, -0.5, 0.5, 1.5, 0.5]),
+        y: approx([1, 2, 2, 2, 3]),
     }
     assert dim.upper == {
-        x: pytest.approx([1.5, 0.5, 1.5, 2.5, 1.5]),
-        y: pytest.approx([1, 2, 2, 2, 3]),
+        x: approx([1.5, 0.5, 1.5, 2.5, 1.5]),
+        y: approx([1, 2, 2, 2, 3]),
     }
     assert dim.gap == ints("11001")
 
@@ -380,16 +368,16 @@ def test_circle_snaked_region() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([1, 2, 1, 0, 1]),
-        y: pytest.approx([1, 2, 2, 2, 3]),
+        x: approx([1, 2, 1, 0, 1]),
+        y: approx([1, 2, 2, 2, 3]),
     }
     assert dim.lower == {
-        x: pytest.approx([0.5, 2.5, 1.5, 0.5, 0.5]),
-        y: pytest.approx([1, 2, 2, 2, 3]),
+        x: approx([0.5, 2.5, 1.5, 0.5, 0.5]),
+        y: approx([1, 2, 2, 2, 3]),
     }
     assert dim.upper == {
-        x: pytest.approx([1.5, 1.5, 0.5, -0.5, 1.5]),
-        y: pytest.approx([1, 2, 2, 2, 3]),
+        x: approx([1.5, 1.5, 0.5, -0.5, 1.5]),
+        y: approx([1, 2, 2, 2, 3]),
     }
     assert dim.gap == ints("11001")
 
@@ -399,16 +387,16 @@ def test_ellipse_region() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([0, 1, 0, 1, 2, 1, 2]),
-        y: pytest.approx([1, 1, 2, 2, 2, 3, 3]),
+        x: approx([0, 1, 0, 1, 2, 1, 2]),
+        y: approx([1, 1, 2, 2, 2, 3, 3]),
     }
     assert dim.lower == {
-        x: pytest.approx([-0.5, 0.5, -0.5, 0.5, 1.5, 0.5, 1.5]),
-        y: pytest.approx([1, 1, 2, 2, 2, 3, 3]),
+        x: approx([-0.5, 0.5, -0.5, 0.5, 1.5, 0.5, 1.5]),
+        y: approx([1, 1, 2, 2, 2, 3, 3]),
     }
     assert dim.upper == {
-        x: pytest.approx([0.5, 1.5, 0.5, 1.5, 2.5, 1.5, 2.5]),
-        y: pytest.approx([1, 1, 2, 2, 2, 3, 3]),
+        x: approx([0.5, 1.5, 0.5, 1.5, 2.5, 1.5, 2.5]),
+        y: approx([1, 1, 2, 2, 2, 3, 3]),
     }
     assert dim.gap == ints("1010010")
 
@@ -420,16 +408,16 @@ def test_polygon_region() -> None:
     assert inst.axes() == [y, x]
     (dim,) = inst.calculate()
     assert dim.midpoints == {
-        x: pytest.approx([1, 2, 1, 2, 3, 1, 2, 3]),
-        y: pytest.approx([1, 1, 2, 2, 2, 3, 3, 3]),
+        x: approx([1, 2, 1, 2, 3, 1, 2, 3]),
+        y: approx([1, 1, 2, 2, 2, 3, 3, 3]),
     }
     assert dim.lower == {
-        x: pytest.approx([0.5, 1.5, 0.5, 1.5, 2.5, 0.5, 1.5, 2.5]),
-        y: pytest.approx([1, 1, 2, 2, 2, 3, 3, 3]),
+        x: approx([0.5, 1.5, 0.5, 1.5, 2.5, 0.5, 1.5, 2.5]),
+        y: approx([1, 1, 2, 2, 2, 3, 3, 3]),
     }
     assert dim.upper == {
-        x: pytest.approx([1.5, 2.5, 1.5, 2.5, 3.5, 1.5, 2.5, 3.5]),
-        y: pytest.approx([1, 1, 2, 2, 2, 3, 3, 3]),
+        x: approx([1.5, 2.5, 1.5, 2.5, 3.5, 1.5, 2.5, 3.5]),
+        y: approx([1, 1, 2, 2, 2, 3, 3, 3]),
     }
     assert dim.gap == ints("10100100")
 
@@ -443,12 +431,12 @@ def test_xyz_stack() -> None:
     assert dim.lower == {
         z: ints("000000000000111111111111"),
         y: ints("000011112222222211110000"),
-        x: pytest.approx([-0.5, 0.5, 1.5, 2.5, 3.5, 2.5, 1.5, 0.5] * 3),
+        x: approx([-0.5, 0.5, 1.5, 2.5, 3.5, 2.5, 1.5, 0.5] * 3),
     }
     assert dim.upper == {
         z: ints("000000000000111111111111"),
         y: ints("000011112222222211110000"),
-        x: pytest.approx([0.5, 1.5, 2.5, 3.5, 2.5, 1.5, 0.5, -0.5] * 3),
+        x: approx([0.5, 1.5, 2.5, 3.5, 2.5, 1.5, 0.5, -0.5] * 3),
     }
     assert dim.midpoints == {
         z: ints("000000000000111111111111"),
@@ -470,34 +458,34 @@ def test_xyz_stack() -> None:
 def test_beam_selector() -> None:
     # Beam selector scan moves bounded between midpoints and lower and upper bounds at
     # maximum speed. Turnaround sections are where it sends the triggers
-    spec = 10 * ~Line.bounded(x, 11, 19, 1)
+    spec: Spec[str] = 10 * ~Line.bounded(x, 11, 19, 1)
     dim = spec.frames()
     assert len(dim) == 10
-    assert dim.lower == {x: pytest.approx([11, 19, 11, 19, 11, 19, 11, 19, 11, 19])}
-    assert dim.upper == {x: pytest.approx([19, 11, 19, 11, 19, 11, 19, 11, 19, 11])}
-    assert dim.midpoints == {x: pytest.approx([15, 15, 15, 15, 15, 15, 15, 15, 15, 15])}
+    assert dim.lower == {x: approx([11, 19, 11, 19, 11, 19, 11, 19, 11, 19])}
+    assert dim.upper == {x: approx([19, 11, 19, 11, 19, 11, 19, 11, 19, 11])}
+    assert dim.midpoints == {x: approx([15, 15, 15, 15, 15, 15, 15, 15, 15, 15])}
     assert dim.gap == ints("1111111111")
 
 
 def test_gap_repeat() -> None:
     # Check that no gap propogates to dim.gap for snaked axis
-    spec: Spec[Any] = Repeat(10, gap=False) * ~Line.bounded(x, 11, 19, 1)
+    spec: Spec[str] = Repeat(10, gap=False) * ~Line.bounded(x, 11, 19, 1)
     dim = spec.frames()
     assert len(dim) == 10
-    assert dim.lower == {x: pytest.approx([11, 19, 11, 19, 11, 19, 11, 19, 11, 19])}
-    assert dim.upper == {x: pytest.approx([19, 11, 19, 11, 19, 11, 19, 11, 19, 11])}
-    assert dim.midpoints == {x: pytest.approx([15, 15, 15, 15, 15, 15, 15, 15, 15, 15])}
+    assert dim.lower == {x: approx([11, 19, 11, 19, 11, 19, 11, 19, 11, 19])}
+    assert dim.upper == {x: approx([19, 11, 19, 11, 19, 11, 19, 11, 19, 11])}
+    assert dim.midpoints == {x: approx([15, 15, 15, 15, 15, 15, 15, 15, 15, 15])}
     assert dim.gap == ints("0000000000")
 
 
 def test_gap_repeat_non_snake() -> None:
     # Check that no gap doesn't propogate to dim.gap for non-snaked axis
-    spec: Spec[Any] = Repeat(3, gap=False) * Line.bounded(x, 11, 19, 1)
+    spec: Spec[str] = Repeat(3, gap=False) * Line.bounded(x, 11, 19, 1)
     dim = spec.frames()
     assert len(dim) == 3
-    assert dim.lower == {x: pytest.approx([11, 11, 11])}
-    assert dim.upper == {x: pytest.approx([19, 19, 19])}
-    assert dim.midpoints == {x: pytest.approx([15, 15, 15])}
+    assert dim.lower == {x: approx([11, 11, 11])}
+    assert dim.upper == {x: approx([19, 19, 19])}
+    assert dim.midpoints == {x: approx([15, 15, 15])}
     assert dim.gap == ints("111")
 
 
@@ -558,7 +546,7 @@ def test_multiple_statics_with_grid():
         ),
     ],
 )
-def test_shape(spec: Spec, expected_shape: tuple[int, ...]):
+def test_shape(spec: Spec[Any], expected_shape: tuple[int, ...]):
     assert expected_shape == spec.shape()
 
 
@@ -568,7 +556,7 @@ def test_single_frame_single_point():
 
 
 def test_consistent_points():
-    spec = Static.duration(0.1).concat(Static.duration(0.1))
+    spec: Spec[str] = Static.duration(0.1).concat(Static.duration(0.1))
     assert get_constant_duration(spec.calculate()) == 0.1