ventilatepro-cli 0.5.1__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.1 → ventilatepro_cli-0.7.0}/PKG-INFO +30 -2
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/README.md +27 -1
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/pyproject.toml +2 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/tests/test_cli.py +260 -1
- ventilatepro_cli-0.7.0/tests/test_hvac_calculations.py +220 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/tests/test_mcp_server.py +167 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/__init__.py +1 -1
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/api_client.py +34 -0
- ventilatepro_cli-0.7.0/ventilatepro/calc.py +422 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/cli.py +190 -1
- ventilatepro_cli-0.7.0/ventilatepro/decisions.py +178 -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.1 → ventilatepro_cli-0.7.0}/ventilatepro/mcp_server.py +317 -43
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/PKG-INFO +30 -2
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/SOURCES.txt +6 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/requires.txt +2 -0
- ventilatepro_cli-0.5.1/ventilatepro/calc.py +0 -142
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/setup.cfg +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/categorization.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/design_day.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/entities.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/mcp_cli.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/meetings.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/output.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/payloads.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/projects.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/queue.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/revit_imports.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/systems.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/tasks.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
- {ventilatepro_cli-0.5.1 → 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,12 +62,19 @@ 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
|
|
61
72
|
ventilatepro meetings context --project 1 --json
|
|
62
73
|
ventilatepro meetings record --project 1 --input meeting.json --json
|
|
63
74
|
ventilatepro meetings list --project 1
|
|
75
|
+
ventilatepro decisions create --project 1 --text "Use heat recovery on AHU-2" --reason "Energy model payback" --tags ahu,energy
|
|
76
|
+
ventilatepro decisions list --project 1
|
|
77
|
+
ventilatepro decisions revise 51 --text "Use heat recovery with bypass control"
|
|
64
78
|
ventilatepro tasks context --project 1 --json
|
|
65
79
|
ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
|
|
66
80
|
ventilatepro mcp doctor
|
|
@@ -71,6 +85,10 @@ ventilatepro-mcp
|
|
|
71
85
|
and other MCP clients. It uses the same saved base URL and CLI token created by
|
|
72
86
|
`ventilatepro auth login`.
|
|
73
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
|
+
|
|
74
92
|
For meeting capture, Codex first reads `meetings context` to resolve names to
|
|
75
93
|
stable project-member IDs, interprets the PM's prose into a structured JSON
|
|
76
94
|
payload, and calls `meetings record`. The server atomically creates the meeting,
|
|
@@ -81,6 +99,12 @@ stable project-member user ID, then calls `tasks create`. The same workflow is
|
|
|
81
99
|
available through the typed `vp_get_task_context` and
|
|
82
100
|
`vp_create_project_task` MCP tools.
|
|
83
101
|
|
|
102
|
+
Design decisions do not require meeting minutes. Codex can call `decisions
|
|
103
|
+
create` or `vp_create_decision` as soon as a PM communicates a decision, with
|
|
104
|
+
an optional meeting ID only when the relationship is useful. Listing, showing,
|
|
105
|
+
and revising decisions are available through first-class CLI and MCP surfaces;
|
|
106
|
+
revisions preserve the prior version as immutable history.
|
|
107
|
+
|
|
84
108
|
For room classification, Codex can read the project-specific categorization
|
|
85
109
|
review resource or use the dedicated MCP tools. Review manifests default every
|
|
86
110
|
room to rejected so ambiguous spaces remain visible until a category is
|
|
@@ -145,4 +169,8 @@ or pipe the token through stdin:
|
|
|
145
169
|
printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --token-stdin
|
|
146
170
|
```
|
|
147
171
|
|
|
148
|
-
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,12 +45,19 @@ 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
|
|
46
55
|
ventilatepro meetings context --project 1 --json
|
|
47
56
|
ventilatepro meetings record --project 1 --input meeting.json --json
|
|
48
57
|
ventilatepro meetings list --project 1
|
|
58
|
+
ventilatepro decisions create --project 1 --text "Use heat recovery on AHU-2" --reason "Energy model payback" --tags ahu,energy
|
|
59
|
+
ventilatepro decisions list --project 1
|
|
60
|
+
ventilatepro decisions revise 51 --text "Use heat recovery with bypass control"
|
|
49
61
|
ventilatepro tasks context --project 1 --json
|
|
50
62
|
ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
|
|
51
63
|
ventilatepro mcp doctor
|
|
@@ -56,6 +68,10 @@ ventilatepro-mcp
|
|
|
56
68
|
and other MCP clients. It uses the same saved base URL and CLI token created by
|
|
57
69
|
`ventilatepro auth login`.
|
|
58
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
|
+
|
|
59
75
|
For meeting capture, Codex first reads `meetings context` to resolve names to
|
|
60
76
|
stable project-member IDs, interprets the PM's prose into a structured JSON
|
|
61
77
|
payload, and calls `meetings record`. The server atomically creates the meeting,
|
|
@@ -66,6 +82,12 @@ stable project-member user ID, then calls `tasks create`. The same workflow is
|
|
|
66
82
|
available through the typed `vp_get_task_context` and
|
|
67
83
|
`vp_create_project_task` MCP tools.
|
|
68
84
|
|
|
85
|
+
Design decisions do not require meeting minutes. Codex can call `decisions
|
|
86
|
+
create` or `vp_create_decision` as soon as a PM communicates a decision, with
|
|
87
|
+
an optional meeting ID only when the relationship is useful. Listing, showing,
|
|
88
|
+
and revising decisions are available through first-class CLI and MCP surfaces;
|
|
89
|
+
revisions preserve the prior version as immutable history.
|
|
90
|
+
|
|
69
91
|
For room classification, Codex can read the project-specific categorization
|
|
70
92
|
review resource or use the dedicated MCP tools. Review manifests default every
|
|
71
93
|
room to rejected so ambiguous spaces remain visible until a category is
|
|
@@ -130,4 +152,8 @@ or pipe the token through stdin:
|
|
|
130
152
|
printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --token-stdin
|
|
131
153
|
```
|
|
132
154
|
|
|
133
|
-
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
|
|
@@ -440,6 +516,189 @@ class CliTests(unittest.TestCase):
|
|
|
440
516
|
self.assertFalse(sent_payload["include_in_minutes"])
|
|
441
517
|
self.assertEqual(sent_payload["source"], "cli")
|
|
442
518
|
|
|
519
|
+
def test_decisions_create_logs_standalone_decision_immediately(self):
|
|
520
|
+
fake_client = mock.Mock()
|
|
521
|
+
fake_client.base_url = "https://ventilatepro.com"
|
|
522
|
+
fake_client.create_decision.return_value = {
|
|
523
|
+
"project": 1,
|
|
524
|
+
"created": [{
|
|
525
|
+
"model": "notes.Decision",
|
|
526
|
+
"id": 51,
|
|
527
|
+
"data": {
|
|
528
|
+
"id": 51,
|
|
529
|
+
"project": 1,
|
|
530
|
+
"minute": None,
|
|
531
|
+
"text": "Use heat recovery on AHU-2.",
|
|
532
|
+
"reason": "Energy model payback.",
|
|
533
|
+
"impact": "Add a heat recovery wheel.",
|
|
534
|
+
"code_reference": "ASHRAE 90.1",
|
|
535
|
+
"tags": ["ahu", "energy"],
|
|
536
|
+
"version": 1,
|
|
537
|
+
"is_active": True,
|
|
538
|
+
},
|
|
539
|
+
}],
|
|
540
|
+
"audit_entry_id": 52,
|
|
541
|
+
"idempotent": False,
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
545
|
+
os.environ[config.DATA_ENV_VAR] = tmpdir
|
|
546
|
+
try:
|
|
547
|
+
with mock.patch.object(
|
|
548
|
+
auth,
|
|
549
|
+
"get_authenticated_client",
|
|
550
|
+
return_value=(fake_client, "https://ventilatepro.com"),
|
|
551
|
+
):
|
|
552
|
+
result = self.runner.invoke(
|
|
553
|
+
app,
|
|
554
|
+
[
|
|
555
|
+
"decisions",
|
|
556
|
+
"create",
|
|
557
|
+
"--project",
|
|
558
|
+
"1",
|
|
559
|
+
"--text",
|
|
560
|
+
"Use heat recovery on AHU-2.",
|
|
561
|
+
"--reason",
|
|
562
|
+
"Energy model payback.",
|
|
563
|
+
"--impact",
|
|
564
|
+
"Add a heat recovery wheel.",
|
|
565
|
+
"--code-reference",
|
|
566
|
+
"ASHRAE 90.1",
|
|
567
|
+
"--tags",
|
|
568
|
+
"ahu, energy, ahu",
|
|
569
|
+
"--idempotency-key",
|
|
570
|
+
"decision-51",
|
|
571
|
+
"--json",
|
|
572
|
+
],
|
|
573
|
+
)
|
|
574
|
+
finally:
|
|
575
|
+
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
576
|
+
|
|
577
|
+
self.assertEqual(result.exit_code, 0)
|
|
578
|
+
payload = json.loads(result.stdout)
|
|
579
|
+
self.assertEqual(payload["decision_ref"], "decision:51")
|
|
580
|
+
self.assertIn("view=decision-log", payload["deep_link"])
|
|
581
|
+
sent = fake_client.create_decision.call_args.args[0]
|
|
582
|
+
self.assertEqual(sent["project_id"], 1)
|
|
583
|
+
self.assertEqual(sent["idempotency_key"], "decision-51")
|
|
584
|
+
decision_data = sent["writes"][0]["data"]
|
|
585
|
+
self.assertNotIn("minute", decision_data)
|
|
586
|
+
self.assertEqual(decision_data["references"], ["ahu", "energy"])
|
|
587
|
+
self.assertEqual(decision_data["source"], "cli")
|
|
588
|
+
|
|
589
|
+
def test_decisions_list_and_revise_use_first_class_commands(self):
|
|
590
|
+
fake_client = mock.Mock()
|
|
591
|
+
fake_client.list_decisions.return_value = [{
|
|
592
|
+
"id": 51,
|
|
593
|
+
"text": "Use heat recovery on AHU-2.",
|
|
594
|
+
"version": 1,
|
|
595
|
+
"is_active": True,
|
|
596
|
+
}]
|
|
597
|
+
fake_client.revise_decision.return_value = {
|
|
598
|
+
"id": 54,
|
|
599
|
+
"text": "Use heat recovery on AHU-2 with bypass.",
|
|
600
|
+
"version": 2,
|
|
601
|
+
"supersedes": 51,
|
|
602
|
+
}
|
|
603
|
+
with mock.patch.object(
|
|
604
|
+
auth,
|
|
605
|
+
"get_authenticated_client",
|
|
606
|
+
return_value=(fake_client, "https://ventilatepro.com"),
|
|
607
|
+
):
|
|
608
|
+
list_result = self.runner.invoke(
|
|
609
|
+
app,
|
|
610
|
+
["decisions", "list", "--project", "1", "--json"],
|
|
611
|
+
)
|
|
612
|
+
revise_result = self.runner.invoke(
|
|
613
|
+
app,
|
|
614
|
+
[
|
|
615
|
+
"decisions",
|
|
616
|
+
"revise",
|
|
617
|
+
"51",
|
|
618
|
+
"--text",
|
|
619
|
+
"Use heat recovery on AHU-2 with bypass.",
|
|
620
|
+
"--tags",
|
|
621
|
+
"ahu,energy",
|
|
622
|
+
"--json",
|
|
623
|
+
],
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
self.assertEqual(list_result.exit_code, 0)
|
|
627
|
+
self.assertEqual(json.loads(list_result.stdout)["results"][0]["id"], 51)
|
|
628
|
+
fake_client.list_decisions.assert_called_once_with(
|
|
629
|
+
1,
|
|
630
|
+
search=None,
|
|
631
|
+
include_history=False,
|
|
632
|
+
)
|
|
633
|
+
self.assertEqual(revise_result.exit_code, 0)
|
|
634
|
+
fake_client.revise_decision.assert_called_once_with(
|
|
635
|
+
51,
|
|
636
|
+
{
|
|
637
|
+
"text": "Use heat recovery on AHU-2 with bypass.",
|
|
638
|
+
"tags": ["ahu", "energy"],
|
|
639
|
+
},
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
def test_decisions_create_queues_offline_and_syncs_with_same_key(self):
|
|
643
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
644
|
+
os.environ[config.DATA_ENV_VAR] = tmpdir
|
|
645
|
+
try:
|
|
646
|
+
offline_client = mock.Mock()
|
|
647
|
+
offline_client.create_decision.side_effect = NetworkError("offline")
|
|
648
|
+
with mock.patch.object(
|
|
649
|
+
auth,
|
|
650
|
+
"get_authenticated_client",
|
|
651
|
+
return_value=(offline_client, "https://ventilatepro.com"),
|
|
652
|
+
):
|
|
653
|
+
queued_result = self.runner.invoke(
|
|
654
|
+
app,
|
|
655
|
+
[
|
|
656
|
+
"decisions",
|
|
657
|
+
"create",
|
|
658
|
+
"--project",
|
|
659
|
+
"1",
|
|
660
|
+
"--text",
|
|
661
|
+
"Use heat recovery.",
|
|
662
|
+
"--idempotency-key",
|
|
663
|
+
"offline-decision-1",
|
|
664
|
+
"--json",
|
|
665
|
+
],
|
|
666
|
+
)
|
|
667
|
+
self.assertEqual(queued_result.exit_code, 0)
|
|
668
|
+
queued_payload = json.loads(queued_result.stdout)
|
|
669
|
+
self.assertEqual(queued_payload["status"], "queued")
|
|
670
|
+
|
|
671
|
+
online_client = mock.Mock()
|
|
672
|
+
online_client.base_url = "https://ventilatepro.com"
|
|
673
|
+
online_client.create_decision.return_value = {
|
|
674
|
+
"created": [{
|
|
675
|
+
"model": "notes.Decision",
|
|
676
|
+
"id": 61,
|
|
677
|
+
"data": {"id": 61, "text": "Use heat recovery."},
|
|
678
|
+
}],
|
|
679
|
+
"audit_entry_id": 62,
|
|
680
|
+
"idempotent": False,
|
|
681
|
+
}
|
|
682
|
+
with mock.patch.object(
|
|
683
|
+
auth,
|
|
684
|
+
"get_authenticated_client",
|
|
685
|
+
return_value=(online_client, "https://ventilatepro.com"),
|
|
686
|
+
):
|
|
687
|
+
sync_result = self.runner.invoke(
|
|
688
|
+
app,
|
|
689
|
+
["decisions", "sync", "--project", "1", "--json"],
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
self.assertEqual(sync_result.exit_code, 0)
|
|
693
|
+
self.assertEqual(json.loads(sync_result.stdout)["synced"], 1)
|
|
694
|
+
sent = online_client.create_decision.call_args.args[0]
|
|
695
|
+
self.assertEqual(sent["idempotency_key"], "offline-decision-1")
|
|
696
|
+
operation = QueueStore().get_operation(queued_payload["local_id"])
|
|
697
|
+
self.assertEqual(operation.sync_state, "synced")
|
|
698
|
+
self.assertEqual(operation.server_ref, "decision:61")
|
|
699
|
+
finally:
|
|
700
|
+
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
701
|
+
|
|
443
702
|
def test_meetings_record_reads_structured_stdin_and_saves_immediately(self):
|
|
444
703
|
fake_client = mock.Mock()
|
|
445
704
|
fake_client.record_meeting.return_value = {
|
|
@@ -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()
|