aspy21 0.1.1b8__py3-none-any.whl
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/__init__.py +17 -0
- aspy21/_version.py +34 -0
- aspy21/cache.py +34 -0
- aspy21/client.py +684 -0
- aspy21/models.py +19 -0
- aspy21/utils.py +27 -0
- aspy21-0.1.1b8.dist-info/METADATA +358 -0
- aspy21-0.1.1b8.dist-info/RECORD +11 -0
- aspy21-0.1.1b8.dist-info/WHEEL +5 -0
- aspy21-0.1.1b8.dist-info/licenses/LICENSE +21 -0
- aspy21-0.1.1b8.dist-info/top_level.txt +1 -0
aspy21/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aspy21 - Modern Python client for Aspen InfoPlus.21 (IP.21).
|
|
3
|
+
|
|
4
|
+
A high-performance REST API client for accessing Aspen process data
|
|
5
|
+
with pandas DataFrame output and flexible batching.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import AspenClient, configure_logging
|
|
9
|
+
from .models import ReaderType
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from ._version import __version__ # type: ignore[import-not-found]
|
|
13
|
+
except ImportError:
|
|
14
|
+
# Development mode - no version file generated yet
|
|
15
|
+
__version__ = "0.0.0.dev0"
|
|
16
|
+
|
|
17
|
+
__all__ = ["AspenClient", "ReaderType", "configure_logging", "__version__"]
|
aspy21/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1.1b8'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 1, 'b8')
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
aspy21/cache.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SmartCache:
|
|
8
|
+
def __init__(self, db_path: Path):
|
|
9
|
+
self.db_path = Path(db_path)
|
|
10
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
self._init()
|
|
12
|
+
|
|
13
|
+
def _init(self) -> None:
|
|
14
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
15
|
+
conn.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB)")
|
|
16
|
+
conn.execute("""
|
|
17
|
+
CREATE TABLE IF NOT EXISTS hist (
|
|
18
|
+
tag TEXT,
|
|
19
|
+
ts TEXT,
|
|
20
|
+
value BLOB,
|
|
21
|
+
status INTEGER,
|
|
22
|
+
PRIMARY KEY (tag, ts)
|
|
23
|
+
)
|
|
24
|
+
""")
|
|
25
|
+
|
|
26
|
+
def put_meta(self, key: str, value: bytes) -> None:
|
|
27
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
28
|
+
conn.execute("REPLACE INTO meta (key, value) VALUES (?,?)", (key, value))
|
|
29
|
+
|
|
30
|
+
def get_meta(self, key: str) -> bytes | None:
|
|
31
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
32
|
+
cur = conn.execute("SELECT value FROM meta WHERE key=?", (key,))
|
|
33
|
+
row = cur.fetchone()
|
|
34
|
+
return row[0] if row else None
|
aspy21/client.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"""Main client for interacting with Aspen InfoPlus.21 REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
13
|
+
|
|
14
|
+
from .models import ReaderType
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from httpx import Auth
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def configure_logging(level: str | None = None) -> None:
|
|
23
|
+
"""Configure logging level for aspy21 library.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
|
27
|
+
If None, reads from ASPEN_LOG_LEVEL environment variable.
|
|
28
|
+
Defaults to WARNING if not set.
|
|
29
|
+
"""
|
|
30
|
+
if level is None:
|
|
31
|
+
level = os.getenv("ASPEN_LOG_LEVEL", "WARNING")
|
|
32
|
+
|
|
33
|
+
numeric_level = getattr(logging, level.upper(), logging.WARNING)
|
|
34
|
+
logger.setLevel(numeric_level)
|
|
35
|
+
|
|
36
|
+
# Only add handler if logger doesn't have one already
|
|
37
|
+
if not logger.handlers:
|
|
38
|
+
handler = logging.StreamHandler()
|
|
39
|
+
formatter = logging.Formatter(
|
|
40
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
|
41
|
+
)
|
|
42
|
+
handler.setFormatter(formatter)
|
|
43
|
+
logger.addHandler(handler)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AspenClient:
|
|
47
|
+
"""Client for Aspen InfoPlus.21 REST API.
|
|
48
|
+
|
|
49
|
+
Provides methods to read historical and real-time process data from
|
|
50
|
+
Aspen IP.21 historian via REST API with automatic batching, retries,
|
|
51
|
+
and pandas DataFrame output.
|
|
52
|
+
|
|
53
|
+
Supports context manager protocol for automatic resource cleanup.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
base_url: str,
|
|
59
|
+
*,
|
|
60
|
+
auth: Auth | tuple[str, str] | None = None,
|
|
61
|
+
timeout: float = 30.0,
|
|
62
|
+
verify_ssl: bool = True,
|
|
63
|
+
datasource: str | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Initialize the Aspen client.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
base_url: Base URL of the Aspen ProcessData REST API
|
|
69
|
+
auth: Authentication as (username, password) tuple or httpx Auth object.
|
|
70
|
+
If None, no authentication is used.
|
|
71
|
+
timeout: Request timeout in seconds (default: 30.0)
|
|
72
|
+
verify_ssl: Whether to verify SSL certificates (default: True)
|
|
73
|
+
datasource: Aspen datasource name. If None, uses server default.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
Using context manager with authentication:
|
|
77
|
+
>>> with AspenClient(
|
|
78
|
+
... base_url="https://aspen.example.com/ProcessData",
|
|
79
|
+
... auth=("user", "pass")
|
|
80
|
+
... ) as client:
|
|
81
|
+
... df = client.read(["TAG1"], "2025-01-01", "2025-01-02")
|
|
82
|
+
|
|
83
|
+
Without authentication:
|
|
84
|
+
>>> with AspenClient(base_url="http://aspen.example.com/ProcessData") as client:
|
|
85
|
+
... df = client.read(["TAG1"], "2025-01-01", "2025-01-02")
|
|
86
|
+
|
|
87
|
+
With datasource for search:
|
|
88
|
+
>>> with AspenClient(
|
|
89
|
+
... base_url="https://aspen.example.com/ProcessData",
|
|
90
|
+
... auth=("user", "pass"),
|
|
91
|
+
... datasource="IP21"
|
|
92
|
+
... ) as client:
|
|
93
|
+
... tags = client.search(tag="TEMP*")
|
|
94
|
+
"""
|
|
95
|
+
self.base_url = base_url.rstrip("/")
|
|
96
|
+
self.timeout = timeout
|
|
97
|
+
self.verify_ssl = verify_ssl
|
|
98
|
+
self.datasource = datasource or "" # Empty string = use server default
|
|
99
|
+
self.auth = auth
|
|
100
|
+
|
|
101
|
+
self._client = httpx.Client(timeout=timeout, verify=verify_ssl, auth=auth)
|
|
102
|
+
|
|
103
|
+
logger.info(f"Initialized AspenClient for {self.base_url}")
|
|
104
|
+
logger.debug(
|
|
105
|
+
f"Config: timeout={timeout}s, verify_ssl={verify_ssl}, datasource={datasource}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def __enter__(self) -> AspenClient:
|
|
109
|
+
"""Enter context manager.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
self for use in with statement
|
|
113
|
+
"""
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
117
|
+
"""Exit context manager and close connection.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
exc_type: Exception type if an exception occurred
|
|
121
|
+
exc_val: Exception value if an exception occurred
|
|
122
|
+
exc_tb: Exception traceback if an exception occurred
|
|
123
|
+
"""
|
|
124
|
+
self.close()
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
"""Close the HTTP client connection."""
|
|
128
|
+
self._client.close()
|
|
129
|
+
|
|
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
|
+
def _parse_aspen_response(
|
|
194
|
+
self, response: dict, tag_name: str, include_status: bool = True
|
|
195
|
+
) -> pd.DataFrame:
|
|
196
|
+
"""Parse Aspen REST API response into DataFrame.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
response: Response dict from Aspen API
|
|
200
|
+
tag_name: Name of the tag being queried
|
|
201
|
+
include_status: Whether to include status column
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
DataFrame with timestamp index and tag data
|
|
205
|
+
|
|
206
|
+
Aspen API returns:
|
|
207
|
+
{
|
|
208
|
+
"data": [
|
|
209
|
+
{
|
|
210
|
+
"samples": [
|
|
211
|
+
{"t": timestamp_ms, "v": value, "s": status},
|
|
212
|
+
...
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
"""
|
|
218
|
+
try:
|
|
219
|
+
# Get data array
|
|
220
|
+
data = response.get("data", [])
|
|
221
|
+
if not data or not isinstance(data, list):
|
|
222
|
+
logger.warning(f"No data array in response for tag {tag_name}")
|
|
223
|
+
return pd.DataFrame()
|
|
224
|
+
|
|
225
|
+
# Get first element (should contain samples)
|
|
226
|
+
tag_data = data[0] if len(data) > 0 else {}
|
|
227
|
+
|
|
228
|
+
# Check for errors in samples
|
|
229
|
+
samples = tag_data.get("samples", [])
|
|
230
|
+
if samples and isinstance(samples, list) and len(samples) > 0:
|
|
231
|
+
first_sample = samples[0]
|
|
232
|
+
# Check if first sample contains an error
|
|
233
|
+
if "er" in first_sample and first_sample.get("er", 0) != 0:
|
|
234
|
+
error_msg = first_sample.get("es", "Unknown error")
|
|
235
|
+
logger.warning(f"API error for tag {tag_name}: {error_msg}")
|
|
236
|
+
return pd.DataFrame()
|
|
237
|
+
|
|
238
|
+
if not samples:
|
|
239
|
+
logger.warning(f"No samples found for tag {tag_name}")
|
|
240
|
+
return pd.DataFrame()
|
|
241
|
+
|
|
242
|
+
# Build DataFrame
|
|
243
|
+
rows = []
|
|
244
|
+
for sample in samples:
|
|
245
|
+
# Skip error samples
|
|
246
|
+
if "er" in sample:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
row = {
|
|
250
|
+
"time": pd.to_datetime(sample["t"], unit="ms", utc=True).tz_convert(None),
|
|
251
|
+
tag_name: sample.get("v"),
|
|
252
|
+
}
|
|
253
|
+
if include_status:
|
|
254
|
+
row["status"] = sample.get("s", 0)
|
|
255
|
+
rows.append(row)
|
|
256
|
+
|
|
257
|
+
if not rows:
|
|
258
|
+
logger.warning(f"No valid data samples for tag {tag_name}")
|
|
259
|
+
return pd.DataFrame()
|
|
260
|
+
|
|
261
|
+
df = pd.DataFrame(rows)
|
|
262
|
+
if not df.empty:
|
|
263
|
+
df = df.set_index("time")
|
|
264
|
+
|
|
265
|
+
return df
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.error(f"Error parsing response for tag {tag_name}: {e}")
|
|
269
|
+
logger.debug(f"Response was: {response}")
|
|
270
|
+
return pd.DataFrame()
|
|
271
|
+
|
|
272
|
+
@retry(wait=wait_exponential(multiplier=0.5, min=0.5, max=8), stop=stop_after_attempt(3))
|
|
273
|
+
def _fetch(self, xml_query: str) -> dict:
|
|
274
|
+
"""Fetch data from API endpoint with automatic retry.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
xml_query: XML query string
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
JSON response as dictionary
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
httpx.HTTPError: If the request fails after retries
|
|
284
|
+
"""
|
|
285
|
+
logger.debug(f"POST {self.base_url}")
|
|
286
|
+
logger.debug(f"Query XML: {xml_query}")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
# Try sending XML as POST body with correct content type
|
|
290
|
+
r = self._client.post(
|
|
291
|
+
self.base_url, content=xml_query, headers={"Content-Type": "text/xml"}
|
|
292
|
+
)
|
|
293
|
+
logger.debug(f"Response status: {r.status_code}")
|
|
294
|
+
logger.debug(f"Response headers: {dict(r.headers)}")
|
|
295
|
+
|
|
296
|
+
r.raise_for_status()
|
|
297
|
+
response_data = r.json()
|
|
298
|
+
|
|
299
|
+
logger.debug(
|
|
300
|
+
f"Response data keys: {list(response_data.keys()) if response_data else 'empty'}"
|
|
301
|
+
)
|
|
302
|
+
logger.debug(f"Full response: {response_data}") # Show complete response for debugging
|
|
303
|
+
|
|
304
|
+
return response_data
|
|
305
|
+
|
|
306
|
+
except httpx.HTTPStatusError as e:
|
|
307
|
+
logger.error(f"HTTP {e.response.status_code} error for {self.base_url}")
|
|
308
|
+
logger.error(f"Response body: {e.response.text[:500]}")
|
|
309
|
+
raise
|
|
310
|
+
except httpx.RequestError as e:
|
|
311
|
+
logger.error(f"Request error: {type(e).__name__}: {e}")
|
|
312
|
+
raise
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.error(f"Unexpected error: {type(e).__name__}: {e}")
|
|
315
|
+
raise
|
|
316
|
+
|
|
317
|
+
def read(
|
|
318
|
+
self,
|
|
319
|
+
tags: Iterable[str],
|
|
320
|
+
start: str,
|
|
321
|
+
end: str,
|
|
322
|
+
interval: int | None = None,
|
|
323
|
+
read_type: ReaderType = ReaderType.RAW,
|
|
324
|
+
include_status: bool = True,
|
|
325
|
+
max_rows: int = 100000,
|
|
326
|
+
) -> pd.DataFrame:
|
|
327
|
+
"""Read historical data for multiple tags.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
tags: List of tag names to retrieve
|
|
331
|
+
start: Start timestamp (ISO format or compatible string)
|
|
332
|
+
end: End timestamp (ISO format or compatible string)
|
|
333
|
+
interval: Optional interval in seconds for aggregated data
|
|
334
|
+
read_type: Type of data retrieval (RAW, INT, SNAPSHOT, AVG)
|
|
335
|
+
include_status: Include status column in output (default: True)
|
|
336
|
+
max_rows: Maximum number of rows to return per tag (default: 100000)
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
pandas DataFrame with time index and columns for each tag.
|
|
340
|
+
If include_status=True, includes a 'status' column.
|
|
341
|
+
|
|
342
|
+
Example:
|
|
343
|
+
>>> client = AspenClient("https://aspen.example.com/ProcessData")
|
|
344
|
+
>>> df = client.read(
|
|
345
|
+
... ["ATI111", "AP101.PV"],
|
|
346
|
+
... start="2025-01-01 00:00:00",
|
|
347
|
+
... end="2025-01-01 01:00:00",
|
|
348
|
+
... read_type=ReaderType.RAW
|
|
349
|
+
... )
|
|
350
|
+
|
|
351
|
+
>>> # Get more rows if needed
|
|
352
|
+
>>> df = client.read(
|
|
353
|
+
... ["ATI111"],
|
|
354
|
+
... start="2025-01-01 00:00:00",
|
|
355
|
+
... end="2025-01-02 00:00:00",
|
|
356
|
+
... max_rows=500000
|
|
357
|
+
... )
|
|
358
|
+
"""
|
|
359
|
+
tags_list = list(tags)
|
|
360
|
+
logger.info(f"Reading {len(tags_list)} tag(s) from {start} to {end} (max_rows={max_rows})")
|
|
361
|
+
logger.debug(f"Tags: {tags_list}")
|
|
362
|
+
logger.debug(f"Reader type: {read_type.value}, Interval: {interval}")
|
|
363
|
+
|
|
364
|
+
frames: list[pd.DataFrame] = []
|
|
365
|
+
|
|
366
|
+
# Process each tag individually (Aspen API queries one tag at a time)
|
|
367
|
+
for tag_idx, tag in enumerate(tags_list, 1):
|
|
368
|
+
logger.debug(f"Processing tag {tag_idx}/{len(tags_list)}: {tag}")
|
|
369
|
+
|
|
370
|
+
# Generate XML query for this tag
|
|
371
|
+
xml_query = self._generate_xml_query(
|
|
372
|
+
tag=tag,
|
|
373
|
+
start=start,
|
|
374
|
+
end=end,
|
|
375
|
+
read_type=read_type,
|
|
376
|
+
interval=interval,
|
|
377
|
+
datasource=self.datasource,
|
|
378
|
+
max_rows=max_rows,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
response = self._fetch(xml_query)
|
|
382
|
+
logger.debug(f"Received response for tag: {tag}")
|
|
383
|
+
|
|
384
|
+
# Parse the Aspen-style response
|
|
385
|
+
df = self._parse_aspen_response(response, tag, include_status=include_status)
|
|
386
|
+
logger.debug(f"Parsed {len(df)} data points for tag {tag}")
|
|
387
|
+
|
|
388
|
+
if not df.empty:
|
|
389
|
+
frames.append(df)
|
|
390
|
+
|
|
391
|
+
if not frames:
|
|
392
|
+
logger.warning("No data returned from API")
|
|
393
|
+
return pd.DataFrame()
|
|
394
|
+
|
|
395
|
+
# Merge frames by index (time), combining columns for different tags
|
|
396
|
+
out = pd.concat(frames, axis=1)
|
|
397
|
+
out = out.sort_index()
|
|
398
|
+
|
|
399
|
+
logger.info(f"Successfully retrieved {len(out)} rows for {len(out.columns)} column(s)")
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
def _search_by_sql(
|
|
403
|
+
self,
|
|
404
|
+
description: str,
|
|
405
|
+
tag_pattern: str = "*",
|
|
406
|
+
max_results: int = 10000,
|
|
407
|
+
) -> list[dict[str, str]]:
|
|
408
|
+
"""Search for tags by description using SQL endpoint.
|
|
409
|
+
|
|
410
|
+
This is an internal method that uses the Aspen SQL endpoint to search
|
|
411
|
+
by tag description (ip_description field).
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
description: Description pattern to search for (SQL LIKE pattern)
|
|
415
|
+
tag_pattern: Optional tag name pattern to filter by (default: "*")
|
|
416
|
+
max_results: Maximum number of results to return (default: 10000)
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
List of dictionaries with 'name' and 'description' keys
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
ValueError: If datasource is not configured
|
|
423
|
+
"""
|
|
424
|
+
if not self.datasource:
|
|
425
|
+
raise ValueError(
|
|
426
|
+
"Datasource is required for SQL search. "
|
|
427
|
+
"Please set datasource when creating AspenClient: "
|
|
428
|
+
"AspenClient(base_url=..., datasource='your_datasource')"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Build SQL query - search for description pattern
|
|
432
|
+
# Note: SQL LIKE uses % as wildcard, not *
|
|
433
|
+
sql_pattern = description.replace("*", "%")
|
|
434
|
+
if not sql_pattern.startswith("%"):
|
|
435
|
+
sql_pattern = f"%{sql_pattern}%"
|
|
436
|
+
|
|
437
|
+
sql_query = (
|
|
438
|
+
f"Select name, name->ip_description d, name->ip_input_value "
|
|
439
|
+
f"from all_records where d like '{sql_pattern}'"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Build XML request for SQL endpoint
|
|
443
|
+
xml = (
|
|
444
|
+
f'<SQL g="aspy21_search" t="SQLplus" ds="{self.datasource}" '
|
|
445
|
+
f'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
|
|
446
|
+
f'm="{max_results}" to="30" response="Original" s="1">'
|
|
447
|
+
f"<![CDATA[{sql_query}]]>"
|
|
448
|
+
f"</SQL>"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
logger.debug(f"SQL query XML: {xml}")
|
|
452
|
+
|
|
453
|
+
sql_url = f"{self.base_url}/SQL"
|
|
454
|
+
logger.info(f"SQL request: POST {sql_url}")
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
response = self._client.post(sql_url, content=xml, headers={"Content-Type": "text/xml"})
|
|
458
|
+
|
|
459
|
+
logger.debug(f"Response status: {response.status_code}")
|
|
460
|
+
response.raise_for_status()
|
|
461
|
+
|
|
462
|
+
data = response.json()
|
|
463
|
+
logger.debug(f"SQL response keys: {list(data.keys())}")
|
|
464
|
+
|
|
465
|
+
# Parse SQL response format
|
|
466
|
+
# Expected: {"data": [{"g": "...", "r": "D", "cols": [...], "rows": [{"fld": [...]}]}]}
|
|
467
|
+
if "data" not in data or not isinstance(data["data"], list):
|
|
468
|
+
logger.warning("Unexpected SQL response structure")
|
|
469
|
+
logger.debug(f"Full response: {data}")
|
|
470
|
+
return []
|
|
471
|
+
|
|
472
|
+
data_array = data["data"]
|
|
473
|
+
if not data_array or len(data_array) == 0:
|
|
474
|
+
logger.info("SQL search returned 0 results")
|
|
475
|
+
return []
|
|
476
|
+
|
|
477
|
+
# Get first result set
|
|
478
|
+
result_set = data_array[0]
|
|
479
|
+
if not isinstance(result_set, dict):
|
|
480
|
+
logger.warning("Unexpected result set structure")
|
|
481
|
+
return []
|
|
482
|
+
|
|
483
|
+
# Check for errors
|
|
484
|
+
if "result" in result_set:
|
|
485
|
+
result = result_set["result"]
|
|
486
|
+
if isinstance(result, dict) and result.get("er", 0) != 0:
|
|
487
|
+
error_msg = result.get("es", "Unknown error")
|
|
488
|
+
logger.error(f"API error from SQL endpoint: {error_msg}")
|
|
489
|
+
raise ValueError(f"SQL API error: {error_msg}")
|
|
490
|
+
|
|
491
|
+
# Get rows array
|
|
492
|
+
rows = result_set.get("rows", [])
|
|
493
|
+
if not rows:
|
|
494
|
+
logger.info("SQL search returned 0 results")
|
|
495
|
+
return []
|
|
496
|
+
|
|
497
|
+
logger.debug(f"Found {len(rows)} rows in SQL response")
|
|
498
|
+
|
|
499
|
+
results = []
|
|
500
|
+
for row in rows:
|
|
501
|
+
if not isinstance(row, dict) or "fld" not in row:
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
# Extract field values: fld is array of {"i": index, "v": value}
|
|
505
|
+
fields = row["fld"]
|
|
506
|
+
if len(fields) < 2:
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
tag_name = fields[0].get("v", "")
|
|
510
|
+
tag_desc = fields[1].get("v", "")
|
|
511
|
+
|
|
512
|
+
# Apply tag pattern filter if specified
|
|
513
|
+
if tag_pattern != "*":
|
|
514
|
+
# Convert wildcard pattern to regex for matching
|
|
515
|
+
import re
|
|
516
|
+
|
|
517
|
+
regex_pattern = tag_pattern.replace("*", ".*").replace("?", ".")
|
|
518
|
+
if not re.match(f"^{regex_pattern}$", tag_name, re.IGNORECASE):
|
|
519
|
+
continue
|
|
520
|
+
|
|
521
|
+
results.append({"name": tag_name, "description": tag_desc})
|
|
522
|
+
|
|
523
|
+
logger.info(f"Found {len(results)} matching tags via SQL")
|
|
524
|
+
return results[:max_results]
|
|
525
|
+
|
|
526
|
+
except Exception as e:
|
|
527
|
+
logger.error(f"Error in SQL search: {type(e).__name__}: {e}")
|
|
528
|
+
raise
|
|
529
|
+
|
|
530
|
+
def search(
|
|
531
|
+
self,
|
|
532
|
+
tag: str = "*",
|
|
533
|
+
description: str | None = None,
|
|
534
|
+
case_sensitive: bool = False,
|
|
535
|
+
max_results: int = 10000,
|
|
536
|
+
) -> list[dict[str, str]]:
|
|
537
|
+
"""Search for tags by name pattern and/or description.
|
|
538
|
+
|
|
539
|
+
Supports wildcards:
|
|
540
|
+
- '*' matches any number of characters
|
|
541
|
+
- '?' matches exactly one character
|
|
542
|
+
|
|
543
|
+
When description parameter is provided, uses SQL endpoint for more
|
|
544
|
+
efficient server-side filtering. Otherwise uses Browse endpoint.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
tag: Tag name pattern with wildcards (e.g., "TEMP*", "?AI_10?", "*" for all tags).
|
|
548
|
+
Defaults to "*" (all tags).
|
|
549
|
+
description: Description pattern to filter by (case-insensitive substring match).
|
|
550
|
+
When provided, uses SQL endpoint for server-side search.
|
|
551
|
+
case_sensitive: Whether tag name matching should be case-sensitive (default: False).
|
|
552
|
+
Only applies to Browse endpoint (tag-only search).
|
|
553
|
+
max_results: Maximum number of results to return (default: 10000)
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
List of dictionaries with 'name' and 'description' keys
|
|
557
|
+
|
|
558
|
+
Raises:
|
|
559
|
+
ValueError: If datasource is not configured
|
|
560
|
+
|
|
561
|
+
Example:
|
|
562
|
+
>>> # Find all temperature tags
|
|
563
|
+
>>> tags = client.search(tag="TEMP*")
|
|
564
|
+
>>>
|
|
565
|
+
>>> # Find all tags
|
|
566
|
+
>>> tags = client.search(tag="*")
|
|
567
|
+
>>>
|
|
568
|
+
>>> # Search by description only
|
|
569
|
+
>>> tags = client.search(description="reactor")
|
|
570
|
+
>>>
|
|
571
|
+
>>> # Combine name and description
|
|
572
|
+
>>> tags = client.search(tag="*AI*", description="reactor")
|
|
573
|
+
>>>
|
|
574
|
+
>>> for tag in tags:
|
|
575
|
+
... print(f"{tag['name']}: {tag['description']}")
|
|
576
|
+
"""
|
|
577
|
+
import urllib.parse
|
|
578
|
+
|
|
579
|
+
if not self.datasource:
|
|
580
|
+
raise ValueError(
|
|
581
|
+
"Datasource is required for search. "
|
|
582
|
+
"Please set datasource when creating AspenClient: "
|
|
583
|
+
"AspenClient(base_url=..., datasource='your_datasource')"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
logger.info(f"Searching tags: pattern={tag}, description={description}")
|
|
587
|
+
|
|
588
|
+
# If description is provided, use SQL endpoint for efficient server-side search
|
|
589
|
+
if description:
|
|
590
|
+
return self._search_by_sql(
|
|
591
|
+
description=description, tag_pattern=tag, max_results=max_results
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Otherwise, use Browse endpoint for tag name search
|
|
595
|
+
# Initialize results list
|
|
596
|
+
results: list[dict[str, str]] = []
|
|
597
|
+
|
|
598
|
+
# Build query parameters
|
|
599
|
+
# Note: The key is "dataSource" (camelCase)
|
|
600
|
+
params = {
|
|
601
|
+
"dataSource": self.datasource,
|
|
602
|
+
"tag": tag,
|
|
603
|
+
"max": max_results,
|
|
604
|
+
"getTrendable": 0,
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
# Construct Browse endpoint URL with manually encoded query string
|
|
608
|
+
# We need to keep * unencoded for wildcard matching
|
|
609
|
+
# Following tagreader's approach: encode with safe="*"
|
|
610
|
+
encoded_params = urllib.parse.urlencode(params, safe="*", quote_via=urllib.parse.quote)
|
|
611
|
+
browse_url = f"{self.base_url}/Browse?{encoded_params}"
|
|
612
|
+
|
|
613
|
+
logger.info(f"Browse request: GET {browse_url}")
|
|
614
|
+
logger.debug(f"Query params: {params}")
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
# Make GET request to Browse endpoint
|
|
618
|
+
# URL already contains query string, so don't pass params again
|
|
619
|
+
response = self._client.get(browse_url)
|
|
620
|
+
|
|
621
|
+
# Log the actual request details
|
|
622
|
+
logger.debug(f"Request URL: {response.request.url}")
|
|
623
|
+
logger.debug(f"Request method: {response.request.method}")
|
|
624
|
+
logger.debug(f"Response status: {response.status_code}")
|
|
625
|
+
|
|
626
|
+
response.raise_for_status()
|
|
627
|
+
data = response.json()
|
|
628
|
+
|
|
629
|
+
logger.debug(f"Browse response keys: {list(data.keys())}")
|
|
630
|
+
|
|
631
|
+
# Check for API error response
|
|
632
|
+
if "data" in data and isinstance(data["data"], dict):
|
|
633
|
+
data_obj = data["data"]
|
|
634
|
+
logger.debug(f"data['data'] keys: {list(data_obj.keys())}")
|
|
635
|
+
|
|
636
|
+
# Check for error in result
|
|
637
|
+
if "result" in data_obj:
|
|
638
|
+
result = data_obj["result"]
|
|
639
|
+
if isinstance(result, dict) and result.get("er", 0) != 0:
|
|
640
|
+
error_msg = result.get("es", "Unknown error")
|
|
641
|
+
logger.error(f"API error from Browse endpoint: {error_msg}")
|
|
642
|
+
raise ValueError(f"Browse API error: {error_msg}")
|
|
643
|
+
|
|
644
|
+
# Check if tags key exists
|
|
645
|
+
if "tags" not in data_obj:
|
|
646
|
+
logger.warning("No 'tags' key in response - search returned no results")
|
|
647
|
+
logger.debug(f"Full response: {data}")
|
|
648
|
+
return []
|
|
649
|
+
|
|
650
|
+
tags_data = data_obj["tags"]
|
|
651
|
+
else:
|
|
652
|
+
logger.error(f"Unexpected response structure: {data}")
|
|
653
|
+
return []
|
|
654
|
+
|
|
655
|
+
logger.debug(f"tags_data type: {type(tags_data)}, length: {len(tags_data)}")
|
|
656
|
+
|
|
657
|
+
if not tags_data:
|
|
658
|
+
logger.info("Search returned 0 tags")
|
|
659
|
+
return []
|
|
660
|
+
|
|
661
|
+
for tag_entry in tags_data:
|
|
662
|
+
tag_name = tag_entry.get("t", "")
|
|
663
|
+
# Response contains "m" (map type) like "IP_ANALOGMAP", "KPI_VALUEMAP"
|
|
664
|
+
# and optionally "n" (description). Use "n" if available, otherwise "m"
|
|
665
|
+
tag_desc = tag_entry.get("n", tag_entry.get("m", ""))
|
|
666
|
+
|
|
667
|
+
# Apply case-insensitive filtering if needed
|
|
668
|
+
if (
|
|
669
|
+
not case_sensitive
|
|
670
|
+
and tag
|
|
671
|
+
and "*" not in tag
|
|
672
|
+
and "?" not in tag
|
|
673
|
+
and tag.lower() not in tag_name.lower()
|
|
674
|
+
):
|
|
675
|
+
continue
|
|
676
|
+
|
|
677
|
+
results.append({"name": tag_name, "description": tag_desc})
|
|
678
|
+
|
|
679
|
+
logger.info(f"Found {len(results)} matching tags")
|
|
680
|
+
return results[:max_results]
|
|
681
|
+
|
|
682
|
+
except Exception as e:
|
|
683
|
+
logger.error(f"Error searching tags: {type(e).__name__}: {e}")
|
|
684
|
+
raise
|
aspy21/models.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Data models for Aspen InfoPlus.21 client."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReaderType(str, Enum):
|
|
7
|
+
"""Reader types for Aspen IP.21 data retrieval.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
RAW: Raw data points as stored in the historian
|
|
11
|
+
INT: Interpolated values at specified intervals
|
|
12
|
+
SNAPSHOT: Current snapshot of tag values
|
|
13
|
+
AVG: Average values over specified intervals
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
RAW = "RAW"
|
|
17
|
+
INT = "INT"
|
|
18
|
+
SNAPSHOT = "SNAPSHOT"
|
|
19
|
+
AVG = "AVG"
|
aspy21/utils.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Utility functions for the Aspen client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from itertools import islice
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def chunked(iterable: Iterable[T], size: int) -> Iterable[list[T]]:
|
|
13
|
+
"""Split an iterable into chunks of specified size.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
iterable: The input iterable to be chunked
|
|
17
|
+
size: Maximum size of each chunk
|
|
18
|
+
|
|
19
|
+
Yields:
|
|
20
|
+
Lists of items, each containing up to 'size' elements
|
|
21
|
+
"""
|
|
22
|
+
it = iter(iterable)
|
|
23
|
+
while True:
|
|
24
|
+
batch = list(islice(it, size))
|
|
25
|
+
if not batch:
|
|
26
|
+
return
|
|
27
|
+
yield batch
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aspy21
|
|
3
|
+
Version: 0.1.1b8
|
|
4
|
+
Summary: Modern Python client for Aspen InfoPlus.21 (IP.21) with pandas DataFrame output, tag search, and automatic retry logic
|
|
5
|
+
Author: bazdalaz
|
|
6
|
+
Maintainer-email: bazdalaz <bazdalaz@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/bazdalaz/aspy21
|
|
9
|
+
Project-URL: Repository, https://github.com/bazdalaz/aspy21
|
|
10
|
+
Project-URL: Issues, https://github.com/bazdalaz/aspy21/issues
|
|
11
|
+
Keywords: aspen,ip21,historian,process data,industrial,ot,scada,aspentech
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Manufacturing
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: System :: Monitoring
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Operating System :: OS Independent
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: httpx>=0.27
|
|
31
|
+
Requires-Dist: pandas>=2.0
|
|
32
|
+
Requires-Dist: tenacity>=9.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8.2; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
37
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
39
|
+
Requires-Dist: pyright>=1.1.389; extra == "dev"
|
|
40
|
+
Requires-Dist: types-python-dateutil; extra == "dev"
|
|
41
|
+
Requires-Dist: pre-commit>=3.5; extra == "dev"
|
|
42
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
43
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
44
|
+
Provides-Extra: examples
|
|
45
|
+
Requires-Dist: python-dotenv>=1.0; extra == "examples"
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Aspy21
|
|
50
|
+
|
|
51
|
+
**Python Client for Aspen InfoPlus.21 (IP.21)**
|
|
52
|
+
|
|
53
|
+
[](https://pypi.org/project/aspy21/)
|
|
54
|
+
[](https://pypi.org/project/aspy21/)
|
|
55
|
+
[](LICENSE)
|
|
56
|
+
[](https://github.com/bazdalaz/aspy21/actions)
|
|
57
|
+
|
|
58
|
+
> **Note**: This is an independent, unofficial client library. Not affiliated with AspenTech.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Overview
|
|
63
|
+
|
|
64
|
+
**aspy21** is a modern, high-performance Python client for Aspen InfoPlus.21 (IP.21) built on the AspenOne ProcessData REST API. It provides unified access to process historian data with pandas DataFrame output, automatic batching, and intelligent retry logic.
|
|
65
|
+
|
|
66
|
+
### Key Capabilities
|
|
67
|
+
|
|
68
|
+
- REST-based communication with Aspen IP.21 historian
|
|
69
|
+
- Basic HTTP authentication (cross-platform compatible)
|
|
70
|
+
- Tag search with wildcards and description filtering
|
|
71
|
+
- Unified interface for analog, discrete, and text tags
|
|
72
|
+
- Support for RAW, INT, SNAPSHOT, and AVG reader types
|
|
73
|
+
- Pandas DataFrame output with optional status columns
|
|
74
|
+
- Configurable row limits and query parameters
|
|
75
|
+
- Built-in retry logic with exponential backoff
|
|
76
|
+
- Type-annotated and fully tested
|
|
77
|
+
|
|
78
|
+
### Use Cases
|
|
79
|
+
|
|
80
|
+
- Industrial data analysis and reporting
|
|
81
|
+
- Integration with data analytics pipelines
|
|
82
|
+
- Process monitoring and dashboard development
|
|
83
|
+
- Time-series data extraction and transformation
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Installation
|
|
88
|
+
|
|
89
|
+
Install via pip:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install aspy21
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Requirements
|
|
96
|
+
|
|
97
|
+
- Python 3.9+
|
|
98
|
+
- httpx >= 0.27
|
|
99
|
+
- pandas >= 2.0
|
|
100
|
+
- tenacity >= 9.0
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Quick Start
|
|
105
|
+
|
|
106
|
+
### Basic Usage with Context Manager
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from aspy21 import AspenClient, ReaderType
|
|
110
|
+
|
|
111
|
+
# Initialize client using context manager (recommended)
|
|
112
|
+
with AspenClient(
|
|
113
|
+
base_url="https://aspen.myplant.local/ProcessData",
|
|
114
|
+
auth=("user", "password")
|
|
115
|
+
) as client:
|
|
116
|
+
# Read historical data
|
|
117
|
+
df = client.read(
|
|
118
|
+
tags=["ATI111", "AP101.PV"],
|
|
119
|
+
start="2025-06-20 08:00:00",
|
|
120
|
+
end="2025-06-20 09:00:00",
|
|
121
|
+
interval=600,
|
|
122
|
+
read_type=ReaderType.RAW
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
print(df)
|
|
126
|
+
# Connection automatically closed
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Authentication
|
|
132
|
+
|
|
133
|
+
### HTTP Basic Authentication
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
with AspenClient(
|
|
137
|
+
base_url="https://aspen.example.com/ProcessData",
|
|
138
|
+
auth=("user", "password")
|
|
139
|
+
) as client:
|
|
140
|
+
# Your code here
|
|
141
|
+
pass
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### No Authentication
|
|
145
|
+
|
|
146
|
+
For public or internal endpoints:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
with AspenClient(
|
|
150
|
+
base_url="http://aspen.example.com/ProcessData"
|
|
151
|
+
) as client:
|
|
152
|
+
# Your code here
|
|
153
|
+
pass
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## API Reference
|
|
159
|
+
|
|
160
|
+
### AspenClient
|
|
161
|
+
|
|
162
|
+
**Constructor Parameters** (all except `base_url` are keyword-only):
|
|
163
|
+
|
|
164
|
+
| Parameter | Type | Default | Description |
|
|
165
|
+
|-----------|------|---------|-------------|
|
|
166
|
+
| `base_url` | str | required | Base URL of Aspen ProcessData REST API |
|
|
167
|
+
| `auth` | Auth\|tuple\|None | None | Authentication as (username, password) tuple or httpx Auth object |
|
|
168
|
+
| `timeout` | float | 30.0 | Request timeout in seconds |
|
|
169
|
+
| `verify_ssl` | bool | True | Whether to verify SSL certificates |
|
|
170
|
+
| `datasource` | str\|None | None | Aspen datasource name (required for search) |
|
|
171
|
+
|
|
172
|
+
### read() Method
|
|
173
|
+
|
|
174
|
+
**Parameters**:
|
|
175
|
+
|
|
176
|
+
| Parameter | Type | Default | Description |
|
|
177
|
+
|-----------|------|---------|-------------|
|
|
178
|
+
| `tags` | Iterable[str] | required | List of tag names to retrieve |
|
|
179
|
+
| `start` | str | required | Start timestamp (ISO format) |
|
|
180
|
+
| `end` | str | required | End timestamp (ISO format) |
|
|
181
|
+
| `interval` | int\|None | None | Interval in seconds for aggregated data |
|
|
182
|
+
| `read_type` | ReaderType | RAW | Data retrieval mode |
|
|
183
|
+
| `include_status` | bool | True | Include status column in output |
|
|
184
|
+
| `max_rows` | int | 100000 | Maximum rows to return per tag |
|
|
185
|
+
|
|
186
|
+
**Returns**: pandas.DataFrame with time index and columns for each tag.
|
|
187
|
+
|
|
188
|
+
### search() Method
|
|
189
|
+
|
|
190
|
+
**Parameters**:
|
|
191
|
+
|
|
192
|
+
| Parameter | Type | Default | Description |
|
|
193
|
+
|-----------|------|---------|-------------|
|
|
194
|
+
| `tag` | str | required | Tag name pattern with wildcards (`*`, `?`). Use `"*"` for all tags. |
|
|
195
|
+
| `description` | str\|None | None | Description filter (case-insensitive substring) |
|
|
196
|
+
| `case_sensitive` | bool | False | Whether tag matching is case-sensitive |
|
|
197
|
+
| `max_results` | int | 10000 | Maximum number of results to return |
|
|
198
|
+
|
|
199
|
+
**Returns**: List of dicts with 'name' and 'description' keys.
|
|
200
|
+
|
|
201
|
+
**Requirements**:
|
|
202
|
+
- `tag` parameter is required
|
|
203
|
+
- `datasource` must be configured in AspenClient
|
|
204
|
+
|
|
205
|
+
**Wildcards**:
|
|
206
|
+
- `*` - Matches any number of characters
|
|
207
|
+
- `?` - Matches exactly one character
|
|
208
|
+
|
|
209
|
+
**Examples**:
|
|
210
|
+
```python
|
|
211
|
+
# Find all temperature tags
|
|
212
|
+
tags = client.search(tag="TEMP*")
|
|
213
|
+
|
|
214
|
+
# Search all tags by description
|
|
215
|
+
tags = client.search(tag="*", description="reactor")
|
|
216
|
+
|
|
217
|
+
# Combine filters
|
|
218
|
+
tags = client.search(tag="AI_1*", description="pressure")
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### ReaderType Enum
|
|
222
|
+
|
|
223
|
+
Available reader types:
|
|
224
|
+
|
|
225
|
+
- `ReaderType.RAW` - Raw data points as stored
|
|
226
|
+
- `ReaderType.INT` - Interpolated values at intervals
|
|
227
|
+
- `ReaderType.SNAPSHOT` - Current snapshot of tag values
|
|
228
|
+
- `ReaderType.AVG` - Average values over intervals
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Configuration
|
|
233
|
+
|
|
234
|
+
### Connection Settings
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
with AspenClient(
|
|
238
|
+
base_url="https://aspen.example.com/ProcessData",
|
|
239
|
+
auth=("user", "password"),
|
|
240
|
+
timeout=60.0, # Request timeout (seconds)
|
|
241
|
+
verify_ssl=True, # SSL certificate verification
|
|
242
|
+
datasource="IP21" # Required for search operations
|
|
243
|
+
) as client:
|
|
244
|
+
# Your code here
|
|
245
|
+
pass
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Retry Behavior
|
|
249
|
+
|
|
250
|
+
The client automatically retries failed requests with exponential backoff:
|
|
251
|
+
|
|
252
|
+
- Maximum attempts: 3
|
|
253
|
+
- Initial delay: 0.5 seconds
|
|
254
|
+
- Maximum delay: 8 seconds
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Error Handling
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from aspy21 import AspenClient
|
|
262
|
+
import httpx
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
with AspenClient(
|
|
266
|
+
base_url="https://aspen.example.com/ProcessData",
|
|
267
|
+
auth=("user", "password")
|
|
268
|
+
) as client:
|
|
269
|
+
df = client.read(
|
|
270
|
+
tags=["ATI111"],
|
|
271
|
+
start="2025-06-20 08:00:00",
|
|
272
|
+
end="2025-06-20 09:00:00"
|
|
273
|
+
)
|
|
274
|
+
except httpx.HTTPStatusError as e:
|
|
275
|
+
print(f"HTTP error: {e.response.status_code}")
|
|
276
|
+
except httpx.RequestError as e:
|
|
277
|
+
print(f"Connection error: {e}")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Development
|
|
283
|
+
|
|
284
|
+
### Running Tests
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
# Install development dependencies
|
|
288
|
+
pip install -e ".[dev]"
|
|
289
|
+
|
|
290
|
+
# Run tests
|
|
291
|
+
pytest
|
|
292
|
+
|
|
293
|
+
# Run with coverage
|
|
294
|
+
pytest --cov=src/aspy21 --cov-report=html
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Type Checking
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
pyright
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Code Formatting
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
ruff format
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Dependencies
|
|
312
|
+
|
|
313
|
+
### Core Dependencies
|
|
314
|
+
|
|
315
|
+
- **httpx** (>= 0.27) - HTTP client with async support
|
|
316
|
+
- **pandas** (>= 2.0) - DataFrame output and date parsing
|
|
317
|
+
- **tenacity** (>= 9.0) - Retry logic with exponential backoff
|
|
318
|
+
|
|
319
|
+
### Development Dependencies
|
|
320
|
+
|
|
321
|
+
- pytest, pytest-cov - Testing framework
|
|
322
|
+
- respx - HTTP mocking for tests
|
|
323
|
+
- ruff - Code formatting and linting
|
|
324
|
+
- pyright - Static type checking
|
|
325
|
+
- pre-commit - Git hooks
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## License
|
|
330
|
+
|
|
331
|
+
This project is licensed under the MIT License. See [LICENSE](LICENSE) file for details.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Contributing
|
|
336
|
+
|
|
337
|
+
Contributions are welcome. Please ensure:
|
|
338
|
+
|
|
339
|
+
1. All tests pass (`pytest`)
|
|
340
|
+
2. Code is formatted (`ruff format`)
|
|
341
|
+
3. Type checking passes (`pyright`)
|
|
342
|
+
4. Coverage remains above 75%
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Support
|
|
347
|
+
|
|
348
|
+
For issues, questions, or feature requests, please open an issue on [GitHub](https://github.com/bazdalaz/aspy21/issues).
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Disclaimer
|
|
353
|
+
|
|
354
|
+
This project is an independent open-source client library and is not affiliated with, endorsed by, or sponsored by AspenTech. "Aspen InfoPlus.21", "IP.21", and "AspenTech" are trademarks or registered trademarks of Aspen Technology, Inc.
|
|
355
|
+
|
|
356
|
+
This software interacts with Aspen InfoPlus.21 systems through their documented REST API endpoints. Users must have appropriate licenses and authorization to access AspenTech systems.
|
|
357
|
+
|
|
358
|
+
Users are responsible for compliance with their AspenTech license agreements and applicable terms of service. This library merely provides a technical interface and does not grant any rights to AspenTech software or services.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aspy21/__init__.py,sha256=ClsPhRKAP51Ne01EeSNPxz45pYPKranPX5g5Jlfjmfw,534
|
|
2
|
+
aspy21/_version.py,sha256=Kq5TYovpH5dQ4g5K3qTstRlYHf54jgTfsA0GNDsUyPc,712
|
|
3
|
+
aspy21/cache.py,sha256=pRIaxaI3yNJcOZ7SiLI_KNBRdMqbFq1sDPW7y-ezqtU,1158
|
|
4
|
+
aspy21/client.py,sha256=OP5D7IhWHTWv_kmrBT-1qnE3KijnGNwgBrPswj6AnFs,24941
|
|
5
|
+
aspy21/models.py,sha256=IXHAB2e0xB_lBmWZ7Z0bmSilejFjUzRpDKNJPdRgByk,468
|
|
6
|
+
aspy21/utils.py,sha256=7BFx1O1zGaN3fxqdYC_-vYx91ARDFmSHuxdMblxmmUc,646
|
|
7
|
+
aspy21-0.1.1b8.dist-info/licenses/LICENSE,sha256=N4WQNfHzhpXKz7lrcW0D94maukUHJ8dHgCDpykLvmSI,1065
|
|
8
|
+
aspy21-0.1.1b8.dist-info/METADATA,sha256=Ej_MiU3XmKojzWFZaa0-Ap0wtIgXsk3xKxJ06-3iP7I,10011
|
|
9
|
+
aspy21-0.1.1b8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
aspy21-0.1.1b8.dist-info/top_level.txt,sha256=Xc09Tx1mB3tpPre7oO14j05v2BZvjeyvkwYFpy6TMrQ,7
|
|
11
|
+
aspy21-0.1.1b8.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 bazdalaz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aspy21
|