ventilatepro-cli 0.5.2__tar.gz → 0.7.0__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.
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/PKG-INFO +21 -2
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/README.md +18 -1
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/pyproject.toml +2 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/tests/test_cli.py +77 -1
- ventilatepro_cli-0.7.0/tests/test_hvac_calculations.py +220 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/tests/test_mcp_server.py +81 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/__init__.py +1 -1
- ventilatepro_cli-0.7.0/ventilatepro/calc.py +422 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/cli.py +25 -0
- ventilatepro_cli-0.7.0/ventilatepro/entity_catalog.py +124 -0
- ventilatepro_cli-0.7.0/ventilatepro/equipment.py +49 -0
- ventilatepro_cli-0.7.0/ventilatepro/esp.py +77 -0
- ventilatepro_cli-0.7.0/ventilatepro/hvac_calculations.py +516 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/mcp_server.py +255 -43
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/PKG-INFO +21 -2
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/SOURCES.txt +5 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/requires.txt +2 -0
- ventilatepro_cli-0.5.2/ventilatepro/calc.py +0 -142
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/setup.cfg +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/api_client.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/categorization.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/decisions.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/design_day.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/entities.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/mcp_cli.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/meetings.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/output.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/payloads.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/projects.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/queue.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/revit_imports.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/systems.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/tasks.py +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
- {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ventilatepro-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: VentilatePro CLI
|
|
5
5
|
Project-URL: Homepage, https://ventilatepro.com/exam/cli/
|
|
6
6
|
Project-URL: Documentation, https://ventilatepro.com/exam/cli/
|
|
@@ -12,11 +12,18 @@ Requires-Dist: requests>=2.32.0
|
|
|
12
12
|
Requires-Dist: keyring>=25.6.0
|
|
13
13
|
Requires-Dist: platformdirs>=4.3.0
|
|
14
14
|
Requires-Dist: mcp<2.0.0,>=1.23.0
|
|
15
|
+
Requires-Dist: PsychroLib==2.5.0
|
|
16
|
+
Requires-Dist: CoolProp==8.0.0
|
|
15
17
|
|
|
16
18
|
# VentilatePro CLI
|
|
17
19
|
|
|
18
20
|
VentilatePro CLI is a local command-line client for authenticating to VentilatePro, recording project notes and meeting minutes, and running scoped engineering workflows.
|
|
19
21
|
|
|
22
|
+
It also includes offline HVAC calculations for PsychroLib-based psychrometrics,
|
|
23
|
+
sensible and total air loads, complete air-process balances, hydronics,
|
|
24
|
+
IAPWS-IF97 saturated steam through CoolProp 8.0.0, fan and pump power, duct sizing, and LMTD. Local
|
|
25
|
+
calculations require no login and are also available as typed MCP tools.
|
|
26
|
+
|
|
20
27
|
## Install
|
|
21
28
|
|
|
22
29
|
Published releases install from PyPI:
|
|
@@ -55,6 +62,10 @@ ventilatepro ahus list --project 1
|
|
|
55
62
|
ventilatepro systems hierarchy --project 1
|
|
56
63
|
ventilatepro calc status --project 1
|
|
57
64
|
ventilatepro calc ahu-properties --ahu 1
|
|
65
|
+
ventilatepro calc psychrometrics --dry-bulb 75 --relative-humidity 50 --json
|
|
66
|
+
ventilatepro calc air-process --cfm 5000 --entering-dry-bulb 80 --entering-rh 50 --leaving-dry-bulb 55 --leaving-rh 95 --json
|
|
67
|
+
ventilatepro calc steam --pressure 15 --pressure-units psig --load 1000000 --json
|
|
68
|
+
ventilatepro calc fan-power --cfm 20000 --static-pressure 4 --fan-efficiency 0.68 --motor-efficiency 0.92
|
|
58
69
|
ventilatepro design-day context --project 1
|
|
59
70
|
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
60
71
|
ventilatepro notes sync
|
|
@@ -74,6 +85,10 @@ ventilatepro-mcp
|
|
|
74
85
|
and other MCP clients. It uses the same saved base URL and CLI token created by
|
|
75
86
|
`ventilatepro auth login`.
|
|
76
87
|
|
|
88
|
+
Local HVAC tools (`vp_calculate_psychrometrics`,
|
|
89
|
+
`vp_calculate_air_process`, `vp_calculate_steam`, and the rest of the catalog)
|
|
90
|
+
do not use that login or make network requests.
|
|
91
|
+
|
|
77
92
|
For meeting capture, Codex first reads `meetings context` to resolve names to
|
|
78
93
|
stable project-member IDs, interprets the PM's prose into a structured JSON
|
|
79
94
|
payload, and calls `meetings record`. The server atomically creates the meeting,
|
|
@@ -154,4 +169,8 @@ or pipe the token through stdin:
|
|
|
154
169
|
printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --token-stdin
|
|
155
170
|
```
|
|
156
171
|
|
|
157
|
-
See the hosted install guide at `https://ventilatepro.com/exam/cli/`,
|
|
172
|
+
See the hosted install guide at `https://ventilatepro.com/exam/cli/`, the HVAC
|
|
173
|
+
calculation reference at `https://ventilatepro.com/exam/cli/calculations/`, the
|
|
174
|
+
machine-readable index at `https://ventilatepro.com/llms.txt`, plus
|
|
175
|
+
[docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md),
|
|
176
|
+
[docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
VentilatePro CLI is a local command-line client for authenticating to VentilatePro, recording project notes and meeting minutes, and running scoped engineering workflows.
|
|
4
4
|
|
|
5
|
+
It also includes offline HVAC calculations for PsychroLib-based psychrometrics,
|
|
6
|
+
sensible and total air loads, complete air-process balances, hydronics,
|
|
7
|
+
IAPWS-IF97 saturated steam through CoolProp 8.0.0, fan and pump power, duct sizing, and LMTD. Local
|
|
8
|
+
calculations require no login and are also available as typed MCP tools.
|
|
9
|
+
|
|
5
10
|
## Install
|
|
6
11
|
|
|
7
12
|
Published releases install from PyPI:
|
|
@@ -40,6 +45,10 @@ ventilatepro ahus list --project 1
|
|
|
40
45
|
ventilatepro systems hierarchy --project 1
|
|
41
46
|
ventilatepro calc status --project 1
|
|
42
47
|
ventilatepro calc ahu-properties --ahu 1
|
|
48
|
+
ventilatepro calc psychrometrics --dry-bulb 75 --relative-humidity 50 --json
|
|
49
|
+
ventilatepro calc air-process --cfm 5000 --entering-dry-bulb 80 --entering-rh 50 --leaving-dry-bulb 55 --leaving-rh 95 --json
|
|
50
|
+
ventilatepro calc steam --pressure 15 --pressure-units psig --load 1000000 --json
|
|
51
|
+
ventilatepro calc fan-power --cfm 20000 --static-pressure 4 --fan-efficiency 0.68 --motor-efficiency 0.92
|
|
43
52
|
ventilatepro design-day context --project 1
|
|
44
53
|
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
45
54
|
ventilatepro notes sync
|
|
@@ -59,6 +68,10 @@ ventilatepro-mcp
|
|
|
59
68
|
and other MCP clients. It uses the same saved base URL and CLI token created by
|
|
60
69
|
`ventilatepro auth login`.
|
|
61
70
|
|
|
71
|
+
Local HVAC tools (`vp_calculate_psychrometrics`,
|
|
72
|
+
`vp_calculate_air_process`, `vp_calculate_steam`, and the rest of the catalog)
|
|
73
|
+
do not use that login or make network requests.
|
|
74
|
+
|
|
62
75
|
For meeting capture, Codex first reads `meetings context` to resolve names to
|
|
63
76
|
stable project-member IDs, interprets the PM's prose into a structured JSON
|
|
64
77
|
payload, and calls `meetings record`. The server atomically creates the meeting,
|
|
@@ -139,4 +152,8 @@ or pipe the token through stdin:
|
|
|
139
152
|
printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --token-stdin
|
|
140
153
|
```
|
|
141
154
|
|
|
142
|
-
See the hosted install guide at `https://ventilatepro.com/exam/cli/`,
|
|
155
|
+
See the hosted install guide at `https://ventilatepro.com/exam/cli/`, the HVAC
|
|
156
|
+
calculation reference at `https://ventilatepro.com/exam/cli/calculations/`, the
|
|
157
|
+
machine-readable index at `https://ventilatepro.com/llms.txt`, plus
|
|
158
|
+
[docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md),
|
|
159
|
+
[docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
|
|
@@ -6,7 +6,7 @@ from unittest import mock
|
|
|
6
6
|
|
|
7
7
|
from typer.testing import CliRunner
|
|
8
8
|
|
|
9
|
-
from ventilatepro import auth, config
|
|
9
|
+
from ventilatepro import __version__, auth, config
|
|
10
10
|
from ventilatepro.api_client import ApiClient, ApiError, NetworkError
|
|
11
11
|
from ventilatepro.cli import app
|
|
12
12
|
from ventilatepro.categorization import _classification_group_key
|
|
@@ -18,6 +18,12 @@ class CliTests(unittest.TestCase):
|
|
|
18
18
|
def setUp(self):
|
|
19
19
|
self.runner = CliRunner()
|
|
20
20
|
|
|
21
|
+
def test_version_option_reports_installed_package_version(self):
|
|
22
|
+
result = self.runner.invoke(app, ["--version"])
|
|
23
|
+
|
|
24
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
25
|
+
self.assertEqual(result.stdout.strip(), __version__)
|
|
26
|
+
|
|
21
27
|
def test_auth_login_reads_token_from_stdin(self):
|
|
22
28
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
23
29
|
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
@@ -163,6 +169,76 @@ class CliTests(unittest.TestCase):
|
|
|
163
169
|
self.assertEqual(result.exit_code, 1)
|
|
164
170
|
self.assertIn("FAIL saved_login", result.output)
|
|
165
171
|
|
|
172
|
+
def test_equipment_create_routes_supported_type_through_cli_proxy(self):
|
|
173
|
+
fake_client = mock.Mock()
|
|
174
|
+
fake_client.cli_proxy.return_value = {"id": 81, "project": 7, "tag": "EF-CLI-1"}
|
|
175
|
+
|
|
176
|
+
with mock.patch.object(
|
|
177
|
+
auth,
|
|
178
|
+
"get_authenticated_client",
|
|
179
|
+
return_value=(fake_client, "http://localhost:8000"),
|
|
180
|
+
):
|
|
181
|
+
result = self.runner.invoke(
|
|
182
|
+
app,
|
|
183
|
+
[
|
|
184
|
+
"equipment",
|
|
185
|
+
"create",
|
|
186
|
+
"exhaust-fans",
|
|
187
|
+
"--data",
|
|
188
|
+
'{"project":7,"tag":"EF-CLI-1"}',
|
|
189
|
+
"--json",
|
|
190
|
+
],
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
194
|
+
fake_client.cli_proxy.assert_called_once_with(
|
|
195
|
+
"POST",
|
|
196
|
+
"exhaust-fans/",
|
|
197
|
+
json_payload={"project": 7, "tag": "EF-CLI-1"},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def test_esp_commands_create_every_path_shape_and_components(self):
|
|
201
|
+
fake_client = mock.Mock()
|
|
202
|
+
fake_client.cli_proxy.side_effect = [
|
|
203
|
+
{"id": 91, "fan_type": "SUPPLY", "ahu": 12},
|
|
204
|
+
{"id": 92, "fan_type": "RETURN", "ahu": 12},
|
|
205
|
+
{"id": 93, "fan_type": "EXHAUST", "exhaust_fan": 18},
|
|
206
|
+
{"id": 94, "fan_type": "STANDALONE", "project": 7},
|
|
207
|
+
{"id": 101, "calculation": 91, "component_type": "FILTER_MERV13"},
|
|
208
|
+
]
|
|
209
|
+
payloads = [
|
|
210
|
+
{"project": 7, "ahu": 12, "fan_type": "SUPPLY", "name": "Supply path"},
|
|
211
|
+
{"project": 7, "ahu": 12, "fan_type": "RETURN", "name": "Return path"},
|
|
212
|
+
{"project": 7, "exhaust_fan": 18, "fan_type": "EXHAUST", "name": "Exhaust path"},
|
|
213
|
+
{"project": 7, "fan_type": "STANDALONE", "name": "Standalone path"},
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
with mock.patch.object(
|
|
217
|
+
auth,
|
|
218
|
+
"get_authenticated_client",
|
|
219
|
+
return_value=(fake_client, "http://localhost:8000"),
|
|
220
|
+
):
|
|
221
|
+
for payload in payloads:
|
|
222
|
+
result = self.runner.invoke(
|
|
223
|
+
app,
|
|
224
|
+
["esp", "create", "--data", json.dumps(payload), "--json"],
|
|
225
|
+
)
|
|
226
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
227
|
+
component = {
|
|
228
|
+
"calculation": 91,
|
|
229
|
+
"component_type": "FILTER_MERV13",
|
|
230
|
+
"description": "Final filter",
|
|
231
|
+
"pressure_drop_iwc": 0.5,
|
|
232
|
+
}
|
|
233
|
+
result = self.runner.invoke(
|
|
234
|
+
app,
|
|
235
|
+
["esp", "add-component", "--data", json.dumps(component), "--json"],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
239
|
+
paths = [call.args[1] for call in fake_client.cli_proxy.call_args_list]
|
|
240
|
+
self.assertEqual(paths, ["esp-calculations/"] * 4 + ["esp-components/"])
|
|
241
|
+
|
|
166
242
|
def test_notes_create_queues_on_network_error(self):
|
|
167
243
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
168
244
|
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import unittest
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from ventilatepro.cli import app
|
|
9
|
+
from ventilatepro.hvac_calculations import (
|
|
10
|
+
calculate_air_process,
|
|
11
|
+
calculate_duct_size,
|
|
12
|
+
calculate_fan_power,
|
|
13
|
+
calculate_hydronic_flow,
|
|
14
|
+
calculate_hydronic_load,
|
|
15
|
+
calculate_lmtd,
|
|
16
|
+
calculate_psychrometrics,
|
|
17
|
+
calculate_pump_power,
|
|
18
|
+
calculate_saturated_steam,
|
|
19
|
+
calculate_sensible_load,
|
|
20
|
+
calculate_total_load,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HvacCalculationTests(unittest.TestCase):
|
|
25
|
+
def test_psychrometrics_matches_published_psychrolib_example(self):
|
|
26
|
+
result = calculate_psychrometrics(
|
|
27
|
+
dry_bulb=25.0,
|
|
28
|
+
relative_humidity=80.0,
|
|
29
|
+
pressure=101325.0,
|
|
30
|
+
units="si",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
self.assertAlmostEqual(result["values"]["dew_point"], 21.309397, places=5)
|
|
34
|
+
self.assertAlmostEqual(result["values"]["relative_humidity"], 80.0, places=7)
|
|
35
|
+
self.assertEqual(result["units"]["enthalpy"], "J/kg_dry_air")
|
|
36
|
+
|
|
37
|
+
def test_psychrometrics_accepts_each_supported_input_pair(self):
|
|
38
|
+
base = calculate_psychrometrics(dry_bulb=75, relative_humidity=50, units="ip")
|
|
39
|
+
values = base["values"]
|
|
40
|
+
|
|
41
|
+
for keyword, value in (
|
|
42
|
+
("wet_bulb", values["wet_bulb"]),
|
|
43
|
+
("dew_point", values["dew_point"]),
|
|
44
|
+
("humidity_ratio", values["humidity_ratio"]),
|
|
45
|
+
):
|
|
46
|
+
resolved = calculate_psychrometrics(dry_bulb=75, units="ip", **{keyword: value})
|
|
47
|
+
# PsychroLib's wet-bulb inverse solver uses its documented
|
|
48
|
+
# convergence tolerance, so a round trip can differ by a few
|
|
49
|
+
# thousandths of a percentage point.
|
|
50
|
+
self.assertAlmostEqual(resolved["values"]["relative_humidity"], 50.0, delta=0.01)
|
|
51
|
+
self.assertAlmostEqual(resolved["values"]["enthalpy"], values["enthalpy"], delta=0.001)
|
|
52
|
+
|
|
53
|
+
def test_psychrometrics_rejects_ambiguous_inputs(self):
|
|
54
|
+
with self.assertRaisesRegex(ValueError, "exactly one"):
|
|
55
|
+
calculate_psychrometrics(
|
|
56
|
+
dry_bulb=75,
|
|
57
|
+
relative_humidity=50,
|
|
58
|
+
wet_bulb=62,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def test_psychrometrics_accepts_dry_air_and_is_thread_safe_across_units(self):
|
|
62
|
+
dry_air = calculate_psychrometrics(
|
|
63
|
+
dry_bulb=75,
|
|
64
|
+
relative_humidity=0,
|
|
65
|
+
units="ip",
|
|
66
|
+
)
|
|
67
|
+
self.assertAlmostEqual(dry_air["values"]["relative_humidity"], 0.0)
|
|
68
|
+
|
|
69
|
+
def calculate(index):
|
|
70
|
+
if index % 2:
|
|
71
|
+
return calculate_psychrometrics(
|
|
72
|
+
dry_bulb=25,
|
|
73
|
+
relative_humidity=80,
|
|
74
|
+
pressure=101325,
|
|
75
|
+
units="si",
|
|
76
|
+
)
|
|
77
|
+
return calculate_psychrometrics(
|
|
78
|
+
dry_bulb=77,
|
|
79
|
+
relative_humidity=80,
|
|
80
|
+
pressure=14.6959488,
|
|
81
|
+
units="ip",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
85
|
+
results = list(executor.map(calculate, range(40)))
|
|
86
|
+
|
|
87
|
+
for index, result in enumerate(results):
|
|
88
|
+
expected_units = "si" if index % 2 else "ip"
|
|
89
|
+
self.assertEqual(result["unit_system"], expected_units)
|
|
90
|
+
self.assertAlmostEqual(result["values"]["relative_humidity"], 80.0, places=5)
|
|
91
|
+
|
|
92
|
+
def test_standard_air_load_formulas(self):
|
|
93
|
+
sensible = calculate_sensible_load(
|
|
94
|
+
airflow_cfm=1000,
|
|
95
|
+
entering_dry_bulb_f=80,
|
|
96
|
+
leaving_dry_bulb_f=60,
|
|
97
|
+
)
|
|
98
|
+
total = calculate_total_load(
|
|
99
|
+
airflow_cfm=1000,
|
|
100
|
+
entering_enthalpy_btu_per_lb=35,
|
|
101
|
+
leaving_enthalpy_btu_per_lb=25,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
self.assertAlmostEqual(sensible["values"]["sensible_load_btu_per_hr"], 21600)
|
|
105
|
+
self.assertAlmostEqual(total["values"]["total_load_btu_per_hr"], 45000)
|
|
106
|
+
|
|
107
|
+
def test_air_process_reconciles_sensible_latent_and_total(self):
|
|
108
|
+
result = calculate_air_process(
|
|
109
|
+
airflow_cfm=5000,
|
|
110
|
+
entering_dry_bulb_f=80,
|
|
111
|
+
entering_relative_humidity=50,
|
|
112
|
+
leaving_dry_bulb_f=55,
|
|
113
|
+
leaving_relative_humidity=95,
|
|
114
|
+
)
|
|
115
|
+
values = result["values"]
|
|
116
|
+
|
|
117
|
+
self.assertAlmostEqual(
|
|
118
|
+
values["sensible_load_btu_per_hr"] + values["latent_load_btu_per_hr"],
|
|
119
|
+
values["total_load_btu_per_hr"],
|
|
120
|
+
places=6,
|
|
121
|
+
)
|
|
122
|
+
self.assertGreater(values["dry_air_mass_flow_lb_per_hr"], 0)
|
|
123
|
+
self.assertGreaterEqual(values["condensate_gpm"], 0)
|
|
124
|
+
|
|
125
|
+
def test_hydronic_load_and_inverse_flow_reconcile(self):
|
|
126
|
+
load = calculate_hydronic_load(flow_gpm=10, delta_t_f=20)
|
|
127
|
+
flow = calculate_hydronic_flow(
|
|
128
|
+
load_btu_per_hr=load["values"]["load_btu_per_hr"],
|
|
129
|
+
delta_t_f=20,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
self.assertAlmostEqual(load["values"]["load_btu_per_hr"], 99960)
|
|
133
|
+
self.assertAlmostEqual(flow["values"]["flow_gpm"], 10)
|
|
134
|
+
|
|
135
|
+
def test_saturated_steam_matches_boiling_point_at_zero_psig(self):
|
|
136
|
+
result = calculate_saturated_steam(
|
|
137
|
+
pressure=0,
|
|
138
|
+
pressure_units="psig",
|
|
139
|
+
quality=1,
|
|
140
|
+
load_btu_per_hr=970_000,
|
|
141
|
+
)
|
|
142
|
+
values = result["values"]
|
|
143
|
+
|
|
144
|
+
self.assertAlmostEqual(values["saturation_temperature_f"], 212.0, delta=0.08)
|
|
145
|
+
self.assertAlmostEqual(values["latent_heat_btu_per_lb"], 970.3, delta=1.0)
|
|
146
|
+
self.assertAlmostEqual(values["required_steam_lb_per_hr"], 1000, delta=2.0)
|
|
147
|
+
|
|
148
|
+
def test_fan_pump_duct_and_lmtd_calculations(self):
|
|
149
|
+
fan = calculate_fan_power(
|
|
150
|
+
airflow_cfm=10_000,
|
|
151
|
+
static_pressure_in_wg=3,
|
|
152
|
+
fan_efficiency=0.65,
|
|
153
|
+
motor_efficiency=0.9,
|
|
154
|
+
)
|
|
155
|
+
pump = calculate_pump_power(
|
|
156
|
+
flow_gpm=500,
|
|
157
|
+
head_ft=60,
|
|
158
|
+
pump_efficiency=0.75,
|
|
159
|
+
motor_efficiency=0.9,
|
|
160
|
+
)
|
|
161
|
+
duct = calculate_duct_size(airflow_cfm=1200, velocity_fpm=1000, aspect_ratio=2)
|
|
162
|
+
lmtd = calculate_lmtd(
|
|
163
|
+
hot_in_f=200,
|
|
164
|
+
hot_out_f=150,
|
|
165
|
+
cold_in_f=80,
|
|
166
|
+
cold_out_f=120,
|
|
167
|
+
arrangement="counterflow",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
self.assertGreater(fan["values"]["input_kw"], 0)
|
|
171
|
+
self.assertGreater(pump["values"]["input_kw"], 0)
|
|
172
|
+
self.assertAlmostEqual(duct["values"]["area_sq_ft"], 1.2)
|
|
173
|
+
self.assertAlmostEqual(duct["values"]["round_diameter_in"], math.sqrt(4.8 / math.pi) * 12)
|
|
174
|
+
self.assertAlmostEqual(lmtd["values"]["lmtd_f"], (80 - 70) / math.log(80 / 70))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class HvacCalculationCliTests(unittest.TestCase):
|
|
178
|
+
def setUp(self):
|
|
179
|
+
self.runner = CliRunner()
|
|
180
|
+
|
|
181
|
+
def test_psychrometrics_command_runs_offline_and_emits_json(self):
|
|
182
|
+
result = self.runner.invoke(
|
|
183
|
+
app,
|
|
184
|
+
[
|
|
185
|
+
"calc",
|
|
186
|
+
"psychrometrics",
|
|
187
|
+
"--dry-bulb",
|
|
188
|
+
"75",
|
|
189
|
+
"--relative-humidity",
|
|
190
|
+
"50",
|
|
191
|
+
"--json",
|
|
192
|
+
],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
196
|
+
payload = json.loads(result.output)
|
|
197
|
+
self.assertEqual(payload["calculation"], "psychrometrics")
|
|
198
|
+
self.assertAlmostEqual(payload["values"]["relative_humidity"], 50.0, places=5)
|
|
199
|
+
|
|
200
|
+
def test_invalid_psychrometric_pair_exits_nonzero(self):
|
|
201
|
+
result = self.runner.invoke(
|
|
202
|
+
app,
|
|
203
|
+
[
|
|
204
|
+
"calc",
|
|
205
|
+
"psychrometrics",
|
|
206
|
+
"--dry-bulb",
|
|
207
|
+
"75",
|
|
208
|
+
"--relative-humidity",
|
|
209
|
+
"50",
|
|
210
|
+
"--wet-bulb",
|
|
211
|
+
"62",
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
216
|
+
self.assertIn("exactly one", result.output)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
unittest.main()
|
|
@@ -107,8 +107,22 @@ class McpServerTests(unittest.TestCase):
|
|
|
107
107
|
tools = await server.list_tools()
|
|
108
108
|
names = {tool.name for tool in tools}
|
|
109
109
|
self.assertIn("vp_whoami", names)
|
|
110
|
+
self.assertIn("vp_list_hvac_calculations", names)
|
|
111
|
+
self.assertIn("vp_calculate_psychrometrics", names)
|
|
112
|
+
self.assertIn("vp_calculate_sensible_load", names)
|
|
113
|
+
self.assertIn("vp_calculate_total_load", names)
|
|
114
|
+
self.assertIn("vp_calculate_air_process", names)
|
|
115
|
+
self.assertIn("vp_calculate_hydronic", names)
|
|
116
|
+
self.assertIn("vp_calculate_steam", names)
|
|
117
|
+
self.assertIn("vp_calculate_fan_power", names)
|
|
118
|
+
self.assertIn("vp_calculate_pump_power", names)
|
|
119
|
+
self.assertIn("vp_calculate_duct_size", names)
|
|
120
|
+
self.assertIn("vp_calculate_lmtd", names)
|
|
110
121
|
self.assertIn("vp_api_request", names)
|
|
111
122
|
self.assertIn("vp_list_entities", names)
|
|
123
|
+
self.assertIn("vp_create_equipment", names)
|
|
124
|
+
self.assertIn("vp_create_esp_calculation", names)
|
|
125
|
+
self.assertIn("vp_create_esp_component", names)
|
|
112
126
|
self.assertIn("vp_run_workflow", names)
|
|
113
127
|
self.assertIn("vp_get_categorization_review", names)
|
|
114
128
|
self.assertIn("vp_apply_categorization", names)
|
|
@@ -128,6 +142,73 @@ class McpServerTests(unittest.TestCase):
|
|
|
128
142
|
|
|
129
143
|
asyncio.run(run())
|
|
130
144
|
|
|
145
|
+
def test_local_psychrometric_tool_does_not_require_authentication(self):
|
|
146
|
+
async def run():
|
|
147
|
+
server = build_server()
|
|
148
|
+
with mock.patch(
|
|
149
|
+
"ventilatepro.mcp_server.auth.get_authenticated_client",
|
|
150
|
+
side_effect=AssertionError("local calculations must not authenticate"),
|
|
151
|
+
):
|
|
152
|
+
_content, structured = await server.call_tool(
|
|
153
|
+
"vp_calculate_psychrometrics",
|
|
154
|
+
{"dry_bulb": 75, "relative_humidity": 50, "units": "ip"},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
result = structured["result"]
|
|
158
|
+
self.assertEqual(result["calculation"], "psychrometrics")
|
|
159
|
+
self.assertAlmostEqual(result["values"]["relative_humidity"], 50.0, places=5)
|
|
160
|
+
|
|
161
|
+
asyncio.run(run())
|
|
162
|
+
|
|
163
|
+
def test_typed_equipment_and_esp_tools_route_to_supported_entities(self):
|
|
164
|
+
async def run():
|
|
165
|
+
server = build_server()
|
|
166
|
+
fake_client = FakeClient()
|
|
167
|
+
with mock.patch(
|
|
168
|
+
"ventilatepro.mcp_server.auth.get_authenticated_client",
|
|
169
|
+
return_value=(fake_client, "http://localhost:8000"),
|
|
170
|
+
):
|
|
171
|
+
await server.call_tool(
|
|
172
|
+
"vp_create_equipment",
|
|
173
|
+
{
|
|
174
|
+
"equipment_type": "condensing-units",
|
|
175
|
+
"payload": {"project": 7, "name": "CU-1"},
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
await server.call_tool(
|
|
179
|
+
"vp_create_esp_calculation",
|
|
180
|
+
{
|
|
181
|
+
"payload": {
|
|
182
|
+
"project": 7,
|
|
183
|
+
"ahu": 12,
|
|
184
|
+
"fan_type": "SUPPLY",
|
|
185
|
+
"name": "Longest supply path",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
await server.call_tool(
|
|
190
|
+
"vp_create_esp_component",
|
|
191
|
+
{
|
|
192
|
+
"payload": {
|
|
193
|
+
"calculation": 91,
|
|
194
|
+
"component_type": "FILTER_MERV13",
|
|
195
|
+
"description": "Final filter",
|
|
196
|
+
"pressure_drop_iwc": 0.5,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
self.assertEqual(
|
|
202
|
+
[call[:2] for call in fake_client.calls],
|
|
203
|
+
[
|
|
204
|
+
("POST", "condensing-units/"),
|
|
205
|
+
("POST", "esp-calculations/"),
|
|
206
|
+
("POST", "esp-components/"),
|
|
207
|
+
],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
asyncio.run(run())
|
|
211
|
+
|
|
131
212
|
def test_record_meeting_tool_saves_without_confirmation(self):
|
|
132
213
|
async def run():
|
|
133
214
|
server = build_server()
|