Repository: bluesky/scanspec (language: python)

Issue:
Move to pydantic 2



Discussion / hints:
From discussion with @coretl:

The main remaining complication is that `ScanSpec`s and `Region`s are pydantic dataclasses instead of base models, we did that because we wanted positional arguments. Unfortunately it looks like pydantic v2 dataclasses have more issues than v1 dataclasses.

We're thinking about converting the scanspecs and regions to base models and putting in some sort of shimming to allow positional args.

Options are:
1. Create helper functions for creating scanspecs, so `Line("x", 0, 10, 5)` becomes `line("x", 0, 10, 5)`. This adds boilerplate but is the most flexibile, since we could add nice utility functions to create complex, commonly compositions, e.g. `Line("x", 0, 10, 5) * Line(y, 0, 10, 5)` -> `grid_scan("x", 0, 10, 5, "y", 0, 10, 5)`.
2. Create a common base class with an overriding init method that takes positional args. This is the simplest approach and adds a minimal amount of magic
3. Some combination of the above 
WIP on this branch: https://github.com/bluesky/scanspec/tree/122-move-to-pydantic-v2-convert-specs-to-basemodel

By @evalott100 
In order of preference:
1. Make the `pydantic.dataclass` implementation that was already there work with v2
2. Provide some magic in the `Spec` baseclass that does this
3. Ditch positional args, and provide helpers like `grid_scan` that re-introduce them

3 was mostly rejected because of the sheer volume of tests and docs that use positional args

2 could be accomplished messily at runtime only (type checker will still moan that it doesn't support positional args):
```python
from pydantic import BaseModel, Field

class Spec(BaseModel):
    @classmethod
    def __pydantic_init_subclass__(cls, **kwargs):
        original_init = cls.__init__
        def patched_init(self, *args, **kwargs):
            for k, v in zip(cls.model_fields, args):
                kwargs[k] = v
            original_init(self, **kwargs)
        cls.__init__ = patched_init
    

class Line(Spec):
    start: float = Field(description="Midpoint of the first point of the line")
    stop: float = Field(description="Midpoint of the last point of the line")
    num: int = Field(min=1, description="Number of frames to produce")


obj = Line(3, 4, 5)
```

1 would be the neatest solution if someone could make it work...
One of the main issues is that `pydantic.dataclasses.dataclass` creates a `cls.__pydantic_validator__` according to a dataclass schema in `pydantic._internal._dataclasses.py:181`, which replaces whatever happen(s/ed) in `scanspec.core._discriminated_union_of_subclasses`, and also replaces the `__init__` with validation using this validator. This then passes up a `TypeError` somewhere in the Rust validation part, because (at least as far as is reported) its own internal `_pydantic_core.ArgsKwargs` can't be converted to a `PyTuple` - if one inspects a simple dataclass this clearly isn't true, but it's not very easy to inspect what validator is doing in `pydantic_core.validators.dataclass.rs` (probably in line 632). 

I think fixing this by post-dataclass-initialisation hacking over the validator is possible, but would be really messy and fragile, and I think option 3 is actually what would end up with the most maintainable, correctly typed result in the end
I agree that messing with pydantic under the bonnet is increasingly fragile. Happy with 2. or 3.

@dperl-dls do you have a particular objection to 2.?
I have no specific objection to it as such, I just think it might not be that easy.

Anyway, I might have to give up on this, I've tried a few different ways for a few days and I can't really get it to work. Starting from @evalott100 's WIP I have a branch here:
https://github.com/bluesky/scanspec/tree/122_move_to_pydanticv2_option_3

The main problem I'm running into is that I'm finding it impossible to rebuild the model on finalising without pydantic complaining that I've provided multiple options for each tag

BTW when researching I found this discussion: https://github.com/pydantic/pydantic/discussions/8789 which would have been useful, so linking here for reference
Just to say I spent a couple of hours on this today and made a bit of progress. The dataclass approach seems to work with some hacky workarounds (I can prod the `__pydantic_fields__` attribute of the dataclass and then deserialize works, but I need to call [`serialize_as_any`](https://docs.pydantic.dev/latest/concepts/serialization/#subclass-instances-for-fields-of-basemodel-dataclasses-typeddict) to get serialize to work). I experimented with `rebuild_dataclass` but no joy yet. @gilesknap mentioned something about deferred union definitions possibly being a thing, so will have more of a look tomorrow.
Ok, I now have a working dataclass implementation here:
https://github.com/coretl/pydantic-tagged-dc

@ZohebShaikh will take this ticket on to apply the strategy in this repo to scanspec

There is one remaining bug, if all `Region`s are created before `Mask` (which refers to `Region`) then it won't get the right `Region` tagged union. This should make some tests fail. I will try and fix that in this standalone repo and then that should fix the remaining tests
I've fixed that issue, and I've also simplified and commented while I'm at it:
https://github.com/coretl/pydantic-tagged-dc/pull/3

Fix patch:
diff --git a/pyproject.toml b/pyproject.toml
index 2d064382..38c13f8f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,11 @@ classifiers = [
     "Programming Language :: Python :: 3.11",
 ]
 description = "Specify step and flyscan paths in a serializable, efficient and Pythonic way"
-dependencies = ["numpy>=2", "click>=8.1", "pydantic<2.0", "httpx==0.26.0"]
+dependencies = [
+    "numpy>=2",
+    "click>=8.1",
+    "pydantic>=2.0",
+]
 dynamic = ["version"]
 license.file = "LICENSE"
 readme = "README.md"
@@ -21,7 +25,7 @@ requires-python = ">=3.10"
 # Plotting
 plotting = ["scipy", "matplotlib"]
 # REST service support
-service = ["fastapi==0.99", "uvicorn"]
+service = ["fastapi>=0.100.0", "uvicorn"]
 # For development tests/docs
 dev = [
     # This syntax is supported since pip 21.2
diff --git a/schema.json b/schema.json
index 156e3d8b..e35c026a 100644
--- a/schema.json
+++ b/schema.json
@@ -1,1 +1,3805 @@
-{"openapi": "3.1.0", "info": {"title": "FastAPI", "version": "0.1.0"}, "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": {"title": "Spec", "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": {"allOf": [{"$ref": "#/components/schemas/PointsRequest"}], "title": "Request", "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": {"allOf": [{"$ref": "#/components/schemas/PointsRequest"}], "title": "Request", "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    request: Scanspec and formatting info.\n\nReturns:\n    GapResponse: Bounds of the scan", "operationId": "gap_gap_post", "requestBody": {"content": {"application/json": {"schema": {"title": "Spec", "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": {"title": "Spec", "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": {"allOf": [{"$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."}, "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"}, "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": {"allOf": [{"$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": {"title": "Spec", "description": "The spec from which to generate points"}, "max_frames": {"type": "integer", "title": "Max Frames", "description": "The maximum number of points to return, if None will return as many as calculated", "default": 100000}, "format": {"allOf": [{"$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."}, "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."}, "ValidResponse": {"properties": {"input_spec": {"title": "Input Spec", "description": "The input scanspec"}, "valid_spec": {"title": "Valid Spec", "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"}}}}
+{
+    "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": {
+                                "anyOf": [
+                                    {
+                                        "$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"
+                                    }
+                                ],
+                                "title": "Spec",
+                                "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": {
+                                "allOf": [
+                                    {
+                                        "$ref": "#/components/schemas/PointsRequest"
+                                    }
+                                ],
+                                "title": "Request",
+                                "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": {
+                                "allOf": [
+                                    {
+                                        "$ref": "#/components/schemas/PointsRequest"
+                                    }
+                                ],
+                                "title": "Request",
+                                "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    request: Scanspec and formatting info.\n\nReturns:\n    GapResponse: Bounds of the scan",
+                "operationId": "gap_gap_post",
+                "requestBody": {
+                    "content": {
+                        "application/json": {
+                            "schema": {
+                                "anyOf": [
+                                    {
+                                        "$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"
+                                    }
+                                ],
+                                "title": "Spec",
+                                "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": {
+                                "anyOf": [
+                                    {
+                                        "$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"
+                                    }
+                                ],
+                                "title": "Spec",
+                                "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": {
+                        "allOf": [
+                            {
+                                "$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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Spec to Concat, midpoints will appear earlier",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Spec to Concat, midpoints will appear later",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Spec to Concat, midpoints will appear earlier",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Spec to Concat, midpoints will appear later",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Spec",
+                        "description": "The Spec containing the source midpoints",
+                        "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"
+                            }
+                        }
+                    },
+                    "region": {
+                        "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"
+                            }
+                        ],
+                        "title": "Region",
+                        "description": "The Region that midpoints will be inside",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Spec",
+                        "description": "The Spec containing the source midpoints",
+                        "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"
+                            }
+                        }
+                    },
+                    "region": {
+                        "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"
+                            }
+                        ],
+                        "title": "Region",
+                        "description": "The Region that midpoints will be inside",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "allOf": [
+                            {
+                                "$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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Spec",
+                        "description": "The spec from which to generate points",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "allOf": [
+                            {
+                                "$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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Outer",
+                        "description": "Will be executed once",
+                        "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"
+                            }
+                        }
+                    },
+                    "inner": {
+                        "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"
+                            }
+                        ],
+                        "title": "Inner",
+                        "description": "Will be executed len(outer) times",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Outer",
+                        "description": "Will be executed once",
+                        "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"
+                            }
+                        }
+                    },
+                    "inner": {
+                        "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"
+                            }
+                        ],
+                        "title": "Inner",
+                        "description": "Will be executed len(outer) times",
+                        "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"
+                            }
+                        }
+                    },
+                    "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)"
+            },
+            "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Spec",
+                        "description": "The Spec to run in reverse every other iteration",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Spec",
+                        "description": "The Spec to run in reverse every other iteration",
+                        "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"
+                            }
+                        }
+                    },
+                    "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)"
+            },
+            "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Spec",
+                        "description": "The Spec to squash the dimensions of",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Spec",
+                        "description": "The Spec to squash the dimensions of",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Region to combine",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Input Spec",
+                        "description": "The input scanspec",
+                        "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"
+                            }
+                        }
+                    },
+                    "valid_spec": {
+                        "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"
+                            }
+                        ],
+                        "title": "Valid Spec",
+                        "description": "The validated version of the spec",
+                        "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"
+                            }
+                        }
+                    }
+                },
+                "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Spec to Zip, will appear earlier in axes",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Spec to Zip, will appear later in axes",
+                        "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"
+                            }
+                        }
+                    },
+                    "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": {
+                        "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"
+                            }
+                        ],
+                        "title": "Left",
+                        "description": "The left-hand Spec to Zip, will appear earlier in axes",
+                        "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"
+                            }
+                        }
+                    },
+                    "right": {
+                        "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"
+                            }
+                        ],
+                        "title": "Right",
+                        "description": "The right-hand Spec to Zip, will appear later in axes",
+                        "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"
+                            }
+                        }
+                    },
+                    "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))"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/scanspec/core.py b/src/scanspec/core.py
index ca162565..39f2a57a 100644
--- a/src/scanspec/core.py
+++ b/src/scanspec/core.py
@@ -1,12 +1,28 @@
 from __future__ import annotations
 
+import dataclasses
 from collections.abc import Callable, Iterable, Iterator, Sequence
-from dataclasses import field
-from typing import Any, Generic, Literal, TypeVar, Union
+from functools import partial
+from inspect import isclass
+from typing import (
+    Any,
+    Generic,
+    Literal,
+    TypeVar,
+    Union,
+    get_origin,
+    get_type_hints,
+)
 
 import numpy as np
-from pydantic import BaseConfig, Extra, Field, ValidationError, create_model
-from pydantic.error_wrappers import ErrorWrapper
+from pydantic import (
+    ConfigDict,
+    Field,
+    GetCoreSchemaHandler,
+    TypeAdapter,
+)
+from pydantic.dataclasses import rebuild_dataclass
+from pydantic.fields import FieldInfo
 
 __all__ = [
     "if_instance_do",
@@ -23,24 +39,18 @@
 ]
 
 
-class StrictConfig(BaseConfig):
-    """Pydantic configuration for scanspecs and regions."""
-
-    extra: Extra = Extra.forbid
+StrictConfig: ConfigDict = {"extra": "forbid"}
 
 
 def discriminated_union_of_subclasses(
-    super_cls: type | None = None,
-    *,
+    cls,
     discriminator: str = "type",
-    config: type[BaseConfig] | None = None,
-) -> type | Callable[[type], type]:
+):
     """Add all subclasses of super_cls to a discriminated union.
 
     For all subclasses of super_cls, add a discriminator field to identify
     the type. Raw JSON should look like {"type": <type name>, params for
     <type name>...}.
-    Add validation methods to super_cls so it can be parsed by pydantic.parse_obj_as.
 
     Example::
 
@@ -104,72 +114,124 @@ def calculate(self) -> int:
         Type | Callable[[Type], Type]: A decorator that adds the necessary
             functionality to a class.
     """
+    tagged_union = _TaggedUnion(cls, discriminator)
+    _tagged_unions[cls] = tagged_union
+    cls.__init_subclass__ = classmethod(partial(__init_subclass__, discriminator))
+    cls.__get_pydantic_core_schema__ = classmethod(
+        partial(__get_pydantic_core_schema__, tagged_union=tagged_union)
+    )
+    return cls
 
-    def wrap(cls):
-        return _discriminated_union_of_subclasses(cls, discriminator, config)
 
-    # Work out if the call was @discriminated_union_of_subclasses or
-    # @discriminated_union_of_subclasses(...)
-    if super_cls is None:
-        return wrap
-    else:
-        return wrap(super_cls)
-
-
-def _discriminated_union_of_subclasses(
-    super_cls: type,
-    discriminator: str,
-    config: type[BaseConfig] | None = None,
-) -> type | Callable[[type], type]:
-    super_cls._ref_classes = set()  # type: ignore
-    super_cls._model = None  # type: ignore
-
-    def __init_subclass__(cls) -> None:
-        # Keep track of inherting classes in super class
-        cls._ref_classes.add(cls)
-
-        # Add a discriminator field to the class so it can
-        # be identified when deserailizing.
-        cls.__annotations__ = {
-            **cls.__annotations__,
-            discriminator: Literal[cls.__name__],
-        }
-        setattr(cls, discriminator, field(default=cls.__name__, repr=False))
-
-    def __get_validators__(cls) -> Any:
-        yield cls.__validate__
-
-    def __validate__(cls, v: Any) -> Any:
-        # Lazily initialize model on first use because this
-        # needs to be done once, after all subclasses have been
-        # declared
-        if cls._model is None:
-            root = Union[tuple(cls._ref_classes)]  # type: ignore  # noqa
-            cls._model = create_model(
-                super_cls.__name__,
-                __root__=(root, Field(..., discriminator=discriminator)),
-                __config__=config,
-            )
-
-        try:
-            return cls._model(__root__=v).__root__
-        except ValidationError as e:
-            for (
-                error
-            ) in e.raw_errors:  # need in to remove redundant __root__ from error path
-                if (
-                    isinstance(error, ErrorWrapper)
-                    and error.loc_tuple()[0] == "__root__"
-                ):
-                    error._loc = error.loc_tuple()[1:]
-
-            raise e
-
-    # Inject magic methods into super_cls
-    for method in __init_subclass__, __get_validators__, __validate__:
-        setattr(super_cls, method.__name__, classmethod(method))  # type: ignore
-
-    return super_cls
+T = TypeVar("T", type, Callable)
+
+
+def deserialize_as(cls, obj):
+    return _tagged_unions[cls].type_adapter.validate_python(obj)
+
+
+def uses_tagged_union(cls_or_func: T) -> T:
+    """
+    Decorator that processes the type hints of a class or function to detect and
+    register any tagged unions. If a tagged union is detected in the type hints,
+    it registers the class or function as a referrer to that tagged union.
+    Args:
+        cls_or_func (T): The class or function to be processed for tagged unions.
+    Returns:
+        T: The original class or function, unmodified.
+    """
+    for k, v in get_type_hints(cls_or_func).items():
+        tagged_union = _tagged_unions.get(get_origin(v) or v, None)
+        if tagged_union:
+            tagged_union.add_referrer(cls_or_func, k)
+    return cls_or_func
+
+
+class _TaggedUnion:
+    def __init__(self, base_class: type, discriminator: str):
+        self._base_class = base_class
+        # The members of the tagged union, i.e. subclasses of the baseclasses
+        self._members: list[type] = []
+        # Classes and their field names that refer to this tagged union
+        self._referrers: dict[type | Callable, set[str]] = {}
+        self.type_adapter: TypeAdapter = TypeAdapter(None)
+        self._discriminator = discriminator
+
+    def _make_union(self):
+        if len(self._members) > 0:
+            return Union[tuple(self._members)]  # type: ignore  # noqa
+
+    def _set_discriminator(self, cls: type | Callable, field_name: str, field: Any):
+        # Set the field to use the `type` discriminator on deserialize
+        # https://docs.pydantic.dev/2.8/concepts/unions/#discriminated-unions-with-str-discriminators
+        if isclass(cls):
+            assert isinstance(
+                field, FieldInfo
+            ), f"Expected {cls.__name__}.{field_name} to be a Pydantic field, not {field!r}"  # noqa: E501
+            field.discriminator = self._discriminator
+
+    def add_member(self, cls: type):
+        if cls in self._members:
+            # A side effect of hooking to __get_pydantic_core_schema__ is that it is
+            # called muliple times for the same member, do no process if it wouldn't
+            # change the member list
+            return
+
+        self._members.append(cls)
+        union = self._make_union()
+        if union:
+            # There are more than 1 subclasses in the union, so set all the referrers
+            # to use this union
+            for referrer, fields in self._referrers.items():
+                if isclass(referrer):
+                    for field in dataclasses.fields(referrer):
+                        if field.name in fields:
+                            field.type = union
+                            self._set_discriminator(referrer, field.name, field.default)
+                    rebuild_dataclass(referrer, force=True)
+            # Make a type adapter for use in deserialization
+            self.type_adapter = TypeAdapter(union)
+
+    def add_referrer(self, cls: type | Callable, attr_name: str):
+        self._referrers.setdefault(cls, set()).add(attr_name)
+        union = self._make_union()
+        if union:
+            # There are more than 1 subclasses in the union, so set the referrer
+            # (which is currently being constructed) to use it
+            # note that we use annotations as the class has not been turned into
+            # a dataclass yet
+            cls.__annotations__[attr_name] = union
+            self._set_discriminator(cls, attr_name, getattr(cls, attr_name, None))
+
+
+_tagged_unions: dict[type, _TaggedUnion] = {}
+
+
+def __init_subclass__(discriminator: str, cls: type):
+    # Add a discriminator field to the class so it can
+    # be identified when deserailizing, and make sure it is last in the list
+    cls.__annotations__ = {
+        **cls.__annotations__,
+        discriminator: Literal[cls.__name__],  # type: ignore
+    }
+    cls.type = Field(cls.__name__, repr=False)  # type: ignore
+    # Replace any bare annotation with a discriminated union of subclasses
+    # and register this class as one that refers to that union so it can be updated
+    for k, v in get_type_hints(cls).items():
+        # This works for Expression[T] or Expression
+        tagged_union = _tagged_unions.get(get_origin(v) or v, None)
+        if tagged_union:
+            tagged_union.add_referrer(cls, k)
+
+
+def __get_pydantic_core_schema__(
+    cls, source_type: Any, handler: GetCoreSchemaHandler, tagged_union: _TaggedUnion
+):
+    # Rebuild any dataclass (including this one) that references this union
+    # Note that this has to be done after the creation of the dataclass so that
+    # previously created classes can refer to this newly created class
+    tagged_union.add_member(cls)
+    return handler(source_type)
 
 
 def if_instance_do(x: Any, cls: type, func: Callable):
diff --git a/src/scanspec/plot.py b/src/scanspec/plot.py
index 43311663..0ee4419f 100644
--- a/src/scanspec/plot.py
+++ b/src/scanspec/plot.py
@@ -8,7 +8,7 @@
 from mpl_toolkits.mplot3d import Axes3D, proj3d
 from scipy import interpolate
 
-from .core import Path
+from .core import Path, uses_tagged_union
 from .regions import Circle, Ellipse, Polygon, Rectangle, Region, find_regions
 from .specs import DURATION, Spec
 
@@ -86,6 +86,7 @@ def _plot_spline(axes, ranges, arrays: list[np.ndarray], index_colours: dict[int
             yield unscaled_splines
 
 
+@uses_tagged_union
 def plot_spec(spec: Spec[Any], title: str | None = None):
     """Plot a spec, drawing the path taken through the scan.
 
diff --git a/src/scanspec/regions.py b/src/scanspec/regions.py
index 201a8a4b..0c9b0b1f 100644
--- a/src/scanspec/regions.py
+++ b/src/scanspec/regions.py
@@ -1,7 +1,8 @@
 from __future__ import annotations
 
-from collections.abc import Iterator
-from typing import Generic
+from collections.abc import Iterator, Mapping
+from dataclasses import asdict, is_dataclass
+from typing import Any, Generic
 
 import numpy as np
 from pydantic import BaseModel, Field
@@ -11,6 +12,7 @@
     AxesPoints,
     Axis,
     StrictConfig,
+    deserialize_as,
     discriminated_union_of_subclasses,
     if_instance_do,
 )
@@ -64,6 +66,15 @@ def __sub__(self, other) -> DifferenceOf[Axis]:
     def __xor__(self, other) -> 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 asdict(self)  # type: ignore
+
+    @staticmethod
+    def deserialize(obj):
+        """Deserialize the Region from a dictionary."""
+        return deserialize_as(Region, obj)
+
 
 def get_mask(region: Region[Axis], points: AxesPoints[Axis]) -> np.ndarray:
     """Return a mask of the points inside the region.
@@ -254,10 +265,10 @@ class Polygon(Region[Axis]):
     x_axis: Axis = Field(description="The name matching the x axis of the spec")
     y_axis: Axis = Field(description="The name matching the y axis of the spec")
     x_verts: list[float] = Field(
-        description="The Nx1 x coordinates of the polygons vertices", min_len=3
+        description="The Nx1 x coordinates of the polygons vertices", min_length=3
     )
     y_verts: list[float] = Field(
-        description="The Nx1 y coordinates of the polygons vertices", min_len=3
+        description="The Nx1 y coordinates of the polygons vertices", min_length=3
     )
 
     def axis_sets(self) -> list[set[Axis]]:
@@ -298,7 +309,7 @@ class Circle(Region[Axis]):
     y_axis: Axis = Field(description="The name matching the y axis of the spec")
     x_middle: float = Field(description="The central x point of the circle")
     y_middle: float = Field(description="The central y point of the circle")
-    radius: float = Field(description="Radius of the circle", exc_min=0)
+    radius: float = Field(description="Radius of the circle", gt=0)
 
     def axis_sets(self) -> list[set[Axis]]:
         return [{self.x_axis, self.y_axis}]
@@ -328,10 +339,10 @@ class Ellipse(Region[Axis]):
     x_middle: float = Field(description="The central x point of the ellipse")
     y_middle: float = Field(description="The central y point of the ellipse")
     x_radius: float = Field(
-        description="The radius along the x axis of the ellipse", exc_min=0
+        description="The radius along the x axis of the ellipse", gt=0
     )
     y_radius: float = Field(
-        description="The radius along the y axis of the ellipse", exc_min=0
+        description="The radius along the y axis of the ellipse", gt=0
     )
     angle: float = Field(description="The angle of the ellipse (degrees)", default=0.0)
 
@@ -354,8 +365,10 @@ def mask(self, points: AxesPoints[Axis]) -> np.ndarray:
 
 def find_regions(obj) -> Iterator[Region[Axis]]:
     """Recursively yield Regions from obj and its children."""
-    if hasattr(obj, "__pydantic_model__") and issubclass(
-        obj.__pydantic_model__, BaseModel
+    if (
+        hasattr(obj, "__pydantic_model__")
+        and issubclass(obj.__pydantic_model__, BaseModel)
+        or is_dataclass(obj)
     ):
         if isinstance(obj, Region):
             yield obj
diff --git a/src/scanspec/service.py b/src/scanspec/service.py
index 939bfd8a..e52ef5d9 100644
--- a/src/scanspec/service.py
+++ b/src/scanspec/service.py
@@ -11,11 +11,11 @@
 from pydantic import Field
 from pydantic.dataclasses import dataclass
 
-from scanspec.core import AxesPoints, Frames, Path
+from scanspec.core import AxesPoints, Frames, Path, uses_tagged_union
 
 from .specs import Line, Spec
 
-app = FastAPI()
+app = FastAPI(version="0.1.1")
 
 #
 # Data Model
@@ -27,6 +27,7 @@
 
 
 @dataclass
+@uses_tagged_union
 class ValidResponse:
     """Response model for spec validation."""
 
@@ -43,6 +44,7 @@ class PointsFormat(str, Enum):
 
 
 @dataclass
+@uses_tagged_union
 class PointsRequest:
     """A request for generated scan points."""
 
@@ -123,6 +125,7 @@ class SmallestStepResponse:
 
 
 @app.post("/valid", response_model=ValidResponse)
+@uses_tagged_union
 def valid(
     spec: Spec = Body(..., examples=[_EXAMPLE_SPEC]),
 ) -> ValidResponse | JSONResponse:
@@ -195,6 +198,7 @@ def bounds(
 
 
 @app.post("/gap", response_model=GapResponse)
+@uses_tagged_union
 def gap(
     spec: Spec = Body(
         ...,
@@ -220,6 +224,7 @@ def gap(
 
 
 @app.post("/smalleststep", response_model=SmallestStepResponse)
+@uses_tagged_union
 def smallest_step(
     spec: Spec = Body(..., examples=[_EXAMPLE_SPEC]),
 ) -> SmallestStepResponse:
diff --git a/src/scanspec/specs.py b/src/scanspec/specs.py
index cbafb126..d51e5a6f 100644
--- a/src/scanspec/specs.py
+++ b/src/scanspec/specs.py
@@ -2,10 +2,13 @@
 
 from collections.abc import Callable, Mapping
 from dataclasses import asdict
-from typing import Any, Generic
+from typing import (
+    Any,
+    Generic,
+)
 
 import numpy as np
-from pydantic import Field, parse_obj_as
+from pydantic import Field, validate_call
 from pydantic.dataclasses import dataclass
 
 from .core import (
@@ -15,6 +18,7 @@
     Path,
     SnakedFrames,
     StrictConfig,
+    deserialize_as,
     discriminated_union_of_subclasses,
     gap_between_frames,
     if_instance_do,
@@ -44,7 +48,7 @@
 DURATION = "DURATION"
 
 
-@discriminated_union_of_subclasses(config=StrictConfig)
+@discriminated_union_of_subclasses
 class Spec(Generic[Axis]):
     """A serializable representation of the type and parameters of a scan.
 
@@ -106,10 +110,10 @@ def serialize(self) -> Mapping[str, Any]:
         """Serialize the spec to a dictionary."""
         return asdict(self)  # type: ignore
 
-    @classmethod
-    def deserialize(cls, obj):
+    @staticmethod
+    def deserialize(obj):
         """Deserialize the spec from a dictionary."""
-        return parse_obj_as(cls, obj)
+        return deserialize_as(Spec, obj)
 
 
 @dataclass(config=StrictConfig)
@@ -160,7 +164,7 @@ class Repeat(Spec[Axis]):
     .. note:: There is no turnaround arrow at x=4
     """
 
-    num: int = Field(min=1, description="Number of frames to produce")
+    num: int = Field(ge=1, description="Number of frames to produce")
     gap: bool = Field(
         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",
@@ -286,7 +290,7 @@ def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:
                 # The axis_set spans multiple Dimensions, squash them together
                 # If the spec to be squashed is nested (inside the Mask or outside)
                 # then check the path changes if requested
-                check_path_changes = (nested or si) and self.check_path_changes
+                check_path_changes = bool(nested or si) and self.check_path_changes
                 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
@@ -457,7 +461,7 @@ class Line(Spec[Axis]):
     axis: Axis = Field(description="An identifier for what to move")
     start: float = Field(description="Midpoint of the first point of the line")
     stop: float = Field(description="Midpoint of the last point of the line")
-    num: int = Field(min=1, description="Number of frames to produce")
+    num: int = Field(ge=1, description="Number of frames to produce")
 
     def axes(self) -> list:
         return [self.axis]
@@ -485,7 +489,7 @@ def bounded(
         axis: Axis = 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(min=1, description="Number of frames to produce"),
+        num: int = Field(ge=1, description="Number of frames to produce"),
     ) -> Line[Axis]:
         """Specify a Line by extreme bounds instead of midpoints.
 
@@ -506,6 +510,12 @@ def bounded(
         return cls(axis, start, stop, num)
 
 
+"""
+Defers wrapping function with validate_call until class is fully instantiated
+"""
+Line.bounded = validate_call(Line.bounded)  # type:ignore
+
+
 @dataclass(config=StrictConfig)
 class Static(Spec[Axis]):
     """A static frame, repeated num times, with axis at value.
@@ -521,13 +531,13 @@ class Static(Spec[Axis]):
 
     axis: Axis = Field(description="An identifier for what to move")
     value: float = Field(description="The value at each point")
-    num: int = Field(min=1, description="Number of frames to produce", default=1)
+    num: int = Field(ge=1, description="Number of frames to produce", default=1)
 
     @classmethod
     def duration(
         cls: type[Static],
         duration: float = Field(description="The duration of each static point"),
-        num: int = Field(min=1, description="Number of frames to produce", default=1),
+        num: int = Field(ge=1, description="Number of frames to produce", default=1),
     ) -> Static[str]:
         """A static spec with no motion, only a duration repeated "num" times.
 
@@ -551,6 +561,9 @@ def calculate(self, bounds=True, nested=False) -> list[Frames[Axis]]:
         )
 
 
+Static.duration = validate_call(Static.duration)  # type:ignore
+
+
 @dataclass(config=StrictConfig)
 class Spiral(Spec[Axis]):
     """Archimedean spiral of "x_axis" and "y_axis".
@@ -573,7 +586,7 @@ class Spiral(Spec[Axis]):
     y_start: float = Field(description="y centre of the spiral")
     x_range: float = Field(description="x width of the spiral")
     y_range: float = Field(description="y width of the spiral")
-    num: int = Field(min=1, description="Number of frames to produce")
+    num: int = Field(ge=1, description="Number of frames to produce")
     rotate: float = Field(
         description="How much to rotate the angle of the spiral", default=0.0
     )
@@ -636,6 +649,9 @@ def spaced(
         )
 
 
+Spiral.spaced = validate_call(Spiral.spaced)  # type:ignore
+
+
 def fly(spec: Spec[Axis], duration: float) -> Spec[Axis]:
     """Flyscan, zipping with fixed duration for every frame.
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4268f017..37894ff0 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,3 +1,4 @@
+import json
 import pathlib
 import subprocess
 import sys
@@ -238,7 +239,9 @@ def test_schema() -> None:
     result = runner.invoke(cli.schema)
     assert result.exit_code == 0
     schema_path = pathlib.Path(__file__).resolve().parent.parent / "schema.json"
-    assert result.output == schema_path.read_text()
+    with open(schema_path) as file:
+        data = json.load(file)
+        assert data == json.loads(result.output)
 
 
 def test_cli_version():
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index adb5729d..89c2e7d7 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -4,7 +4,7 @@
 import pytest
 from pydantic import ValidationError
 
-from scanspec.regions import Circle, Rectangle, UnionOf
+from scanspec.regions import Circle, Rectangle, Region, UnionOf
 from scanspec.specs import Line, Mask, Spec, Spiral
 
 
@@ -15,6 +15,20 @@ def test_line_serializes() -> None:
     assert Spec.deserialize(serialized) == ob
 
 
+def test_circle_serializes() -> None:
+    ob = Circle("x", "y", x_middle=0, y_middle=1, radius=4)
+    serialized = {
+        "x_axis": "x",
+        "y_axis": "y",
+        "x_middle": 0.0,
+        "y_middle": 1.0,
+        "radius": 4.0,
+        "type": "Circle",
+    }
+    assert ob.serialize() == serialized
+    assert Region.deserialize(serialized) == ob
+
+
 def test_masked_circle_serializes() -> None:
     ob = Mask(Line("x", 0, 1, 4), Circle("x", "y", x_middle=0, y_middle=1, radius=4))
     serialized = {