aspy21 0.2.0b0__tar.gz → 0.2.0b2__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.
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/PKG-INFO +1 -1
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/_version.py +3 -3
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/client.py +85 -111
- aspy21-0.2.0b2/src/aspy21/query_builder.py +130 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/PKG-INFO +1 -1
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/SOURCES.txt +1 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/test_client.py +229 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.env.example +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.github/SETUP_GUIDE.md +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.github/workflows/release.yml +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.github/workflows/tests.yml +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.gitignore +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.pre-commit-config.yaml +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/CHANGELOG.md +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/CODE_OF_CONDUCT.md +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/CONTRIBUTING.md +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/LICENSE +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/README.md +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/SECURITY.md +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/docs/_static/logo.png +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/examples/README.md +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/examples/basic_usage.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/examples/search_and_read.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/examples/search_tags.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/justfile +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/pyproject.toml +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/scripts/bump_version.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/setup.cfg +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/__init__.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/cache.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/models.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/utils.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/dependency_links.txt +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/requires.txt +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/top_level.txt +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/__init__.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/conftest.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/test_auth.py +0 -0
- {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/test_logging.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aspy21
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.0b2
|
|
4
4
|
Summary: Modern Python client for Aspen InfoPlus.21 (IP.21) with pandas DataFrame output, tag search, and automatic retry logic
|
|
5
5
|
Author: bazdalaz
|
|
6
6
|
Maintainer-email: bazdalaz <bazdalaz@users.noreply.github.com>
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.2.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 2, 0, '
|
|
31
|
+
__version__ = version = '0.2.0b2'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'b2')
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'gfe38a9fe3'
|
|
@@ -12,6 +12,7 @@ import pandas as pd
|
|
|
12
12
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
13
13
|
|
|
14
14
|
from .models import ReaderType
|
|
15
|
+
from .query_builder import build_read_query, build_sql_search_query
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from httpx import Auth
|
|
@@ -127,72 +128,9 @@ class AspenClient:
|
|
|
127
128
|
"""Close the HTTP client connection."""
|
|
128
129
|
self._client.close()
|
|
129
130
|
|
|
130
|
-
def _generate_xml_query(
|
|
131
|
-
self,
|
|
132
|
-
tag: str,
|
|
133
|
-
start: str,
|
|
134
|
-
end: str,
|
|
135
|
-
read_type: ReaderType,
|
|
136
|
-
interval: int | None = None,
|
|
137
|
-
datasource: str = "", # Empty string = use default datasource
|
|
138
|
-
max_rows: int = 100000,
|
|
139
|
-
) -> str:
|
|
140
|
-
"""Generate XML query for Aspen REST API.
|
|
141
|
-
|
|
142
|
-
Args:
|
|
143
|
-
tag: Tag name
|
|
144
|
-
start: Start timestamp (ISO format)
|
|
145
|
-
end: End timestamp (ISO format)
|
|
146
|
-
read_type: Type of read (RAW, INT, etc.)
|
|
147
|
-
interval: Interval in seconds (for aggregated reads)
|
|
148
|
-
datasource: Data source name (default: IP21)
|
|
149
|
-
max_rows: Maximum number of rows to return
|
|
150
|
-
|
|
151
|
-
Returns:
|
|
152
|
-
XML query string
|
|
153
|
-
"""
|
|
154
|
-
# Convert ISO timestamps to milliseconds since epoch
|
|
155
|
-
start_dt = pd.to_datetime(start)
|
|
156
|
-
end_dt = pd.to_datetime(end)
|
|
157
|
-
start_ms = int(start_dt.timestamp() * 1000)
|
|
158
|
-
end_ms = int(end_dt.timestamp() * 1000)
|
|
159
|
-
|
|
160
|
-
# Map ReaderType to Aspen RT codes
|
|
161
|
-
rt_map = {
|
|
162
|
-
ReaderType.RAW: 0,
|
|
163
|
-
ReaderType.INT: 1,
|
|
164
|
-
ReaderType.SNAPSHOT: 2, # Will use Attribute endpoint
|
|
165
|
-
ReaderType.AVG: 10,
|
|
166
|
-
}
|
|
167
|
-
rt_code = rt_map.get(read_type, 0)
|
|
168
|
-
|
|
169
|
-
# Build XML query
|
|
170
|
-
xml = '<Q f="d" allQuotes="1">'
|
|
171
|
-
xml += "<Tag>"
|
|
172
|
-
xml += f"<N><![CDATA[{tag}]]></N>"
|
|
173
|
-
# Only include datasource if specified
|
|
174
|
-
if datasource:
|
|
175
|
-
xml += f"<D><![CDATA[{datasource}]]></D>"
|
|
176
|
-
xml += "<F><![CDATA[VAL]]></F>"
|
|
177
|
-
xml += "<HF>0</HF>" # History format: 0=Raw
|
|
178
|
-
xml += f"<St>{start_ms}</St>"
|
|
179
|
-
xml += f"<Et>{end_ms}</Et>"
|
|
180
|
-
xml += f"<RT>{rt_code}</RT>"
|
|
181
|
-
xml += f"<X>{max_rows}</X>"
|
|
182
|
-
|
|
183
|
-
# Add interval for aggregated reads
|
|
184
|
-
if interval and rt_code >= 10:
|
|
185
|
-
xml += f"<P>{interval}</P>"
|
|
186
|
-
xml += "<PU>3</PU>" # Period unit: 3=seconds
|
|
187
|
-
|
|
188
|
-
xml += "</Tag>"
|
|
189
|
-
xml += "</Q>"
|
|
190
|
-
|
|
191
|
-
return xml
|
|
192
|
-
|
|
193
131
|
def _parse_aspen_response(
|
|
194
132
|
self, response: dict, tag_name: str, include_status: bool = True
|
|
195
|
-
) -> pd.DataFrame:
|
|
133
|
+
) -> tuple[pd.DataFrame, str]:
|
|
196
134
|
"""Parse Aspen REST API response into DataFrame.
|
|
197
135
|
|
|
198
136
|
Args:
|
|
@@ -201,7 +139,7 @@ class AspenClient:
|
|
|
201
139
|
include_status: Whether to include status column
|
|
202
140
|
|
|
203
141
|
Returns:
|
|
204
|
-
DataFrame with timestamp index and tag data
|
|
142
|
+
Tuple of (DataFrame with timestamp index and tag data, tag description)
|
|
205
143
|
|
|
206
144
|
Aspen API returns:
|
|
207
145
|
{
|
|
@@ -220,11 +158,19 @@ class AspenClient:
|
|
|
220
158
|
data = response.get("data", [])
|
|
221
159
|
if not data or not isinstance(data, list):
|
|
222
160
|
logger.warning(f"No data array in response for tag {tag_name}")
|
|
223
|
-
return pd.DataFrame()
|
|
161
|
+
return pd.DataFrame(), ""
|
|
224
162
|
|
|
225
163
|
# Get first element (should contain samples)
|
|
226
164
|
tag_data = data[0] if len(data) > 0 else {}
|
|
227
165
|
|
|
166
|
+
# Extract description if available (from IP_DESCRIPTION field)
|
|
167
|
+
description = ""
|
|
168
|
+
if "l" in tag_data and isinstance(tag_data["l"], list) and len(tag_data["l"]) > 0:
|
|
169
|
+
# "l" contains list of field values, IP_DESCRIPTION is second field if requested
|
|
170
|
+
fields = tag_data["l"]
|
|
171
|
+
if len(fields) > 1:
|
|
172
|
+
description = fields[1] if fields[1] is not None else ""
|
|
173
|
+
|
|
228
174
|
# Check for errors in samples
|
|
229
175
|
samples = tag_data.get("samples", [])
|
|
230
176
|
if samples and isinstance(samples, list) and len(samples) > 0:
|
|
@@ -233,11 +179,11 @@ class AspenClient:
|
|
|
233
179
|
if "er" in first_sample and first_sample.get("er", 0) != 0:
|
|
234
180
|
error_msg = first_sample.get("es", "Unknown error")
|
|
235
181
|
logger.warning(f"API error for tag {tag_name}: {error_msg}")
|
|
236
|
-
return pd.DataFrame()
|
|
182
|
+
return pd.DataFrame(), description
|
|
237
183
|
|
|
238
184
|
if not samples:
|
|
239
185
|
logger.warning(f"No samples found for tag {tag_name}")
|
|
240
|
-
return pd.DataFrame()
|
|
186
|
+
return pd.DataFrame(), description
|
|
241
187
|
|
|
242
188
|
# Build DataFrame
|
|
243
189
|
rows = []
|
|
@@ -256,18 +202,18 @@ class AspenClient:
|
|
|
256
202
|
|
|
257
203
|
if not rows:
|
|
258
204
|
logger.warning(f"No valid data samples for tag {tag_name}")
|
|
259
|
-
return pd.DataFrame()
|
|
205
|
+
return pd.DataFrame(), description
|
|
260
206
|
|
|
261
207
|
df = pd.DataFrame(rows)
|
|
262
208
|
if not df.empty:
|
|
263
209
|
df = df.set_index("time")
|
|
264
210
|
|
|
265
|
-
return df
|
|
211
|
+
return df, description
|
|
266
212
|
|
|
267
213
|
except Exception as e:
|
|
268
214
|
logger.error(f"Error parsing response for tag {tag_name}: {e}")
|
|
269
215
|
logger.debug(f"Response was: {response}")
|
|
270
|
-
return pd.DataFrame()
|
|
216
|
+
return pd.DataFrame(), ""
|
|
271
217
|
|
|
272
218
|
@retry(wait=wait_exponential(multiplier=0.5, min=0.5, max=8), stop=stop_after_attempt(3))
|
|
273
219
|
def _fetch(self, xml_query: str) -> dict:
|
|
@@ -323,7 +269,9 @@ class AspenClient:
|
|
|
323
269
|
read_type: ReaderType = ReaderType.RAW,
|
|
324
270
|
include_status: bool = True,
|
|
325
271
|
max_rows: int = 100000,
|
|
326
|
-
|
|
272
|
+
as_json: bool = False,
|
|
273
|
+
with_description: bool = False,
|
|
274
|
+
) -> pd.DataFrame | list[dict]:
|
|
327
275
|
"""Read historical data for multiple tags.
|
|
328
276
|
|
|
329
277
|
Args:
|
|
@@ -334,12 +282,21 @@ class AspenClient:
|
|
|
334
282
|
read_type: Type of data retrieval (RAW, INT, SNAPSHOT, AVG)
|
|
335
283
|
include_status: Include status column in output (default: True)
|
|
336
284
|
max_rows: Maximum number of rows to return per tag (default: 100000)
|
|
285
|
+
as_json: Return data as list of dictionaries instead of DataFrame (default: False)
|
|
286
|
+
with_description: Include tag descriptions in response (default: False).
|
|
287
|
+
Automatically enabled when as_json=True.
|
|
337
288
|
|
|
338
289
|
Returns:
|
|
339
|
-
pandas DataFrame with time index and columns for each tag.
|
|
340
|
-
|
|
290
|
+
If as_json=False: pandas DataFrame with time index and columns for each tag.
|
|
291
|
+
If include_status=True, includes a 'status' column.
|
|
292
|
+
If as_json=True: List of dictionaries, each containing:
|
|
293
|
+
- timestamp: ISO format timestamp string
|
|
294
|
+
- tag: Tag name
|
|
295
|
+
- description: Tag description (from IP_DESCRIPTION field)
|
|
296
|
+
- value: Tag value
|
|
341
297
|
|
|
342
298
|
Example:
|
|
299
|
+
>>> # DataFrame output (default)
|
|
343
300
|
>>> client = AspenClient("https://aspen.example.com/ProcessData")
|
|
344
301
|
>>> df = client.read(
|
|
345
302
|
... ["ATI111", "AP101.PV"],
|
|
@@ -348,13 +305,18 @@ class AspenClient:
|
|
|
348
305
|
... read_type=ReaderType.RAW
|
|
349
306
|
... )
|
|
350
307
|
|
|
351
|
-
>>> #
|
|
352
|
-
>>>
|
|
308
|
+
>>> # JSON output with descriptions
|
|
309
|
+
>>> data = client.read(
|
|
353
310
|
... ["ATI111"],
|
|
354
311
|
... start="2025-01-01 00:00:00",
|
|
355
|
-
... end="2025-01-
|
|
356
|
-
...
|
|
312
|
+
... end="2025-01-01 01:00:00",
|
|
313
|
+
... as_json=True
|
|
357
314
|
... )
|
|
315
|
+
>>> # Returns: [
|
|
316
|
+
>>> # {"timestamp": "2025-01-01T00:00:00", "tag": "ATI111",
|
|
317
|
+
>>> # "description": "Temperature sensor", "value": 25.5},
|
|
318
|
+
>>> # ...
|
|
319
|
+
>>> # ]
|
|
358
320
|
"""
|
|
359
321
|
tags_list = list(tags)
|
|
360
322
|
logger.info(f"Reading {len(tags_list)} tag(s) from {start} to {end} (max_rows={max_rows})")
|
|
@@ -362,13 +324,14 @@ class AspenClient:
|
|
|
362
324
|
logger.debug(f"Reader type: {read_type.value}, Interval: {interval}")
|
|
363
325
|
|
|
364
326
|
frames: list[pd.DataFrame] = []
|
|
327
|
+
tag_descriptions: dict[str, str] = {}
|
|
365
328
|
|
|
366
329
|
# Process each tag individually (Aspen API queries one tag at a time)
|
|
367
330
|
for tag_idx, tag in enumerate(tags_list, 1):
|
|
368
331
|
logger.debug(f"Processing tag {tag_idx}/{len(tags_list)}: {tag}")
|
|
369
332
|
|
|
370
333
|
# Generate XML query for this tag
|
|
371
|
-
xml_query =
|
|
334
|
+
xml_query = build_read_query(
|
|
372
335
|
tag=tag,
|
|
373
336
|
start=start,
|
|
374
337
|
end=end,
|
|
@@ -376,20 +339,27 @@ class AspenClient:
|
|
|
376
339
|
interval=interval,
|
|
377
340
|
datasource=self.datasource,
|
|
378
341
|
max_rows=max_rows,
|
|
342
|
+
with_description=with_description or as_json, # Request description if needed
|
|
379
343
|
)
|
|
380
344
|
|
|
381
345
|
response = self._fetch(xml_query)
|
|
382
346
|
logger.debug(f"Received response for tag: {tag}")
|
|
383
347
|
|
|
384
348
|
# Parse the Aspen-style response
|
|
385
|
-
df = self._parse_aspen_response(
|
|
349
|
+
df, description = self._parse_aspen_response(
|
|
350
|
+
response, tag, include_status=include_status
|
|
351
|
+
)
|
|
386
352
|
logger.debug(f"Parsed {len(df)} data points for tag {tag}")
|
|
387
353
|
|
|
388
354
|
if not df.empty:
|
|
389
355
|
frames.append(df)
|
|
356
|
+
if description:
|
|
357
|
+
tag_descriptions[tag] = description
|
|
390
358
|
|
|
391
359
|
if not frames:
|
|
392
360
|
logger.warning("No data returned from API")
|
|
361
|
+
if as_json:
|
|
362
|
+
return []
|
|
393
363
|
return pd.DataFrame()
|
|
394
364
|
|
|
395
365
|
# Merge frames by index (time), combining columns for different tags
|
|
@@ -397,6 +367,35 @@ class AspenClient:
|
|
|
397
367
|
out = out.sort_index()
|
|
398
368
|
|
|
399
369
|
logger.info(f"Successfully retrieved {len(out)} rows for {len(out.columns)} column(s)")
|
|
370
|
+
|
|
371
|
+
# Convert to JSON format if requested
|
|
372
|
+
if as_json:
|
|
373
|
+
json_data: list[dict] = []
|
|
374
|
+
for idx, row in out.iterrows():
|
|
375
|
+
# Iterate through each tag (column) in this row
|
|
376
|
+
for tag in tags_list:
|
|
377
|
+
if tag in row.index:
|
|
378
|
+
value = row[tag]
|
|
379
|
+
# Skip NaN values - use isinstance check to avoid Series.__bool__ issue
|
|
380
|
+
if isinstance(value, (int, float, str)) and pd.notna(value):
|
|
381
|
+
# idx is pandas Timestamp, which has isoformat method
|
|
382
|
+
# type: ignore is needed because iterrows() returns Hashable for index
|
|
383
|
+
ts = (
|
|
384
|
+
idx.isoformat() # type: ignore[union-attr]
|
|
385
|
+
if hasattr(idx, "isoformat")
|
|
386
|
+
else str(idx)
|
|
387
|
+
)
|
|
388
|
+
json_data.append(
|
|
389
|
+
{
|
|
390
|
+
"timestamp": ts,
|
|
391
|
+
"tag": tag,
|
|
392
|
+
"description": tag_descriptions.get(tag, ""),
|
|
393
|
+
"value": value,
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
logger.debug(f"Converted to {len(json_data)} JSON records")
|
|
397
|
+
return json_data
|
|
398
|
+
|
|
400
399
|
return out
|
|
401
400
|
|
|
402
401
|
def _search_by_sql(
|
|
@@ -432,37 +431,12 @@ class AspenClient:
|
|
|
432
431
|
"AspenClient(base_url=..., datasource='your_datasource')"
|
|
433
432
|
)
|
|
434
433
|
|
|
435
|
-
# Build
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
# Build WHERE clause - filter by both name and description in SQL
|
|
442
|
-
where_clauses = [f"d like '{sql_pattern}'"]
|
|
443
|
-
|
|
444
|
-
# Add name pattern filter if not wildcard
|
|
445
|
-
if tag_pattern != "*":
|
|
446
|
-
name_pattern = tag_pattern.replace("*", "%").replace("?", "_")
|
|
447
|
-
# Add wildcards to name pattern if not already present
|
|
448
|
-
if "%" not in name_pattern and "_" not in name_pattern:
|
|
449
|
-
name_pattern = f"%{name_pattern}%"
|
|
450
|
-
where_clauses.append(f"name like '{name_pattern}'")
|
|
451
|
-
|
|
452
|
-
where_clause = " and ".join(where_clauses)
|
|
453
|
-
|
|
454
|
-
sql_query = (
|
|
455
|
-
f"Select name, name->ip_description d, name->ip_input_value "
|
|
456
|
-
f"from all_records where {where_clause}"
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
# Build XML request for SQL endpoint
|
|
460
|
-
xml = (
|
|
461
|
-
f'<SQL g="aspy21_search" t="SQLplus" ds="{self.datasource}" '
|
|
462
|
-
f'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
|
|
463
|
-
f'm="{max_results}" to="30" response="Original" s="1">'
|
|
464
|
-
f"<![CDATA[{sql_query}]]>"
|
|
465
|
-
f"</SQL>"
|
|
434
|
+
# Build XML query for SQL endpoint
|
|
435
|
+
xml = build_sql_search_query(
|
|
436
|
+
datasource=self.datasource,
|
|
437
|
+
description=description,
|
|
438
|
+
tag_pattern=tag_pattern,
|
|
439
|
+
max_results=max_results,
|
|
466
440
|
)
|
|
467
441
|
|
|
468
442
|
logger.debug(f"SQL query XML: {xml}")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""XML query generation for Aspen InfoPlus.21 REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from .models import ReaderType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_read_query(
|
|
11
|
+
tag: str,
|
|
12
|
+
start: str,
|
|
13
|
+
end: str,
|
|
14
|
+
read_type: ReaderType,
|
|
15
|
+
interval: int | None = None,
|
|
16
|
+
datasource: str = "",
|
|
17
|
+
max_rows: int = 100000,
|
|
18
|
+
with_description: bool = False,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Generate XML query for Aspen REST API data read.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
tag: Tag name
|
|
24
|
+
start: Start timestamp (ISO format)
|
|
25
|
+
end: End timestamp (ISO format)
|
|
26
|
+
read_type: Type of read (RAW, INT, etc.)
|
|
27
|
+
interval: Interval in seconds (for aggregated reads)
|
|
28
|
+
datasource: Data source name (default: IP21)
|
|
29
|
+
max_rows: Maximum number of rows to return
|
|
30
|
+
with_description: Include ip_description field in response
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
XML query string
|
|
34
|
+
"""
|
|
35
|
+
# Convert ISO timestamps to milliseconds since epoch
|
|
36
|
+
start_dt = pd.to_datetime(start)
|
|
37
|
+
end_dt = pd.to_datetime(end)
|
|
38
|
+
start_ms = int(start_dt.timestamp() * 1000)
|
|
39
|
+
end_ms = int(end_dt.timestamp() * 1000)
|
|
40
|
+
|
|
41
|
+
# Map ReaderType to Aspen RT codes
|
|
42
|
+
rt_map = {
|
|
43
|
+
ReaderType.RAW: 0,
|
|
44
|
+
ReaderType.INT: 1,
|
|
45
|
+
ReaderType.SNAPSHOT: 2, # Will use Attribute endpoint
|
|
46
|
+
ReaderType.AVG: 10,
|
|
47
|
+
}
|
|
48
|
+
rt_code = rt_map.get(read_type, 0)
|
|
49
|
+
|
|
50
|
+
# Build XML query
|
|
51
|
+
xml = '<Q f="d" allQuotes="1">'
|
|
52
|
+
xml += "<Tag>"
|
|
53
|
+
xml += f"<N><![CDATA[{tag}]]></N>"
|
|
54
|
+
# Only include datasource if specified
|
|
55
|
+
if datasource:
|
|
56
|
+
xml += f"<D><![CDATA[{datasource}]]></D>"
|
|
57
|
+
xml += "<F><![CDATA[VAL]]></F>"
|
|
58
|
+
|
|
59
|
+
# Add description field if requested
|
|
60
|
+
if with_description:
|
|
61
|
+
xml += "<F><![CDATA[IP_DESCRIPTION]]></F>"
|
|
62
|
+
|
|
63
|
+
xml += "<HF>0</HF>" # History format: 0=Raw
|
|
64
|
+
xml += f"<St>{start_ms}</St>"
|
|
65
|
+
xml += f"<Et>{end_ms}</Et>"
|
|
66
|
+
xml += f"<RT>{rt_code}</RT>"
|
|
67
|
+
xml += f"<X>{max_rows}</X>"
|
|
68
|
+
|
|
69
|
+
# Add interval for aggregated reads
|
|
70
|
+
if interval and rt_code >= 10:
|
|
71
|
+
xml += f"<P>{interval}</P>"
|
|
72
|
+
xml += "<PU>3</PU>" # Period unit: 3=seconds
|
|
73
|
+
|
|
74
|
+
xml += "</Tag>"
|
|
75
|
+
xml += "</Q>"
|
|
76
|
+
|
|
77
|
+
return xml
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_sql_search_query(
|
|
81
|
+
datasource: str,
|
|
82
|
+
description: str,
|
|
83
|
+
tag_pattern: str = "*",
|
|
84
|
+
max_results: int = 10000,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Generate XML query for SQL-based tag search.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
datasource: Aspen datasource name
|
|
90
|
+
description: Description pattern to search for (supports * wildcards)
|
|
91
|
+
tag_pattern: Tag name pattern (supports * and ? wildcards)
|
|
92
|
+
max_results: Maximum number of results
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
XML query string for SQL endpoint
|
|
96
|
+
"""
|
|
97
|
+
# Build SQL query - search for description pattern
|
|
98
|
+
# Note: SQL LIKE uses % as wildcard, not *
|
|
99
|
+
sql_pattern = description.replace("*", "%")
|
|
100
|
+
if not sql_pattern.startswith("%"):
|
|
101
|
+
sql_pattern = f"%{sql_pattern}%"
|
|
102
|
+
|
|
103
|
+
# Build WHERE clause - filter by both name and description in SQL
|
|
104
|
+
where_clauses = [f"d like '{sql_pattern}'"]
|
|
105
|
+
|
|
106
|
+
# Add name pattern filter if not wildcard
|
|
107
|
+
if tag_pattern != "*":
|
|
108
|
+
name_pattern = tag_pattern.replace("*", "%").replace("?", "_")
|
|
109
|
+
# Add wildcards to name pattern if not already present
|
|
110
|
+
if "%" not in name_pattern and "_" not in name_pattern:
|
|
111
|
+
name_pattern = f"%{name_pattern}%"
|
|
112
|
+
where_clauses.append(f"name like '{name_pattern}'")
|
|
113
|
+
|
|
114
|
+
where_clause = " and ".join(where_clauses)
|
|
115
|
+
|
|
116
|
+
sql_query = (
|
|
117
|
+
f"Select name, name->ip_description d, name->ip_input_value "
|
|
118
|
+
f"from all_records where {where_clause}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Build XML request for SQL endpoint
|
|
122
|
+
xml = (
|
|
123
|
+
f'<SQL g="aspy21_search" t="SQLplus" ds="{datasource}" '
|
|
124
|
+
f'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
|
|
125
|
+
f'm="{max_results}" to="30" response="Original" s="1">'
|
|
126
|
+
f"<![CDATA[{sql_query}]]>"
|
|
127
|
+
f"</SQL>"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return xml
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aspy21
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.0b2
|
|
4
4
|
Summary: Modern Python client for Aspen InfoPlus.21 (IP.21) with pandas DataFrame output, tag search, and automatic retry logic
|
|
5
5
|
Author: bazdalaz
|
|
6
6
|
Maintainer-email: bazdalaz <bazdalaz@users.noreply.github.com>
|
|
@@ -655,3 +655,232 @@ def test_context_manager_returns_self():
|
|
|
655
655
|
assert returned is client
|
|
656
656
|
|
|
657
657
|
client.close()
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_read_as_json_basic(mock_api):
|
|
661
|
+
"""Test reading data with as_json=True returns list of dictionaries."""
|
|
662
|
+
mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll").mock(
|
|
663
|
+
return_value=httpx.Response(
|
|
664
|
+
200,
|
|
665
|
+
json={
|
|
666
|
+
"data": [
|
|
667
|
+
{
|
|
668
|
+
"l": ["VAL", "Temperature sensor"], # Field values including description
|
|
669
|
+
"samples": [
|
|
670
|
+
{"t": 1704110400000, "v": 25.5, "s": 8}, # 2024-01-01 12:00:00
|
|
671
|
+
{"t": 1704114000000, "v": 26.0, "s": 8}, # 2024-01-01 13:00:00
|
|
672
|
+
],
|
|
673
|
+
}
|
|
674
|
+
]
|
|
675
|
+
},
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
with AspenClient(
|
|
680
|
+
base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
|
|
681
|
+
timeout=2,
|
|
682
|
+
verify_ssl=False,
|
|
683
|
+
) as client:
|
|
684
|
+
data = client.read(
|
|
685
|
+
tags=["TAG1"],
|
|
686
|
+
start="2024-01-01 12:00:00",
|
|
687
|
+
end="2024-01-01 13:00:00",
|
|
688
|
+
read_type=ReaderType.RAW,
|
|
689
|
+
as_json=True,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Verify it's a list of dicts
|
|
693
|
+
assert isinstance(data, list)
|
|
694
|
+
assert len(data) == 2
|
|
695
|
+
|
|
696
|
+
# Verify structure of first record
|
|
697
|
+
assert "timestamp" in data[0]
|
|
698
|
+
assert "tag" in data[0]
|
|
699
|
+
assert "description" in data[0]
|
|
700
|
+
assert "value" in data[0]
|
|
701
|
+
|
|
702
|
+
# Verify values
|
|
703
|
+
assert data[0]["tag"] == "TAG1"
|
|
704
|
+
assert data[0]["description"] == "Temperature sensor"
|
|
705
|
+
assert data[0]["value"] == 25.5
|
|
706
|
+
assert isinstance(data[0]["timestamp"], str)
|
|
707
|
+
|
|
708
|
+
assert data[1]["value"] == 26.0
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def test_read_with_description_dataframe(mock_api):
|
|
712
|
+
"""Test reading data with with_description=True still returns DataFrame."""
|
|
713
|
+
route = mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll")
|
|
714
|
+
route.mock(
|
|
715
|
+
return_value=httpx.Response(
|
|
716
|
+
200,
|
|
717
|
+
json={
|
|
718
|
+
"data": [
|
|
719
|
+
{
|
|
720
|
+
"l": ["VAL", "Pressure sensor"], # Field values including description
|
|
721
|
+
"samples": [
|
|
722
|
+
{"t": 1704110400000, "v": 101.3, "s": 8},
|
|
723
|
+
],
|
|
724
|
+
}
|
|
725
|
+
]
|
|
726
|
+
},
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
with AspenClient(
|
|
731
|
+
base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
|
|
732
|
+
timeout=2,
|
|
733
|
+
verify_ssl=False,
|
|
734
|
+
) as client:
|
|
735
|
+
df = client.read(
|
|
736
|
+
tags=["PRESS1"],
|
|
737
|
+
start="2024-01-01 12:00:00",
|
|
738
|
+
end="2024-01-01 13:00:00",
|
|
739
|
+
read_type=ReaderType.RAW,
|
|
740
|
+
with_description=True,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# Should still return DataFrame when as_json=False
|
|
744
|
+
assert isinstance(df, pd.DataFrame)
|
|
745
|
+
assert not df.empty
|
|
746
|
+
assert "PRESS1" in df.columns
|
|
747
|
+
|
|
748
|
+
# Verify IP_DESCRIPTION field was requested in XML
|
|
749
|
+
request = route.calls.last.request
|
|
750
|
+
request_body = request.content.decode("utf-8")
|
|
751
|
+
assert "<F><![CDATA[IP_DESCRIPTION]]></F>" in request_body
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def test_read_as_json_multiple_tags(mock_api):
|
|
755
|
+
"""Test as_json with multiple tags."""
|
|
756
|
+
|
|
757
|
+
def response_handler(request):
|
|
758
|
+
# Return different data based on which tag is requested
|
|
759
|
+
body = request.content.decode("utf-8")
|
|
760
|
+
if "TAG1" in body:
|
|
761
|
+
return httpx.Response(
|
|
762
|
+
200,
|
|
763
|
+
json={
|
|
764
|
+
"data": [
|
|
765
|
+
{
|
|
766
|
+
"l": ["VAL", "Temperature sensor"],
|
|
767
|
+
"samples": [
|
|
768
|
+
{"t": 1704110400000, "v": 25.5, "s": 8},
|
|
769
|
+
],
|
|
770
|
+
}
|
|
771
|
+
]
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
else: # TAG2
|
|
775
|
+
return httpx.Response(
|
|
776
|
+
200,
|
|
777
|
+
json={
|
|
778
|
+
"data": [
|
|
779
|
+
{
|
|
780
|
+
"l": ["VAL", "Pressure sensor"],
|
|
781
|
+
"samples": [
|
|
782
|
+
{"t": 1704110400000, "v": 101.3, "s": 8},
|
|
783
|
+
],
|
|
784
|
+
}
|
|
785
|
+
]
|
|
786
|
+
},
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll").mock(
|
|
790
|
+
side_effect=response_handler
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
with AspenClient(
|
|
794
|
+
base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
|
|
795
|
+
timeout=2,
|
|
796
|
+
verify_ssl=False,
|
|
797
|
+
) as client:
|
|
798
|
+
data = client.read(
|
|
799
|
+
tags=["TAG1", "TAG2"],
|
|
800
|
+
start="2024-01-01 12:00:00",
|
|
801
|
+
end="2024-01-01 13:00:00",
|
|
802
|
+
read_type=ReaderType.RAW,
|
|
803
|
+
as_json=True,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
# Should have 2 records (one per tag, same timestamp)
|
|
807
|
+
assert isinstance(data, list)
|
|
808
|
+
assert len(data) == 2
|
|
809
|
+
|
|
810
|
+
# Verify both tags are present
|
|
811
|
+
tags = {record["tag"] for record in data}
|
|
812
|
+
assert tags == {"TAG1", "TAG2"}
|
|
813
|
+
|
|
814
|
+
# Verify descriptions
|
|
815
|
+
tag1_record = next(r for r in data if r["tag"] == "TAG1")
|
|
816
|
+
tag2_record = next(r for r in data if r["tag"] == "TAG2")
|
|
817
|
+
|
|
818
|
+
assert tag1_record["description"] == "Temperature sensor"
|
|
819
|
+
assert tag1_record["value"] == 25.5
|
|
820
|
+
|
|
821
|
+
assert tag2_record["description"] == "Pressure sensor"
|
|
822
|
+
assert tag2_record["value"] == 101.3
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def test_read_as_json_empty_response(mock_api):
|
|
826
|
+
"""Test as_json=True with empty response returns empty list."""
|
|
827
|
+
mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll").mock(
|
|
828
|
+
return_value=httpx.Response(
|
|
829
|
+
200,
|
|
830
|
+
json={"data": [{"samples": []}]},
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
with AspenClient(
|
|
835
|
+
base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
|
|
836
|
+
timeout=2,
|
|
837
|
+
verify_ssl=False,
|
|
838
|
+
) as client:
|
|
839
|
+
data = client.read(
|
|
840
|
+
tags=["TAG1"],
|
|
841
|
+
start="2024-01-01 12:00:00",
|
|
842
|
+
end="2024-01-01 13:00:00",
|
|
843
|
+
read_type=ReaderType.RAW,
|
|
844
|
+
as_json=True,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
# Should return empty list
|
|
848
|
+
assert isinstance(data, list)
|
|
849
|
+
assert len(data) == 0
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def test_read_as_json_without_description(mock_api):
|
|
853
|
+
"""Test as_json=True when description is not available in response."""
|
|
854
|
+
mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll").mock(
|
|
855
|
+
return_value=httpx.Response(
|
|
856
|
+
200,
|
|
857
|
+
json={
|
|
858
|
+
"data": [
|
|
859
|
+
{
|
|
860
|
+
# No "l" field - description not available
|
|
861
|
+
"samples": [
|
|
862
|
+
{"t": 1704110400000, "v": 25.5, "s": 8},
|
|
863
|
+
],
|
|
864
|
+
}
|
|
865
|
+
]
|
|
866
|
+
},
|
|
867
|
+
)
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
with AspenClient(
|
|
871
|
+
base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
|
|
872
|
+
timeout=2,
|
|
873
|
+
verify_ssl=False,
|
|
874
|
+
) as client:
|
|
875
|
+
data = client.read(
|
|
876
|
+
tags=["TAG1"],
|
|
877
|
+
start="2024-01-01 12:00:00",
|
|
878
|
+
end="2024-01-01 13:00:00",
|
|
879
|
+
read_type=ReaderType.RAW,
|
|
880
|
+
as_json=True,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# Should still work, but description should be empty string
|
|
884
|
+
assert len(data) == 1
|
|
885
|
+
assert data[0]["description"] == ""
|
|
886
|
+
assert data[0]["value"] == 25.5
|
|
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
|
|
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
|