aspy21 0.2.0b2__tar.gz → 0.2.0b4__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.0b2 → aspy21-0.2.0b4}/PKG-INFO +1 -1
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/examples/search_and_read.py +47 -14
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/examples/search_tags.py +16 -4
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/_version.py +3 -3
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/client.py +108 -21
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/query_builder.py +51 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/PKG-INFO +1 -1
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/test_client.py +36 -11
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.env.example +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.github/SETUP_GUIDE.md +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.github/workflows/release.yml +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.github/workflows/tests.yml +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.gitignore +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.pre-commit-config.yaml +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/CHANGELOG.md +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/CODE_OF_CONDUCT.md +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/CONTRIBUTING.md +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/LICENSE +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/README.md +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/SECURITY.md +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/docs/_static/logo.png +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/examples/README.md +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/examples/basic_usage.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/justfile +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/pyproject.toml +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/scripts/bump_version.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/setup.cfg +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/__init__.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/cache.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/models.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/utils.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/SOURCES.txt +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/dependency_links.txt +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/requires.txt +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/top_level.txt +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/__init__.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/conftest.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/test_auth.py +0 -0
- {aspy21-0.2.0b2 → aspy21-0.2.0b4}/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.0b4
|
|
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>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import os
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
import pandas as pd
|
|
6
7
|
from dotenv import load_dotenv
|
|
7
8
|
|
|
8
9
|
from aspy21 import AspenClient, ReaderType, configure_logging
|
|
@@ -50,17 +51,25 @@ try:
|
|
|
50
51
|
print("Searching for temperature tags...")
|
|
51
52
|
|
|
52
53
|
# Get list of tag names matching pattern
|
|
53
|
-
|
|
54
|
+
tag_names_result = client.search(tag="TEMP*", return_desc=False, max_results=5)
|
|
55
|
+
# Type narrowing: return_desc=False guarantees list[str]
|
|
56
|
+
assert isinstance(tag_names_result, list) and (
|
|
57
|
+
not tag_names_result or isinstance(tag_names_result[0], str)
|
|
58
|
+
)
|
|
59
|
+
tag_names: list[str] = tag_names_result # type: ignore[assignment]
|
|
54
60
|
print(f"Found {len(tag_names)} tags: {tag_names}")
|
|
55
61
|
|
|
56
62
|
if tag_names:
|
|
57
63
|
print("\nReading last hour of data...")
|
|
58
|
-
|
|
64
|
+
result = client.read(
|
|
59
65
|
tags=tag_names,
|
|
60
66
|
start="2025-01-31 08:00:00",
|
|
61
67
|
end="2025-01-31 09:00:00",
|
|
62
68
|
read_type=ReaderType.RAW,
|
|
63
69
|
)
|
|
70
|
+
# Type narrowing: as_json=False (default) guarantees pd.DataFrame
|
|
71
|
+
assert isinstance(result, pd.DataFrame)
|
|
72
|
+
df: pd.DataFrame = result
|
|
64
73
|
|
|
65
74
|
print(f"Data shape: {df.shape}")
|
|
66
75
|
print(f"\nFirst few rows:\n{df.head()}")
|
|
@@ -72,7 +81,12 @@ try:
|
|
|
72
81
|
print("Searching for reactor tags...")
|
|
73
82
|
|
|
74
83
|
# Get tags with descriptions for display/logging
|
|
75
|
-
|
|
84
|
+
tags_result = client.search(tag="*", description="reactor", max_results=5)
|
|
85
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
86
|
+
assert isinstance(tags_result, list) and (
|
|
87
|
+
not tags_result or isinstance(tags_result[0], dict)
|
|
88
|
+
)
|
|
89
|
+
tags: list[dict[str, str]] = tags_result # type: ignore[assignment]
|
|
76
90
|
|
|
77
91
|
print(f"Found {len(tags)} tags:")
|
|
78
92
|
for tag in tags:
|
|
@@ -80,16 +94,19 @@ try:
|
|
|
80
94
|
|
|
81
95
|
if tags:
|
|
82
96
|
# Extract just the names for reading
|
|
83
|
-
|
|
97
|
+
tag_names_list = [tag["name"] for tag in tags]
|
|
84
98
|
|
|
85
99
|
print("\nReading 10-minute averages...")
|
|
86
|
-
|
|
87
|
-
tags=
|
|
100
|
+
result = client.read(
|
|
101
|
+
tags=tag_names_list,
|
|
88
102
|
start="2025-01-31 08:00:00",
|
|
89
103
|
end="2025-01-31 09:00:00",
|
|
90
104
|
read_type=ReaderType.AVG,
|
|
91
105
|
interval=600, # 10 minute averages
|
|
92
106
|
)
|
|
107
|
+
# Type narrowing: as_json=False (default) guarantees pd.DataFrame
|
|
108
|
+
assert isinstance(result, pd.DataFrame)
|
|
109
|
+
df: pd.DataFrame = result
|
|
93
110
|
|
|
94
111
|
print(f"Data shape: {df.shape}")
|
|
95
112
|
print(f"\nFirst few rows:\n{df.head()}")
|
|
@@ -101,18 +118,26 @@ try:
|
|
|
101
118
|
print("Searching for all V1-01 tags...")
|
|
102
119
|
|
|
103
120
|
# Find all tags related to V1-01
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
121
|
+
tag_names_result2 = client.search(description="V1-01", return_desc=False, max_results=10)
|
|
122
|
+
# Type narrowing: return_desc=False guarantees list[str]
|
|
123
|
+
assert isinstance(tag_names_result2, list) and (
|
|
124
|
+
not tag_names_result2 or isinstance(tag_names_result2[0], str)
|
|
125
|
+
)
|
|
126
|
+
tag_names_v101: list[str] = tag_names_result2 # type: ignore[assignment]
|
|
127
|
+
print(f"Found {len(tag_names_v101)} tags: {tag_names_v101[:5]}...")
|
|
128
|
+
|
|
129
|
+
if tag_names_v101:
|
|
108
130
|
print("\nReading hourly averages...")
|
|
109
|
-
|
|
110
|
-
tags=
|
|
131
|
+
result = client.read(
|
|
132
|
+
tags=tag_names_v101[:5], # Limit to first 5 tags for demo
|
|
111
133
|
start="2025-01-31 00:00:00",
|
|
112
134
|
end="2025-01-31 12:00:00",
|
|
113
135
|
read_type=ReaderType.AVG,
|
|
114
136
|
interval=3600, # 1 hour averages
|
|
115
137
|
)
|
|
138
|
+
# Type narrowing: as_json=False (default) guarantees pd.DataFrame
|
|
139
|
+
assert isinstance(result, pd.DataFrame)
|
|
140
|
+
df: pd.DataFrame = result
|
|
116
141
|
|
|
117
142
|
print(f"Data shape: {df.shape}")
|
|
118
143
|
print(f"\nFirst few rows:\n{df.head()}")
|
|
@@ -124,7 +149,12 @@ try:
|
|
|
124
149
|
print("Searching for all G* tags with V1-01 in description...")
|
|
125
150
|
|
|
126
151
|
# Search broadly
|
|
127
|
-
|
|
152
|
+
all_tags_result = client.search(tag="G*", description="V1-01", max_results=20)
|
|
153
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
154
|
+
assert isinstance(all_tags_result, list) and (
|
|
155
|
+
not all_tags_result or isinstance(all_tags_result[0], dict)
|
|
156
|
+
)
|
|
157
|
+
all_tags: list[dict[str, str]] = all_tags_result # type: ignore[assignment]
|
|
128
158
|
print(f"Found {len(all_tags)} tags total")
|
|
129
159
|
|
|
130
160
|
# Filter to only temperature and pressure tags
|
|
@@ -140,12 +170,15 @@ try:
|
|
|
140
170
|
|
|
141
171
|
if selected_tags:
|
|
142
172
|
print("\nReading raw data...")
|
|
143
|
-
|
|
173
|
+
result = client.read(
|
|
144
174
|
tags=selected_tags[:3], # Limit to first 3 for demo
|
|
145
175
|
start="2025-01-31 08:00:00",
|
|
146
176
|
end="2025-01-31 09:00:00",
|
|
147
177
|
read_type=ReaderType.RAW,
|
|
148
178
|
)
|
|
179
|
+
# Type narrowing: as_json=False (default) guarantees pd.DataFrame
|
|
180
|
+
assert isinstance(result, pd.DataFrame)
|
|
181
|
+
df: pd.DataFrame = result
|
|
149
182
|
|
|
150
183
|
print(f"Data shape: {df.shape}")
|
|
151
184
|
print(f"\nFirst few rows:\n{df.head()}")
|
|
@@ -47,7 +47,10 @@ try:
|
|
|
47
47
|
# Example 1: Search by tag name pattern with wildcards
|
|
48
48
|
print("Example 1: Search all tags starting with 'TEMP'")
|
|
49
49
|
print("-" * 80)
|
|
50
|
-
|
|
50
|
+
tags_raw = client.search(tag="TEMP*", max_results=10)
|
|
51
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
52
|
+
assert isinstance(tags_raw, list) and (not tags_raw or isinstance(tags_raw[0], dict))
|
|
53
|
+
tags: list[dict[str, str]] = tags_raw # type: ignore[assignment]
|
|
51
54
|
print(f"Found {len(tags)} tags:\n")
|
|
52
55
|
for tag in tags[:5]: # Show first 5
|
|
53
56
|
print(f" {tag['name']:30} - {tag['description']}")
|
|
@@ -56,7 +59,10 @@ try:
|
|
|
56
59
|
# Example 2: Search by description only (uses SQL endpoint)
|
|
57
60
|
print("Example 2: Search all tags with description containing 'V1-01'")
|
|
58
61
|
print("-" * 80)
|
|
59
|
-
|
|
62
|
+
tags_raw = client.search(description="V1-01", max_results=10)
|
|
63
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
64
|
+
assert isinstance(tags_raw, list) and (not tags_raw or isinstance(tags_raw[0], dict))
|
|
65
|
+
tags: list[dict[str, str]] = tags_raw # type: ignore[assignment]
|
|
60
66
|
print(f"Found {len(tags)} tags:\n")
|
|
61
67
|
for tag in tags[:5]:
|
|
62
68
|
print(f" {tag['name']:30} - {tag['description']}")
|
|
@@ -65,7 +71,10 @@ try:
|
|
|
65
71
|
# Example 3: Combine tag pattern and description
|
|
66
72
|
print("Example 3: Search 'AI*' tags with 'temperature' in description")
|
|
67
73
|
print("-" * 80)
|
|
68
|
-
|
|
74
|
+
tags_raw = client.search(tag="AI*", description="temperature", max_results=10)
|
|
75
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
76
|
+
assert isinstance(tags_raw, list) and (not tags_raw or isinstance(tags_raw[0], dict))
|
|
77
|
+
tags: list[dict[str, str]] = tags_raw # type: ignore[assignment]
|
|
69
78
|
print(f"Found {len(tags)} tags:\n")
|
|
70
79
|
for tag in tags[:5]:
|
|
71
80
|
print(f" {tag['name']:30} - {tag['description']}")
|
|
@@ -83,7 +92,10 @@ try:
|
|
|
83
92
|
# Example 5: Case-insensitive search (default)
|
|
84
93
|
print("Example 5: Case-insensitive search (lowercase query)")
|
|
85
94
|
print("-" * 80)
|
|
86
|
-
|
|
95
|
+
tags_raw = client.search(tag="*", description="reactor", max_results=5)
|
|
96
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
97
|
+
assert isinstance(tags_raw, list) and (not tags_raw or isinstance(tags_raw[0], dict))
|
|
98
|
+
tags: list[dict[str, str]] = tags_raw # type: ignore[assignment]
|
|
87
99
|
print(f"Found {len(tags)} tags (matches 'Reactor', 'REACTOR', etc.):\n")
|
|
88
100
|
for tag in tags[:5]:
|
|
89
101
|
print(f" {tag['name']:30} - {tag['description']}")
|
|
@@ -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.0b4'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'b4')
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'gb96f5454a'
|
|
@@ -12,7 +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
|
+
from .query_builder import build_history_sql_query, build_read_query, build_sql_search_query
|
|
16
16
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
18
|
from httpx import Auth
|
|
@@ -128,6 +128,56 @@ class AspenClient:
|
|
|
128
128
|
"""Close the HTTP client connection."""
|
|
129
129
|
self._client.close()
|
|
130
130
|
|
|
131
|
+
def _parse_sql_history_response(
|
|
132
|
+
self, response: list[dict], tag_name: str
|
|
133
|
+
) -> tuple[pd.DataFrame, str]:
|
|
134
|
+
"""Parse SQL history response into DataFrame.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
response: SQL response as list of record dictionaries
|
|
138
|
+
tag_name: Name of the tag being queried
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Tuple of (DataFrame with timestamp index and tag data, tag description)
|
|
142
|
+
|
|
143
|
+
SQL response format:
|
|
144
|
+
[
|
|
145
|
+
{"ts": "2025-11-02T00:00:00.000000Z", "name": "TAG",
|
|
146
|
+
"name->ip_description": "desc", "value": 22.85},
|
|
147
|
+
...
|
|
148
|
+
]
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
if not response or not isinstance(response, list):
|
|
152
|
+
logger.warning(f"No data in SQL response for tag {tag_name}")
|
|
153
|
+
return pd.DataFrame(), ""
|
|
154
|
+
|
|
155
|
+
# Extract description from first record if available
|
|
156
|
+
description = ""
|
|
157
|
+
if response and "name->ip_description" in response[0]:
|
|
158
|
+
description = response[0]["name->ip_description"] or ""
|
|
159
|
+
|
|
160
|
+
# Build DataFrame from records
|
|
161
|
+
rows = []
|
|
162
|
+
for record in response:
|
|
163
|
+
timestamp = pd.to_datetime(record["ts"])
|
|
164
|
+
value = record["value"]
|
|
165
|
+
rows.append({"time": timestamp, tag_name: value})
|
|
166
|
+
|
|
167
|
+
if not rows:
|
|
168
|
+
logger.warning(f"No valid data in SQL response for tag {tag_name}")
|
|
169
|
+
return pd.DataFrame(), description
|
|
170
|
+
|
|
171
|
+
df = pd.DataFrame(rows)
|
|
172
|
+
df = df.set_index("time")
|
|
173
|
+
|
|
174
|
+
return df, description
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error(f"Error parsing SQL response for tag {tag_name}: {e}")
|
|
178
|
+
logger.debug(f"Response was: {response}")
|
|
179
|
+
return pd.DataFrame(), ""
|
|
180
|
+
|
|
131
181
|
def _parse_aspen_response(
|
|
132
182
|
self, response: dict, tag_name: str, include_status: bool = True
|
|
133
183
|
) -> tuple[pd.DataFrame, str]:
|
|
@@ -284,7 +334,7 @@ class AspenClient:
|
|
|
284
334
|
max_rows: Maximum number of rows to return per tag (default: 100000)
|
|
285
335
|
as_json: Return data as list of dictionaries instead of DataFrame (default: False)
|
|
286
336
|
with_description: Include tag descriptions in response (default: False).
|
|
287
|
-
|
|
337
|
+
Note: Some Aspen servers may not support this field.
|
|
288
338
|
|
|
289
339
|
Returns:
|
|
290
340
|
If as_json=False: pandas DataFrame with time index and columns for each tag.
|
|
@@ -326,30 +376,67 @@ class AspenClient:
|
|
|
326
376
|
frames: list[pd.DataFrame] = []
|
|
327
377
|
tag_descriptions: dict[str, str] = {}
|
|
328
378
|
|
|
379
|
+
# Determine if we should use SQL endpoint (for RAW/INT with datasource configured)
|
|
380
|
+
use_sql = read_type in (ReaderType.RAW, ReaderType.INT) and self.datasource
|
|
381
|
+
|
|
382
|
+
if use_sql:
|
|
383
|
+
logger.debug(f"Using SQL endpoint for {read_type.value} read")
|
|
384
|
+
else:
|
|
385
|
+
logger.debug(f"Using XML endpoint for {read_type.value} read")
|
|
386
|
+
|
|
329
387
|
# Process each tag individually (Aspen API queries one tag at a time)
|
|
330
388
|
for tag_idx, tag in enumerate(tags_list, 1):
|
|
331
389
|
logger.debug(f"Processing tag {tag_idx}/{len(tags_list)}: {tag}")
|
|
332
390
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
391
|
+
if use_sql:
|
|
392
|
+
# Use SQL endpoint for historical data
|
|
393
|
+
xml_query = build_history_sql_query(
|
|
394
|
+
tag=tag,
|
|
395
|
+
start=start,
|
|
396
|
+
end=end,
|
|
397
|
+
datasource=self.datasource,
|
|
398
|
+
max_rows=max_rows,
|
|
399
|
+
with_description=with_description or as_json,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
sql_url = f"{self.base_url}/SQL"
|
|
403
|
+
logger.debug(f"POST {sql_url}")
|
|
404
|
+
logger.debug(f"SQL query XML: {xml_query}")
|
|
405
|
+
|
|
406
|
+
response = self._client.post(
|
|
407
|
+
sql_url, content=xml_query, headers={"Content-Type": "text/xml"}
|
|
408
|
+
)
|
|
409
|
+
response.raise_for_status()
|
|
410
|
+
sql_response = response.json()
|
|
411
|
+
|
|
412
|
+
logger.debug(f"SQL response type: {type(sql_response)}")
|
|
413
|
+
logger.debug(f"SQL response: {sql_response}")
|
|
414
|
+
|
|
415
|
+
# Parse SQL response (simple JSON array)
|
|
416
|
+
df, description = self._parse_sql_history_response(sql_response, tag)
|
|
417
|
+
logger.debug(f"Parsed {len(df)} data points for tag {tag}")
|
|
347
418
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
419
|
+
else:
|
|
420
|
+
# Use original XML endpoint for other read types
|
|
421
|
+
xml_query = build_read_query(
|
|
422
|
+
tag=tag,
|
|
423
|
+
start=start,
|
|
424
|
+
end=end,
|
|
425
|
+
read_type=read_type,
|
|
426
|
+
interval=interval,
|
|
427
|
+
datasource=self.datasource,
|
|
428
|
+
max_rows=max_rows,
|
|
429
|
+
with_description=with_description,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
response = self._fetch(xml_query)
|
|
433
|
+
logger.debug(f"Received response for tag: {tag}")
|
|
434
|
+
|
|
435
|
+
# Parse the Aspen-style response
|
|
436
|
+
df, description = self._parse_aspen_response(
|
|
437
|
+
response, tag, include_status=include_status
|
|
438
|
+
)
|
|
439
|
+
logger.debug(f"Parsed {len(df)} data points for tag {tag}")
|
|
353
440
|
|
|
354
441
|
if not df.empty:
|
|
355
442
|
frames.append(df)
|
|
@@ -77,6 +77,57 @@ def build_read_query(
|
|
|
77
77
|
return xml
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def build_history_sql_query(
|
|
81
|
+
tag: str,
|
|
82
|
+
start: str,
|
|
83
|
+
end: str,
|
|
84
|
+
datasource: str,
|
|
85
|
+
max_rows: int = 100000,
|
|
86
|
+
with_description: bool = True,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Generate SQL query for historical data read.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
tag: Tag name
|
|
92
|
+
start: Start timestamp (ISO format)
|
|
93
|
+
end: End timestamp (ISO format)
|
|
94
|
+
datasource: Aspen datasource name
|
|
95
|
+
max_rows: Maximum number of rows to return
|
|
96
|
+
with_description: Include ip_description field in response
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
XML query string for SQL endpoint
|
|
100
|
+
"""
|
|
101
|
+
# Convert timestamps to Aspen SQL format (DD-MMM-YY)
|
|
102
|
+
start_dt = pd.to_datetime(start)
|
|
103
|
+
end_dt = pd.to_datetime(end)
|
|
104
|
+
start_sql = start_dt.strftime("%d-%b-%y")
|
|
105
|
+
end_sql = end_dt.strftime("%d-%b-%y")
|
|
106
|
+
|
|
107
|
+
# Build SELECT clause
|
|
108
|
+
if with_description:
|
|
109
|
+
select_fields = "ts, name, name->ip_description, value"
|
|
110
|
+
else:
|
|
111
|
+
select_fields = "ts, name, value"
|
|
112
|
+
|
|
113
|
+
# Build SQL query
|
|
114
|
+
sql_query = (
|
|
115
|
+
f"Select {select_fields} from history "
|
|
116
|
+
f"where name = '{tag}' and ts between '{start_sql}' and '{end_sql}'"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Build XML request for SQL endpoint
|
|
120
|
+
xml = (
|
|
121
|
+
f'<SQL g="aspy21_history" t="SQLplus" ds="{datasource}" '
|
|
122
|
+
f'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
|
|
123
|
+
f'm="{max_rows}" to="30" response="Record" s="1">'
|
|
124
|
+
f"<![CDATA[{sql_query}]]>"
|
|
125
|
+
f"</SQL>"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return xml
|
|
129
|
+
|
|
130
|
+
|
|
80
131
|
def build_sql_search_query(
|
|
81
132
|
datasource: str,
|
|
82
133
|
description: str,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aspy21
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.0b4
|
|
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>
|
|
@@ -214,12 +214,19 @@ def test_aggregated_read_with_interval(mock_api):
|
|
|
214
214
|
|
|
215
215
|
|
|
216
216
|
def test_datasource_parameter(mock_api):
|
|
217
|
-
"""Test that datasource parameter is included in
|
|
218
|
-
|
|
217
|
+
"""Test that datasource parameter is included in SQL query for RAW reads."""
|
|
218
|
+
# RAW reads with datasource use SQL endpoint
|
|
219
|
+
route = mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll/SQL")
|
|
219
220
|
route.mock(
|
|
220
221
|
return_value=httpx.Response(
|
|
221
222
|
200,
|
|
222
|
-
json=
|
|
223
|
+
json=[
|
|
224
|
+
{
|
|
225
|
+
"ts": "2025-06-20T08:00:00",
|
|
226
|
+
"name": "TAG1",
|
|
227
|
+
"value": 1.0,
|
|
228
|
+
}
|
|
229
|
+
],
|
|
223
230
|
)
|
|
224
231
|
)
|
|
225
232
|
|
|
@@ -237,12 +244,12 @@ def test_datasource_parameter(mock_api):
|
|
|
237
244
|
read_type=ReaderType.RAW,
|
|
238
245
|
)
|
|
239
246
|
|
|
240
|
-
# Verify datasource is in the XML
|
|
247
|
+
# Verify datasource is in the SQL query XML
|
|
241
248
|
assert route.called
|
|
242
249
|
request = route.calls.last.request
|
|
243
250
|
request_body = request.content.decode("utf-8")
|
|
244
251
|
|
|
245
|
-
assert "
|
|
252
|
+
assert 'ds="MY_DATASOURCE"' in request_body
|
|
246
253
|
assert isinstance(df, pd.DataFrame)
|
|
247
254
|
c.close()
|
|
248
255
|
|
|
@@ -302,7 +309,10 @@ def test_search_by_tag_pattern(mock_api):
|
|
|
302
309
|
)
|
|
303
310
|
|
|
304
311
|
# Search with wildcard
|
|
305
|
-
|
|
312
|
+
results_raw = c.search(tag="TEMP*")
|
|
313
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
314
|
+
assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
|
|
315
|
+
results: list[dict[str, str]] = results_raw # type: ignore[assignment]
|
|
306
316
|
|
|
307
317
|
assert isinstance(results, list)
|
|
308
318
|
assert len(results) == 3
|
|
@@ -358,7 +368,10 @@ def test_search_by_description(mock_api):
|
|
|
358
368
|
)
|
|
359
369
|
|
|
360
370
|
# Search by description - should use SQL endpoint
|
|
361
|
-
|
|
371
|
+
results_raw = c.search(description="reactor")
|
|
372
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
373
|
+
assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
|
|
374
|
+
results: list[dict[str, str]] = results_raw # type: ignore[assignment]
|
|
362
375
|
|
|
363
376
|
assert len(results) == 2
|
|
364
377
|
assert results[0]["name"] == "TEMP_101"
|
|
@@ -416,7 +429,10 @@ def test_search_combined_filters(mock_api):
|
|
|
416
429
|
)
|
|
417
430
|
|
|
418
431
|
# Search with both filters - SQL WHERE clause filters server-side
|
|
419
|
-
|
|
432
|
+
results_raw = c.search(tag="AI_1*", description="reactor")
|
|
433
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
434
|
+
assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
|
|
435
|
+
results: list[dict[str, str]] = results_raw # type: ignore[assignment]
|
|
420
436
|
|
|
421
437
|
assert len(results) == 2
|
|
422
438
|
assert all("AI_1" in tag["name"] for tag in results)
|
|
@@ -470,7 +486,10 @@ def test_search_case_insensitive(mock_api):
|
|
|
470
486
|
)
|
|
471
487
|
|
|
472
488
|
# Search with lowercase description (should use SQL endpoint)
|
|
473
|
-
|
|
489
|
+
results_raw = c.search(description="REACTOR")
|
|
490
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
491
|
+
assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
|
|
492
|
+
results: list[dict[str, str]] = results_raw # type: ignore[assignment]
|
|
474
493
|
|
|
475
494
|
assert len(results) == 2 # Case-insensitive match
|
|
476
495
|
c.close()
|
|
@@ -525,7 +544,10 @@ def test_search_return_desc_false(mock_api):
|
|
|
525
544
|
)
|
|
526
545
|
|
|
527
546
|
# Search with return_desc=False - should return list of strings
|
|
528
|
-
|
|
547
|
+
results_raw = c.search(tag="TEMP*", return_desc=False)
|
|
548
|
+
# Type narrowing: return_desc=False guarantees list[str]
|
|
549
|
+
assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], str))
|
|
550
|
+
results: list[str] = results_raw # type: ignore[assignment]
|
|
529
551
|
|
|
530
552
|
assert isinstance(results, list)
|
|
531
553
|
assert len(results) == 2
|
|
@@ -561,7 +583,10 @@ def test_search_by_tag_only(mock_api):
|
|
|
561
583
|
)
|
|
562
584
|
|
|
563
585
|
# Search by tag only (should use Browse endpoint, not SQL)
|
|
564
|
-
|
|
586
|
+
results_raw = c.search(tag="AI*")
|
|
587
|
+
# Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
|
|
588
|
+
assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
|
|
589
|
+
results: list[dict[str, str]] = results_raw # type: ignore[assignment]
|
|
565
590
|
|
|
566
591
|
assert len(results) == 2
|
|
567
592
|
assert results[0]["name"] == "AI_101"
|
|
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
|