maprt-emulator 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. maprt_emulator/__init__.py +11 -0
  2. maprt_emulator/__main__.py +6 -0
  3. maprt_emulator/app.py +182 -0
  4. maprt_emulator/auth.py +105 -0
  5. maprt_emulator/check.py +262 -0
  6. maprt_emulator/cli.py +378 -0
  7. maprt_emulator/data/maps/demo_hfs_lowres.txt +1334 -0
  8. maprt_emulator/data/maps/index.json +17 -0
  9. maprt_emulator/data/patients.json +22 -0
  10. maprt_emulator/data/rooms.json +26 -0
  11. maprt_emulator/data/surfaces/17c0b9d4-3e8a-4f21-b6c5-9d20e8713f4a.obj +28808 -0
  12. maprt_emulator/data/surfaces/a6f3fbee-6714-4f92-80c1-f5aadda4cbeb.obj +28808 -0
  13. maprt_emulator/data/surfaces/dad7394f-6893-4ce3-96ed-8bc3091cf4c3.obj +28808 -0
  14. maprt_emulator/envelope.py +123 -0
  15. maprt_emulator/inventory.py +322 -0
  16. maprt_emulator/library.py +430 -0
  17. maprt_emulator/mapgen.py +333 -0
  18. maprt_emulator/maps.py +176 -0
  19. maprt_emulator/obj.py +103 -0
  20. maprt_emulator/packs.py +438 -0
  21. maprt_emulator/replies.py +98 -0
  22. maprt_emulator/routes/__init__.py +1 -0
  23. maprt_emulator/routes/beams.py +166 -0
  24. maprt_emulator/routes/getmap.py +270 -0
  25. maprt_emulator/routes/inventory.py +23 -0
  26. maprt_emulator/routes/ping.py +26 -0
  27. maprt_emulator/routes/rooms.py +61 -0
  28. maprt_emulator/routes/surfaces.py +114 -0
  29. maprt_emulator/scenarios.py +215 -0
  30. maprt_emulator/schemas.py +153 -0
  31. maprt_emulator/settings.py +239 -0
  32. maprt_emulator/store.py +210 -0
  33. maprt_emulator-0.1.0.dist-info/METADATA +179 -0
  34. maprt_emulator-0.1.0.dist-info/RECORD +38 -0
  35. maprt_emulator-0.1.0.dist-info/WHEEL +4 -0
  36. maprt_emulator-0.1.0.dist-info/entry_points.txt +2 -0
  37. maprt_emulator-0.1.0.dist-info/licenses/LICENSE +21 -0
  38. maprt_emulator-0.1.0.dist-info/licenses/LICENSE-packs +396 -0
@@ -0,0 +1,11 @@
1
+ """maprt_emulator — the MapRT Emulator, a local emulator of the Vision RT MapRT API 2.0.
2
+
3
+ Serves the seven documented REST endpoints over FastAPI, with the same Bearer-token auth, the
4
+ same JSON envelope, the same two-ways-to-fail error model and the same streaming collision-map
5
+ wire format as a real appliance — reading surfaces and recorded clearance maps out of a folder,
6
+ and synthesizing plausible maps when no recording matches.
7
+
8
+ **Not a medical device. Teaching use only. Not affiliated with or endorsed by Vision RT.**
9
+ """
10
+
11
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """``python -m maprt_emulator`` — the same entry point as the ``maprt-emulator`` console script."""
2
+
3
+ from maprt_emulator.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
maprt_emulator/app.py ADDED
@@ -0,0 +1,182 @@
1
+ """The FastAPI application: wiring, the ``Server`` header, and envelope error rendering.
2
+
3
+ It serves the seven MapRT REST routes, plus FastAPI's own pages (``/docs``, ``/redoc``,
4
+ ``/openapi.json``), which every FastAPI application has, and the inventory page (``/inventory``,
5
+ ``/inventory.md``: what is served and what a call can reach; not in the schema). The pages describe only what an
6
+ appliance takes and sends (:mod:`maprt_emulator.replies`), so a call tried there is a real one. There are no emulator control routes: a
7
+ real appliance has none, and a client tested here must not come to rely on one. A real appliance
8
+ publishes no API pages either, so a client must not depend on those. Faults are chosen when the
9
+ emulator starts (``--scenario``), not over HTTP.
10
+
11
+ Two things to remember when reading responses:
12
+
13
+ * Every endpoint but ``GetMap`` answers with the envelope: ``{"data", "errorData", "success",
14
+ "errorCode", "errorCodeType"}`` on success, and the same without ``data`` on a negative result.
15
+ * **A ``200`` status does not mean the call succeeded.** A negative result — bad token, unlicensed
16
+ feature, ambiguous room name — comes back at ``200`` with ``success: false``.
17
+ """
18
+
19
+ import asyncio
20
+ import logging
21
+
22
+ from fastapi import FastAPI, Security
23
+ from fastapi.exceptions import RequestValidationError
24
+ from fastapi.responses import JSONResponse
25
+
26
+ from maprt_emulator import envelope
27
+ from maprt_emulator.auth import bearer_scheme
28
+ from maprt_emulator.library import load_library, serves_packs
29
+ from maprt_emulator.maps import MapLibrary
30
+ from maprt_emulator.routes import beams, getmap, inventory, ping, rooms, surfaces
31
+ from maprt_emulator.routes.getmap import MapCache
32
+ from maprt_emulator.scenarios import ScenarioState
33
+ from maprt_emulator.settings import API_VERSION, SERVER_VERSION, EmulatorSettings
34
+ from maprt_emulator.store import Catalog
35
+
36
+ logger = logging.getLogger("maprt_emulator")
37
+
38
+ #: ``create_app``'s default for ``library``: load the packs the settings ask for.
39
+ _FROM_SETTINGS = object()
40
+
41
+
42
+ def create_app(settings=None, library=_FROM_SETTINGS):
43
+ """Build the application. ``settings`` defaults to one resolved from the environment.
44
+
45
+ ``library`` is the :class:`maprt_emulator.library.PackLibrary` to serve (or ``None`` for none),
46
+ when the caller has already loaded it, as the command line does to report it in the banner;
47
+ by default the packs the settings ask for are loaded here.
48
+ """
49
+ settings = settings or EmulatorSettings()
50
+
51
+ # FastAPI's default /docs, /redoc and /openapi.json stay: they come with any FastAPI app.
52
+ app = FastAPI(title="MapRT Emulator", version="2.0.0", description=DOCS_DESCRIPTION)
53
+ _describe_only_the_api(app)
54
+
55
+ app.state.settings = settings
56
+ app.state.token = settings.resolve_token()
57
+ app.state.catalog = Catalog.load(settings.data_dir)
58
+ app.state.library = load_library(settings) if library is _FROM_SETTINGS else library
59
+ # Decision 3: with packs loaded, the emulator presents the newest appliance they were recorded
60
+ # on, in the Server header and the ping reply. Nothing else changes with it.
61
+ appliance = app.state.library.appliance if serves_packs(app) else None
62
+ app.state.server_version = appliance["server"] if appliance else SERVER_VERSION
63
+ app.state.api_version = appliance["apiVersion"] if appliance else API_VERSION
64
+ app.state.maps = MapLibrary.load(settings.data_dir)
65
+ app.state.map_cache = MapCache(app.state.catalog, library=app.state.library)
66
+ app.state.scenarios = ScenarioState(settings)
67
+
68
+ @app.exception_handler(envelope.MapRTFailure)
69
+ async def _render_failure(_request, exc: envelope.MapRTFailure):
70
+ """Render a reported failure as the MapRT envelope at the status the raiser chose.
71
+
72
+ Note the status is usually **200**: that is the API's own convention for a negative
73
+ result, not an oversight here.
74
+ """
75
+ if exc.detail:
76
+ logger.warning("%s: %s", exc.error_code, exc.detail)
77
+ return JSONResponse(exc.envelope(), status_code=exc.status_code)
78
+
79
+ @app.exception_handler(RequestValidationError)
80
+ async def _render_validation_error(_request, exc: RequestValidationError):
81
+ """Render a malformed request body as the MapRT envelope at HTTP 400.
82
+
83
+ FastAPI's own answer would be ``422`` with ``{"detail": [...]}``, which is a perfectly
84
+ good REST convention and the wrong one here twice over: the spec (§5.3) puts a rejected
85
+ request at a 4xx such as 400, not 422, and the body is not the
86
+ envelope, so a MapRT client that has one parser for every response cannot read it.
87
+
88
+ The validation detail goes to the server log, not ``errorData``: no recorded error
89
+ carries anything there, so a client must not learn to read it.
90
+ """
91
+ logger.warning("InvalidArgument: %s", "; ".join(_readable(error) for error in exc.errors()))
92
+ return JSONResponse(envelope.fail(*envelope.INVALID_ARGUMENT), status_code=400)
93
+
94
+ @app.middleware("http")
95
+ async def _appliance_behaviour(request, call_next):
96
+ """Latency, the armed scenario's transport-level faults, and the ``Server`` header.
97
+
98
+ The faults are applied only to ``/integration`` paths, which is every route there is; an
99
+ unknown path still gets a plain 404.
100
+ """
101
+ scenarios_state = app.state.scenarios
102
+ is_api = request.url.path.startswith("/integration")
103
+
104
+ if settings.latency_ms:
105
+ await asyncio.sleep(settings.latency_ms / 1000)
106
+
107
+ if is_api and scenarios_state.extra_delay:
108
+ await asyncio.sleep(scenarios_state.extra_delay)
109
+
110
+ if is_api and scenarios_state.take_throttle_slot():
111
+ # The spec (§2.2.1.3) allows a server to throttle: to tell a client it can't take the
112
+ # request now and that it should try again. Retry-After tells a client how long to
113
+ # wait instead of hammering.
114
+ logger.warning("ServerBusy: the 'throttle' scenario refused %s", request.url.path)
115
+ response = JSONResponse(envelope.fail(*envelope.SERVER_BUSY), status_code=503)
116
+ response.headers["Retry-After"] = "1"
117
+ response.headers["server"] = app.state.server_version
118
+ return response
119
+
120
+ if is_api and scenarios_state.forced_status:
121
+ # A 5xx has no envelope to read on a real appliance, and a client that assumes it can
122
+ # always parse JSON fails inside its own error handler. One is sent anyway here, so
123
+ # the status is the thing being exercised rather than the body.
124
+ logger.warning("InternalError: the 'server-error' scenario answered %s", request.url.path)
125
+ response = JSONResponse(envelope.fail(*envelope.INTERNAL_ERROR),
126
+ status_code=scenarios_state.forced_status)
127
+ response.headers["server"] = app.state.server_version
128
+ return response
129
+
130
+ response = await call_next(request)
131
+ # Every appliance response carries a Server header, Saturn/<version> (spec §5.1). Worth
132
+ # reproducing — it is how a client can tell what it is talking to,
133
+ # and a notebook reads it to confirm the connection landed where it thinks.
134
+ response.headers["server"] = app.state.server_version
135
+ return response
136
+
137
+ # Security() here only documents the Bearer token for /docs; require_token does the checking.
138
+ for router in (ping.router, rooms.router, surfaces.router, getmap.router, beams.router):
139
+ app.include_router(router, dependencies=[Security(bearer_scheme)])
140
+ # A page for people, like /docs: no token, and not part of the API (left out of the schema).
141
+ app.include_router(inventory.router)
142
+
143
+ return app
144
+
145
+
146
+ DOCS_DESCRIPTION = """\
147
+ A local emulator of the MapRT API 2.0 REST routes. Teaching and testing use only. A real
148
+ appliance publishes no page like this one.
149
+
150
+ To try a call: **Authorize** with the token the emulator printed at start. A browser can't send
151
+ the `VisionRT.Integration.Saturn/<version>` User-Agent an appliance requires, so start the
152
+ emulator with `--lenient-user-agent`, or every call here answers `VersionMismatch`.
153
+
154
+ [The inventory](/inventory) ([Markdown](/inventory.md)) lists what this emulator serves: with
155
+ packs loaded, every recorded request, with a body to copy that gets the recorded reply.
156
+ """
157
+
158
+
159
+ def _describe_only_the_api(app):
160
+ """Leave FastAPI's 422 validation reply out of the schema: the emulator never sends one.
161
+
162
+ A malformed body gets the MapRT error envelope at 400 (``_render_validation_error``), and
163
+ each POST route documents that 400 itself.
164
+ """
165
+ generate = app.openapi
166
+
167
+ def openapi():
168
+ schema = generate()
169
+ for operations in schema.get("paths", {}).values():
170
+ for operation in operations.values():
171
+ operation.get("responses", {}).pop("422", None)
172
+ for name in ("HTTPValidationError", "ValidationError"):
173
+ schema.get("components", {}).get("schemas", {}).pop(name, None)
174
+ return schema
175
+
176
+ app.openapi = openapi
177
+
178
+
179
+ def _readable(error):
180
+ """One pydantic validation error as a short string, without its internal bookkeeping."""
181
+ location = ".".join(str(part) for part in error.get("loc", ()) if part != "body")
182
+ return f"{location or 'body'}: {error.get('msg', 'invalid')}"
maprt_emulator/auth.py ADDED
@@ -0,0 +1,105 @@
1
+ """Bearer-token auth and the User-Agent check, as a real appliance applies them.
2
+
3
+ Three headers go on every MapRT API call::
4
+
5
+ Content-Type: application/json
6
+ Authorization: Bearer <API_access_token>
7
+ User-Agent: VisionRT.Integration.Saturn/<major>.<minor>.<patch>
8
+
9
+ The token is a **shared secret** issued per appliance — not a login, not a session, nothing to
10
+ refresh. It is the whole of the authentication story, which is why a real deployment puts the
11
+ appliance behind TLS on a controlled network and treats the token like a password.
12
+
13
+ What a real appliance does, read from recordings of one (Saturn/1.3.217.2, API 2.0.0,
14
+ 2026-10-23), and what the emulator therefore does:
15
+
16
+ * **Any token problem** — no ``Authorization`` header, ``Bearer`` with nothing after it, the token
17
+ without the ``Bearer`` prefix, or a wrong token — gets HTTP **200** with ``success: false`` and
18
+ ``InvalidClientToken``. Never a 401. (``--auth-401`` returns 401 instead, for exercising a
19
+ client's other branch.)
20
+ * **The token is checked first.** A wrong token with a bad User-Agent is ``InvalidClientToken``.
21
+ * **The User-Agent** must be ``VisionRT.Integration.Saturn/`` followed by a version whose
22
+ *major.minor* is not newer than the API's (2.0): ``…/2.0.0``, ``…/2.0.1`` and ``…/1.2.8``
23
+ pass; ``…/2.1.0``, ``…/3.0.0``, ``…/`` with no version, no header at all, or ``curl/8.7.1``
24
+ all get HTTP 200, ``success: false``, ``VersionMismatch``. The patch number is ignored.
25
+
26
+ The status code is the trap: a client that trusts it reads every one of these as a success.
27
+ """
28
+
29
+ import logging
30
+
31
+ from fastapi import Request
32
+ from fastapi.security import HTTPBearer
33
+
34
+ from maprt_emulator import envelope
35
+ from maprt_emulator.settings import API_VERSION, USER_AGENT_PREFIX
36
+
37
+ logger = logging.getLogger("maprt_emulator.auth")
38
+
39
+
40
+ #: For ``/docs`` only: it gives the page an Authorize button that sends ``Bearer <token>``.
41
+ #: ``auto_error=False`` means it never answers a request itself; :func:`require_token` does,
42
+ #: with the envelope an appliance sends.
43
+ bearer_scheme = HTTPBearer(auto_error=False, description="The token the emulator printed at start.")
44
+
45
+
46
+ def bearer_token(request):
47
+ """The token from the ``Authorization`` header, or ``None`` if none was presented."""
48
+ header = request.headers.get("authorization")
49
+ if not header:
50
+ return None
51
+ scheme, _, value = header.partition(" ")
52
+ if scheme.lower() != "bearer" or not value.strip():
53
+ return None
54
+ return value.strip()
55
+
56
+
57
+ def _major_minor(version):
58
+ """``(major, minor)`` from a ``a.b[.c…]`` version string, or ``None`` if it isn't one."""
59
+ parts = version.split(".")
60
+ if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit():
61
+ return None
62
+ return int(parts[0]), int(parts[1])
63
+
64
+
65
+ def user_agent_accepted(agent, api_version=API_VERSION):
66
+ """Whether an appliance serving ``api_version`` accepts this User-Agent (see the module notes)."""
67
+ if not agent or not agent.startswith(USER_AGENT_PREFIX):
68
+ return False
69
+ client = _major_minor(agent[len(USER_AGENT_PREFIX):])
70
+ return client is not None and client <= _major_minor(api_version)
71
+
72
+
73
+ def check_user_agent(request, settings):
74
+ """Refuse a User-Agent the appliance would refuse, unless ``--lenient-user-agent`` is set.
75
+
76
+ Strict by default, because the appliance is. Lenient only logs, so a beginner's first bare
77
+ ``GET`` can work before the header has been explained.
78
+ """
79
+ agent = request.headers.get("user-agent", "")
80
+ if user_agent_accepted(agent):
81
+ return
82
+ if settings.strict_user_agent:
83
+ raise envelope.MapRTFailure(envelope.VERSION_MISMATCH)
84
+ logger.warning("User-Agent %r would get VersionMismatch from an appliance", agent or "(none)")
85
+
86
+
87
+ async def require_token(request: Request):
88
+ """FastAPI dependency: authenticate the request, or raise :class:`envelope.MapRTFailure`.
89
+
90
+ The token first, then the User-Agent — the appliance's order. Also the one place both are
91
+ applied, so every authenticated endpoint gets them without each route remembering to ask.
92
+ """
93
+ settings = request.app.state.settings
94
+ presented = bearer_token(request)
95
+
96
+ # The `bad-token` scenario refuses a correct token too — what an expired or rotated secret
97
+ # looks like from the client side, which is otherwise impossible to arrange on demand.
98
+ rejected = (presented is None or presented != request.app.state.token
99
+ or request.app.state.scenarios.rejects_token)
100
+ if rejected:
101
+ raise envelope.MapRTFailure(envelope.INVALID_CLIENT_TOKEN,
102
+ status_code=401 if settings.auth_401 else 200)
103
+
104
+ check_user_agent(request, settings)
105
+ return presented
@@ -0,0 +1,262 @@
1
+ """Validate a data directory without starting a server — ``maprt-emulator --check-data``.
2
+
3
+ The loaders refuse to start on anything they cannot read, which is the right behaviour for a
4
+ server but a slow way to iterate when you are adding data: edit, start, read a traceback, stop,
5
+ repeat. This does the same loading, then goes further and reports the problems that are *not*
6
+ fatal but are almost always mistakes — a map recorded against a surface id that no longer exists,
7
+ an ``.obj`` nobody references, a plan naming a patient the appliance has never heard of.
8
+
9
+ Nothing here changes what the server will do. It tells you what it is about to do.
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ from maprt_emulator.library import load_library
15
+ from maprt_emulator.maps import MapLibrary, MapLibraryError
16
+ from maprt_emulator.store import Catalog, CatalogError
17
+
18
+
19
+ class Report:
20
+ """Findings, split by consequence.
21
+
22
+ Note what the split is *not*: whether the loader raises. A duplicate room GUID and a
23
+ truncated recorded map both load perfectly happily and then serve wrong answers, which is
24
+ worse than refusing to start — so they are errors here even though the server would come up.
25
+ """
26
+
27
+ def __init__(self):
28
+ self.errors = [] # the server will refuse to start
29
+ self.warnings = [] # it will start, but this is probably not what you meant
30
+ self.notes = [] # worth seeing, not a problem
31
+
32
+ def error(self, message):
33
+ """Something that will produce wrong behaviour. Not necessarily a startup failure."""
34
+ self.errors.append(message)
35
+
36
+ def warn(self, message):
37
+ self.warnings.append(message)
38
+
39
+ def note(self, message):
40
+ self.notes.append(message)
41
+
42
+ @property
43
+ def ok(self):
44
+ return not self.errors
45
+
46
+ def render(self, data_dir):
47
+ lines = [f"Checking {data_dir}", ""]
48
+ for label, items, marker in (("ERROR", self.errors, "✗"),
49
+ ("WARNING", self.warnings, "!"),
50
+ ("", self.notes, "·")):
51
+ for item in items:
52
+ prefix = f"{marker} {label}: " if label else f" {marker} "
53
+ lines.append(f"{prefix}{item}")
54
+ if self.errors:
55
+ lines += ["", (f"{_count(len(self.errors), 'error')} — fix these before serving this "
56
+ f"data. Some of them will not stop the emulator starting; it will "
57
+ f"start and give wrong answers, which is worse.")]
58
+ elif self.warnings:
59
+ lines += ["", (f"No errors, {_count(len(self.warnings), 'warning')}. "
60
+ f"The emulator will run.")]
61
+ else:
62
+ lines += ["", "No problems found."]
63
+ return "\n".join(lines)
64
+
65
+
66
+ def _count(number, noun):
67
+ return f"{number} {noun}{'' if number == 1 else 's'}"
68
+
69
+
70
+ def check(data_dir):
71
+ """Validate ``data_dir`` and return a :class:`Report`."""
72
+ data_dir = Path(data_dir)
73
+ report = Report()
74
+
75
+ try:
76
+ catalog = Catalog.load(data_dir)
77
+ except CatalogError as exc:
78
+ report.error(str(exc))
79
+ return report # nothing else can be checked without a catalog
80
+
81
+ try:
82
+ maps = MapLibrary.load(data_dir)
83
+ except MapLibraryError as exc:
84
+ report.error(str(exc))
85
+ maps = None
86
+
87
+ report.note(", ".join([
88
+ _count(len(catalog.known_patients), "patient"),
89
+ _count(len(catalog.surfaces), "surface"),
90
+ _count(len(catalog.rooms), "room"),
91
+ _count(len(maps) if maps else 0, "recorded map"),
92
+ ]))
93
+
94
+ _check_rooms(catalog, report)
95
+ _check_surfaces(catalog, data_dir, report)
96
+ _check_patients(catalog, report)
97
+ if maps is not None:
98
+ _check_maps(maps, catalog, report)
99
+ _check_plans(catalog, data_dir, report)
100
+
101
+ return report
102
+
103
+
104
+ def _check_rooms(catalog, report):
105
+ ids = [room.id for room in catalog.rooms]
106
+ duplicates = {i for i in ids if ids.count(i) > 1}
107
+ for room_id in sorted(duplicates):
108
+ # GetMap keys on the GUID, so two rooms sharing one makes the choice between them
109
+ # arbitrary — and silently so.
110
+ report.error(f"two or more rooms share the id {room_id}; room ids must be unique")
111
+
112
+ names = [room.machine_name for room in catalog.rooms]
113
+ shared = sorted({n for n in names if names.count(n) > 1})
114
+ if shared:
115
+ # Not a problem — the shipped data does this on purpose, and so do real sites.
116
+ report.note(f"machine name(s) shared by several rooms: {', '.join(shared)} "
117
+ f"(GET /integration/rooms/{{machineName}} will return DuplicateKey for these)")
118
+
119
+ for room in catalog.rooms:
120
+ if len(room.machine_name) > 16:
121
+ report.warn(f"room {room.name!r} has a machineName of {len(room.machine_name)} "
122
+ f"characters; the spec caps it at 16")
123
+
124
+
125
+ def _check_surfaces(catalog, data_dir, report):
126
+ from maprt_emulator.obj import ObjParseError, parse_obj
127
+
128
+ referenced = {surface.path.resolve() for surface in catalog.surfaces}
129
+ for directory in (data_dir / "surfaces", data_dir / "local" / "surfaces"):
130
+ if not directory.is_dir():
131
+ continue
132
+ for path in sorted(directory.glob("*.obj")):
133
+ if path.resolve() not in referenced:
134
+ report.warn(f"{path.relative_to(data_dir)} is not listed for any patient in "
135
+ f"patients.json, so it will never be served")
136
+
137
+ for surface in catalog.surfaces:
138
+ try:
139
+ vertices, faces, _normals = parse_obj(surface.path.read_bytes())
140
+ except ObjParseError as exc:
141
+ report.error(f"{surface.path.name} does not parse as an OBJ mesh: {exc}")
142
+ continue
143
+ if len(vertices) < 100 or len(faces) < 100:
144
+ report.warn(f"{surface.path.name} has only {len(vertices)} vertices and "
145
+ f"{len(faces)} faces — is it the mesh you meant?")
146
+
147
+
148
+ def _check_patients(catalog, report):
149
+ for patient_id in sorted(catalog.known_patients):
150
+ if len(patient_id) > 64:
151
+ report.warn(f"patient id {patient_id!r} is {len(patient_id)} characters; "
152
+ f"DICOM caps it at 64")
153
+ if not catalog.surfaces_for(patient_id):
154
+ # Legitimate and deliberately represented in the shipped data, but say so in case it
155
+ # is a typo in a surface id rather than an intentionally empty patient.
156
+ report.note(f"patient {patient_id} has no surfaces (a valid case: the API returns "
157
+ f"an empty list at success:true)")
158
+
159
+ timestamps = [(p, [s.timestamp for s in catalog.surfaces_for(p)])
160
+ for p in catalog.known_patients]
161
+ for patient_id, stamps in timestamps:
162
+ if len(stamps) != len(set(stamps)):
163
+ report.warn(f"patient {patient_id} has two surfaces with the same timeStamp; "
164
+ f"'use the most recent' becomes arbitrary")
165
+
166
+
167
+ def _check_maps(maps, catalog, report):
168
+ known_surfaces = {surface.id for surface in catalog.surfaces}
169
+ known_rooms = {room.id for room in catalog.rooms}
170
+
171
+ for recording in maps.recordings:
172
+ name = recording.path.name
173
+ # Not fatal — the library simply never matches it — but it means a recording you went to
174
+ # the trouble of capturing will silently never be served.
175
+ if recording.surface_id not in known_surfaces:
176
+ report.warn(f"{name} is recorded against surface {recording.surface_id}, which is "
177
+ f"not in patients.json — it will never be served")
178
+ if recording.room_id not in known_rooms:
179
+ report.warn(f"{name} is recorded against room {recording.room_id}, which is not in "
180
+ f"rooms.json — it will never be served")
181
+
182
+ text = recording.text()
183
+ if not text.strip().endswith("DONE"):
184
+ report.error(f"{name} has no DONE terminator; it was captured from a truncated "
185
+ f"response and will parse into a wrong-shaped array")
186
+ try:
187
+ header = text.split("\n", 1)[0]
188
+ couch_samples, gantry_samples = (int(v) for v in header.split(","))
189
+ except ValueError:
190
+ report.error(f"{name} does not start with a 'couch,gantry' header line")
191
+ continue
192
+ triples = len(text.split()) - 2
193
+ if triples != couch_samples * gantry_samples:
194
+ report.error(f"{name} promises {couch_samples * gantry_samples:,} triples but "
195
+ f"carries {triples:,}")
196
+
197
+
198
+ def _check_plans(catalog, data_dir, report):
199
+ plans_dir = data_dir / "plans"
200
+ if not plans_dir.is_dir():
201
+ return
202
+ try:
203
+ import pydicom
204
+ except ImportError:
205
+ report.note("pydicom is not installed, so RT Plans were not checked")
206
+ return
207
+
208
+ for path in sorted(plans_dir.glob("*.dcm")):
209
+ try:
210
+ plan = pydicom.dcmread(path, stop_before_pixels=True)
211
+ except Exception as exc: # noqa: BLE001 — any unreadable file is worth reporting
212
+ report.error(f"{path.name} does not read as DICOM: {type(exc).__name__}: {exc}")
213
+ continue
214
+
215
+ if getattr(plan, "Modality", None) != "RTPLAN":
216
+ report.warn(f"{path.name} is {getattr(plan, 'Modality', 'unknown')}, not RTPLAN")
217
+ continue
218
+
219
+ patient_id = str(getattr(plan, "PatientID", ""))
220
+ if patient_id not in catalog.known_patients:
221
+ # The capstone notebook looks this patient up; a mismatch fails its second cell.
222
+ report.warn(f"{path.name} is for patient {patient_id!r}, who is not in "
223
+ f"patients.json — a notebook using this plan will fail")
224
+
225
+ machines = {str(b.TreatmentMachineName) for b in getattr(plan, "BeamSequence", [])
226
+ if hasattr(b, "TreatmentMachineName")}
227
+ configured = {room.machine_name for room in catalog.rooms}
228
+ for machine in sorted(machines - configured):
229
+ report.warn(f"{path.name} names machine {machine!r}, which is not in rooms.json")
230
+
231
+
232
+ def check_packs(settings, report):
233
+ """Add the packs the settings select to ``report``: a refusal, or a skipped pack, is an error
234
+ (a packs folder can be checked before a workshop); a mixed-version warning is a warning; the rest
235
+ are notes."""
236
+ try:
237
+ library = load_library(settings)
238
+ except CatalogError as exc:
239
+ report.error(f"packs: {exc}")
240
+ return
241
+ if library is None:
242
+ report.note("packs: off (--no-packs)" if settings.no_packs else "packs: none (no --packs-dir)")
243
+ return
244
+ report.note(f"packs: {library.summary()}")
245
+ for entry in library.skipped:
246
+ report.error(f"packs: {entry.name or entry.path} ({entry.path}) failed its checks and would be skipped: "
247
+ + "; ".join(map(str, entry.findings)))
248
+ for warning in library.warnings:
249
+ report.warn(f"packs: {warning}")
250
+ for notice in library.notices:
251
+ report.note(f"packs: {notice}")
252
+
253
+
254
+ def main(data_dir, settings=None):
255
+ """Print the report; return a process exit code. With ``settings``, the packs they select are
256
+ checked too."""
257
+ report = check(data_dir)
258
+ if settings is not None:
259
+ check_packs(settings, report)
260
+ print(report.render(data_dir))
261
+ return 0 if report.ok else 1
262
+