atomscale 0.7.0__tar.gz → 0.7.2__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 (111) hide show
  1. {atomscale-0.7.0/src/atomscale.egg-info → atomscale-0.7.2}/PKG-INFO +1 -1
  2. {atomscale-0.7.0 → atomscale-0.7.2}/docs/index.rst +4 -4
  3. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/client.py +16 -30
  4. atomscale-0.7.2/src/atomscale/timeseries/align.py +256 -0
  5. {atomscale-0.7.0 → atomscale-0.7.2/src/atomscale.egg-info}/PKG-INFO +1 -1
  6. atomscale-0.7.0/src/atomscale/timeseries/align.py +0 -106
  7. {atomscale-0.7.0 → atomscale-0.7.2}/.github/workflows/release.yml +0 -0
  8. {atomscale-0.7.0 → atomscale-0.7.2}/.github/workflows/testing.yml +0 -0
  9. {atomscale-0.7.0 → atomscale-0.7.2}/.github/workflows/upgrade_dependencies.yml +0 -0
  10. {atomscale-0.7.0 → atomscale-0.7.2}/.gitignore +0 -0
  11. {atomscale-0.7.0 → atomscale-0.7.2}/.pre-commit-config.yaml +0 -0
  12. {atomscale-0.7.0 → atomscale-0.7.2}/CHANGELOG.md +0 -0
  13. {atomscale-0.7.0 → atomscale-0.7.2}/LICENSE +0 -0
  14. {atomscale-0.7.0 → atomscale-0.7.2}/MANIFEST.in +0 -0
  15. {atomscale-0.7.0 → atomscale-0.7.2}/README.md +0 -0
  16. {atomscale-0.7.0 → atomscale-0.7.2}/atomicds-shim-dist/pyproject.toml +0 -0
  17. {atomscale-0.7.0 → atomscale-0.7.2}/docs/Makefile +0 -0
  18. {atomscale-0.7.0 → atomscale-0.7.2}/docs/_templates/custom-class-template.rst +0 -0
  19. {atomscale-0.7.0 → atomscale-0.7.2}/docs/_templates/custom-module-template.rst +0 -0
  20. {atomscale-0.7.0 → atomscale-0.7.2}/docs/conf.py +0 -0
  21. {atomscale-0.7.0 → atomscale-0.7.2}/docs/guides/index.rst +0 -0
  22. {atomscale-0.7.0 → atomscale-0.7.2}/docs/guides/inspect-results.rst +0 -0
  23. {atomscale-0.7.0 → atomscale-0.7.2}/docs/guides/poll-timeseries.rst +0 -0
  24. {atomscale-0.7.0 → atomscale-0.7.2}/docs/guides/quickstart.rst +0 -0
  25. {atomscale-0.7.0 → atomscale-0.7.2}/docs/guides/search-data.rst +0 -0
  26. {atomscale-0.7.0 → atomscale-0.7.2}/docs/guides/stream-rheed.rst +0 -0
  27. {atomscale-0.7.0 → atomscale-0.7.2}/docs/guides/upload-data.rst +0 -0
  28. {atomscale-0.7.0 → atomscale-0.7.2}/docs/make.bat +0 -0
  29. {atomscale-0.7.0 → atomscale-0.7.2}/docs/modules.rst +0 -0
  30. {atomscale-0.7.0 → atomscale-0.7.2}/examples/general_use.ipynb +0 -0
  31. {atomscale-0.7.0 → atomscale-0.7.2}/examples/rheed_streaming.ipynb +0 -0
  32. {atomscale-0.7.0 → atomscale-0.7.2}/examples/timeseries_polling.ipynb +0 -0
  33. {atomscale-0.7.0 → atomscale-0.7.2}/examples/vxwse2-placeholder/task1_films.ipynb +0 -0
  34. {atomscale-0.7.0 → atomscale-0.7.2}/examples/vxwse2-placeholder/task1_sapphire.ipynb +0 -0
  35. {atomscale-0.7.0 → atomscale-0.7.2}/examples/vxwse2-placeholder/task2_composition.ipynb +0 -0
  36. {atomscale-0.7.0 → atomscale-0.7.2}/pyproject.toml +0 -0
  37. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-macos-latest_py3.10.txt +0 -0
  38. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-macos-latest_py3.10_extras.txt +0 -0
  39. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-macos-latest_py3.11.txt +0 -0
  40. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-macos-latest_py3.11_extras.txt +0 -0
  41. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-macos-latest_py3.12.txt +0 -0
  42. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-macos-latest_py3.12_extras.txt +0 -0
  43. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-macos-latest_py3.9.txt +0 -0
  44. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-macos-latest_py3.9_extras.txt +0 -0
  45. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-ubuntu-latest_py3.10.txt +0 -0
  46. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-ubuntu-latest_py3.10_extras.txt +0 -0
  47. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-ubuntu-latest_py3.11.txt +0 -0
  48. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-ubuntu-latest_py3.11_extras.txt +0 -0
  49. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-ubuntu-latest_py3.12.txt +0 -0
  50. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-ubuntu-latest_py3.12_extras.txt +0 -0
  51. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-ubuntu-latest_py3.9.txt +0 -0
  52. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-ubuntu-latest_py3.9_extras.txt +0 -0
  53. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-windows-latest_py3.10.txt +0 -0
  54. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-windows-latest_py3.10_extras.txt +0 -0
  55. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-windows-latest_py3.11.txt +0 -0
  56. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-windows-latest_py3.11_extras.txt +0 -0
  57. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-windows-latest_py3.12.txt +0 -0
  58. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-windows-latest_py3.12_extras.txt +0 -0
  59. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-windows-latest_py3.9.txt +0 -0
  60. {atomscale-0.7.0 → atomscale-0.7.2}/requirements/requirements-windows-latest_py3.9_extras.txt +0 -0
  61. {atomscale-0.7.0 → atomscale-0.7.2}/setup.cfg +0 -0
  62. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomicds/__init__.py +0 -0
  63. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/__init__.py +0 -0
  64. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/core/__init__.py +0 -0
  65. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/core/client.py +0 -0
  66. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/core/files.py +0 -0
  67. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/core/utils.py +0 -0
  68. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/__init__.py +0 -0
  69. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/group.py +0 -0
  70. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/metrology.py +0 -0
  71. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/optical.py +0 -0
  72. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/photoluminescence.py +0 -0
  73. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/raman.py +0 -0
  74. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/rheed_image.py +0 -0
  75. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/rheed_video.py +0 -0
  76. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/unknown.py +0 -0
  77. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/results/xps.py +0 -0
  78. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/streaming/Cargo.lock +0 -0
  79. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/streaming/Cargo.toml +0 -0
  80. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/streaming/__init__.py +0 -0
  81. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/streaming/rheed_stream.pyi +0 -0
  82. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/streaming/src/initialize.rs +0 -0
  83. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/streaming/src/lib.rs +0 -0
  84. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/streaming/src/upload.rs +0 -0
  85. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/streaming/src/utils.rs +0 -0
  86. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/timeseries/__init__.py +0 -0
  87. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/timeseries/metrology.py +0 -0
  88. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/timeseries/optical.py +0 -0
  89. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/timeseries/polling.py +0 -0
  90. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/timeseries/provider.py +0 -0
  91. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/timeseries/registry.py +0 -0
  92. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/timeseries/rheed.py +0 -0
  93. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale/timeseries/sample.py +0 -0
  94. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale.egg-info/SOURCES.txt +0 -0
  95. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale.egg-info/dependency_links.txt +0 -0
  96. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale.egg-info/requires.txt +0 -0
  97. {atomscale-0.7.0 → atomscale-0.7.2}/src/atomscale.egg-info/top_level.txt +0 -0
  98. {atomscale-0.7.0 → atomscale-0.7.2}/tests/__init__.py +0 -0
  99. {atomscale-0.7.0 → atomscale-0.7.2}/tests/conftest.py +0 -0
  100. {atomscale-0.7.0 → atomscale-0.7.2}/tests/data/test_rheed.mp4 +0 -0
  101. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_atomicds_alias.py +0 -0
  102. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_client.py +0 -0
  103. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_core.py +0 -0
  104. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_metrology.py +0 -0
  105. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_optical.py +0 -0
  106. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_photoluminescence.py +0 -0
  107. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_polling.py +0 -0
  108. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_raman.py +0 -0
  109. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_rheed_image.py +0 -0
  110. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_rheed_video.py +0 -0
  111. {atomscale-0.7.0 → atomscale-0.7.2}/tests/test_xps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atomscale
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Python SDK for Atomscale.
5
5
  Author-email: Atomscale <info@atomscale.ai>
6
6
  License: GPL-3.0-only
@@ -1,4 +1,4 @@
1
- atomscale API Client
1
+ Atomscale Python SDK
2
2
  ===================
3
3
 
4
4
  .. |testing-badge| image:: https://github.com/atomscale-ai/sdk/workflows/Testing/badge.svg
@@ -42,9 +42,9 @@ Installation
42
42
 
43
43
  pip install atomscale
44
44
 
45
- Set the ``AS_API_KEY`` and (optional) ``AS_API_ENDPOINT`` environment variables
46
- before creating a :class:`~atomscale.client.Client`, or pass them directly when
47
- constructing the client in your scripts.
45
+ Much of the functionality of the package requires an API key. It can be provided using
46
+ the ``AS_API_KEY`` environment variable before creating a :class:`~atomscale.client.Client`,
47
+ or passed in directly when constructing the client in your code.
48
48
 
49
49
  .. note::
50
50
 
@@ -434,7 +434,6 @@ class Client(BaseClient):
434
434
  *,
435
435
  include_organization_data: bool = True,
436
436
  align: bool | str = False,
437
- resample: str | None = None,
438
437
  ) -> PhysicalSampleResult:
439
438
  """Get all data for a physical sample.
440
439
 
@@ -442,7 +441,6 @@ class Client(BaseClient):
442
441
  physical_sample_id: Identifier of the physical sample.
443
442
  include_organization_data: Whether to include organization data. Defaults to True.
444
443
  align: Whether to align timeseries data. If truthy, an aligned DataFrame is returned.
445
- resample: Optional pandas resample rule applied after alignment.
446
444
  """
447
445
  physical_samples: list[dict] | None = self._get( # type: ignore # noqa: PGH003
448
446
  sub_url="physical_samples/",
@@ -468,11 +466,7 @@ class Client(BaseClient):
468
466
  if isinstance(align, str):
469
467
  join_how = align
470
468
 
471
- ts_aligned = (
472
- align_timeseries(results, how=join_how, resample=resample)
473
- if align
474
- else None
475
- )
469
+ ts_aligned = align_timeseries(results, how=join_how) if align else None
476
470
 
477
471
  non_timeseries = [
478
472
  r
@@ -501,7 +495,6 @@ class Client(BaseClient):
501
495
  *,
502
496
  include_organization_data: bool = True,
503
497
  align: bool | str = False,
504
- resample: str | None = None,
505
498
  ) -> ProjectResult:
506
499
  """Get all data grouped by physical sample for a project.
507
500
 
@@ -509,7 +502,6 @@ class Client(BaseClient):
509
502
  project_id: Identifier of the project.
510
503
  include_organization_data: Whether to include organization data. Defaults to True.
511
504
  align: Whether to align timeseries at the project level. Defaults to False.
512
- resample: Optional pandas resample rule applied after alignment.
513
505
  """
514
506
  # Get physical samples associated with the project, then fetch data per sample.
515
507
  project_samples: list[dict] = (
@@ -519,39 +511,27 @@ class Client(BaseClient):
519
511
  return ProjectResult(project_id, None, [], None)
520
512
 
521
513
  sample_results: list[PhysicalSampleResult] = []
514
+ all_results: list = []
522
515
  for sample in project_samples:
523
516
  sid = sample.get("id")
524
517
  if not sid:
525
518
  continue
519
+ # For project-level alignment we align once across all entries, so
520
+ # skip per-sample alignment when align=True.
521
+ sample_align = False if align else align
526
522
  sample_results.append(
527
523
  self.get_physical_sample(
528
524
  sid,
529
525
  include_organization_data=include_organization_data,
530
- align=align,
531
- resample=resample,
526
+ align=sample_align,
532
527
  )
533
528
  )
529
+ if sample_results[-1].data_results:
530
+ all_results.extend(sample_results[-1].data_results)
534
531
 
535
532
  project_aligned = None
536
533
  if align:
537
- frames = []
538
- for sample in sample_results:
539
- if sample.aligned_timeseries is None:
540
- continue
541
- renamed = sample.aligned_timeseries.copy()
542
- renamed.columns = pd.MultiIndex.from_tuples(
543
- [
544
- (sample.physical_sample_id, *tuple(col))
545
- if isinstance(col, tuple)
546
- else (sample.physical_sample_id, col)
547
- for col in renamed.columns
548
- ]
549
- )
550
- frames.append(renamed)
551
- if frames:
552
- project_aligned = frames[0]
553
- for frame in frames[1:]:
554
- project_aligned = project_aligned.join(frame, how="outer")
534
+ project_aligned = align_timeseries(all_results, how="outer")
555
535
 
556
536
  project_name = None
557
537
  return ProjectResult(
@@ -654,7 +634,13 @@ class Client(BaseClient):
654
634
  raw = provider.fetch_raw(self, data_id)
655
635
  ts_df = provider.to_dataframe(raw)
656
636
 
657
- return provider.build_result(self, data_id, data_type, ts_df)
637
+ result_obj = provider.build_result(self, data_id, data_type, ts_df)
638
+ if catalogue_entry:
639
+ # Store upload datetime for alignment fallback when only relative time is available.
640
+ upload_dt = catalogue_entry.get("upload_datetime")
641
+ if upload_dt:
642
+ result_obj.upload_datetime = upload_dt
643
+ return result_obj
658
644
 
659
645
  # Fallback for unknown/unsupported data types
660
646
  return UnknownResult(
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ import pandas as pd
6
+
7
+ from atomscale.results import MetrologyResult, OpticalResult, RHEEDVideoResult
8
+
9
+ ABS_TIME_COLS = (
10
+ "UNIX Timestamp",
11
+ "Unix Timestamp",
12
+ "unix_timestamp_ms",
13
+ "unix_timestamp",
14
+ "timestamp_ms",
15
+ "timestamp_seconds",
16
+ "timestamp",
17
+ )
18
+
19
+ REL_TIME_COLS = (
20
+ "time_seconds",
21
+ "relative_time_seconds",
22
+ "Relative Time",
23
+ "Time",
24
+ )
25
+
26
+
27
+ def _infer_absolute_time(df: pd.DataFrame) -> pd.Series | None:
28
+ """Return UTC datetime index from absolute (epoch-based) columns."""
29
+ for col in ABS_TIME_COLS:
30
+ if col not in df.columns:
31
+ continue
32
+ series = df[col]
33
+
34
+ # Accept numeric-looking strings as well as numeric dtypes
35
+ numeric_series = pd.to_numeric(series, errors="coerce")
36
+ has_numeric = numeric_series.notna().any()
37
+
38
+ target = numeric_series if has_numeric else series
39
+
40
+ if pd.api.types.is_integer_dtype(target) or has_numeric:
41
+ max_val = target.max(skipna=True)
42
+ if max_val > 1e18:
43
+ unit = "ns"
44
+ elif max_val > 1e15:
45
+ unit = "us"
46
+ elif max_val > 1e12:
47
+ unit = "ms"
48
+ else:
49
+ unit = "s"
50
+ return pd.to_datetime(target, unit=unit, errors="coerce", utc=True)
51
+
52
+ if pd.api.types.is_float_dtype(target):
53
+ return pd.to_datetime(target, unit="s", errors="coerce", utc=True)
54
+
55
+ # assume already datetime-like strings
56
+ return pd.to_datetime(target, errors="coerce", utc=True)
57
+ return None
58
+
59
+
60
+ def _infer_relative_time(df: pd.DataFrame) -> pd.Series | None:
61
+ """Return timedelta series from relative time columns (seconds)."""
62
+ for col in REL_TIME_COLS:
63
+ if col not in df.columns:
64
+ continue
65
+ series = df[col]
66
+ return pd.to_timedelta(series, unit="s", errors="coerce")
67
+ return None
68
+
69
+
70
+ def _extract_timeseries(result):
71
+ """Return (data_id, domain, df_with_timeindex) or None for non-timeseries."""
72
+ if isinstance(result, RHEEDVideoResult):
73
+ domain = "rheed"
74
+ df = result.timeseries_data
75
+ elif isinstance(result, OpticalResult):
76
+ domain = "optical"
77
+ df = result.timeseries_data
78
+ elif isinstance(result, MetrologyResult):
79
+ domain = "metrology"
80
+ df = result.timeseries_data
81
+ else:
82
+ return None
83
+
84
+ if df is None or df.empty:
85
+ return None
86
+
87
+ # Build time index: prefer absolute epochs; fall back to upload_datetime + relative offsets.
88
+ upload_dt = getattr(result, "upload_datetime", None)
89
+
90
+ time_index = _infer_absolute_time(df)
91
+ if time_index is None and upload_dt is not None:
92
+ base = pd.to_datetime(upload_dt, utc=True, errors="coerce")
93
+ rel = _infer_relative_time(df)
94
+ if base is not pd.NaT and rel is not None:
95
+ time_index = base + rel
96
+
97
+ if time_index is None:
98
+ return None
99
+
100
+ valid_mask = time_index.notna()
101
+ if not valid_mask.any():
102
+ return None
103
+
104
+ indexed = df.loc[valid_mask].copy(deep=False)
105
+ indexed.index = pd.Index(time_index[valid_mask], name="time")
106
+ indexed = indexed.sort_index()
107
+
108
+ if not indexed.index.is_unique:
109
+ indexed = indexed[~indexed.index.duplicated(keep="first")]
110
+
111
+ return str(result.data_id), domain, indexed
112
+
113
+
114
+ def _infer_resample_freq(indices: list[pd.DatetimeIndex]) -> pd.Timedelta | None:
115
+ """Infer a reasonable base frequency from multiple datetime indices."""
116
+ deltas: list[pd.Timedelta] = []
117
+ for idx in indices:
118
+ if idx.size < 2:
119
+ continue
120
+ # use numpy diff to avoid Series construction overhead
121
+ diffs = idx.view("int64")[1:] - idx.view("int64")[:-1]
122
+ if diffs.size:
123
+ median_ns = pd.Series(diffs).median() # nan-safe median
124
+ if pd.notna(median_ns) and median_ns > 0:
125
+ deltas.append(pd.Timedelta(median_ns, unit="ns"))
126
+
127
+ if not deltas:
128
+ return None
129
+
130
+ # Use the median of medians to avoid over-densifying to the smallest step
131
+ median_delta = pd.Series(deltas).median()
132
+ if median_delta <= pd.Timedelta(0):
133
+ return None
134
+
135
+ # Enforce a floor to avoid overly dense grids
136
+ floor = pd.Timedelta(seconds=0.5)
137
+ if median_delta < floor:
138
+ median_delta = floor
139
+
140
+ # Clamp frequency to keep total grid size reasonable (performance guard)
141
+ min_ts = min(idx[0] for idx in indices if len(idx))
142
+ max_ts = max(idx[-1] for idx in indices if len(idx))
143
+ span = max_ts - min_ts
144
+ if span <= pd.Timedelta(0):
145
+ return median_delta
146
+
147
+ est_points = span / median_delta
148
+ max_points = 1_000_000 # tighter cap for performance
149
+ if est_points > max_points:
150
+ median_delta = span / max_points
151
+
152
+ return median_delta
153
+
154
+
155
+ def align_timeseries(
156
+ results: Iterable,
157
+ *,
158
+ how: str = "outer",
159
+ ) -> pd.DataFrame | None:
160
+ """Align timeseries results by time index.
161
+
162
+ Args:
163
+ results: Iterable of result objects (RHEEDVideoResult, OpticalResult, MetrologyResult).
164
+ how: Join strategy for the outer alignment. Defaults to "outer".
165
+
166
+ Returns:
167
+ DataFrame | None: Aligned DataFrame with MultiIndex columns (data_id, domain, metric).
168
+ """
169
+ frames: list[pd.DataFrame] = []
170
+ indices: list[pd.DatetimeIndex] = []
171
+ for item in results:
172
+ extracted = _extract_timeseries(item)
173
+ if not extracted:
174
+ continue
175
+
176
+ data_id, domain, df = extracted
177
+ df = df.copy(deep=False)
178
+ df.columns = pd.MultiIndex.from_product([[data_id], [domain], df.columns])
179
+ frames.append(df)
180
+ indices.append(df.index)
181
+
182
+ if not frames:
183
+ return pd.DataFrame()
184
+
185
+ join_how = how if how in {"outer", "inner"} else "outer"
186
+ aligned = pd.concat(frames, axis=1, join=join_how, copy=False, sort=False)
187
+ aligned = aligned.sort_index()
188
+
189
+ if how == "left":
190
+ aligned = aligned.reindex(frames[0].index)
191
+ elif how == "right":
192
+ aligned = aligned.reindex(frames[-1].index)
193
+
194
+ freq = _infer_resample_freq(indices)
195
+ if freq:
196
+ aligned = aligned.resample(freq).mean(numeric_only=True)
197
+
198
+ # Drop raw time columns now that the index is the aligned time base.
199
+ if isinstance(aligned.columns, pd.MultiIndex):
200
+ time_metrics = {
201
+ "Time",
202
+ "UNIX Timestamp",
203
+ "Relative Time",
204
+ "timestamp",
205
+ "timestamp_ms",
206
+ "timestamp_seconds",
207
+ }
208
+ aligned = aligned.loc[
209
+ :, [c for c in aligned.columns if c[-1] not in time_metrics]
210
+ ]
211
+
212
+ # Merge compatible metrics across items: if multiple columns share (domain, metric)
213
+ # and never conflict where they overlap, collapse into (shared, domain, metric).
214
+ def _merge_compatible_metrics(df: pd.DataFrame) -> pd.DataFrame:
215
+ if not isinstance(df.columns, pd.MultiIndex):
216
+ return df
217
+ domains = df.columns.get_level_values(1)
218
+ metrics = df.columns.get_level_values(2)
219
+ new_cols: dict = {}
220
+ drop_cols: list = []
221
+
222
+ for domain in domains.unique():
223
+ for metric in metrics.unique():
224
+ cols = [
225
+ c
226
+ for c in df.columns
227
+ if c[1] == domain and c[2] == metric and c[0] != "shared"
228
+ ]
229
+ if len(cols) <= 1:
230
+ continue
231
+
232
+ merged = df[cols[0]]
233
+ conflict = False
234
+ for c in cols[1:]:
235
+ other = df[c]
236
+ overlap_mask = merged.notna() & other.notna()
237
+ if (merged[overlap_mask] != other[overlap_mask]).any():
238
+ conflict = True
239
+ break
240
+ merged = merged.combine_first(other)
241
+
242
+ if conflict:
243
+ continue
244
+
245
+ new_col = ("shared", domain, metric)
246
+ new_cols[new_col] = merged
247
+ drop_cols.extend(cols)
248
+
249
+ if new_cols:
250
+ df = df.drop(columns=drop_cols)
251
+ for col, series in new_cols.items():
252
+ df[col] = series
253
+ df = df.sort_index(axis=1)
254
+ return df
255
+
256
+ return _merge_compatible_metrics(aligned)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atomscale
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Python SDK for Atomscale.
5
5
  Author-email: Atomscale <info@atomscale.ai>
6
6
  License: GPL-3.0-only
@@ -1,106 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from collections.abc import Iterable
4
- from functools import reduce
5
-
6
- import pandas as pd
7
-
8
- from atomscale.results import MetrologyResult, OpticalResult, RHEEDVideoResult
9
-
10
-
11
- def _infer_time_column(df: pd.DataFrame) -> pd.Series:
12
- """Return a datetime-like series suitable for alignment."""
13
- candidates = [
14
- "UNIX Timestamp",
15
- "unix_timestamp_ms",
16
- "timestamp_ms",
17
- "timestamp",
18
- "time_seconds",
19
- "relative_time_seconds",
20
- "Time",
21
- ]
22
- for col in candidates:
23
- if col in df.columns:
24
- series = df[col]
25
- # Heuristically pick unit for integer epoch values.
26
- if pd.api.types.is_integer_dtype(series):
27
- max_val = series.max(skipna=True)
28
- if max_val > 1e12:
29
- dt = pd.to_datetime(series, unit="ns", errors="coerce")
30
- elif max_val > 1e10:
31
- dt = pd.to_datetime(series, unit="ms", errors="coerce")
32
- else:
33
- dt = pd.to_datetime(series, unit="s", errors="coerce")
34
- elif pd.api.types.is_float_dtype(series):
35
- dt = pd.to_datetime(series, unit="s", errors="coerce")
36
- else:
37
- dt = pd.to_datetime(series, errors="coerce")
38
- return dt
39
- # Fallback to existing index if no candidate column is present.
40
- if df.index.nlevels == 1:
41
- idx = df.index
42
- if pd.api.types.is_datetime64_any_dtype(idx):
43
- return pd.Series(idx, index=df.index)
44
- # Final fallback: monotonically increasing integers.
45
- return pd.Series(range(len(df)), index=df.index).astype("int64")
46
-
47
-
48
- def _extract_timeseries(result):
49
- """Return (data_id, domain, df_with_timeindex) or None for non-timeseries."""
50
- if isinstance(result, RHEEDVideoResult):
51
- domain = "rheed"
52
- df = result.timeseries_data
53
- elif isinstance(result, OpticalResult):
54
- domain = "optical"
55
- df = result.timeseries_data
56
- elif isinstance(result, MetrologyResult):
57
- domain = "metrology"
58
- df = result.timeseries_data
59
- else:
60
- return None
61
-
62
- if df is None or df.empty:
63
- return None
64
-
65
- flat_df = df.copy().reset_index()
66
- flat_df["__time__"] = _infer_time_column(flat_df)
67
- flat_df = flat_df.set_index("__time__")
68
- flat_df.index.name = "time"
69
- flat_df = flat_df.sort_index()
70
-
71
- return str(result.data_id), domain, flat_df
72
-
73
-
74
- def align_timeseries(
75
- results: Iterable,
76
- *,
77
- how: str = "outer",
78
- resample: str | None = None,
79
- ) -> pd.DataFrame | None:
80
- """Align timeseries results by time index.
81
-
82
- Args:
83
- results: Iterable of result objects (RHEEDVideoResult, OpticalResult, MetrologyResult).
84
- how: Join strategy for the outer alignment. Defaults to "outer".
85
- resample: Optional pandas resample rule (e.g., "1s").
86
-
87
- Returns:
88
- DataFrame | None: Aligned DataFrame with MultiIndex columns (data_id, domain, metric).
89
- """
90
- frames = []
91
- for item in results:
92
- extracted = _extract_timeseries(item)
93
- if not extracted:
94
- continue
95
- data_id, domain, df = extracted
96
- df = df.copy()
97
- df.columns = pd.MultiIndex.from_product([[data_id], [domain], df.columns])
98
- frames.append(df)
99
-
100
- if not frames:
101
- return None
102
-
103
- aligned = reduce(lambda a, b: a.join(b, how=how), frames)
104
- if resample:
105
- aligned = aligned.resample(resample).mean()
106
- return aligned
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes