atomscale 0.10.2__tar.gz → 0.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. {atomscale-0.10.2 → atomscale-0.12.0}/PKG-INFO +1 -1
  2. atomscale-0.12.0/examples/changepoints.ipynb +191 -0
  3. {atomscale-0.10.2 → atomscale-0.12.0}/pyproject.toml +1 -0
  4. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/client.py +211 -32
  5. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/__init__.py +4 -0
  6. atomscale-0.12.0/src/atomscale/results/changepoint.py +67 -0
  7. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/metrology.py +4 -1
  8. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/optical.py +3 -0
  9. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/photoluminescence.py +3 -0
  10. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/raman.py +3 -0
  11. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/rheed_image.py +4 -1
  12. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/rheed_video.py +3 -0
  13. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/unknown.py +2 -0
  14. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/xps.py +3 -0
  15. atomscale-0.12.0/src/atomscale/results/xrd.py +61 -0
  16. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/rheed_stream.pyi +10 -0
  17. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/src/initialize.rs +126 -0
  18. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/src/lib.rs +23 -4
  19. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/src/timeseries.rs +25 -4
  20. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale.egg-info/PKG-INFO +1 -1
  21. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale.egg-info/SOURCES.txt +4 -0
  22. {atomscale-0.10.2 → atomscale-0.12.0}/tests/_mock_http_server.py +17 -6
  23. {atomscale-0.10.2 → atomscale-0.12.0}/tests/conftest.py +1 -0
  24. atomscale-0.12.0/tests/test_changepoint.py +51 -0
  25. atomscale-0.12.0/tests/test_rheed_stream.py +545 -0
  26. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_timeseries_stream.py +43 -0
  27. atomscale-0.10.2/tests/test_rheed_stream.py +0 -279
  28. {atomscale-0.10.2 → atomscale-0.12.0}/.github/workflows/release.yml +0 -0
  29. {atomscale-0.10.2 → atomscale-0.12.0}/.github/workflows/testing.yml +0 -0
  30. {atomscale-0.10.2 → atomscale-0.12.0}/.github/workflows/upgrade_dependencies.yml +0 -0
  31. {atomscale-0.10.2 → atomscale-0.12.0}/.gitignore +0 -0
  32. {atomscale-0.10.2 → atomscale-0.12.0}/.pre-commit-config.yaml +0 -0
  33. {atomscale-0.10.2 → atomscale-0.12.0}/CHANGELOG.md +0 -0
  34. {atomscale-0.10.2 → atomscale-0.12.0}/LICENSE +0 -0
  35. {atomscale-0.10.2 → atomscale-0.12.0}/MANIFEST.in +0 -0
  36. {atomscale-0.10.2 → atomscale-0.12.0}/README.md +0 -0
  37. {atomscale-0.10.2 → atomscale-0.12.0}/atomicds-shim-dist/pyproject.toml +0 -0
  38. {atomscale-0.10.2 → atomscale-0.12.0}/docs/Makefile +0 -0
  39. {atomscale-0.10.2 → atomscale-0.12.0}/docs/_static/AtomscaleLogoFull.png +0 -0
  40. {atomscale-0.10.2 → atomscale-0.12.0}/docs/_static/custom.css +0 -0
  41. {atomscale-0.10.2 → atomscale-0.12.0}/docs/_templates/custom-class-template.rst +0 -0
  42. {atomscale-0.10.2 → atomscale-0.12.0}/docs/_templates/custom-module-template.rst +0 -0
  43. {atomscale-0.10.2 → atomscale-0.12.0}/docs/conf.py +0 -0
  44. {atomscale-0.10.2 → atomscale-0.12.0}/docs/guides/analysis-results.rst +0 -0
  45. {atomscale-0.10.2 → atomscale-0.12.0}/docs/guides/monitor-live.rst +0 -0
  46. {atomscale-0.10.2 → atomscale-0.12.0}/docs/guides/quickstart.rst +0 -0
  47. {atomscale-0.10.2 → atomscale-0.12.0}/docs/guides/search-and-download.rst +0 -0
  48. {atomscale-0.10.2 → atomscale-0.12.0}/docs/guides/stream-rheed.rst +0 -0
  49. {atomscale-0.10.2 → atomscale-0.12.0}/docs/guides/stream-timeseries.rst +0 -0
  50. {atomscale-0.10.2 → atomscale-0.12.0}/docs/guides/upload-files.rst +0 -0
  51. {atomscale-0.10.2 → atomscale-0.12.0}/docs/index.rst +0 -0
  52. {atomscale-0.10.2 → atomscale-0.12.0}/docs/make.bat +0 -0
  53. {atomscale-0.10.2 → atomscale-0.12.0}/docs/modules.rst +0 -0
  54. {atomscale-0.10.2 → atomscale-0.12.0}/examples/general_use.ipynb +0 -0
  55. {atomscale-0.10.2 → atomscale-0.12.0}/examples/rheed_streaming.ipynb +0 -0
  56. {atomscale-0.10.2 → atomscale-0.12.0}/examples/timeseries_polling.ipynb +0 -0
  57. {atomscale-0.10.2 → atomscale-0.12.0}/examples/vxwse2-placeholder/task1_films.ipynb +0 -0
  58. {atomscale-0.10.2 → atomscale-0.12.0}/examples/vxwse2-placeholder/task1_sapphire.ipynb +0 -0
  59. {atomscale-0.10.2 → atomscale-0.12.0}/examples/vxwse2-placeholder/task2_composition.ipynb +0 -0
  60. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-macos-latest_py3.10.txt +0 -0
  61. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-macos-latest_py3.10_extras.txt +0 -0
  62. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-macos-latest_py3.11.txt +0 -0
  63. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-macos-latest_py3.11_extras.txt +0 -0
  64. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-macos-latest_py3.12.txt +0 -0
  65. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-macos-latest_py3.12_extras.txt +0 -0
  66. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-macos-latest_py3.9.txt +0 -0
  67. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-macos-latest_py3.9_extras.txt +0 -0
  68. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-ubuntu-latest_py3.10.txt +0 -0
  69. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-ubuntu-latest_py3.10_extras.txt +0 -0
  70. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-ubuntu-latest_py3.11.txt +0 -0
  71. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-ubuntu-latest_py3.11_extras.txt +0 -0
  72. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-ubuntu-latest_py3.12.txt +0 -0
  73. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-ubuntu-latest_py3.12_extras.txt +0 -0
  74. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-ubuntu-latest_py3.9.txt +0 -0
  75. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-ubuntu-latest_py3.9_extras.txt +0 -0
  76. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-windows-latest_py3.10.txt +0 -0
  77. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-windows-latest_py3.10_extras.txt +0 -0
  78. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-windows-latest_py3.11.txt +0 -0
  79. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-windows-latest_py3.11_extras.txt +0 -0
  80. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-windows-latest_py3.12.txt +0 -0
  81. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-windows-latest_py3.12_extras.txt +0 -0
  82. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-windows-latest_py3.9.txt +0 -0
  83. {atomscale-0.10.2 → atomscale-0.12.0}/requirements/requirements-windows-latest_py3.9_extras.txt +0 -0
  84. {atomscale-0.10.2 → atomscale-0.12.0}/setup.cfg +0 -0
  85. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomicds/__init__.py +0 -0
  86. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/__init__.py +0 -0
  87. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/core/__init__.py +0 -0
  88. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/core/client.py +0 -0
  89. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/core/files.py +0 -0
  90. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/core/utils.py +0 -0
  91. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/group.py +0 -0
  92. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/results/similarity_trajectory.py +0 -0
  93. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/similarity/__init__.py +0 -0
  94. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/similarity/polling.py +0 -0
  95. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/similarity/provider.py +0 -0
  96. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/Cargo.lock +0 -0
  97. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/Cargo.toml +0 -0
  98. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/__init__.py +0 -0
  99. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/rheed_stream.py +0 -0
  100. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/src/upload.rs +0 -0
  101. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/streaming/src/utils.rs +0 -0
  102. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/__init__.py +0 -0
  103. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/_polling_utils.py +0 -0
  104. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/align.py +0 -0
  105. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/metrology.py +0 -0
  106. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/optical.py +0 -0
  107. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/polling.py +0 -0
  108. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/provider.py +0 -0
  109. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/registry.py +0 -0
  110. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/rheed.py +0 -0
  111. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale/timeseries/sample.py +0 -0
  112. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale.egg-info/dependency_links.txt +0 -0
  113. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale.egg-info/requires.txt +0 -0
  114. {atomscale-0.10.2 → atomscale-0.12.0}/src/atomscale.egg-info/top_level.txt +0 -0
  115. {atomscale-0.10.2 → atomscale-0.12.0}/tests/__init__.py +0 -0
  116. {atomscale-0.10.2 → atomscale-0.12.0}/tests/data/test_rheed.mp4 +0 -0
  117. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_atomicds_alias.py +0 -0
  118. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_client.py +0 -0
  119. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_core.py +0 -0
  120. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_metrology.py +0 -0
  121. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_optical.py +0 -0
  122. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_photoluminescence.py +0 -0
  123. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_polling.py +0 -0
  124. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_raman.py +0 -0
  125. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_rheed_image.py +0 -0
  126. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_rheed_video.py +0 -0
  127. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_similarity_trajectory.py +0 -0
  128. {atomscale-0.10.2 → atomscale-0.12.0}/tests/test_xps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atomscale
3
- Version: 0.10.2
3
+ Version: 0.12.0
4
4
  Summary: Python SDK for Atomscale.
5
5
  Author-email: Atomscale <info@atomscale.ai>
6
6
  License: GPL-3.0-only
@@ -0,0 +1,191 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Changepoint Detection\n",
8
+ "This short notebook shows how to pull changepoint detection records from the Atomscale API using `client.get_changepoints()`. Changepoints are time-bounded regions where an automated detector flagged a meaningful shift in one of the analyzed properties of a data item."
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "markdown",
13
+ "metadata": {},
14
+ "source": [
15
+ "### Install package"
16
+ ]
17
+ },
18
+ {
19
+ "cell_type": "code",
20
+ "execution_count": null,
21
+ "metadata": {},
22
+ "outputs": [],
23
+ "source": [
24
+ "#!pip install atomscale"
25
+ ]
26
+ },
27
+ {
28
+ "cell_type": "markdown",
29
+ "metadata": {},
30
+ "source": [
31
+ "### API Client Setup"
32
+ ]
33
+ },
34
+ {
35
+ "cell_type": "code",
36
+ "execution_count": null,
37
+ "metadata": {},
38
+ "outputs": [],
39
+ "source": [
40
+ "from atomscale import Client\n",
41
+ "from atomscale.results import ChangepointResult\n",
42
+ "\n",
43
+ "api_key = \"YOUR_API_KEY_HERE\"\n",
44
+ "client = Client(api_key=api_key)\n",
45
+ "\n",
46
+ "# Use a data ID from your catalogue. Search or grab one from the web interface.\n",
47
+ "data_id = \"YOUR_DATA_ID_HERE\""
48
+ ]
49
+ },
50
+ {
51
+ "cell_type": "markdown",
52
+ "metadata": {},
53
+ "source": [
54
+ "### Fetching Changepoints\n",
55
+ "Call `get_changepoints` with one or more `data_ids` to get back a pandas `DataFrame` of detected changepoints. With default arguments it returns the most recent intensity-profile detection run, filtered to the critical severity tier — a good starting point for most users."
56
+ ]
57
+ },
58
+ {
59
+ "cell_type": "code",
60
+ "execution_count": null,
61
+ "metadata": {},
62
+ "outputs": [],
63
+ "source": [
64
+ "changepoints = client.get_changepoints(data_ids=data_id)\n",
65
+ "changepoints"
66
+ ]
67
+ },
68
+ {
69
+ "cell_type": "markdown",
70
+ "metadata": {},
71
+ "source": [
72
+ "Each row is a single changepoint. The columns are:\n",
73
+ "```\n",
74
+ "['id', 'data_id', 'data_modality', 'property_name', 'severity', 'score',\n",
75
+ " 'window_start_elapsed', 'window_end_elapsed', 'detection_method', 'detail', 'label']\n",
76
+ "```\n",
77
+ "- `score` is a normalized magnitude in `[0, 1]` — higher means stronger signal.\n",
78
+ "- `window_start_elapsed` / `window_end_elapsed` are seconds from the start of the source time series, so you can line changepoints up against timeseries data pulled via `client.get()`.\n",
79
+ "- `detail` holds method-specific metadata (e.g. RMS error, comparison window size).\n",
80
+ "- `label` is the applied category label if one has been set, otherwise `None`."
81
+ ]
82
+ },
83
+ {
84
+ "cell_type": "markdown",
85
+ "metadata": {},
86
+ "source": [
87
+ "### Filtering Options\n",
88
+ "The defaults keep the view focused, but all three filter arguments can be adjusted:\n",
89
+ "\n",
90
+ "- `detection_method` — `\"forecasting\"`, `\"clustering\"`, `\"intensity_profile\"`, or `None` for all methods. Defaults to `\"intensity_profile\"`.\n",
91
+ "- `severity` — `\"info\"`, `\"warning\"`, `\"critical\"`, or `None` for all levels. Defaults to `\"critical\"`.\n",
92
+ "- `latest_only` — when `True` (default), only anomalies from the most recently completed run per `(data_id, detection_method)` are returned. Set to `False` to include every historical run."
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "code",
97
+ "execution_count": null,
98
+ "metadata": {},
99
+ "outputs": [],
100
+ "source": [
101
+ "# Everything the detector flagged, no filters applied.\n",
102
+ "all_changepoints = client.get_changepoints(\n",
103
+ " data_ids=data_id,\n",
104
+ " detection_method=None,\n",
105
+ " severity=None,\n",
106
+ ")\n",
107
+ "all_changepoints[[\"detection_method\", \"severity\", \"score\", \"property_name\"]]"
108
+ ]
109
+ },
110
+ {
111
+ "cell_type": "code",
112
+ "execution_count": null,
113
+ "metadata": {},
114
+ "outputs": [],
115
+ "source": [
116
+ "# All historical runs for this data_id, not just the latest.\n",
117
+ "history = client.get_changepoints(\n",
118
+ " data_ids=data_id,\n",
119
+ " detection_method=None,\n",
120
+ " severity=None,\n",
121
+ " latest_only=False,\n",
122
+ ")\n",
123
+ "print(f\"latest run only: {len(all_changepoints)} rows\")\n",
124
+ "print(f\"all historical runs: {len(history)} rows\")"
125
+ ]
126
+ },
127
+ {
128
+ "cell_type": "markdown",
129
+ "metadata": {},
130
+ "source": [
131
+ "### Result Objects\n",
132
+ "Pass `as_dataframe=False` to get a list of `ChangepointResult` objects instead of a DataFrame. Useful when you want to iterate and work with the records programmatically."
133
+ ]
134
+ },
135
+ {
136
+ "cell_type": "code",
137
+ "execution_count": null,
138
+ "metadata": {},
139
+ "outputs": [],
140
+ "source": [
141
+ "results = client.get_changepoints(data_ids=data_id, as_dataframe=False)\n",
142
+ "\n",
143
+ "for cp in results[:5]:\n",
144
+ " window = f\"[{cp.window_start_elapsed:.1f}s \\u2192 {cp.window_end_elapsed:.1f}s]\"\n",
145
+ " print(f\"{cp.severity:>8} score={cp.score:.3f} {window} {cp.property_name}\")"
146
+ ]
147
+ },
148
+ {
149
+ "cell_type": "markdown",
150
+ "metadata": {},
151
+ "source": [
152
+ "### Batch Over Multiple Data IDs\n",
153
+ "`data_ids` accepts a list; requests are chunked under the hood so you can pass an arbitrary number of IDs in a single call."
154
+ ]
155
+ },
156
+ {
157
+ "cell_type": "code",
158
+ "execution_count": null,
159
+ "metadata": {},
160
+ "outputs": [],
161
+ "source": [
162
+ "# Use a search to pull a batch of data IDs, then fetch changepoints across all of them.\n",
163
+ "search_results = client.search(data_type=\"rheed_stationary\", status=\"success\")\n",
164
+ "data_ids = search_results[\"Data ID\"].to_list()\n",
165
+ "\n",
166
+ "batch = client.get_changepoints(data_ids=data_ids)\n",
167
+ "batch.groupby(\"data_id\").size().sort_values(ascending=False).head()"
168
+ ]
169
+ },
170
+ {
171
+ "cell_type": "markdown",
172
+ "metadata": {},
173
+ "source": [
174
+ "For more information on other data from the API or other example use see notebooks in the code repository (https://github.com/atomic-data-sciences/api-client) and the documentation (https://atomic-data-sciences.github.io/api-client/)"
175
+ ]
176
+ }
177
+ ],
178
+ "metadata": {
179
+ "kernelspec": {
180
+ "display_name": "Python 3",
181
+ "language": "python",
182
+ "name": "python3"
183
+ },
184
+ "language_info": {
185
+ "name": "python",
186
+ "pygments_lexer": "ipython3"
187
+ }
188
+ },
189
+ "nbformat": 4,
190
+ "nbformat_minor": 5
191
+ }
@@ -63,6 +63,7 @@ package-dir = { "" = "src" }
63
63
  [tool.setuptools.packages.find]
64
64
  where = ["src"]
65
65
  include = ["atomscale*", "atomicds"]
66
+ exclude = ["atomscale.streaming.target*"]
66
67
 
67
68
  [tool.setuptools-rust]
68
69
  debug = false
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
  from concurrent.futures import ThreadPoolExecutor, as_completed
7
8
  from datetime import datetime
8
9
  from pathlib import Path
@@ -14,12 +15,14 @@ from pandas import DataFrame
14
15
  from atomscale.core import BaseClient, ClientError, _FileSlice
15
16
  from atomscale.core.utils import _make_progress, normalize_path
16
17
  from atomscale.results import (
18
+ ChangepointResult,
17
19
  PhotoluminescenceResult,
18
20
  RamanResult,
19
21
  RHEEDImageResult,
20
22
  RHEEDVideoResult,
21
23
  UnknownResult,
22
24
  XPSResult,
25
+ XRDResult,
23
26
  _get_rheed_image_result,
24
27
  )
25
28
  from atomscale.results.group import PhysicalSampleResult, ProjectResult
@@ -71,6 +74,7 @@ class Client(BaseClient):
71
74
  "rheed_stationary",
72
75
  "rheed_rotating",
73
76
  "xps",
77
+ "xrd",
74
78
  "photoluminescence",
75
79
  "pl",
76
80
  "raman",
@@ -102,7 +106,7 @@ class Client(BaseClient):
102
106
  data_ids (str | list[str] | None): Data ID or list of data IDs. Defaults to None.
103
107
  physical_sample_ids (str | list[str] | None): Physical sample ID or list of IDs. Defaults to None.
104
108
  project_ids (str | list[str] | None): Project ID or list of IDs. Defaults to None.
105
- data_type (Literal["rheed_image", "rheed_stationary", "rheed_rotating", "xps", "photoluminescence", "raman", "all"]): Type of data. Defaults to "all".
109
+ data_type (Literal["rheed_image", "rheed_stationary", "rheed_rotating", "xps", "xrd", "photoluminescence", "raman", "all"]): Type of data. Defaults to "all".
106
110
  status (Literal["success", "pending", "error", "running", "all"]): Analyzed status of the data. Defaults to "all".
107
111
  growth_length (tuple[int | None, int | None]): Minimum and maximum values of the growth length in seconds.
108
112
  Defaults to (None, None) which will include all non-video data.
@@ -172,10 +176,10 @@ class Client(BaseClient):
172
176
 
173
177
  if "projects" in catalogue.columns:
174
178
  catalogue["project_ids"] = catalogue["projects"].apply(
175
- lambda projects: (projects[0].get("id") if projects else None)
179
+ lambda projects: projects[0].get("id") if projects else None
176
180
  )
177
181
  catalogue["project_names"] = catalogue["projects"].apply(
178
- lambda projects: (projects[0].get("name") if projects else None)
182
+ lambda projects: projects[0].get("name") if projects else None
179
183
  )
180
184
 
181
185
  if len(catalogue):
@@ -224,6 +228,7 @@ class Client(BaseClient):
224
228
  RHEEDVideoResult
225
229
  | RHEEDImageResult
226
230
  | XPSResult
231
+ | XRDResult
227
232
  | PhotoluminescenceResult
228
233
  | RamanResult
229
234
  | UnknownResult
@@ -234,7 +239,7 @@ class Client(BaseClient):
234
239
  data_ids (str | list[str]): Data ID or list of data IDs from the data catalogue to obtain analyzed results for.
235
240
 
236
241
  Returns:
237
- list[atomscale.results.RHEEDVideoResult | atomscale.results.RHEEDImageResult | atomscale.results.XPSResult]:
242
+ list[atomscale.results.RHEEDVideoResult | atomscale.results.RHEEDImageResult | atomscale.results.XPSResult | atomscale.results.XRDResult]:
238
243
  List of result objects
239
244
 
240
245
  """
@@ -285,6 +290,80 @@ class Client(BaseClient):
285
290
  progress_description="Obtaining data results",
286
291
  )
287
292
 
293
+ def get_changepoints(
294
+ self,
295
+ data_ids: str | list[str],
296
+ latest_only: bool = True,
297
+ detection_method: (
298
+ Literal["forecasting", "clustering", "intensity_profile"] | None
299
+ ) = "intensity_profile",
300
+ severity: Literal["info", "warning", "critical"] | None = "critical",
301
+ as_dataframe: bool = True,
302
+ ) -> DataFrame | list[ChangepointResult]:
303
+ """Get changepoint detection records for one or more data IDs.
304
+
305
+ Args:
306
+ data_ids (str | list[str]): Data ID or list of data IDs from the data catalogue.
307
+ latest_only (bool): If True (default), only return changepoints from the most
308
+ recently completed detection run for each (data_id, detection_method) pair.
309
+ If False, return all changepoints from every historical run.
310
+ detection_method (str | None): Filter to a single detection method. One of
311
+ "forecasting", "clustering", "intensity_profile". Defaults to "intensity_profile".
312
+ Pass None to include all detection methods.
313
+ severity (str | None): Filter to a single severity level. One of "info",
314
+ "warning", "critical". Defaults to "critical". Pass None to include all
315
+ severities.
316
+ as_dataframe (bool): If True (default) return a pandas DataFrame. If False
317
+ return a list of ChangepointResult objects.
318
+
319
+ Returns:
320
+ DataFrame | list[ChangepointResult]: Changepoint records matching the filters.
321
+ """
322
+ if isinstance(data_ids, str):
323
+ data_ids = [data_ids]
324
+
325
+ records: list[dict] = []
326
+ chunk_size = 100
327
+ chunks = [
328
+ data_ids[i : i + chunk_size] for i in range(0, len(data_ids), chunk_size)
329
+ ]
330
+
331
+ for chunk in chunks:
332
+ payload: dict | None = self._get( # type: ignore[assignment]
333
+ sub_url="changepoints/",
334
+ params={"data_ids": chunk, "latest_only": latest_only},
335
+ )
336
+ if payload:
337
+ records.extend(payload.get("changepoints", []))
338
+
339
+ if detection_method is not None:
340
+ records = [
341
+ r for r in records if r.get("detection_method") == detection_method
342
+ ]
343
+ if severity is not None:
344
+ records = [r for r in records if r.get("severity") == severity]
345
+
346
+ if as_dataframe:
347
+ # Keep only the label itself; drop label provenance/metadata fields.
348
+ _drop = {
349
+ "label_category",
350
+ "label_notes",
351
+ "label_source",
352
+ "label_confidence",
353
+ "labeled_at",
354
+ "labeled_by_user_id",
355
+ "similar_neighbor_ids",
356
+ }
357
+ rows = [
358
+ {
359
+ **{k: v for k, v in r.items() if k not in _drop},
360
+ "label": r.get("label_category"),
361
+ }
362
+ for r in records
363
+ ]
364
+ return DataFrame(rows)
365
+ return [ChangepointResult.from_api(r) for r in records]
366
+
288
367
  def list_physical_samples(self) -> DataFrame:
289
368
  """List physical samples available to the user."""
290
369
  data = self._get(sub_url="physical_samples/")
@@ -294,10 +373,10 @@ class Client(BaseClient):
294
373
 
295
374
  if "projects" in samples.columns:
296
375
  samples["project_id"] = samples["projects"].apply(
297
- lambda projects: (projects[0].get("id") if projects else None)
376
+ lambda projects: projects[0].get("id") if projects else None
298
377
  )
299
378
  samples["project_name"] = samples["projects"].apply(
300
- lambda projects: (projects[0].get("name") if projects else None)
379
+ lambda projects: projects[0].get("name") if projects else None
301
380
  )
302
381
 
303
382
  if "detail_notes" in samples.columns:
@@ -305,9 +384,9 @@ class Client(BaseClient):
305
384
  lambda note: note.get("content") if isinstance(note, dict) else None
306
385
  )
307
386
  samples["detail_note_last_updated"] = samples["detail_notes"].apply(
308
- lambda note: note.get("last_updated")
309
- if isinstance(note, dict)
310
- else None
387
+ lambda note: (
388
+ note.get("last_updated") if isinstance(note, dict) else None
389
+ )
311
390
  )
312
391
  samples["detail_note_last_updated"] = samples[
313
392
  "detail_note_last_updated"
@@ -315,13 +394,15 @@ class Client(BaseClient):
315
394
 
316
395
  if "target_material" in samples.columns:
317
396
  samples["target_material"] = samples["target_material"].apply(
318
- lambda tm: {
319
- k: tm.get(k)
320
- for k in ("substrate", "sample_name")
321
- if isinstance(tm, dict) and k in tm
322
- }
323
- if isinstance(tm, dict)
324
- else tm
397
+ lambda tm: (
398
+ {
399
+ k: tm.get(k)
400
+ for k in ("substrate", "sample_name")
401
+ if isinstance(tm, dict) and k in tm
402
+ }
403
+ if isinstance(tm, dict)
404
+ else tm
405
+ )
325
406
  )
326
407
 
327
408
  columns_to_drop = [
@@ -390,9 +471,9 @@ class Client(BaseClient):
390
471
  lambda note: note.get("content") if isinstance(note, dict) else None
391
472
  )
392
473
  projects["detail_note_last_updated"] = projects["detail_note"].apply(
393
- lambda note: note.get("last_updated")
394
- if isinstance(note, dict)
395
- else None
474
+ lambda note: (
475
+ note.get("last_updated") if isinstance(note, dict) else None
476
+ )
396
477
  )
397
478
  projects["detail_note_last_updated"] = projects[
398
479
  "detail_note_last_updated"
@@ -552,6 +633,7 @@ class Client(BaseClient):
552
633
  data_id: str,
553
634
  data_type: Literal[
554
635
  "xps",
636
+ "xrd",
555
637
  "photoluminescence",
556
638
  "pl",
557
639
  "raman",
@@ -570,20 +652,41 @@ class Client(BaseClient):
570
652
  | XPSResult
571
653
  | PhotoluminescenceResult
572
654
  | RamanResult
655
+ | XRDResult
573
656
  | UnknownResult
574
657
  | None
575
658
  ):
659
+ collected_dt = (
660
+ catalogue_entry.get("collected_datetime") if catalogue_entry else None
661
+ )
662
+
576
663
  if data_type == "xps":
577
- result: dict = self._get(sub_url=f"xps/{data_id}") # type: ignore # noqa: PGH003
664
+ result: dict = self._get(sub_url=f"xps/{data_id}") or {} # type: ignore # noqa: PGH003
578
665
 
579
666
  return XPSResult(
580
667
  data_id=data_id,
581
- xps_id=result["xps_id"],
582
- binding_energies=result["binding_energies"],
583
- intensities=result["intensities"],
584
- predicted_composition=result["predicted_composition"],
585
- detected_peaks=result["detected_peaks"],
586
- elements_manually_set=bool(result["set_elements"]),
668
+ xps_id=result.get("xps_id"),
669
+ binding_energies=result.get("binding_energies", []),
670
+ intensities=result.get("intensities", []),
671
+ predicted_composition=result.get("predicted_composition", {}),
672
+ detected_peaks=result.get("detected_peaks", {}),
673
+ elements_manually_set=bool(result.get("set_elements", False)),
674
+ collected_datetime=collected_dt,
675
+ )
676
+
677
+ if data_type == "xrd":
678
+ result = self._get(sub_url=f"xrd/{data_id}") or {} # type: ignore # noqa: PGH003
679
+ return XRDResult(
680
+ data_id=data_id,
681
+ xrd_id=result.get("id"),
682
+ two_theta=result.get("two_theta", []),
683
+ intensities=result.get("intensities", []),
684
+ detected_peaks=result.get("detected_peaks", []),
685
+ wavelength_angstrom=result.get("wavelength_angstrom", 1.5406),
686
+ two_theta_unit=result.get("two_theta_unit", "degrees"),
687
+ spectral_metadata=result.get("spectral_metadata", {}),
688
+ last_updated=result.get("last_updated"),
689
+ collected_datetime=collected_dt,
587
690
  )
588
691
 
589
692
  if data_type in ("photoluminescence", "pl"):
@@ -602,6 +705,7 @@ class Client(BaseClient):
602
705
  intensities=result.get("intensities", []),
603
706
  detected_peaks=result.get("detected_peaks", {}),
604
707
  last_updated=result.get("last_updated"),
708
+ collected_datetime=collected_dt,
605
709
  )
606
710
 
607
711
  if data_type == "raman":
@@ -613,10 +717,14 @@ class Client(BaseClient):
613
717
  intensities=result.get("intensities", []),
614
718
  detected_peaks=result.get("detected_peaks", {}),
615
719
  last_updated=result.get("last_updated"),
720
+ collected_datetime=collected_dt,
616
721
  )
617
722
 
618
723
  if data_type == "rheed_image":
619
- return _get_rheed_image_result(self, data_id)
724
+ result_obj = _get_rheed_image_result(self, data_id)
725
+ if result_obj is not None:
726
+ result_obj.collected_datetime = collected_dt
727
+ return result_obj
620
728
 
621
729
  if data_type in [
622
730
  "rheed_stationary",
@@ -646,6 +754,7 @@ class Client(BaseClient):
646
754
  upload_dt = catalogue_entry.get("upload_datetime")
647
755
  if upload_dt:
648
756
  result_obj.upload_datetime = upload_dt
757
+ result_obj.collected_datetime = collected_dt
649
758
  return result_obj
650
759
 
651
760
  # Fallback for unknown/unsupported data types
@@ -653,16 +762,80 @@ class Client(BaseClient):
653
762
  data_id=data_id,
654
763
  data_type=data_type,
655
764
  catalogue_entry=catalogue_entry,
765
+ collected_datetime=collected_dt,
766
+ )
767
+
768
+ _UUID_RE = re.compile(
769
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
770
+ )
771
+
772
+ def _resolve_physical_sample(self, physical_sample: str) -> tuple[str, str]:
773
+ """Resolve a physical sample name or UUID to (id, name).
774
+
775
+ If UUID: look up existing sample, error if not found.
776
+ If name: case-insensitive match, auto-create if not found.
777
+
778
+ Returns:
779
+ Tuple of (physical_sample_id, physical_sample_name).
780
+ """
781
+ physical_sample = physical_sample.strip()
782
+ if not physical_sample:
783
+ raise ClientError("physical_sample cannot be empty")
784
+
785
+ samples_df = self.list_physical_samples()
786
+
787
+ if self._UUID_RE.match(physical_sample):
788
+ match = samples_df[samples_df["Physical Sample ID"] == physical_sample]
789
+ if match.empty:
790
+ raise ClientError(
791
+ f"Physical sample with id '{physical_sample}' not found"
792
+ )
793
+ return physical_sample, match.iloc[0]["Physical Sample Name"]
794
+
795
+ # Name lookup: case-insensitive exact match
796
+ names_lower = samples_df["Physical Sample Name"].str.strip().str.lower()
797
+ mask = names_lower == physical_sample.lower()
798
+ match = samples_df[mask]
799
+
800
+ if not match.empty:
801
+ return match.iloc[0]["Physical Sample ID"], match.iloc[0][
802
+ "Physical Sample Name"
803
+ ]
804
+
805
+ # Not found — create a new physical sample
806
+ resp: dict = self._post_or_put( # type: ignore # noqa: PGH003
807
+ method="POST",
808
+ sub_url="physical_samples/",
809
+ body={"name": physical_sample},
656
810
  )
811
+ return resp["id"], physical_sample
657
812
 
658
- def upload(self, files: list[str | BinaryIO]):
659
- """Upload and process files
813
+ def upload(
814
+ self,
815
+ files: list[str | BinaryIO],
816
+ physical_sample: str | None = None,
817
+ ) -> list[str]:
818
+ """Upload and process files.
660
819
 
661
820
  Args:
662
- files (list[str | BinaryIO]): List containing string paths to files, or BinaryIO objects from `open`.
821
+ files (list[str | BinaryIO]): List containing string paths to files, or BinaryIO objects from ``open``.
822
+ physical_sample (str | None): Physical sample name or UUID to link uploads to.
823
+ If a name is given and no matching sample exists, one is created automatically.
824
+
825
+ Returns:
826
+ list[str]: Data IDs assigned to the uploaded files.
663
827
  """
664
828
  chunk_size = 40 * 1024 * 1024 # 40 MiB
665
829
 
830
+ # Resolve physical sample before uploading so we fail fast on bad input
831
+ metadata_body: dict[str, str] | None = None
832
+ if physical_sample is not None:
833
+ ps_id, ps_name = self._resolve_physical_sample(physical_sample)
834
+ metadata_body = {
835
+ "physical_sample_id": ps_id,
836
+ "physical_sample_name": ps_name,
837
+ }
838
+
666
839
  # Check to make sure list is valid and get pre-signed URL nums
667
840
  file_data = []
668
841
  for file in files:
@@ -697,7 +870,7 @@ class Client(BaseClient):
697
870
  file_info: dict[
698
871
  Literal["num_urls", "file_name", "file_size", "file_path"], int | str
699
872
  ],
700
- ):
873
+ ) -> str:
701
874
  url_data: list[dict[str, str | int]] = self._post_or_put(
702
875
  method="POST",
703
876
  sub_url="data_entries/raw_data/staged/upload_urls/",
@@ -706,6 +879,7 @@ class Client(BaseClient):
706
879
  "num_parts": file_info["num_urls"],
707
880
  "staging_type": "core",
708
881
  },
882
+ body=metadata_body,
709
883
  ) # type: ignore # noqa: PGH003
710
884
 
711
885
  # Iterate through data structure above and upload file using multi-part S3 urls. Multithread appropriately.
@@ -783,6 +957,9 @@ class Client(BaseClient):
783
957
  },
784
958
  )
785
959
 
960
+ return str(first_part["data_id"])
961
+
962
+ data_ids: list[str] = []
786
963
  main_task = None
787
964
  file_count = len(file_data)
788
965
  with _make_progress(self.mute_bars, False) as progress:
@@ -803,10 +980,12 @@ class Client(BaseClient):
803
980
  for file_info in file_data
804
981
  }
805
982
  for future in as_completed(futures):
806
- future.result() # raise early if anything went wrong
983
+ data_ids.append(future.result())
807
984
  if main_task is not None:
808
985
  progress.update(main_task, advance=1, refresh=True)
809
986
 
987
+ return data_ids
988
+
810
989
  def download_videos(
811
990
  self,
812
991
  data_ids: str | list[str],
@@ -1,3 +1,4 @@
1
+ from .changepoint import ChangepointResult
1
2
  from .group import PhysicalSampleResult, ProjectResult
2
3
  from .metrology import MetrologyResult
3
4
  from .optical import OpticalResult
@@ -8,8 +9,10 @@ from .rheed_video import RHEEDVideoResult
8
9
  from .similarity_trajectory import SimilarityTrajectoryResult
9
10
  from .unknown import UnknownResult
10
11
  from .xps import XPSResult
12
+ from .xrd import XRDResult
11
13
 
12
14
  __all__ = [
15
+ "ChangepointResult",
13
16
  "MetrologyResult",
14
17
  "OpticalResult",
15
18
  "PhotoluminescenceResult",
@@ -22,5 +25,6 @@ __all__ = [
22
25
  "SimilarityTrajectoryResult",
23
26
  "UnknownResult",
24
27
  "XPSResult",
28
+ "XRDResult",
25
29
  "_get_rheed_image_result",
26
30
  ]