opensampl 1.2.2__tar.gz → 1.2.3__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 (100) hide show
  1. {opensampl-1.2.2 → opensampl-1.2.3}/PKG-INFO +2 -2
  2. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/cli.py +9 -0
  3. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/config/server.py +1 -1
  4. opensampl-1.2.3/opensampl/helpers/convert_value.py +253 -0
  5. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/load_data.py +1 -1
  6. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/backend/main.py +4 -1
  7. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/docker-compose.dev.yaml +19 -3
  8. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/docker-compose.yaml +17 -3
  9. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/Dockerfile +1 -1
  10. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/env.py +5 -1
  11. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2026_09_11_1445_update_probe_data_types.py +0 -12
  12. {opensampl-1.2.2 → opensampl-1.2.3}/pyproject.toml +2 -2
  13. {opensampl-1.2.2 → opensampl-1.2.3}/.gitignore +0 -0
  14. {opensampl-1.2.2 → opensampl-1.2.3}/LICENSE +0 -0
  15. {opensampl-1.2.2 → opensampl-1.2.3}/README.md +0 -0
  16. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/__init__.py +0 -0
  17. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/__init__.py +0 -0
  18. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/cli.py +0 -0
  19. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/microchip/__init__.py +0 -0
  20. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/microchip/tp4100/__init__.py +0 -0
  21. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/microchip/tp4100/collect_4100.py +0 -0
  22. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/microchip/twst/__init__.py +0 -0
  23. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/microchip/twst/context.py +0 -0
  24. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/microchip/twst/generate_twst_files.py +0 -0
  25. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/microchip/twst/readings.py +0 -0
  26. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/collect/modem.py +0 -0
  27. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/config/__init__.py +0 -0
  28. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/config/base.py +0 -0
  29. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/config/tp4100.py +0 -0
  30. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/__init__.py +0 -0
  31. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/create_vendor.py +0 -0
  32. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/insert_markers.py +0 -0
  33. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/templates/collect_mixin_template.txt +0 -0
  34. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/templates/compound_reference_template.txt +0 -0
  35. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/templates/metric_template.txt +0 -0
  36. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/templates/orm_metadata.txt +0 -0
  37. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/templates/parser_template.txt +0 -0
  38. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/templates/reference_template.txt +0 -0
  39. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/create/templates/vendor_template.txt +0 -0
  40. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/db/__init__.py +0 -0
  41. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/db/access_orm.py +0 -0
  42. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/db/orm.py +0 -0
  43. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/helpers/__init__.py +0 -0
  44. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/helpers/geolocator.py +0 -0
  45. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/load/__init__.py +0 -0
  46. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/load/data.py +0 -0
  47. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/load/routing.py +0 -0
  48. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/load/table_factory.py +0 -0
  49. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/metrics.py +0 -0
  50. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/mixins/__init__.py +0 -0
  51. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/mixins/collect.py +0 -0
  52. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/mixins/random_data.py +0 -0
  53. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/references.py +0 -0
  54. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/__init__.py +0 -0
  55. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/backend/Dockerfile +0 -0
  56. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/backend/__init__.py +0 -0
  57. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/cli.py +0 -0
  58. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/cli2.py +0 -0
  59. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/default.env +0 -0
  60. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/Dockerfile +0 -0
  61. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/grafana-dashboards/ntp_dash.json +0 -0
  62. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/grafana-dashboards/public-timing-dashboard.json +0 -0
  63. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/grafana-dashboards/public_twst_dash.json +0 -0
  64. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/grafana-dashboards/timing-dashboard2.json +0 -0
  65. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/grafana-dashboards/twst_dash.json +0 -0
  66. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/grafana-provisioning/dashboards/dashboard.yaml +0 -0
  67. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/grafana-provisioning/datasources/postgresql.yaml +0 -0
  68. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/grafana/grafana.env +0 -0
  69. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/README +0 -0
  70. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/script.py.mako +0 -0
  71. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2024_03_26_1145_create_schema_initialize_orm.py +0 -0
  72. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2024_12_04_1155_update_db_tables.py +0 -0
  73. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_01_28_2212_create_time_buckets.py +0 -0
  74. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_01_29_0909_updating_location_and_test_tables.py +0 -0
  75. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_03_05_0958_add_grafana_user_access.py +0 -0
  76. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_03_26_0743_create_campus_view.py +0 -0
  77. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_04_14_1231_update_retention_policy.py +0 -0
  78. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_04_22_1531_add_access_tokens.py +0 -0
  79. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_04_22_1650_turn_off_matviews.py +0 -0
  80. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_06_03_1223_add_column_comments.py +0 -0
  81. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_06_03_1235_create_reference_and_metric_tables.py +0 -0
  82. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_06_03_1254_update_probe_data_to_take_reference_and_.py +0 -0
  83. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_06_03_1318_adding_freeform_metadata_to_adva.py +0 -0
  84. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_06_03_1358_setting_default_reference_and_metric.py +0 -0
  85. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_06_03_1539_making_default_trigger_functions.py +0 -0
  86. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_06_23_0825_data_filtering_functions.py +0 -0
  87. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_06_23_1654_making_microsemi_table.py +0 -0
  88. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_08_15_0840_create_microchip_tp400_metadata.py +0 -0
  89. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2025_09_22_0915_campus_view_not_forced.py +0 -0
  90. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2026_04_17_1243_add_ntp_values.py +0 -0
  91. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/_migrations/versions/2026_04_17_1254_add_reference_view.py +0 -0
  92. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/server/migrations/alembic.ini +0 -0
  93. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/vendors/__init__.py +0 -0
  94. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/vendors/adva.py +0 -0
  95. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/vendors/base_probe.py +0 -0
  96. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/vendors/constants.py +0 -0
  97. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/vendors/microchip/__init__.py +0 -0
  98. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/vendors/microchip/tp4100.py +0 -0
  99. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/vendors/microchip/twst.py +0 -0
  100. {opensampl-1.2.2 → opensampl-1.2.3}/opensampl/vendors/ntp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opensampl
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Python tools for adding clock data to a timescale db.
5
5
  Project-URL: Homepage, https://github.com/ORNL/OpenSAMPL
6
6
  Project-URL: Repository, https://github.com/ORNL/OpenSAMPL
@@ -60,7 +60,7 @@ Requires-Python: <3.15,>=3.10
60
60
  Requires-Dist: allantools
61
61
  Requires-Dist: astor
62
62
  Requires-Dist: click<9,>=8.0.0
63
- Requires-Dist: geoalchemy2==0.16.0
63
+ Requires-Dist: geoalchemy2<1,>=0.18.0
64
64
  Requires-Dist: jinja2>=3.1.6
65
65
  Requires-Dist: libcst
66
66
  Requires-Dist: loguru<0.8,>=0.7.0
@@ -18,6 +18,7 @@ from loguru import logger
18
18
 
19
19
  from opensampl.config.base import BaseConfig as CLIConfig
20
20
  from opensampl.db.orm import get_table_names
21
+ from opensampl.helpers.convert_value import value_backfill
21
22
  from opensampl.load_data import create_new_tables, write_to_table
22
23
  from opensampl.mixins.collect import CollectMixin
23
24
  from opensampl.mixins.random_data import RandomDataMixin
@@ -176,6 +177,14 @@ def config_set(ctx: click.Context, name: str, value: str):
176
177
  conf.set_by_name(name=name, value=value)
177
178
 
178
179
 
180
+ @cli.group(cls=CaseInsensitiveGroup)
181
+ def maintenance():
182
+ """Run direct-database maintenance operations."""
183
+
184
+
185
+ maintenance.add_command(value_backfill)
186
+
187
+
179
188
  @cli.group(cls=CaseInsensitiveGroup)
180
189
  def load():
181
190
  """Load data into database"""
@@ -133,5 +133,5 @@ class ServerConfig(BaseConfig):
133
133
  password = self.docker_env_values.get("POSTGRES_PASSWORD")
134
134
  db = self.docker_env_values.get("POSTGRES_DB")
135
135
  if all(x is not None for x in [user, password, db]):
136
- return f"postgresql://{user}:{password}@localhost:5415/{db}"
136
+ return f"postgresql+psycopg2://{user}:{password}@localhost:5415/{db}"
137
137
  raise ValueError("Database environment variables POSTGRES_USER, POSTGRES_PASSWORD, or POSTGRES_DB are not set.")
@@ -0,0 +1,253 @@
1
+ """Backfill numeric probe values after the probe-data type migration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from datetime import datetime, timedelta
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import click
12
+ from loguru import logger
13
+ from sqlalchemy import create_engine, text
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable, Iterable
17
+
18
+ from sqlalchemy import Engine
19
+
20
+ DEFAULT_BATCH_SIZE = "1d"
21
+ _DURATION_PATTERN = re.compile(r"^(?P<value>(?:\d+(?:\.\d*)?|\.\d+))(?P<unit>[mhdw])$", re.IGNORECASE)
22
+ _DURATION_SECONDS = {
23
+ "m": 60,
24
+ "h": 60 * 60,
25
+ "d": 24 * 60 * 60,
26
+ "w": 7 * 24 * 60 * 60,
27
+ }
28
+
29
+ METRIC_TYPES_SQL = text("""
30
+ SELECT uuid
31
+ FROM castdb.metric_type
32
+ WHERE value_type IN ('float', 'int')
33
+ """)
34
+
35
+ SCHEMA_COLUMNS_SQL = text("""
36
+ SELECT column_name
37
+ FROM information_schema.columns
38
+ WHERE table_schema = 'castdb'
39
+ AND table_name = 'probe_data'
40
+ AND column_name IN ('value_jsonb', 'value_float')
41
+ """)
42
+
43
+ RANGE_SQL = text("""
44
+ SELECT
45
+ min(pd.time) AS start_time,
46
+ max(pd.time) AS end_time
47
+ FROM castdb.probe_data pd
48
+ WHERE pd.metric_type_uuid = ANY(:metric_type_uuids)
49
+ AND pd.value_float IS NULL
50
+ AND pd.value_jsonb IS NOT NULL
51
+ """)
52
+
53
+ UPDATE_SQL = text("""
54
+ UPDATE castdb.probe_data
55
+ SET value_float = (value_jsonb #>> '{}')::double precision
56
+ WHERE metric_type_uuid = ANY(:metric_type_uuids)
57
+ AND value_float IS NULL
58
+ AND value_jsonb IS NOT NULL
59
+ AND time >= :start_time
60
+ AND time < :end_time
61
+ """)
62
+
63
+
64
+ def parse_duration(value: str) -> timedelta:
65
+ """Parse a positive duration expressed in minutes, hours, days, or weeks."""
66
+ match = _DURATION_PATTERN.fullmatch(value.strip())
67
+ if match is None:
68
+ raise click.BadParameter("must be a positive duration such as 30m, 12h, 1d, or 2w")
69
+
70
+ seconds = float(match.group("value")) * _DURATION_SECONDS[match.group("unit").lower()]
71
+ if seconds <= 0:
72
+ raise click.BadParameter("must be greater than zero")
73
+ return timedelta(seconds=seconds)
74
+
75
+
76
+ def resolve_workers(workers: int | None) -> int:
77
+ """Resolve worker count from the option, environment, or local CPU count."""
78
+ if workers is not None:
79
+ return workers
80
+
81
+ env_workers = os.getenv("WORKERS")
82
+ if env_workers is not None:
83
+ try:
84
+ resolved = int(env_workers)
85
+ except ValueError as exc:
86
+ raise click.BadParameter("WORKERS must be a positive integer", param_hint="--workers") from exc
87
+ if resolved <= 0:
88
+ raise click.BadParameter("WORKERS must be a positive integer", param_hint="--workers")
89
+ return resolved
90
+
91
+ return os.cpu_count() or 4
92
+
93
+
94
+ def build_windows(
95
+ start_time: datetime,
96
+ end_time: datetime,
97
+ batch_size: timedelta,
98
+ ) -> list[tuple[datetime, datetime]]:
99
+ """Build newest-first half-open time windows that include the final row."""
100
+ end_exclusive = end_time + timedelta(microseconds=1)
101
+ windows: list[tuple[datetime, datetime]] = []
102
+ current_start = start_time
103
+ while current_start < end_exclusive:
104
+ current_end = min(current_start + batch_size, end_exclusive)
105
+ windows.append((current_start, current_end))
106
+ current_start = current_end
107
+ windows.reverse()
108
+ return windows
109
+
110
+
111
+ def _validate_schema(engine: Engine) -> None:
112
+ """Ensure the value-type migration has created both required columns."""
113
+ with engine.connect() as conn:
114
+ columns = set(conn.execute(SCHEMA_COLUMNS_SQL).scalars().all())
115
+ missing = {"value_jsonb", "value_float"} - columns
116
+ if missing:
117
+ missing_names = ", ".join(sorted(missing))
118
+ raise RuntimeError(
119
+ f"The probe-data type migration has not been applied; missing castdb.probe_data column(s): {missing_names}"
120
+ )
121
+
122
+
123
+ def _iter_waves(
124
+ windows: list[tuple[datetime, datetime]],
125
+ vacuum_every_batches: int,
126
+ ) -> Iterable[list[tuple[datetime, datetime]]]:
127
+ """Split windows at vacuum boundaries, or return one wave when disabled."""
128
+ wave_size = vacuum_every_batches or len(windows)
129
+ for index in range(0, len(windows), wave_size):
130
+ yield windows[index : index + wave_size]
131
+
132
+
133
+ def run_backfill(
134
+ database_url: str,
135
+ workers: int,
136
+ batch_size: timedelta,
137
+ vacuum_every_batches: int,
138
+ *,
139
+ engine_factory: Callable[..., Engine] | None = None,
140
+ ) -> int:
141
+ """Backfill numeric values and return the number of rows updated."""
142
+ factory = engine_factory or create_engine
143
+ engine = factory(database_url, pool_size=workers + 1)
144
+ logger.info("Using database: {}", engine.url)
145
+ logger.info("Workers: {}", workers)
146
+
147
+ try:
148
+ _validate_schema(engine)
149
+ with engine.connect() as conn:
150
+ metric_type_uuids = conn.execute(METRIC_TYPES_SQL).scalars().all()
151
+
152
+ if not metric_type_uuids:
153
+ logger.info("No float/int metric types found; nothing to map.")
154
+ return 0
155
+
156
+ with engine.connect() as conn:
157
+ time_range = conn.execute(
158
+ RANGE_SQL,
159
+ {"metric_type_uuids": metric_type_uuids},
160
+ ).one()
161
+
162
+ if time_range.start_time is None or time_range.end_time is None:
163
+ logger.info("No values require backfilling.")
164
+ return 0
165
+
166
+ windows = build_windows(time_range.start_time, time_range.end_time, batch_size)
167
+ logger.info("Backfilling values from {} through {}", time_range.start_time, time_range.end_time)
168
+
169
+ def run_batch(window: tuple[datetime, datetime]) -> int:
170
+ batch_start, batch_end = window
171
+ with engine.begin() as conn:
172
+ conn.execute(text("SET LOCAL synchronous_commit = off"))
173
+ result = conn.execute(
174
+ UPDATE_SQL,
175
+ {
176
+ "metric_type_uuids": metric_type_uuids,
177
+ "start_time": batch_start,
178
+ "end_time": batch_end,
179
+ },
180
+ )
181
+ logger.info("{} -> {}: committed {:,} rows", batch_start, batch_end, result.rowcount)
182
+ return result.rowcount
183
+
184
+ total_updated = 0
185
+ vacuum_engine = engine.execution_options(isolation_level="AUTOCOMMIT")
186
+ with ThreadPoolExecutor(max_workers=workers) as pool:
187
+ for wave in _iter_waves(windows, vacuum_every_batches):
188
+ total_updated += sum(pool.map(run_batch, wave))
189
+ logger.info("Vacuuming probe_data...")
190
+ with vacuum_engine.connect() as conn:
191
+ conn.execute(text("VACUUM castdb.probe_data"))
192
+
193
+ logger.info("Value backfill complete; updated {:,} rows.", total_updated)
194
+ return total_updated
195
+ finally:
196
+ engine.dispose()
197
+
198
+
199
+ @click.command("value-backfill")
200
+ @click.option(
201
+ "--workers",
202
+ type=click.IntRange(min=1),
203
+ default=None,
204
+ help="Parallel database workers. Defaults to WORKERS or the local CPU count.",
205
+ )
206
+ @click.option(
207
+ "--batch-size",
208
+ type=str,
209
+ default=DEFAULT_BATCH_SIZE,
210
+ show_default=True,
211
+ metavar="DURATION",
212
+ help="Time span per batch, using m, h, d, or w units.",
213
+ )
214
+ @click.option(
215
+ "--vacuum-every-batches",
216
+ type=click.IntRange(min=0),
217
+ default=None,
218
+ metavar="INTEGER",
219
+ help="Vacuum after this many batches; defaults to workers * 2. Use 0 for only a final vacuum.",
220
+ )
221
+ @click.pass_obj
222
+ def value_backfill(
223
+ obj: dict[str, Any],
224
+ workers: int | None,
225
+ batch_size: str,
226
+ vacuum_every_batches: int | None,
227
+ ) -> None:
228
+ """Map numeric JSON values into the optimized floating-point column."""
229
+ config = obj["conf"]
230
+ if config.ROUTE_TO_BACKEND:
231
+ click.echo(
232
+ "Warning: value-backfill requires a direct database connection and cannot run when ROUTE_TO_BACKEND=true.",
233
+ err=True,
234
+ )
235
+ raise click.UsageError("Disable ROUTE_TO_BACKEND before running this command.")
236
+ if not config.DATABASE_URL:
237
+ raise click.UsageError("DATABASE_URL must be configured to run value-backfill.")
238
+
239
+ resolved_workers = resolve_workers(workers)
240
+ resolved_batch_size = parse_duration(batch_size)
241
+ resolved_vacuum_cadence = resolved_workers * 2 if vacuum_every_batches is None else vacuum_every_batches
242
+
243
+ try:
244
+ run_backfill(
245
+ config.DATABASE_URL,
246
+ resolved_workers,
247
+ resolved_batch_size,
248
+ resolved_vacuum_cadence,
249
+ )
250
+ except click.ClickException:
251
+ raise
252
+ except Exception as exc:
253
+ raise click.ClickException(f"Value backfill failed: {exc}") from exc
@@ -142,7 +142,7 @@ def load_time_data(
142
142
  # Ensure correct time dtypes
143
143
  df["time"] = pd.to_datetime(df["time"], format="mixed", utc=True, errors="raise")
144
144
 
145
- if data_definition.metric.is_numeric():
145
+ if metric_type.is_numeric():
146
146
  df["value_float"] = pd.to_numeric(df["value"], errors="raise")
147
147
  df["value_jsonb"] = None
148
148
  else:
@@ -53,6 +53,9 @@ class ProbeMetadataPayload(BaseModel):
53
53
 
54
54
 
55
55
  DATABASE_URI = os.getenv("DATABASE_URL")
56
+ if DATABASE_URI and DATABASE_URI.startswith("postgresql://"):
57
+ DATABASE_URI = DATABASE_URI.replace("postgresql://", "postgresql+psycopg2://", 1)
58
+
56
59
  engine = create_engine(DATABASE_URI)
57
60
 
58
61
  loglevel = os.getenv("BACKEND_LOG_LEVEL", "INFO")
@@ -93,7 +96,7 @@ def get_keys():
93
96
  Session = sessionmaker(bind=engine) # noqa: N806
94
97
  with Session() as session:
95
98
  now = datetime.now(tz=UTC)
96
- stmt = select(APIAccessKey.key).where(or_(APIAccessKey.expires_at is None, APIAccessKey.expires_at > now))
99
+ stmt = select(APIAccessKey.key).where(or_(APIAccessKey.expires_at.is_(None), APIAccessKey.expires_at > now))
97
100
  result = session.execute(stmt)
98
101
  keys = [row[0] for row in result.all()]
99
102
  logger.debug("api access keys loaded from db")
@@ -44,11 +44,27 @@ services:
44
44
  context: migrations
45
45
  restart: "no"
46
46
  environment:
47
- - DB_URI=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
47
+ - DB_URI=postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
48
48
  depends_on:
49
49
  db:
50
50
  condition: service_healthy
51
51
 
52
+ value-backfill:
53
+ image: savannah.ornl.gov/opensampl/migrations:latest
54
+ build:
55
+ context: migrations
56
+ profiles: ["maintenance"]
57
+ restart: "no"
58
+ command: ["opensampl", "maintenance", "value-backfill"]
59
+ environment:
60
+ - DATABASE_URL=postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
61
+ - ROUTE_TO_BACKEND=false
62
+ depends_on:
63
+ db:
64
+ condition: service_healthy
65
+ migrations:
66
+ condition: service_completed_successfully
67
+
52
68
 
53
69
  backend:
54
70
  image: savannah.ornl.gov/opensampl/backend:latest
@@ -59,7 +75,7 @@ services:
59
75
  - "8015:8000"
60
76
  restart: unless-stopped
61
77
  environment:
62
- - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
78
+ - DATABASE_URL=postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
63
79
  - ROUTE_TO_BACKEND=false
64
80
  - BACKEND_LOG_LEVEL=${BACKEND_LOG_LEVEL}
65
81
  - USE_API_KEY=${USE_API_KEY}
@@ -72,4 +88,4 @@ services:
72
88
 
73
89
  volumes:
74
90
  castdb:
75
- grafana-data:
91
+ grafana-data:
@@ -40,13 +40,27 @@ services:
40
40
  context: ./migrations
41
41
  restart: "no"
42
42
  environment:
43
- - DB_URI=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
43
+ - DB_URI=postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
44
44
  - CHUNK_INTERVAL=${CHUNK_INTERVAL}
45
45
  - RETENTION_POLICY=${RETENTION_POLICY}
46
46
  depends_on:
47
47
  db:
48
48
  condition: service_healthy
49
49
 
50
+ value-backfill:
51
+ image: savannah.ornl.gov/opensampl/migrations:latest
52
+ profiles: ["maintenance"]
53
+ restart: "no"
54
+ command: ["opensampl", "maintenance", "value-backfill"]
55
+ environment:
56
+ - DATABASE_URL=postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
57
+ - ROUTE_TO_BACKEND=false
58
+ depends_on:
59
+ db:
60
+ condition: service_healthy
61
+ migrations:
62
+ condition: service_completed_successfully
63
+
50
64
  backend:
51
65
  image: savannah.ornl.gov/opensampl/backend:latest
52
66
  build:
@@ -56,7 +70,7 @@ services:
56
70
  - "8015:8000"
57
71
  restart: always
58
72
  environment:
59
- - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
73
+ - DATABASE_URL=postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
60
74
  - BACKEND_LOG_LEVEL=${BACKEND_LOG_LEVEL:-INFO}
61
75
  - USE_API_KEY=${USE_API_KEY:-false}
62
76
  - API_KEY=${API_KEY:-}
@@ -66,4 +80,4 @@ services:
66
80
 
67
81
  volumes:
68
82
  castdb:
69
- grafana-data:
83
+ grafana-data:
@@ -3,7 +3,7 @@ FROM python:3.12
3
3
  ARG OPENSAMPL_VERSION=1.1.5
4
4
 
5
5
  WORKDIR /
6
- RUN pip install --no-cache-dir "opensampl[migrations]==${OPENSAMPL_VERSION}" alembic
6
+ RUN pip install --no-cache-dir "opensampl[migrations]==${OPENSAMPL_VERSION}"
7
7
 
8
8
  RUN useradd -m alembic
9
9
  USER alembic
@@ -6,7 +6,11 @@ from sqlalchemy import pool
6
6
 
7
7
  from alembic import context
8
8
 
9
- sqlalchemy_url = os.environ.get('DB_URI')
9
+ db_uri = os.environ.get('DB_URI')
10
+ if db_uri and db_uri.startswith('postgresql://'):
11
+ db_uri = db_uri.replace('postgresql://', 'postgresql+psycopg2://', 1)
12
+
13
+ sqlalchemy_url = db_uri
10
14
  # this is the Alembic Config object, which provides
11
15
  # access to the values within the .ini file in use.
12
16
  config = context.config
@@ -30,18 +30,6 @@ def upgrade() -> None:
30
30
  sa.Column("value_float", sa.Float(), nullable=True),
31
31
  schema=SCHEMA)
32
32
 
33
- # Backfill value_float from the numeric jsonb values
34
- op.execute(
35
- """
36
- UPDATE castdb.probe_data pd
37
- SET value_float = (pd.value_jsonb #>> '{}')::double precision
38
- FROM castdb.metric_type mt
39
- WHERE pd.metric_type_uuid = mt.uuid
40
- AND mt.value_type IN ('float'
41
- , 'int')
42
- """
43
- )
44
-
45
33
 
46
34
  def downgrade() -> None:
47
35
  op.execute(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opensampl"
3
- version = "1.2.2"
3
+ version = "1.2.3"
4
4
  description = "Python tools for adding clock data to a timescale db."
5
5
  license = {file = "LICENSE"}
6
6
  authors = [
@@ -43,7 +43,7 @@ dependencies = [
43
43
  "pydantic>=2.10.3,<3",
44
44
  "pydantic-settings>=2.9.0",
45
45
  "sqlalchemy>=2.0.39,<3",
46
- "geoalchemy2==0.16.0",
46
+ "geoalchemy2>=0.18.0,<1",
47
47
  "click>=8.0.0,<9",
48
48
  "pandas>=2.2.1,<3",
49
49
  "tqdm>=4.66.2,<5",
File without changes
File without changes
File without changes
File without changes