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.
Files changed (41) hide show
  1. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/PKG-INFO +30 -2
  2. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/README.md +27 -1
  3. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/pyproject.toml +2 -0
  4. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/tests/test_cli.py +260 -1
  5. ventilatepro_cli-0.7.0/tests/test_hvac_calculations.py +220 -0
  6. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/tests/test_mcp_server.py +167 -0
  7. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/__init__.py +1 -1
  8. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/api_client.py +34 -0
  9. ventilatepro_cli-0.7.0/ventilatepro/calc.py +422 -0
  10. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/cli.py +190 -1
  11. ventilatepro_cli-0.7.0/ventilatepro/decisions.py +178 -0
  12. ventilatepro_cli-0.7.0/ventilatepro/entity_catalog.py +124 -0
  13. ventilatepro_cli-0.7.0/ventilatepro/equipment.py +49 -0
  14. ventilatepro_cli-0.7.0/ventilatepro/esp.py +77 -0
  15. ventilatepro_cli-0.7.0/ventilatepro/hvac_calculations.py +516 -0
  16. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/mcp_server.py +317 -43
  17. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/PKG-INFO +30 -2
  18. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/SOURCES.txt +6 -0
  19. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/requires.txt +2 -0
  20. ventilatepro_cli-0.5.1/ventilatepro/calc.py +0 -142
  21. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/setup.cfg +0 -0
  22. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/tests/test_config.py +0 -0
  23. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/__main__.py +0 -0
  24. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/auth.py +0 -0
  25. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/categorization.py +0 -0
  26. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/config.py +0 -0
  27. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/design_day.py +0 -0
  28. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/entities.py +0 -0
  29. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/mcp_cli.py +0 -0
  30. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/meetings.py +0 -0
  31. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/notes.py +0 -0
  32. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/output.py +0 -0
  33. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/payloads.py +0 -0
  34. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/projects.py +0 -0
  35. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/queue.py +0 -0
  36. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/revit_imports.py +0 -0
  37. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/systems.py +0 -0
  38. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro/tasks.py +0 -0
  39. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  40. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
  41. {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.5.1
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/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
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/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
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.
@@ -14,6 +14,8 @@ dependencies = [
14
14
  "keyring>=25.6.0",
15
15
  "platformdirs>=4.3.0",
16
16
  "mcp>=1.23.0,<2.0.0",
17
+ "PsychroLib==2.5.0",
18
+ "CoolProp==8.0.0",
17
19
  ]
18
20
 
19
21
  [project.urls]
@@ -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()